0fe315b759
gates / gates (push) Successful in 15s
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
114 lines
4.8 KiB
Go
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())
|
|
}
|
|
}
|