package web import ( "context" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" ) // ── R-311 — A CORRECT CODE FOR AN EARLIER PACKAGE STOPS BEING CALLED WRONG ─────────────────────── // // What was measured on 2026-08-12: a recovery code that provably opens a RETAINED package — unsealed // by hand, and it restored planted files byte-identical from a store the box itself could no longer // open — was answered by the product as a code that opened nothing. // // The screen already hedged (R-222/R-226): "it may be a typo, or it may be an older code, and we // cannot tell them apart from here." That sentence was TRUE and it was honest. It was also a // statement about our own incuriosity — nothing ever tried the retained packages — read by the // customer as a statement about their code. Now something tries, so the hedge can become an answer. // // Every test asserts the EFFECT at the HANDLER: which sentence the customer is shown. // saysCodeIsCorrect is the load-bearing half of the new message. Anything that leaves this out has // failed at the one job it has. func saysCodeIsCorrect(body string) bool { return strings.Contains(body, "A kódod helyes") } // saysCurrentBackupsUnaffected — a customer told "your code opens something else" will otherwise // assume their CURRENT backups are in doubt. They are not, and the message must say so. func saysCurrentBackupsUnaffected(body string) bool { return strings.Contains(body, "mostani mentéseidet ez nem érinti") } // namesARoute — a refusal that names no next step is a defect on this surface. func namesARoute(body string) bool { return strings.Contains(body, "ügyfélszolgálat") } // ── THE ONE THAT MATTERS ──────────────────────────────────────────────────────────────────────── // // RED-PROOF: delete the `agentapi.RecoveryCodeOpensRetained` case from recoveryUnlockHandler so a 422 // falls through → the customer gets the hedged "we cannot tell them apart" text (or, without R-222's // gate, the bare typing accusation) → this FAILS on saysCodeIsCorrect. That is yesterday's behaviour // returning, and it is the whole reason this case exists. func TestR311_CodeOpensRetained_IsNotReportedAsWrong(t *testing.T) { f := newRecoveryFixture(t) f.rec.failWith = refusal(422, "the recovery code is correct, but it belongs to an EARLIER sealed package (superseded 2026-08-12 15:18:55), not the one currently held") body := postUnlockWith(t, f.s, testRecoveryCode).Body.String() if namesTyping(body) || isBareAccusation(body) { t.Fatalf("a CORRECT code was reported as a possible mistype — the R-311 defect is back; got %q", firstAlert(body)) } if !saysCodeIsCorrect(body) { t.Fatalf("the message must say the code is CORRECT; got %q", firstAlert(body)) } if !saysCurrentBackupsUnaffected(body) { t.Errorf("the message must deny the assumption it creates — that the CURRENT backups are affected; got %q", firstAlert(body)) } if !namesARoute(body) { t.Errorf("a refusal on this screen must name a route; got %q", firstAlert(body)) } } // The message must NOT promise the older history can be reopened from this screen. There is no // in-product route to a set-aside store (the restore machinery resolves its repository from settings // and its password from one file), and the retained package may itself predate the repository-password // field. A conditional promise that turns out false HERE is worse than saying less — the R-202 lesson. // // RED-PROOF: add "és vissza is állítjuk" (or any unconditional retrieval promise) to the message → // this FAILS. func TestR311_TheMessageDoesNotPromiseARestore(t *testing.T) { f := newRecoveryFixture(t) f.rec.failWith = refusal(422, "belongs to an EARLIER sealed package") body := firstAlert(postUnlockWith(t, f.s, testRecoveryCode).Body.String()) // A promise would take the form "we will restore it" / "you can get them back from here". for _, promise := range []string{"vissza is állítjuk", "most visszaállítjuk", "innen visszaszerezheted", "azonnal visszaállítható"} { if strings.Contains(body, promise) { t.Fatalf("the message promises a restore this screen cannot perform (%q): %q", promise, body) } } // It must still say the package is KEPT — that is the true, useful half. if !strings.Contains(body, "nem töröltük") { t.Errorf("the message must say the earlier package was kept; got %q", body) } } // AN OLDER AGENT CANNOT PRODUCE THIS VERDICT, so the screen must keep the hedged sentence. The gate // decides which of two TRUE sentences to print, never whether to attempt the unlock. // // RED-PROOF: make ClassifyRecoveryFailure return RecoveryCodeOpensRetained for 422 regardless of // trustRetained → an agent that never looked at a retained package is treated as having looked → // this FAILS. func TestR311_OlderAgent_KeepsTheHedgedSentence(t *testing.T) { f := newRecoveryFixture(t) f.s.SetRecoveryRetainedTrusted(func(context.Context) bool { return false }) f.rec.failWith = refusal(422, "some shape this agent should not be producing") body := postUnlockWith(t, f.s, testRecoveryCode).Body.String() if saysCodeIsCorrect(body) { t.Fatalf("an agent too old to look at retained packages was read as having looked; got %q", firstAlert(body)) } // RecoveryUnknown → the neutral message. It must not accuse either. if isBareAccusation(body) { t.Errorf("an unclassifiable 422 produced an accusation; got %q", firstAlert(body)) } } // SCENARIO C — a genuinely wrong code must still reach the typing message. The new branch must not // become an escape hatch that stops the product ever saying "check your typing". // // RED-PROOF: route 400 to RecoveryCodeOpensRetained → a mistype is congratulated → this FAILS. func TestR311_WrongCodeStillReachesTheTypingMessage(t *testing.T) { f := newRecoveryFixture(t) f.rec.failWith = refusal(400, "the recovery code did not open the sealed bundle — nothing was written") body := postUnlockWith(t, f.s, testRecoveryCode).Body.String() if saysCodeIsCorrect(body) { t.Fatalf("a WRONG code was told it is correct; got %q", firstAlert(body)) } if !namesTyping(body) { t.Errorf("a genuine refusal must still be able to mention typing; got %q", firstAlert(body)) } } // SCENARIO A — the ordinary successful recovery is untouched. This is the path that WORKS, and it is // the one a change like this is most likely to break by accident. // // RED-PROOF: return the 422 refusal from the fixture's recoverer on the success path → this FAILS. func TestR311_OrdinaryRecoveryUnaffected(t *testing.T) { f := newRecoveryFixture(t) f.rec.failWith = nil // the current package opens rr := postUnlockWith(t, f.s, testRecoveryCode) body := rr.Body.String() if saysCodeIsCorrect(body) { t.Fatal("the retained message rendered on a SUCCESSFUL recovery") } if namesTyping(body) || isBareAccusation(body) { t.Fatalf("a successful recovery produced a refusal; got %q", firstAlert(body)) } } // The classifier maps 422 by STATUS and gate, never by sentence — the R-224 rule, applied to the new // status so it cannot regress the same way. // // RED-PROOF: classify on strings.Contains(reason, "EARLIER") instead of the status → the two // same-text/different-status calls below stop differing → this FAILS. func TestR311_ClassificationFollowsStatusNotText(t *testing.T) { same := "the recovery code is correct, but it belongs to an EARLIER sealed package" if agentapi.ClassifyRecoveryFailure(refusal(422, same), true, true) == agentapi.ClassifyRecoveryFailure(refusal(400, same), true, true) { t.Fatal("classification followed the TEXT — it must follow the status") } if got := agentapi.ClassifyRecoveryFailure(refusal(422, same), true, true); got != agentapi.RecoveryCodeOpensRetained { t.Fatalf("422 with the gate open = %v, want RecoveryCodeOpensRetained", got) } if got := agentapi.ClassifyRecoveryFailure(refusal(422, same), true, false); got != agentapi.RecoveryUnknown { t.Fatalf("422 with the gate CLOSED = %v, want RecoveryUnknown (neutral)", got) } // The two gates are independent: an agent between v0.126.0 and v0.129.0 trusts 400 but not 422. if got := agentapi.ClassifyRecoveryFailure(refusal(400, same), true, false); got != agentapi.RecoveryAskedAndRefused { t.Fatalf("400 with only the R-224 gate open = %v, want RecoveryAskedAndRefused", got) } }