import { describe, it, expect, vi, beforeEach } from "vitest"; // --- Mocks must be hoisted before importing the module under test. --- vi.mock("@/lib/db", () => ({ prisma: { apiToken: { findUnique: vi.fn(), update: vi.fn(), }, user: { findUnique: vi.fn(), }, }, })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(), })); vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(), })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn(), })); const headersGet = vi.fn<(name: string) => string | null>(); vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: headersGet })), cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {}, })), })); // --- Imports use the mocked modules above. --- import { requireAuth, HttpError, apiHandler } from "../api-handler"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { hashToken } from "@/lib/auth/hmac"; import { auditLog } from "@/lib/auth/audit"; const FAKE_HASH = "deadbeefcafef00d"; const RAW_TOKEN = "hlk_" + "a".repeat(64); const FAKE_USER = { id: "user-1", role: "USER" as const, username: "testuser", email: "user@example.com", }; function setBearerHeader(value: string | null): void { headersGet.mockReset(); headersGet.mockImplementation((name: string) => name.toLowerCase() === "authorization" ? value : null, ); } beforeEach(() => { vi.resetAllMocks(); vi.mocked(hashToken).mockReturnValue(FAKE_HASH); vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); vi.mocked(auditLog).mockResolvedValue(undefined as never); }); describe("requireAuth — Bearer token path", () => { it("authenticates a valid Bearer token and returns AuthContext", async () => { vi.mocked(getSession).mockResolvedValue(null); setBearerHeader(`Bearer ${RAW_TOKEN}`); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ id: "token-1", userId: "user-1", permissions: ["*"], revoked: false, expiresAt, } as never); vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); const ctx = await requireAuth(); expect(hashToken).toHaveBeenCalledWith(RAW_TOKEN); // V3 audit: pin the where-clause shape — a regression that switches // back to raw-token comparison (where: { tokenHash: RAW_TOKEN }) would // be a CRITICAL leak of stored hashes. Tests must enforce the hash. expect(prisma.apiToken.findUnique).toHaveBeenCalledWith( expect.objectContaining({ where: { tokenHash: FAKE_HASH } }), ); expect(ctx.user.id).toBe("user-1"); expect(ctx.session.id).toBe("token-1"); expect(ctx.session.expiresAt).toEqual(expiresAt); // lastUsedAt refresh is fire-and-forget but should still have been triggered. expect(prisma.apiToken.update).toHaveBeenCalledWith({ where: { id: "token-1" }, data: { lastUsedAt: expect.any(Date) }, }); // v1.25 — the success path no longer writes a per-request audit row; the // wide event carries `auth_method: "bearer"` + `user_id` instead. expect(auditLog).not.toHaveBeenCalledWith( "auth.bearer.success", expect.anything(), ); }); it("rejects a revoked token with 401", async () => { vi.mocked(getSession).mockResolvedValue(null); setBearerHeader(`Bearer ${RAW_TOKEN}`); vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ id: "token-2", userId: "user-1", permissions: [], revoked: true, expiresAt: null, } as never); await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401, } satisfies Partial); expect(auditLog).toHaveBeenCalledWith( "auth.bearer.failure", expect.objectContaining({ details: expect.objectContaining({ reason: "revoked" }), }), ); expect(prisma.user.findUnique).not.toHaveBeenCalled(); }); it("rejects an expired token with 401", async () => { vi.mocked(getSession).mockResolvedValue(null); setBearerHeader(`Bearer ${RAW_TOKEN}`); vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ id: "token-3", userId: "user-1", permissions: [], revoked: false, expiresAt: new Date(Date.now() - 60_000), } as never); await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 }); expect(auditLog).toHaveBeenCalledWith( "auth.bearer.failure", expect.objectContaining({ details: expect.objectContaining({ reason: "expired" }), }), ); }); it("rejects an unknown token with 401", async () => { vi.mocked(getSession).mockResolvedValue(null); setBearerHeader(`Bearer ${RAW_TOKEN}`); vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null); await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 }); expect(auditLog).toHaveBeenCalledWith( "auth.bearer.failure", expect.objectContaining({ details: expect.objectContaining({ reason: "unknown_token" }), }), ); }); it("rejects a Bearer token missing the requested permission with 403", async () => { vi.mocked(getSession).mockResolvedValue(null); setBearerHeader(`Bearer ${RAW_TOKEN}`); vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ id: "token-4", userId: "user-1", permissions: ["something:else"], revoked: false, expiresAt: null, } as never); await expect(requireAuth("medication:ingest")).rejects.toMatchObject({ statusCode: 403, }); expect(auditLog).toHaveBeenCalledWith( "auth.bearer.failure", expect.objectContaining({ details: expect.objectContaining({ reason: "insufficient_permissions", required: "medication:ingest", }), }), ); expect(prisma.user.findUnique).not.toHaveBeenCalled(); }); // v1.4.25 W10 reconcile (code-review H2): a route that does NOT declare // a scope is content with any authenticated token. The previous policy // 403'd every narrow-scope token here and broke `/api/measurements/by- // external-ids`, `/api/personal-records`, `/api/medications/[id]/glp1`, // and `/api/dashboard/glp1` — all four were called without a scope. it("accepts a narrow-scope Bearer token when the route did not declare a scope", async () => { vi.mocked(getSession).mockResolvedValue(null); setBearerHeader(`Bearer ${RAW_TOKEN}`); vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ id: "token-5a", userId: "user-1", permissions: ["medication:ingest"], revoked: false, expiresAt: null, } as never); vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); const ctx = await requireAuth(); expect(ctx.user.id).toBe("user-1"); expect(ctx.session.id).toBe("token-5a"); // v1.25 — no per-request success audit row on the accept path. expect(auditLog).not.toHaveBeenCalledWith( "auth.bearer.success", expect.anything(), ); }); it("accepts a wildcard Bearer token when the route did not declare a scope", async () => { vi.mocked(getSession).mockResolvedValue(null); setBearerHeader(`Bearer ${RAW_TOKEN}`); vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ id: "token-5b", userId: "user-1", permissions: ["*"], revoked: false, expiresAt: null, } as never); vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); const ctx = await requireAuth(); expect(ctx.user.id).toBe("user-1"); expect(ctx.session.id).toBe("token-5b"); }); it("accepts a narrow-scope Bearer token when the route's required scope matches", async () => { vi.mocked(getSession).mockResolvedValue(null); setBearerHeader(`Bearer ${RAW_TOKEN}`); vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ id: "token-5c", userId: "user-1", permissions: ["medication:ingest"], revoked: false, expiresAt: null, } as never); vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); const ctx = await requireAuth("medication:ingest"); expect(ctx.user.id).toBe("user-1"); expect(ctx.session.id).toBe("token-5c"); }); it("returns 401 when neither cookie nor Bearer is present", async () => { vi.mocked(getSession).mockResolvedValue(null); setBearerHeader(null); await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 }); expect(prisma.apiToken.findUnique).not.toHaveBeenCalled(); }); }); describe("requireAuth — H1 MCP health:read audience binding on REST", () => { // Drive the FULL apiHandler path so the request method is in event context — // the H1 check reads it to allow safe reads but block writes. const route = apiHandler(async (req: Request) => { await requireAuth(); return Response.json({ ok: true, method: req.method }); }); function call(method: string): Promise { setBearerHeader(`Bearer ${RAW_TOKEN}`); return route( new Request("https://health.example/api/measurements/batch", { method, }) as never, ); } beforeEach(() => { vi.mocked(getSession).mockResolvedValue(null); vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); }); function mockHealthReadToken(): void { vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ id: "mcp-token", userId: "user-1", permissions: ["health:read"], revoked: false, expiresAt: null, } as never); } it("accepts a health:read token on a GET read route", async () => { mockHealthReadToken(); const res = await call("GET"); expect(res.status).toBe(200); }); it("rejects a health:read token on POST with 403", async () => { mockHealthReadToken(); const res = await call("POST"); expect(res.status).toBe(403); expect(auditLog).toHaveBeenCalledWith( "auth.bearer.failure", expect.objectContaining({ details: expect.objectContaining({ reason: "mcp_audience_write_blocked", }), }), ); }); it("rejects a health:read token on DELETE with 403", async () => { mockHealthReadToken(); const res = await call("DELETE"); expect(res.status).toBe(403); }); it("does NOT restrict a wildcard token on POST (not MCP-audience)", async () => { vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ id: "wild", userId: "user-1", permissions: ["*"], revoked: false, expiresAt: null, } as never); const res = await call("POST"); expect(res.status).toBe(200); }); it("rejects a health:read+write token on POST with 403 (writes are /mcp-only)", async () => { // A write-scoped MCP token is STILL audience-bound: its `health:write` // grant admits the confirmed in-process `/mcp` write tools, never a REST // write/delete. So it gets the same 403 as a read-only MCP token here. vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ id: "rw", userId: "user-1", permissions: ["health:read", "health:write"], revoked: false, expiresAt: null, } as never); const res = await call("POST"); expect(res.status).toBe(403); expect(auditLog).toHaveBeenCalledWith( "auth.bearer.failure", expect.objectContaining({ details: expect.objectContaining({ reason: "mcp_audience_write_blocked", }), }), ); }); it("accepts a health:read+write token on a GET read route", async () => { vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ id: "rw", userId: "user-1", permissions: ["health:read", "health:write"], revoked: false, expiresAt: null, } as never); const res = await call("GET"); expect(res.status).toBe(200); }); }); describe("requireAuth — cookie path remains intact", () => { it("returns the session payload without consulting the Bearer header", async () => { const cookieSession = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) }, user: { ...FAKE_USER, role: "USER" as const }, }; vi.mocked(getSession).mockResolvedValue(cookieSession as never); // Even if a Bearer header is present, the cookie wins (existing behaviour). setBearerHeader(`Bearer ${RAW_TOKEN}`); const ctx = await requireAuth("medication:ingest"); expect(ctx).toEqual(cookieSession); // Cookie short-circuits before any token logic runs. expect(prisma.apiToken.findUnique).not.toHaveBeenCalled(); expect(hashToken).not.toHaveBeenCalled(); expect(auditLog).not.toHaveBeenCalled(); }); });