package server_test // D-045 — the setup gate is the config toggle `setup` (SPEC-005 §3/§4, // SPEC-003 §7). These tests drive the real `/setup` gate end to end: // // * `setup: false`, no admin → LOCKED: `/` and `/setup` render an "enable // setup" boilerplate (never a bare 404), every other route 404, and the // wizard is not claimable; // * `setup: true`, no code → the wizard is served directly; // * `setup: true`, code set → the code form comes first (D-044), and the // correct code unlocks the wizard. // // The D-044 code mechanics (single-use · rate-limited · CSRF · redacted) are // exercised here too. import ( "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/setup" ) // proxyPeer / proxyXFF are a same-host reverse proxy relaying a remote client: // a loopback TCP peer that carries X-Forwarded-For. Under D-045 the peer no // longer decides setup access, but the proxy path is still the realistic one. const ( proxyPeer = "127.0.0.1:9999" proxyXFF = "203.0.113.9" ) // proxyGet issues a GET from behind the proxy. func (h *setupHarness) proxyGet(target string, cookies ...*http.Cookie) *httptest.ResponseRecorder { h.t.Helper() req := httptest.NewRequest("GET", target, nil) req.RemoteAddr = proxyPeer req.Header.Set("X-Forwarded-For", proxyXFF) for _, c := range cookies { req.AddCookie(c) } rec := httptest.NewRecorder() h.srv.ServeHTTP(rec, req) return rec } // proxyPost issues a wizard step from behind the proxy with a fresh pre-session // CSRF token and cookie (A15). func (h *setupHarness) proxyPost(action string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { h.t.Helper() cookie, token := h.preCSRF() if form == nil { form = url.Values{} } form.Set("csrf", token) req := httptest.NewRequest("POST", "/setup?do=setup&action="+url.QueryEscape(action), strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.RemoteAddr = proxyPeer req.Header.Set("X-Forwarded-For", proxyXFF) req.AddCookie(cookie) for _, c := range cookies { req.AddCookie(c) } rec := httptest.NewRecorder() h.srv.ServeHTTP(rec, req) return rec } // unlockCookie extracts the D-044 unlock cookie from a response. func unlockCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { t.Helper() for _, c := range rec.Result().Cookies() { if c.Name == setup.SetupUnlockCookieName && c.Value != "" { return c } } return nil } // ── state 2 · `setup: true` without a code (D-045) ─────────────────────────── // TestD045_NoCodeServesTheWizardDirectly: with `setup: true` and no // `setup_code`, `GET /setup` is the wizard for every visitor — no form, no // unlock. `GET /` points at it. func TestD045_NoCodeServesTheWizardDirectly(t *testing.T) { h := newSetupHarness(t) // default: no code required for name, rec := range map[string]*httptest.ResponseRecorder{ "loopback": request(h.srv, "GET", "/setup"), "proxied": h.proxyGet("/setup"), } { if rec.Code != http.StatusOK { t.Fatalf("GET /setup (%s) = %d, want 200 (the wizard)", name, rec.Code) } body := rec.Body.String() if !strings.Contains(body, "domain.save") { t.Errorf("GET /setup (%s) did not render the wizard (D-045)", name) } if strings.Contains(body, `action="/setup?do=setup&action=unlock"`) { t.Errorf("GET /setup (%s) showed the code form though no code is required (D-045)", name) } } // `GET /` is a landing pointing at the wizard — never a bare 404, never the // wizard itself. root := h.proxyGet("/") if root.Code != http.StatusOK { t.Fatalf("GET / = %d, want 200 (D-045)", root.Code) } if !strings.Contains(root.Body.String(), "/setup") { t.Error("GET / did not point at the wizard (D-045)") } } // ── state 2 · `setup: true` with a code (D-044) ────────────────────────────── // TestD045_CodeFormAndUnlock: with `setup_code` set, `GET /setup` shows the code // form; the correct code unlocks the wizard for that browser. func TestD045_CodeFormAndUnlock(t *testing.T) { h := newSetupHarness(t, withSetupCode("t3st-c0de-9999")) form := h.proxyGet("/setup") if form.Code != http.StatusOK { t.Fatalf("GET /setup with a code set = %d, want 200 (the form)", form.Code) } for _, want := range []string{ `action="/setup?do=setup&action=unlock"`, `name="code"`, "not set up yet", } { if !strings.Contains(form.Body.String(), want) { t.Errorf("the code form is missing %q", want) } } if strings.Contains(form.Body.String(), "domain.save") { t.Error("GET /setup served the wizard before the code (D-044)") } // The anonymous form carries the pre-session CSRF cookie (A15). if !hasCookie(form, httpx.PreSessionCookieName) { t.Error("the code form issued no pre-session CSRF cookie (A15)") } rec := h.proxyPost("unlock", url.Values{"code": {h.code}}) if rec.Code != http.StatusSeeOther { t.Fatalf("POST unlock with the correct code = %d, want 303 (D-044)", rec.Code) } if loc := rec.Header().Get("Location"); loc != "/setup" { t.Errorf("post-unlock Location = %q, want /setup (PRG)", loc) } c := unlockCookie(t, rec) if c == nil { t.Fatal("the unlock set no signed unlock cookie (D-044)") } if !c.HttpOnly { t.Error("the unlock cookie is not HttpOnly") } got := h.proxyGet("/setup", c) if got.Code != http.StatusOK { t.Fatalf("GET /setup after unlock = %d, want 200 (the wizard)", got.Code) } if !strings.Contains(got.Body.String(), "domain.save") { t.Error("GET /setup after unlock did not render the wizard (D-044)") } // A different browser (no unlock cookie) still gets the form. if other := h.proxyGet("/setup"); strings.Contains(other.Body.String(), "domain.save") { t.Error("an unlocked browser leaked the wizard to another visitor (D-044)") } } // ── a wrong/absent code is generic and rate-limited ────────────────────────── // TestD044_WrongOrAbsentCodeIsGeneric: neither a wrong nor an absent code // unlocks, and both get the SAME generic answer (no oracle, A3). func TestD044_WrongOrAbsentCodeIsGeneric(t *testing.T) { h := newSetupHarness(t, withSetupCode("t3st-c0de-9999")) wrong := h.proxyPost("unlock", url.Values{"code": {"nope-not-the-code"}}) absent := h.proxyPost("unlock", nil) for name, rec := range map[string]*httptest.ResponseRecorder{"wrong": wrong, "absent": absent} { if rec.Code != http.StatusForbidden { t.Errorf("%s code = %d, want 403 (generic, D-044)", name, rec.Code) } if unlockCookie(t, rec) != nil { t.Errorf("%s code set an unlock cookie (D-044)", name) } body := rec.Body.String() if strings.Contains(body, "domain.save") { t.Errorf("%s code reached the wizard (D-044)", name) } if !strings.Contains(body, "That setup code is not correct.") { t.Errorf("%s code did not get the generic notice (D-044)", name) } } if wrong.Code != absent.Code { t.Error("a wrong and an absent code gave different statuses (an oracle, A3)") } // The correct code still works afterwards — a failed attempt does not // consume the code (it retires only on SUCCESS). if rec := h.proxyPost("unlock", url.Values{"code": {h.code}}); rec.Code != http.StatusSeeOther { t.Errorf("the correct code after a failed attempt = %d, want 303", rec.Code) } } // TestD044_UnlockIsRateLimited: the setup route is rate-limited (A3/AC-9) — a // burst of attempts is answered 429, not a free oracle. func TestD044_UnlockIsRateLimited(t *testing.T) { h := newSetupHarness(t, withSetupCode("t3st-c0de-9999"), func(h *setupHarness) { h.rateLimit = 2 h.rateWindow = time.Minute }) for i := 0; i < 2; i++ { if rec := h.proxyPost("unlock", url.Values{"code": {"wrong"}}); rec.Code != http.StatusForbidden { t.Fatalf("attempt %d = %d, want 403", i+1, rec.Code) } } rec := h.proxyPost("unlock", url.Values{"code": {"wrong"}}) if rec.Code != http.StatusTooManyRequests { t.Fatalf("third attempt = %d, want 429 (A3)", rec.Code) } if rec.Header().Get("Retry-After") == "" { t.Error("429 is missing a Retry-After header (A3)") } } // TestD044_UnlockRequiresCSRF: the unlock is an anonymous form, so it carries // the pre-session double-submit token (A15) — a POST without it is refused. func TestD044_UnlockRequiresCSRF(t *testing.T) { h := newSetupHarness(t, withSetupCode("t3st-c0de-9999")) req := httptest.NewRequest("POST", "/setup?do=setup&action=unlock", strings.NewReader(url.Values{"code": {h.code}}.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.RemoteAddr = proxyPeer req.Header.Set("X-Forwarded-For", proxyXFF) rec := httptest.NewRecorder() h.srv.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Errorf("POST unlock without CSRF = %d, want 403 (A15)", rec.Code) } if unlockCookie(t, rec) != nil { t.Error("an unlock was granted without the CSRF token (A15)") } } // TestD044_CodeIsSingleUse: the code retires the moment it works — a second // browser cannot re-use the printed value. func TestD044_CodeIsSingleUse(t *testing.T) { h := newSetupHarness(t, withSetupCode("t3st-c0de-9999")) if rec := h.proxyPost("unlock", url.Values{"code": {h.code}}); rec.Code != http.StatusSeeOther { t.Fatalf("first unlock = %d, want 303", rec.Code) } // A fresh visitor (no unlock cookie) tries the same code again. again := h.proxyPost("unlock", url.Values{"code": {h.code}}) if again.Code != http.StatusForbidden { t.Errorf("re-use of the setup code = %d, want 403 (single-use, D-044)", again.Code) } if unlockCookie(t, again) != nil { t.Error("a spent setup code granted a second unlock (single-use, D-044)") } } // ── redaction (A8) ─────────────────────────────────────────────────────────── // TestD044_CodeIsRedactedFromTheAccessLog: an attempt that carries the code in // the QUERY (a hand-typed URL, say) must never leak it into the access log. func TestD044_CodeIsRedactedFromTheAccessLog(t *testing.T) { var logs strings.Builder h := newSetupHarness(t, withSetupCode("t3st-c0de-9999"), func(h *setupHarness) { h.logger = slog.New(slog.NewTextHandler(&logs, nil)) }) const secret = "SUPERSECRETSETUPCODE" rec := h.proxyGet("/setup?do=setup&action=unlock&code=" + secret) if rec.Code == 0 { t.Fatal("no response") } out := logs.String() if strings.Contains(out, secret) { t.Error("the access log leaked the setup code (A8)") } if !strings.Contains(strings.ToLower(out), "redacted") { t.Errorf("the access log did not mark the credential redaction (A8):\n%s", out) } } // ── the gate, unit-level ───────────────────────────────────────────────────── // TestD045_BlankCodeIsNotRequired: a blank `setup_code` means no code is // required, nothing is printed, and no unlock is possible. func TestD045_BlankCodeIsNotRequired(t *testing.T) { pre := mustPreSessionCSRF(t) gate, err := setup.NewGate("", pre, nil) if err != nil { t.Fatalf("NewGate: %v", err) } if gate.Required() { t.Error("a blank code must not be required (D-045)") } if gate.Code() != "" { t.Errorf("a blank code printed %q, want nothing (D-045)", gate.Code()) } if gate.Accept("anything") { t.Error("a non-required gate accepted a code") } if gate.Grant() != "" { t.Error("a non-required gate granted an unlock cookie") } } // TestD044_SetCodeIsRequiredAndRetires: a set `setup_code` is required, is // printed verbatim, ignores formatting, and is single-use. func TestD044_SetCodeIsRequiredAndRetires(t *testing.T) { pre := mustPreSessionCSRF(t) gate, err := setup.NewGate("9F3A-1B2C", pre, nil) if err != nil { t.Fatalf("NewGate: %v", err) } if !gate.Required() { t.Fatal("a set code must be required (D-045)") } if gate.Code() != "9F3A-1B2C" { t.Errorf("Code() = %q, want the configured value", gate.Code()) } if gate.Accept("nope") { t.Error("a wrong code was accepted") } if !gate.Accept("9f3a1b2c") { t.Error("the code with different case/spacing was not accepted") } if gate.Accept("9f3a1b2c") { t.Error("the code was accepted twice (single-use, D-044)") } } func mustPreSessionCSRF(t *testing.T) *httpx.PreSessionCSRF { t.Helper() pre, err := httpx.NewPreSessionCSRF([]byte("gate-test-cookie-secret-0123456789")) if err != nil { t.Fatalf("NewPreSessionCSRF: %v", err) } return pre }