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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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 —
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -507,6 +507,17 @@
|
||||
<span class="badge badge-error">Nyitott — a kód e-mail NEM ment ki (resend!)</span>
|
||||
{{end}}
|
||||
<span class="form-hint" style="margin-left: 0.5rem;">generation {{.Claim.Generation}} · issued {{timeAgo .Claim.IssuedAt}}</span>
|
||||
<div id="box-claim">
|
||||
{{if .ClaimMismatch}}
|
||||
<span class="badge badge-warn" id="box-claim-mismatch">The box says NOT claimed — it shows its first-run setup page</span>
|
||||
<span class="form-hint">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.</span>
|
||||
{{else if .BoxClaimKnown}}
|
||||
<span class="badge badge-neutral" id="box-claim-state">Box: {{if .BoxClaimedTrue}}claimed{{else}}not claimed yet{{end}}</span>
|
||||
<span class="form-hint">box report {{timeAgo .BoxClaimedAt}}</span>
|
||||
{{else}}
|
||||
<span class="form-hint" id="box-claim-unknown">Box claim state unknown (no report yet, or a controller too old to send it)</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<span class="badge badge-neutral">no code issued yet</span>
|
||||
<span class="form-hint" style="margin-left: 0.5rem;">issued automatically at the first config pull or report</span>
|
||||
|
||||
Reference in New Issue
Block a user