package web import ( "context" "fmt" "io" "log" "net/http" "net/http/httptest" "net/url" "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/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "golang.org/x/crypto/bcrypt" ) // Escrow wizard handler tests (v0.127.0). The agent + staging seams record CALL ORDER — the // load-bearing Scenario A/B assertion is "re-stage happened BEFORE the agent trigger", and every // Scenario E gate must exit with the agent NEVER called. const wizardPassword = "titkos-jelszo" type fakeEscrowAgent struct { order *[]string // shared call-order log (harness-owned) version string startResp agentapi.EscrowCeremonyStartResponse startStatus int startErr error status agentapi.EscrowCeremonyStatusResponse claimCode string claimStatus int claimErr error pf agentapi.EscrowPreflightResponse } func (f *fakeEscrowAgent) EscrowPreflight(context.Context) (agentapi.EscrowPreflightResponse, error) { *f.order = append(*f.order, "preflight") return f.pf, nil } func (f *fakeEscrowAgent) EscrowCeremonyStart(context.Context) (agentapi.EscrowCeremonyStartResponse, int, error) { *f.order = append(*f.order, "start") return f.startResp, f.startStatus, f.startErr } func (f *fakeEscrowAgent) EscrowCeremonyStatus(context.Context) (agentapi.EscrowCeremonyStatusResponse, error) { return f.status, nil } func (f *fakeEscrowAgent) EscrowCeremonyClaim(context.Context) (string, int, error) { *f.order = append(*f.order, "claim") return f.claimCode, f.claimStatus, f.claimErr } func (f *fakeEscrowAgent) AgentVersion() string { return f.version } type escrowWizardHarness struct { s *Server sett *settings.Settings m *backup.Manager agent *fakeEscrowAgent order []string } func newEscrowWizardHarness(t *testing.T) *escrowWizardHarness { t.Helper() tmp := t.TempDir() lg := log.New(io.Discard, "", 0) sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg) if err != nil { t.Fatal(err) } cfg := &config.Config{} cfg.Paths.DataDir = tmp hash, err := bcrypt.GenerateFromPassword([]byte(wizardPassword), bcrypt.MinCost) if err != nil { t.Fatal(err) } cfg.Web.PasswordHash = string(hash) h := &escrowWizardHarness{ sett: sett, m: backup.NewManager(cfg, sett, lg), } h.agent = &fakeEscrowAgent{ order: &h.order, version: "0.88.0", startResp: agentapi.EscrowCeremonyStartResponse{JobID: "escrow-1", Phase: "running"}, startStatus: http.StatusAccepted, } h.s = &Server{cfg: cfg, backupMgr: h.m, settings: sett, logger: lg} h.s.escrowAgentFn = func() (escrowAgent, error) { return h.agent, nil } h.s.escrowStageFn = func(context.Context) error { h.order = append(h.order, "stage"); return nil } return h } // configureOffbox makes OffboxConfigured() true with the given escrow state. func (h *escrowWizardHarness) configureOffbox(t *testing.T, state string) { t.Helper() if err := h.m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil { t.Fatal(err) } if err := h.sett.SetOffboxTarget(&settings.OffboxTarget{ Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily", EscrowState: state, }); err != nil { t.Fatal(err) } if !h.m.OffboxConfigured() { t.Fatal("setup: offbox should be configured") } } func postStart(t *testing.T, s *Server, password string) *httptest.ResponseRecorder { t.Helper() form := url.Values{"password": {password}} r := httptest.NewRequest("POST", "/api/escrow/start", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.escrowStartAPIHandler(w, r) return w } // Scenario A — pending offsite happy path: correct password → re-stage FIRST, then the agent // trigger; 200 with the job id. func TestEscrowStart_StagesBeforeTrigger(t *testing.T) { h := newEscrowWizardHarness(t) h.configureOffbox(t, "pending") w := postStart(t, h.s, wizardPassword) if w.Code != http.StatusOK { t.Fatalf("start: got %d (%s)", w.Code, w.Body.String()) } if got := strings.Join(h.order, ","); got != "stage,start" { t.Fatalf("call order = %q, want stage BEFORE start (a ceremony without the staged secret mints a hash-less blob)", got) } if !strings.Contains(w.Body.String(), "escrow-1") { t.Fatalf("response lacks the job id: %s", w.Body.String()) } } // Scenario B — re-ceremony from ESCROWED state stages too (the same order assertion). func TestEscrowStart_ReceremonyStagesToo(t *testing.T) { h := newEscrowWizardHarness(t) h.configureOffbox(t, "escrowed") if w := postStart(t, h.s, wizardPassword); w.Code != http.StatusOK { t.Fatalf("re-ceremony start: got %d", w.Code) } if got := strings.Join(h.order, ","); got != "stage,start" { t.Fatalf("re-ceremony call order = %q, want stage,start", got) } } // Scenario C — no offbox configured: NO staging attempted; the ceremony still runs. func TestEscrowStart_NoOffboxSkipsStaging(t *testing.T) { h := newEscrowWizardHarness(t) if w := postStart(t, h.s, wizardPassword); w.Code != http.StatusOK { t.Fatalf("start: got %d", w.Code) } if got := strings.Join(h.order, ","); got != "start" { t.Fatalf("call order = %q, want start only (no staging without offbox)", got) } } // Scenario E — every gate refuses BEFORE the agent (and staging) is touched. func TestEscrowStart_SecurityGates(t *testing.T) { t.Run("wrong password", func(t *testing.T) { h := newEscrowWizardHarness(t) h.configureOffbox(t, "pending") w := postStart(t, h.s, "rossz-jelszo") if w.Code != http.StatusUnauthorized { t.Fatalf("wrong password: got %d, want 401", w.Code) } if len(h.order) != 0 { t.Fatalf("agent/staging touched despite failed re-auth: %v", h.order) } // The failure rides the LOGIN rate limiter. h.s.loginAttemptMu.Lock() var count int for _, a := range h.s.loginAttempts { count += a.count } h.s.loginAttemptMu.Unlock() if count != 1 { t.Fatalf("wrong password must increment the login rate-limit counter, got %d", count) } }) t.Run("rate limited after max attempts", func(t *testing.T) { h := newEscrowWizardHarness(t) for i := 0; i < loginMaxAttempts; i++ { postStart(t, h.s, "rossz-jelszo") } w := postStart(t, h.s, wizardPassword) // even the CORRECT password is refused inside the window if w.Code != http.StatusTooManyRequests { t.Fatalf("rate limit: got %d, want 429", w.Code) } if len(h.order) != 0 { t.Fatalf("agent touched while rate-limited: %v", h.order) } }) t.Run("passwordless box refused", func(t *testing.T) { h := newEscrowWizardHarness(t) h.s.cfg.Web.PasswordHash = "" w := postStart(t, h.s, "") if w.Code != http.StatusForbidden { t.Fatalf("passwordless: got %d, want 403", w.Code) } if len(h.order) != 0 { t.Fatal("agent touched on a passwordless box") } }) t.Run("stage failure aborts before trigger", func(t *testing.T) { h := newEscrowWizardHarness(t) h.configureOffbox(t, "pending") h.s.escrowStageFn = func(context.Context) error { return fmt.Errorf("agent down") } w := postStart(t, h.s, wizardPassword) if w.Code != http.StatusBadGateway { t.Fatalf("stage failure: got %d, want 502", w.Code) } for _, c := range h.order { if c == "start" { t.Fatal("the ceremony started despite the failed staging (would mint a hash-less blob)") } } }) t.Run("agent too old", func(t *testing.T) { h := newEscrowWizardHarness(t) h.agent.version = "0.87.0" w := postStart(t, h.s, wizardPassword) if w.Code != http.StatusConflict { t.Fatalf("old agent: got %d, want 409", w.Code) } for _, c := range h.order { if c == "start" { t.Fatal("an old agent must not be triggered") } } }) t.Run("header-less agent counts as older", func(t *testing.T) { h := newEscrowWizardHarness(t) h.agent.version = "" if w := postStart(t, h.s, wizardPassword); w.Code != http.StatusConflict { t.Fatalf("version-less agent: got %d, want 409", w.Code) } }) t.Run("ceremony already running", func(t *testing.T) { h := newEscrowWizardHarness(t) h.agent.startStatus = http.StatusConflict h.agent.startErr = fmt.Errorf("agentapi: POST /escrow/ceremony: HTTP 409: already running") w := postStart(t, h.s, wizardPassword) if w.Code != http.StatusConflict { t.Fatalf("busy agent: got %d, want 409", w.Code) } if !strings.Contains(w.Body.String(), "folyamatban") { t.Fatalf("busy refusal must speak Hungarian: %s", w.Body.String()) } }) } // Scenario D (controller half) — the claim proxy: no-store on 200, the 410 void message, and the // code appears ONLY in the claim response. func TestEscrowClaim_ProxySemantics(t *testing.T) { const code = "proba-kod-tiz-szo" t.Run("success", func(t *testing.T) { h := newEscrowWizardHarness(t) h.agent.claimCode = code h.agent.claimStatus = http.StatusOK w := httptest.NewRecorder() h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil)) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), code) { t.Fatalf("claim: got %d %s", w.Code, w.Body.String()) } if cc := w.Header().Get("Cache-Control"); cc != "no-store" { t.Fatalf("claim Cache-Control = %q, want no-store", cc) } }) t.Run("gone", func(t *testing.T) { h := newEscrowWizardHarness(t) h.agent.claimStatus = http.StatusGone h.agent.claimErr = fmt.Errorf("gone") w := httptest.NewRecorder() h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil)) if w.Code != http.StatusGone || !strings.Contains(w.Body.String(), "újra nem kérhető le") { t.Fatalf("gone: got %d %s", w.Code, w.Body.String()) } }) // Scenario I (F-C fix): agent 404 "no ceremony has run" → a clean 404, NOT 502. t.Run("no_active_ceremony_404_not_502", func(t *testing.T) { h := newEscrowWizardHarness(t) h.agent.claimStatus = http.StatusNotFound h.agent.claimErr = fmt.Errorf("no ceremony has run") w := httptest.NewRecorder() h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil)) if w.Code != http.StatusNotFound { t.Fatalf("no-ceremony claim must be 404, got %d %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "Nincs aktív helyreállítási folyamat") { t.Fatalf("expected honest Hungarian no-ceremony message, got %s", w.Body.String()) } }) // Scenario J (regression): agent 409 → passed through as 409, not 502. t.Run("conflict_409", func(t *testing.T) { h := newEscrowWizardHarness(t) h.agent.claimStatus = http.StatusConflict h.agent.claimErr = fmt.Errorf("conflict") w := httptest.NewRecorder() h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil)) if w.Code != http.StatusConflict { t.Fatalf("409 must pass through, got %d", w.Code) } }) // Scenario K (regression): a genuinely-unreachable agent (status 0) stays 502 — that IS a real // bad gateway; the fix must NOT over-correct it to a 4xx. t.Run("unreachable_stays_502", func(t *testing.T) { h := newEscrowWizardHarness(t) h.agent.claimStatus = 0 h.agent.claimErr = fmt.Errorf("dial tcp: connection refused") w := httptest.NewRecorder() h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil)) if w.Code != http.StatusBadGateway { t.Fatalf("unreachable agent must stay 502, got %d", w.Code) } }) } // The status proxy relays the agent's non-secret job view verbatim. func TestEscrowStatus_Proxy(t *testing.T) { h := newEscrowWizardHarness(t) h.agent.status = agentapi.EscrowCeremonyStatusResponse{ Phase: "done", JobID: "escrow-1", ResticPwSealed: true, Uploaded: true, Claimable: true, ClaimExpiresInSec: 599, } w := httptest.NewRecorder() h.s.escrowStatusAPIHandler(w, httptest.NewRequest("GET", "/api/escrow/status", nil)) body := w.Body.String() for _, want := range []string{`"phase":"done"`, `"claimable":true`, `"restic_pw_sealed":true`} { if !strings.Contains(body, want) { t.Fatalf("status proxy missing %s: %s", want, body) } } } // The wizard page + card render states (Hungarian copy, no server-side R anywhere by // construction — the template has no code variable to leak). func TestEscrowTemplates_Render(t *testing.T) { base := func() map[string]interface{} { return map[string]interface{}{ "Title": "Helyreállítási kód", "Domain": "example.hu", "AgentSupported": true, "Receremony": false, "OffboxConfigured": true, } } t.Run("wizard fresh", func(t *testing.T) { html := renderBackupPage(t, "backups_escrow", base()) for _, want := range []string{ "Előfeltételek ellenőrzése", "a mentései utolsó kulcsa", "A folytatáshoz adja meg a bejelentkezési jelszavát", "Kód létrehozása", "Kód megjelenítése", // v0.127.3 (Viktor): the reveal states EXPLICITLY that the shown code is already the // live one (the supersede happened at upload, before display). "Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentse el most: a kód többé nem jeleníthető meg.", // The pre-generation cancel must stay next to the start button (nothing runs until then). `>Mégsem`, "nem ezen a szerveren", "biztonsági okból újra nem kérhető le", // v0.127.1 polish: the Hungarian preflight detail map + the hide/show toggle // (v0.127.2: manual-only — initial label "Elrejtés", code visible by default). "nincs előkészített jelszó — a varázsló indításkor automatikusan előkészíti", "a rendszerjogosultság hiányzik", "Megjelenítés", ">Elrejtés", } { if !strings.Contains(html, want) { t.Errorf("wizard missing %q", want) } } // v0.127.2 (Viktor's live finding): the code must NOT auto-blur when a verification input // gets focus — the customer types from the screen. The hide toggle is manual-only. if strings.Contains(html, "armCodeBlur") || strings.Contains(html, "onfocus=") { t.Error("auto-blur-on-focus must be gone (manual toggle only)") } if strings.Contains(html, "érvényét veszti") { t.Error("fresh wizard must not show the re-ceremony supersede warning") } // v0.127.1: the agent's operator-English detail strings must never be hardcoded into the // page — they may only arrive at runtime, inside the muted failure-diagnostic span. for _, raw := range []string{"DR tier applied", "hub upload target configured", "sudo grant listed"} { if strings.Contains(html, raw) { t.Errorf("raw English agent detail %q leaked into the wizard page source", raw) } } }) t.Run("wizard re-ceremony variant", func(t *testing.T) { d := base() d["Receremony"] = true if html := renderBackupPage(t, "backups_escrow", d); !strings.Contains(html, "érvényét veszti") { t.Error("re-ceremony wizard must show the supersede warning") } }) t.Run("wizard agent too old", func(t *testing.T) { d := base() d["AgentSupported"] = false html := renderBackupPage(t, "backups_escrow", d) if !strings.Contains(html, "az ügynök frissítése szükséges") { t.Error("old-agent wizard must show the version note") } if strings.Contains(html, "Kód létrehozása") { t.Error("old-agent wizard must be inert (no start form)") } }) t.Run("remote card states", func(t *testing.T) { data := splitTestData() data["EscrowAgentOK"] = true data["EscrowStale"] = false html := renderBackupPage(t, "backups_remote", data) // escrowed clean if !strings.Contains(html, "Új helyreállítási kód készítése") { t.Error("escrowed card must offer the secondary re-ceremony link") } // v0.127.1: the CTA is a real secondary BUTTON (outline — available-not-urgent), no longer // an inline link buried in the muted hint. if !strings.Contains(html, `class="btn btn-sm btn-outline">Új helyreállítási kód készítése`) { t.Error("escrowed-card CTA must render as a btn-outline button") } if strings.Contains(html, "Letét megerősítése") { t.Error("the deprecated manual-confirm button must be GONE from the card") } data["EscrowStale"] = true html = renderBackupPage(t, "backups_remote", data) if !strings.Contains(html, "nem fedi a jelenlegi távoli mentési jelszót") { t.Error("stale card must show the exact stale warning") } pending := splitTestData() pending["EscrowAgentOK"] = true pending["EscrowStale"] = false pending["Offbox"].(*settings.OffboxTarget).EscrowState = "pending" html = renderBackupPage(t, "backups_remote", pending) if !strings.Contains(html, "Helyreállítási kód szükséges") || !strings.Contains(html, "Helyreállítási kód létrehozása") { t.Error("pending card must show the CTA state") } old := splitTestData() old["EscrowAgentOK"] = false old["EscrowStale"] = false old["Offbox"].(*settings.OffboxTarget).EscrowState = "pending" html = renderBackupPage(t, "backups_remote", old) if !strings.Contains(html, "az ügynök frissítése szükséges") || strings.Contains(html, `href="/backup/escrow"`) { t.Error("old-agent card must show the version note with NO CTA") } }) } // The preflight proxy augments the agent checklist with the version gate + offbox facts. func TestEscrowPreflight_Augments(t *testing.T) { h := newEscrowWizardHarness(t) h.configureOffbox(t, "escrowed") h.agent.pf = agentapi.EscrowPreflightResponse{OK: true, Items: []agentapi.EscrowPreflightItem{ {ID: "pbs_storage_id", OK: true, Detail: "felhom-pbs"}, }} w := httptest.NewRecorder() h.s.escrowPreflightAPIHandler(w, httptest.NewRequest("GET", "/api/escrow/preflight", nil)) body := w.Body.String() for _, want := range []string{`"agent_supported":true`, `"escrow_state":"escrowed"`, `"offbox_configured":true`, `"pbs_storage_id"`} { if !strings.Contains(body, want) { t.Fatalf("preflight missing %s: %s", want, body) } } // An old agent flips both the flag and the aggregate ok. h.agent.version = "0.87.0" w2 := httptest.NewRecorder() h.s.escrowPreflightAPIHandler(w2, httptest.NewRequest("GET", "/api/escrow/preflight", nil)) if !strings.Contains(w2.Body.String(), `"agent_supported":false`) || !strings.Contains(w2.Body.String(), `"ok":false`) { t.Fatalf("old agent must flip agent_supported + ok: %s", w2.Body.String()) } }