Files
route-commerce/tests/unit/reset-admin-password.test.ts
Tyler 7e079e0186
Deploy to route.crispygoat.com / deploy (push) Successful in 4m8s
Show success banner after sending a password-reset email
The 'Send Reset Email' button in /admin/users previously just
cleared the error banner on success, with no actual 'yes it
worked' feedback. Added a green success banner that mirrors the
error banner's style and auto-dismisses after 6 seconds.

The 'Reset Password' button already shows confirmation in the
modal (temp password to copy, or 'reset email sent' message), so
it doesn't need the banner.

Also tightened the type narrowing in the resetAdminPassword unit
tests — the discriminated union needed a two-step
narrow (`r.success` then `r.method`) before TypeScript would
allow access to variant-specific fields like `tempPassword`.
2026-06-17 12:31:38 -06:00

248 lines
8.9 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);
if (r.success) throw new Error("expected failure");
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);
if (r.success) throw new Error("expected failure");
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);
if (r.success) throw new Error("expected failure");
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);
if (r.success) throw new Error("expected failure");
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.success) throw new Error("expected success");
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.success) throw new Error("expected success");
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",
});
// The fallback case returns method="reset_link_sent" with a
// `message` describing what was sent; TypeScript guarantees
// tempPassword is not on this variant.
});
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);
if (!r.success) throw new Error("expected success");
if (r.method !== "reset_link_sent") throw new Error("expected method=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);
if (!r.success) throw new Error("expected success");
if (r.method !== "reset_link_sent") throw new Error("expected method=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);
if (r.success) throw new Error("expected failure");
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);
if (r.success) throw new Error("expected failure");
expect(r.error).toMatch(/too many requests/);
});
});