import type { AuthAdapter, EmailSendFn } from "@emdash-cms/auth"; import type { EmailMessage } from "@emdash-cms/auth"; import { Role, createInvite, createInviteToken, validateInvite, completeInvite, InviteError, escapeHtml, generateToken, } from "@emdash-cms/auth"; import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely"; import type { Kysely } from "kysely"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import type { Database } from "../../../src/database/types.js"; import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; // Regex patterns for token validation const TOKEN_PARAM_REGEX = /token=/; const TOKEN_EXTRACT_REGEX = /token=([a-zA-Z0-9_-]+)/; describe("Invite", () => { let db: Kysely; let adapter: AuthAdapter; let adminId: string; beforeEach(async () => { db = await setupTestDatabase(); adapter = createKyselyAdapter(db); // Create an admin user (required for the invitedBy FK) const admin = await adapter.createUser({ email: "admin@example.com", name: "Admin", role: Role.ADMIN, emailVerified: true, }); adminId = admin.id; }); afterEach(async () => { await teardownTestDatabase(db); }); describe("createInviteToken", () => { it("should create a token and return url + email", async () => { const result = await createInviteToken( { baseUrl: "https://example.com" }, adapter, "new@example.com", Role.AUTHOR, adminId, ); expect(result.email).toBe("new@example.com"); expect(result.url).toContain("https://example.com"); expect(result.url).toContain("/admin/invite/accept?token="); expect(result.url).toMatch(TOKEN_PARAM_REGEX); // Should NOT have a token field on the result expect("token" in result).toBe(false); }); it("should preserve baseUrl path prefix in invite URL", async () => { const result = await createInviteToken( { baseUrl: "https://example.com/_emdash" }, adapter, "path@example.com", Role.AUTHOR, adminId, ); expect(result.url).toContain("https://example.com/_emdash/admin/invite/accept"); }); it("should throw user_exists if email is already registered", async () => { await adapter.createUser({ email: "existing@example.com", name: "Existing", role: Role.AUTHOR, emailVerified: true, }); await expect( createInviteToken( { baseUrl: "https://example.com" }, adapter, "existing@example.com", Role.AUTHOR, adminId, ), ).rejects.toThrow(InviteError); try { await createInviteToken( { baseUrl: "https://example.com" }, adapter, "existing@example.com", Role.AUTHOR, adminId, ); } catch (error) { expect(error).toBeInstanceOf(InviteError); expect((error as InviteError).code).toBe("user_exists"); } }); }); describe("createInvite", () => { let mockEmailSend: EmailSendFn & ReturnType; let sentEmails: Array; beforeEach(() => { sentEmails = []; mockEmailSend = vi.fn(async (email: EmailMessage) => { sentEmails.push(email); }); }); it("should send email when email sender is provided", async () => { const result = await createInvite( { baseUrl: "https://example.com", siteName: "Test Site", email: mockEmailSend, }, adapter, "invite@example.com", Role.EDITOR, adminId, ); expect(mockEmailSend).toHaveBeenCalledOnce(); expect(sentEmails).toHaveLength(1); expect(sentEmails[0]!.to).toBe("invite@example.com"); expect(sentEmails[0]!.subject).toContain("Test Site"); expect(sentEmails[0]!.html).toContain("Accept Invite"); expect(sentEmails[0]!.text).toContain(result.url); }); it("should return url without sending email when no sender", async () => { const result = await createInvite( { baseUrl: "https://example.com", siteName: "Test Site", // No email sender — copy-link fallback }, adapter, "noemail@example.com", Role.AUTHOR, adminId, ); expect(result.url).toContain("https://example.com"); expect(result.url).toMatch(TOKEN_PARAM_REGEX); expect(result.email).toBe("noemail@example.com"); }); it("should HTML-escape siteName in email HTML body", async () => { await createInvite( { baseUrl: "https://example.com", siteName: '', email: mockEmailSend, }, adapter, "xss@example.com", Role.AUTHOR, adminId, ); expect(sentEmails).toHaveLength(1); const html = sentEmails[0]!.html!; // HTML body should be escaped expect(html).not.toContain("