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:
parent
5dc0a0150e
commit
23dc461118
2 changed files with 453 additions and 8 deletions
|
|
@ -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)
|
||||
|
|
|
|||
438
internal/core/server/spec010_account_admin_test.go
Normal file
438
internal/core/server/spec010_account_admin_test.go
Normal 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()
|
||||
}
|
||||
Loading…
Reference in a new issue