NextMyDay/internal/core/server/setup_code_test.go
Claus Lohmar c7229e23ff feat(setup): gate setup with the config toggle and an optional code
D-045 — the setup gate is now `config.setup`, not the peer/SSH tunnel:

- setup: true      -> /setup serves the wizard (the code form first when
                      `setup_code` is set, via the existing D-044 gate);
                      GET / points at the wizard; every other route -> 404.
- setup: false + no admin -> LOCKED: GET / and /setup render an "enable
                      setup" boilerplate; every other route -> 404; the
                      wizard is not claimable.
- completion       -> writes `setup: false` and creates the admin.

`setup_code` is now OPTIONAL: blank => Gate.Required() false => the toggle
alone admits; set => required (reusing the unlock cookie/fingerprint/
redaction/rate-limit). The peer/tunnel gate is retired: `httpx.
SetupRequestAllowed` and `httpx.IsLoopbackIP` are deleted; the XFF/XFP
handling is kept for the client IP and the Secure/__Host- cookies + HSTS
(A6/A11/D-043). The !bootstrapped() fail-safe becomes the locked predicate,
latched at startup.

New door seams: Deps.LockedMode and SetupGate.{Required,CodeForm,Landing,
Locked}; the Gate renders three first-run pages. The startup banner states
the mode and prints the code only when required.

Tests cover the three states, the boilerplate, the code optionality and the
unlock mechanics; live-smoked end to end (locked / wizard / code form /
full bootstrap -> setup: false).
2026-10-15 06:47:33 +00:00

356 lines
12 KiB
Go

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
}