Files
felhom-controller/controller/internal/web/recovery_gate_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

361 lines
15 KiB
Go

package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// CAMPAIGN-11 fixes — R-216, R-217, R-219, R-222, R-215.
//
// HANDLER-LEVEL throughout. Every assertion below is on the RENDERED PAGE or an on-disk effect,
// because each of these defects lived in the handler while a helper-level test looked green: R-215's
// predicate was correct and its page never asked, and R-217's failure branch built a struct whose
// zero value the TEMPLATE misread. A test that stops at the helper observes neither.
// postUnlockWith drives the real unlock handler with a code.
func postUnlockWith(t *testing.T, s *Server, code string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"recovery_code": {code}}
req := httptest.NewRequest(http.MethodPost, "/recovery/unlock", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.recoveryUnlockHandler(rr, req)
return rr
}
// blamesTyping reports whether the page tells the customer to check how they typed their code. It is
// the ONE sentence that may only ever appear for a genuinely wrong code.
func blamesTyping(body string) bool {
return strings.Contains(body, "z szót pontosan") || strings.Contains(body, "nem fogadtuk el")
}
// ── SCENARIO A — an agent that cannot answer is NAMED, never the customer's code (R-216) ─────────
//
// THE HEADLINE. Measured live 2026-08-05: agent 0.120.0 404s the recovery route, the unlock was
// attempted anyway, and a CORRECT code came back as "we did not accept your recovery code, check
// that all ten words…" in 0.134 s.
//
// RED-PROOF: delete the `if support := s.recoverySupport(...)` block from recoveryUnlockHandler and
// this test FAILS on the blamesTyping assertion — the accusation returns verbatim. Demonstrated
// failing before this test was kept.
func TestRecoveryGate_A_OldAgentIsNamed_NeverTheCode(t *testing.T) {
for _, tc := range []struct {
name string
state agentapi.SupportState
}{
{"agent predates the route (404)", agentapi.SupportNo},
{"agent cannot be asked at all", agentapi.SupportUnknown}, // fail-CLOSED: §7.1
} {
t.Run(tc.name, func(t *testing.T) {
f := newRecoveryFixture(t)
f.s.SetRecoverySupport(func(context.Context) agentapi.SupportState { return tc.state })
// Model the REAL agent: one that cannot answer the route also fails the call. Without
// this the fake would succeed and the red-proof could not show the accusation returning —
// the mutation would fail the test for weaker reasons than the finding itself.
f.rec.fail = true
rr := postUnlockWith(t, f.s, testRecoveryCode)
if rr.Code != http.StatusOK {
t.Fatalf("unlock = %d", rr.Code)
}
body := rr.Body.String()
// EFFECT 1 — the customer is NOT blamed. This is the whole finding.
if blamesTyping(body) {
t.Fatal("R-216 RETURNED: an agent that cannot answer is still reported as a wrong recovery code")
}
// EFFECT 2 — the machine is named as the thing that cannot do it yet.
if !strings.Contains(body, "Ez a gép még nem tudja") {
t.Errorf("the refusal must say THE MACHINE cannot do this yet; got: %q", firstAlert(body))
}
// EFFECT 3 — it says the code is fine and was not consumed, so the customer keeps it.
if !strings.Contains(body, "kódoddal semmi baj") {
t.Error("the refusal must reassure the customer that their code is fine")
}
// EFFECT 4 — no attempt was made. An attempt that cannot succeed must never be made,
// because its failure is what got attributed to the code.
if n := len(f.rec.codes); n != 0 {
t.Fatalf("the handler attempted an unlock it could not complete (%d attempt(s))", n)
}
})
}
}
// The supported case still works — the gate must not refuse everything.
func TestRecoveryGate_A_SupportedAgentProceeds(t *testing.T) {
f := newRecoveryFixture(t)
rr := postUnlockWith(t, f.s, testRecoveryCode)
if rr.Code != http.StatusOK {
t.Fatalf("unlock = %d", rr.Code)
}
if len(f.rec.codes) != 1 {
t.Fatalf("a supported agent must be asked exactly once, got %d", len(f.rec.codes))
}
if _, ok := f.mgr.OffboxRepoPasswordHash(); !ok {
t.Fatal("the recovered key was not placed")
}
}
// The declared coupling itself: the table must carry the row, at the version that ships the route.
func TestRecoveryGate_A_CouplingIsDeclared(t *testing.T) {
if got := agentapi.MinAgentFor(agentapi.FeatureOffsiteKeyRecovery); got != "0.125.0" {
t.Fatalf("FeatureOffsiteKeyRecovery MinAgent = %q, want 0.125.0 (the version shipping POST /escrow/recover-offsite-password)", got)
}
}
// ── SCENARIO G — opened / empty / unreadable are THREE sentences (R-217) ─────────────────────────
//
// RED-PROOF: replace `s.renderRecoveryUnlockedNoList(w, r, msg)` with
// `s.renderRecovery(w, r, msg, "", &backup.OffsiteInventory{})` and this test FAILS — the zero value's
// Empty=false falls through to .InvUntagged and the page claims the store opened with content it
// could not attribute. Demonstrated failing before this test was kept.
func TestRecoveryGate_G_UnreadableStoreNeverClaimsToHaveOpened(t *testing.T) {
f := newRecoveryFixture(t)
// The tier is up, but reading the repository fails.
f.mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
return nil, context.DeadlineExceeded
})
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if strings.Contains(body, "van benne tartalom") {
t.Fatal("R-217 RETURNED: an UNREADABLE store is reported as opened, with unattributable content")
}
if strings.Contains(body, "nincs benne egyetlen") {
t.Fatal("an unreadable store must not be reported as opened-and-empty either")
}
// It says the key is safe and what is pending — no claim about the contents.
if !strings.Contains(body, "kulcs visszakerült") {
t.Errorf("the customer must be told the key came back; got: %q", firstAlert(body))
}
}
// The three states must be DISTINGUISHABLE, which is the actual requirement.
func TestRecoveryGate_G_ThreeDistinctStates(t *testing.T) {
// (1) opened, with apps.
f1 := newRecoveryFixture(t)
f1.runner.snapshots = []map[string]any{{"short_id": "aaa1111", "time": "2026-08-01T10:00:00Z", "tags": []string{"immich"}}}
b1 := postUnlockWith(t, f1.s, testRecoveryCode).Body.String()
// (2) opened, empty.
f2 := newRecoveryFixture(t)
f2.runner.snapshots = nil
b2 := postUnlockWith(t, f2.s, testRecoveryCode).Body.String()
// (3) could not be read.
f3 := newRecoveryFixture(t)
f3.mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
return nil, context.DeadlineExceeded
})
b3 := postUnlockWith(t, f3.s, testRecoveryCode).Body.String()
if !strings.Contains(b1, "immich") {
t.Error("state 1 (opened, with apps) must list the app")
}
if !strings.Contains(b2, "nincs benne egyetlen") {
t.Error("state 2 (opened, empty) must say so explicitly")
}
if strings.Contains(b3, "nincs benne egyetlen") || strings.Contains(b3, "van benne tartalom") {
t.Error("state 3 (unreadable) must claim nothing about the contents")
}
}
// ── SCENARIO H — the retained earlier package is NAMED, not blamed on the customer (R-222) ───────
func TestRecoveryGate_H_SupersededPackageIsNamed(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.fail = true // the unseal fails against the CURRENT package — the real engine behaviour
if err := f.sett.SetHubEscrowSuperseded(true, "2026-08-05T15:03:14Z"); err != nil {
t.Fatal(err)
}
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
// ⚠ R-226 DELIBERATELY REVERSED HALF OF THIS ASSERTION (2026-08-06), so it is spelled out.
//
// As first shipped, this test forbade ANY mention of typing here — R-222's guarantee was that a
// customer holding the RIGHT code for a retained earlier package must not be told they mistyped.
// That was right about the accusation and wrong about the omission: because this branch is tested
// BEFORE the wrong-code message, it made the ten-words prompt UNREACHABLE on every box the hub
// keeps an earlier package for — which is exactly the box whose customer has just been given a new
// code and is most likely to be typing one. Measured 2026-08-05 (CAMPAIGN-11 F1): three genuinely
// wrong codes, three real ~1 s unseals, three copies of the earlier-package message.
//
// The two causes are INDISTINGUISHABLE at the engine, so the message now names BOTH. What stays
// forbidden is the bare ACCUSATION — the M1 opener that asserts the code was simply not accepted
// and says nothing about the retained package.
if strings.Contains(body, "nem fogadtuk el") {
t.Fatal("R-222 RETURNED: the bare wrong-code accusation, with no mention of the retained earlier package")
}
if !blamesTyping(body) {
t.Fatal("R-226 RETURNED: a genuine mistype on a re-escrowed box is given no way to discover it was a mistype")
}
if !strings.Contains(body, "nem töröltük") {
t.Errorf("the customer must be told the earlier package is kept; got: %q", firstAlert(body))
}
if !strings.Contains(body, "2026-08-05T15:03:14Z") {
t.Error("when the hub knows WHEN the earlier package was set aside, say so")
}
// §7.6 — it may state the facts and 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) — the read path does not exist", forbidden)
}
}
}
// And with NO retained package the wrong-code message is unchanged — the only one mentioning typing.
func TestRecoveryGate_H_WithoutASupersededPackageItIsStillTheWrongCodeMessage(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.fail = true
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if !blamesTyping(body) {
t.Fatal("a genuinely wrong code must still get the wrong-code message")
}
if strings.Contains(body, "nem töröltük") {
t.Fatal("a box with no retained earlier package must not claim one exists")
}
}
// ── SCENARIO I — /recovery is not reachable by typing the path (R-215) ──────────────────────────
//
// RED-PROOF: delete the `if !s.recoveryOffer()` guard from recoveryPageHandler and this test FAILS —
// a box that never had off-site backups renders "this machine was reinstalled, your off-site backups
// are there". Demonstrated failing before this test was kept.
func TestRecoveryGate_I_DirectGetRefusedOnABoxThatNeverHadBackups(t *testing.T) {
f := newRecoveryFixture(t)
// The defining fact: the hub holds nothing for this box.
if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil {
t.Fatal(err)
}
if f.s.recoveryOffer() {
t.Fatal("fixture error: the box still reads as offered")
}
rr := getRecoveryPage(t, f.s)
if rr.Code == http.StatusOK && strings.Contains(rr.Body.String(), "jratelep") {
t.Fatal("R-215 RETURNED: a box that never had off-site backups renders the recovery story on a direct GET")
}
if rr.Code != http.StatusFound {
t.Errorf("a direct GET on a non-offered box should redirect away, got %d", rr.Code)
}
}
// A box that IS in the situation still reaches the page directly — including after "most nem",
// which is what makes the permanent backups-area entry point work at all.
func TestRecoveryGate_I_DirectGetStillWorksWhenOffered(t *testing.T) {
f := newRecoveryFixture(t)
if err := f.sett.SetRecoveryNoticePostponed(true); err != nil {
t.Fatal(err)
}
if f.s.recoveryInterrupts() {
t.Fatal("fixture error: postpone did not suppress the interruption")
}
rr := getRecoveryPage(t, f.s)
if rr.Code != http.StatusOK {
t.Fatalf("an offered box must still reach /recovery directly after a postpone, got %d", rr.Code)
}
}
// ── SCENARIO F — the unlock finishes the job: key placed, tier brought up, then the listing ──────
func TestRecoveryGate_F_UnlockBringsTheTierUpBeforeListing(t *testing.T) {
f := newRecoveryFixture(t)
// The pristine rebuilt shape: no coordinates at all, so the listing is impossible until the tier
// comes up. Removing the target is what makes this the shape (a) the screen exists for.
if err := f.sett.SetOffboxTarget(nil); err != nil {
t.Fatal(err)
}
called := 0
f.s.SetRecoveryTierUp(func(context.Context) error {
called++
// Bringing the tier up = coordinates exist afterwards.
return f.sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
Schedule: "daily", EscrowState: "escrowed",
})
})
f.runner.snapshots = []map[string]any{{"short_id": "bbb2222", "time": "2026-08-02T09:00:00Z", "tags": []string{"calibre-web"}}}
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if called != 1 {
t.Fatalf("the unlock must bring the tier up exactly once, got %d", called)
}
if !strings.Contains(body, "calibre-web") {
t.Fatalf("the listing the screen PROMISES did not render; got: %q", firstAlert(body))
}
}
// A tier that cannot come up yet is PENDING, not a failure, and never a wrong code.
func TestRecoveryGate_F_TierNotUpYetSaysPendingNotFailed(t *testing.T) {
f := newRecoveryFixture(t)
if err := f.sett.SetOffboxTarget(nil); err != nil {
t.Fatal(err)
}
f.s.SetRecoveryTierUp(func(context.Context) error { return context.DeadlineExceeded })
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if blamesTyping(body) {
t.Fatal("a tier that has not come up yet must never read as a wrong code")
}
if !strings.Contains(body, "kapcsolódási adatait") {
t.Errorf("say what is pending — the connection details; got: %q", firstAlert(body))
}
// The key IS placed regardless: nothing failed.
if _, ok := f.mgr.OffboxRepoPasswordHash(); !ok {
t.Fatal("the recovered key must be placed even when the tier cannot come up yet")
}
}
// firstAlert extracts the first rendered alert body, for readable failure output.
func firstAlert(body string) string {
i := strings.Index(body, "alert-error")
if i < 0 {
i = strings.Index(body, "alert-info")
}
if i < 0 {
return "(no alert rendered)"
}
seg := body[i:]
if j := strings.Index(seg, "</div>"); j > 0 {
seg = seg[:j]
}
if len(seg) > 400 {
seg = seg[:400]
}
return strings.TrimSpace(seg)
}
// dataDirHasNoCode asserts the recovery code never reached disk — the sweep, with a positive control
// in the caller. Kept here because every path above handles a code.
func dataDirHasNoCode(t *testing.T, dir, code string) {
t.Helper()
var hits []string
_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info == nil || info.IsDir() {
return nil
}
b, rerr := os.ReadFile(p)
if rerr == nil && strings.Contains(string(b), code) {
hits = append(hits, p)
}
return nil
})
if len(hits) > 0 {
t.Fatalf("the recovery code was written to disk: %v", hits)
}
}
var _ = backup.HashResticPassword