d75380eb9a
Deploy to route.crispygoat.com / deploy (push) Successful in 4m18s
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.
454 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|