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).
684 lines
24 KiB
Go
684 lines
24 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).
|
|
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 ─────────────────────────────────────────────────────────────────────
|
|
|
|
// 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)
|
|
}
|
|
}
|