c7446f2d6a
R-225 — an unread store said '0 pillanatkép / 0 / 50 GB' above a card stating it held backups under another key. An SFTP listing found snapshot f3d9cd67 and 12 535 KB really there; snapshot_count and repo_size_bytes were simply ABSENT and the zero value spoke for them. StatsKnown is now NAMED, for the same reason OffsiteInventory.Empty is: zero is what an unread store and an empty one both look like, and on the wire 'absent' and '0' are the same bytes. The fill bar renders only when the fill is known — a 0%-wide bar is a picture of emptiness, and a picture is a claim. A measured zero still says zero. R-227 — WHICH LAYER ANSWERS: traefik, and this repo generates its config. But traefik v3 serves no static files, so a branded proxy page needs a new always-up container for every 502 on the box — out of proportion, and scoped in the report rather than built. Shipped instead: the unlock posts via fetch and answers a gateway failure in Hungarian without leaving the page. Progressive enhancement — with no JS the plain POST is unchanged and still shows the proxy's error, which the report says plainly rather than implying otherwise. R-228 — the set-aside history was recorded in orphaned_renamed_to and read by nobody: a census found zero references in any template or handler, while 12 535 KB sat at that path. It is surfaced as two facts and stops. It does NOT promise the history can be reopened, because it cannot be by anyone today (R-199's inventory is unbuilt) — and the set-aside CONFIRMATION copy was corrected for the same reason: 'a helyreállítási kód nélkül többé nem lesznek megnyithatók' implied that WITH the code they could be. The field's own comment called it 'recovery-code-recoverable', which was the same over-promise in the code. Tests: scenarios F, G, H as render tests per branch of each gate. Red-proofs, each demonstrated failing then restored: remove the StatsKnown guards (F, 'R-225 RETURNED: an unread store reports a snapshot COUNT of zero'), delete the set-aside block (H). The F assertion on the fill bar is scoped to the bar's own container — a bare width:0% search matched unrelated elements and would have passed for the wrong reason. 28 packages ok, vet clean, all controller gates OK (the emoji gate caught a warning sign in a template comment).
280 lines
14 KiB
Go
280 lines
14 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
|
)
|
|
|
|
// ── R-224 / R-226 — WHY IT FAILED DECIDES WHAT WE SAY ────────────────────────────────────────────
|
|
//
|
|
// CAMPAIGN-11 measured the defect these tests pin. With a CORRECT, CURRENT recovery code:
|
|
//
|
|
// hub firewalled off → "this code does not open your package" in 0.0556 s
|
|
// agent stopped → the same sentence in 0.0299 s
|
|
// genuinely wrong code → the same sentence after 1.194 / 1.004 / 1.014 s
|
|
//
|
|
// A real unseal costs ~1 s of scrypt, so the first two had not attempted one. Three different causes,
|
|
// one accusation, and the failure path never inspected the error.
|
|
//
|
|
// Every test here asserts the EFFECT — which sentence the customer is shown — at the HANDLER, because
|
|
// a helper-level test cannot observe a mutation that lives in the handler.
|
|
|
|
// namesTyping / isBareAccusation split what the old single predicate conflated. R-226 requires the
|
|
// typing HINT on a re-escrowed box; what stays forbidden is the bare accusation that says only that.
|
|
func namesTyping(body string) bool { return strings.Contains(body, "z szót pontosan") }
|
|
func isBareAccusation(body string) bool { return strings.Contains(body, "nem fogadtuk el") }
|
|
|
|
// saysCodeWasNotUsed is the load-bearing half of every could-not-ask message: the customer must be
|
|
// told their code was not spent, because that is what makes "keep it safe and try again" honest.
|
|
func saysCodeWasNotUsed(body string) bool { return strings.Contains(body, "NEM használtuk fel") }
|
|
|
|
// refusal builds the error shape agent >= v0.126.0 returns for a given status.
|
|
func refusal(status int, reason string) error {
|
|
return &agentapi.RecoveryRefusal{Status: status, Reason: reason}
|
|
}
|
|
|
|
// ── SCENARIO A — the hub is unreachable and the customer is not blamed ───────────────────────────
|
|
//
|
|
// RED-PROOF: delete the `agentapi.RecoveryHubUnreachable` case from recoveryUnlockHandler so a 502
|
|
// falls through to the wrong-code branch → this FAILS on namesTyping, and the accusation returns
|
|
// exactly as CAMPAIGN-11 F3 measured it. Demonstrated failing before this test was kept.
|
|
func TestRecoveryClass_A_HubUnreachableNamesTheConnection(t *testing.T) {
|
|
f := newRecoveryFixture(t)
|
|
f.rec.failWith = refusal(502, "the sealed recovery bundle could not be fetched from the hub — the recovery code was NOT used and nothing was written")
|
|
|
|
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
|
|
|
|
if namesTyping(body) || isBareAccusation(body) {
|
|
t.Fatalf("R-224 RETURNED: a hub outage is reported as a bad recovery code; got %q", firstAlert(body))
|
|
}
|
|
if !strings.Contains(body, "központi rendszer") {
|
|
t.Errorf("the message must name the connection that failed; got %q", firstAlert(body))
|
|
}
|
|
if !saysCodeWasNotUsed(body) {
|
|
t.Errorf("the customer must be told their code was NOT used; got %q", firstAlert(body))
|
|
}
|
|
// It must not invent an earlier package either — that is a different situation.
|
|
if strings.Contains(body, "nem töröltük") {
|
|
t.Errorf("a hub outage must not be dressed up as a retained earlier package; got %q", firstAlert(body))
|
|
}
|
|
}
|
|
|
|
// ── SCENARIO B — the agent is stopped and the customer is not blamed ─────────────────────────────
|
|
//
|
|
// A dial failure produces NO agent verdict at all, so it is not a *RecoveryRefusal and classifies as
|
|
// RecoveryAgentUnreachable. This is the F4 shape: connection refused to 169.254.253.1:8443.
|
|
func TestRecoveryClass_B_AgentUnreachableNamesTheMachine(t *testing.T) {
|
|
f := newRecoveryFixture(t)
|
|
f.rec.failWith = errors.New(`agentapi: POST /escrow/recover-offsite-password: dial tcp 169.254.253.1:8443: connect: connection refused`)
|
|
|
|
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
|
|
|
|
if namesTyping(body) || isBareAccusation(body) {
|
|
t.Fatalf("R-224 RETURNED: a stopped agent is reported as a bad recovery code; got %q", firstAlert(body))
|
|
}
|
|
if !strings.Contains(body, "házon belüli") {
|
|
t.Errorf("the message must name the machine's own service; got %q", firstAlert(body))
|
|
}
|
|
if !saysCodeWasNotUsed(body) {
|
|
t.Errorf("the customer must be told their code was NOT used; got %q", firstAlert(body))
|
|
}
|
|
// The raw technical error must never reach the customer.
|
|
if strings.Contains(body, "dial tcp") || strings.Contains(body, "169.254") {
|
|
t.Errorf("the raw transport error was rendered to the customer; got %q", firstAlert(body))
|
|
}
|
|
}
|
|
|
|
// ── SCENARIO C — a genuinely wrong code says so, even on a re-escrowed box (R-226) ───────────────
|
|
//
|
|
// RED-PROOF: remove the mistype clause from the superseded message → this FAILS on namesTyping, and
|
|
// the ten-words prompt is unreachable again on exactly the population most likely to need it.
|
|
func TestRecoveryClass_C_MistypeOnAReEscrowedBoxNamesBoth(t *testing.T) {
|
|
f := newRecoveryFixture(t)
|
|
f.rec.failWith = refusal(400, "the recovery code did not open the sealed bundle — nothing was written")
|
|
if err := f.sett.SetHubEscrowSuperseded(true, "2026-08-05T15:03:14Z"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
|
|
|
|
if !namesTyping(body) {
|
|
t.Fatalf("R-226 RETURNED: a mistype on a re-escrowed box is never told to re-check the words; got %q", firstAlert(body))
|
|
}
|
|
if !strings.Contains(body, "nem töröltük") {
|
|
t.Fatalf("the retained earlier package must still be named; got %q", firstAlert(body))
|
|
}
|
|
if isBareAccusation(body) {
|
|
t.Fatalf("R-222 RETURNED: the bare accusation, with no mention of the retained package")
|
|
}
|
|
// §7.3 — it must NOT promise the earlier package can be opened.
|
|
for _, forbidden := range []string{"vissza tudod állítani", "megnyithatod", "vissza fogod kapni"} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Errorf("the message promises the earlier package can be opened (%q)", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── SCENARIO D — an unclassifiable failure never blames the customer ─────────────────────────────
|
|
//
|
|
// THE RULE THAT WAS MISSING WHEN THIS DEFECT WAS FIXED THE FIRST TIME. The default must be neutral,
|
|
// and it must claim NEITHER that the code was wrong NOR that it went unused — neither is known.
|
|
//
|
|
// RED-PROOF: change the `default:` arm to render the wrong-code message → this FAILS. That mutation
|
|
// is precisely the pre-R-224 shape, where everything unrecognised fell through to an accusation.
|
|
func TestRecoveryClass_D_UnclassifiableIsNeutral(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
err error
|
|
}{
|
|
{"an unrecognised status", refusal(418, "something nobody anticipated")},
|
|
{"a 400 from an agent too old to split fetch from refusal", refusal(400, "the recovery code did not open the sealed bundle, or the bundle could not be fetched")},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := newRecoveryFixture(t)
|
|
f.rec.failWith = tc.err
|
|
if tc.name != "an unrecognised status" {
|
|
// The OLD-agent case: a 400 may not be read as a refusal.
|
|
f.s.SetRecoveryRefusalTrusted(func(context.Context) bool { return false })
|
|
}
|
|
|
|
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
|
|
|
|
if namesTyping(body) || isBareAccusation(body) {
|
|
t.Fatalf("an unclassifiable failure blamed the customer; got %q", firstAlert(body))
|
|
}
|
|
// And it must not claim the opposite either — "we did not use your code" is also a claim.
|
|
if saysCodeWasNotUsed(body) {
|
|
t.Fatalf("an unclassifiable failure asserted the code was unused — that is not known; got %q", firstAlert(body))
|
|
}
|
|
if !strings.Contains(body, "nem tudjuk biztosan") {
|
|
t.Errorf("the neutral message must say the cause is not known; got %q", firstAlert(body))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── SCENARIO E — the accusing message requires a REAL attempt ────────────────────────────────────
|
|
//
|
|
// §7.2 asks for this as a TEST rather than production logic, and the distinction matters: elapsed
|
|
// time is the symptom that DIAGNOSED R-224, never a classifier. A production guard on duration would
|
|
// be a second thing that can be wrong, and §5 forbids it outright.
|
|
//
|
|
// So the guard is STRUCTURAL: the typing hint is reachable from exactly ONE class — the one that can
|
|
// only arise from a 400, which by the agent's contract means the bundle was fetched and age ran. This
|
|
// asserts that exhaustively, and the clock is injected so the assertion needs no sleeping.
|
|
//
|
|
// RED-PROOF: route any instant-failing class (502, a transport error, an unrecognised status) to the
|
|
// wrong-code message → this FAILS on that row.
|
|
func TestRecoveryClass_E_OnlyARealRefusalMayMentionTyping(t *testing.T) {
|
|
instant := []struct {
|
|
name string
|
|
err error
|
|
}{
|
|
{"hub unreachable (502)", refusal(502, "could not be fetched")},
|
|
{"recovery not configured (503)", refusal(503, "not configured")},
|
|
{"agent unreachable (transport)", errors.New("dial tcp: connection refused")},
|
|
{"no bundle (404)", refusal(404, "no sealed bundle")},
|
|
{"bundle too old (409)", refusal(409, "predates the field")},
|
|
{"unrecognised (418)", refusal(418, "unanticipated")},
|
|
}
|
|
for _, tc := range instant {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := newRecoveryFixture(t)
|
|
// A clock that NEVER advances: none of these performs an unseal, and the assertion below
|
|
// must hold without any wall-clock time passing.
|
|
frozen := time.Date(2026, 8, 6, 4, 0, 0, 0, time.UTC)
|
|
f.s.SetRecoveryClock(func() time.Time { return frozen })
|
|
f.rec.failWith = tc.err
|
|
|
|
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
|
|
if namesTyping(body) || isBareAccusation(body) {
|
|
t.Fatalf("a failure that performed NO unseal mentioned typing; got %q", firstAlert(body))
|
|
}
|
|
})
|
|
}
|
|
|
|
// The positive half: the one class that DID perform an unseal may say it. Without this the test
|
|
// would pass with the typing message deleted outright.
|
|
t.Run("a real refusal (400) may mention typing", func(t *testing.T) {
|
|
f := newRecoveryFixture(t)
|
|
f.rec.failWith = refusal(400, "the recovery code did not open the sealed bundle")
|
|
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
|
|
if !namesTyping(body) {
|
|
t.Fatalf("a genuinely refused code must be told what to check; got %q", firstAlert(body))
|
|
}
|
|
})
|
|
}
|
|
|
|
// The classifier itself, at the value level — the table the handler switches on. Kept separate from
|
|
// the handler tests so a mapping change is named directly rather than inferred from Hungarian copy.
|
|
func TestClassifyRecoveryFailure_MapsFromTheValueNotTheText(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
trusted bool
|
|
want agentapi.RecoveryFailure
|
|
}{
|
|
{"fetch failure", refusal(502, ""), true, agentapi.RecoveryHubUnreachable},
|
|
{"not configured", refusal(503, ""), true, agentapi.RecoveryHubUnreachable},
|
|
{"refused, trusted", refusal(400, ""), true, agentapi.RecoveryAskedAndRefused},
|
|
{"refused, NOT trusted (old agent)", refusal(400, ""), false, agentapi.RecoveryUnknown},
|
|
{"no bundle", refusal(404, ""), true, agentapi.RecoveryNoBundle},
|
|
{"bundle too old", refusal(409, ""), true, agentapi.RecoveryBundleTooOld},
|
|
{"unrecognised status", refusal(418, ""), true, agentapi.RecoveryUnknown},
|
|
{"transport", errors.New("dial tcp"), true, agentapi.RecoveryAgentUnreachable},
|
|
{"nil", nil, true, agentapi.RecoveryUnknown},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := agentapi.ClassifyRecoveryFailure(tc.err, tc.trusted); got != tc.want {
|
|
t.Fatalf("got %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
// The text must be irrelevant: the SAME sentence under two statuses classifies two ways.
|
|
same := "the recovery code did not open the sealed bundle"
|
|
if agentapi.ClassifyRecoveryFailure(refusal(400, same), true) == agentapi.ClassifyRecoveryFailure(refusal(502, same), true) {
|
|
t.Fatal("classification followed the TEXT — it must follow the status")
|
|
}
|
|
}
|
|
|
|
// ── SCENARIO G (R-227) — A RESTART MID-UNLOCK IS ANSWERED IN HUNGARIAN ──────────────────────────
|
|
//
|
|
// Measured 2026-08-05 (CAMPAIGN-11 F8): the controller was restarted 0.7 s into an unlock and the
|
|
// customer got traefik's raw English `Bad Gateway`. The state was clean; the page was not.
|
|
//
|
|
// The layer that answers is traefik, whose config this repo generates — but traefik v3 serves no
|
|
// static files, so a branded proxy page would need a new always-up container for every 502 on the
|
|
// box. What ships is the second sanctioned option: the unlock posts via fetch and answers a gateway
|
|
// failure in the page. This asserts the handling is PRESENT and says the right thing; with no JS the
|
|
// plain POST is unchanged and still shows the proxy's error, which the report states plainly.
|
|
func TestRecoveryClass_G_GatewayErrorIsAnsweredInHungarian(t *testing.T) {
|
|
f := newRecoveryFixture(t)
|
|
body := getRecoveryPage(t, f.s).Body.String()
|
|
|
|
// RED-PROOF: delete the fetch handler from recovery.html → this FAILS, and a restart mid-unlock
|
|
// shows `Bad Gateway` again.
|
|
if !strings.Contains(body, "unlock-gateway-error") {
|
|
t.Fatal("R-227 RETURNED: the page carries no handling for a gateway failure")
|
|
}
|
|
if !strings.Contains(body, "A gép éppen újraindul") {
|
|
t.Fatal("the gateway message must say, in Hungarian, that the machine is restarting")
|
|
}
|
|
if !strings.Contains(body, "resp.status >= 500") {
|
|
t.Fatal("a 5xx from the proxy must be caught, not rendered")
|
|
}
|
|
// It must claim NOTHING about the code — whether it was used is unknown at that point.
|
|
if namesTyping(body) && !strings.Contains(body, "Helyreállítási kód (tíz szó)") {
|
|
t.Fatal("the gateway path must not blame the code")
|
|
}
|
|
// Progressive enhancement: the plain form must survive for a JS-less browser.
|
|
if !strings.Contains(body, `method="POST" action="/recovery/unlock"`) {
|
|
t.Fatal("the plain POST form must remain for browsers without JS")
|
|
}
|
|
}
|