diff --git a/internal/core/account/account.go b/internal/core/account/account.go index e68ac37..7b11673 100644 --- a/internal/core/account/account.go +++ b/internal/core/account/account.go @@ -222,8 +222,17 @@ func (a *Actions) Create(w http.ResponseWriter, r *http.Request, rc *server.Requ } link := a.inviteLink(guid) a.sendInvite(ctx, name, email, link) - return a.renderList(w, r, rc, http.StatusOK, - fmt.Sprintf("Created %s and emailed the invitation.", name), "notice", link) + return a.renderList(w, r, rc, http.StatusOK, a.inviteNotice(name), "notice", link) +} + +// inviteNotice is the outcome line after a successful create. It does not claim +// an email was sent when mail is not configured — the link below is the +// fallback the admin shares by hand (SPEC-010 §5). +func (a *Actions) inviteNotice(name string) string { + if _, nop := a.d.Mailer.(mail.Nop); nop || a.d.Mailer == nil { + return fmt.Sprintf("Created %s. Email is not configured — share the invitation link below.", name) + } + return fmt.Sprintf("Created %s and emailed the invitation.", name) } // createError maps a users.Create sentinel to an HTTP status + a human message. diff --git a/internal/core/server/spec010_account_admin_test.go b/internal/core/server/spec010_account_admin_test.go index ef7d4e8..aef6d25 100644 --- a/internal/core/server/spec010_account_admin_test.go +++ b/internal/core/server/spec010_account_admin_test.go @@ -34,12 +34,19 @@ func spec010Gate() *server.Tiers { // 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() + mailer := mail.NewFake() + return spec010DoorWith(t, mailer), mailer +} + +// spec010DoorWith is spec010Door with an explicit mailer (a test can pass +// mail.Nop{} to exercise the "email is not configured" path). +func spec010DoorWith(t *testing.T, mailer mail.Mailer) *harness { 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() @@ -62,7 +69,7 @@ func spec010Door(t *testing.T) (*harness, *mail.Fake) { }) web.SetNav(mods) t.Cleanup(func() { web.SetNav(nil) }) - return h, mailer + return h } // spec010Team creates an ACTIVE team member under the admin (for the non-admin @@ -245,8 +252,32 @@ func TestSpec010AC3_CannotCreateARoleAtOrAboveYourOwn(t *testing.T) { } } -// ── AC-4 — the invite path enrols ──────────────────────────────────────────── +// TestSpec010AC3_NoMailStillShowsTheLink: with mail unconfigured the invite is +// not claimed to be emailed — the link is the fallback (SPEC-010 §5). +func TestSpec010AC3_NoMailStillShowsTheLink(t *testing.T) { + h := spec010DoorWith(t, mail.Nop{}) + body := createUser(t, h, "nop@example.com", "team") + u, err := h.users.ByEmail(h.ctx, "nop@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.Errorf("the invite link is not shown when mail is unconfigured:\n%s", body) + } + if strings.Contains(body, "emailed the invitation") { + t.Errorf("the page claims an email was sent with no mailer configured:\n%s", body) + } + if !strings.Contains(body, "not configured") { + t.Errorf("the page does not say mail is unconfigured:\n%s", body) + } +} + +// ── the account page's factors ─────────────────────────────────────────────── // TestSpec010_AccountShowsTheUsersFactors: the account page reports the passkey // count and whether TOTP is enrolled (SPEC-010 §4). func TestSpec010_AccountShowsTheUsersFactors(t *testing.T) { @@ -275,6 +306,8 @@ func TestSpec010_AccountShowsTheUsersFactors(t *testing.T) { } } +// ── AC-4 — the invite path enrols ──────────────────────────────────────────── + // TestSpec010AC4_InvitedUserEnrolsAtTheGUID: the emailed link opens the // enrolment screen (SPEC-006's invite path — no new flow). func TestSpec010AC4_InvitedUserEnrolsAtTheGUID(t *testing.T) {