Implements D-025 (revised 2026-10-15) and D-043. Setup gate (D-025): - Remove the bind refusal (CheckSetupBind/ErrNonLoopbackSetup/server.IsLoopback); Serve binds any address, so the app can listen on 0.0.0.0:8080 behind Zoraxy. - The gate is now per request and strict (httpx.SetupRequestAllowed): a loopback TCP peer AND no X-Forwarded-For — the direct SSH tunnel. Anything else is 404 before dispatch; an unparseable peer fails closed (AC-7/AC-10). The raw XFF is read before ClientIP SETS it (A6). Proxy scheme (D-043): - httpx.RequestIsHTTPS resolves the scheme per request: direct TLS, or a trusted proxy's X-Forwarded-Proto. The door carries it on the request context (httpx.WithSecure) so the session cookie, its __Host- prefix, the pre-session and ceremony cookies, and HSTS all agree (AC-13). - Over plain http (the tunnel) cookies stay bare and non-Secure, which fixes the bootstrap from an https:// config.example.yaml; Config.IsSecure() is removed as now dead. Tests: rewrite the setup-gate tests for AC-7/AC-10, add AC-13, and unit-test SetupRequestAllowed / RequestIsHTTPS.
608 lines
21 KiB
Go
608 lines
21 KiB
Go
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)
|
|
}
|
|
}
|