package server_test // D-044 — the first-run SETUP CODE and the boilerplate (SPEC-005 §3/§4, // SPEC-003 §7 / AC-14). These tests drive the real `/setup` gate end to end: // `GET /` and `GET /setup` must never be a bare 404, the correct code unlocks // the wizard, a wrong code is a generic (rate-limited) answer, and every other // route stays 404 until setup completes. 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 — i.e. NOT the tunnel. const ( proxyPeer = "127.0.0.1:9999" proxyXFF = "203.0.113.9" ) // proxyGet issues a GET from behind the proxy (gate a does not apply). 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 } // ── the boilerplate (never a bare 404) ─────────────────────────────────────── // TestD044_BoilerplateAtRootAndSetup: in setup mode, without the tunnel, `GET /` // and `GET /setup` render the friendly first-run page with the setup-code form. func TestD044_BoilerplateAtRootAndSetup(t *testing.T) { h := newSetupHarness(t) for _, target := range []string{"/", "/setup"} { t.Run(target, func(t *testing.T) { rec := h.proxyGet(target) if rec.Code != http.StatusOK { t.Fatalf("GET %s = %d, want 200 — never a bare 404 (D-044)", target, rec.Code) } body := rec.Body.String() for _, want := range []string{ "MyDay is not set up yet", `action="/setup?do=setup&action=unlock"`, `name="code"`, "journalctl -u myday", "pct exec 11020", } { if !strings.Contains(body, want) { t.Errorf("GET %s boilerplate is missing %q", target, want) } } // It must not be the wizard, and it must not be a bare 404 body. if strings.Contains(body, "domain.save") { t.Errorf("GET %s served the WIZARD without the code/tunnel (D-044)", target) } // The anonymous form carries the pre-session CSRF cookie (A15). if !hasCookie(rec, httpx.PreSessionCookieName) { t.Errorf("GET %s issued no pre-session CSRF cookie (A15)", target) } }) } } // TestD044_OtherRoutesStillNotFound: in setup mode, before the unlock, every // route other than `/` and `/setup` is 404 — the bootstrap has no app yet. func TestD044_OtherRoutesStillNotFound(t *testing.T) { h := newSetupHarness(t) // A GUID branch: the resolver does not even know this name. for _, target := range []string{"/home", "/access", "/some-guid", "/some-guid/home"} { if rec := h.proxyGet(target); rec.Code != http.StatusNotFound { t.Errorf("GET %s in setup mode = %d, want 404 (SPEC-005 §3)", target, rec.Code) } } // The wizard's own steps are NOT steppable without the unlock. if rec := h.proxyPost("domain.save", url.Values{"base_url": {"https://example.com"}}); rec.Code != http.StatusNotFound { t.Errorf("POST domain.save before unlock = %d, want 404 (D-044)", rec.Code) } } // ── unlock ─────────────────────────────────────────────────────────────────── // TestD044_CorrectCodeUnlocksTheWizard: the correct code unlocks the wizard for // that browser (a signed flag cookie, not a session), and `GET /setup` then // renders the wizard. func TestD044_CorrectCodeUnlocksTheWizard(t *testing.T) { h := newSetupHarness(t) 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") } // The wizard is now served to this browser over the proxy. got := h.proxyGet("/setup", c) if got.Code != http.StatusOK { t.Fatalf("GET /setup after unlock = %d, want 200 (the wizard)", got.Code) } body := got.Body.String() if !strings.Contains(body, "MyDay setup") || !strings.Contains(body, "domain.save") { t.Errorf("GET /setup after unlock did not render the wizard (D-044)") } // A different browser (no unlock cookie) still gets the boilerplate. 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)") } } // TestD044_TunnelNeedsNoCode: the direct SSH tunnel (gate a) still opens the // wizard without any code (D-025). func TestD044_TunnelNeedsNoCode(t *testing.T) { h := newSetupHarness(t) rec := request(h.srv, "GET", "/setup") // loopback peer, no XFF — the tunnel if rec.Code != http.StatusOK { t.Fatalf("GET /setup over the tunnel = %d, want 200 (D-025 gate a)", rec.Code) } if !strings.Contains(rec.Body.String(), "domain.save") { t.Error("the tunnel did not reach the wizard without a code (D-025)") } } // ── 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) wrong := h.proxyPost("unlock", url.Values{"code": {"nope-not-the-code"}}) absent := h.proxyPost("unlock", nil) // Both get the SAME generic answer — no oracle (A3). 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, 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) 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) 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, 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 ───────────────────────────────────────────────────── // TestD044_GateGeneratesA128BitCode: a blank code generates 128 bits of // entropy, and the generated value is what the banner prints. func TestD044_GateGeneratesA128BitCode(t *testing.T) { pre := mustPreSessionCSRF(t) gate, err := setup.NewGate("", pre, nil) if err != nil { t.Fatalf("NewGate: %v", err) } code := gate.Code() if code == "" { t.Fatal("NewGate generated no code") } // 16 bytes → 32 hex chars, plus the visual grouping dashes. plain := strings.ReplaceAll(code, "-", "") if len(plain) != setup.CodeBytes*2 { t.Errorf("generated code %q has %d hex chars, want %d (128 bits)", code, len(plain), setup.CodeBytes*2) } // A second gate generates a DIFFERENT code (fresh entropy each boot). other, err := setup.NewGate("", pre, nil) if err != nil { t.Fatalf("NewGate: %v", err) } if other.Code() == code { t.Error("two gates generated the same code") } } // TestD044_GateAcceptsFormattingAndRetires: the comparison ignores case and // separators, and the accepted code is spent. func TestD044_GateAcceptsFormattingAndRetires(t *testing.T) { pre := mustPreSessionCSRF(t) gate, err := setup.NewGate("9F3A-1B2C", pre, nil) if err != nil { t.Fatalf("NewGate: %v", err) } 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 }