diff --git a/packages/api/src/features/resume/access-policy.test.ts b/packages/api/src/features/resume/access-policy.test.ts index 0b8970707..2ab7bd9d0 100644 --- a/packages/api/src/features/resume/access-policy.test.ts +++ b/packages/api/src/features/resume/access-policy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; import { assertCanView, isOwner, redactResumeForViewer, shouldCountForStatistics } from "./access-policy"; describe("isOwner", () => { @@ -79,6 +80,24 @@ describe("redactResumeForViewer", () => { expect(result.data.metadata.check).toBeUndefined(); }); + it("strips what the author hid for non-owner and keeps what prints", () => { + const data = structuredClone(sampleResumeData); + data.picture.hidden = true; + data.summary.hidden = true; + data.sections.references.hidden = true; + data.sections.projects.items = data.sections.projects.items.map((item, index) => ({ ...item, hidden: index > 0 })); + data.customSections = data.customSections.map((section) => ({ ...section, hidden: true })); + + const shared = redactResumeForViewer({ name: "Title", data }, false).data; + + expect(shared.picture.url).toBe(""); + expect(shared.summary.content).toBe(""); + expect(shared.sections.references.items).toEqual([]); + expect(shared.sections.projects.items).toEqual(data.sections.projects.items.slice(0, 1)); + expect(shared.customSections).toEqual([]); + expect(shared.sections.education).toEqual(data.sections.education); + }); + it("preserves stylesheet source for an authorized non-owner", () => { const source = { languageVersion: 1, text: "@version 1;\nresume { color: red; }\n" }; const resume = { diff --git a/packages/api/src/features/resume/access-policy.ts b/packages/api/src/features/resume/access-policy.ts index a209e79e6..9a6eb9933 100644 --- a/packages/api/src/features/resume/access-policy.ts +++ b/packages/api/src/features/resume/access-policy.ts @@ -44,6 +44,9 @@ export function assertCanView(resume: Resume, viewer: Viewer): void { * to the author when editing" in the resume schema. * - `resume.data.metadata.check` — the author's Check choices (ignored * issues, job-posting terms hidden as "not true for me"). + * - Everything the author hid: hidden sections, summary, picture and items + * "aren't printed or shared". Hidden custom sections are dropped; built-in + * ones keep their (now empty) slot because the schema requires it. * * Everything else (including `data.basics.name`, the person's name on the * resume itself) is part of the public payload and is returned unchanged. @@ -55,18 +58,20 @@ export function redactResumeForViewer !section.hidden); + const sections: { hidden: boolean; items: { hidden: boolean }[] }[] = [ + ...Object.values(data.sections), + ...data.customSections, + ]; + for (const section of sections) section.items = section.hidden ? [] : section.items.filter((item) => !item.hidden); + + return { ...resume, name: "Resume", data }; } /** diff --git a/packages/api/src/features/resume/public-pdf.test.ts b/packages/api/src/features/resume/public-pdf.test.ts index 4675cf39e..482d83714 100644 --- a/packages/api/src/features/resume/public-pdf.test.ts +++ b/packages/api/src/features/resume/public-pdf.test.ts @@ -2,6 +2,19 @@ import { describe, expect, it, vi } from "vitest"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; import { createPublicResumePdf } from "./public-pdf"; +// The default lookup runs its real query against a stand-in `pg` client that records the SQL and finds nothing. +const queries = vi.hoisted(() => [] as string[]); +vi.mock("@reactive-resume/db/client", async () => { + const { drizzle } = await import("drizzle-orm/node-postgres"); + const client = { + query: ({ text }: { text: string }) => { + queries.push(text); + return Promise.resolve({ rows: [] }); + }, + }; + return { db: drizzle({ client: client as never }) }; +}); + const requestHeaders = new Headers({ "x-forwarded-for": "203.0.113.7" }); const input = { username: "jane", @@ -40,6 +53,11 @@ describe("createPublicResumePdf", () => { expect(passwordDependencies.renderPdf).not.toHaveBeenCalled(); }); + it("does not look in Trash, so a trashed resume's link stops serving its PDF", async () => { + await expect(createPublicResumePdf(input)).rejects.toMatchObject({ code: "NOT_FOUND" }); + expect(queries.at(-1)).toContain('"resume"."trashed_at" is null'); + }); + it("rejects renderer-unsafe stored data before budget or rendering", async () => { const resume = buildResume(); resume.data.customSections = [ diff --git a/packages/api/src/features/resume/public-pdf.ts b/packages/api/src/features/resume/public-pdf.ts index 9cd9fe5ad..6eb6a5bb9 100644 --- a/packages/api/src/features/resume/public-pdf.ts +++ b/packages/api/src/features/resume/public-pdf.ts @@ -29,7 +29,7 @@ export type PublicResumePdfDependencies = { }; const findResume = async ({ username, slug }: Pick) => { - const [{ db }, schema, { and, eq }] = await Promise.all([ + const [{ db }, schema, { and, eq, isNull }] = await Promise.all([ import("@reactive-resume/db/client"), import("@reactive-resume/db/schema"), import("drizzle-orm"), @@ -44,7 +44,7 @@ const findResume = async ({ username, slug }: Pick ({ showDownloadButtons: "show_download_buttons", isLocked: "is_locked", password: "password", + trashedAt: "trashed_at", updatedAt: "updated_at", createdAt: "created_at", }, @@ -1147,6 +1148,13 @@ describe("statistics.recordDownload", () => { }, ); + it("does not look in Trash for the resume", async () => { + const where = vi.fn((_condition: unknown) => Promise.resolve([])); + dbMock.select.mockReturnValueOnce({ from: () => ({ innerJoin: () => ({ where }) }) }); + await expect(resumeService.statistics.recordDownload(input)).rejects.toMatchObject({ code: "NOT_FOUND" }); + expect(where.mock.calls[0]?.[0]).toContainEqual(["trashed_at"]); + }); + it("requires current password access before recording a download", async () => { selectResume([{ ...publicResume, passwordHash: "hash" }]); hasResumeAccessMock.mockReturnValueOnce(false); diff --git a/packages/api/src/features/resume/service.ts b/packages/api/src/features/resume/service.ts index f5e62af97..3491b174b 100644 --- a/packages/api/src/features/resume/service.ts +++ b/packages/api/src/features/resume/service.ts @@ -191,7 +191,13 @@ const statistics = { }) .from(schema.resume) .innerJoin(schema.user, eq(schema.resume.userId, schema.user.id)) - .where(and(eq(schema.resume.slug, input.slug), eq(schema.user.username, input.username))); + .where( + and( + eq(schema.resume.slug, input.slug), + eq(schema.user.username, input.username), + isNull(schema.resume.trashedAt), + ), + ); if (!resume) throw new ORPCError("NOT_FOUND"); const viewer = input.currentUserId ? { id: input.currentUserId } : null; diff --git a/packages/api/src/features/resume/social-meta.test.ts b/packages/api/src/features/resume/social-meta.test.ts new file mode 100644 index 000000000..eae2370a9 --- /dev/null +++ b/packages/api/src/features/resume/social-meta.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; +import { getPublicResumeSocialMeta } from "./social-meta"; + +// The real lookup query, run against a stand-in `pg` client that records the SQL and returns `rows`. +const pg = vi.hoisted(() => ({ queries: [] as { text: string; params: unknown[] }[], rows: [] as unknown[][] })); +vi.mock("@reactive-resume/db/client", async () => { + const { drizzle } = await import("drizzle-orm/node-postgres"); + const client = { + query: ({ text }: { text: string }, params: unknown[]) => { + pg.queries.push({ text, params }); + return Promise.resolve({ rows: pg.rows }); + }, + }; + return { db: drizzle({ client: client as never }) }; +}); + +describe("getPublicResumeSocialMeta", () => { + it("cards only public, password-free resumes outside Trash, showing what an anonymous visitor sees", async () => { + const data = structuredClone(sampleResumeData); + data.basics.name = ""; + data.summary.hidden = true; + pg.rows = [["Senior Eng @ Foo — final draft", data]]; + + const meta = await getPublicResumeSocialMeta({ username: "jane", slug: "resume" }); + + const [query] = pg.queries; + expect(query?.text).toContain('"resume"."is_public" = $3'); + expect(query?.params[2]).toBe(true); + expect(query?.text).toContain('"resume"."password" is null'); + expect(query?.text).toContain('"resume"."trashed_at" is null'); + expect(meta).toMatchObject({ name: "Resume", description: data.basics.headline }); + }); +}); diff --git a/packages/api/src/features/resume/social-meta.ts b/packages/api/src/features/resume/social-meta.ts index fed24c79e..88af444ae 100644 --- a/packages/api/src/features/resume/social-meta.ts +++ b/packages/api/src/features/resume/social-meta.ts @@ -1,12 +1,13 @@ import type { ResumeSocialMeta } from "@reactive-resume/resume/social-meta"; import { getResumeSocialMeta } from "@reactive-resume/resume/social-meta"; +import { redactResumeForViewer } from "./access-policy"; import { parseStoredResumeData } from "./resume-data-validation"; export type PublicResumeSocialMetaInput = { username: string; slug: string }; -// Only public, password-free resumes are matched. Password-protected resumes must not leak their -// summary to an unauthenticated crawler, and this read deliberately skips the view counting and -// access gating in resumeService.getBySlug — a card render is not a visit. +// Only public, password-free resumes outside Trash are matched. Password-protected resumes must not +// leak their summary to an unauthenticated crawler, and this read deliberately skips the view counting +// and access gating in resumeService.getBySlug — a card render is not a visit. const findResume = async ({ username, slug }: PublicResumeSocialMetaInput) => { const [{ db }, schema, { and, eq, isNull }] = await Promise.all([ import("@reactive-resume/db/client"), @@ -23,6 +24,7 @@ const findResume = async ({ username, slug }: PublicResumeSocialMetaInput) => { eq(schema.user.username, username), eq(schema.resume.isPublic, true), isNull(schema.resume.password), + isNull(schema.resume.trashedAt), ), ); @@ -33,5 +35,7 @@ export async function getPublicResumeSocialMeta(input: PublicResumeSocialMetaInp const resume = await findResume(input); if (!resume) return null; - return getResumeSocialMeta(parseStoredResumeData(resume.data), resume.name); + // The card shows what an anonymous visitor gets: no dashboard title, nothing the author hid. + const visible = redactResumeForViewer({ name: resume.name, data: parseStoredResumeData(resume.data) }, false); + return getResumeSocialMeta(visible.data, visible.name); }