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
123 changes: 31 additions & 92 deletions __tests__/mcpAddJob.spec.ts
Original file line number Diff line number Diff line change
@@ -1,155 +1,94 @@
import { handleAddJob } from "@/lib/mcp/tools/addJob";
import { createJobFromNames } from "@/lib/jobs/createJobFromNames";
import { getDefaultResumeForUser } from "@/lib/jobs/getDefaultResumeForUser";
import { preprocessResume } from "@/lib/ai/tools/preprocessing";
import { checkMcpRateLimit } from "@/lib/mcp/rate-limit";

vi.mock("@/lib/jobs/createJobFromNames", () => ({
createJobFromNames: vi.fn(),
}));

vi.mock("@/lib/jobs/getDefaultResumeForUser", () => ({
getDefaultResumeForUser: vi.fn(),
}));

vi.mock("@/lib/ai/tools/preprocessing", () => ({
preprocessResume: vi.fn(),
}));

vi.mock("@/lib/mcp/rate-limit", () => ({
checkMcpRateLimit: vi.fn(() => ({ allowed: true, resetIn: 0 })),
}));

const longDescription = "a".repeat(250);
const shortDescription = "Short JD.";

const baseInput = {
company: "Acme",
jobTitle: "Engineer",
jobDescription: longDescription,
jobDescription: "A substantive fictional description ".repeat(10),
autoMatch: false,
};

