NextMyDay/internal/core/server/setup_code_test.go
Claus Lohmar 6d421b61fd feat(setup): gate first run with a printed setup code and a first-run page
The real topology is an LXC container (11020) behind Proxmox + Zoraxy, so
the SSH tunnel is impractical: it lands on the container gateway, not
loopback. D-025 is re-revised and D-044 adds the general gate.

- core/setup: a Gate (gate.go) holding the 128-bit setup code (generated at
  first run, or pinned by config `setup_code`) plus a signed unlock cookie
  (`myday_setup`) that carries a keyed one-way FINGERPRINT of the code, never
  the code itself. The code is single-use (retired on the first success) and
  the comparison ignores case/separators.
- The unlock action (`POST /setup?do=setup&action=unlock`) is CSRF-protected
  (A15), rate-limited (the /setup A3 class) and audited; a wrong or absent
  code gets the same generic 403 page.
- The door (server.Deps.SetupGate): in setup mode the wizard is served to
  EITHER the direct tunnel (gate a) OR an unlocked request (gate b); `GET /`
  and an un-unlocked `GET /setup` render a friendly first-run page with the
  code form — never a bare 404 — and every other route stays 404.
- cmd/myday prints an unmistakable startup banner: setup mode, the wizard
  URL, the code, where to read it (journalctl / pct exec 11020) and the
  restart that adopts the persisted secrets.
- httpx.Signer.Fingerprint backs the unlock cookie; config gains `setup_code`.

Tests cover the boilerplate at `/` and `/setup` (200, not 404), the unlock
(code → wizard; wrong/absent → generic, 429 under the limit), single-use, the
CSRF requirement, access-log redaction, and that a non-/setup route is still
404. Gate green (build · vet · test -race); live-smoked against the binary.
2026-10-15 06:33:13 +00:00

364 lines
13 KiB
Go

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
}