/** * Unit tests for `createAdminUser` in `src/actions/admin/users.ts`. * * The action does three things, in order: * 1. Authorize the caller (platform_admin only) — tested via * `getAdminUser` mock. * 2. Create a Neon Auth account (via `auth.admin.createUser` first, * with a public sign-up fallback). * 3. Insert the local `admin_users` row + brand link inside a tx, * send a welcome email (best-effort), and return the result. * * These tests mock the Neon Auth SDK, the DB layer, and the email * service so the orchestration logic can be exercised without a real * network or DB. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Use vi.hoisted to make mock fns available inside the hoisted // vi.mock factories. const { mockGetAdminUser, mockNeonAuthCreateUser, mockQuery, mockWithTx, mockSendWelcomeEmail, mockGetBrandSettings, } = vi.hoisted(() => ({ mockGetAdminUser: vi.fn(), mockNeonAuthCreateUser: vi.fn(), mockQuery: vi.fn(), mockWithTx: vi.fn(), mockSendWelcomeEmail: vi.fn(), mockGetBrandSettings: vi.fn(), })); vi.mock("server-only", () => ({})); vi.mock("@/lib/admin-permissions", () => ({ getAdminUser: mockGetAdminUser, })); vi.mock("@/lib/auth", () => ({ createUser: mockNeonAuthCreateUser, })); vi.mock("@/lib/db", () => ({ query: mockQuery, withTx: mockWithTx, })); vi.mock("@/lib/email-service", () => ({ sendWelcomeEmail: mockSendWelcomeEmail, })); vi.mock("@/actions/brand-settings", () => ({ getBrandSettings: mockGetBrandSettings, })); vi.mock("next/headers", () => ({ cookies: () => Promise.resolve({ get: () => undefined }), })); // `fetch` is used by the signup fallback. Default to a harmless // 200 — tests that exercise the fallback override this. const originalFetch = global.fetch; import { createAdminUser } from "@/actions/admin/users"; const PLATFORM_ADMIN = { id: "platform-1", email: "root@example.com", role: "platform_admin" as const, brand_id: null, brand_slug: null, brand_ids: [], display_name: "Root", active: true, auth_provider: "neon_auth" as const, can_manage_products: true, can_manage_stops: true, can_manage_orders: true, can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true, can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, can_manage_settings: true, can_manage_billing: true, can_manage_branding: true, can_manage_marketing: true, can_manage_team: true, must_change_password: false, user_id: "platform-1", }; const BRAND_ADMIN = { ...PLATFORM_ADMIN, role: "brand_admin" as const, brand_id: "brand-tux" }; const baseInput = { email: "newuser@example.com", password: "TempPass123!", role: "brand_admin" as const, brand_id: "brand-tux", display_name: "New User", flags: { can_manage_orders: true }, mustChangePassword: true, }; beforeEach(() => { vi.clearAllMocks(); // Default: platform admin caller, no-op fetch, happy email path. mockGetAdminUser.mockResolvedValue(PLATFORM_ADMIN); mockSendWelcomeEmail.mockResolvedValue(true); mockGetBrandSettings.mockResolvedValue({ success: true, settings: { brand_name: "Tuxedo Citrus", logo_url: null }, }); // Set env vars the action reads at call time. process.env.NEON_AUTH_BASE_URL = "https://test.neonauth.example/auth"; process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:4000"; global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ user: { id: "neon-user-from-signup" } }), } as Response); }); afterEach(() => { global.fetch = originalFetch; }); describe("createAdminUser — authorization", () => { it("rejects unauthenticated callers", async () => { mockGetAdminUser.mockResolvedValue(null); const r = await createAdminUser(baseInput); expect(r.user).toBeNull(); expect(r.error).toMatch(/not authenticated/i); expect(mockNeonAuthCreateUser).not.toHaveBeenCalled(); expect(mockWithTx).not.toHaveBeenCalled(); }); it("rejects non-platform-admin callers", async () => { mockGetAdminUser.mockResolvedValue(BRAND_ADMIN); const r = await createAdminUser(baseInput); expect(r.user).toBeNull(); expect(r.error).toMatch(/only platform admins/i); expect(mockNeonAuthCreateUser).not.toHaveBeenCalled(); }); }); describe("createAdminUser — happy path via auth.admin.createUser", () => { it("uses the privileged admin endpoint when it succeeds", async () => { mockNeonAuthCreateUser.mockResolvedValue({ data: { user: { id: "neon-user-abc" } }, error: null, }); mockWithTx.mockImplementation(async (fn) => { // Simulate the tx client: record what the action called, then // return a fresh admin row. return fn({ query: vi .fn() .mockResolvedValueOnce({ rows: [ { id: "admin-row-1", user_id: "neon-user-abc", display_name: "New User", email: "newuser@example.com", phone_number: null, role: "brand_admin", brand_id: "brand-tux", can_manage_products: false, can_manage_stops: false, can_manage_orders: true, can_manage_pickup: false, can_manage_messages: false, can_manage_refunds: false, can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, active: true, must_change_password: true, created_at: "2026-06-17T00:00:00Z", last_login: null, }, ], }) // Second query inside the tx: admin_user_brands link. .mockResolvedValueOnce({ rows: [] }), } as never); }); const r = await createAdminUser(baseInput); expect(r.error).toBeNull(); expect(r.user?.email).toBe("newuser@example.com"); expect(r.tempPassword).toBe(baseInput.password); expect(r.emailSent).toBe(true); expect(r.authPath).toBe("admin"); expect(mockNeonAuthCreateUser).toHaveBeenCalledWith({ email: "newuser@example.com", password: "TempPass123!", name: "New User", }); // Fallback should NOT have been called. expect(global.fetch).not.toHaveBeenCalled(); }); }); describe("createAdminUser — fallback to public sign-up", () => { it("falls back to /sign-up/email when the admin endpoint fails with FORBIDDEN", async () => { mockNeonAuthCreateUser.mockResolvedValue({ data: null, error: { code: "FORBIDDEN", message: "Admin role required" }, }); mockWithTx.mockImplementation(async (fn) => fn({ query: vi .fn() .mockResolvedValueOnce({ rows: [ { id: "admin-row-2", user_id: "neon-user-from-signup", display_name: "New User", email: "newuser@example.com", phone_number: null, role: "brand_admin", brand_id: "brand-tux", can_manage_products: false, can_manage_stops: false, can_manage_orders: true, can_manage_pickup: false, can_manage_messages: false, can_manage_refunds: false, can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, active: true, must_change_password: true, created_at: "2026-06-17T00:00:00Z", last_login: null, }, ], }) .mockResolvedValueOnce({ rows: [] }), } as never), ); // The fallback calls `query` to flip emailVerified=true. Pre-set // a default that satisfies that call (and any future ones). mockQuery.mockResolvedValue({ rows: [] }); const r = await createAdminUser(baseInput); expect(r.error).toBeNull(); expect(r.authPath).toBe("signup"); expect(r.user?.user_id).toBe("neon-user-from-signup"); expect(global.fetch).toHaveBeenCalledTimes(1); const [url, init] = (global.fetch as unknown as ReturnType).mock.calls[0]; expect(url).toMatch(/\/sign-up\/email$/); expect(JSON.parse(init.body)).toEqual({ email: "newuser@example.com", password: "TempPass123!", name: "New User", callbackURL: "/admin", }); }); it("rejects with a clear error when the email is already in use", async () => { mockNeonAuthCreateUser.mockResolvedValue({ data: null, error: { code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL", message: "User already exists", }, }); const r = await createAdminUser(baseInput); expect(r.user).toBeNull(); expect(r.error).toMatch(/already exists/i); // We should not have called the fallback or the DB. expect(global.fetch).not.toHaveBeenCalled(); expect(mockWithTx).not.toHaveBeenCalled(); }); it("surfaces the fallback error if the public sign-up fails", async () => { mockNeonAuthCreateUser.mockResolvedValue({ data: null, error: { code: "INTERNAL_SERVER_ERROR", message: "boom" }, }); global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({ code: "INTERNAL_SERVER_ERROR", message: "sign-up down" }), } as Response); const r = await createAdminUser(baseInput); expect(r.user).toBeNull(); expect(r.error).toMatch(/sign-up down/i); expect(mockWithTx).not.toHaveBeenCalled(); }); }); describe("createAdminUser — DB failure after auth success", () => { it("returns an error explaining the orphaned Neon Auth user", async () => { mockNeonAuthCreateUser.mockResolvedValue({ data: { user: { id: "neon-user-orphaned" } }, error: null, }); mockWithTx.mockRejectedValue(new Error("FK violation on brand_id")); const r = await createAdminUser(baseInput); expect(r.user).toBeNull(); expect(r.error).toMatch(/orphaned/i); expect(r.error).toMatch(/FK violation/); }); }); describe("createAdminUser — email is best-effort", () => { it("still returns success when the welcome email fails", async () => { mockNeonAuthCreateUser.mockResolvedValue({ data: { user: { id: "neon-user-abc" } }, error: null, }); mockWithTx.mockImplementation(async (fn) => fn({ query: vi.fn().mockResolvedValueOnce({ rows: [ { id: "admin-row-3", user_id: "neon-user-abc", display_name: "New User", email: "newuser@example.com", phone_number: null, role: "brand_admin", brand_id: "brand-tux", can_manage_products: false, can_manage_stops: false, can_manage_orders: true, can_manage_pickup: false, can_manage_messages: false, can_manage_refunds: false, can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, active: true, must_change_password: true, created_at: "2026-06-17T00:00:00Z", last_login: null, }, ], }), } as never), ); mockSendWelcomeEmail.mockResolvedValue(false); const r = await createAdminUser(baseInput); expect(r.error).toBeNull(); expect(r.user).not.toBeNull(); expect(r.emailSent).toBe(false); expect(r.tempPassword).toBe(baseInput.password); }); it("records the email error message when sendWelcomeEmail throws", async () => { mockNeonAuthCreateUser.mockResolvedValue({ data: { user: { id: "neon-user-abc" } }, error: null, }); mockWithTx.mockImplementation(async (fn) => fn({ query: vi.fn().mockResolvedValueOnce({ rows: [ { id: "admin-row-4", user_id: "neon-user-abc", display_name: "New User", email: "newuser@example.com", phone_number: null, role: "brand_admin", brand_id: "brand-tux", can_manage_products: false, can_manage_stops: false, can_manage_orders: true, can_manage_pickup: false, can_manage_messages: false, can_manage_refunds: false, can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, active: true, must_change_password: true, created_at: "2026-06-17T00:00:00Z", last_login: null, }, ], }), } as never), ); mockSendWelcomeEmail.mockRejectedValue(new Error("Resend 401")); const r = await createAdminUser(baseInput); expect(r.error).toBeNull(); expect(r.emailSent).toBe(false); expect(r.emailError).toMatch(/Resend 401/); }); }); describe("createAdminUser — no brand", () => { it("creates a platform admin without inserting an admin_user_brands link", async () => { mockNeonAuthCreateUser.mockResolvedValue({ data: { user: { id: "neon-user-platform" } }, error: null, }); const txClient = { query: vi.fn().mockResolvedValueOnce({ rows: [ { id: "admin-row-platform", user_id: "neon-user-platform", display_name: "New Platform", email: "platform2@example.com", phone_number: null, role: "platform_admin", brand_id: null, can_manage_products: true, can_manage_stops: true, can_manage_orders: true, can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true, can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, active: true, must_change_password: true, created_at: "2026-06-17T00:00:00Z", last_login: null, }, ], }), }; mockWithTx.mockImplementation(async (fn) => fn(txClient as never)); const r = await createAdminUser({ ...baseInput, email: "platform2@example.com", role: "platform_admin", brand_id: null, }); expect(r.error).toBeNull(); expect(r.user?.role).toBe("platform_admin"); // Only one query inside the tx: the INSERT. No brand link INSERT. expect(txClient.query).toHaveBeenCalledTimes(1); }); });