diff --git a/internal/core/server/server_test.go b/internal/core/server/server_test.go index d8d1153..b839867 100644 --- a/internal/core/server/server_test.go +++ b/internal/core/server/server_test.go @@ -212,11 +212,14 @@ func newHarness(t *testing.T, opts ...func(*server.Deps)) *harness { Resolver: ids, Sessions: sessions, Identities: ids, - Users: us, - Audit: aud, - CSRF: csrf, - PreSession: pre, - SetupGate: gate, + // 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) @@ -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) diff --git a/internal/core/server/spec010_account_admin_test.go b/internal/core/server/spec010_account_admin_test.go new file mode 100644 index 0000000..ef7d4e8 --- /dev/null +++ b/internal/core/server/spec010_account_admin_test.go @@ -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() +}