Files
felhom-controller/controller/internal/web/r546_escrow_readiness_test.go
T
admin 0fe315b759
gates / gates (push) Successful in 15s
v0.246.0: an interrupted restore is told; the recovery-code reminder waits until the box can take it
MinAgent: 0.131.0 (unchanged). Requires hub v0.117.0 for restore_interrupted.

R-550 (operator ruling: fix). A design reversed and recorded: the restore
op-status was in memory by choice. Now restore-status.json in DataDir, written
atomically at both ends of an op. At startup a record still marked running
becomes a failed, interrupted result kept per app until that app's next
restore, shown on /backups/restore and the off-site wizard, and raised once as
restore_interrupted. Cooldowns stay in memory.

R-546. The R-543 reminder bar consults the agent's own preflight ok (every
blocking item, not a copy of pbs_storage_id), cached 60 s, probed only while
paused. /backup/escrow shows a waiting card that polls and reloads instead of
red crosses and English diagnostics. POST /api/escrow/start refuses 409 before
staging or starting - the direct path chaos night used. Unknown readiness keeps
the bar.

Red-proofs (each seen failing): restore record across restart; main() calls
both startup functions; startup helper with loading skipped; restore page card;
bar held back; waiting card; start refusal. go build/vet/test ./... green, 28
packages; controller_gates --fast all OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 10:45:29 +02:00

114 lines
4.8 KiB
Go

package web
import (
"errors"
"net/http"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// ── R-546 — do not send the household to a ceremony the box cannot run yet ────────────────────────
//
// Measured 2026-09-16/17 (chaos night Phase 0): for ~17 minutes after the bind the box has no PBS
// storage, the agent's preflight is red, and the ceremony refuses. Meanwhile the R-543 bar is on every
// page urging the household there. Readiness is the AGENT'S OWN `ok` — every blocking preflight item
// (pbs_storage_id, dr_tier, age_binary, hub_upload, sudo_grant), never a copy of one of them.
const escrowNotReadyMarker = `id="escrow-not-ready"`
func readinessAgent(ok bool) *fakeEscrowAgent {
order := []string{}
pf := agentapi.EscrowPreflightResponse{OK: ok}
if !ok {
pf.Items = []agentapi.EscrowPreflightItem{{ID: "pbs_storage_id", OK: false, Detail: "escrow.pbs_storage_id not configured"}}
}
return &fakeEscrowAgent{order: &order, version: "0.132.0", pf: pf}
}
// RED-PROOF: remove the readiness check from escrowBannerVisible → the bar renders on a box that
// cannot run the ceremony → "the bar urges a ceremony the box cannot run yet".
func TestR546_NotReadyBoxHoldsTheBar(t *testing.T) {
s := escrowServer(t, "pending")
a := readinessAgent(false)
s.escrowAgentFn = func() (escrowAgent, error) { return a, nil }
body := getPage(t, s, "/dashboard").Body.String()
if strings.Contains(body, escrowBarSentence) {
t.Fatal("R-546: the bar urges a ceremony the box cannot run yet (agent preflight is red)")
}
}
// Control: once the agent says ready, the R-543 bar is back.
func TestR546_ReadyBoxShowsTheBar(t *testing.T) {
s := escrowServer(t, "pending")
a := readinessAgent(true)
s.escrowAgentFn = func() (escrowAgent, error) { return a, nil }
if body := getPage(t, s, "/dashboard").Body.String(); !strings.Contains(body, escrowBarSentence) {
t.Fatal("a READY, paused box no longer shows the R-543 bar")
}
}
// Fail loud: readiness UNKNOWN (agent unreachable) keeps the reminder — silence is not a safe default.
func TestR546_UnknownReadinessStillAsks(t *testing.T) {
s := escrowServer(t, "pending")
s.escrowAgentFn = func() (escrowAgent, error) { return nil, errors.New("agent unreachable") }
if body := getPage(t, s, "/dashboard").Body.String(); !strings.Contains(body, escrowBarSentence) {
t.Fatal("an unknown readiness hid the reminder — an unreachable agent must not silence R-543")
}
}
// The page itself: a calm waiting card, no start form, and none of the agent's raw diagnostics.
// RED-PROOF: never set EscrowNotReady → the checklist + start form render → "start form offered".
func TestR546_EscrowPageWaitsWhenNotReady(t *testing.T) {
s := escrowServer(t, "pending")
a := readinessAgent(false)
s.escrowAgentFn = func() (escrowAgent, error) { return a, nil }
rec := getPage(t, s, "/backup/escrow")
if rec.Code != 200 {
t.Fatalf("GET /backup/escrow = %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, escrowNotReadyMarker) || !strings.Contains(body, "A doboz m") {
t.Fatal("R-546: the escrow page does not tell the household the box is still preparing")
}
if strings.Contains(body, `id="start-form"`) {
t.Fatal("R-546: the start form is offered on a box whose ceremony would refuse")
}
// The exact texts chaos night met: the preflight detail, and the ceremony stderr ("…escrow-create
// requires -storage …"). NOT a bare "-storage": the menu carries id="nav-group-storage".
for _, raw := range []string{"pbs_storage_id not configured", "requires -storage"} {
if strings.Contains(body, raw) {
t.Fatalf("R-546: the agent's raw diagnostic %q reached the household's page", raw)
}
}
if strings.Contains(body, "zzzz-not-present") {
t.Fatal("negative control matched")
}
}
// The direct path — the one that actually produced the stderr in chaos night: POST /api/escrow/start
// on a not-ready box is refused BEFORE anything is staged or started, in Hungarian.
// RED-PROOF: remove the readiness gate from escrowStartAPIHandler → 200 and "stage,start" run.
func TestR546_StartRefusedWhenNotReady(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "pending")
h.agent.pf = agentapi.EscrowPreflightResponse{OK: false,
Items: []agentapi.EscrowPreflightItem{{ID: "pbs_storage_id", OK: false}}}
w := postStart(t, h.s, wizardPassword)
if w.Code != http.StatusConflict {
t.Fatalf("start on a not-ready box = %d (%s), want 409", w.Code, w.Body.String())
}
for _, op := range h.order {
if op == "stage" || op == "start" {
t.Fatalf("a not-ready box still ran %q (order %v) — the refusal must come first", op, h.order)
}
}
if !strings.Contains(w.Body.String(), "A doboz m") {
t.Fatalf("the refusal is not the Hungarian waiting sentence: %s", w.Body.String())
}
}