Files
route-commerce/tests/unit/send-password-reset-email.test.ts
Tyler eb37df347e
Deploy to route.crispygoat.com / deploy (push) Successful in 4m14s
Fix admin password reset (Send Reset Email + Reset Password)
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).
2026-06-17 12:22:34 -06:00

152 lines
4.8 KiB
TypeScript

/**
* Unit tests for `sendPasswordResetEmail` in `src/actions/admin/users.ts`.
*
* The action must:
* 1. Reject unauthenticated callers
* 2. Reject non-platform-admin callers
* 3. Trim + lowercase the email before calling Neon Auth
* 4. Call `requestPasswordReset` with the correct `redirectTo`
* 5. Return success on a clean Neon Auth response
* 6. Propagate Neon Auth errors back to the UI
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const {
mockGetAdminUser,
mockRequestPasswordReset,
} = vi.hoisted(() => ({
mockGetAdminUser: vi.fn(),
mockRequestPasswordReset: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@/lib/admin-permissions", () => ({
getAdminUser: mockGetAdminUser,
}));
vi.mock("@/lib/auth", () => ({
requestPasswordReset: mockRequestPasswordReset,
}));
vi.mock("@/lib/db", () => ({
query: vi.fn(),
withTx: vi.fn(),
}));
vi.mock("next/headers", () => ({
cookies: () => Promise.resolve({ get: () => undefined }),
}));
import { sendPasswordResetEmail } from "@/actions/admin/users";
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" };
beforeEach(() => {
vi.clearAllMocks();
process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:4000";
mockGetAdminUser.mockResolvedValue(PLATFORM_ADMIN);
mockRequestPasswordReset.mockResolvedValue({ data: null, error: null });
});
describe("sendPasswordResetEmail — authorization", () => {
it("rejects unauthenticated callers", async () => {
mockGetAdminUser.mockResolvedValue(null);
const r = await sendPasswordResetEmail("user@example.com");
expect(r.success).toBe(false);
expect(r.error).toMatch(/not authenticated/i);
expect(mockRequestPasswordReset).not.toHaveBeenCalled();
});
it("rejects non-platform-admin callers", async () => {
mockGetAdminUser.mockResolvedValue(BRAND_ADMIN);
const r = await sendPasswordResetEmail("user@example.com");
expect(r.success).toBe(false);
expect(r.error).toMatch(/only platform admins/i);
expect(mockRequestPasswordReset).not.toHaveBeenCalled();
});
});
describe("sendPasswordResetEmail — input handling", () => {
it("rejects an empty email without calling Neon Auth", async () => {
const r = await sendPasswordResetEmail(" ");
expect(r.success).toBe(false);
expect(r.error).toMatch(/required/i);
expect(mockRequestPasswordReset).not.toHaveBeenCalled();
});
it("trims and lowercases the email before calling Neon Auth", async () => {
await sendPasswordResetEmail(" User@Example.COM ");
expect(mockRequestPasswordReset).toHaveBeenCalledWith({
email: "user@example.com",
redirectTo: "http://localhost:4000/reset-password",
});
});
});
describe("sendPasswordResetEmail — happy path", () => {
it("returns success when Neon Auth accepts the request", async () => {
mockRequestPasswordReset.mockResolvedValue({ data: { ok: true }, error: null });
const r = await sendPasswordResetEmail("user@example.com");
expect(r.success).toBe(true);
expect(r.error).toBeNull();
});
});
describe("sendPasswordResetEmail — error propagation", () => {
it("returns a clear error when Neon Auth returns a structured error", async () => {
mockRequestPasswordReset.mockResolvedValue({
data: null,
error: { code: "USER_NOT_FOUND", message: "no such user" },
});
const r = await sendPasswordResetEmail("user@example.com");
expect(r.success).toBe(false);
expect(r.error).toMatch(/no such user/);
});
it("falls back to the error code if the message is missing", async () => {
mockRequestPasswordReset.mockResolvedValue({
data: null,
error: { code: "RATE_LIMITED" },
});
const r = await sendPasswordResetEmail("user@example.com");
expect(r.success).toBe(false);
expect(r.error).toMatch(/RATE_LIMITED/);
});
it("catches thrown exceptions and surfaces them as a string", async () => {
mockRequestPasswordReset.mockRejectedValue(new Error("network down"));
const r = await sendPasswordResetEmail("user@example.com");
expect(r.success).toBe(false);
expect(r.error).toMatch(/network down/);
});
});