package server_test import ( "bytes" "context" "errors" "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 // writeFile overrides the persister (a failing writer exercises the // "print the values" fallback, SPEC-005 §4). writeFile func(string, []byte) error 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) } // 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, 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, }) 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) 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)") } // EVERY other route is 404 — including the app the productive instance // would serve (/home, /access, the canonical redirect, 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_ServedOnlyOverTheTunnel covers D-025 (revised): there is no // bind refusal (any address may be bound, D-043) — the bootstrap is protected // per request: in setup mode only the direct SSH tunnel (a loopback TCP peer // with no X-Forwarded-For) is served. func TestSpec005AC2_ServedOnlyOverTheTunnel(t *testing.T) { h := newSetupHarness(t) // The tunnel works. if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK { t.Errorf("GET /setup over the tunnel = %d, want 200 (AC-2)", rec.Code) } // A remote peer, and a same-host proxy relaying a client, are both refused. 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.StatusNotFound { t.Errorf("GET /setup from a remote peer = %d, want 404 (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.StatusNotFound { t.Errorf("GET /setup relayed by a same-host proxy = %d, want 404 (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) } }