Skip to content
Merged

Main #109

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/app/api/resumes/[...objectName]/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@/lib/resume/storage-read")>();
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");
});
});
8 changes: 8 additions & 0 deletions src/app/api/resumes/[...objectName]/route.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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);
Expand Down
23 changes: 7 additions & 16 deletions src/app/api/resumes/preview/[...objectName]/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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);
Expand Down
83 changes: 83 additions & 0 deletions src/lib/resume/access.test.ts
Original file line number Diff line number Diff line change
@@ -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/<objectName> 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" });
});
});
64 changes: 64 additions & 0 deletions src/lib/resume/access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { auth } from "@/lib/auth/auth";
import { prisma } from "@/lib/db/prisma";

/**
* Resume files are served from `/api/resumes/<objectName>` 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/<objectName>` (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<boolean> {
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<ResumeAccess> {
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 };
}
25 changes: 23 additions & 2 deletions src/lib/storage/upload.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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", () => ({
Expand All @@ -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,
Expand Down
Loading