diff --git a/lib/auth/__tests__/guard.test.ts b/lib/auth/__tests__/guard.test.ts new file mode 100644 index 0000000..5d21a7b --- /dev/null +++ b/lib/auth/__tests__/guard.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest" + +// Mock the auth module — no real betterAuth initialisation should run. +vi.mock("@/lib/auth", () => ({ + auth: { + api: { + getSession: vi.fn(), + }, + }, +})) + +// Use dynamic imports so vi.mock is guaranteed to be registered before +// any module evaluation in bun test's shared-module-cache multi-file mode. +let requireAuth: typeof import("../guard")["requireAuth"] +let requireRole: typeof import("../guard")["requireRole"] +let requireAdmin: typeof import("../guard")["requireAdmin"] +let requireContributorOrAdmin: typeof import("../guard")["requireContributorOrAdmin"] +let requireModeratorOrAdmin: typeof import("../guard")["requireModeratorOrAdmin"] +let getSession: ReturnType + +beforeAll(async () => { + const guard = await import("../guard") + requireAuth = guard.requireAuth + requireRole = guard.requireRole + requireAdmin = guard.requireAdmin + requireContributorOrAdmin = guard.requireContributorOrAdmin + requireModeratorOrAdmin = guard.requireModeratorOrAdmin + + const mocked = await import("@/lib/auth") + getSession = mocked.auth.api.getSession +}) + +function mockSession(overrides: Record = {}) { + getSession.mockResolvedValue({ + user: { + id: "user-1", + name: "Test User", + email: "test@example.com", + role: "user", + ...overrides, + }, + session: { + id: "session-1", + userId: "user-1", + token: "token-abc", + }, + }) +} + +function mockNoSession() { + getSession.mockResolvedValue(null) +} + +beforeEach(() => { + getSession.mockReset() +}) + +describe("requireAuth", () => { + it("returns ok with user and session when authenticated", async () => { + mockSession() + const result = await requireAuth(new Headers()) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.user.id).toBe("user-1") + expect(result.session.id).toBe("session-1") + } + }) + + it("returns error 401 when not authenticated", async () => { + mockNoSession() + const result = await requireAuth(new Headers()) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.status).toBe(401) + expect(result.error).toBe("Unauthorized") + } + }) +}) + +describe("requireRole", () => { + it("allows user with matching role", async () => { + mockSession({ role: "admin" }) + const result = await requireRole(new Headers(), ["admin"]) + expect(result.ok).toBe(true) + }) + + it("denies user with non-matching role (403)", async () => { + mockSession({ role: "user" }) + const result = await requireRole(new Headers(), ["admin"]) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.status).toBe(403) + expect(result.error).toBe("Forbidden") + } + }) + + it("returns 401 when not authenticated", async () => { + mockNoSession() + const result = await requireRole(new Headers(), ["admin"]) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.status).toBe(401) + } + }) + + it("defaults to 'user' role when role is null", async () => { + mockSession({ role: null }) + const result = await requireRole(new Headers(), ["user"]) + expect(result.ok).toBe(true) + }) +}) + +describe("requireAdmin", () => { + it("allows admin user", async () => { + mockSession({ role: "admin" }) + const result = await requireAdmin(new Headers()) + expect(result.ok).toBe(true) + }) + + it("denies non-admin user (403)", async () => { + mockSession({ role: "moderator" }) + const result = await requireAdmin(new Headers()) + expect(result.ok).toBe(false) + }) +}) + +describe("requireContributorOrAdmin", () => { + it("allows contributor", async () => { + mockSession({ role: "contributor" }) + const result = await requireContributorOrAdmin(new Headers()) + expect(result.ok).toBe(true) + }) + + it("allows admin", async () => { + mockSession({ role: "admin" }) + const result = await requireContributorOrAdmin(new Headers()) + expect(result.ok).toBe(true) + }) + + it("denies regular user", async () => { + mockSession({ role: "user" }) + const result = await requireContributorOrAdmin(new Headers()) + expect(result.ok).toBe(false) + }) +}) + +describe("requireModeratorOrAdmin", () => { + it("allows moderator", async () => { + mockSession({ role: "moderator" }) + const result = await requireModeratorOrAdmin(new Headers()) + expect(result.ok).toBe(true) + }) + + it("allows admin", async () => { + mockSession({ role: "admin" }) + const result = await requireModeratorOrAdmin(new Headers()) + expect(result.ok).toBe(true) + }) + + it("denies contributor", async () => { + mockSession({ role: "contributor" }) + const result = await requireModeratorOrAdmin(new Headers()) + expect(result.ok).toBe(false) + }) +}) \ No newline at end of file