Create-user flow now provisions the Neon Auth account
Deploy to route.crispygoat.com / deploy (push) Successful in 4m18s
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.
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user