NextMyDay/internal/core/server/setup_wizard_test.go
Claus Lohmar 34f8ddbcfe test(setup): pin the closed-window refusal on the TOTP step
Lock in the behaviour the fix introduces (docs/specs/spec-005-setup-wizard.md
§11 D-2): once the admin's bounded enrollment window has closed (SPEC-004 §4,
A1) `admin.totp.begin` answers a clean 409 and issues no secret, rather than
propagating the flow's "no pending identity" as a server fault.
2026-10-15 07:08:31 +00:00

735 lines
26 KiB
Go

package server_test
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
pqotp "github.com/pquerna/otp/totp"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/totp"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/setup"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// ── harness: a FRESH instance (no user) wired to the real SPEC-005 wizard ─────
type setupHarness struct {
t *testing.T
ctx context.Context
db *store.DB
users *users.Store
ids *auth.IdentitiesService
sessions *auth.SessionsService
csrf *httpx.CSRF
pre *httpx.PreSessionCSRF
wa *fakeWebAuthn
totpSvc *totp.Generator
cfg *config.Config
cfgPath string
prints *bytes.Buffer
// code is the OPTIONAL setup code (D-045). Empty (the default) means no
// code is required and the wizard is served directly; a test that exercises
// the code path sets it via withSetupCode.
code string
// writeFile overrides the persister (a failing writer exercises the
// "print the values" fallback, SPEC-005 §4).
writeFile func(string, []byte) error
// rateLimit / rateWindow tune the door's auth-sensitive limiter (A3).
rateLimit int
rateWindow time.Duration
// logger, when set, receives the door's logs (the access-log redaction test).
logger *slog.Logger
setupFlow *setup.Actions
srv *server.Server
}
func newSetupHarness(t *testing.T, opts ...func(*setupHarness)) *setupHarness {
t.Helper()
ctx := context.Background()
dir := t.TempDir()
db, err := store.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
aud := audit.New(db.DB)
us := users.New(db.DB, aud) // NO user created — a fresh instance.
sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x24}, auth.KeySize))
if err != nil {
t.Fatalf("NewAEADSealer: %v", err)
}
core := auth.New(db.DB, aud, auth.WithSealer(sealer))
ids := core.Identities()
sessions := core.Sessions()
const (
csrfKey = "setup-csrf-key-0123456789abcdef"
cookieSecret = "setup-cookie-secret-0123456789abcdef"
)
csrf, err := httpx.NewCSRF([]byte(csrfKey))
if err != nil {
t.Fatalf("NewCSRF: %v", err)
}
pre, err := httpx.NewPreSessionCSRF([]byte(cookieSecret))
if err != nil {
t.Fatalf("NewPreSessionCSRF: %v", err)
}
cfg := &config.Config{
Setup: true,
Server: config.Server{Listen: "127.0.0.1:8080"},
Database: config.Database{Path: db.Path()},
Secrets: config.Secrets{CSRFKey: csrfKey, CookieSecret: cookieSecret},
}
h := &setupHarness{
t: t, ctx: ctx, db: db, users: us, ids: ids, sessions: sessions,
csrf: csrf, pre: pre, wa: &fakeWebAuthn{}, totpSvc: totp.New("MyDay", ids),
cfg: cfg, cfgPath: filepath.Join(dir, config.FileName), prints: &bytes.Buffer{},
}
for _, o := range opts {
o(h)
}
// D-045: the three states. `setupMode` is the `setup` toggle, cleared by
// Done(); `locked` is `setup: false` and no user. `bootstrapped` is latched
// at startup (the DB is empty here).
bootstrapped := func() bool {
n, err := us.Count(context.Background())
return err == nil && n > 0
}()
inSetup := cfg.Setup
setupMode := func() bool {
if h.setupFlow != nil && h.setupFlow.Done() {
return false
}
return inSetup
}
locked := func() bool { return !cfg.Setup && !bootstrapped }
// The SPEC-006 flow, reused by the wizard. TOTPOnlyEnroll is the wizard's
// D-041 mode (setup enrolls TOTP only); SuccessPath keeps the operator on
// the wizard until it is finished.
authFlow, err := flow.New(flow.Deps{
Identities: ids, Credentials: ids, Sessions: sessions, Users: us,
WebAuthn: h.wa, TOTP: h.totpSvc, CSRF: pre, Audit: aud,
TOTPOnlyEnroll: true,
SuppressNudge: setupMode,
SuccessPath: func(string) string {
if setupMode() {
return "/setup"
}
return ""
},
})
if err != nil {
t.Fatalf("flow.New: %v", err)
}
setupFlow, err := setup.New(setup.Deps{
Config: cfg, ConfigPath: h.cfgPath, Users: us, Identities: ids,
Code: h.code,
Enroll: authFlow, Audit: aud, CSRF: pre,
Printout: h.prints, WriteFile: h.writeFile,
})
if err != nil {
t.Fatalf("setup.New: %v", err)
}
h.setupFlow = setupFlow
reg := server.NewMapRegistry()
reg.RegisterCore("home", server.Home)
reg.RegisterCore("access", server.AccessPlaceholder)
reg.RegisterModule(server.NewModule("home").
SetDefault("dashboard", server.Home).
AddRoute("home", "home", server.Home))
server.RegisterSetupFlow(reg, setupFlow)
srv, err := server.New(server.Deps{
Config: cfg, Resolver: ids, Registry: reg, Sessions: sessions,
Identities: ids, Users: us, Audit: aud, CSRF: csrf, PreSession: pre,
SetupMode: setupMode,
// D-045: the non-claimable locked state.
LockedMode: locked,
// D-044/D-045: the optional-code gate + the first-run pages.
SetupGate: setupFlow.Gate(),
RateLimit: h.rateLimit, RateWindow: h.rateWindow, Logger: h.logger,
})
if err != nil {
t.Fatalf("server.New: %v", err)
}
h.srv = srv
return h
}
// withSetupCode pins a required setup code for a harness (D-044/D-045).
func withSetupCode(code string) func(*setupHarness) {
return func(h *setupHarness) { h.code = code }
}
// preCSRF mints a signed pre-session token plus its cookie (A15).
func (h *setupHarness) preCSRF() (*http.Cookie, string) {
h.t.Helper()
tok, err := h.pre.Token()
if err != nil {
h.t.Fatalf("pre-session Token: %v", err)
}
return &http.Cookie{Name: httpx.PreSessionCookieName, Value: tok}, tok
}
// post issues a wizard step with a fresh pre-session CSRF token.
func (h *setupHarness) post(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")
// The peer no longer decides setup access (D-045); the request is a plain
// wizard step over loopback.
req.RemoteAddr = loopbackPeer
req.AddCookie(cookie)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.srv.ServeHTTP(rec, req)
return rec
}
// beginAdmin runs steps 1 and 2a: the domain, then the admin's details.
func (h *setupHarness) beginAdmin(t *testing.T) {
t.Helper()
if rec := h.post("domain.save", url.Values{"base_url": {"http://127.0.0.1:8080"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("domain.save = %d, want 303", rec.Code)
}
if rec := h.post("admin.create", url.Values{
"display_name": {"Claus"}, "email": {"admin@example.com"},
}); rec.Code != http.StatusSeeOther {
t.Fatalf("admin.create = %d, want 303", rec.Code)
}
}
// enrollAdmin runs step 2b — the TOTP-only ceremony (D-041) — and returns the
// session id the flow minted on completion (SPEC-006 reused).
//
// The `begin` response must be the wizard's server-rendered TOTP PAGE, not the
// enroll flow's JSON API (`{"otpauth_url":…}` — that is webauthn.js's contract):
// the browser must never be shown raw JSON. This is the regression the fix
// guards against, so it is asserted here (content type + secret + verify form).
func (h *setupHarness) enrollAdmin(t *testing.T) string {
t.Helper()
begin := h.post("admin.totp.begin", nil)
if begin.Code != http.StatusOK {
t.Fatalf("admin.totp.begin = %d, want 200", begin.Code)
}
if ct := begin.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("admin.totp.begin Content-Type = %q, want text/html (the wizard renders a page, not JSON)", ct)
}
body := begin.Body.String()
if strings.Contains(body, `"otpauth_url"`) {
t.Error("admin.totp.begin answered with the enroll flow's JSON, not the wizard's TOTP page")
}
if !strings.Contains(body, "otpauth://") {
t.Error("the TOTP page did not show the otpauth URL")
}
if !strings.Contains(body, "admin.totp.verify") {
t.Error("the TOTP page did not render the verify form")
}
secret, err := h.ids.TOTPSecret(h.ctx, 1)
if err != nil {
t.Fatalf("TOTPSecret: %v", err)
}
if !strings.Contains(body, string(secret)) {
t.Error("the TOTP page did not show the issued secret")
}
code, err := pqotp.GenerateCode(string(secret), time.Now())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
done := h.post("admin.totp.verify", url.Values{"code": {code}})
if done.Code != http.StatusSeeOther {
t.Fatalf("admin.totp.verify = %d, want 303 (enrolled)", done.Code)
}
// The wizard reuses the flow's SuccessPath so the admin stays on /setup.
if loc := done.Header().Get("Location"); loc != "/setup" {
t.Errorf("post-enroll Location = %q, want /setup", loc)
}
return sessionCookie(t, done)
}
// ── AC-1 ─────────────────────────────────────────────────────────────────────
// TestSpec005AC1_SetupModeServesOnlySetup covers D-045 state 2 (`setup: true`):
// `GET /setup` serves the wizard (no code is required by default), `GET /`
// points at it, and every other route is 404.
func TestSpec005AC1_SetupModeServesOnlySetup(t *testing.T) {
h := newSetupHarness(t)
// `setup: true` → `GET /setup` is the wizard, from any peer (there is no
// tunnel privilege any more, D-045).
rec := request(h.srv, "GET", "/setup")
if rec.Code != http.StatusOK {
t.Fatalf("GET /setup on a fresh instance = %d, want 200 (AC-1)", rec.Code)
}
if !strings.Contains(rec.Body.String(), "MyDay setup") {
t.Error("GET /setup did not render the wizard (AC-1)")
}
// The wizard issues the pre-session CSRF cookie (A15).
if !hasCookie(rec, httpx.PreSessionCookieName) {
t.Error("the wizard issued no pre-session CSRF cookie (A15)")
}
// `GET /` is a friendly page pointing at the wizard — never the app.
root := request(h.srv, "GET", "/")
if root.Code != http.StatusOK {
t.Errorf("GET / while setup is enabled = %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)")
}
if strings.Contains(root.Body.String(), "domain.save") {
t.Error("GET / served the wizard itself; it should point at /setup (D-045)")
}
// EVERY other route is 404 — including the app the productive instance
// would serve (/home, /access, GUID branches).
for _, p := range []string{"/home", "/access", "/holy-shit", "/some-guid", "/home/extra"} {
if rec := request(h.srv, "GET", p); rec.Code != http.StatusNotFound {
t.Errorf("GET %s in setup mode = %d, want 404 (AC-1)", p, rec.Code)
}
}
}
// ── R-6 · generated secrets ──────────────────────────────────────────────────
// TestR6_WizardGeneratesStrongSecrets: a first boot from config.example.yaml
// (all secrets are placeholders) must persist a VIABLE productive config — the
// wizard generates strong replacements (R-6), and the result reloads through
// the strict loader.
func TestR6_WizardGeneratesStrongSecrets(t *testing.T) {
h := newSetupHarness(t, func(h *setupHarness) {
h.cfg.Secrets = config.Secrets{
CookieSecret: "CHANGE_ME_32_BYTES_RANDOM",
CSRFKey: "CHANGE_ME_32_BYTES_RANDOM",
TOTPEncryptionKey: "CHANGE_ME_32_BYTES_RANDOM",
}
})
h.beginAdmin(t)
h.enrollAdmin(t)
if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("finish = %d, want 303", rec.Code)
}
data, err := os.ReadFile(h.cfgPath)
if err != nil {
t.Fatalf("config was not persisted: %v", err)
}
if strings.Contains(string(data), "CHANGE_ME") {
t.Error("the persisted config still holds a placeholder secret (R-6)")
}
cfg, err := config.Load(h.cfgPath)
if err != nil {
t.Fatalf("the generated config does not load (R-6): %v", err)
}
for name, v := range map[string]string{
"cookie_secret": cfg.Secrets.CookieSecret,
"csrf_key": cfg.Secrets.CSRFKey,
"totp_encryption_key": cfg.Secrets.TOTPEncryptionKey,
} {
if config.SecretIsPlaceholder(v) {
t.Errorf("%s is still a placeholder after setup (R-6)", name)
}
if len(v) < 32 {
t.Errorf("%s is only %d bytes, want >= 32 (R-6)", name, len(v))
}
}
// The TOTP key feeds AES-256-GCM: exactly 32 bytes.
if n := len(cfg.Secrets.TOTPEncryptionKey); n != 32 {
t.Errorf("totp_encryption_key = %d bytes, want exactly 32 (R-6)", n)
}
}
// ── AC-2 ─────────────────────────────────────────────────────────────────────
// TestSpec005AC2_WizardRequiresTheCodeWhenSet covers D-044 + D-045: with
// `setup_code` SET, `GET /setup` shows the code form from ANY peer (the tunnel
// gate is retired); only an unlocked browser reaches the wizard, and a wrong
// code does not unlock.
func TestSpec005AC2_WizardRequiresTheCodeWhenSet(t *testing.T) {
h := newSetupHarness(t, withSetupCode("t3st-c0de-9999"))
for name, peer := range map[string]string{
"loopback peer": loopbackPeer,
"remote peer": "203.0.113.9:4444",
} {
req := httptest.NewRequest("GET", "/setup", nil)
req.RemoteAddr = peer
rec := httptest.NewRecorder()
h.srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET /setup (%s) = %d, want 200 (the code form)", name, rec.Code)
}
if strings.Contains(rec.Body.String(), "domain.save") {
t.Errorf("GET /setup (%s) reached the wizard without the code (D-045)", name)
}
if !strings.Contains(rec.Body.String(), "name=\"code\"") {
t.Errorf("GET /setup (%s) did not show the code form (D-044)", name)
}
}
// Unlock per browser → the wizard; another browser still gets the form.
rec := h.proxyPost("unlock", url.Values{"code": {h.code}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("unlock = %d, want 303 (D-044)", rec.Code)
}
unlock := unlockCookie(t, rec)
if unlock == nil {
t.Fatal("the correct code set no unlock cookie (D-044)")
}
got := h.proxyGet("/setup", unlock)
if got.Code != http.StatusOK || !strings.Contains(got.Body.String(), "domain.save") {
t.Errorf("GET /setup after unlock = %d, want the wizard", got.Code)
}
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 code does not unlock.
if wrong := h.proxyPost("unlock", url.Values{"code": {"nope"}}); wrong.Code != http.StatusForbidden {
t.Errorf("a wrong code = %d, want 403 (D-044)", wrong.Code)
}
}
// ── AC-3 ─────────────────────────────────────────────────────────────────────
func TestSpec005AC3_CompletingTheWizardCreatesTheTopAdmin(t *testing.T) {
h := newSetupHarness(t)
h.beginAdmin(t)
// Step 2b renders the TOTP-only enrollment ceremony (D-041) — the passkey
// is deliberately NOT offered here.
page := request(h.srv, "GET", "/setup")
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "admin.totp.verify") {
t.Errorf("the enrollment step did not render the TOTP form (AC-3): %d", page.Code)
}
if strings.Contains(page.Body.String(), "admin.enroll.begin") {
t.Error("the wizard still offers passkey enrollment (D-041, AC-3)")
}
h.enrollAdmin(t)
u, err := h.users.Get(h.ctx, 1)
if err != nil {
t.Fatalf("user 1 was not created: %v", err)
}
if u.Role != users.RoleAdmin {
t.Errorf("user 1 role = %q, want admin (AC-3)", u.Role)
}
if u.Manager.Set {
t.Error("the top admin must be the root of the chain (no manager, AC-3)")
}
idn, err := h.ids.ByUserID(h.ctx, 1)
if err != nil {
t.Fatalf("ByUserID(1): %v", err)
}
if idn.Status != auth.StatusActive {
t.Errorf("the admin's identity = %s, want active (AC-3)", idn.Status)
}
// D-041/AC-3: TOTP is enrolled, and NO passkey is — the admin adds one
// later at first sign-in on the real origin, via the nudge (SPEC-006 §8).
if hasPasskey, err := h.ids.HasWebAuthnCredential(h.ctx, idn.ID); err != nil || hasPasskey {
t.Errorf("the wizard enrolled a passkey (D-041, AC-3): %v", err)
}
secret, err := h.ids.TOTPSecret(h.ctx, idn.ID)
if err != nil || len(secret) == 0 {
t.Errorf("the admin has no TOTP secret (AC-3): %v", err)
}
}
// ── AC-4 ─────────────────────────────────────────────────────────────────────
func TestSpec005AC4_CompletionPersistsAndSetupDisappears(t *testing.T) {
h := newSetupHarness(t)
h.beginAdmin(t)
h.enrollAdmin(t)
rec := h.post("finish", url.Values{
"mail.host": {"smtp.example.com"}, "mail.port": {"587"},
"mail.username": {"myday"}, "mail.from": {"myday@example.com"},
"app.locale": {"en-GB"}, "app.timezone": {"Europe/London"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("finish = %d, want 303", rec.Code)
}
if !h.setupFlow.Done() {
t.Error("the wizard did not signal completion (AC-4)")
}
// The settings were persisted to config.yaml (D-035).
data, err := os.ReadFile(h.cfgPath)
if err != nil {
t.Fatalf("config was not persisted to %s: %v", h.cfgPath, err)
}
got := string(data)
for _, want := range []string{
"setup: false",
"base_url: http://127.0.0.1:8080",
"locale: en-GB",
"timezone: Europe/London",
"smtp.example.com",
} {
if !strings.Contains(got, want) {
t.Errorf("persisted config is missing %q (AC-4)", want)
}
}
// /setup now 404s — through BOTH doors (delete, don't redirect).
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusNotFound {
t.Errorf("GET /setup after completion = %d, want 404 (AC-4)", rec.Code)
}
if rec := h.post("finish", nil); rec.Code != http.StatusNotFound {
t.Errorf("POST /setup action after completion = %d, want 404 (AC-4)", rec.Code)
}
}
// TestSpec005AC4_UnwritableConfigPrintsTheValues: when config.yaml is not
// writable the wizard prints the settings for the operator and still completes.
func TestSpec005AC4_UnwritableConfigPrintsTheValues(t *testing.T) {
h := newSetupHarness(t, func(h *setupHarness) {
h.writeFile = func(string, []byte) error { return errors.New("read-only file system") }
})
h.beginAdmin(t)
h.enrollAdmin(t)
if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("finish = %d, want 303", rec.Code)
}
if !h.setupFlow.Done() {
t.Error("setup was not marked complete despite the printed fallback (AC-4)")
}
if !strings.Contains(h.prints.String(), "setup: false") {
t.Error("the values were not printed for the operator (AC-4)")
}
if _, err := os.Stat(h.cfgPath); !errors.Is(err, os.ErrNotExist) {
t.Error("a config file was written even though the writer failed (AC-4)")
}
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusNotFound {
t.Errorf("GET /setup after the printed completion = %d, want 404 (AC-4)", rec.Code)
}
}
// ── AC-5 ─────────────────────────────────────────────────────────────────────
func TestSpec005AC5_OnlyOneAdmin(t *testing.T) {
h := newSetupHarness(t)
h.beginAdmin(t) // creates user 1
rec := h.post("admin.create", url.Values{
"display_name": {"Second"}, "email": {"second@example.com"},
})
if rec.Code != http.StatusConflict {
t.Errorf("a second admin.create = %d, want 409 (AC-5)", rec.Code)
}
// The service itself refuses a second admin, whoever asks (SPEC-004 AC-6).
if _, err := h.users.Create(h.ctx, 1, "third@example.com", "Third", users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) {
t.Errorf("Create(role=admin) with user 1 present = %v, want ErrOneAdmin (AC-5)", err)
}
n, err := h.users.Count(h.ctx)
if err != nil {
t.Fatalf("Count: %v", err)
}
if n != 1 {
t.Errorf("user count = %d, want exactly 1 (AC-5)", n)
}
}
// ── AC-6 ─────────────────────────────────────────────────────────────────────
func TestSpec005AC6_TheBootstrapIsAudited(t *testing.T) {
h := newSetupHarness(t)
h.beginAdmin(t)
h.enrollAdmin(t)
if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("finish = %d, want 303", rec.Code)
}
rows, err := h.db.Query(`SELECT action FROM audit_log`)
if err != nil {
t.Fatalf("read audit_log: %v", err)
}
defer rows.Close()
got := map[string]bool{}
for rows.Next() {
var a string
if err := rows.Scan(&a); err != nil {
t.Fatalf("scan audit action: %v", err)
}
got[a] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate audit_log: %v", err)
}
// The whole bootstrap: the admin's creation, its identity, the TOTP
// enrollment (D-041: no passkey), and the completion of setup (AC-6).
for _, want := range []string{
"user.create", "identity.mint",
"enroll.totp.begin", "enroll.complete",
"setup.admin", "setup.complete",
} {
if !got[want] {
t.Errorf("audit log is missing %q (AC-6); got %v", want, got)
}
}
}
// TestSpec005AdminCreateRepairsMissingIdentity: a crash between Create and Mint
// must not wedge the wizard — the next admin.create mints the missing identity.
func TestSpec005AdminCreateRepairsMissingIdentity(t *testing.T) {
h := newSetupHarness(t)
// The user exists (user 1) but has NO identity.
if _, err := h.users.Create(h.ctx, 0, "admin@example.com", "Claus", users.RoleAdmin); err != nil {
t.Fatalf("create admin: %v", err)
}
if _, err := h.ids.ByUserID(h.ctx, 1); !errors.Is(err, auth.ErrIdentityNotFound) {
t.Fatalf("precondition: identity exists already (%v)", err)
}
rec := h.post("admin.create", url.Values{"display_name": {"Claus"}, "email": {"admin@example.com"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("admin.create (repair) = %d, want 303", rec.Code)
}
idn, err := h.ids.ByUserID(h.ctx, 1)
if err != nil {
t.Fatalf("the admin identity was not repaired: %v", err)
}
if idn.Status != auth.StatusPendingEnrollment {
t.Errorf("repaired identity = %s, want pending_enrollment", idn.Status)
}
}
// TestSpec005LockedState: `setup: false` with no admin is the LOCKED state
// (D-045): `/` and `/setup` render the "enable setup" boilerplate (never a bare
// 404), every other route is 404, and the wizard is unreachable — the state is
// deliberately not claimable.
func TestSpec005LockedState(t *testing.T) {
h := newSetupHarness(t, func(h *setupHarness) { h.cfg.Setup = false })
for _, p := range []string{"/", "/setup"} {
rec := request(h.srv, "GET", p)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s locked = %d, want 200 (the boilerplate, never a bare 404)", p, rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "setup: true") || !strings.Contains(body, "config.yaml") {
t.Errorf("GET %s did not render the locked boilerplate (D-045)", p)
}
if strings.Contains(body, "domain.save") || strings.Contains(body, `action="/setup?do=setup`) {
t.Errorf("GET %s exposed the wizard or the code form while locked (D-045)", p)
}
}
for _, p := range []string{"/home", "/access", "/some-guid", "/some-guid/home"} {
if rec := request(h.srv, "GET", p); rec.Code != http.StatusNotFound {
t.Errorf("GET %s locked = %d, want 404 (D-045)", p, rec.Code)
}
}
// Not claimable: the wizard's own action 404s while locked.
if rec := h.post("unlock", url.Values{"code": {"anything"}}); rec.Code != http.StatusNotFound {
t.Errorf("POST unlock while locked = %d, want 404 (not claimable, D-045)", rec.Code)
}
}
// TestSpec005SetupHoldsMidWizard: once the admin row exists the wizard must stay
// reachable until `finish`. The door must not fall out of setup mode on a live
// "a user exists" check (D-045: setup mode is the toggle; completion is the
// in-process Done signal).
func TestSpec005SetupHoldsMidWizard(t *testing.T) {
h := newSetupHarness(t)
h.beginAdmin(t) // creates user 1 (pending enrollment)
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK {
t.Fatalf("GET /setup after admin.create = %d, want 200 (setup must hold mid-wizard)", rec.Code)
}
if rec := request(h.srv, "GET", "/home"); rec.Code != http.StatusNotFound {
t.Fatalf("GET /home after admin.create = %d, want 404 (setup must hold mid-wizard)", rec.Code)
}
h.enrollAdmin(t)
// Finish ends setup mode: /setup 404s and the app serves normally.
if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("finish = %d, want 303", rec.Code)
}
if !h.setupFlow.Done() {
t.Error("the wizard did not signal completion")
}
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusNotFound {
t.Errorf("GET /setup after finish = %d, want 404 (D-045)", rec.Code)
}
}
// TestSpec005TOTPBeginRefusesAClosedWindow: the wizard's TOTP step must refuse
// once the admin's bounded enrollment window has closed (SPEC-004 §4, A1). It
// must not issue a secret, and the refusal must be a clean 4xx — not the
// server fault a bare propagation of the flow's "no pending identity" would be.
func TestSpec005TOTPBeginRefusesAClosedWindow(t *testing.T) {
h := newSetupHarness(t)
h.beginAdmin(t)
// Backdate the identity so it sits comfortably outside the bounded window.
if _, err := h.db.Exec(
`UPDATE identities SET created_at = ? WHERE user_id = ?`,
store.FormatTime(time.Now().Add(-365*24*time.Hour)), 1,
); err != nil {
t.Fatalf("backdate the identity: %v", err)
}
if inWindow, err := h.ids.WithinEnrollmentWindow(h.ctx, 1); err != nil || inWindow {
t.Fatalf("precondition: the window is still open (inWindow=%v err=%v)", inWindow, err)
}
rec := h.post("admin.totp.begin", nil)
if rec.Code != http.StatusConflict {
t.Fatalf("admin.totp.begin past the window = %d, want 409 (A1)", rec.Code)
}
if _, err := h.ids.TOTPSecret(h.ctx, 1); err == nil {
t.Error("the wizard issued a TOTP secret for an identity outside its window (A1)")
}
}