import { describe, it, expect, vi, beforeEach } from "vitest"; const { requireAdminMock, findUserMock, findManyAdminsMock, insertReturningMock, updateMock, deleteMock, checkRateLimitMock, revalidateMock, } = vi.hoisted(() => ({ requireAdminMock: vi.fn(), findUserMock: vi.fn(), findManyAdminsMock: vi.fn(), insertReturningMock: vi.fn(), updateMock: vi.fn(), deleteMock: vi.fn(), checkRateLimitMock: vi.fn(), revalidateMock: vi.fn(), })); vi.mock("@/lib/auth", async () => { const actual = await vi.importActual("@/lib/auth"); return { ...actual, requireAdmin: () => requireAdminMock(), }; }); vi.mock("@/lib/db", () => ({ db: { query: { operators: { findFirst: (...a: unknown[]) => findUserMock(...a), findMany: (...a: unknown[]) => findManyAdminsMock(...a), }, }, insert: () => ({ values: () => ({ returning: async () => insertReturningMock() }) }), update: () => ({ set: () => ({ where: async (...a: unknown[]) => updateMock(...a) }) }), delete: () => ({ where: async (...a: unknown[]) => deleteMock(...a) }), }, })); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a), })); vi.mock("next/cache", () => ({ revalidatePath: revalidateMock })); vi.mock("next/headers", () => ({ headers: async () => ({ get: () => "127.0.0.1" }), })); beforeEach(() => { requireAdminMock.mockReset(); findUserMock.mockReset(); findManyAdminsMock.mockReset(); insertReturningMock.mockReset(); updateMock.mockReset(); deleteMock.mockReset(); checkRateLimitMock.mockReset(); revalidateMock.mockReset(); checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 }); }); const ADMIN = { id: "11111111-1111-1111-1111-111111111111", username: "admin", role: "admin" as const, }; const OTHER_ADMIN = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice" }; const USER = { ...ADMIN, id: "33333333-3333-3333-3333-333333333333", username: "bob", role: "user" as const }; import { createUserAction, setUserRoleAction, resetUserPasswordAction, deleteUserAction, } from "./users"; describe("createUserAction", () => { it("admin can create a user with role 'user'", async () => { requireAdminMock.mockResolvedValue(ADMIN); insertReturningMock.mockResolvedValue([{ id: USER.id }]); const r = await createUserAction({ username: "bob", password: "longpw1", role: "user", }); expect(r).toEqual({ ok: true, userId: USER.id }); }); it("rejects username/password under length limits", async () => { requireAdminMock.mockResolvedValue(ADMIN); const r = await createUserAction({ username: "a", password: "shortpw", role: "user" }); expect(r.ok).toBe(false); }); }); describe("setUserRoleAction — self-demote guard", () => { it("admin demoting themselves is rejected", async () => { requireAdminMock.mockResolvedValue(ADMIN); findUserMock.mockResolvedValue(ADMIN); const r = await setUserRoleAction({ userId: ADMIN.id, role: "user" }); expect(r).toEqual({ ok: false, error: "You can't demote your own account.", }); expect(updateMock).not.toHaveBeenCalled(); }); it("admin demoting another admin is allowed when others remain", async () => { requireAdminMock.mockResolvedValue(ADMIN); findUserMock.mockResolvedValue(OTHER_ADMIN); findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]); // 2 admins const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" }); expect(r).toEqual({ ok: true }); }); it("admin demoting the last remaining admin is rejected", async () => { requireAdminMock.mockResolvedValue(ADMIN); findUserMock.mockResolvedValue(OTHER_ADMIN); findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // only OTHER_ADMIN is an admin const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" }); expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toMatch(/last admin/i); }); }); describe("deleteUserAction", () => { it("admin deleting themselves is rejected", async () => { requireAdminMock.mockResolvedValue(ADMIN); findUserMock.mockResolvedValue(ADMIN); const r = await deleteUserAction({ userId: ADMIN.id }); expect(r).toEqual({ ok: false, error: "You can't delete your own account." }); expect(deleteMock).not.toHaveBeenCalled(); }); it("admin deleting another user is allowed", async () => { requireAdminMock.mockResolvedValue(ADMIN); findUserMock.mockResolvedValue(USER); findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]); const r = await deleteUserAction({ userId: USER.id }); expect(r).toEqual({ ok: true }); }); it("admin deleting the last admin is rejected", async () => { requireAdminMock.mockResolvedValue(ADMIN); findUserMock.mockResolvedValue(OTHER_ADMIN); findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // 1 admin total const r = await deleteUserAction({ userId: OTHER_ADMIN.id }); expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toMatch(/last admin/i); }); }); describe("resetUserPasswordAction", () => { it("admin can reset another user's password", async () => { requireAdminMock.mockResolvedValue(ADMIN); findUserMock.mockResolvedValue(USER); const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "alpha7!" }); expect(r).toEqual({ ok: true }); expect(updateMock).toHaveBeenCalled(); }); it("rejects too-short passwords", async () => { requireAdminMock.mockResolvedValue(ADMIN); findUserMock.mockResolvedValue(USER); const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "ab1" }); expect(r.ok).toBe(false); }); it("rejects letters-only passwords (no number or symbol)", async () => { requireAdminMock.mockResolvedValue(ADMIN); findUserMock.mockResolvedValue(USER); const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "abcdefghij", }); expect(r).toEqual({ ok: false, error: "Password must mix letters with numbers or symbols.", }); }); it("rejects digits-only passwords", async () => { requireAdminMock.mockResolvedValue(ADMIN); findUserMock.mockResolvedValue(USER); const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "1234567890", }); expect(r.ok).toBe(false); }); });