Files
felhom-controller/controller/internal/web/recovery_class_test.go
T
admin 1e759a16ec R-224/R-226 Part 1: why the unlock failed decides what we say
The failure branch was a two-way choice — superseded? M4 : M1 — and BOTH are
statements about the customer's code. rerr was never inspected, so a hub that
refused, an agent that was stopped and a genuinely mistyped code all produced
the same accusation. Measured live 2026-08-05 with a CORRECT current code: hub
firewalled off 0.0556s, agent stopped 0.0299s, against ~1.0s for a real unseal.

Five classes, from the VALUE and never the text:
  hub-unreachable    502/503 from the agent — the code was NOT used
  agent-unreachable  no agent verdict at all (transport) — NOT used
  no-bundle          404
  bundle-too-old     409
  asked-and-refused  400 — the ONLY class that may mention typing
  unknown            everything else -> NEUTRAL, the safe default

agentapi.RecoveryRefusal carries the status as a value (refusalError flattened
it into a sentence, and a sentence is not something a caller can branch on).

THE OLD-AGENT CASE IS WHY THIS NEEDS A COUPLING. Agent < 0.126.0 answers 400
for both a fetch failure and a wrong code, so a 400 from one cannot be read as
a refusal. FeatureRecoveryFailureClass (MinAgent 0.126.0) withholds that
reading and the 400 degrades to neutral. The gate BLOCKS NOTHING — it only
decides whether the customer may be told to check their typing.

R-226: the superseded message now names BOTH possibilities and restores the
ten-words prompt. The two are indistinguishable at the engine; the honest
message says so. It still does not promise the earlier package can be opened.

Elapsed time is logged (it is what diagnosed this) and is NEVER a classifier.

Tests: scenarios A-E at the HANDLER + the classifier table asserting the same
sentence under two statuses classifies two ways. Red-proofs, each demonstrated
failing then restored: delete the 502 case (A), remove the mistype clause (C),
default to the accusation (D), route an instant transport failure to the typing
message (E).

Two existing tests encoded the defect and were corrected, not deleted: the web
fake returned a BARE error for 'wrong code' (which is the shape of a failure we
cannot classify), and R-222's test forbade any mention of typing on a
superseded box — half of which R-226 deliberately reverses.

28 packages ok, vet clean, all controller gates OK.
2026-08-06 08:06:57 +02:00

245 lines
12 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")
}
}