diff --git a/src/app/api/resumes/[...objectName]/route.test.ts b/src/app/api/resumes/[...objectName]/route.test.ts new file mode 100644 index 0000000..1f9a78a --- /dev/null +++ b/src/app/api/resumes/[...objectName]/route.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { prismaMock, mockAuth, readResumeObject } = vi.hoisted(() => { + const fn = () => vi.fn(); + return { + prismaMock: { + user: { findUnique: fn() }, + applicant: { findFirst: fn() }, + }, + mockAuth: fn(), + readResumeObject: fn(), + }; +}); + +vi.mock("@/lib/db/prisma", () => ({ prisma: prismaMock })); +vi.mock("@/lib/auth/auth", () => ({ auth: mockAuth })); +vi.mock("@/lib/resume/storage-read", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readResumeObject }; +}); + +import { GET } from "@/app/api/resumes/[...objectName]/route"; + +function call(objectName: string[]) { + return GET(new Request(`http://localhost/api/resumes/${objectName.join("/")}`) as never, { + params: Promise.resolve({ objectName }), + }); +} + +beforeEach(() => { + mockAuth.mockResolvedValue({ user: { email: "admin@scoutlane.local" } }); + prismaMock.user.findUnique.mockResolvedValue({ organizationId: "org-1" }); + prismaMock.applicant.findFirst.mockResolvedValue({ id: "a1" }); + readResumeObject.mockResolvedValue({ + buffer: Buffer.from("%PDF-1.4 fake"), + contentType: "application/pdf", + filename: "ada.pdf", + size: 13, + }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/resumes/[...objectName] authorization", () => { + it("returns 401 and never reads the file when unauthenticated", async () => { + mockAuth.mockResolvedValueOnce(null); + + const response = await call(["resumes", "2026-06", "ada-uuid.pdf"]); + + expect(response.status).toBe(401); + expect(readResumeObject).not.toHaveBeenCalled(); + }); + + it("returns 404 when the resume belongs to another organization", async () => { + prismaMock.applicant.findFirst.mockResolvedValueOnce(null); + + const response = await call(["resumes", "2026-06", "ada-uuid.pdf"]); + + expect(response.status).toBe(404); + expect(readResumeObject).not.toHaveBeenCalled(); + }); + + it("serves the file when the caller's organization owns it", async () => { + const response = await call(["resumes", "2026-06", "ada-uuid.pdf"]); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("application/pdf"); + expect(readResumeObject).toHaveBeenCalledWith("resumes/2026-06/ada-uuid.pdf"); + }); +}); diff --git a/src/app/api/resumes/[...objectName]/route.ts b/src/app/api/resumes/[...objectName]/route.ts index 1cbe5cb..66ce1d0 100644 --- a/src/app/api/resumes/[...objectName]/route.ts +++ b/src/app/api/resumes/[...objectName]/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; +import { authorizeResumeRequest } from "@/lib/resume/access"; import { canEmbedResume } from "@/lib/resume/preview"; import { buildContentDisposition, readResumeObject } from "@/lib/resume/storage-read"; +export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET( @@ -11,6 +13,12 @@ export async function GET( const { objectName } = await params; const storedObjectName = objectName.join("/"); + // Resume files contain candidate PII — only the owning workspace may read them. + const access = await authorizeResumeRequest(storedObjectName); + if (!access.ok) { + return NextResponse.json({ error: access.error }, { status: access.status }); + } + let resume; try { resume = await readResumeObject(storedObjectName); diff --git a/src/app/api/resumes/preview/[...objectName]/route.ts b/src/app/api/resumes/preview/[...objectName]/route.ts index 5465853..1f09931 100644 --- a/src/app/api/resumes/preview/[...objectName]/route.ts +++ b/src/app/api/resumes/preview/[...objectName]/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/db/prisma"; -import { auth } from "@/lib/auth/auth"; +import { authorizeResumeRequest } from "@/lib/resume/access"; import { convertDocxToSafeHtml } from "@/lib/resume/docx-preview"; import { readResumeObject } from "@/lib/resume/storage-read"; @@ -13,28 +12,20 @@ const DOCX_CONTENT_TYPE = /** * Renders a stored DOCX resume as sanitized HTML for the admin inline preview. * The middleware matcher excludes /api/resumes, so authentication is enforced - * here: a signed-in user with an organization is required. + * here: a signed-in user whose organization owns the requested resume. */ export async function GET( _request: NextRequest, { params }: { params: Promise<{ objectName: string[] }> }, ) { - const session = await auth(); - if (!session?.user?.email) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { email: session.user.email }, - select: { organizationId: true }, - }); - if (!user?.organizationId) { - return NextResponse.json({ error: "Not authorized" }, { status: 403 }); - } - const { objectName } = await params; const storedObjectName = objectName.join("/"); + const access = await authorizeResumeRequest(storedObjectName); + if (!access.ok) { + return NextResponse.json({ error: access.error }, { status: access.status }); + } + let resume; try { resume = await readResumeObject(storedObjectName); diff --git a/src/lib/resume/access.test.ts b/src/lib/resume/access.test.ts new file mode 100644 index 0000000..40da3aa --- /dev/null +++ b/src/lib/resume/access.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { prismaMock, mockAuth } = vi.hoisted(() => { + const fn = () => vi.fn(); + return { + prismaMock: { + user: { findUnique: fn() }, + applicant: { findFirst: fn() }, + }, + mockAuth: fn(), + }; +}); + +vi.mock("@/lib/db/prisma", () => ({ prisma: prismaMock })); +vi.mock("@/lib/auth/auth", () => ({ auth: mockAuth })); + +import { authorizeResumeRequest, resumeObjectBelongsToOrg } from "@/lib/resume/access"; + +beforeEach(() => { + mockAuth.mockResolvedValue({ user: { email: "admin@scoutlane.local" } }); + prismaMock.user.findUnique.mockResolvedValue({ organizationId: "org-1" }); + prismaMock.applicant.findFirst.mockResolvedValue({ id: "a1" }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("resumeObjectBelongsToOrg", () => { + it("matches the canonical /api/resumes/ resumeUrl scoped to the org", async () => { + await resumeObjectBelongsToOrg("resumes/2026-06/ada-uuid.pdf", "org-1"); + + expect(prismaMock.applicant.findFirst).toHaveBeenCalledWith({ + where: { + resumeUrl: "/api/resumes/resumes/2026-06/ada-uuid.pdf", + job: { is: { organizationId: "org-1" } }, + }, + select: { id: true }, + }); + }); + + it("returns true when a matching applicant exists, false otherwise", async () => { + prismaMock.applicant.findFirst.mockResolvedValueOnce({ id: "a1" }); + expect(await resumeObjectBelongsToOrg("obj.pdf", "org-1")).toBe(true); + + prismaMock.applicant.findFirst.mockResolvedValueOnce(null); + expect(await resumeObjectBelongsToOrg("obj.pdf", "org-1")).toBe(false); + }); + + it("rejects empty object name or org without querying", async () => { + expect(await resumeObjectBelongsToOrg("", "org-1")).toBe(false); + expect(await resumeObjectBelongsToOrg("obj.pdf", "")).toBe(false); + expect(prismaMock.applicant.findFirst).not.toHaveBeenCalled(); + }); +}); + +describe("authorizeResumeRequest", () => { + it("returns 401 when not authenticated", async () => { + mockAuth.mockResolvedValueOnce(null); + const result = await authorizeResumeRequest("obj.pdf"); + expect(result).toEqual({ ok: false, status: 401, error: "Not authenticated" }); + expect(prismaMock.applicant.findFirst).not.toHaveBeenCalled(); + }); + + it("returns 403 when the user has no organization", async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ organizationId: null }); + const result = await authorizeResumeRequest("obj.pdf"); + expect(result).toEqual({ ok: false, status: 403, error: "Not authorized" }); + expect(prismaMock.applicant.findFirst).not.toHaveBeenCalled(); + }); + + it("returns 404 when the resume is not owned by the user's organization", async () => { + prismaMock.applicant.findFirst.mockResolvedValueOnce(null); + const result = await authorizeResumeRequest("obj.pdf"); + expect(result).toEqual({ ok: false, status: 404, error: "Resume file not found." }); + }); + + it("returns ok with the organizationId when authorized and owned", async () => { + const result = await authorizeResumeRequest("obj.pdf"); + expect(result).toEqual({ ok: true, organizationId: "org-1" }); + }); +}); diff --git a/src/lib/resume/access.ts b/src/lib/resume/access.ts new file mode 100644 index 0000000..be2e1b3 --- /dev/null +++ b/src/lib/resume/access.ts @@ -0,0 +1,64 @@ +import { auth } from "@/lib/auth/auth"; +import { prisma } from "@/lib/db/prisma"; + +/** + * Resume files are served from `/api/resumes/` and contain candidate + * PII. These helpers gate that route so a stored object is only readable by the + * workspace that owns the applicant it belongs to. + */ + +/** + * Returns true when an applicant whose resume resolves to `objectName` belongs to + * the given organization. Database/local-dev uploads store the resume URL as the + * canonical `/api/resumes/` (slugged, so segments need no encoding), + * which lets us match exactly. Externally-hosted resumes (GCS/S3) use absolute + * URLs and are never served by this route. + */ +export async function resumeObjectBelongsToOrg( + objectName: string, + organizationId: string, +): Promise { + if (!objectName || !organizationId) return false; + + const match = await prisma.applicant.findFirst({ + where: { + resumeUrl: `/api/resumes/${objectName}`, + job: { is: { organizationId } }, + }, + select: { id: true }, + }); + + return match !== null; +} + +export type ResumeAccess = + | { ok: true; organizationId: string } + | { ok: false; status: 401 | 403 | 404; error: string }; + +/** + * Full authorization gate for a resume-serving request: requires a signed-in + * user with an organization, and that the requested object belongs to that + * organization. Returns 404 (not 403) for objects the org does not own so the + * route never leaks the existence of another workspace's resume. + */ +export async function authorizeResumeRequest(objectName: string): Promise { + const session = await auth(); + if (!session?.user?.email) { + return { ok: false, status: 401, error: "Not authenticated" }; + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { organizationId: true }, + }); + if (!user?.organizationId) { + return { ok: false, status: 403, error: "Not authorized" }; + } + + const owned = await resumeObjectBelongsToOrg(objectName, user.organizationId); + if (!owned) { + return { ok: false, status: 404, error: "Resume file not found." }; + } + + return { ok: true, organizationId: user.organizationId }; +} diff --git a/src/lib/storage/upload.test.ts b/src/lib/storage/upload.test.ts index 49061de..fb72bb6 100644 --- a/src/lib/storage/upload.test.ts +++ b/src/lib/storage/upload.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { rm } from "node:fs/promises"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GET } from "@/app/api/resumes/[...objectName]/route"; import { LOCAL_RESUME_STORAGE_DIR, uploadFileBuffer } from "./upload"; @@ -10,9 +10,18 @@ vi.mock("./client", () => ({ isStorageConfigured: () => false, })); -const { resumeFileCreate, resumeFileFindUnique } = vi.hoisted(() => ({ +const { + resumeFileCreate, + resumeFileFindUnique, + userFindUnique, + applicantFindFirst, + mockAuth, +} = vi.hoisted(() => ({ resumeFileCreate: vi.fn(), resumeFileFindUnique: vi.fn(), + userFindUnique: vi.fn(), + applicantFindFirst: vi.fn(), + mockAuth: vi.fn(), })); vi.mock("@/lib/db/prisma", () => ({ @@ -21,9 +30,21 @@ vi.mock("@/lib/db/prisma", () => ({ create: resumeFileCreate, findUnique: resumeFileFindUnique, }, + user: { findUnique: userFindUnique }, + applicant: { findFirst: applicantFindFirst }, }, })); +// Mocked so the resume route's authorization gate passes and next-auth is never +// loaded in the node test env; the gate itself is covered by access.test.ts. +vi.mock("@/lib/auth/auth", () => ({ auth: mockAuth })); + +beforeEach(() => { + mockAuth.mockResolvedValue({ user: { email: "admin@scoutlane.local" } }); + userFindUnique.mockResolvedValue({ organizationId: "org-1" }); + applicantFindFirst.mockResolvedValue({ id: "a1" }); +}); + const originalVercel = process.env.VERCEL; const originalS3 = { S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID,