eb37df347e
Deploy to route.crispygoat.com / deploy (push) Successful in 4m14s
Both buttons in /admin/users were broken:
- "Send Reset Email" called a no-op stub in
src/actions/admin/users.ts that always returned an error.
- "Reset Password" called resetAdminPassword with a hard-coded
'Tuxedo2026!' password, and the function itself queried a
non-existent `users` table and called a non-existent
`update_user_password` RPC (leftover Supabase-era code).
Rewritten against Neon Auth:
- sendPasswordResetEmail(email) — platform_admin-only action that
calls auth.requestPasswordReset with the configured
NEXT_PUBLIC_SITE_URL + '/reset-password' redirect. Always returns
a clear success/error.
- resetAdminPassword(email) — platform_admin-only action that:
1. Looks up neon_auth.user by email
2. Generates a strong server-side random temp password
3. Tries auth.admin.setUserPassword first (instant credential,
returned to the UI for the platform admin to share)
4. On FORBIDDEN / UNAUTHORIZED / INTERNAL_SERVER_ERROR (the common
case — provision-admin.ts does not promote callers to
role='admin' in Neon Auth), falls back to
auth.requestPasswordReset, which sends a reset link the user
can click to set their own password.
5. On the privileged path, flips admin_users.must_change_password
so the user is forced to pick a real password on next sign-in.
UI updated to handle the new response shape (success-with-temp-
password vs success-with-reset-email-sent) with a fourth modal
state.
Tests: 19 new unit tests across both paths cover authz, input
handling, the privileged happy path, the FORBIDDEN/UNAUTHORIZED/
network-throw fallback, USER_NOT_FOUND surfacing, and the
'both paths fail' case. Full suite: 110/113 (3 pre-existing
getAdminUser failures unchanged).
240 lines
8.4 KiB
TypeScript
240 lines
8.4 KiB
TypeScript
/**
|
|
* Unit tests for `resetAdminPassword` in `src/actions/admin/reset-admin.ts`.
|
|
*
|
|
* The action must:
|
|
* 1. Reject unauthenticated callers and non-platform-admins
|
|
* 2. Look up `neon_auth.user` by email (NOT the legacy `users` table)
|
|
* 3. Try `auth.admin.setUserPassword` first (instant credential)
|
|
* 4. Fall back to `auth.requestPasswordReset` when the admin
|
|
* endpoint returns FORBIDDEN / UNAUTHORIZED / INTERNAL_SERVER_ERROR
|
|
* 5. On success, set `must_change_password = true` for the user
|
|
* 6. Generate a strong server-side random password (no hard-coded
|
|
* defaults like "Tuxedo2026!")
|
|
* 7. Return clear errors when no Neon Auth user matches the email
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
const {
|
|
mockGetAdminUser,
|
|
mockQuery,
|
|
mockSetUserPassword,
|
|
mockRequestPasswordReset,
|
|
} = vi.hoisted(() => ({
|
|
mockGetAdminUser: vi.fn(),
|
|
mockQuery: vi.fn(),
|
|
mockSetUserPassword: vi.fn(),
|
|
mockRequestPasswordReset: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("server-only", () => ({}));
|
|
|
|
vi.mock("@/lib/admin-permissions", () => ({
|
|
getAdminUser: mockGetAdminUser,
|
|
}));
|
|
|
|
vi.mock("@/lib/auth", () => ({
|
|
setUserPassword: mockSetUserPassword,
|
|
requestPasswordReset: mockRequestPasswordReset,
|
|
}));
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
query: mockQuery,
|
|
}));
|
|
|
|
vi.mock("next/headers", () => ({
|
|
cookies: () => Promise.resolve({ get: () => undefined }),
|
|
}));
|
|
|
|
import { resetAdminPassword } from "@/actions/admin/reset-admin";
|
|
|
|
const PLATFORM_ADMIN = {
|
|
id: "platform-1",
|
|
user_id: "platform-1",
|
|
email: "root@example.com",
|
|
display_name: "Root",
|
|
brand_id: null,
|
|
brand_ids: [],
|
|
brand_slug: null,
|
|
role: "platform_admin" as const,
|
|
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,
|
|
};
|
|
|
|
const BRAND_ADMIN = {
|
|
...PLATFORM_ADMIN,
|
|
role: "brand_admin" as const,
|
|
brand_id: "brand-tux",
|
|
brand_ids: ["brand-tux"],
|
|
brand_slug: "tuxedo",
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:4000";
|
|
mockGetAdminUser.mockResolvedValue(PLATFORM_ADMIN);
|
|
// Default: Neon Auth user lookup succeeds.
|
|
mockQuery.mockImplementation(async (sql: string) => {
|
|
if (sql.includes("SELECT id FROM neon_auth.user")) {
|
|
return { rows: [{ id: "neon-user-1" }] };
|
|
}
|
|
// UPDATE admin_users SET must_change_password — accept silently.
|
|
return { rows: [], rowCount: 1 };
|
|
});
|
|
// Default: setUserPassword succeeds.
|
|
mockSetUserPassword.mockResolvedValue({ data: { ok: true }, error: null });
|
|
mockRequestPasswordReset.mockResolvedValue({ data: { ok: true }, error: null });
|
|
});
|
|
|
|
describe("resetAdminPassword — authorization", () => {
|
|
it("rejects unauthenticated callers", async () => {
|
|
mockGetAdminUser.mockResolvedValue(null);
|
|
const r = await resetAdminPassword("user@example.com");
|
|
expect(r.success).toBe(false);
|
|
expect(r.error).toMatch(/not authenticated/i);
|
|
expect(mockSetUserPassword).not.toHaveBeenCalled();
|
|
expect(mockRequestPasswordReset).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects non-platform-admin callers", async () => {
|
|
mockGetAdminUser.mockResolvedValue(BRAND_ADMIN);
|
|
const r = await resetAdminPassword("user@example.com");
|
|
expect(r.success).toBe(false);
|
|
expect(r.error).toMatch(/only platform admins/i);
|
|
expect(mockSetUserPassword).not.toHaveBeenCalled();
|
|
expect(mockRequestPasswordReset).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("resetAdminPassword — input handling", () => {
|
|
it("rejects an empty email", async () => {
|
|
const r = await resetAdminPassword(" ");
|
|
expect(r.success).toBe(false);
|
|
expect(r.error).toMatch(/required/i);
|
|
});
|
|
|
|
it("looks up the user in neon_auth.user (not the legacy `users` table)", async () => {
|
|
const r = await resetAdminPassword("User@Example.COM");
|
|
expect(r.success).toBe(true);
|
|
expect(mockQuery).toHaveBeenCalledWith(
|
|
"SELECT id FROM neon_auth.user WHERE email = $1 LIMIT 1",
|
|
["user@example.com"],
|
|
);
|
|
});
|
|
|
|
it("returns a clear error when no Neon Auth user matches the email", async () => {
|
|
mockQuery.mockImplementation(async (sql: string) => {
|
|
if (sql.includes("SELECT id FROM neon_auth.user")) {
|
|
return { rows: [] };
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
});
|
|
const r = await resetAdminPassword("missing@example.com");
|
|
expect(r.success).toBe(false);
|
|
expect(r.error).toMatch(/no neon auth user found/i);
|
|
expect(mockSetUserPassword).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("resetAdminPassword — happy path via setUserPassword", () => {
|
|
it("returns a server-generated temp password and flips must_change_password", async () => {
|
|
const r = await resetAdminPassword("user@example.com");
|
|
expect(r.success).toBe(true);
|
|
if (r.method !== "set") throw new Error("expected method=set");
|
|
// The password is server-generated — must be a non-empty strong
|
|
// string, never a hard-coded constant like "Tuxedo2026!".
|
|
expect(r.tempPassword.length).toBeGreaterThan(20);
|
|
expect(r.tempPassword).not.toBe("Tuxedo2026!");
|
|
expect(r.tempPassword).toMatch(/[!@#$%^&*]/); // includes a symbol
|
|
|
|
expect(mockSetUserPassword).toHaveBeenCalledTimes(1);
|
|
const [args] = mockSetUserPassword.mock.calls[0];
|
|
expect(args.userId).toBe("neon-user-1");
|
|
expect(args.newPassword).toBe(r.tempPassword);
|
|
|
|
// The must_change_password flag should be flipped.
|
|
const flagUpdate = mockQuery.mock.calls.find(([sql]) =>
|
|
String(sql).includes("UPDATE admin_users SET must_change_password"),
|
|
);
|
|
expect(flagUpdate).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("resetAdminPassword — FORBIDDEN fallback to requestPasswordReset", () => {
|
|
it("falls back to the public reset-email endpoint when setUserPassword returns FORBIDDEN", async () => {
|
|
mockSetUserPassword.mockResolvedValue({
|
|
data: null,
|
|
error: { code: "FORBIDDEN", message: "Admin role required" },
|
|
});
|
|
const r = await resetAdminPassword("user@example.com");
|
|
expect(r.success).toBe(true);
|
|
if (r.method !== "reset_link_sent") throw new Error("expected method=reset_link_sent");
|
|
expect(r.message).toMatch(/reset email/i);
|
|
expect(mockRequestPasswordReset).toHaveBeenCalledWith({
|
|
email: "user@example.com",
|
|
redirectTo: "http://localhost:4000/reset-password",
|
|
});
|
|
// In the fallback case, no tempPassword is returned and the
|
|
// must_change_password flag is NOT flipped (the user picks their
|
|
// own password via the link).
|
|
if (r.method === "set") throw new Error("unreachable");
|
|
expect((r as { tempPassword?: string }).tempPassword).toBeUndefined();
|
|
});
|
|
|
|
it("falls back when setUserPassword returns UNAUTHORIZED", async () => {
|
|
mockSetUserPassword.mockResolvedValue({
|
|
data: null,
|
|
error: { code: "UNAUTHORIZED", message: "no session" },
|
|
});
|
|
const r = await resetAdminPassword("user@example.com");
|
|
expect(r.success).toBe(true);
|
|
expect(r.method).toBe("reset_link_sent");
|
|
});
|
|
|
|
it("falls back when setUserPassword throws", async () => {
|
|
mockSetUserPassword.mockRejectedValue(new Error("network down"));
|
|
const r = await resetAdminPassword("user@example.com");
|
|
expect(r.success).toBe(true);
|
|
expect(r.method).toBe("reset_link_sent");
|
|
});
|
|
|
|
it("does NOT fall back for USER_NOT_FOUND — it surfaces the error", async () => {
|
|
mockSetUserPassword.mockResolvedValue({
|
|
data: null,
|
|
error: { code: "USER_NOT_FOUND", message: "no such user in neon auth" },
|
|
});
|
|
const r = await resetAdminPassword("user@example.com");
|
|
expect(r.success).toBe(false);
|
|
expect(r.error).toMatch(/no such user in neon auth/);
|
|
expect(mockRequestPasswordReset).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("propagates the public-endpoint error if BOTH paths fail", async () => {
|
|
mockSetUserPassword.mockResolvedValue({
|
|
data: null,
|
|
error: { code: "FORBIDDEN" },
|
|
});
|
|
mockRequestPasswordReset.mockResolvedValue({
|
|
data: null,
|
|
error: { code: "RATE_LIMITED", message: "too many requests" },
|
|
});
|
|
const r = await resetAdminPassword("user@example.com");
|
|
expect(r.success).toBe(false);
|
|
expect(r.error).toMatch(/too many requests/);
|
|
});
|
|
});
|