a3499d1807
gates / gates (push) Successful in 9s
R-216: the offsite key recovery is a coupled feature and now says so. featureProbes +
featureMinAgent 0.125.0 + a Supports gate at the unlock entry point, FAILING CLOSED — an
agent that cannot answer is named as such instead of the customer's code being blamed.
Measured live: a 404 from agent 0.120.0 came back as "we did not accept your recovery
code, check that all ten words", in 0.134 s, against a perfect code.
R-218: delete the repo-password short-circuit in needsOffsiteCredential. The declaration
stops when the TIER WORKS, not when a key exists — installing a key is the recovery
screen's whole job, so succeeding at recovery was switching off the mechanism that would
have delivered the coordinates to use it.
R-219: the unlock finishes the job — place the key, bring the tier up, then list. Without
it the promised listing could never render on the shape the screen exists for.
R-217: an unreadable store no longer claims to have opened with unattributable content
(the OffsiteInventory{} zero value). Opened / empty / unreadable are three states.
R-222: a code that is right about a RETAINED earlier package is named, not blamed. States
what the hub knows and promises nothing — no read path exists.
R-215: GET /recovery is gated on the same predicate as the interception.
Five red-proofs, each demonstrated failing and restored.
345 lines
14 KiB
Go
345 lines
14 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()
|
|
|
|
if blamesTyping(body) {
|
|
t.Fatal("R-222 RETURNED: a code that is RIGHT about a retained earlier package is blamed on the customer's typing")
|
|
}
|
|
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
|