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") } }