From daeed175ca3911ed132510c28d3b629f49367abb Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 5 Oct 2026 21:28:20 +0200 Subject: [PATCH] R-283: customer page shows the box's own claim state beside the hub's, and warns on a mismatch After a guest rebuild the hub (set-only claim, by design) said "Claimed" while the box showed its first-run setup page. The latest report's `claimed` field is now shown; hub-claimed + box-not-claimed renders an amber warning. No claim state is changed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- hub/internal/store/store.go | 24 +++++++ hub/internal/web/configs.go | 12 ++++ hub/internal/web/r283_box_claim_test.go | 70 +++++++++++++++++++ .../web/templates/customer_unified.html | 11 +++ 4 files changed, 117 insertions(+) create mode 100644 hub/internal/web/r283_box_claim_test.go diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 8ab9cb9c..af3d4beb 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -1260,6 +1260,30 @@ func (s *Store) LatestReportOffsitePresence(customerID string) (found bool, rece return true, parseSQLiteTime(recv), reportHasOffsite(reportJSON), nil } +// LatestReportClaimed returns what the customer's most recent controller report says about the BOX'S +// claim state (R-283): claimed=nil when no report exists, the report is unparseable, or the controller is +// too old to send the field. The hub's own claim row is set-only on purpose (a wiped settings.json must +// never un-claim — handler.go), so after a guest rebuild the two sides disagree; this is the box's side, +// shown beside the hub's on the customer page. It changes no claim state. +func (s *Store) LatestReportClaimed(customerID string) (claimed *bool, receivedAt time.Time, err error) { + var recv, reportJSON string + err = s.db.QueryRow(`SELECT received_at, report_json FROM reports WHERE customer_id = ? ORDER BY id DESC LIMIT 1`, + customerID).Scan(&recv, &reportJSON) + if err == sql.ErrNoRows { + return nil, time.Time{}, nil + } + if err != nil { + return nil, time.Time{}, err + } + var p struct { + Claimed *bool `json:"claimed"` + } + if json.Unmarshal([]byte(reportJSON), &p) != nil { + return nil, parseSQLiteTime(recv), nil + } + return p.Claimed, parseSQLiteTime(recv), nil +} + // CountReportsOffsiteSince counts the customer's controller reports received strictly after `since` // (UTC) and how many of them carry an offsite status object (R-70 detector input: "N consecutive // reports since consume without offbox" == total>0 && withOffsite==0). Capped at 500 rows per call — diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index 40bb2af8..3238af11 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -360,6 +360,13 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c // Claim (v0.50.0, customer-claim arc): the dashboard claim state for the Setup-tab card — // nil when no code has been issued yet (pre-arc / never-pulled customer). Claim *store.ClaimState + // BoxClaimKnown/BoxClaimedTrue (R-283) are the box's own claim state from its latest report (unknown: + // no report or an old controller); BoxClaimedAt is that report's time. ClaimMismatch is set when the + // hub says claimed and the box says not — the shape a guest rebuild leaves behind. + BoxClaimKnown bool + BoxClaimedTrue bool + BoxClaimedAt time.Time + ClaimMismatch bool // SelfBindSentAt / SelfBindSentOccasion (R-509, v0.114.0): when and why the connect link last // went out (auto on creation / RESET / e-mail set / host delete, or the button). Empty = never. @@ -532,6 +539,11 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c if cs, err := s.store.GetClaim(customerID); err == nil { data.Claim = cs } + if bc, at, err := s.store.LatestReportClaimed(customerID); err == nil { + data.BoxClaimKnown, data.BoxClaimedAt = bc != nil, at + data.BoxClaimedTrue = bc != nil && *bc + data.ClaimMismatch = bc != nil && !*bc && data.Claim != nil && data.Claim.ClaimedAt != nil + } if ev, err := s.store.GetLatestEventByType(customerID, selfBindSentEvent); err == nil && ev != nil { data.SelfBindSentAt = ev.CreatedAt.UTC().Format("2006-01-02 15:04 UTC") var d struct { diff --git a/hub/internal/web/r283_box_claim_test.go b/hub/internal/web/r283_box_claim_test.go new file mode 100644 index 00000000..3734dd11 --- /dev/null +++ b/hub/internal/web/r283_box_claim_test.go @@ -0,0 +1,70 @@ +package web + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// R-283: after a guest rebuild the hub's claim (set-only by design) says "Claimed" while the box serves +// its first-run setup page. The customer page now shows the BOX's side too, from its latest report, and +// warns on the mismatch. One render per branch of the template gate (seam rule): unknown, box claimed, +// box not claimed with an unclaimed hub, and the mismatch. +// RED-PROOF: drop the ClaimMismatch assignment in configs.go → the mismatch branch assertion fails. +func TestR283_CustomerPageShowsBoxClaimState(t *testing.T) { + s, st := newTestServer(t) + e, _ := withUIClaim(t, s, st) + cc := &store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme", Domain: "acme.hu", + RetrievalPassword: "pw", APIKey: "k", Status: "active", Email: "owner@acme.hu"} + if err := st.SaveCustomerConfig(cc); err != nil { + t.Fatal(err) + } + if _, err := e.EnsureIssued(cc); err != nil { + t.Fatal(err) + } + + // No report at all → unknown. + html := renderCustomerPage(t, s, "acme") + if !strings.Contains(html, `id="box-claim-unknown"`) || strings.Contains(html, `id="box-claim-mismatch"`) { + t.Fatal("no report: want the unknown line and no mismatch") + } + // An old controller that does not send the field → still unknown. + if err := st.SaveReport("acme", []byte(`{"customer_id":"acme"}`)); err != nil { + t.Fatal(err) + } + if html = renderCustomerPage(t, s, "acme"); !strings.Contains(html, `id="box-claim-unknown"`) { + t.Fatal("a report without the claimed field must read unknown, never not-claimed") + } + // Box not claimed, hub not claimed → neutral, no mismatch. + if err := st.SaveReport("acme", []byte(`{"customer_id":"acme","claimed":false}`)); err != nil { + t.Fatal(err) + } + html = renderCustomerPage(t, s, "acme") + if !strings.Contains(html, "Box: not claimed yet") || strings.Contains(html, `id="box-claim-mismatch"`) { + t.Fatal("unclaimed on both sides: want the neutral box line, no mismatch") + } + // Box claims → hub marked claimed; both say claimed. + if err := e.MarkClaimed(cc); err != nil { + t.Fatal(err) + } + if err := st.SaveReport("acme", []byte(`{"customer_id":"acme","claimed":true}`)); err != nil { + t.Fatal(err) + } + html = renderCustomerPage(t, s, "acme") + if !strings.Contains(html, "Box: claimed") || strings.Contains(html, `id="box-claim-mismatch"`) { + t.Fatal("claimed on both sides: want 'Box: claimed', no mismatch") + } + // The guest is rebuilt: the box reports claimed=false while the hub keeps its claim → mismatch. + if err := st.SaveReport("acme", []byte(`{"customer_id":"acme","claimed":false}`)); err != nil { + t.Fatal(err) + } + html = renderCustomerPage(t, s, "acme") + if !strings.Contains(html, `id="box-claim-mismatch"`) || !strings.Contains(html, ">Claimed ") { + t.Fatal("after a rebuild: want the hub's Claimed chip AND the box mismatch warning") + } + // Showing it changes nothing: the hub's claim is still set (set-only, by design). + if cs, _ := st.GetClaim("acme"); cs == nil || cs.ClaimedAt == nil { + t.Fatal("rendering the page un-claimed the hub") + } +} diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index df526b77..91321aba 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -507,6 +507,17 @@ Nyitott — a kód e-mail NEM ment ki (resend!) {{end}} generation {{.Claim.Generation}} · issued {{timeAgo .Claim.IssuedAt}} +
+ {{if .ClaimMismatch}} + The box says NOT claimed — it shows its first-run setup page + box report {{timeAgo .BoxClaimedAt}}. The hub's claim survives a guest rebuild, the box's does not: an earlier code fails on the box, and a resend sends a reset-type code. + {{else if .BoxClaimKnown}} + Box: {{if .BoxClaimedTrue}}claimed{{else}}not claimed yet{{end}} + box report {{timeAgo .BoxClaimedAt}} + {{else}} + Box claim state unknown (no report yet, or a controller too old to send it) + {{end}} +
{{else}} no code issued yet issued automatically at the first config pull or report