NextMyDay/internal/core/server/setup_wizard_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

646 lines
23 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 D-044 setup code the harness pins (so tests know it).
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{},
code: "test-setup-code-1234",
}
for _, o := range opts {
o(h)
}
// Setup mode (SPEC-005 §3), latched: config.setup OR not yet bootstrapped.
// Latched at startup so the wizard can finish in-process; cleared by Done().
bootstrapped := func() bool {
n, err := us.Count(context.Background())
if err != nil || n == 0 {
return false
}
idn, err := ids.ByUserID(context.Background(), 1)
if err != nil {
return false
}
return idn.Status == auth.StatusActive
}
inSetup := cfg.Setup || !bootstrapped()
setupMode := func() bool {
if h.setupFlow != nil && h.setupFlow.Done() {
return false
}
return inSetup
}
// 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-044: the first-run gate (the setup code + the boilerplate).
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
}
// 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 wizard is tunnel-only per request (SPEC-003 §7, D-025): the operator
// reaches it over an SSH tunnel, so the tests speak from loopback with no XFF.
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).
func (h *setupHarness) enrollAdmin(t *testing.T) string {
t.Helper()
if rec := h.post("admin.totp.begin", nil); rec.Code != http.StatusOK {
t.Fatalf("admin.totp.begin = %d, want 200", rec.Code)
}
secret, err := h.ids.TOTPSecret(h.ctx, 1)
if err != nil {
t.Fatalf("TOTPSecret: %v", err)
}
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 ─────────────────────────────────────────────────────────────────────
func TestSpec005AC1_SetupModeServesOnlySetup(t *testing.T) {
h := newSetupHarness(t)
// Over the direct tunnel (gate a) `GET /setup` is the wizard.
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)")
}
// D-044: without the tunnel, `GET /` and `GET /setup` show the friendly
// first-run boilerplate — NEVER a bare 404. (AC-14; see setup_code_test.go
// for the gate itself.)
proxyGET := func(target string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", target, nil)
req.RemoteAddr = "127.0.0.1:9999"
req.Header.Set("X-Forwarded-For", "203.0.113.9")
out := httptest.NewRecorder()
h.srv.ServeHTTP(out, req)
return out
}
for _, p := range []string{"/", "/setup"} {
got := proxyGET(p)
if got.Code != http.StatusOK {
t.Errorf("GET %s before unlock = %d, want 200 (the first-run page, not a 404/D-044)", p, got.Code)
}
if !strings.Contains(got.Body.String(), "not set up yet") {
t.Errorf("GET %s did not render the first-run boilerplate (D-044)", p)
}
}
// EVERY other route is 404 — including the app the productive instance
// would serve (/home, /access, GUID branches) and the canonical redirect.
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_ServedOnlyOverTheTunnel covers D-025 (2026-10-15b) + D-044:
// there is no bind refusal (any address may be bound, D-043) — the WIZARD is
// protected per request by EITHER the direct SSH tunnel (a loopback TCP peer
// with no X-Forwarded-For) OR the setup code. A remote peer, and a same-host
// proxy relaying a client, get the first-run boilerplate — never the wizard.
func TestSpec005AC2_ServedOnlyOverTheTunnel(t *testing.T) {
h := newSetupHarness(t)
// The tunnel reaches the wizard.
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK ||
!strings.Contains(rec.Body.String(), "domain.save") {
t.Errorf("GET /setup over the tunnel did not reach the wizard (AC-2)")
}
// A remote peer, and a same-host proxy relaying a client, are both refused
// the wizard — they get the friendly first-run page (D-044).
remote := httptest.NewRequest("GET", "/setup", nil)
remote.RemoteAddr = "203.0.113.9:4444"
rec := httptest.NewRecorder()
h.srv.ServeHTTP(rec, remote)
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "domain.save") {
t.Errorf("GET /setup from a remote peer = %d, want the boilerplate, never the wizard (AC-2)", rec.Code)
}
proxied := httptest.NewRequest("GET", "/setup", nil)
proxied.RemoteAddr = "127.0.0.1:9999"
proxied.Header.Set("X-Forwarded-For", "203.0.113.9")
rec = httptest.NewRecorder()
h.srv.ServeHTTP(rec, proxied)
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "domain.save") {
t.Errorf("GET /setup relayed by a same-host proxy = %d, want the boilerplate, never the wizard (AC-2)", rec.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)
}
}
// TestSpec005FailSafeSetupMode: with `config.setup: false` and an empty
// database the instance is STILL in setup mode (fail-safe) — and it stays there
// until the wizard finishes, even though creating the admin makes a live
// "no user exists" check false.
func TestSpec005FailSafeSetupMode(t *testing.T) {
h := newSetupHarness(t, func(h *setupHarness) { h.cfg.Setup = false })
// Fresh: setup mode via the fail-safe clause.
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK {
t.Fatalf("GET /setup (fail-safe) = %d, want 200", rec.Code)
}
if rec := request(h.srv, "GET", "/home"); rec.Code != http.StatusNotFound {
t.Fatalf("GET /home (fail-safe) = %d, want 404", rec.Code)
}
// Create the admin (now a user exists) and enroll — setup mode must HOLD.
h.beginAdmin(t)
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.
if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("finish = %d, want 303", rec.Code)
}
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusNotFound {
t.Errorf("GET /setup after finish = %d, want 404", rec.Code)
}
}