fix(account): do not claim the invite was emailed when mail is off

The create notice said "emailed the invitation" even when the mailer is the
no-op fallback (no SMTP host). It now reports that email is unconfigured and
points at the shown link — the copy/paste fallback the spec calls for — and
only claims a send when a real mailer is wired. Covered by a test.
This commit is contained in:
Claus Lohmar 2026-10-15 08:00:00 +00:00
parent 13133717e1
commit 3a258aeb55
2 changed files with 47 additions and 5 deletions

View file

@ -222,8 +222,17 @@ func (a *Actions) Create(w http.ResponseWriter, r *http.Request, rc *server.Requ
} }
link := a.inviteLink(guid) link := a.inviteLink(guid)
a.sendInvite(ctx, name, email, link) a.sendInvite(ctx, name, email, link)
return a.renderList(w, r, rc, http.StatusOK, return a.renderList(w, r, rc, http.StatusOK, a.inviteNotice(name), "notice", link)
fmt.Sprintf("Created %s and emailed the invitation.", 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. // createError maps a users.Create sentinel to an HTTP status + a human message.

View file

@ -34,12 +34,19 @@ func spec010Gate() *server.Tiers {
// ceremony the account page reuses) and the admin-only role gate. It returns // ceremony the account page reuses) and the admin-only role gate. It returns
// the harness and the recording mailer. // the harness and the recording mailer.
func spec010Door(t *testing.T) (*harness, *mail.Fake) { 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() t.Helper()
mods, err := module.Load(nil) mods, err := module.Load(nil)
if err != nil { if err != nil {
t.Fatalf("module.Load: %v", err) t.Fatalf("module.Load: %v", err)
} }
mailer := mail.NewFake()
h := newHarness(t, func(d *server.Deps) { h := newHarness(t, func(d *server.Deps) {
reg := server.NewMapRegistry() reg := server.NewMapRegistry()
@ -62,7 +69,7 @@ func spec010Door(t *testing.T) (*harness, *mail.Fake) {
}) })
web.SetNav(mods) web.SetNav(mods)
t.Cleanup(func() { web.SetNav(nil) }) t.Cleanup(func() { web.SetNav(nil) })
return h, mailer return h
} }
// spec010Team creates an ACTIVE team member under the admin (for the non-admin // 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 // TestSpec010_AccountShowsTheUsersFactors: the account page reports the passkey
// count and whether TOTP is enrolled (SPEC-010 §4). // count and whether TOTP is enrolled (SPEC-010 §4).
func TestSpec010_AccountShowsTheUsersFactors(t *testing.T) { 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 // TestSpec010AC4_InvitedUserEnrolsAtTheGUID: the emailed link opens the
// enrolment screen (SPEC-006's invite path — no new flow). // enrolment screen (SPEC-006's invite path — no new flow).
func TestSpec010AC4_InvitedUserEnrolsAtTheGUID(t *testing.T) { func TestSpec010AC4_InvitedUserEnrolsAtTheGUID(t *testing.T) {