d604e624a3
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
238 lines
8.8 KiB
Go
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)
|
|
}
|
|
}
|