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).
356 lines
12 KiB
Go
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
|
|
}
|