Door-level tests (no browser) for AC-1…AC-7: the role-gated nav, reusing the
passkey ceremony from the Account page, creating a user + emailing/showing the
invite, the invite path enrolling at /{GUID}, the passkey banner (shows without
a passkey, clears after adding one or opting out, suppressed on loopback), the
admin-only /users role gate, and no inline scripts on the new screens.
The harness now always wires the enroll/login flow (as the entrypoint does) and
the Credentials seam, so a test-supplied registry matches production.
873 lines
32 KiB
Go
873 lines
32 KiB
Go
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,
|
|
// SPEC-010 §6: the door's passkey banner reads "does this identity
|
|
// have a passkey?" — the identity service also implements Credentials.
|
|
Credentials: 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 {
|
|
deps.Registry = testRegistry(nil)
|
|
}
|
|
// The entrypoint always wires the SPEC-006 enroll/login flow, so the harness
|
|
// does too — including into a test-supplied registry (the SPEC-010 pages
|
|
// reuse the passkey ceremony the flow carries).
|
|
if mr, ok := deps.Registry.(*server.MapRegistry); ok {
|
|
server.RegisterAuthFlow(mr, authFlow)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|