package server_test import ( "bytes" "context" "log/slog" "net/http" "net/http/httptest" "net/url" "path/filepath" "strconv" "strings" "testing" "time" "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/middleware" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing" "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" ) // ── fakes / wrappers ───────────────────────────────────────────────────────── // The counting wrappers embed the real interface and increment a counter before // delegating, so a test can prove "the door never touched this" (AC-2). type countingResolver struct { routing.Resolver calls *int } func (c countingResolver) GUID(g string) (bool, bool) { *c.calls++; return c.Resolver.GUID(g) } type countingSessions struct { auth.Sessions calls *int } func (c countingSessions) Validate(ctx context.Context, id string) (auth.Session, error) { *c.calls++ return c.Sessions.Validate(ctx, id) } type countingIdentities struct { auth.Identities calls *int } func (c countingIdentities) ByGUID(ctx context.Context, g string) (auth.Identity, error) { *c.calls++ return c.Identities.ByGUID(ctx, g) } type countingUsers struct { users.Users calls *int } func (c countingUsers) Get(ctx context.Context, id int64) (users.User, error) { *c.calls++ return c.Users.Get(ctx, id) } // probeCapture records what the door handed to a module function. type probeCapture struct { rc *server.RequestContext meta audit.Meta metaOK bool } // ── registry ───────────────────────────────────────────────────────────────── func testRegistry(cap *probeCapture) *server.MapRegistry { reg := server.NewMapRegistry() reg.RegisterCore("home", server.Home) reg.RegisterCore("login", server.LoginPlaceholder) reg.RegisterCore("enroll", server.EnrollPlaceholder) reg.RegisterCore("setup", server.SetupPlaceholder) reg.RegisterCore("access", server.AccessPlaceholder) reg.RegisterModule(server.NewModule("home"). SetDefault("dashboard", server.Home). AddRoute("home", "home", server.Home)) reg.RegisterModule(server.NewModule("hr"). AddAction("save-data", "save-data", func(w http.ResponseWriter, _ *http.Request, _ *server.RequestContext) error { w.WriteHeader(http.StatusOK) _, err := w.Write([]byte("saved")) return err })) reg.RegisterModule(server.NewModule("probe"). AddRoute("show", "show", func(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error { if cap != nil { cap.rc = rc cap.meta, cap.metaOK = audit.MetaFrom(r.Context()) } w.WriteHeader(http.StatusOK) _, err := w.Write([]byte("probe")) return err })) return reg } // ── harness ────────────────────────────────────────────────────────────────── type harness 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 totp *totp.Generator srv *server.Server adminID int64 guid string session auth.Session } func newHarness(t *testing.T, opts ...func(*server.Deps)) *harness { t.Helper() ctx := context.Background() db, err := store.Open(filepath.Join(t.TempDir(), "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) adminID, err := us.Create(ctx, 0, "admin@example.com", "Admin", users.RoleAdmin) if err != nil { t.Fatalf("bootstrap admin: %v", err) } sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x42}, 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() idID, guid, err := ids.Mint(ctx, adminID) if err != nil { t.Fatalf("Mint: %v", err) } if err := ids.Activate(ctx, idID); err != nil { t.Fatalf("Activate: %v", err) } sess, err := sessions.Mint(ctx, idID, "127.0.0.1", "test") if err != nil { t.Fatalf("session Mint: %v", err) } const ( csrfKey = "test-csrf-key-0123456789abcdef" cookieSecret = "test-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) } // The first-run gate (D-044/D-045). It is only consulted while the wizard is // enabled; a REQUIRED code is wired here so a setup-mode test exercises the // code form + the unlock. A test that wants the code-less wizard overrides // SetupGate. gate, err := setup.NewGate("test-setup-code", pre, nil) if err != nil { t.Fatalf("setup.NewGate: %v", err) } cfg := &config.Config{ Setup: false, Server: config.Server{Listen: "127.0.0.1:8080", BaseURL: "http://example.com"}, // BaseURL is a PUBLIC origin on purpose: the passkey nudge only fires // off-loopback (SPEC-006 §8), and L7c makes the CONFIGURED origin the // signal — so a test that wants loopback must configure it. Database: config.Database{Path: db.Path()}, Secrets: config.Secrets{CSRFKey: csrfKey, CookieSecret: cookieSecret}, } wa := &fakeWebAuthn{} totpSvc := totp.New("MyDay", ids) deps := server.Deps{ Config: cfg, Resolver: ids, Sessions: sessions, Identities: ids, Users: us, Audit: aud, CSRF: csrf, PreSession: pre, SetupGate: gate, } for _, o := range opts { o(&deps) } // Build the SPEC-006 flow with the (possibly overridden) logger + CSRF, and // register it unless the test supplied its own registry. authFlow, err := flow.New(flow.Deps{ Identities: ids, Credentials: ids, Sessions: sessions, Users: us, WebAuthn: wa, TOTP: totpSvc, CSRF: deps.PreSession, Audit: aud, Logger: deps.Logger, // L7c: the nudge's origin signal is the CONFIGURED base_url, not the // (proxy-rewritable) request Host. BaseURL: deps.Config.Server.BaseURL, // The passkey nudge (SPEC-006 §8) is suppressed in setup mode; the // door's SetupMode is the same predicate (nil = nudge enabled). SuppressNudge: deps.SetupMode, }) if err != nil { t.Fatalf("flow.New: %v", err) } if deps.Registry == nil { reg := testRegistry(nil) server.RegisterAuthFlow(reg, authFlow) deps.Registry = reg } srv, err := server.New(deps) if err != nil { t.Fatalf("server.New: %v", err) } return &harness{ t: t, ctx: ctx, db: db, users: us, ids: ids, sessions: sessions, csrf: csrf, pre: pre, wa: wa, totp: totpSvc, srv: srv, adminID: adminID, guid: guid, session: sess, } } // preCSRF mints a signed pre-session token plus its cookie, as the enroll/login // screens would (A15). func (h *harness) 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 } func (h *harness) cookie() *http.Cookie { return &http.Cookie{Name: httpx.SessionCookieName, Value: h.session.ID} } // loopbackPeer is the RemoteAddr the test door speaks from. It is a plain // same-host peer: the setup gate is the `setup` toggle (D-045), not the peer. const loopbackPeer = "127.0.0.1:52345" // request issues a request directly against the door (no network). func request(h http.Handler, method, target string, cookies ...*http.Cookie) *httptest.ResponseRecorder { req := httptest.NewRequest(method, target, nil) req.RemoteAddr = loopbackPeer for _, c := range cookies { req.AddCookie(c) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // ── AC-1 ───────────────────────────────────────────────────────────────────── func TestAC1_RedirectAndNotFound(t *testing.T) { h := newHarness(t) rec := request(h.srv, "GET", "/") if rec.Code != http.StatusFound { t.Fatalf("GET / = %d, want 302", rec.Code) } if loc := rec.Header().Get("Location"); loc != "/home" { t.Errorf("Location = %q, want /home", loc) } rec = request(h.srv, "GET", "/holy-shit") if rec.Code != http.StatusNotFound { t.Errorf("GET /holy-shit = %d, want 404", rec.Code) } } // ── AC-2 ───────────────────────────────────────────────────────────────────── func TestAC2_HomeNoSessionNoDB(t *testing.T) { var resolverCalls, validateCalls, byGUIDCalls, getCalls int h := newHarness(t, func(d *server.Deps) { d.Resolver = countingResolver{Resolver: d.Resolver, calls: &resolverCalls} d.Sessions = countingSessions{Sessions: d.Sessions, calls: &validateCalls} d.Identities = countingIdentities{Identities: d.Identities, calls: &byGUIDCalls} d.Users = countingUsers{Users: d.Users, calls: &getCalls} }) rec := request(h.srv, "GET", "/home") if rec.Code != http.StatusOK { t.Fatalf("GET /home = %d, want 200", rec.Code) } if !strings.Contains(rec.Body.String(), "MyDay") { t.Error("GET /home did not render the landing page") } if resolverCalls != 0 || validateCalls != 0 || byGUIDCalls != 0 || getCalls != 0 { t.Errorf("/home touched storage: resolver=%d sessions=%d identities=%d users=%d (AC-2)", resolverCalls, validateCalls, byGUIDCalls, getCalls) } } // ── AC-3 ───────────────────────────────────────────────────────────────────── func TestAC3_GUIDWithAndWithoutSession(t *testing.T) { h := newHarness(t) // No session -> the login screen (a rendered page: 200, F-1). rec := request(h.srv, "GET", "/"+h.guid) if rec.Code != http.StatusOK { t.Errorf("GET /{guid} without a session = %d, want 200 (login screen, F-1)", rec.Code) } if !strings.Contains(rec.Body.String(), "Sign in") { t.Error("GET /{guid} without a session did not render the login screen (AC-3)") } // Valid session -> the module function (the dashboard). rec = request(h.srv, "GET", "/"+h.guid, h.cookie()) if rec.Code != http.StatusOK { t.Fatalf("GET /{guid} with a session = %d, want 200", rec.Code) } if !strings.Contains(rec.Body.String(), "MyDay") { t.Error("the module function did not render the dashboard") } // /{GUID}/home is the same user landing. rec = request(h.srv, "GET", "/"+h.guid+"/home", h.cookie()) if rec.Code != http.StatusOK { t.Errorf("GET /{guid}/home with a session = %d, want 200", rec.Code) } } // ── AC-4 ───────────────────────────────────────────────────────────────────── func TestAC4_SessionUserMismatchRejected(t *testing.T) { h := newHarness(t) // A second user with an active identity. otherID, err := h.users.Create(h.ctx, h.adminID, "b@example.com", "B", users.RoleTeam) if err != nil { t.Fatalf("create other user: %v", err) } idOther, guidOther, err := h.ids.Mint(h.ctx, otherID) if err != nil { t.Fatalf("Mint other: %v", err) } if err := h.ids.Activate(h.ctx, idOther); err != nil { t.Fatalf("Activate other: %v", err) } // The admin's session must NOT open the other user's GUID: the door hands // off to the login screen (200, a rendered page — F-1), never the module. rec := request(h.srv, "GET", "/"+guidOther, h.cookie()) if rec.Code != http.StatusOK { t.Errorf("GET /{other-guid} with a foreign session = %d, want 200 (login screen, AC-4)", rec.Code) } if !strings.Contains(rec.Body.String(), "Sign in") { t.Error("a foreign session did not land on the login screen (AC-4)") } // The dashboard marker proves the module function was never reached (the // shell's own chrome carries "MyDay" on every page, login included). if strings.Contains(rec.Body.String(), "Your day, at a glance.") { t.Error("a foreign session reached the module function (AC-4)") } } // ── AC-5 ───────────────────────────────────────────────────────────────────── func TestAC5_EmptyOrMissingSessionRejected(t *testing.T) { h := newHarness(t) empty := &http.Cookie{Name: httpx.SessionCookieName, Value: ""} cases := []struct { name string target string cookie *http.Cookie }{ {"missing id on /{guid}", "/" + h.guid, nil}, {"empty id on /{guid}", "/" + h.guid, empty}, {"missing id on /{guid}/home", "/" + h.guid + "/home", nil}, {"empty id on /{guid}/home", "/" + h.guid + "/home", empty}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { var rec *httptest.ResponseRecorder if tc.cookie == nil { rec = request(h.srv, "GET", tc.target) } else { rec = request(h.srv, "GET", tc.target, tc.cookie) } // The door rejects the empty/missing session and renders the login // screen (200, F-1) — it must never reach the module. if rec.Code != http.StatusOK { t.Errorf("%s = %d, want 200 (login screen, A2/F-1)", tc.target, rec.Code) } if !strings.Contains(rec.Body.String(), "Sign in") { t.Errorf("%s did not render the login screen (A2)", tc.target) } }) } } // ── AC-6 ───────────────────────────────────────────────────────────────────── func TestAC6_ActionRequiresCSRF(t *testing.T) { h := newHarness(t) base := "/" + h.guid + "?do=hr&action=save-data" // No CSRF token -> rejected. rec := request(h.srv, "POST", base, h.cookie()) if rec.Code != http.StatusForbidden { t.Errorf("action without a CSRF token = %d, want 403 (AC-6)", rec.Code) } // A wrong token -> rejected. rec = request(h.srv, "POST", base+"&csrf=wrong", h.cookie()) if rec.Code != http.StatusForbidden { t.Errorf("action with a bad CSRF token = %d, want 403", rec.Code) } // The correct, session-bound token -> dispatched. token := url.QueryEscape(h.csrf.Token(h.session.ID)) rec = request(h.srv, "POST", base+"&csrf="+token, h.cookie()) if rec.Code != http.StatusOK { t.Fatalf("action with a valid CSRF token = %d, want 200", rec.Code) } if !strings.Contains(rec.Body.String(), "saved") { t.Error("the action handler did not run") } } // ── AC-7 · AC-10 ───────────────────────────────────────────────────────────── // TestAC7_SetupGateIsTheConfigToggle covers D-045 + D-044 at the door: while the // wizard is enabled with a required setup code, the code is the gate — no peer // (loopback or proxied) reaches the wizard without the unlock, and every visitor // sees the code form. The peer/tunnel rule of D-025 is retired. The unlock // action itself is the ONE request admitted without an unlock. func TestAC7_SetupGateIsTheConfigToggle(t *testing.T) { unlocked := func(w http.ResponseWriter, r *http.Request, _ *server.RequestContext) error { http.SetCookie(w, &http.Cookie{Name: setup.SetupUnlockCookieName, Value: "granted", Path: "/"}) http.Redirect(w, r, "/setup", http.StatusSeeOther) return nil } h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } reg := testRegistry(nil) reg.RegisterModule(server.NewModule("setup"). AddAction("unlock", "unlock", unlocked). AddAction("domain.save", "domain.save", unlocked)) d.Registry = reg }) cases := []struct { name string peer string xff string }{ {"a loopback peer", loopbackPeer, ""}, {"a same-host proxy", "127.0.0.1:9999", "203.0.113.9"}, {"a remote peer", "203.0.113.9:4444", ""}, {"a remote peer behind a proxy", "203.0.113.9:4444", "198.51.100.7"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest("GET", "/setup", nil) req.RemoteAddr = tc.peer if tc.xff != "" { req.Header.Set("X-Forwarded-For", tc.xff) } rec := httptest.NewRecorder() h.srv.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("GET /setup (%s) = %d, want 200 (the code form)", tc.name, rec.Code) } body := rec.Body.String() if strings.Contains(body, "Bootstrap wizard") { t.Errorf("GET /setup (%s) reached the wizard without the code (D-045)", tc.name) } if !strings.Contains(body, `name="code"`) { t.Errorf("GET /setup (%s) did not show the code form (D-044)", tc.name) } }) } // A non-unlock wizard step is refused before dispatch while not admitted. if rec := request(h.srv, "POST", "/setup?do=setup&action=domain.save"); rec.Code != http.StatusNotFound { t.Errorf("domain.save without the unlock = %d, want 404 (D-045)", rec.Code) } // The unlock action is the ONE carve-out: admitted without an unlock (it is // what grants it), and it needs the pre-session CSRF token (A15). cookie, token := h.preCSRF() form := url.Values{"csrf": {token}, "code": {"whatever"}} req := httptest.NewRequest("POST", "/setup?do=setup&action=unlock", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.RemoteAddr = loopbackPeer req.AddCookie(cookie) rec := httptest.NewRecorder() h.srv.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Errorf("POST unlock (the carve-out) = %d, want 303 (D-044)", rec.Code) } // Without the CSRF token it is refused (A15). rec = request(h.srv, "POST", "/setup?do=setup&action=unlock") if rec.Code != http.StatusForbidden { t.Errorf("POST unlock without CSRF = %d, want 403 (A15)", rec.Code) } } // ── AC-8 ───────────────────────────────────────────────────────────────────── func TestAC8_CorrelationAndRedaction(t *testing.T) { var logs bytes.Buffer h := newHarness(t, func(d *server.Deps) { d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) }) rec := request(h.srv, "GET", "/home") cid := rec.Header().Get(middleware.CorrelationHeader) if cid == "" { t.Fatal("the response carries no correlation id (AC-8)") } if !strings.Contains(logs.String(), cid) { t.Errorf("the log does not echo the correlation id %q (AC-8)", cid) } // A credential riding the path is redacted in the log. logs.Reset() request(h.srv, "GET", "/setup/token/SUPERSECRETTOKEN") out := logs.String() if strings.Contains(out, "SUPERSECRETTOKEN") { t.Error("the access log leaked a path credential (A8/AC-8)") } if !strings.Contains(out, httpx.Redacted) { t.Errorf("the access log did not mark the redaction (%q)", out) } } // ── AC-9 ───────────────────────────────────────────────────────────────────── func TestAC9_RateLimiterOnAuthRoutes(t *testing.T) { h := newHarness(t, func(d *server.Deps) { d.RateLimit = 2 d.RateWindow = time.Minute }) for i := 0; i < 2; i++ { if rec := request(h.srv, "GET", "/access"); rec.Code != http.StatusOK { t.Fatalf("request %d = %d, want 200", i+1, rec.Code) } } rec := request(h.srv, "GET", "/access") if rec.Code != http.StatusTooManyRequests { t.Fatalf("third request = %d, want 429 (AC-9)", rec.Code) } if rec.Header().Get("Retry-After") == "" { t.Error("429 is missing a Retry-After header") } } // ── AC-13 ──────────────────────────────────────────────────────────────────── // TestAC13_ProxySchemeDrivesCookieAndHSTS covers D-043: a TRUSTED proxy's // `X-Forwarded-Proto: https` ⇒ the session cookie is `Secure` + `__Host-`, and // HSTS is sent. Plain http keeps the bare, non-Secure cookie (the bootstrap), // and an untrusted peer's header is ignored. func TestAC13_ProxySchemeDrivesCookieAndHSTS(t *testing.T) { login := func(h *harness, peer, xfp string) *httptest.ResponseRecorder { t.Helper() code := h.issueTOTP(t, h.session.IdentityID) cookie, token := h.preCSRF() form := url.Values{"csrf": {token}, "code": {code}} req := httptest.NewRequest("POST", "/"+h.guid+"?do=auth&action=totp.verify", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.RemoteAddr = peer if xfp != "" { req.Header.Set("X-Forwarded-Proto", xfp) } req.AddCookie(cookie) rec := httptest.NewRecorder() h.srv.ServeHTTP(rec, req) return rec } // Behind the proxy: the peer is trusted, XFP says https. h := newHarness(t, func(d *server.Deps) { d.Config.Server.TrustedProxies = []string{"127.0.0.1"} }) rec := login(h, "127.0.0.1:9999", "https") if rec.Code != http.StatusSeeOther { t.Fatalf("TOTP login behind the proxy = %d, want 303 (AC-13)", rec.Code) } if rec.Header().Get("Strict-Transport-Security") == "" { t.Error("no HSTS behind an https proxy (AC-13)") } if !hasCookieNamed(rec, httpx.HostPrefix+httpx.SessionCookieName, true) { t.Error("the session cookie is not Secure + __Host- behind the proxy (AC-13)") } // Plain http (the tunnel): no XFP → bare, non-Secure cookie, no HSTS. h2 := newHarness(t) plain := login(h2, "127.0.0.1:9999", "") if plain.Header().Get("Strict-Transport-Security") != "" { t.Error("HSTS set on a plain-http request (AC-13)") } if !hasCookieNamed(plain, httpx.SessionCookieName, false) { t.Error("expected the bare, non-Secure session cookie over plain http (AC-13)") } // An UNTRUSTED peer's XFP is ignored — no `__Host-` cookie. h3 := newHarness(t) spoof := login(h3, "203.0.113.9:4444", "https") if hasCookieNamed(spoof, httpx.HostPrefix+httpx.SessionCookieName, true) { t.Error("an untrusted peer's X-Forwarded-Proto was honoured (AC-13)") } } // hasCookieNamed reports whether the response set a non-empty cookie with the // given name and Secure flag. func hasCookieNamed(rec *httptest.ResponseRecorder, name string, secure bool) bool { for _, c := range rec.Result().Cookies() { if c.Name == name && c.Value != "" && c.Secure == secure { return true } } return false } // ── supporting behaviour ───────────────────────────────────────────────────── // TestRequestContextHandover proves §5: the module function receives the // resolved identification, and the door attached audit.Meta (R-1). func TestRequestContextHandover(t *testing.T) { var cap probeCapture h := newHarness(t, func(d *server.Deps) { d.Registry = testRegistry(&cap) }) rec := request(h.srv, "GET", "/"+h.guid+"/probe/show", h.cookie()) if rec.Code != http.StatusOK { t.Fatalf("probe/show = %d, want 200", rec.Code) } if cap.rc == nil { t.Fatal("the module function was never invoked") } if cap.rc.UserID != h.adminID { t.Errorf("rc.UserID = %d, want %d", cap.rc.UserID, h.adminID) } if want := "user:" + strconv.FormatInt(h.adminID, 10); cap.rc.Principal != want { t.Errorf("rc.Principal = %q, want %q", cap.rc.Principal, want) } if cap.rc.Workflow != h.guid { t.Errorf("rc.Workflow = %q, want the GUID %q", cap.rc.Workflow, h.guid) } if cap.rc.Function != "show" { t.Errorf("rc.Function = %q, want show", cap.rc.Function) } if cap.rc.Correlation == "" || cap.rc.ClientIP == "" { t.Errorf("rc correlation/client IP not set: %+v", cap.rc) } if !cap.metaOK { t.Fatal("audit.Meta was not attached to the context (R-1)") } if cap.meta.ActorUserID != h.adminID { t.Errorf("meta.ActorUserID = %d, want %d", cap.meta.ActorUserID, h.adminID) } if cap.meta.CorrelationID != cap.rc.Correlation { t.Errorf("meta.CorrelationID = %q, want %q", cap.meta.CorrelationID, cap.rc.Correlation) } } // TestSetupModeRouting: /setup is served only while the wizard is enabled // (SPEC-003 §4, D-045). func TestSetupModeRouting(t *testing.T) { productive := newHarness(t) if rec := request(productive.srv, "GET", "/setup"); rec.Code != http.StatusNotFound { t.Errorf("GET /setup in productive mode = %d, want 404", rec.Code) } // The action door must also 404 /setup in productive mode. if rec := request(productive.srv, "POST", "/setup?do=setup&action=begin"); rec.Code != http.StatusNotFound { t.Errorf("POST /setup?do=setup in productive mode = %d, want 404", rec.Code) } // Setup mode: /setup is served — the harness gate requires a code, so this // is the code form (200), never a bare 404 (D-045). inSetup := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } }) if rec := request(inSetup.srv, "GET", "/setup"); rec.Code != http.StatusOK { t.Errorf("GET /setup in setup mode = %d, want 200", rec.Code) } } // TestSpec003SetupGateIsPerRequest covers AC-10 under D-045: while the wizard // is enabled with a required code, `GET /setup` is the code form from EVERY // peer — the wizard needs the unlock, which is not a peer property (the D-025 // tunnel rule is retired). Every non-wizard route is 404. func TestSpec003SetupGateIsPerRequest(t *testing.T) { h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } }) // A remote client: the code form, never the wizard. req := httptest.NewRequest("GET", "/setup", nil) req.RemoteAddr = "203.0.113.9:4444" rec := httptest.NewRecorder() h.srv.ServeHTTP(rec, req) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `name="code"`) { t.Errorf("GET /setup from a remote client = %d, want the code form (D-045)", rec.Code) } if strings.Contains(rec.Body.String(), "Bootstrap wizard") { t.Error("GET /setup from a remote client reached the wizard without the code (D-045)") } // Every other route is 404. (Static assets are the first-run stylesheet, so // they are served — see TestSpec009_StaticIsServedInSetupMode.) for _, tc := range []struct{ method, target string }{ {"POST", "/setup?do=setup&action=domain.save"}, {"GET", "/home"}, {"GET", "/" + h.guid}, } { req := httptest.NewRequest(tc.method, tc.target, nil) req.RemoteAddr = "203.0.113.9:4444" // a remote peer through the proxy rec := httptest.NewRecorder() h.srv.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("%s %s from a non-loopback client = %d, want 404 (AC-10)", tc.method, tc.target, rec.Code) } } } // TestSpec003SetupModulePinnedAtTheDoor covers M1c/L3s: the wizard's module is // reachable ONLY through the reserved `setup` workflow. The registry mounts a // `setup` module and a `core` action module, so a missing pin would actually // dispatch them (that is what makes the assertion meaningful). func TestSpec003SetupModulePinnedAtTheDoor(t *testing.T) { ok := func(msg string) server.Handler { return func(w http.ResponseWriter, _ *http.Request, _ *server.RequestContext) error { w.WriteHeader(http.StatusOK) _, err := w.Write([]byte(msg)) return err } } registry := func() *server.MapRegistry { reg := server.NewMapRegistry() reg.RegisterCore("home", server.Home) reg.RegisterModule(server.NewModule("home"). SetDefault("dashboard", server.Home). AddRoute("home", "home", server.Home)) reg.RegisterModule(server.NewModule("setup"). AddAction("admin.create", "admin.create", ok("created"))) reg.RegisterModule(server.NewModule("core"). AddAction("modules.toggle", "modules.toggle", ok("toggled"))) return reg } // In setup mode: `do=core` under /setup must not reach the core action, // even with a valid pre-session CSRF token (M1c). inSetup := newHarness(t, func(d *server.Deps) { d.Registry = registry() d.SetupMode = func() bool { return true } }) cookie, token := inSetup.preCSRF() form := url.Values{"csrf": {token}}.Encode() req := httptest.NewRequest("POST", "/setup?do=core&action=modules.toggle", strings.NewReader(form)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.RemoteAddr = loopbackPeer req.AddCookie(cookie) rec := httptest.NewRecorder() inSetup.srv.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("do=core under /setup in setup mode = %d, want 404 (M1c)", rec.Code) } // In productive mode: a GUID action naming the setup module must 404 rather // than fall through to serveGUID and dispatch it (L3s). productive := newHarness(t, func(d *server.Deps) { d.Registry = registry() }) target := "/" + productive.guid + "?do=setup&action=admin.create" token2 := url.QueryEscape(productive.csrf.Token(productive.session.ID)) rec2 := request(productive.srv, "POST", target+"&csrf="+token2, productive.cookie()) if rec2.Code != http.StatusNotFound { t.Errorf("POST %s = %d, want 404 (L3s: the setup module is pinned to the setup workflow)", target, rec2.Code) } // And under a reserved level-1 workflow (not just a GUID). rec3 := request(productive.srv, "POST", "/home?do=setup&action=admin.create&csrf="+token2) if rec3.Code != http.StatusNotFound { t.Errorf("do=setup under /home = %d, want 404 (L3s)", rec3.Code) } } // TestEnrollRouting: a pending identity routes to the enrollment screen. func TestEnrollRouting(t *testing.T) { h := newHarness(t) pendingUser, err := h.users.Create(h.ctx, h.adminID, "pending@example.com", "P", users.RoleTeam) if err != nil { t.Fatalf("create pending user: %v", err) } _, guidPending, err := h.ids.Mint(h.ctx, pendingUser) // pending_enrollment if err != nil { t.Fatalf("Mint pending: %v", err) } rec := request(h.srv, "GET", "/"+guidPending) if rec.Code != http.StatusOK { t.Fatalf("GET /{pending-guid} = %d, want 200 (enroll)", rec.Code) } if !strings.Contains(rec.Body.String(), "Enrollment") { t.Error("a pending GUID did not route to the enrollment screen") } } // TestTrustedProxyClientIPAtTheDoor: only a trusted peer's XFF is honoured. func TestTrustedProxyClientIPAtTheDoor(t *testing.T) { h := newHarness(t, func(d *server.Deps) { d.Config.Server.TrustedProxies = []string{"127.0.0.1"} }) req := httptest.NewRequest("GET", "/home", nil) req.RemoteAddr = "127.0.0.1:9999" req.Header.Set("X-Forwarded-For", "203.0.113.77") rec := httptest.NewRecorder() h.srv.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if got := req.Header.Get("X-Forwarded-For"); got != "203.0.113.77" { t.Errorf("X-Forwarded-For = %q, want the client value SET (never appended)", got) } }