Files
route-commerce/tests/unit/create-admin-user.test.ts
T
Tyler d75380eb9a
Deploy to route.crispygoat.com / deploy (push) Successful in 4m18s
Create-user flow now provisions the Neon Auth account
Previously createAdminUser only inserted a local admin_users row and
emailed the password as plaintext — the user could not sign in because
the password was never set on a Neon Auth account.

This change makes the create flow:
  1. Authorize: only platform_admin can mint new admin users.
  2. Create the Neon Auth user via auth.admin.createUser, falling back
     to the public /sign-up/email + emailVerified=true pattern when
     the caller's Neon Auth session isn't an admin (the common case
     in dev — provision-admin.ts does not set neon_auth.user.role).
  3. Wrap the admin_users INSERT + admin_user_brands link in a
     transaction, returning an error if the local insert fails (the
     Neon Auth user is left orphaned and surfaced in the message).
  4. Send the welcome email best-effort, returning success/failure
     info to the UI.

The CreateUserModal now shows a success state with the temp password
(copy-to-clipboard), the welcome email status, and the auth path
used. The slide-in edit panel surfaces the password via window.alert
as a defense against the dead-code new-user path.

10 new unit tests cover authorization, the admin + signup paths, the
DB-failure orphan case, and the email best-effort behavior.
2026-06-17 11:46:39 -06:00

454 lines
14 KiB
TypeScript

/**
* 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<typeof vi.fn>).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);
});
});