package web import ( "io" "log" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "regexp" "strings" "testing" "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-204 item 1 — a freshly minted reset code must work on the FIRST attempt, with nothing restarted. // // WHAT THESE TESTS REPRODUCE, AND WHY THE SEAM IS WHERE IT IS. `--print-reset-code` is a SEPARATE // PROCESS (`docker exec`): it calls settings.Load on the same file, mints, persists and exits. The // running server never hears. So the mint below runs against a SECOND settings.Settings loaded from // the SAME path — that is the whole defect, and a test that reused the server's own *Settings would // mint straight into the cache and prove nothing. The submission then goes through the FULL MUX // (RequireAuth + CsrfProtect + ServeHTTP), i.e. the production path, not handleClaimSubmit directly. // claimReadThroughServer is claimTestServer's sibling: same wiring, but it installs NO code and // returns the settings PATH, because these tests need a second process's view of that file. func claimReadThroughServer(t *testing.T) (*Server, *config.Config, string) { t.Helper() lg := log.New(io.Discard, "", 0) dir := t.TempDir() cfg := &config.Config{} cfg.Customer.ID = "c1" cfg.Customer.Name = "Teszt" cfg.Customer.Domain = "example.hu" cfg.Paths.StacksDir = filepath.Join(dir, "stacks") cfg.Paths.DataDir = filepath.Join(dir, "data") cfg.Stacks.ComposeCommand = "docker compose" cfg.Web.SessionSecret = "test-session-secret-abcdef" path := filepath.Join(dir, "settings.json") sett, err := settings.Load(path, lg) if err != nil { t.Fatalf("settings: %v", err) } mgr, err := stacks.NewManager(cfg, lg) if err != nil { t.Fatalf("stacks: %v", err) } s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"} s.loadTemplates() return s, cfg, path } var hatchCodeRe = regexp.MustCompile(`(?m)^\s{4}([a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4})\s*$`) // mintCodeViaHatch runs the REAL escape hatch exactly as `docker exec ... --print-reset-code` does: // a fresh settings.Load on the same file (a separate process's cache), PrintLocalResetCode, exit. // Returns the plaintext the operator would read off the terminal. func mintCodeViaHatch(t *testing.T, path string, cfg *config.Config) string { t.Helper() other, err := settings.Load(path, log.New(io.Discard, "", 0)) if err != nil { t.Fatalf("hatch: settings.Load: %v", err) } r, w, perr := os.Pipe() if perr != nil { t.Fatalf("hatch: pipe: %v", perr) } saved := os.Stdout os.Stdout = w rc := PrintLocalResetCode(other, cfg) os.Stdout = saved w.Close() out, _ := io.ReadAll(r) r.Close() if rc != 0 { t.Fatalf("hatch: PrintLocalResetCode rc=%d, out=%q", rc, string(out)) } m := hatchCodeRe.FindStringSubmatch(string(out)) if m == nil { t.Fatalf("hatch: no code found in output %q", string(out)) } return m[1] } // submitClaim POSTs the code + a new password through the FULL MUX. Returns the recorder. func submitClaim(t *testing.T, s *Server, code, pw string) *httptest.ResponseRecorder { t.Helper() form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {code}, "new_password": {pw}, "confirm_password": {pw}} req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()}) rr := httptest.NewRecorder() s.fullMux().ServeHTTP(rr, req) return rr } // SCENARIO A — a code minted by the hatch is accepted immediately, with NOTHING restarted. // // RED-PROOF (task §10): delete the `s.settings.ReloadClaimCode()` call in effectiveClaimCode. The // server then validates against its startup cache (empty here), the submission is refused with // „Nincs aktív kód" and this test fails. That mutation removes the ONLY guard this test covers. func TestClaimCode_FreshlyMintedByHatch_AcceptedWithoutRestart(t *testing.T) { s, cfg, path := claimReadThroughServer(t) // Precondition: the server's cache is empty — this is the running process before the hatch. if h, _, _, err := s.effectiveClaimCode(); err != nil || h != "" { t.Fatalf("precondition: want no code and no error, got hash=%q err=%v", h, err) } code := mintCodeViaHatch(t, path, cfg) // NOTHING is restarted and no reload is invoked by the test — the server must see it by itself. rr := submitClaim(t, s, code, "a-strong-passphrase-12") if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/" { t.Fatalf("freshly minted code refused: got %d loc=%q body=%q — the running server did not see the persisted code", rr.Code, rr.Header().Get("Location"), claimFirstLine(rr.Body.String())) } // Assert the EFFECT, not the absence of an error: the box is claimed and the password is live. if !s.settings.GetClaimed() { t.Fatal("box not marked claimed after accepting the freshly minted code") } if !s.authEnabled() { t.Fatal("password not set after accepting the freshly minted code") } } // SCENARIO B — the MOMENT a second code is minted, the first one stops working. // // This is the guard that matters: the plausible wrong fix is a TTL/interval cache, which would make // the new code visible but leave a window in which the SUPERSEDED one still works — worse than the // bug being fixed (task §8.1). // // RED-PROOF: substitute a TTL cache for the read-through (reload only when a stored deadline has // passed, e.g. 30s). Within the window the server keeps the first hash AND the first generation, so // the first code VERIFIES and „the SUPERSEDED code was accepted" fires. // // THE SETUP IS LOAD-BEARING, not ceremony: the server must have ALREADY SEEN the first code before // the second is minted. Without that step the TTL mutation leaves the cache empty, the first code is // refused for having no code at all, and the test would fail for the wrong reason — which is the // red-proof-that-passes-for-the-wrong-reason trap (task §10). The seeing is done through // claimGateActive(), the production path a customer's page load takes, not a test-only reload. func TestClaimCode_SupersededByASecondMint_RefusedImmediately(t *testing.T) { s, cfg, path := claimReadThroughServer(t) first := mintCodeViaHatch(t, path, cfg) if !s.claimGateActive() { t.Fatal("the gate is not active after the first mint — the server never saw the first code") } if h, _, _, _ := s.effectiveClaimCode(); h == "" { t.Fatal("precondition: the server must hold the FIRST code before the second is minted") } second := mintCodeViaHatch(t, path, cfg) if first == second { t.Fatal("the two mints produced the same code — the test cannot distinguish them") } // The FIRST code must be dead the instant the second exists — no window, no TTL. rr := submitClaim(t, s, first, "a-strong-passphrase-12") if rr.Code == http.StatusFound { t.Fatal("the SUPERSEDED code was accepted — a stale/TTL read left an old code alive") } if !strings.Contains(rr.Body.String(), "Hib") { // „Hibás vagy lejárt kód" (ASCII-safe substring) t.Fatalf("superseded code: want the wrong-code error, body=%q", claimFirstLine(rr.Body.String())) } if s.settings.GetClaimed() { t.Fatal("box was claimed by a superseded code") } if s.authEnabled() { t.Fatal("a password was set by a superseded code") } // ...and the SECOND code — the one the customer was actually told — works on the first attempt. rr = submitClaim(t, s, second, "a-strong-passphrase-12") if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/" { t.Fatalf("the current code was refused: got %d body=%q", rr.Code, claimFirstLine(rr.Body.String())) } } // FAIL CLOSED (§8.1) — an unreadable persisted state must never open the gate or accept a claim. // The mutation that breaks it: return the cached value instead of the error from effectiveClaimCode. func TestClaimCode_UnreadablePersistedState_FailsClosed(t *testing.T) { s, cfg, path := claimReadThroughServer(t) code := mintCodeViaHatch(t, path, cfg) // Corrupt the persisted state the way a truncated write would (present but unparseable). if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { t.Fatalf("corrupting settings: %v", err) } if _, _, _, err := s.effectiveClaimCode(); err == nil { t.Fatal("an unparseable settings file did not surface an error") } if !s.claimGateActive() { t.Fatal("the gate OPENED on an unreadable claim state — must fail closed") } if s.claimLegacyOpen() { t.Fatal("an unreadable claim state was reported as legacy-open — must fail closed") } rr := submitClaim(t, s, code, "a-strong-passphrase-12") if rr.Code == http.StatusFound { t.Fatal("a claim was accepted while the persisted state was unreadable") } if s.authEnabled() { t.Fatal("a password was set while the persisted state was unreadable") } }