Skip to content
Open
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
76 changes: 74 additions & 2 deletions __tests__/job.actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ vi.mock("@prisma/client", () => {
create: vi.fn(),
update: vi.fn(),
},
resume: {
findUnique: vi.fn(),
},
};
return {
PrismaClient: vi.fn(function () {
Expand Down Expand Up @@ -929,6 +932,7 @@ describe("jobActions", () => {

expect(result).toStrictEqual({ data: jobData, success: true });
expect(prisma.job.create).toHaveBeenCalledTimes(1);
expect(prisma.resume.findUnique).not.toHaveBeenCalled();
expect(prisma.job.create).toHaveBeenCalledWith({
data: {
jobTitleId: jobData.title,
Expand All @@ -942,10 +946,12 @@ describe("jobActions", () => {
appliedDate: jobData.dateApplied,
description: jobData.jobDescription,
jobType: jobData.type,
workplaceType: undefined,
userId: mockUser.id,
jobUrl: jobData.jobUrl,
applied: jobData.applied,
resumeId: jobData.resume,
resumeId: null,
coverLetterId: null,
},
});
});
Expand All @@ -970,15 +976,61 @@ describe("jobActions", () => {
salaryRange: jobData.salaryRange,
createdAt: jobData.createdAt,
dueDate: jobData.dueDate,
appliedDate: undefined,
description: jobData.jobDescription,
jobType: jobData.type,
workplaceType: undefined,
userId: mockUser.id,
jobUrl: undefined,
applied: jobData.applied,
resumeId: jobData.resume,
resumeId: null,
coverLetterId: null,
},
});
expect(result).toEqual({ data: jobData, success: true });
});
it("should normalize empty resume to null and skip ownership check", async () => {
(getCurrentUser as any).mockResolvedValue(mockUser);
(prisma.job.create as any).mockResolvedValue(jobData);

await addJob({ ...jobData, resume: " " });

expect(prisma.resume.findUnique).not.toHaveBeenCalled();
expect(prisma.job.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ resumeId: null }),
}),
);
});
it("should validate resume ownership when resumeId is provided", async () => {
(getCurrentUser as any).mockResolvedValue(mockUser);
(prisma.resume.findUnique as any).mockResolvedValue({ id: "resume-1" });
(prisma.job.create as any).mockResolvedValue(jobData);

await addJob({ ...jobData, resume: "resume-1" });

expect(prisma.resume.findUnique).toHaveBeenCalledWith({
where: { id: "resume-1", profile: { userId: mockUser.id } },
select: { id: true },
});
expect(prisma.job.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ resumeId: "resume-1" }),
}),
);
});
it("should reject create when resume is not owned by the user", async () => {
(getCurrentUser as any).mockResolvedValue(mockUser);
(prisma.resume.findUnique as any).mockResolvedValue(null);

await expect(
addJob({ ...jobData, resume: "someone-elses-resume" }),
).resolves.toStrictEqual({
success: false,
message: "Resume not found or access denied",
});
expect(prisma.job.create).not.toHaveBeenCalled();
});
it("should handle unexpected errors", async () => {
(getCurrentUser as any).mockResolvedValue(mockUser);

Expand Down Expand Up @@ -1009,6 +1061,26 @@ describe("jobActions", () => {

expect(result).toStrictEqual({ data: jobData, success: true });
expect(prisma.job.update).toHaveBeenCalledTimes(1);
expect(prisma.job.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
resumeId: null,
coverLetterId: null,
}),
}),
);
});
it("should reject update when resume is not owned by the user", async () => {
(getCurrentUser as any).mockResolvedValue(mockUser);
(prisma.resume.findUnique as any).mockResolvedValue(null);

await expect(
updateJob({ ...jobData, resume: "someone-elses-resume" }),
).resolves.toStrictEqual({
success: false,
message: "Resume not found or access denied",
});
expect(prisma.job.update).not.toHaveBeenCalled();
});
it("should handle unexpected errors", async () => {
(getCurrentUser as any).mockResolvedValue(mockUser);
Expand Down
29 changes: 25 additions & 4 deletions src/actions/job.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,17 @@ import { AddJobFormSchema } from "@/models/addJobForm.schema";
import { JOB_TYPES, JobStatus } from "@/models/job.model";
import { getCurrentUser } from "@/utils/user.utils";
import { APP_CONSTANTS } from "@/lib/constants";
import { assertResumeOwnership } from "@/lib/resumeOwnership";
import { revalidatePath } from "next/cache";
import { z } from "zod";

/** Coerce empty/whitespace optional FK strings to null (avoids Prisma P2003). */
const optionalFkId = (value?: string | null): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};

export const getStatusList = async (): Promise<any | undefined> => {
try {
const statuses = await prisma.jobStatus.findMany();
Expand Down Expand Up @@ -355,6 +363,13 @@ export const addJob = async (
tags,
} = data;

const resumeId = optionalFkId(resume);
const coverLetterId = optionalFkId(coverLetter);

if (resumeId) {
await assertResumeOwnership(resumeId, user.id);
}

const job = await createJobRecord({
jobTitleId: title,
companyId: company,
Expand All @@ -370,8 +385,8 @@ export const addJob = async (
userId: user.id,
jobUrl,
applied,
resumeId: resume,
coverLetterId: coverLetter,
resumeId,
coverLetterId,
tagIds: tags ?? [],
});
revalidatePath("/dashboard");
Expand Down Expand Up @@ -415,8 +430,14 @@ export const updateJob = async (
tags,
} = data;

const resumeId = optionalFkId(resume);
const coverLetterId = optionalFkId(coverLetter);
const tagIds = tags ?? [];

if (resumeId) {
await assertResumeOwnership(resumeId, user.id);
}

const job = await prisma.job.update({
where: {
id,
Expand All @@ -437,8 +458,8 @@ export const updateJob = async (
workplaceType,
jobUrl,
applied,
resumeId: resume,
coverLetterId: coverLetter,
resumeId,
coverLetterId,
tags: { set: tagIds.map((id) => ({ id })) },
},
});
Expand Down
10 changes: 1 addition & 9 deletions src/actions/profile.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,13 @@ import {
hasMinResumeSections,
} from "@/lib/resumeSections";
import { buildCopyTitle, ensureUniqueTitle } from "@/lib/resumeCopyTitle";
import { assertResumeOwnership } from "@/lib/resumeOwnership";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import path from "path";
import fs from "fs";
import { writeFile } from "fs/promises";

// Canonical IDOR guard for actions that only need to confirm resume ownership
const assertResumeOwnership = async (resumeId: string, userId: string) => {
const owned = await prisma.resume.findUnique({
where: { id: resumeId, profile: { userId } },
select: { id: true },
});
if (!owned) throw new Error("Resume not found or access denied");
};

const resumeListSelect = {
id: true,
profileId: true,
Expand Down
21 changes: 12 additions & 9 deletions src/components/myjobs/AddJob.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,25 +118,28 @@ export function AddJob({

const loadResumes = useCallback(async () => {
try {
const resumes = await getResumeList(
1,
APP_CONSTANTS.RECORDS_PER_PAGE,
APP_CONSTANTS.MIN_RESUME_SECTIONS_FOR_SELECTION,
);
setResumes(resumes.data);
// Load all of the user's resumes (minSections=0). The section-count
// filter is for AI/automation pickers only; job assignment should list
// every resume the user can see on Profile.
const result = await getResumeList(1, APP_CONSTANTS.RECORDS_PER_PAGE);
if (result?.success) {
setResumes(result.data ?? []);
}
} catch (error) {
console.error("Failed to load resumes:", error);
}
}, [setResumes]);
}, []);

const loadCoverLetters = useCallback(async () => {
try {
const result = await getCoverLetterList(1, 100);
setCoverLetters(result.data);
if (result?.success) {
setCoverLetters(result.data ?? []);
}
} catch (error) {
console.error("Failed to load cover letters:", error);
}
}, [setCoverLetters]);
}, []);

useEffect(() => {
if (editJob) {
Expand Down
13 changes: 13 additions & 0 deletions src/lib/resumeOwnership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import prisma from "@/lib/db";

/** Canonical IDOR guard: resume must exist and belong to the given user. */
export const assertResumeOwnership = async (
resumeId: string,
userId: string,
) => {
const owned = await prisma.resume.findUnique({
where: { id: resumeId, profile: { userId } },
select: { id: true },
});
if (!owned) throw new Error("Resume not found or access denied");
};
11 changes: 9 additions & 2 deletions src/models/addJobForm.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,14 @@ export const AddJobFormSchema = z.object({
}),
jobUrl: z.string().optional(),
applied: z.boolean().default(false),
resume: z.string().optional(),
coverLetter: z.string().optional(),
// Empty select values must not become FK strings (Prisma P2003).
resume: z
.string()
.optional()
.transform((val) => (val && val.trim() !== "" ? val : undefined)),
coverLetter: z
.string()
.optional()
.transform((val) => (val && val.trim() !== "" ? val : undefined)),
tags: z.array(z.string()).max(APP_CONSTANTS.MAX_JOB_TAGS).optional().default([]),
});