test(account): cover the SPEC-010 acceptance criteria

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.
This commit is contained in:
Claus Lohmar 2026-10-15 07:58:23 +00:00
parent 5dc0a0150e
commit 23dc461118
2 changed files with 453 additions and 8 deletions

View file

@ -212,6 +212,9 @@ func newHarness(t *testing.T, opts ...func(*server.Deps)) *harness {
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,
@ -245,9 +248,13 @@ func newHarness(t *testing.T, opts ...func(*server.Deps)) *harness {
t.Fatalf("flow.New: %v", err)
}
if deps.Registry == nil {
reg := testRegistry(nil)
server.RegisterAuthFlow(reg, authFlow)
deps.Registry = reg
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)

View file

@ -0,0 +1,438 @@
package server_test
import (
"net/http"
"net/url"
"strings"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/account"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/module"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
)
// ── SPEC-010 · the admin & account UI ────────────────────────────────────────
// spec010Gate is the role gate the entrypoint installs for SPEC-010 (plus the
// SPEC-008 Modules page): the Users page and its create action are admin-only.
func spec010Gate() *server.Tiers {
return server.NewTiers().
Require(module.ConsoleModule, module.FunctionModules, users.RoleAdmin).
Require(module.ConsoleModule, module.FunctionToggle, users.RoleAdmin).
Require(account.UsersModule, account.FunctionList, users.RoleAdmin).
Require(account.UsersModule, account.FunctionCreate, users.RoleAdmin)
}
// spec010Door wires a door as the entrypoint does for SPEC-010: the discovered
// modules (the real home dashboard, so the shell's nav renders), the core
// Modules page, the account + Users pages, the enroll/login flow (the passkey
// ceremony the account page reuses) and the admin-only role gate. It returns
// the harness and the recording mailer.
func spec010Door(t *testing.T) (*harness, *mail.Fake) {
t.Helper()
mods, err := module.Load(nil)
if err != nil {
t.Fatalf("module.Load: %v", err)
}
mailer := mail.NewFake()
h := newHarness(t, func(d *server.Deps) {
reg := server.NewMapRegistry()
mods.Mount(reg)
buildConsole(t, mods, reg, d.Audit)
accounts, err := account.New(account.Deps{
Users: d.Users,
Identities: d.Identities,
Credentials: d.Credentials,
Mailer: mailer,
BaseURL: d.Config.Server.BaseURL,
Audit: d.Audit,
})
if err != nil {
t.Fatalf("account.New: %v", err)
}
accounts.Register(reg)
d.Registry = reg
d.RoleGate = spec010Gate()
})
web.SetNav(mods)
t.Cleanup(func() { web.SetNav(nil) })
return h, mailer
}
// spec010Team creates an ACTIVE team member under the admin (for the non-admin
// half of the role gate and the nav).
func spec010Team(t *testing.T, h *harness) (guid, sessionID string) {
t.Helper()
userID, err := h.users.Create(h.ctx, h.adminID, "team@example.com", "Team", users.RoleTeam)
if err != nil {
t.Fatalf("create team: %v", err)
}
idID, guid, err := h.ids.Mint(h.ctx, userID)
if err != nil {
t.Fatalf("Mint team: %v", err)
}
if err := h.ids.Activate(h.ctx, idID); err != nil {
t.Fatalf("Activate team: %v", err)
}
sess, err := h.sessions.Mint(h.ctx, idID, "127.0.0.1", "test")
if err != nil {
t.Fatalf("session Mint team: %v", err)
}
return guid, sess.ID
}
func spec010Cookie(id string) *http.Cookie {
return &http.Cookie{Name: httpx.SessionCookieName, Value: id}
}
// ── AC-1 — the nav ───────────────────────────────────────────────────────────
// TestSpec010AC1_NavIsRoleGated: the shell shows Home · Account to everyone and
// Users · Modules only to an admin.
func TestSpec010AC1_NavIsRoleGated(t *testing.T) {
h, _ := spec010Door(t)
// The admin's dashboard: the role-gated entries are present.
admin := request(h.srv, "GET", "/"+h.guid, h.cookie()).Body.String()
for _, want := range []string{
`href="/` + h.guid + `/home"`,
`href="/` + h.guid + `/account"`,
`href="/` + h.guid + `/users"`,
`href="/` + h.guid + `/core/modules"`,
} {
if !strings.Contains(admin, want) {
t.Errorf("the admin nav is missing %q:\n%s", want, admin)
}
}
// A team member's account page: Home · Account only.
guid, sid := spec010Team(t, h)
team := request(h.srv, "GET", "/"+guid+"/account", spec010Cookie(sid)).Body.String()
if !strings.Contains(team, `href="/`+guid+`/home"`) ||
!strings.Contains(team, `href="/`+guid+`/account"`) {
t.Errorf("the team nav is missing Home/Account:\n%s", team)
}
if strings.Contains(team, `href="/`+guid+`/users"`) ||
strings.Contains(team, `href="/`+guid+`/core/modules"`) {
t.Errorf("a non-admin saw the admin nav entries (AC-1):\n%s", team)
}
}
// ── AC-2 — add a passkey from the account page ───────────────────────────────
// TestSpec010AC2_AccountPageReusesThePasskeyCeremony: the account page offers an
// "Add a passkey" button that links to the existing SPEC-006 §8 screen, which
// begins a real WebAuthn registration — the ceremony is reused, not duplicated.
func TestSpec010AC2_AccountPageReusesThePasskeyCeremony(t *testing.T) {
h, _ := spec010Door(t)
acct := request(h.srv, "GET", "/"+h.guid+"/account", h.cookie())
if acct.Code != http.StatusOK {
t.Fatalf("GET account = %d, want 200 (body %q)", acct.Code, acct.Body.String())
}
if !strings.Contains(acct.Body.String(), `href="/`+h.guid+`/auth/passkey"`) {
t.Fatalf("the account page does not offer the passkey ceremony (AC-2):\n%s", acct.Body.String())
}
// Following it reaches the existing ceremony screen, which begins a
// registration (proving the flow is reused).
before := h.wa.beginReg
ceremony := request(h.srv, "GET", "/"+h.guid+"/auth/passkey", h.cookie())
if ceremony.Code != http.StatusOK {
t.Fatalf("GET the passkey screen = %d, want 200 (AC-2)", ceremony.Code)
}
if !strings.Contains(ceremony.Body.String(), `data-webauthn="passkey"`) {
t.Error("the ceremony screen carries no passkey contract (AC-2)")
}
if h.wa.beginReg != before+1 {
t.Errorf("BeginRegistration calls = %d, want %d (AC-2)", h.wa.beginReg, before+1)
}
}
// ── AC-3 — create a user and email the invite ────────────────────────────────
// TestSpec010AC3_CreateUserInvitesAndShowsTheLink: an admin creates a user
// (role below their own), the invite is EMAILED and the link is shown.
func TestSpec010AC3_CreateUserInvitesAndShowsTheLink(t *testing.T) {
h, mailer := spec010Door(t)
// The create form offers only roles BELOW the admin (D-019).
page := request(h.srv, "GET", "/"+h.guid+"/users", h.cookie())
if page.Code != http.StatusOK {
t.Fatalf("GET /users = %d, want 200 (body %q)", page.Code, page.Body.String())
}
for _, role := range []string{"director", "manager", "team"} {
if !strings.Contains(page.Body.String(), `value="`+role+`"`) {
t.Errorf("the create form does not offer role %q (D-019):\n%s", role, page.Body.String())
}
}
if strings.Contains(page.Body.String(), `value="admin"`) {
t.Error("the create form offers the admin role — only roles below the creator may be created (D-019)")
}
form := url.Values{
"display_name": {"Ada Lovelace"},
"email": {"ada@example.com"},
"role": {"manager"},
"csrf": {h.csrf.Token(h.session.ID)},
}
res := serveForm(h.srv, "/"+h.guid+"?do=users&action=users.create", form, h.cookie())
if res.Code != http.StatusOK {
t.Fatalf("create user = %d, want 200 (body %q)", res.Code, res.Body.String())
}
// The user exists with the right role and manager (the creator).
u, err := h.users.ByEmail(h.ctx, "ada@example.com")
if err != nil {
t.Fatalf("the created user is not in the store: %v", err)
}
if u.Role != users.RoleManager {
t.Errorf("created role = %q, want manager", u.Role)
}
if !u.Manager.Set || u.Manager.Value != h.adminID {
t.Errorf("manager = %+v, want the creator (%d)", u.Manager, h.adminID)
}
// The identity is pending_enrollment (the invite window is open).
idn, err := h.ids.ByUserID(h.ctx, u.ID)
if err != nil {
t.Fatalf("the created user has no identity: %v", err)
}
if idn.Status != auth.StatusPendingEnrollment {
t.Errorf("identity status = %q, want pending_enrollment", idn.Status)
}
// The invitation was emailed, carrying the /{GUID} link.
sent := mailer.To("ada@example.com")
if len(sent) != 1 {
t.Fatalf("invite emails to ada = %d, want 1 (AC-3)", len(sent))
}
if !strings.Contains(sent[0].Body, "/"+idn.GUID) {
t.Errorf("the invite email carries no enrolment link:\n%s", sent[0].Body)
}
// …and the link is shown in the UI for copy/paste.
if !strings.Contains(res.Body.String(), "/"+idn.GUID) {
t.Errorf("the invite link is not shown in the UI (AC-3):\n%s", res.Body.String())
}
// The new user is now listed.
if !strings.Contains(res.Body.String(), "ada@example.com") {
t.Errorf("the created user is missing from the list (AC-3):\n%s", res.Body.String())
}
}
// TestSpec010AC3_CannotCreateARoleAtOrAboveYourOwn: the service's D-019 rule is
// surfaced as a 403, not a 500.
func TestSpec010AC3_CannotCreateARoleAtOrAboveYourOwn(t *testing.T) {
h, mailer := spec010Door(t)
form := url.Values{
"display_name": {"P2"},
"email": {"p2@example.com"},
"role": {"admin"}, // at the creator's own level — refused
"csrf": {h.csrf.Token(h.session.ID)},
}
res := serveForm(h.srv, "/"+h.guid+"?do=users&action=users.create", form, h.cookie())
if res.Code != http.StatusForbidden {
t.Fatalf("creating an admin = %d, want 403 (D-019)", res.Code)
}
if len(mailer.Sent()) != 0 {
t.Error("a refused creation sent an invite")
}
}
// ── AC-4 — the invite path enrols ────────────────────────────────────────────
// TestSpec010_AccountShowsTheUsersFactors: the account page reports the passkey
// count and whether TOTP is enrolled (SPEC-010 §4).
func TestSpec010_AccountShowsTheUsersFactors(t *testing.T) {
h, _ := spec010Door(t)
body := request(h.srv, "GET", "/"+h.guid+"/account", h.cookie()).Body.String()
if !strings.Contains(body, "Passkeys registered: 0") {
t.Errorf("the account page does not report the passkey count:\n%s", body)
}
if !strings.Contains(body, "TOTP): not enrolled") {
t.Errorf("the account page should report TOTP as not enrolled:\n%s", body)
}
// After a passkey and a verified TOTP code, both factors read as present.
h.putPasskey(t, h.session.IdentityID)
code := h.issueTOTP(t, h.session.IdentityID)
if err := h.totp.Verify(h.ctx, h.session.IdentityID, code); err != nil {
t.Fatalf("totp.Verify: %v", err)
}
body = request(h.srv, "GET", "/"+h.guid+"/account", h.cookie()).Body.String()
if !strings.Contains(body, "Passkeys registered: 1") {
t.Errorf("the account page does not count the passkey:\n%s", body)
}
if !strings.Contains(body, "TOTP): enrolled") {
t.Errorf("the account page does not report TOTP as enrolled:\n%s", body)
}
}
// TestSpec010AC4_InvitedUserEnrolsAtTheGUID: the emailed link opens the
// enrolment screen (SPEC-006's invite path — no new flow).
func TestSpec010AC4_InvitedUserEnrolsAtTheGUID(t *testing.T) {
h, _ := spec010Door(t)
body := createUser(t, h, "ada@example.com", "manager")
u, err := h.users.ByEmail(h.ctx, "ada@example.com")
if err != nil {
t.Fatalf("ByEmail: %v", err)
}
idn, err := h.ids.ByUserID(h.ctx, u.ID)
if err != nil {
t.Fatalf("ByUserID: %v", err)
}
if !strings.Contains(body, "/"+idn.GUID) {
t.Fatalf("the invite link was not shown")
}
rec := request(h.srv, "GET", "/"+idn.GUID)
if rec.Code != http.StatusOK {
t.Fatalf("GET the invite GUID = %d, want 200 (enroll, AC-4)", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Enrollment") {
t.Errorf("the invite GUID did not route to the enrolment screen (AC-4):\n%s", rec.Body.String())
}
}
// ── AC-5 — the passkey banner ────────────────────────────────────────────────
// TestSpec010AC5_BannerShowsWithoutAPasskeyAndClears: a signed-in user with no
// passkey sees the prominent prompt; it clears once a passkey exists.
func TestSpec010AC5_BannerShowsWithoutAPasskeyAndClears(t *testing.T) {
h, _ := spec010Door(t)
body := request(h.srv, "GET", "/"+h.guid, h.cookie()).Body.String()
if !strings.Contains(body, "You have no passkey") {
t.Fatalf("the admin with no passkey saw no banner (AC-5):\n%s", body)
}
if !strings.Contains(body, `href="/`+h.guid+`/account"`) {
t.Errorf("the banner does not link to the account page (AC-5):\n%s", body)
}
// Adding a passkey clears it.
h.putPasskey(t, h.session.IdentityID)
body = request(h.srv, "GET", "/"+h.guid, h.cookie()).Body.String()
if strings.Contains(body, "You have no passkey") {
t.Errorf("the banner persists after a passkey exists (AC-5):\n%s", body)
}
}
// TestSpec010AC5_OptingOutClearsTheBanner: "Don't ask again" (the account page's
// control) writes users.passkey_opt_out and silences the banner.
func TestSpec010AC5_OptingOutClearsTheBanner(t *testing.T) {
h, _ := spec010Door(t)
if !strings.Contains(request(h.srv, "GET", "/"+h.guid, h.cookie()).Body.String(), "You have no passkey") {
t.Fatal("precondition: the banner should show before opting out (AC-5)")
}
form := url.Values{"csrf": {h.csrf.Token(h.session.ID)}}
res := serveForm(h.srv, "/"+h.guid+"?do=account&action=account.passkey_opt_out", form, h.cookie())
if res.Code != http.StatusSeeOther {
t.Fatalf("opt out = %d, want 303 (AC-5)", res.Code)
}
if optOut, err := h.users.PasskeyOptOut(h.ctx, h.adminID); err != nil || !optOut {
t.Fatalf("the opt-out was not persisted (AC-5): %v", err)
}
if body := request(h.srv, "GET", "/"+h.guid, h.cookie()).Body.String(); strings.Contains(body, "You have no passkey") {
t.Errorf("the banner shows after opting out (AC-5):\n%s", body)
}
}
// TestSpec010AC5_NoBannerOnLoopback: a loopback origin suppresses the banner
// (a loopback passkey is unusable at the public domain — D-041).
func TestSpec010AC5_NoBannerOnLoopback(t *testing.T) {
mods, err := module.Load(nil)
if err != nil {
t.Fatalf("module.Load: %v", err)
}
h := newHarness(t, func(d *server.Deps) {
d.Config.Server.BaseURL = "http://127.0.0.1:8080"
reg := server.NewMapRegistry()
mods.Mount(reg)
accounts, err := account.New(account.Deps{
Users: d.Users, Identities: d.Identities, Credentials: d.Credentials,
BaseURL: d.Config.Server.BaseURL,
})
if err != nil {
t.Fatalf("account.New: %v", err)
}
accounts.Register(reg)
d.Registry = reg
})
body := request(h.srv, "GET", "/"+h.guid, h.cookie()).Body.String()
if strings.Contains(body, "You have no passkey") {
t.Errorf("the banner showed on a loopback origin (AC-5/D-041):\n%s", body)
}
}
// ── AC-6 — the role gate ─────────────────────────────────────────────────────
// TestSpec010AC6_UsersPageIsAdminOnly: a non-admin gets 403 on the Users page
// and its create action; the admin is admitted.
func TestSpec010AC6_UsersPageIsAdminOnly(t *testing.T) {
h, _ := spec010Door(t)
if rec := request(h.srv, "GET", "/"+h.guid+"/users", h.cookie()); rec.Code != http.StatusOK {
t.Fatalf("admin GET /users = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
guid, sid := spec010Team(t, h)
if rec := request(h.srv, "GET", "/"+guid+"/users", spec010Cookie(sid)); rec.Code != http.StatusForbidden {
t.Errorf("team GET /users = %d, want 403 (AC-6)", rec.Code)
}
form := url.Values{
"display_name": {"x"},
"email": {"x@example.com"},
"role": {"team"},
"csrf": {h.csrf.Token(sid)},
}
if rec := serveForm(h.srv, "/"+guid+"?do=users&action=users.create", form, spec010Cookie(sid)); rec.Code != http.StatusForbidden {
t.Errorf("team POST users.create = %d, want 403 (AC-6)", rec.Code)
}
}
// ── AC-7 — no inline scripts on the new screens ──────────────────────────────
// TestSpec010AC7_NewScreensHaveNoInlineScripts: the account and Users pages keep
// the CSP `script-src 'self'` intact (AC-7).
func TestSpec010AC7_NewScreensHaveNoInlineScripts(t *testing.T) {
h, _ := spec010Door(t)
for _, target := range []string{"/" + h.guid + "/account", "/" + h.guid + "/users"} {
rec := request(h.srv, "GET", target, h.cookie())
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d, want 200", target, rec.Code)
}
assertNoInlineScripts(t, target, rec.Body.String())
}
}
// ── helpers ──────────────────────────────────────────────────────────────────
// createUser creates a user through the door as the admin and returns the body
// (which carries the invite link).
func createUser(t *testing.T, h *harness, email, role string) string {
t.Helper()
form := url.Values{
"display_name": {"New User"},
"email": {email},
"role": {role},
"csrf": {h.csrf.Token(h.session.ID)},
}
res := serveForm(h.srv, "/"+h.guid+"?do=users&action=users.create", form, h.cookie())
if res.Code != http.StatusOK {
t.Fatalf("create user %s = %d, want 200 (body %q)", email, res.Code, res.Body.String())
}
return res.Body.String()
}