import { describe, expect, it, vi } from "vitest"; import { createIdentityNotifications, type IdentityNotificationFailure, } from "../src/identity/notifications.js"; import { MailError, type MailProvider, type ValidatedMailMessage } from "../src/mail/index.js"; /** * The identity feature's two outbound messages (ADR 0038). * * The properties that matter here are all negative ones: that a link is built from configuration * rather than from anything a request supplied, that a token never reaches a log, that a delivery * failure never becomes an observable difference between "this address has an account" and "it does * not", and that identity code learns nothing about SMTP. */ const authBaseUrl = "https://api.example.test"; const dashboardOrigin = "https://dashboard.example.test"; const token = "verification-token-abc123"; interface Harness { readonly sent: ValidatedMailMessage[]; readonly failures: IdentityNotificationFailure[]; readonly notifications: ReturnType; } function harness( options: { readonly mail?: MailProvider | undefined; readonly configured?: boolean } = {}, ): Harness { const sent: ValidatedMailMessage[] = []; const failures: IdentityNotificationFailure[] = []; const provider: MailProvider = options.mail ?? { close: async () => undefined, send: async (message) => { sent.push(message); return { messageId: "provider-message-id" }; }, verify: async () => undefined, }; return { failures, notifications: createIdentityNotifications({ authBaseUrl, dashboardOrigin, mail: options.configured === false ? undefined : provider, report: (failure) => failures.push(failure), }), sent, }; } function only(sent: readonly ValidatedMailMessage[]): ValidatedMailMessage { expect(sent).toHaveLength(1); return sent[0] as ValidatedMailMessage; } /** Both bodies, so an assertion can never pass because it only looked at the plaintext one. */ function bodies(message: ValidatedMailMessage): string { return `${message.text ?? ""}\n${message.html ?? ""}`; } describe("identity verification email", () => { it("sends one message to the account's own address, with both bodies", async () => { const test = harness(); await test.notifications.sendVerificationEmail({ token, user: { email: "person@example.org", name: "Sam" }, }); const message = only(test.sent); expect(message.to).toEqual(["person@example.org"]); expect(message.subject).toBe("Verify your Ekklesia email address"); expect(message.text).toContain("Hello Sam,"); expect(message.html).toContain("

Hello Sam,

"); expect(bodies(message)).toContain("This link expires in one hour."); expect(bodies(message)).toContain("you can ignore it"); }); it("builds the link from configuration alone, on the Dashboard origin", async () => { const test = harness(); await test.notifications.sendVerificationEmail({ token, user: { email: "p@example.org" } }); const text = only(test.sent).text ?? ""; const link = text.split("\n").find((line) => line.startsWith("https://")) ?? ""; const url = new URL(link); // Better Auth's own endpoint, so Better Auth remains the only thing that acts on the token. expect(url.origin).toBe(authBaseUrl); expect(url.pathname).toBe("/api/auth/verify-email"); expect(url.searchParams.get("token")).toBe(token); // ...and the destination it will redirect to is the Dashboard, never the API origin. const callback = new URL(url.searchParams.get("callbackURL") ?? ""); expect(callback.origin).toBe(dashboardOrigin); expect(callback.searchParams.get("ekklesia_auth")).toBe("verify-email"); }); it("never names the sender, the relay, or any transport detail", async () => { const test = harness(); await test.notifications.sendVerificationEmail({ token, user: { email: "p@example.org" } }); // The message contract has no `from` field at all, so identity code cannot choose a sender. expect(Object.keys(only(test.sent))).not.toContain("from"); const serialized = JSON.stringify(only(test.sent)).toLowerCase(); for (const forbidden of ["smtp", "starttls", "password", "587", "465"]) { expect(serialized).not.toContain(forbidden); } }); }); describe("identity password-reset email", () => { it("sends one message with a link on Better Auth's reset callback", async () => { const test = harness(); await test.notifications.sendResetPassword({ token: "reset-token-xyz", user: { email: "person@example.org", name: "Sam" }, }); const message = only(test.sent); expect(message.to).toEqual(["person@example.org"]); expect(message.subject).toBe("Reset your Ekklesia password"); const link = (message.text ?? "").split("\n").find((line) => line.startsWith("https://")) ?? ""; const url = new URL(link); expect(url.origin).toBe(authBaseUrl); expect(url.pathname).toBe("/api/auth/reset-password/reset-token-xyz"); const callback = new URL(url.searchParams.get("callbackURL") ?? ""); expect(callback.origin).toBe(dashboardOrigin); expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password"); }); it("carries no password, hash, or session material", async () => { const test = harness(); await test.notifications.sendResetPassword({ token: "reset-token-xyz", user: { email: "person@example.org" }, }); const serialized = bodies(only(test.sent)).toLowerCase(); expect(serialized).not.toContain("hash"); expect(serialized).not.toContain("session"); // It does say the current password still works, which is the reassurance the flow needs. expect(serialized).toContain("current password stays in place"); }); }); describe("identity notification failures", () => { it("resolves rather than throwing when the relay fails, and reports a sanitized code", async () => { const test = harness({ mail: { close: async () => undefined, send: async () => { throw new MailError("MAIL_PROVIDER_UNAVAILABLE"); }, verify: async () => undefined, }, }); // Resolving is the security property: Better Auth's own `/send-verification-email` rethrows // whatever this throws, which would make "account exists" observable the moment SMTP broke. await expect( test.notifications.sendVerificationEmail({ token, user: { email: "p@example.org" } }), ).resolves.toBeUndefined(); expect(test.failures).toEqual([ { kind: "email-verification", reason: "MAIL_PROVIDER_UNAVAILABLE" }, ]); }); it("collapses an unrecognized thrown value rather than forwarding it", async () => { const test = harness({ mail: { close: async () => undefined, send: async () => { throw new Error("connect ECONNREFUSED 10.40.0.2:587 user=relay@example.org pass=hunter2"); }, verify: async () => undefined, }, }); await test.notifications.sendResetPassword({ token: "t", user: { email: "p@example.org" } }); expect(test.failures).toEqual([{ kind: "password-reset", reason: "MAIL_SEND_FAILED" }]); const serialized = JSON.stringify(test.failures); expect(serialized).not.toContain("hunter2"); expect(serialized).not.toContain("10.40.0.2"); }); it("reports MAIL_NOT_CONFIGURED, and still resolves, when the installation has no mail", async () => { const test = harness({ configured: false }); await test.notifications.sendVerificationEmail({ token, user: { email: "p@example.org" } }); await test.notifications.sendResetPassword({ token: "t", user: { email: "p@example.org" } }); expect(test.failures).toEqual([ { kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" }, { kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" }, ]); expect(test.sent).toHaveLength(0); }); it("writes one fixed line by default, carrying neither the address nor the token", async () => { const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); try { const notifications = createIdentityNotifications({ authBaseUrl, dashboardOrigin, mail: undefined, }); await notifications.sendResetPassword({ token: "reset-token-xyz", user: { email: "secret-person@example.org" }, }); expect(errors).toHaveBeenCalledTimes(1); const line = String(errors.mock.calls[0]?.[0]); expect(line).toBe("[identity] password-reset email was not delivered: MAIL_NOT_CONFIGURED"); expect(line).not.toContain("secret-person"); expect(line).not.toContain("reset-token-xyz"); } finally { errors.mockRestore(); } }); }); describe("identity notification message safety", () => { it("escapes a display name rather than letting it close an element", async () => { const test = harness(); await test.notifications.sendVerificationEmail({ token, user: { email: "p@example.org", name: 'Sam">' }, }); const message = only(test.sent); expect(message.html).not.toContain("