package web import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "sync" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // R-193 — the recovery screen. HANDLER-LEVEL tests throughout: a test that reaches a helper while the // mutation lives in the handler cannot observe it, which is how a red-proof passed three sessions ago. // Everything below drives the real handler (or the real mux) and asserts the rendered page, the // on-disk effect, or the absence of the code. const testRecoveryCode = "helyre-allitasi-kod-tiz-szo-pontosan-igy-ni-most" const testRepoPW = "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1" // fakeRecoverer is the agent seam. It records every code it was handed so a test can prove the // handler passed the RIGHT one, and can fail on demand for the wrong-code path. type fakeRecoverer struct { mu sync.Mutex pw string sha string fail bool codes []string } func (f *fakeRecoverer) RecoverOffsiteRepoPassword(_ context.Context, code string) (string, string, error) { f.mu.Lock() f.codes = append(f.codes, code) f.mu.Unlock() if f.fail { // The shape the agent returns: names the STEP, never the code. return "", "", fmt.Errorf("unseal failed: age: incorrect passphrase") } return f.pw, f.sha, nil } // recoveryRunner is the restic seam for the post-unlock inventory. type recoveryRunner struct { snapshots []map[string]any statsSize int64 } func (rr *recoveryRunner) run(_ context.Context, _ []string, args ...string) ([]byte, error) { joined := strings.Join(args, " ") switch { case strings.Contains(joined, " snapshots"): b, _ := json.Marshal(rr.snapshots) return b, nil case strings.Contains(joined, " stats "): b, _ := json.Marshal(map[string]any{"total_size": rr.statsSize}) return b, nil } return []byte(""), nil } type recoveryFixture struct { s *Server mgr *backup.Manager sett *settings.Settings rec *fakeRecoverer runner *recoveryRunner dataDir string } // newRecoveryFixture builds a Server in the REBUILT-BOX shape by default: a claimed box (password // set), no repository password on disk, and the hub holding a sealed package. func newRecoveryFixture(t *testing.T) *recoveryFixture { t.Helper() lg := log.New(io.Discard, "", 0) dir := t.TempDir() cfg := &config.Config{} cfg.Paths.DataDir = filepath.Join(dir, "data") cfg.Paths.SystemDataPath = filepath.Join(dir, "sys") cfg.Paths.StacksDir = filepath.Join(dir, "stacks") cfg.Web.SessionSecret = "test-session-secret-abcdef" cfg.Web.PasswordHash = "$2a$10$abcdefghijklmnopqrstuv" // claimed: auth is enabled sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) if err != nil { t.Fatal(err) } if err := sett.SetHubEscrowIdentityPresent(true); err != nil { // the hub holds a package t.Fatal(err) } // The realistic shape once the credential self-heal has re-applied the tier: coordinates exist, // but this box holds no repository password for the history they point at. if err := sett.SetOffboxTarget(&settings.OffboxTarget{ Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily", EscrowState: "escrowed", }); err != nil { t.Fatal(err) } mgr := backup.NewManager(cfg, sett, lg) if err := mgr.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil { t.Fatal(err) } // WriteOffboxSecrets auto-generates a repository password — remove it, because "this box cannot // open the inherited history" is the whole precondition of the screen. if err := os.Remove(filepath.Join(cfg.Paths.DataDir, "offbox", "repo_password")); err != nil { t.Fatal(err) } rr := &recoveryRunner{statsSize: 4 << 20} mgr.SetOffboxRunner(rr.run) rec := &fakeRecoverer{pw: testRepoPW, sha: backup.HashResticPassword(testRepoPW)} stackMgr, serr := stacks.NewManager(cfg, lg) if serr != nil { t.Fatal(serr) } s := &Server{cfg: cfg, settings: sett, backupMgr: mgr, stackMgr: stackMgr, logger: lg, version: "test"} s.loadTemplates() s.SetRecoveryRecoverer(func() (backup.OffsiteKeyRecoverer, error) { return rec, nil }) return &recoveryFixture{s: s, mgr: mgr, sett: sett, rec: rec, runner: rr, dataDir: cfg.Paths.DataDir} } // placeRepoPassword makes the box look HEALTHY (it holds its own repository password). func (f *recoveryFixture) placeRepoPassword(t *testing.T) { t.Helper() if err := f.mgr.InjectOffboxPassword(testRepoPW, true); err != nil { t.Fatal(err) } } func getRecoveryPage(t *testing.T, s *Server) *httptest.ResponseRecorder { t.Helper() rr := httptest.NewRecorder() s.recoveryPageHandler(rr, httptest.NewRequest(http.MethodGet, "/recovery", nil)) return rr } // SCENARIO A — the page appears for the fresh + package box, and interrupts the landing pages. func TestRecovery_A_PageAppearsForARebuiltBox(t *testing.T) { f := newRecoveryFixture(t) if !f.s.recoveryOffer() { t.Fatal("a rebuilt box (fresh data area + a hub-held package) is not offered the recovery screen") } if !f.s.recoveryInterrupts() { t.Fatal("the full page must interrupt the landing pages before any postpone") } rr := getRecoveryPage(t, f.s) if rr.Code != http.StatusOK { t.Fatalf("GET /recovery = %d", rr.Code) } body := rr.Body.String() // The two MANDATORY sentences of §8.2 (ASCII-safe fragments — accented patterns get mangled // through the ssh→pct chain and a false 0 reads exactly like the sentence being gone). if !strings.Contains(body, "helyre") || !strings.Contains(body, "llít") { t.Error("the page does not mention the recovery code at all") } if !strings.Contains(body, "senki nem tudja p") { t.Error("MANDATORY: the page must say that nobody can replace a lost recovery code") } if !strings.Contains(body, "semmi nem v") { t.Error("MANDATORY: the page must say that nothing is restored or changed in this step") } // It takes the code in a POST body, and the field does not autofill. if !strings.Contains(body, `action="/recovery/unlock"`) || !strings.Contains(body, `method="POST"`) { t.Error("the code form must POST to /recovery/unlock") } if !strings.Contains(body, `autocomplete="off"`) { t.Error("the recovery-code field must not autofill") } // And it is not cached. if cc := rr.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") { t.Errorf("the recovery page must be no-store, got %q", cc) } } // SCENARIO B — it does NOT appear for anyone else. THE GUARD ON THE CONJUNCTION. // // RED-PROOF: drop the hub-package condition from backup.OffsiteRecoveryOffer → the // "never had off-site backups" case below FAILS, i.e. a brand-new customer is greeted on day one by // a recovery screen for data they never had. That is the plausible wrong fix. func TestRecovery_B_DoesNotAppearForAnyoneElse(t *testing.T) { t.Run("healthy box (holds its own repository password)", func(t *testing.T) { f := newRecoveryFixture(t) f.placeRepoPassword(t) // healthy, and not orphaned if f.s.recoveryOffer() { t.Fatal("a HEALTHY box was offered the recovery screen") } }) t.Run("never had off-site backups (no hub package)", func(t *testing.T) { f := newRecoveryFixture(t) if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil { t.Fatal(err) } if f.s.recoveryOffer() { t.Fatal("a box that never had off-site backups was offered a recovery screen for data it never had") } }) t.Run("not claimed — the page is behind the household password", func(t *testing.T) { f := newRecoveryFixture(t) f.s.cfg.Web.PasswordHash = "" // unclaimed: no password anywhere if f.s.authEnabled() { t.Fatal("fixture error: the box still reads as claimed") } // The interception is inside the authenticated surface: RequireAuth gates /launcher and // /dashboard before ServeHTTP ever runs. Assert that through the REAL middleware chain. mux := http.NewServeMux() mux.Handle("/", f.s.RequireAuth(f.s.CsrfProtect(http.HandlerFunc(f.s.ServeHTTP)))) rr := httptest.NewRecorder() mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/launcher", nil)) if rr.Code == http.StatusFound && rr.Header().Get("Location") == "/recovery" { t.Fatal("an UNCLAIMED box redirected to the recovery screen — it shows metadata that belongs behind the household password") } }) } // SCENARIO C — the correct code unlocks, places the key, and the page then shows what is in there. func TestRecovery_C_UnlockOpensAndLists(t *testing.T) { f := newRecoveryFixture(t) now := time.Now().UTC() f.runner.snapshots = []map[string]any{ {"short_id": "aaa1111", "time": now.Add(-24 * time.Hour).Format(time.RFC3339), "tags": []string{"immich"}}, {"short_id": "bbb2222", "time": now.Format(time.RFC3339), "tags": []string{"immich"}}, {"short_id": "ccc3333", "time": now.Add(-48 * time.Hour).Format(time.RFC3339), "tags": []string{"calibre-web"}}, } rr := postUnlock(t, f.s, testRecoveryCode) if rr.Code != http.StatusOK { t.Fatalf("unlock = %d", rr.Code) } // EFFECT 1: the recovered key is on disk. got, present := f.mgr.OffboxRepoPasswordHash() if !present || got != backup.HashResticPassword(testRepoPW) { t.Fatalf("the recovered repository password was not placed (present=%v)", present) } // EFFECT 2: the handler passed the code it was given, unmodified. if len(f.rec.codes) != 1 || f.rec.codes[0] != testRecoveryCode { t.Fatalf("the handler did not hand the agent the typed code: %+v", f.rec.codes) } // EFFECT 3: the page lists what is in there — apps and dates. A success message with nothing // shown is indistinguishable from having unlocked an EMPTY store. body := rr.Body.String() for _, want := range []string{"immich", "calibre-web"} { if !strings.Contains(body, want) { t.Errorf("the listing does not name %q: the customer cannot tell whether this is their data", want) } } if !strings.Contains(body, "4.0 MB") && !strings.Contains(body, "MB") { t.Errorf("the listing shows no size") } // EFFECT 4: it did NOT restore anything — the page points at the restore page rather than doing it. if !strings.Contains(body, "/backups/restore") { t.Error("the page must point at the per-app restore rather than restoring") } if strings.Contains(body, "/backup/offbox/reconstitute") || strings.Contains(body, "/backup/offbox/place") { t.Fatal("the recovery page offers a RESTORE action — unlocking and restoring are separate") } } // The EMPTY store is stated plainly rather than shown as a bare list. func TestRecovery_C_EmptyStoreSaysSo(t *testing.T) { f := newRecoveryFixture(t) f.runner.snapshots = nil // opened cleanly, holds nothing body := postUnlock(t, f.s, testRecoveryCode).Body.String() if !strings.Contains(body, "nincs benne egyetlen ment") { t.Fatalf("an empty store must say so plainly — silence there reads as a broken page. body=%.400q", body) } } // SCENARIO D — a wrong code fails closed, writes nothing, says what to check, and does NOT lock out. func TestRecovery_D_WrongCodeFailsClosedAndIsKind(t *testing.T) { f := newRecoveryFixture(t) f.rec.fail = true for i := 0; i < 6; i++ { // well past any plausible lockout threshold rr := postUnlock(t, f.s, "rossz kod") if rr.Code != http.StatusOK { t.Fatalf("attempt %d: got %d, want a re-rendered page", i, rr.Code) } body := rr.Body.String() if !strings.Contains(body, "nem fogadtuk el") { t.Fatalf("attempt %d: the page does not say the code was not accepted: %.300q", i, body) } if !strings.Contains(body, "z szót") && !strings.Contains(body, "t sz") { t.Errorf("attempt %d: the message does not say what to check", i) } // The raw agent error must NOT be shown to the customer. if strings.Contains(body, "age:") || strings.Contains(body, "incorrect passphrase") { t.Errorf("attempt %d: the raw technical error was rendered to the customer", i) } // NOTHING was written. if _, present := f.mgr.OffboxRepoPasswordHash(); present { t.Fatalf("attempt %d: a repository password was written on a FAILED unlock", i) } // And the form is still there — no lockout. if !strings.Contains(body, `name="recovery_code"`) { t.Fatalf("attempt %d: the customer was locked out of their own data after a mistyped code", i) } } } // SCENARIO E — "most nem" stops the interruption and NOTHING else. The entry point survives. // // RED-PROOF: bind the backups-page entry point to recoveryInterrupts instead of recoveryOffer → the // route to the data disappears after one click. func TestRecovery_E_PostponeKeepsTheEntryPoint(t *testing.T) { f := newRecoveryFixture(t) rr := httptest.NewRecorder() f.s.recoveryPostponeHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/postpone", nil)) if rr.Code != http.StatusFound { t.Fatalf("postpone = %d, want a redirect", rr.Code) } if !f.sett.GetRecoveryNoticePostponed() { t.Fatal("the postpone was not recorded") } // The full page no longer interrupts… if f.s.recoveryInterrupts() { t.Fatal("the full page still interrupts after 'most nem'") } // …and it must NOT interrupt through the real mux either. mux := http.NewServeMux() mux.Handle("/", http.HandlerFunc(f.s.ServeHTTP)) rr2 := httptest.NewRecorder() mux.ServeHTTP(rr2, httptest.NewRequest(http.MethodGet, "/launcher", nil)) if rr2.Code == http.StatusFound && rr2.Header().Get("Location") == "/recovery" { t.Fatal("the landing page still redirects to the recovery screen after 'most nem'") } // …but the ENTRY POINT is untouched: the offer stands, so the backups page still renders it. if !f.s.recoveryOffer() { t.Fatal("'most nem' removed the OFFER — the customer has lost the route to their own data") } // And the page itself is still reachable directly. if getRecoveryPage(t, f.s).Code != http.StatusOK { t.Fatal("the recovery page is unreachable after 'most nem'") } // THE ENTRY POINT ITSELF, rendered: the backups page must still carry the route. Asserted on the // rendered flag rather than on recoveryOffer alone, because the defect this guards against is the // TEMPLATE being fed the wrong predicate. rr3 := httptest.NewRecorder() f.s.backupsRemoteHandler(rr3, httptest.NewRequest(http.MethodGet, "/backups/remote", nil)) if !strings.Contains(rr3.Body.String(), `href="/recovery"`) { t.Fatal("the backups page no longer offers the route to the recovery screen after 'most nem' — a customer who clicked past it once has lost the way to their own data") } } // SCENARIO F — "I do not want the old data" is confirmed TWICE and reaches the SHIPPED move-aside. // // RED-PROOF: render the final button on the first view (drop the ConfirmSetAside gate) → one click // suffices, and this test fails on the first assertion. func TestRecovery_F_SetAsideNeedsTwoConfirmations(t *testing.T) { f := newRecoveryFixture(t) // The move-aside only exists once the tier is orphaned — that is the shipped handler's own // precondition, and the page only offers the choice when it can actually run. if err := f.sett.SetOffboxTarget(&settings.OffboxTarget{ Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily", EscrowState: "escrowed", RepoState: "orphaned", }); err != nil { t.Fatal(err) } if !f.mgr.OffboxOrphaned() { t.Fatal("fixture: the tier is not orphaned, so the set-aside cannot be offered") } // FIRST VIEW — the destructive-looking button must NOT be present yet. first := getRecoveryPage(t, f.s).Body.String() if strings.Contains(first, `action="/backup/offbox/reset"`) { t.Fatal("the set-aside form is on the FIRST view — one click would set the customer's history aside") } if !strings.Contains(first, "setaside=1") { t.Fatal("the first view offers no route to the set-aside choice at all") } // And it must read as the exceptional path, not an equal third button. if strings.Count(first, "btn-primary") > 1 { t.Error("the set-aside is styled as an equal primary action") } // SECOND VIEW — now the confirmation, naming exactly what happens. rr := httptest.NewRecorder() f.s.recoveryPageHandler(rr, httptest.NewRequest(http.MethodGet, "/recovery?setaside=1", nil)) second := rr.Body.String() if !strings.Contains(second, `action="/backup/offbox/reset"`) { t.Fatal("the second view does not reach the SHIPPED move-aside handler") } if !strings.Contains(second, `name="confirm" value="1"`) { t.Fatal("the second view does not carry the shipped handler's confirm gate") } // The copy must say SET ASIDE, not delete — the whole point of the ruling. if !strings.Contains(second, "lretessz") { t.Error("the confirmation does not say the backups are SET ASIDE") } if !strings.Contains(second, "nem t") { t.Error("the confirmation does not say they are NOT deleted") } } // SCENARIO G — the command line and the page drive ONE core. // // RED-PROOF: give the handler its own copy of fetch→compare→install instead of calling // RecoverInstallCore → this test still passes on the happy path, so it asserts the SHARED SYMBOL // from source (below) as well as the behaviour here. func TestRecovery_G_PageAndCLIShareOneCore(t *testing.T) { // Behavioural half: the same fake, the same outcome, through both callers. f := newRecoveryFixture(t) res, err := backup.RecoverInstallCore(context.Background(), f.mgr, f.rec, testRecoveryCode, true) if err != nil || res.Outcome != backup.RecoverInstalled { t.Fatalf("core install: outcome=%q err=%v", res.Outcome, err) } // Re-running is UNCHANGED, not a second install — the same three outcomes the CLI documents. res2, err2 := backup.RecoverInstallCore(context.Background(), f.mgr, f.rec, testRecoveryCode, true) if err2 != nil || res2.Outcome != backup.RecoverUnchanged { t.Fatalf("core re-run: outcome=%q err=%v", res2.Outcome, err2) } } // SCENARIO H (§8.3) — the recovery code persists NOWHERE, with the planted-copy positive control. func TestRecovery_H_CodeLeavesNoTrace(t *testing.T) { f := newRecoveryFixture(t) var logBuf strings.Builder f.s.logger = log.New(&logBuf, "", 0) rr := postUnlock(t, f.s, testRecoveryCode) // 1) not echoed in the response if strings.Contains(rr.Body.String(), testRecoveryCode) { t.Fatal("the recovery code was echoed back in the rendered page") } // 2) not in any log line if strings.Contains(logBuf.String(), testRecoveryCode) { t.Fatal("the recovery code reached the log") } // 3) not in any file under the data dir found := grepTree(t, f.dataDir, testRecoveryCode) if found != "" { t.Fatalf("the recovery code was persisted to %s", found) } // THE POSITIVE CONTROL — a sweep whose sensitivity was never shown is not evidence. Plant a copy // where the sweep looks and require it to be found; then remove it. planted := filepath.Join(f.dataDir, "planted-control.txt") if err := os.WriteFile(planted, []byte("x "+testRecoveryCode+" x"), 0o600); err != nil { t.Fatal(err) } if got := grepTree(t, f.dataDir, testRecoveryCode); got == "" { t.Fatal("POSITIVE CONTROL FAILED: the sweep could not find a planted copy, so its earlier silence proves nothing") } if err := os.Remove(planted); err != nil { t.Fatal(err) } if got := grepTree(t, f.dataDir, testRecoveryCode); got != "" { t.Fatalf("the control was not cleaned up: %s", got) } } // grepTree returns the first file under root whose contents contain needle ("" when none). func grepTree(t *testing.T, root, needle string) string { t.Helper() var hit string _ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error { if err != nil || info == nil || info.IsDir() || hit != "" { return nil } b, rerr := os.ReadFile(p) if rerr == nil && strings.Contains(string(b), needle) { hit = p } return nil }) return hit } func postUnlock(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 }