package web import ( "log" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/intent" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // R-30 (operator ruling D2, 2026-10-08, `09` ยง3 decision 186): "delete host" goes ahead at once // after the operator's tick "I checked: the box is off" ONLY when the host is online by its report // clock AND the hub has had no wait-channel connection from its box for hostOffDeleteAfter. Every // other combination keeps today's refusal. type r30Presence int const ( r30Unknown r30Presence = iota // hub "restarted" just now r30Connected // a hold is open r30OffLong // last hold ended 11 min ago r30OffShort // last hold ended 5 min ago (started 8 min ago) r30NeverSeenLong // hub up 20 min, box never waited ) // r30Server: a server with an ONLINE-by-report host "h1" of customer "c1", and an intent hub whose // presence for c1 is set up as asked. Real wall clock for "now" (the handler reads time.Now()). func r30Server(t *testing.T, p r30Presence) (*Server, *store.Store, *strings.Builder) { t.Helper() s, st := newTestServer(t) var logBuf strings.Builder s.logger = log.New(&logBuf, "", 0) if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil { t.Fatal(err) } if err := st.SaveHostReport("h1", "c1", []byte(`{}`), store.HostReportDenorm{}); err != nil { t.Fatal(err) } now := time.Now() hub := intent.New() var clock time.Time hub.SetClock(func() time.Time { return clock }) set := func(d time.Duration) { clock = now.Add(d) } switch p { case r30Unknown: set(0) hub.SetClock(func() time.Time { return clock }) // born now case r30Connected: set(-time.Hour) hub.SetClock(func() time.Time { return clock }) set(-time.Minute) hub.MarkWaitStart("c1") case r30OffLong: set(-time.Hour) hub.SetClock(func() time.Time { return clock }) set(-15 * time.Minute) hub.MarkWaitStart("c1") set(-11 * time.Minute) hub.MarkWaitEnd("c1") case r30OffShort: set(-time.Hour) hub.SetClock(func() time.Time { return clock }) set(-8 * time.Minute) hub.MarkWaitStart("c1") set(-5 * time.Minute) hub.MarkWaitEnd("c1") case r30NeverSeenLong: set(-20 * time.Minute) hub.SetClock(func() time.Time { return clock }) } s.SetIntentHub(hub) if s.hostStatus(mustHost(t, st, "h1").LastReportAt) != "ok" { t.Fatal("fixture: host must read online by its report clock") } return s, st, &logBuf } func mustHost(t *testing.T, st *store.Store, id string) *store.Host { t.Helper() h, err := st.GetHost(id) if err != nil || h == nil { t.Fatalf("GetHost %s: %v", id, err) } return h } func r30Form(tick bool) url.Values { f := url.Values{"confirm_host_id": {"h1"}, "delete_escrow": {"1"}} if tick { f.Set("box_off_confirmed", "1") } return f } // Online by report + no box connection for 11 min + the tick โ†’ deleted at once, logged with who, and // ONE operator event saved. func TestR30_OffLongWithTickDeletes(t *testing.T) { for _, p := range []r30Presence{r30OffLong, r30NeverSeenLong} { s, st, logBuf := r30Server(t, p) rr := postHostDelete(t, s, "h1", r30Form(true)) if rr.Code != http.StatusSeeOther { t.Fatalf("presence %d: ticked delete = %d, want 303: %s", p, rr.Code, rr.Body.String()) } if h, _ := st.GetHost("h1"); h != nil { t.Fatalf("presence %d: host still present after the box-off delete", p) } if !strings.Contains(logBuf.String(), `deleted while online by its report clock: operator`) || !strings.Contains(logBuf.String(), `ticked "I checked: the box is off"`) { t.Errorf("presence %d: no audit line naming the tick:\n%s", p, logBuf.String()) } evs, err := st.GetEventsByType("c1", hostDeletedBoxOffEvent, time.Now().Add(-time.Hour)) if err != nil { t.Fatal(err) } if n := len(evs); n != 1 { t.Errorf("presence %d: %d %s events, want exactly 1", p, n, hostDeletedBoxOffEvent) } } } // Every other combination is refused as today: 409, host still there, no event. func TestR30_RefusedCombinations(t *testing.T) { cases := []struct { name string p r30Presence tick bool }{ {"off long, no tick", r30OffLong, false}, {"tick, box connected", r30Connected, true}, {"tick, presence unknown (hub restarted)", r30Unknown, true}, {"tick, not connected for less than the limit", r30OffShort, true}, } for _, c := range cases { s, st, _ := r30Server(t, c.p) rr := postHostDelete(t, s, "h1", r30Form(c.tick)) if rr.Code != http.StatusConflict { t.Errorf("%s: = %d, want 409", c.name, rr.Code) } if h, _ := st.GetHost("h1"); h == nil { t.Errorf("%s: host DELETED despite the refusal", c.name) } if evs, _ := st.GetEventsByType("c1", hostDeletedBoxOffEvent, time.Now().Add(-time.Hour)); len(evs) != 0 { t.Errorf("%s: box-off event saved on a refusal", c.name) } } } // No intent hub wired at all โ†’ the tick changes nothing (unknown presence). func TestR30_NoIntentHubTickRefused(t *testing.T) { s, st := newTestServer(t) _ = st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}) _ = st.SaveHostReport("h1", "c1", []byte(`{}`), store.HostReportDenorm{}) if rr := postHostDelete(t, s, "h1", r30Form(true)); rr.Code != http.StatusConflict { t.Fatalf("no intent hub, ticked = %d, want 409", rr.Code) } } // The fast-delete limit must stay at or above the presence window: a hub younger than the window // answers unknown, and a span shorter than one missed reconnect is not evidence of a dead box. func TestR30_OffDeleteLimitAboveWindow(t *testing.T) { if hostOffDeleteAfter < intent.PresenceConnectedWindow { t.Fatalf("hostOffDeleteAfter %s < presence window %s", hostOffDeleteAfter, intent.PresenceConnectedWindow) } } func r30Render(t *testing.T, s *Server) string { t.Helper() rr := httptest.NewRecorder() s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/h1", nil), "h1") if rr.Code != http.StatusOK { t.Fatalf("host detail = %d", rr.Code) } return rr.Body.String() } // Render test per branch of both template gates (the seam-built-but-never-wired trap): the Box // connection line shows its state, and the danger zone + the tick box appear ONLY on the box-off branch. func TestR30_HostPageRendersEachBranch(t *testing.T) { cases := []struct { p r30Presence state string text string tickShow bool }{ {r30Connected, "connected", "connected now", false}, {r30Unknown, "unknown", "unknown (the hub restarted", false}, {r30OffShort, "not_connected", "last connected", false}, {r30OffLong, "not_connected", "last connected", true}, {r30NeverSeenLong, "not_connected", "not connected since the hub started", true}, } for _, c := range cases { s, _, _ := r30Server(t, c.p) body := r30Render(t, s) if !strings.Contains(body, `data-box-connection="`+c.state+`"`) || !strings.Contains(body, c.text) { t.Errorf("presence %d: box connection line missing state %q / text %q", c.p, c.state, c.text) } hasTick := strings.Contains(body, ``) && strings.Contains(body, "I checked: the box is off") hasZone := strings.Contains(body, "Danger zone") hasHidden := strings.Contains(body, `name="box_off_confirmed"`) if hasTick != c.tickShow || hasZone != c.tickShow || hasHidden != c.tickShow { t.Errorf("presence %d: tick=%t zone=%t hidden=%t, want all %t", c.p, hasTick, hasZone, hasHidden, c.tickShow) } } } // A STALE host (deletable by the report clock) shows the danger zone WITHOUT the tick: the tick is // only for the online-by-report case. func TestR30_StaleHostNoTick(t *testing.T) { s, st := newTestServer(t) _ = st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}) body := r30Render(t, s) // never reported โ†’ "pending", deletable if !strings.Contains(body, "Danger zone") || strings.Contains(body, `id="host-delete-off-h1"`) || strings.Contains(body, `name="box_off_confirmed"`) { t.Fatalf("pending host: want danger zone without the tick") } } // The confirm dialog's impact probe agrees with the page: deletable, with the tick required. func TestR30_ImpactJSONOffTick(t *testing.T) { s, _, _ := r30Server(t, r30OffLong) rr := httptest.NewRecorder() s.handleHostDeleteImpact(rr, httptest.NewRequest(http.MethodGet, "/hosts/h1/delete-impact", nil), "h1") body := rr.Body.String() if !strings.Contains(body, `"deletable":true`) || !strings.Contains(body, `"off_tick_required":true`) { t.Fatalf("impact JSON = %s, want deletable + off_tick_required", body) } s2, _, _ := r30Server(t, r30Connected) rr = httptest.NewRecorder() s2.handleHostDeleteImpact(rr, httptest.NewRequest(http.MethodGet, "/hosts/h1/delete-impact", nil), "h1") if b := rr.Body.String(); !strings.Contains(b, `"deletable":false`) || !strings.Contains(b, `"off_tick_required":false`) { t.Fatalf("connected impact JSON = %s, want not deletable", b) } }