describe("handleAddJob match gating", () => {
describe("handleAddJob privacy boundary", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("appends resume text + save_match_result directive when created + substantive + default resume usable", async () => {
it("creates a job without returning default resume content", async () => {
(createJobFromNames as any).mockResolvedValue({
created: true,
jobId: "job-1",
resolutions: [],
message: "Created Acme; Created Engineer. Job created (id: job-1).",
});
(getDefaultResumeForUser as any).mockResolvedValue({ id: "resume-1", title: "My Resume" });
(preprocessResume as any).mockResolvedValue({
success: true,
data: { normalizedText: "NORMALIZED RESUME TEXT", metadata: {}, isValid: true },
});

const result = await handleAddJob(baseInput as any, "user-1", "my-token");
const result = await handleAddJob(baseInput, "user-1", "my-token");
const text = result.content[0].text;

expect(text).toContain("Job created (id: job-1)");
expect(text).toContain("NORMALIZED RESUME TEXT");
expect(text).toContain("job-1");
expect(text).toContain("save_match_result");
expect(text).toContain("SCORES: match=<0-100> recommendation=<strong|good|partial|weak>");
// Directive requests the four structured sections (richer match body).
expect(text).toContain("## Overall Fit");
expect(text).toContain("## Key Strengths");
expect(text).toContain("## Gaps / Risks");
expect(text).toContain("## Recommendation");
// Directive threads the resumeId back for save_match_result provenance.
expect(text).toContain('"resumeId": "resume-1"');
});

it("adds a short-description note and no directive when description is under the threshold", async () => {
(createJobFromNames as any).mockResolvedValue({
created: true,
jobId: "job-1",
resolutions: [],
message: "Created Acme; Created Engineer. Job created (id: job-1).",
});

const result = await handleAddJob(
{ ...baseInput, jobDescription: shortDescription } as any,
"user-2",
"my-token",
);
const text = result.content[0].text;

expect(text).toContain("Description too short to match");
expect(text).not.toContain("DEFAULT RESUME");
expect(text).not.toContain("save_match_result");
expect(getDefaultResumeForUser).not.toHaveBeenCalled();
});

it("adds an actionable note when no default resume is set", async () => {
(createJobFromNames as any).mockResolvedValue({
expect(result.structuredContent).toEqual({
created: true,
jobId: "job-1",
resolutions: [],
message: "Created Acme; Created Engineer. Job created (id: job-1).",
duplicateJobId: null,
autoMatchPerformed: false,
});
(getDefaultResumeForUser as any).mockResolvedValue(null);

const result = await handleAddJob(baseInput as any, "user-3", "my-token");
const text = result.content[0].text;

expect(text).toContain("No default resume set");
expect(text).not.toContain("save_match_result");
expect(preprocessResume).not.toHaveBeenCalled();
});

it("adds a distinct note when the default resume exists but fails preprocessing", async () => {
it("keeps autoMatch fail-closed without disclosing a resume", async () => {
(createJobFromNames as any).mockResolvedValue({
created: true,
jobId: "job-1",
resolutions: [],
message: "Created Acme; Created Engineer. Job created (id: job-1).",
});
(getDefaultResumeForUser as any).mockResolvedValue({ id: "resume-1", title: "My Resume" });
(preprocessResume as any).mockResolvedValue({
success: false,
error: { code: "TOO_SHORT", message: "Resume is too short" },
message: "Job created (id: job-1).",
});

const result = await handleAddJob(baseInput as any, "user-4", "my-token");
const text = result.content[0].text;
const result = await handleAddJob(
{ ...baseInput, autoMatch: true },
"user-1",
"my-token",
);

expect(text).toContain("Default resume couldn't be used for matching");
expect(text).not.toContain("No default resume set");
expect(text).not.toContain("save_match_result");
expect(result.content[0].text).toContain("Automatic MCP matching is disabled");
expect(result.content[0].text).not.toContain("DEFAULT RESUME");
expect(result.structuredContent.autoMatchPerformed).toBe(false);
});

it("does not offer a match on a duplicate, and explains why", async () => {
it("returns a structured duplicate identifier", async () => {
(createJobFromNames as any).mockResolvedValue({
created: false,
duplicateOf: { id: "job-existing", title: "Engineer", company: "Acme" },
resolutions: [],
message: 'Duplicate detected — existing job "Engineer" at "Acme" (id: job-existing).',
message: "Duplicate detected.",
});

const result = await handleAddJob(baseInput as any, "user-5", "my-token");
const text = result.content[0].text;
const result = await handleAddJob(baseInput, "user-1", "my-token");

expect(text).toContain("Duplicate detected");
expect(text).toContain("No match offered for duplicates");
expect(getDefaultResumeForUser).not.toHaveBeenCalled();
expect(preprocessResume).not.toHaveBeenCalled();
expect(result.structuredContent).toMatchObject({
created: false,
duplicateJobId: "job-existing",
});
});

it("returns a rate-limit message and never creates a job when the limit is exceeded", async () => {
it("returns a structured rate-limit error without creating a job", async () => {
(checkMcpRateLimit as any).mockReturnValueOnce({
allowed: false,
resetIn: 60000,
resetIn: 60_000,
});

const result = await handleAddJob(baseInput as any, "user-6", "my-token");
const text = result.content[0].text;
const result = await handleAddJob(baseInput, "user-1", "my-token");

expect(text).toContain("Rate limit exceeded");
expect(text).toContain("60s");
expect(result.content[0].text).toContain("Rate limit exceeded");
expect(result.structuredContent.created).toBe(false);
expect(createJobFromNames).not.toHaveBeenCalled();
});
});
25 changes: 25 additions & 0 deletions __tests__/mcpScopes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {
DEFAULT_MCP_TOKEN_SCOPES,
hasMcpScope,
MCP_SCOPE,
} from "@/lib/mcp/scopes";

describe("MCP scopes", () => {
it("keeps resume data out of default write tokens", () => {
expect(DEFAULT_MCP_TOKEN_SCOPES).toEqual([
"jobs:create",
"matches:write",
"questions:write",
]);
expect(DEFAULT_MCP_TOKEN_SCOPES).not.toContain(MCP_SCOPE.RESUMES_READ);
expect(DEFAULT_MCP_TOKEN_SCOPES).not.toContain(
MCP_SCOPE.RESUME_REVIEWS_WRITE,
);
});

it("separates job creation from match persistence", () => {
expect(hasMcpScope(["jobs:create"], MCP_SCOPE.JOBS_CREATE)).toBe(true);
expect(hasMcpScope(["jobs:create"], MCP_SCOPE.MATCHES_WRITE)).toBe(false);
expect(hasMcpScope(["matches:write"], MCP_SCOPE.MATCHES_WRITE)).toBe(true);
});
});
70 changes: 70 additions & 0 deletions __tests__/ownedResumeFile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import path from "path";
import prisma from "@/lib/db";
import {
getOwnedResumeFile,
isPathInsideUploads,
} from "@/lib/resumes/ownedResumeFile";

vi.mock("@/lib/db", () => ({
default: {
resume: {
findFirst: vi.fn(),
},
},
}));

describe("owned resume download lookup", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("queries by resume id and profile owner", async () => {
const filePath = path.resolve("data/files/resumes/safe.pdf");
(prisma.resume.findFirst as any).mockResolvedValue({
File: { filePath, fileName: "safe.pdf" },
});

await expect(getOwnedResumeFile("user-1", "resume-1")).resolves.toEqual({
filePath,
fileName: "safe.pdf",
});
expect(prisma.resume.findFirst).toHaveBeenCalledWith({
where: {
id: "resume-1",
profile: { userId: "user-1" },
},
select: {
File: {
select: {
filePath: true,
fileName: true,
},
},
},
});
});

it("rejects missing, cross-user, or path-escaped records", async () => {
(prisma.resume.findFirst as any).mockResolvedValue(null);
await expect(
getOwnedResumeFile("user-1", "other-resume"),
).resolves.toBeNull();

(prisma.resume.findFirst as any).mockResolvedValue({
File: {
filePath: path.resolve("../secret.pdf"),
fileName: "secret.pdf",
},
});
await expect(
getOwnedResumeFile("user-1", "resume-1"),
).resolves.toBeNull();
});

it("accepts only paths inside the configured uploads root", () => {
expect(isPathInsideUploads(path.resolve("data/files/resumes/safe.pdf"))).toBe(
true,
);
expect(isPathInsideUploads(path.resolve("../secret.pdf"))).toBe(false);
});
});
9 changes: 9 additions & 0 deletions __tests__/saveMatchResult.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ describe("handleSaveMatchResult", () => {
expect(result.content[0].text).toContain("Match saved for job job-1");
expect(result.content[0].text).toContain("good match");
expect(result.content[0].text).toContain("78");
expect(result.structuredContent).toMatchObject({
saved: true,
jobId: "job-1",
score: 78,
recommendation: "good match",
errorCode: null,
});
});

it("returns a parse-error message when matchText has no SCORES line", async () => {
Expand All @@ -75,6 +82,7 @@ describe("handleSaveMatchResult", () => {
);

expect(result.content[0].text).toContain("Could not parse a SCORES line");
expect(result.structuredContent.errorCode).toBe("invalid_match_format");
expect(prisma.job.update).not.toHaveBeenCalled();
});

Expand All @@ -90,6 +98,7 @@ describe("handleSaveMatchResult", () => {
expect(result.content[0].text).toBe(
"Job not found, not owned by this token's user, or not eligible for a match via MCP.",
);
expect(result.structuredContent.errorCode).toBe("not_found_or_forbidden");
});

it("overwrites a prior match (last-write-wins, no pre-check)", async () => {
Expand Down
2 changes: 1 addition & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,7 @@ model McpAccessToken {
name String
tokenHash String @unique
tokenPrefix String
scopes String // JSON array string; new tokens are always ["jobs:write","questions:write","resume:write"]
scopes String // JSON array; resume scopes require separate explicit grants
expiresAt DateTime
lastUsedAt DateTime?
createdAt DateTime @default(now())
Expand Down
3 changes: 2 additions & 1 deletion src/actions/mcpToken.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getCurrentUser } from "@/utils/user.utils";
import { handleError } from "@/lib/utils";
import { generateToken } from "@/lib/mcp/tokens";
import { APP_CONSTANTS } from "@/lib/constants";
import { DEFAULT_MCP_TOKEN_SCOPES } from "@/lib/mcp/scopes";

export interface PublicTokenMeta {
id: string;
Expand Down Expand Up @@ -44,7 +45,7 @@ export async function createMcpToken(input: {
name: input.name.trim(),
tokenHash: hash,
tokenPrefix: prefix,
scopes: JSON.stringify(["jobs:write", "questions:write", "resume:write"]),
scopes: JSON.stringify(DEFAULT_MCP_TOKEN_SCOPES),
expiresAt,
},
});
Expand Down
Loading