Files
felhom.eu/hub/internal/web/r30_presence_delete_test.go
T
admin d604e624a3 hub: box presence from the wait channel + delete a switched-off box at once (R-30, D2)
Slice 1: intent.Hub records one start/end per GET /api/v1/wait request and answers
Presence(customer, now): connected (hold open, or one started < 333 s = 243 s cadence + 90 s
grace ago), not connected since T, or unknown (hub up < 333 s; in memory only). The host page
shows "Box connection". One DEBUG line per presence change.

Slice 2 (operator ruling D2, 2026-10-08, 09 §3 decision 186): a host that is online by its report
clock but whose box has had no wait-channel connection for >= 360 s may be deleted at once after
the tick "I checked: the box is off". Presence is re-read at POST time; the tick alone, unknown
presence, a connected box or a shorter gap keep today's 409. The delete logs the operator channel
and saves one host_deleted_box_off event. RESET and the customer-delete cascade are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 15:17:05 +02:00

238 lines
8.8 KiB
Go

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, `<input type="checkbox" id="host-delete-off-h1">`) && 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)
}
}