From 6606b59d1e900d080663cb94104c20c19e722bf4 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 19 May 2026 20:05:06 +0000 Subject: [PATCH] feat(core-dsr): Payload impls, recording doubles, DI binders, contract tests Co-Authored-By: Claude Sonnet 4.6 --- coverage/summary.json | 40 +-- docs/library-decisions/2026-05-14-payload.md | 2 +- packages/core-dsr/package.json | 3 +- .../core-dsr/src/__tests__/di-binders.test.ts | 56 ++++ .../core-dsr/src/__tests__/in-memory.test.ts | 83 +++++ .../src/__tests__/jsonld-context.test.ts | 84 +++++ .../src/__tests__/payload-data-delete.test.ts | 316 ++++++++++++++++++ .../src/__tests__/payload-data-export.test.ts | 209 ++++++++++++ .../__tests__/payload-data-rectify.test.ts | 146 ++++++++ .../payload-processing-restriction.test.ts | 153 +++++++++ packages/core-dsr/src/di/bind-dev-seed.ts | 21 ++ packages/core-dsr/src/di/bind-production.ts | 44 +++ .../core-dsr/src/dsr-collection-custom.ts | 65 ++++ .../core-dsr/src/in-memory-data-delete.ts | 27 ++ .../core-dsr/src/in-memory-data-export.ts | 24 ++ .../core-dsr/src/in-memory-data-rectify.ts | 19 ++ .../src/in-memory-processing-restriction.ts | 25 ++ packages/core-dsr/src/index.ts | 21 ++ packages/core-dsr/src/payload-data-delete.ts | 292 ++++++++++++++++ packages/core-dsr/src/payload-data-export.ts | 145 ++++++++ packages/core-dsr/src/payload-data-rectify.ts | 105 ++++++ .../src/payload-processing-restriction.ts | 87 +++++ .../core-testing/src/instrumentation/index.ts | 16 + .../instrumentation/recording-audit-log.ts | 11 +- .../recording-data-delete.test.ts | 41 +++ .../instrumentation/recording-data-delete.ts | 63 ++++ .../recording-data-export.test.ts | 40 +++ .../instrumentation/recording-data-export.ts | 64 ++++ .../recording-data-rectify.test.ts | 47 +++ .../instrumentation/recording-data-rectify.ts | 39 +++ .../recording-processing-restriction.test.ts | 51 +++ .../recording-processing-restriction.ts | 42 +++ pnpm-lock.yaml | 3 + scripts/coverage/diff.mjs | 1 + scripts/coverage/diff.test.mjs | 14 + 35 files changed, 2375 insertions(+), 24 deletions(-) create mode 100644 packages/core-dsr/src/__tests__/di-binders.test.ts create mode 100644 packages/core-dsr/src/__tests__/in-memory.test.ts create mode 100644 packages/core-dsr/src/__tests__/jsonld-context.test.ts create mode 100644 packages/core-dsr/src/__tests__/payload-data-delete.test.ts create mode 100644 packages/core-dsr/src/__tests__/payload-data-export.test.ts create mode 100644 packages/core-dsr/src/__tests__/payload-data-rectify.test.ts create mode 100644 packages/core-dsr/src/__tests__/payload-processing-restriction.test.ts create mode 100644 packages/core-dsr/src/di/bind-dev-seed.ts create mode 100644 packages/core-dsr/src/di/bind-production.ts create mode 100644 packages/core-dsr/src/dsr-collection-custom.ts create mode 100644 packages/core-dsr/src/in-memory-data-delete.ts create mode 100644 packages/core-dsr/src/in-memory-data-export.ts create mode 100644 packages/core-dsr/src/in-memory-data-rectify.ts create mode 100644 packages/core-dsr/src/in-memory-processing-restriction.ts create mode 100644 packages/core-dsr/src/payload-data-delete.ts create mode 100644 packages/core-dsr/src/payload-data-export.ts create mode 100644 packages/core-dsr/src/payload-data-rectify.ts create mode 100644 packages/core-dsr/src/payload-processing-restriction.ts create mode 100644 packages/core-testing/src/instrumentation/recording-data-delete.test.ts create mode 100644 packages/core-testing/src/instrumentation/recording-data-delete.ts create mode 100644 packages/core-testing/src/instrumentation/recording-data-export.test.ts create mode 100644 packages/core-testing/src/instrumentation/recording-data-export.ts create mode 100644 packages/core-testing/src/instrumentation/recording-data-rectify.test.ts create mode 100644 packages/core-testing/src/instrumentation/recording-data-rectify.ts create mode 100644 packages/core-testing/src/instrumentation/recording-processing-restriction.test.ts create mode 100644 packages/core-testing/src/instrumentation/recording-processing-restriction.ts diff --git a/coverage/summary.json b/coverage/summary.json index 68e99e4..ffb5f54 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,18 +1,18 @@ { - "generatedAt": "2026-05-19T19:30:03.273Z", - "commit": "86d9492", + "generatedAt": "2026-05-19T19:59:56.279Z", + "commit": "8068d1b", "repo": { - "statements": 96.74, - "branches": 92.15, - "functions": 96.92, - "lines": 96.74, + "statements": 97.04, + "branches": 92.09, + "functions": 96.87, + "lines": 97.04, "counts": { - "lf": 4632, - "lh": 4481, - "brf": 917, - "brh": 845, - "fnf": 292, - "fnh": 283 + "lf": 5107, + "lh": 4956, + "brf": 1012, + "brh": 932, + "fnf": 319, + "fnh": 309 } }, "byPackage": { @@ -74,16 +74,16 @@ }, "@repo/core-dsr": { "statements": 100, - "branches": 100, - "functions": 100, + "branches": 92, + "functions": 96.88, "lines": 100, "counts": { - "lf": 0, - "lh": 0, - "brf": 5, - "brh": 5, - "fnf": 5, - "fnh": 5 + "lf": 475, + "lh": 475, + "brf": 100, + "brh": 92, + "fnf": 32, + "fnh": 31 } }, "@repo/core-shared": { diff --git a/docs/library-decisions/2026-05-14-payload.md b/docs/library-decisions/2026-05-14-payload.md index 778a557..483a617 100644 --- a/docs/library-decisions/2026-05-14-payload.md +++ b/docs/library-decisions/2026-05-14-payload.md @@ -71,7 +71,7 @@ Payload CMS is a fully self-hosted solution. No data is transmitted to Payload-c -All five feature packages (`@repo/auth`, `@repo/blog`, `@repo/media`, `@repo/marketing-pages`, `@repo/navigation`) use Payload for repository access. `@repo/core-cms` configures the Payload instance. `@repo/core-shared` receives Payload config at constructor time. Named, non-hypothetical consumers exist today. +All five feature packages (`@repo/auth`, `@repo/blog`, `@repo/media`, `@repo/marketing-pages`, `@repo/navigation`) use Payload for repository access. `@repo/core-cms` configures the Payload instance. `@repo/core-shared` receives Payload config at constructor time. `@repo/core-dsr` uses Payload for GDPR DSR operations (data export, deletion, rectification, processing restriction). Named, non-hypothetical consumers exist today. ## Prompt: replaces diff --git a/packages/core-dsr/package.json b/packages/core-dsr/package.json index dd0b77d..51eb7e2 100644 --- a/packages/core-dsr/package.json +++ b/packages/core-dsr/package.json @@ -13,7 +13,8 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { - "@repo/core-shared": "workspace:*" + "@repo/core-shared": "workspace:*", + "payload": "^3.0.0" }, "devDependencies": { "@repo/core-eslint": "workspace:*", diff --git a/packages/core-dsr/src/__tests__/di-binders.test.ts b/packages/core-dsr/src/__tests__/di-binders.test.ts new file mode 100644 index 0000000..551d268 --- /dev/null +++ b/packages/core-dsr/src/__tests__/di-binders.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import type { SanitizedConfig } from "payload"; +import { bindProductionDsr } from "@/di/bind-production"; +import { bindDevSeedDsr } from "@/di/bind-dev-seed"; + +const emptyConfig = { collections: [] } as unknown as SanitizedConfig; + +describe("bindProductionDsr", () => { + it("returns a DsrBinding with all four implementations", () => { + const binding = bindProductionDsr({ config: emptyConfig }); + + expect(binding.dataExport).toBeDefined(); + expect(binding.dataDelete).toBeDefined(); + expect(binding.dataRectify).toBeDefined(); + expect(binding.processingRestriction).toBeDefined(); + }); + + it("each binding exposes the expected interface methods", () => { + const binding = bindProductionDsr({ config: emptyConfig }); + + expect(typeof binding.dataExport.exportSubjectData).toBe("function"); + expect(typeof binding.dataDelete.deleteSubjectData).toBe("function"); + expect(typeof binding.dataRectify.updateSubjectField).toBe("function"); + expect(typeof binding.processingRestriction.setRestriction).toBe( + "function", + ); + expect(typeof binding.processingRestriction.isRestricted).toBe("function"); + }); + + it("uses the noop auditLog when none is provided", () => { + expect(() => bindProductionDsr({ config: emptyConfig })).not.toThrow(); + }); +}); + +describe("bindDevSeedDsr", () => { + it("returns a DsrBinding with all four in-memory implementations", () => { + const binding = bindDevSeedDsr(); + + expect(binding.dataExport).toBeDefined(); + expect(binding.dataDelete).toBeDefined(); + expect(binding.dataRectify).toBeDefined(); + expect(binding.processingRestriction).toBeDefined(); + }); + + it("each binding exposes the expected interface methods", () => { + const binding = bindDevSeedDsr(); + + expect(typeof binding.dataExport.exportSubjectData).toBe("function"); + expect(typeof binding.dataDelete.deleteSubjectData).toBe("function"); + expect(typeof binding.dataRectify.updateSubjectField).toBe("function"); + expect(typeof binding.processingRestriction.setRestriction).toBe( + "function", + ); + expect(typeof binding.processingRestriction.isRestricted).toBe("function"); + }); +}); diff --git a/packages/core-dsr/src/__tests__/in-memory.test.ts b/packages/core-dsr/src/__tests__/in-memory.test.ts new file mode 100644 index 0000000..bf5f67c --- /dev/null +++ b/packages/core-dsr/src/__tests__/in-memory.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { InMemoryDataExport } from "@/in-memory-data-export"; +import { InMemoryDataDelete } from "@/in-memory-data-delete"; +import { InMemoryDataRectify } from "@/in-memory-data-rectify"; +import { InMemoryProcessingRestriction } from "@/in-memory-processing-restriction"; + +describe("InMemoryDataExport", () => { + it("returns an empty bundle with the correct shape", async () => { + const exporter = new InMemoryDataExport(); + const bundle = await exporter.exportSubjectData("alice", "json"); + + expect(bundle.subjectId).toBe("alice"); + expect(bundle.format).toBe("json"); + expect(typeof bundle.exportedAt).toBe("string"); + expect(bundle.data).toEqual({}); + }); + + it("preserves format in the returned bundle", async () => { + const exporter = new InMemoryDataExport(); + const bundle = await exporter.exportSubjectData("bob", "json-ld"); + + expect(bundle.format).toBe("json-ld"); + }); +}); + +describe("InMemoryDataDelete", () => { + it("returns a DeletionCertificate with the correct shape", async () => { + const deleter = new InMemoryDataDelete(); + const cert = await deleter.deleteSubjectData("alice", "soft"); + + expect(cert.subjectId).toBe("alice"); + expect(cert.mode).toBe("soft"); + expect(cert.reason).toBe("art-17-request"); + expect(cert.affected).toEqual([]); + expect(typeof cert.auditEntryId).toBe("string"); + expect(typeof cert.timestamp).toBe("string"); + }); + + it("preserves mode in the certificate", async () => { + const deleter = new InMemoryDataDelete(); + const cert = await deleter.deleteSubjectData("alice", "cascade-hard"); + + expect(cert.mode).toBe("cascade-hard"); + }); +}); + +describe("InMemoryDataRectify", () => { + it("resolves without error", async () => { + const rectifier = new InMemoryDataRectify(); + await expect( + rectifier.updateSubjectField("alice", "users", "name", "Alice New"), + ).resolves.toBeUndefined(); + }); +}); + +describe("InMemoryProcessingRestriction", () => { + it("isRestricted returns false for unknown subjects", async () => { + const restriction = new InMemoryProcessingRestriction(); + expect(await restriction.isRestricted("alice")).toBe(false); + }); + + it("setRestriction(true) makes isRestricted return true", async () => { + const restriction = new InMemoryProcessingRestriction(); + await restriction.setRestriction("alice", true); + expect(await restriction.isRestricted("alice")).toBe(true); + }); + + it("setRestriction(false) makes isRestricted return false", async () => { + const restriction = new InMemoryProcessingRestriction(); + await restriction.setRestriction("alice", true); + await restriction.setRestriction("alice", false); + expect(await restriction.isRestricted("alice")).toBe(false); + }); + + it("tracks restriction per subject independently", async () => { + const restriction = new InMemoryProcessingRestriction(); + await restriction.setRestriction("alice", true); + await restriction.setRestriction("bob", false); + + expect(await restriction.isRestricted("alice")).toBe(true); + expect(await restriction.isRestricted("bob")).toBe(false); + }); +}); diff --git a/packages/core-dsr/src/__tests__/jsonld-context.test.ts b/packages/core-dsr/src/__tests__/jsonld-context.test.ts new file mode 100644 index 0000000..e4b6f4b --- /dev/null +++ b/packages/core-dsr/src/__tests__/jsonld-context.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join, dirname } from "node:path"; +import { vi } from "vitest"; +import type { SanitizedConfig } from "payload"; +import { PayloadDataExport } from "@/payload-data-export"; +import { RecordingAuditLog } from "@repo/core-testing/instrumentation"; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const contextFilePath = join(__dir, "../../src/contexts/user-data.jsonld"); + +function loadContextFile(): Record { + const raw = JSON.parse(readFileSync(contextFilePath, "utf-8")) as { + "@context": Record; + }; + return raw["@context"]; +} + +describe("user-data.jsonld context", () => { + it("is valid JSON with an @context key", () => { + const raw = JSON.parse(readFileSync(contextFilePath, "utf-8")) as unknown; + expect(raw).toMatchObject({ "@context": expect.any(Object) }); + }); + + it("declares @vocab pointing to schema.org", () => { + const ctx = loadContextFile(); + expect(ctx["@vocab"]).toBe("https://schema.org/"); + }); + + it("declares dsr namespace pointing to DPV", () => { + const ctx = loadContextFile(); + expect(ctx["dsr"]).toBe("https://w3.org/ns/dpv#"); + }); + + it("declares prov namespace", () => { + const ctx = loadContextFile(); + expect(ctx["prov"]).toBe("https://www.w3.org/ns/prov#"); + }); + + it("maps subjectId to schema.org identifier", () => { + const ctx = loadContextFile(); + expect(ctx["subjectId"]).toBe("identifier"); + }); + + it("maps exportedAt to schema.org dateCreated", () => { + const ctx = loadContextFile(); + expect(ctx["exportedAt"]).toBe("dateCreated"); + }); + + it("maps data as an @index container", () => { + const ctx = loadContextFile(); + expect(ctx["data"]).toMatchObject({ "@container": "@index" }); + }); + + it("maps asSelf to a DSR handling type", () => { + const ctx = loadContextFile(); + expect(ctx["asSelf"]).toMatchObject({ "@type": "@id" }); + }); + + it("maps asReference to a data subject right type", () => { + const ctx = loadContextFile(); + expect(ctx["asReference"]).toMatchObject({ "@type": "@id" }); + }); + + it("json-ld bundle includes the inline context matching the file", async () => { + const auditLog = new RecordingAuditLog(); + const mockPayload = { find: vi.fn().mockResolvedValue({ docs: [] }) }; + const mockGetPayload = vi.fn().mockResolvedValue(mockPayload); + const config = { collections: [] } as unknown as SanitizedConfig; + + const exporter = new PayloadDataExport(config, auditLog, mockGetPayload); + const bundle = await exporter.exportSubjectData("alice", "json-ld"); + + const fileCtx = loadContextFile(); + const bundleCtx = bundle["@context"] as Record; + + // Core keys must match between the file and the inline constant + expect(bundleCtx["@vocab"]).toBe(fileCtx["@vocab"]); + expect(bundleCtx["dsr"]).toBe(fileCtx["dsr"]); + expect(bundleCtx["subjectId"]).toBe(fileCtx["subjectId"]); + expect(bundleCtx["exportedAt"]).toBe(fileCtx["exportedAt"]); + }); +}); diff --git a/packages/core-dsr/src/__tests__/payload-data-delete.test.ts b/packages/core-dsr/src/__tests__/payload-data-delete.test.ts new file mode 100644 index 0000000..1a09a87 --- /dev/null +++ b/packages/core-dsr/src/__tests__/payload-data-delete.test.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { SanitizedConfig } from "payload"; +import { PayloadDataDelete } from "@/payload-data-delete"; +import { RecordingAuditLog } from "@repo/core-testing/instrumentation"; + +type MockPayload = { + find: ReturnType; + update: ReturnType; + delete: ReturnType; +}; + +function makeMockPayload(): MockPayload { + return { + find: vi.fn(), + update: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue({}), + }; +} + +function makeMockConfig( + collections: Array<{ slug: string; custom?: unknown }>, +): SanitizedConfig { + return { collections } as unknown as SanitizedConfig; +} + +describe("PayloadDataDelete", () => { + let auditLog: RecordingAuditLog; + let mockPayload: MockPayload; + let mockGetPayload: ReturnType; + + beforeEach(() => { + auditLog = new RecordingAuditLog(); + mockPayload = makeMockPayload(); + mockGetPayload = vi.fn().mockResolvedValue(mockPayload); + }); + + describe("soft mode", () => { + it("happy path — self role: NULLs exportable PII fields and emits RESTRICT", async () => { + const config = makeMockConfig([ + { + slug: "users", + custom: { + subject: { field: "id", kind: "self" }, + pii: { email: { exportable: true }, name: { exportable: true } }, + }, + }, + ]); + + mockPayload.find.mockResolvedValue({ + docs: [{ id: "alice", email: "a@ex.com", name: "Alice" }], + }); + + const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload); + const cert = await deleter.deleteSubjectData("alice", "soft"); + + expect(mockPayload.update).toHaveBeenCalledWith({ + collection: "users", + id: "alice", + data: { email: null, name: null }, + overrideAccess: true, + }); + + expect(cert.subjectId).toBe("alice"); + expect(cert.mode).toBe("soft"); + expect(cert.affected).toHaveLength(1); + expect(cert.affected[0]).toMatchObject({ + collection: "users", + action: "redacted", + fields: expect.arrayContaining(["email", "name"]), + }); + }); + + it("happy path — owner role: NULLs exportable fields in owned rows", async () => { + const config = makeMockConfig([ + { + slug: "orders", + custom: { + subject: { field: "userId", kind: "owner" }, + pii: { shippingAddress: { exportable: true } }, + }, + }, + ]); + + mockPayload.find.mockResolvedValue({ + docs: [{ id: "o-1", userId: "alice", shippingAddress: "1 Main" }], + }); + + const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload); + const cert = await deleter.deleteSubjectData("alice", "soft"); + + expect(mockPayload.update).toHaveBeenCalledWith({ + collection: "orders", + id: "o-1", + data: { shippingAddress: null }, + overrideAccess: true, + }); + expect(cert.affected[0]?.action).toBe("redacted"); + }); + + it("happy path — reference role: NULLs only the linked field, preserves row", async () => { + const config = makeMockConfig([ + { + slug: "comments", + custom: { + subject: { field: "mentionedUser", kind: "reference" }, + }, + }, + ]); + + mockPayload.find.mockResolvedValue({ + docs: [{ id: "c-1", mentionedUser: "alice", body: "hello alice" }], + }); + + const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload); + await deleter.deleteSubjectData("alice", "soft"); + + // Only the linking field is NULLed — not the body + expect(mockPayload.update).toHaveBeenCalledWith({ + collection: "comments", + id: "c-1", + data: { mentionedUser: null }, + overrideAccess: true, + }); + // Row is NOT deleted + expect(mockPayload.delete).not.toHaveBeenCalled(); + }); + + it("multi-subject row: only the requesting subject's link is redacted", async () => { + // Two rows in a reference collection: one for alice, one for bob + const config = makeMockConfig([ + { + slug: "comments", + custom: { + subject: { field: "mentionedUser", kind: "reference" }, + }, + }, + ]); + + // find returns only alice's row (Payload's where clause filters for alice) + mockPayload.find.mockResolvedValue({ + docs: [{ id: "c-alice", mentionedUser: "alice", body: "hi alice" }], + }); + + const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload); + await deleter.deleteSubjectData("alice", "soft"); + + // Only c-alice is updated + expect(mockPayload.update).toHaveBeenCalledTimes(1); + expect(mockPayload.update).toHaveBeenCalledWith( + expect.objectContaining({ id: "c-alice" }), + ); + }); + + it("emits RESTRICT audit entries per affected collection", async () => { + const config = makeMockConfig([ + { + slug: "users", + custom: { + subject: { field: "id", kind: "self" }, + pii: { email: { exportable: true } }, + }, + }, + { + slug: "orders", + custom: { + subject: { field: "userId", kind: "owner" }, + pii: { total: { exportable: true } }, + }, + }, + ]); + + mockPayload.find + .mockResolvedValueOnce({ docs: [{ id: "alice", email: "a@ex.com" }] }) + .mockResolvedValueOnce({ + docs: [{ id: "o-1", userId: "alice", total: 50 }], + }); + + const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload); + await deleter.deleteSubjectData("alice", "soft"); + + const restrictEntries = auditLog.recorded.filter( + (e) => e.action === "RESTRICT", + ); + expect(restrictEntries).toHaveLength(2); + expect(restrictEntries.map((e) => e.resource.type)).toEqual( + expect.arrayContaining(["users", "orders"]), + ); + }); + + it("returns DeletionCertificate with correct shape", async () => { + const config = makeMockConfig([{ slug: "posts" }]); + const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload); + const cert = await deleter.deleteSubjectData("alice", "soft"); + + expect(cert.subjectId).toBe("alice"); + expect(cert.mode).toBe("soft"); + expect(cert.reason).toBe("art-17-request"); + expect(cert.auditEntryId).toBeTruthy(); + expect(typeof cert.timestamp).toBe("string"); + }); + }); + + describe("cascade-hard mode", () => { + it("happy path — self/owner: hard-deletes rows", async () => { + const config = makeMockConfig([ + { + slug: "users", + custom: { + subject: { field: "id", kind: "self" }, + pii: { email: { exportable: true } }, + }, + }, + ]); + + mockPayload.find.mockResolvedValue({ + docs: [{ id: "alice", email: "a@ex.com" }], + }); + + const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload); + const cert = await deleter.deleteSubjectData("alice", "cascade-hard"); + + expect(mockPayload.delete).toHaveBeenCalledWith({ + collection: "users", + id: "alice", + overrideAccess: true, + }); + expect(mockPayload.update).not.toHaveBeenCalled(); + expect(cert.affected[0]?.action).toBe("deleted"); + }); + + it("cascade-hard — reference: NULLs linked field (does not delete row)", async () => { + const config = makeMockConfig([ + { + slug: "comments", + custom: { subject: { field: "mentionedUser", kind: "reference" } }, + }, + ]); + + mockPayload.find.mockResolvedValue({ + docs: [{ id: "c-1", mentionedUser: "alice" }], + }); + + const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload); + await deleter.deleteSubjectData("alice", "cascade-hard"); + + expect(mockPayload.delete).not.toHaveBeenCalled(); + expect(mockPayload.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { mentionedUser: null } }), + ); + }); + + it("emits DELETE audit entries for self/owner collections", async () => { + const config = makeMockConfig([ + { + slug: "users", + custom: { + subject: { field: "id", kind: "self" }, + pii: { email: { exportable: true } }, + }, + }, + ]); + + mockPayload.find.mockResolvedValue({ + docs: [{ id: "alice" }], + }); + + const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload); + await deleter.deleteSubjectData("alice", "cascade-hard"); + + const deleteEntries = auditLog.recorded.filter( + (e) => e.action === "DELETE", + ); + expect(deleteEntries).toHaveLength(1); + expect(deleteEntries[0]!.resource.type).toBe("users"); + }); + + it("anonymises the subjectId in the certificate", async () => { + const config = makeMockConfig([{ slug: "posts" }]); + const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload); + const cert = await deleter.deleteSubjectData("alice", "cascade-hard"); + + expect(cert.subjectId).toMatch(/^erased-[0-9a-f]+$/); + expect(cert.subjectId).not.toContain("alice"); + }); + + it("correlationId is shared across all audit entries for one operation", async () => { + const config = makeMockConfig([ + { + slug: "users", + custom: { + subject: { field: "id", kind: "self" }, + pii: { email: { exportable: true } }, + }, + }, + { + slug: "orders", + custom: { + subject: { field: "userId", kind: "owner" }, + pii: { total: { exportable: true } }, + }, + }, + ]); + + mockPayload.find + .mockResolvedValueOnce({ docs: [{ id: "alice" }] }) + .mockResolvedValueOnce({ docs: [{ id: "o-1", userId: "alice" }] }); + + const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload); + const cert = await deleter.deleteSubjectData("alice", "cascade-hard"); + + const ids = auditLog.recorded.map((e) => e.correlationId); + expect(new Set(ids).size).toBe(1); + expect(ids[0]).toBe(cert.auditEntryId); + }); + }); +}); diff --git a/packages/core-dsr/src/__tests__/payload-data-export.test.ts b/packages/core-dsr/src/__tests__/payload-data-export.test.ts new file mode 100644 index 0000000..e0de423 --- /dev/null +++ b/packages/core-dsr/src/__tests__/payload-data-export.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { SanitizedConfig } from "payload"; +import { PayloadDataExport } from "@/payload-data-export"; +import { RecordingAuditLog } from "@repo/core-testing/instrumentation"; + +type MockPayload = { + find: ReturnType; +}; + +function makeMockPayload(): MockPayload { + return { find: vi.fn() }; +} + +function makeMockConfig( + collections: Array<{ slug: string; custom?: unknown }>, +): SanitizedConfig { + return { collections } as unknown as SanitizedConfig; +} + +describe("PayloadDataExport", () => { + let auditLog: RecordingAuditLog; + let mockPayload: MockPayload; + let mockGetPayload: ReturnType; + + beforeEach(() => { + auditLog = new RecordingAuditLog(); + mockPayload = makeMockPayload(); + mockGetPayload = vi.fn().mockResolvedValue(mockPayload); + }); + + it("returns an empty bundle when no collections have custom.subject", async () => { + const config = makeMockConfig([{ slug: "posts" }]); + const exporter = new PayloadDataExport(config, auditLog, mockGetPayload); + const bundle = await exporter.exportSubjectData("alice", "json"); + + expect(bundle.subjectId).toBe("alice"); + expect(bundle.format).toBe("json"); + expect(bundle.data).toEqual({}); + expect(mockPayload.find).not.toHaveBeenCalled(); + }); + + it("happy path — self role: includes exportable PII fields only", async () => { + const config = makeMockConfig([ + { + slug: "users", + custom: { + subject: { field: "id", kind: "self" }, + pii: { + email: { exportable: true }, + name: { exportable: true }, + secret: { exportable: false }, + }, + }, + }, + ]); + + mockPayload.find.mockResolvedValue({ + docs: [{ id: "alice", email: "a@ex.com", name: "Alice", secret: "x" }], + }); + + const exporter = new PayloadDataExport(config, auditLog, mockGetPayload); + const bundle = await exporter.exportSubjectData("alice", "json"); + + expect(bundle.data["users"]?.asSelf).toHaveLength(1); + const row = bundle.data["users"]!.asSelf![0]!; + expect(row).toMatchObject({ + id: "alice", + email: "a@ex.com", + name: "Alice", + }); + expect(row).not.toHaveProperty("secret"); + expect(bundle.data["users"]?.asReference).toBeUndefined(); + }); + + it("happy path — owner role: includes exportable PII fields", async () => { + const config = makeMockConfig([ + { + slug: "orders", + custom: { + subject: { field: "userId", kind: "owner" }, + pii: { shippingAddress: { exportable: true } }, + }, + }, + ]); + + mockPayload.find.mockResolvedValue({ + docs: [{ id: "order-1", userId: "alice", shippingAddress: "1 Main St" }], + }); + + const exporter = new PayloadDataExport(config, auditLog, mockGetPayload); + const bundle = await exporter.exportSubjectData("alice", "json"); + + const row = bundle.data["orders"]?.asSelf?.[0]; + expect(row).toMatchObject({ id: "order-1", shippingAddress: "1 Main St" }); + }); + + it("happy path — reference role: returns SubjectReference, not row content", async () => { + const config = makeMockConfig([ + { + slug: "comments", + custom: { + subject: { field: "mentionedUser", kind: "reference" }, + }, + }, + ]); + + mockPayload.find.mockResolvedValue({ + docs: [{ id: "comment-42", mentionedUser: "alice", body: "hello" }], + }); + + const exporter = new PayloadDataExport(config, auditLog, mockGetPayload); + const bundle = await exporter.exportSubjectData("alice", "json"); + + expect(bundle.data["comments"]?.asSelf).toBeUndefined(); + const refs = bundle.data["comments"]?.asReference; + expect(refs).toHaveLength(1); + expect(refs![0]).toEqual({ + rowId: "comment-42", + linkedField: "mentionedUser", + linkedThrough: "comments", + }); + // Row content must NOT appear in the reference bucket + expect(JSON.stringify(refs)).not.toContain("hello"); + }); + + it("skips collections that return no matching rows", async () => { + const config = makeMockConfig([ + { + slug: "orders", + custom: { + subject: { field: "userId", kind: "owner" }, + pii: { total: { exportable: true } }, + }, + }, + ]); + + mockPayload.find.mockResolvedValue({ docs: [] }); + + const exporter = new PayloadDataExport(config, auditLog, mockGetPayload); + const bundle = await exporter.exportSubjectData("alice", "json"); + + expect(bundle.data).toEqual({}); + }); + + it("emits an EXPORT audit entry", async () => { + const config = makeMockConfig([{ slug: "posts" }]); + const exporter = new PayloadDataExport(config, auditLog, mockGetPayload); + await exporter.exportSubjectData("alice", "json"); + + expect(auditLog.recorded).toHaveLength(1); + const entry = auditLog.recorded[0]!; + expect(entry.action).toBe("EXPORT"); + expect(entry.actorId).toBe("alice"); + expect(entry.resource.type).toBe("subject-data"); + expect(entry.outcome).toBe("success"); + }); + + it("attaches @context when format is json-ld", async () => { + const config = makeMockConfig([{ slug: "posts" }]); + const exporter = new PayloadDataExport(config, auditLog, mockGetPayload); + const bundle = await exporter.exportSubjectData("alice", "json-ld"); + + expect(bundle["@context"]).toBeDefined(); + expect(typeof bundle["@context"]).toBe("object"); + const ctx = bundle["@context"] as Record; + expect(ctx["@vocab"]).toBe("https://schema.org/"); + expect(ctx["dsr"]).toBe("https://w3.org/ns/dpv#"); + expect(ctx["subjectId"]).toBe("identifier"); + }); + + it("does NOT attach @context when format is json", async () => { + const config = makeMockConfig([{ slug: "posts" }]); + const exporter = new PayloadDataExport(config, auditLog, mockGetPayload); + const bundle = await exporter.exportSubjectData("alice", "json"); + + expect(bundle["@context"]).toBeUndefined(); + }); + + it("multi-collection: walks all subject-linked collections", async () => { + const config = makeMockConfig([ + { + slug: "users", + custom: { + subject: { field: "id", kind: "self" }, + pii: { email: { exportable: true } }, + }, + }, + { + slug: "orders", + custom: { + subject: { field: "userId", kind: "owner" }, + pii: { total: { exportable: true } }, + }, + }, + { slug: "posts" }, // no subject linkage — should be skipped + ]); + + mockPayload.find + .mockResolvedValueOnce({ docs: [{ id: "alice", email: "a@ex.com" }] }) + .mockResolvedValueOnce({ + docs: [{ id: "o-1", userId: "alice", total: 99 }], + }); + + const exporter = new PayloadDataExport(config, auditLog, mockGetPayload); + const bundle = await exporter.exportSubjectData("alice", "json"); + + expect(Object.keys(bundle.data)).toEqual(["users", "orders"]); + }); +}); diff --git a/packages/core-dsr/src/__tests__/payload-data-rectify.test.ts b/packages/core-dsr/src/__tests__/payload-data-rectify.test.ts new file mode 100644 index 0000000..57448ca --- /dev/null +++ b/packages/core-dsr/src/__tests__/payload-data-rectify.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { SanitizedConfig } from "payload"; +import { PayloadDataRectify } from "@/payload-data-rectify"; +import { RecordingAuditLog } from "@repo/core-testing/instrumentation"; + +type MockPayload = { + find: ReturnType; + update: ReturnType; +}; + +function makeMockPayload(): MockPayload { + return { + find: vi.fn(), + update: vi.fn().mockResolvedValue({}), + }; +} + +function makeMockConfig( + collections: Array<{ slug: string; custom?: unknown }>, +): SanitizedConfig { + return { collections } as unknown as SanitizedConfig; +} + +describe("PayloadDataRectify", () => { + let auditLog: RecordingAuditLog; + let mockPayload: MockPayload; + let mockGetPayload: ReturnType; + + beforeEach(() => { + auditLog = new RecordingAuditLog(); + mockPayload = makeMockPayload(); + mockGetPayload = vi.fn().mockResolvedValue(mockPayload); + }); + + it("happy path: updates a PII-tagged field and emits RESTRICT with art-16-request reason", async () => { + const config = makeMockConfig([ + { + slug: "users", + custom: { + subject: { field: "id", kind: "self" }, + pii: { name: { exportable: true } }, + }, + }, + ]); + + mockPayload.find.mockResolvedValue({ + docs: [{ id: "alice", name: "Alice Old" }], + }); + + const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload); + await rectifier.updateSubjectField("alice", "users", "name", "Alice New"); + + expect(mockPayload.update).toHaveBeenCalledWith({ + collection: "users", + id: "alice", + data: { name: "Alice New" }, + overrideAccess: true, + }); + + expect(auditLog.recorded).toHaveLength(1); + const entry = auditLog.recorded[0]!; + expect(entry.action).toBe("RESTRICT"); + expect(entry.reason).toBe("art-16-request"); + expect(entry.actorId).toBe("alice"); + expect(entry.changedFields).toContain("name"); + }); + + it("updates all subject rows if multiple exist", async () => { + const config = makeMockConfig([ + { + slug: "profiles", + custom: { + subject: { field: "userId", kind: "owner" }, + pii: { bio: { exportable: true } }, + }, + }, + ]); + + mockPayload.find.mockResolvedValue({ + docs: [ + { id: "p-1", userId: "alice", bio: "old" }, + { id: "p-2", userId: "alice", bio: "old2" }, + ], + }); + + const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload); + await rectifier.updateSubjectField("alice", "profiles", "bio", "new bio"); + + expect(mockPayload.update).toHaveBeenCalledTimes(2); + }); + + it("throws when collection not found in config", async () => { + const config = makeMockConfig([{ slug: "users" }]); + const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload); + + await expect( + rectifier.updateSubjectField("alice", "nonexistent", "field", "val"), + ).rejects.toThrow(/not found/i); + }); + + it("throws when collection has no DSR subject linkage", async () => { + const config = makeMockConfig([{ slug: "posts" }]); + const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload); + + await expect( + rectifier.updateSubjectField("alice", "posts", "title", "new"), + ).rejects.toThrow(/no DSR subject linkage/i); + }); + + it("throws when field is not PII-tagged", async () => { + const config = makeMockConfig([ + { + slug: "users", + custom: { + subject: { field: "id", kind: "self" }, + pii: { email: { exportable: true } }, + }, + }, + ]); + + const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload); + + await expect( + rectifier.updateSubjectField("alice", "users", "internalNote", "oops"), + ).rejects.toThrow(/not tagged as PII/i); + }); + + it("emits audit entry with resource type matching the collection slug", async () => { + const config = makeMockConfig([ + { + slug: "users", + custom: { + subject: { field: "id", kind: "self" }, + pii: { name: { exportable: true } }, + }, + }, + ]); + + mockPayload.find.mockResolvedValue({ docs: [{ id: "alice" }] }); + + const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload); + await rectifier.updateSubjectField("alice", "users", "name", "New"); + + expect(auditLog.recorded[0]!.resource.type).toBe("users"); + }); +}); diff --git a/packages/core-dsr/src/__tests__/payload-processing-restriction.test.ts b/packages/core-dsr/src/__tests__/payload-processing-restriction.test.ts new file mode 100644 index 0000000..9581c1c --- /dev/null +++ b/packages/core-dsr/src/__tests__/payload-processing-restriction.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { SanitizedConfig } from "payload"; +import { PayloadProcessingRestriction } from "@/payload-processing-restriction"; +import { RecordingAuditLog } from "@repo/core-testing/instrumentation"; + +type MockPayload = { + find: ReturnType; + update: ReturnType; +}; + +function makeMockPayload(): MockPayload { + return { + find: vi.fn(), + update: vi.fn().mockResolvedValue({}), + }; +} + +const emptyConfig = { collections: [] } as unknown as SanitizedConfig; + +describe("PayloadProcessingRestriction", () => { + let auditLog: RecordingAuditLog; + let mockPayload: MockPayload; + let mockGetPayload: ReturnType; + + beforeEach(() => { + auditLog = new RecordingAuditLog(); + mockPayload = makeMockPayload(); + mockGetPayload = vi.fn().mockResolvedValue(mockPayload); + }); + + describe("setRestriction", () => { + it("sets processingRestrictedAt when granted=true", async () => { + const restriction = new PayloadProcessingRestriction( + emptyConfig, + auditLog, + mockGetPayload, + ); + await restriction.setRestriction("alice", true); + + expect(mockPayload.update).toHaveBeenCalledWith( + expect.objectContaining({ + collection: "users", + id: "alice", + data: expect.objectContaining({ + processingRestrictedAt: expect.any(String), + }), + overrideAccess: true, + }), + ); + }); + + it("clears processingRestrictedAt when granted=false", async () => { + const restriction = new PayloadProcessingRestriction( + emptyConfig, + auditLog, + mockGetPayload, + ); + await restriction.setRestriction("alice", false); + + expect(mockPayload.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { processingRestrictedAt: null }, + }), + ); + }); + + it("emits RESTRICT audit entry when granting restriction", async () => { + const restriction = new PayloadProcessingRestriction( + emptyConfig, + auditLog, + mockGetPayload, + ); + await restriction.setRestriction("alice", true); + + expect(auditLog.recorded).toHaveLength(1); + const entry = auditLog.recorded[0]!; + expect(entry.action).toBe("RESTRICT"); + expect(entry.actorId).toBe("alice"); + expect(entry.changedFields).toContain("processingRestrictedAt"); + }); + + it("emits UNRESTRICT audit entry when lifting restriction", async () => { + const restriction = new PayloadProcessingRestriction( + emptyConfig, + auditLog, + mockGetPayload, + ); + await restriction.setRestriction("alice", false); + + expect(auditLog.recorded[0]!.action).toBe("UNRESTRICT"); + }); + }); + + describe("isRestricted", () => { + it("returns true when processingRestrictedAt is set", async () => { + mockPayload.find.mockResolvedValue({ + docs: [ + { id: "alice", processingRestrictedAt: "2024-01-01T00:00:00.000Z" }, + ], + }); + + const restriction = new PayloadProcessingRestriction( + emptyConfig, + auditLog, + mockGetPayload, + ); + expect(await restriction.isRestricted("alice")).toBe(true); + }); + + it("returns false when processingRestrictedAt is null", async () => { + mockPayload.find.mockResolvedValue({ + docs: [{ id: "alice", processingRestrictedAt: null }], + }); + + const restriction = new PayloadProcessingRestriction( + emptyConfig, + auditLog, + mockGetPayload, + ); + expect(await restriction.isRestricted("alice")).toBe(false); + }); + + it("returns false when user record not found", async () => { + mockPayload.find.mockResolvedValue({ docs: [] }); + + const restriction = new PayloadProcessingRestriction( + emptyConfig, + auditLog, + mockGetPayload, + ); + expect(await restriction.isRestricted("ghost")).toBe(false); + }); + + it("restriction flag honored: setRestriction(true) then isRestricted returns true", async () => { + // setRestriction updates the DB; simulate the updated state via find mock + mockPayload.find.mockResolvedValue({ + docs: [ + { id: "alice", processingRestrictedAt: new Date().toISOString() }, + ], + }); + + const restriction = new PayloadProcessingRestriction( + emptyConfig, + auditLog, + mockGetPayload, + ); + await restriction.setRestriction("alice", true); + const result = await restriction.isRestricted("alice"); + + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/core-dsr/src/di/bind-dev-seed.ts b/packages/core-dsr/src/di/bind-dev-seed.ts new file mode 100644 index 0000000..31fdaec --- /dev/null +++ b/packages/core-dsr/src/di/bind-dev-seed.ts @@ -0,0 +1,21 @@ +import { InMemoryDataExport } from "../in-memory-data-export"; +import { InMemoryDataDelete } from "../in-memory-data-delete"; +import { InMemoryDataRectify } from "../in-memory-data-rectify"; +import { InMemoryProcessingRestriction } from "../in-memory-processing-restriction"; +import type { DsrBinding } from "./bind-production"; + +/** + * Returns in-memory DSR implementations for dev-seed and storybook contexts + * where Payload is unavailable. + * + * No config or auditLog needed: all operations are no-ops that return + * well-shaped responses. + */ +export function bindDevSeedDsr(): DsrBinding { + return { + dataExport: new InMemoryDataExport(), + dataDelete: new InMemoryDataDelete(), + dataRectify: new InMemoryDataRectify(), + processingRestriction: new InMemoryProcessingRestriction(), + }; +} diff --git a/packages/core-dsr/src/di/bind-production.ts b/packages/core-dsr/src/di/bind-production.ts new file mode 100644 index 0000000..dced427 --- /dev/null +++ b/packages/core-dsr/src/di/bind-production.ts @@ -0,0 +1,44 @@ +import type { SanitizedConfig } from "payload"; +import type { AuditLogProtocol } from "@repo/core-shared/di"; +import type { IDataExport } from "../data-export.interface"; +import type { IDataDelete } from "../data-delete.interface"; +import type { IDataRectify } from "../data-rectify.interface"; +import type { IProcessingRestriction } from "../processing-restriction.interface"; +import { PayloadDataExport } from "../payload-data-export"; +import { PayloadDataDelete } from "../payload-data-delete"; +import { PayloadDataRectify } from "../payload-data-rectify"; +import { PayloadProcessingRestriction } from "../payload-processing-restriction"; + +export type DsrBinding = { + dataExport: IDataExport; + dataDelete: IDataDelete; + dataRectify: IDataRectify; + processingRestriction: IProcessingRestriction; +}; + +export type BindProductionDsrOpts = { + config: SanitizedConfig; + auditLog?: AuditLogProtocol; +}; + +const noopAuditLog: AuditLogProtocol = { record: async () => {} }; + +/** + * Returns Payload-backed DSR implementations pre-wired with config + auditLog. + * + * Wired by the app aggregator alongside feature binders. The returned binding + * is passed as a dependency to dsrRouter and any feature that needs DSR + * operations. + */ +export function bindProductionDsr(opts: BindProductionDsrOpts): DsrBinding { + const auditLog = opts.auditLog ?? noopAuditLog; + return { + dataExport: new PayloadDataExport(opts.config, auditLog), + dataDelete: new PayloadDataDelete(opts.config, auditLog), + dataRectify: new PayloadDataRectify(opts.config, auditLog), + processingRestriction: new PayloadProcessingRestriction( + opts.config, + auditLog, + ), + }; +} diff --git a/packages/core-dsr/src/dsr-collection-custom.ts b/packages/core-dsr/src/dsr-collection-custom.ts new file mode 100644 index 0000000..e656916 --- /dev/null +++ b/packages/core-dsr/src/dsr-collection-custom.ts @@ -0,0 +1,65 @@ +import type { FieldPii } from "@repo/core-shared/payload"; + +export type { FieldPii }; + +/** + * DSR-specific Payload collection custom metadata. + * + * Collection authors annotate their collection configs with these shapes so the + * DSR implementations can walk `custom.subject` linkage and filter by each + * field's `custom.pii.exportable` flag. + * + * The `pii` map mirrors the field-level `FieldPii` shape from + * `@repo/core-shared/payload` so that the `pii-declaration-must-be-complete` + * ESLint rule is satisfied for each declared field. + * + * @example + * ```ts + * import type { DsrCollectionCustom } from "@repo/core-dsr"; + * + * const ordersCollection: CollectionConfig = { + * slug: "orders", + * custom: { + * subject: { field: "userId", kind: "owner" }, + * pii: { + * shippingAddress: { + * category: "contact-address", + * purpose: ["service-delivery"], + * exportable: true, + * restrictable: true, + * }, + * }, + * } satisfies DsrCollectionCustom, + * }; + * ``` + */ + +export type DsrSubjectLinkKind = "self" | "owner" | "reference"; + +export type DsrSubjectLinkage = { + /** + * Field name in this collection that contains the subject's canonical ID. + * For "self" collections (e.g. "users"), use the literal "id". + */ + field: string; + /** + * - "self" — this collection IS the subject record (e.g. users). + * - "owner" — the subject owns these rows (e.g. orders, blog posts). + * - "reference" — the subject is referenced but does not own the row. + */ + kind: DsrSubjectLinkKind; +}; + +/** Shape expected in `collection.custom` for DSR-enabled collections. */ +export type DsrCollectionCustom = { + /** Subject linkage metadata. Omit for collections with no subject data. */ + subject?: DsrSubjectLinkage; + /** + * Map of field name → FieldPii metadata. Must include all required fields + * (`category`, `purpose`, `exportable`, `restrictable`) to satisfy the + * `pii-declaration-must-be-complete` ESLint conformance rule. + * + * Omit for collections with no PII fields. + */ + pii?: Record; +}; diff --git a/packages/core-dsr/src/in-memory-data-delete.ts b/packages/core-dsr/src/in-memory-data-delete.ts new file mode 100644 index 0000000..909933c --- /dev/null +++ b/packages/core-dsr/src/in-memory-data-delete.ts @@ -0,0 +1,27 @@ +import { randomUUID } from "node:crypto"; +import type { IDataDelete } from "./data-delete.interface"; +import type { DeletionMode, DeletionCertificate } from "./dsr-types"; + +/** + * Volatile in-memory IDataDelete. Returns a well-shaped DeletionCertificate + * without touching any persistence layer. + * + * Used in dev-seed and storybook contexts where Payload is unavailable. + * Not a recording double — use RecordingDataDelete from @repo/core-testing for + * call-assertion in unit tests. + */ +export class InMemoryDataDelete implements IDataDelete { + async deleteSubjectData( + subjectId: string, + mode: DeletionMode, + ): Promise { + return { + subjectId, + mode, + timestamp: new Date().toISOString(), + reason: "art-17-request", + affected: [], + auditEntryId: randomUUID(), + }; + } +} diff --git a/packages/core-dsr/src/in-memory-data-export.ts b/packages/core-dsr/src/in-memory-data-export.ts new file mode 100644 index 0000000..d9f4bde --- /dev/null +++ b/packages/core-dsr/src/in-memory-data-export.ts @@ -0,0 +1,24 @@ +import type { IDataExport } from "./data-export.interface"; +import type { DsrFormat, UserDataBundle } from "./dsr-types"; + +/** + * Volatile in-memory IDataExport. Returns an empty bundle with the correct + * shape. + * + * Used in dev-seed and storybook contexts where Payload is unavailable. + * Not a recording double — use RecordingDataExport from @repo/core-testing for + * call-assertion in unit tests. + */ +export class InMemoryDataExport implements IDataExport { + async exportSubjectData( + subjectId: string, + format: DsrFormat, + ): Promise { + return { + subjectId, + exportedAt: new Date().toISOString(), + format, + data: {}, + }; + } +} diff --git a/packages/core-dsr/src/in-memory-data-rectify.ts b/packages/core-dsr/src/in-memory-data-rectify.ts new file mode 100644 index 0000000..cef064a --- /dev/null +++ b/packages/core-dsr/src/in-memory-data-rectify.ts @@ -0,0 +1,19 @@ +import type { IDataRectify } from "./data-rectify.interface"; + +/** + * Volatile in-memory IDataRectify. Accepts calls but makes no changes. + * + * Used in dev-seed and storybook contexts where Payload is unavailable. + * Not a recording double — use RecordingDataRectify from @repo/core-testing for + * call-assertion in unit tests. + */ +export class InMemoryDataRectify implements IDataRectify { + async updateSubjectField( + _subjectId: string, + _collection: string, + _field: string, + _value: unknown, + ): Promise { + // no-op in dev-seed + } +} diff --git a/packages/core-dsr/src/in-memory-processing-restriction.ts b/packages/core-dsr/src/in-memory-processing-restriction.ts new file mode 100644 index 0000000..290c7ba --- /dev/null +++ b/packages/core-dsr/src/in-memory-processing-restriction.ts @@ -0,0 +1,25 @@ +import type { IProcessingRestriction } from "./processing-restriction.interface"; + +/** + * Volatile in-memory IProcessingRestriction. Tracks restriction state in + * memory; state is lost on process restart. + * + * Used in dev-seed and storybook contexts where Payload is unavailable. + * Not a recording double — use RecordingProcessingRestriction from + * @repo/core-testing for call-assertion in unit tests. + */ +export class InMemoryProcessingRestriction implements IProcessingRestriction { + private readonly restricted = new Set(); + + async setRestriction(subjectId: string, granted: boolean): Promise { + if (granted) { + this.restricted.add(subjectId); + } else { + this.restricted.delete(subjectId); + } + } + + async isRestricted(subjectId: string): Promise { + return this.restricted.has(subjectId); + } +} diff --git a/packages/core-dsr/src/index.ts b/packages/core-dsr/src/index.ts index b023864..b671184 100644 --- a/packages/core-dsr/src/index.ts +++ b/packages/core-dsr/src/index.ts @@ -14,3 +14,24 @@ export type { IDataExport } from "./data-export.interface"; export type { IDataDelete } from "./data-delete.interface"; export type { IDataRectify } from "./data-rectify.interface"; export type { IProcessingRestriction } from "./processing-restriction.interface"; + +export type { + DsrSubjectLinkKind, + DsrSubjectLinkage, + DsrCollectionCustom, + FieldPii, +} from "./dsr-collection-custom"; + +export { PayloadDataExport } from "./payload-data-export"; +export { PayloadDataDelete } from "./payload-data-delete"; +export { PayloadDataRectify } from "./payload-data-rectify"; +export { PayloadProcessingRestriction } from "./payload-processing-restriction"; + +export { InMemoryDataExport } from "./in-memory-data-export"; +export { InMemoryDataDelete } from "./in-memory-data-delete"; +export { InMemoryDataRectify } from "./in-memory-data-rectify"; +export { InMemoryProcessingRestriction } from "./in-memory-processing-restriction"; + +export { bindProductionDsr } from "./di/bind-production"; +export type { DsrBinding, BindProductionDsrOpts } from "./di/bind-production"; +export { bindDevSeedDsr } from "./di/bind-dev-seed"; diff --git a/packages/core-dsr/src/payload-data-delete.ts b/packages/core-dsr/src/payload-data-delete.ts new file mode 100644 index 0000000..360aed3 --- /dev/null +++ b/packages/core-dsr/src/payload-data-delete.ts @@ -0,0 +1,292 @@ +import { getPayload as _getPayload } from "payload"; +import type { SanitizedConfig } from "payload"; +import { randomUUID } from "node:crypto"; +import { createHash } from "node:crypto"; +import type { AuditLogProtocol } from "@repo/core-shared/di"; +import type { IDataDelete } from "./data-delete.interface"; +import type { + DeletionMode, + DeletionCertificate, + DeletionAffected, +} from "./dsr-types"; +import type { DsrCollectionCustom } from "./dsr-collection-custom"; + +type PayloadDoc = Record; + +type PayloadAPI = { + find(args: { + collection: string; + where: Record; + overrideAccess: true; + limit: number; + }): Promise<{ docs: PayloadDoc[] }>; + update(args: { + collection: string; + id: string; + data: Record; + overrideAccess: true; + }): Promise; + delete(args: { + collection: string; + id: string; + overrideAccess: true; + }): Promise; +}; + +type GetPayload = (args: { config: SanitizedConfig }) => Promise; + +function buildWhere(field: string, subjectId: string): Record { + return field === "id" + ? { id: { equals: subjectId } } + : { [field]: { equals: subjectId } }; +} + +async function softRedactOwnerRows( + payload: PayloadAPI, + slug: string, + docs: PayloadDoc[], + exportableFields: string[], +): Promise { + if (exportableFields.length === 0) return; + const nullData = Object.fromEntries(exportableFields.map((f) => [f, null])); + for (const doc of docs) { + await payload.update({ + collection: slug, + id: String(doc["id"]), + data: nullData, + overrideAccess: true, + }); + } +} + +async function cascadeHardDeleteOwnerRows( + payload: PayloadAPI, + slug: string, + docs: PayloadDoc[], +): Promise { + for (const doc of docs) { + await payload.delete({ + collection: slug, + id: String(doc["id"]), + overrideAccess: true, + }); + } +} + +async function redactReferenceField( + payload: PayloadAPI, + slug: string, + field: string, + docs: PayloadDoc[], +): Promise { + for (const doc of docs) { + await payload.update({ + collection: slug, + id: String(doc["id"]), + data: { [field]: null }, + overrideAccess: true, + }); + } +} + +/** + * Payload-backed IDataDelete. Implements both GDPR Art. 17 deletion modes: + * + * - "soft" — restricts processing, NULLs exportable PII, redacts reference + * links. Row structure preserved (other subjects' data in shared rows intact). + * - "cascade-hard" — hard-deletes self/owner rows and redacts reference fields. + * Admin-only; auth enforcement at the tRPC procedure layer. + * + * Emits RESTRICT or DELETE audit entries per affected collection. + */ +export class PayloadDataDelete implements IDataDelete { + constructor( + private readonly config: SanitizedConfig, + private readonly auditLog: AuditLogProtocol, + private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload, + ) {} + + async deleteSubjectData( + subjectId: string, + mode: DeletionMode, + ): Promise { + const payload = await this.getPayloadFn({ config: this.config }); + const correlationId = randomUUID(); + const timestamp = new Date().toISOString(); + const affected: DeletionAffected[] = []; + + for (const collection of this.config.collections) { + const custom = (collection.custom ?? {}) as DsrCollectionCustom; + if (!custom.subject) continue; + + const { field, kind } = custom.subject; + const result = await payload.find({ + collection: collection.slug, + where: buildWhere(field, subjectId), + overrideAccess: true, + limit: 1000, + }); + if (result.docs.length === 0) continue; + + if (kind === "self" || kind === "owner") { + await this.processOwnerRows( + payload, + collection.slug, + mode, + custom, + result.docs, + subjectId, + correlationId, + affected, + ); + } else { + await this.processReferenceRows( + payload, + collection.slug, + field, + mode, + result.docs, + subjectId, + correlationId, + affected, + ); + } + } + + return this.buildCertificate( + subjectId, + mode, + timestamp, + correlationId, + affected, + ); + } + + private async processOwnerRows( + payload: PayloadAPI, + slug: string, + mode: DeletionMode, + custom: DsrCollectionCustom, + docs: PayloadDoc[], + subjectId: string, + correlationId: string, + affected: DeletionAffected[], + ): Promise { + const piiMeta = custom.pii ?? {}; + const exportableFields = Object.entries(piiMeta) + .filter(([, m]) => m.exportable) + .map(([name]) => name); + + if (mode === "soft") { + await softRedactOwnerRows(payload, slug, docs, exportableFields); + affected.push({ + collection: slug, + rowsAffected: docs.length, + action: "redacted", + fields: exportableFields, + }); + await this.recordAudit( + subjectId, + "RESTRICT", + slug, + exportableFields, + correlationId, + "art-17-request", + ); + } else { + await cascadeHardDeleteOwnerRows(payload, slug, docs); + affected.push({ + collection: slug, + rowsAffected: docs.length, + action: "deleted", + }); + await this.recordAudit( + subjectId, + "DELETE", + slug, + [], + correlationId, + "art-17-request", + ); + } + } + + private async processReferenceRows( + payload: PayloadAPI, + slug: string, + field: string, + mode: DeletionMode, + docs: PayloadDoc[], + subjectId: string, + correlationId: string, + affected: DeletionAffected[], + ): Promise { + await redactReferenceField(payload, slug, field, docs); + affected.push({ + collection: slug, + rowsAffected: docs.length, + action: "redacted", + fields: [field], + }); + const action = mode === "soft" ? "RESTRICT" : "DELETE"; + await this.recordAudit( + subjectId, + action, + slug, + [field], + correlationId, + "art-17-request", + ); + } + + private async recordAudit( + subjectId: string, + action: "RESTRICT" | "DELETE" | "UNRESTRICT", + resourceType: string, + changedFields: string[], + correlationId: string, + reason: string, + ): Promise { + await this.auditLog.record({ + actorId: subjectId, + actorType: "user", + actorRoles: [], + action, + resource: { type: resourceType }, + changedFields: changedFields.length > 0 ? changedFields : undefined, + at: new Date(), + scope: { + feature: "core-dsr", + environment: process.env["NODE_ENV"] ?? "development", + tenant: "default", + }, + reason, + correlationId, + from: { ipTruncated: "system", userAgent: "system" }, + containsPii: false, + outcome: "success", + }); + } + + private buildCertificate( + subjectId: string, + mode: DeletionMode, + timestamp: string, + correlationId: string, + affected: DeletionAffected[], + ): DeletionCertificate { + const certSubjectId = + mode === "cascade-hard" + ? `erased-${createHash("sha256").update(subjectId).digest("hex").slice(0, 16)}` + : subjectId; + + return { + subjectId: certSubjectId, + mode, + timestamp, + reason: "art-17-request", + affected, + auditEntryId: correlationId, + }; + } +} diff --git a/packages/core-dsr/src/payload-data-export.ts b/packages/core-dsr/src/payload-data-export.ts new file mode 100644 index 0000000..647ea63 --- /dev/null +++ b/packages/core-dsr/src/payload-data-export.ts @@ -0,0 +1,145 @@ +import { getPayload as _getPayload } from "payload"; +import type { SanitizedConfig } from "payload"; +import type { AuditLogProtocol } from "@repo/core-shared/di"; +import type { IDataExport } from "./data-export.interface"; +import type { + DsrFormat, + UserDataBundle, + CollectionDataBucket, + SubjectReference, +} from "./dsr-types"; +import type { DsrCollectionCustom } from "./dsr-collection-custom"; + +// Mirrors packages/core-dsr/src/contexts/user-data.jsonld — update both together. +const USER_DATA_JSONLD_CONTEXT: Record = { + "@vocab": "https://schema.org/", + dsr: "https://w3.org/ns/dpv#", + prov: "https://www.w3.org/ns/prov#", + subjectId: "identifier", + exportedAt: "dateCreated", + format: "encodingFormat", + data: { "@id": "prov:hadMember", "@container": "@index" }, + asSelf: { "@id": "dsr:hasPersonalDataHandling", "@type": "@id" }, + asReference: { "@id": "dsr:hasDataSubjectRight", "@type": "@id" }, + rowId: "identifier", + linkedField: "name", + linkedThrough: { "@id": "isPartOf", "@type": "@id" }, + auditLog: { "@id": "prov:wasGeneratedBy", "@type": "@id" }, + UserDataBundle: "dsr:RightOfAccess", + SubjectReference: "dsr:DataSubjectRight", +}; + +type PayloadDoc = Record; + +type PayloadAPI = { + find(args: { + collection: string; + where: Record; + overrideAccess: true; + limit: number; + }): Promise<{ docs: PayloadDoc[] }>; +}; + +type GetPayload = (args: { config: SanitizedConfig }) => Promise; + +/** + * Payload-backed IDataExport. Walks all collections annotated with + * `custom.subject` linkage, segments rows by role (self/owner vs reference), + * and filters to fields marked `exportable: true` in `custom.pii`. + * + * Emits an EXPORT audit entry for every call. The `getPayloadFn` parameter is + * injectable for unit tests; production code omits it. + */ +export class PayloadDataExport implements IDataExport { + constructor( + private readonly config: SanitizedConfig, + private readonly auditLog: AuditLogProtocol, + private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload, + ) {} + + async exportSubjectData( + subjectId: string, + format: DsrFormat, + ): Promise { + const payload = await this.getPayloadFn({ config: this.config }); + const data: Record = {}; + + for (const collection of this.config.collections) { + const custom = (collection.custom ?? {}) as DsrCollectionCustom; + if (!custom.subject) continue; + + const { field, kind } = custom.subject; + const where = + field === "id" + ? { id: { equals: subjectId } } + : { [field]: { equals: subjectId } }; + + const result = await payload.find({ + collection: collection.slug, + where, + overrideAccess: true, + limit: 1000, + }); + + if (result.docs.length === 0) continue; + + if (kind === "self" || kind === "owner") { + const piiMeta = custom.pii ?? {}; + const exportableFields = Object.entries(piiMeta) + .filter(([, m]) => m.exportable) + .map(([name]) => name); + + data[collection.slug] = { + asSelf: result.docs.map((doc) => { + const row: PayloadDoc = { id: doc["id"] }; + for (const f of exportableFields) { + if (f in doc) row[f] = doc[f]; + } + return row; + }), + }; + } else { + // reference kind — expose only linking coordinates, not row content + data[collection.slug] = { + asReference: result.docs.map( + (doc): SubjectReference => ({ + rowId: String(doc["id"]), + linkedField: field, + linkedThrough: collection.slug, + }), + ), + }; + } + } + + await this.auditLog.record({ + actorId: subjectId, + actorType: "user", + actorRoles: [], + action: "EXPORT", + resource: { type: "subject-data" }, + at: new Date(), + scope: { + feature: "core-dsr", + environment: process.env["NODE_ENV"] ?? "development", + tenant: "default", + }, + from: { ipTruncated: "system", userAgent: "system" }, + containsPii: false, + outcome: "success", + }); + + const bundle: UserDataBundle = { + subjectId, + exportedAt: new Date().toISOString(), + format, + data, + }; + + if (format === "json-ld") { + bundle["@context"] = USER_DATA_JSONLD_CONTEXT; + } + + return bundle; + } +} diff --git a/packages/core-dsr/src/payload-data-rectify.ts b/packages/core-dsr/src/payload-data-rectify.ts new file mode 100644 index 0000000..e3b714d --- /dev/null +++ b/packages/core-dsr/src/payload-data-rectify.ts @@ -0,0 +1,105 @@ +import { getPayload as _getPayload } from "payload"; +import type { SanitizedConfig } from "payload"; +import type { AuditLogProtocol } from "@repo/core-shared/di"; +import type { IDataRectify } from "./data-rectify.interface"; +import type { DsrCollectionCustom } from "./dsr-collection-custom"; + +type PayloadDoc = Record; + +type PayloadAPI = { + find(args: { + collection: string; + where: Record; + overrideAccess: true; + limit: number; + }): Promise<{ docs: PayloadDoc[] }>; + update(args: { + collection: string; + id: string; + data: Record; + overrideAccess: true; + }): Promise; +}; + +type GetPayload = (args: { config: SanitizedConfig }) => Promise; + +/** + * Payload-backed IDataRectify. Verifies the target field is PII-tagged before + * updating and emits a RESTRICT audit entry with `reason: "art-16-request"` as + * the tamper-evident record of the Art. 16 correction. + */ +export class PayloadDataRectify implements IDataRectify { + constructor( + private readonly config: SanitizedConfig, + private readonly auditLog: AuditLogProtocol, + private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload, + ) {} + + async updateSubjectField( + subjectId: string, + collectionSlug: string, + field: string, + value: unknown, + ): Promise { + const collectionCfg = this.config.collections.find( + (c) => c.slug === collectionSlug, + ); + if (!collectionCfg) { + throw new Error(`Collection "${collectionSlug}" not found in config`); + } + + const custom = (collectionCfg.custom ?? {}) as DsrCollectionCustom; + if (!custom.subject) { + throw new Error( + `Collection "${collectionSlug}" has no DSR subject linkage`, + ); + } + if (!custom.pii?.[field]) { + throw new Error( + `Field "${field}" in "${collectionSlug}" is not tagged as PII`, + ); + } + + const { field: subjectField } = custom.subject; + const where = + subjectField === "id" + ? { id: { equals: subjectId } } + : { [subjectField]: { equals: subjectId } }; + + const payload = await this.getPayloadFn({ config: this.config }); + const result = await payload.find({ + collection: collectionSlug, + where, + overrideAccess: true, + limit: 1000, + }); + + for (const doc of result.docs) { + await payload.update({ + collection: collectionSlug, + id: String(doc["id"]), + data: { [field]: value }, + overrideAccess: true, + }); + } + + await this.auditLog.record({ + actorId: subjectId, + actorType: "user", + actorRoles: [], + action: "RESTRICT", + resource: { type: collectionSlug }, + changedFields: [field], + at: new Date(), + scope: { + feature: "core-dsr", + environment: process.env["NODE_ENV"] ?? "development", + tenant: "default", + }, + reason: "art-16-request", + from: { ipTruncated: "system", userAgent: "system" }, + containsPii: false, + outcome: "success", + }); + } +} diff --git a/packages/core-dsr/src/payload-processing-restriction.ts b/packages/core-dsr/src/payload-processing-restriction.ts new file mode 100644 index 0000000..d8c2881 --- /dev/null +++ b/packages/core-dsr/src/payload-processing-restriction.ts @@ -0,0 +1,87 @@ +import { getPayload as _getPayload } from "payload"; +import type { SanitizedConfig } from "payload"; +import type { AuditLogProtocol } from "@repo/core-shared/di"; +import type { IProcessingRestriction } from "./processing-restriction.interface"; + +type PayloadDoc = Record; + +type PayloadAPI = { + find(args: { + collection: string; + where: Record; + overrideAccess: true; + limit: number; + }): Promise<{ docs: PayloadDoc[] }>; + update(args: { + collection: string; + id: string; + data: Record; + overrideAccess: true; + }): Promise; +}; + +type GetPayload = (args: { config: SanitizedConfig }) => Promise; + +const USERS_COLLECTION = "users"; +const RESTRICTION_FIELD = "processingRestrictedAt"; + +/** + * Payload-backed IProcessingRestriction. Toggles and reads the + * `processingRestrictedAt` date field on the subject's user record. + * + * Emits RESTRICT or UNRESTRICT audit entries on every state change. + * The `subjectId` is the document ID in the `users` collection. + */ +export class PayloadProcessingRestriction implements IProcessingRestriction { + constructor( + private readonly config: SanitizedConfig, + private readonly auditLog: AuditLogProtocol, + private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload, + ) {} + + async setRestriction(subjectId: string, granted: boolean): Promise { + const payload = await this.getPayloadFn({ config: this.config }); + + await payload.update({ + collection: USERS_COLLECTION, + id: subjectId, + data: { [RESTRICTION_FIELD]: granted ? new Date().toISOString() : null }, + overrideAccess: true, + }); + + await this.auditLog.record({ + actorId: subjectId, + actorType: "user", + actorRoles: [], + action: granted ? "RESTRICT" : "UNRESTRICT", + resource: { type: USERS_COLLECTION, id: subjectId }, + changedFields: [RESTRICTION_FIELD], + at: new Date(), + scope: { + feature: "core-dsr", + environment: process.env["NODE_ENV"] ?? "development", + tenant: "default", + }, + from: { ipTruncated: "system", userAgent: "system" }, + containsPii: false, + outcome: "success", + }); + } + + async isRestricted(subjectId: string): Promise { + const payload = await this.getPayloadFn({ config: this.config }); + + const result = await payload.find({ + collection: USERS_COLLECTION, + where: { id: { equals: subjectId } }, + overrideAccess: true, + limit: 1, + }); + + const doc = result.docs[0]; + if (!doc) return false; + + const restrictedAt = doc[RESTRICTION_FIELD]; + return restrictedAt !== null && restrictedAt !== undefined; + } +} diff --git a/packages/core-testing/src/instrumentation/index.ts b/packages/core-testing/src/instrumentation/index.ts index a8f7872..599c647 100644 --- a/packages/core-testing/src/instrumentation/index.ts +++ b/packages/core-testing/src/instrumentation/index.ts @@ -15,3 +15,19 @@ export { RecordingConsent, type RecordedConsentGrant, } from "./recording-consent"; +export { + RecordingDataExport, + type RecordedExportCall, +} from "./recording-data-export"; +export { + RecordingDataDelete, + type RecordedDeleteCall, +} from "./recording-data-delete"; +export { + RecordingDataRectify, + type RecordedRectifyCall, +} from "./recording-data-rectify"; +export { + RecordingProcessingRestriction, + type RecordedRestrictionSet, +} from "./recording-processing-restriction"; diff --git a/packages/core-testing/src/instrumentation/recording-audit-log.ts b/packages/core-testing/src/instrumentation/recording-audit-log.ts index 9f3a51f..1a56858 100644 --- a/packages/core-testing/src/instrumentation/recording-audit-log.ts +++ b/packages/core-testing/src/instrumentation/recording-audit-log.ts @@ -9,7 +9,11 @@ type AuditAction = | "UPDATE" | "DELETE" | "EXPORT" - | "PERMISSION_CHANGE"; + | "PERMISSION_CHANGE" + | "CONSENT_GRANT" + | "CONSENT_WITHDRAW" + | "RESTRICT" + | "UNRESTRICT"; type AuditEntry = { actorId: string; @@ -47,7 +51,10 @@ export class RecordingAuditLog { this.recorded.push({ ...entry }); } - async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { + async eraseSubject( + actorId: string, + mode: "pseudonymize" | "delete", + ): Promise { this.erasures.push({ actorId, mode }); if (mode === "pseudonymize") { for (const r of this.recorded) { diff --git a/packages/core-testing/src/instrumentation/recording-data-delete.test.ts b/packages/core-testing/src/instrumentation/recording-data-delete.test.ts new file mode 100644 index 0000000..925bcb2 --- /dev/null +++ b/packages/core-testing/src/instrumentation/recording-data-delete.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { RecordingDataDelete } from "./recording-data-delete"; + +describe("RecordingDataDelete", () => { + it("records deleteSubjectData calls", async () => { + const deleter = new RecordingDataDelete(); + await deleter.deleteSubjectData("alice", "soft"); + + expect(deleter.calls).toHaveLength(1); + expect(deleter.calls[0]).toEqual({ subjectId: "alice", mode: "soft" }); + }); + + it("returns a DeletionCertificate with the correct shape", async () => { + const deleter = new RecordingDataDelete(); + const cert = await deleter.deleteSubjectData("alice", "cascade-hard"); + + expect(cert.subjectId).toBe("alice"); + expect(cert.mode).toBe("cascade-hard"); + expect(cert.reason).toBe("art-17-request"); + expect(cert.affected).toEqual([]); + expect(typeof cert.auditEntryId).toBe("string"); + expect(typeof cert.timestamp).toBe("string"); + }); + + it("records multiple calls in order", async () => { + const deleter = new RecordingDataDelete(); + await deleter.deleteSubjectData("alice", "soft"); + await deleter.deleteSubjectData("bob", "cascade-hard"); + + expect(deleter.calls[0]?.mode).toBe("soft"); + expect(deleter.calls[1]?.mode).toBe("cascade-hard"); + }); + + it("reset() clears recorded calls", async () => { + const deleter = new RecordingDataDelete(); + await deleter.deleteSubjectData("alice", "soft"); + deleter.reset(); + + expect(deleter.calls).toHaveLength(0); + }); +}); diff --git a/packages/core-testing/src/instrumentation/recording-data-delete.ts b/packages/core-testing/src/instrumentation/recording-data-delete.ts new file mode 100644 index 0000000..e841fb9 --- /dev/null +++ b/packages/core-testing/src/instrumentation/recording-data-delete.ts @@ -0,0 +1,63 @@ +// Local type aliases mirroring exports from `@repo/core-dsr`. +// Kept inline to avoid a build-graph cycle between core-testing (tooling) +// and core-dsr (optional core). Same pattern used by RecordingAuditLog, +// RecordingConsent, and other recording doubles. + +type DeletionMode = "soft" | "cascade-hard"; +type DeletionReason = "art-17-request" | "admin-expunge" | "retention-policy"; +type DeletionAction = "deleted" | "redacted" | "pseudonymized"; + +type DeletionAffected = { + collection: string; + rowsAffected: number; + action: DeletionAction; + fields?: string[]; +}; + +type DeletionCertificate = { + subjectId: string; + mode: DeletionMode; + timestamp: string; + reason: DeletionReason; + affected: DeletionAffected[]; + auditEntryId: string; +}; + +/** Recorded call to deleteSubjectData. */ +export type RecordedDeleteCall = { + subjectId: string; + mode: DeletionMode; +}; + +/** + * Test-side recording double for IDataDelete. Records all deleteSubjectData + * calls for assertion while returning a well-shaped DeletionCertificate. + * + * Use directly via constructor injection: + * const deleter = new RecordingDataDelete(); + * const uc = myUseCase(deleter); + * await uc({ ... }); + * expect(deleter.calls[0]?.mode).toBe("soft"); + */ +export class RecordingDataDelete { + public calls: RecordedDeleteCall[] = []; + + async deleteSubjectData( + subjectId: string, + mode: DeletionMode, + ): Promise { + this.calls.push({ subjectId, mode }); + return { + subjectId, + mode, + timestamp: new Date().toISOString(), + reason: "art-17-request", + affected: [], + auditEntryId: `recording-audit-${Date.now()}`, + }; + } + + reset(): void { + this.calls = []; + } +} diff --git a/packages/core-testing/src/instrumentation/recording-data-export.test.ts b/packages/core-testing/src/instrumentation/recording-data-export.test.ts new file mode 100644 index 0000000..f14b5e6 --- /dev/null +++ b/packages/core-testing/src/instrumentation/recording-data-export.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { RecordingDataExport } from "./recording-data-export"; + +describe("RecordingDataExport", () => { + it("records exportSubjectData calls", async () => { + const exporter = new RecordingDataExport(); + await exporter.exportSubjectData("alice", "json"); + + expect(exporter.calls).toHaveLength(1); + expect(exporter.calls[0]).toEqual({ subjectId: "alice", format: "json" }); + }); + + it("returns a UserDataBundle with the correct shape", async () => { + const exporter = new RecordingDataExport(); + const bundle = await exporter.exportSubjectData("bob", "json-ld"); + + expect(bundle.subjectId).toBe("bob"); + expect(bundle.format).toBe("json-ld"); + expect(typeof bundle.exportedAt).toBe("string"); + expect(bundle.data).toEqual({}); + }); + + it("records multiple calls in order", async () => { + const exporter = new RecordingDataExport(); + await exporter.exportSubjectData("alice", "json"); + await exporter.exportSubjectData("bob", "json-ld"); + + expect(exporter.calls).toHaveLength(2); + expect(exporter.calls[0]?.subjectId).toBe("alice"); + expect(exporter.calls[1]?.subjectId).toBe("bob"); + }); + + it("reset() clears recorded calls", async () => { + const exporter = new RecordingDataExport(); + await exporter.exportSubjectData("alice", "json"); + exporter.reset(); + + expect(exporter.calls).toHaveLength(0); + }); +}); diff --git a/packages/core-testing/src/instrumentation/recording-data-export.ts b/packages/core-testing/src/instrumentation/recording-data-export.ts new file mode 100644 index 0000000..e327fda --- /dev/null +++ b/packages/core-testing/src/instrumentation/recording-data-export.ts @@ -0,0 +1,64 @@ +// Local type aliases mirroring exports from `@repo/core-dsr`. +// Kept inline to avoid a build-graph cycle between core-testing (tooling) +// and core-dsr (optional core). Same pattern used by RecordingAuditLog, +// RecordingConsent, and other recording doubles. + +type DsrFormat = "json" | "json-ld"; + +type SubjectReference = { + rowId: string; + linkedField: string; + linkedThrough: string; +}; + +type CollectionDataBucket = { + asSelf?: Array>; + asReference?: SubjectReference[]; +}; + +type UserDataBundle = { + subjectId: string; + exportedAt: string; + format: DsrFormat; + data: Record; + auditLog?: unknown[]; + "@context"?: string | Record; +}; + +/** Recorded call to exportSubjectData. */ +export type RecordedExportCall = { + subjectId: string; + format: DsrFormat; +}; + +/** + * Test-side recording double for IDataExport. Records all exportSubjectData + * calls for assertion while returning an empty but structurally-correct bundle. + * + * Use directly via constructor injection: + * const exporter = new RecordingDataExport(); + * const uc = myUseCase(exporter); + * await uc({ ... }); + * expect(exporter.calls).toHaveLength(1); + * expect(exporter.calls[0]?.format).toBe("json"); + */ +export class RecordingDataExport { + public calls: RecordedExportCall[] = []; + + async exportSubjectData( + subjectId: string, + format: DsrFormat, + ): Promise { + this.calls.push({ subjectId, format }); + return { + subjectId, + exportedAt: new Date().toISOString(), + format, + data: {}, + }; + } + + reset(): void { + this.calls = []; + } +} diff --git a/packages/core-testing/src/instrumentation/recording-data-rectify.test.ts b/packages/core-testing/src/instrumentation/recording-data-rectify.test.ts new file mode 100644 index 0000000..1091e4a --- /dev/null +++ b/packages/core-testing/src/instrumentation/recording-data-rectify.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { RecordingDataRectify } from "./recording-data-rectify"; + +describe("RecordingDataRectify", () => { + it("records updateSubjectField calls", async () => { + const rectifier = new RecordingDataRectify(); + await rectifier.updateSubjectField("alice", "users", "name", "Alice New"); + + expect(rectifier.calls).toHaveLength(1); + expect(rectifier.calls[0]).toEqual({ + subjectId: "alice", + collection: "users", + field: "name", + value: "Alice New", + }); + }); + + it("returns void (undefined)", async () => { + const rectifier = new RecordingDataRectify(); + const result = await rectifier.updateSubjectField( + "alice", + "users", + "name", + "x", + ); + + expect(result).toBeUndefined(); + }); + + it("records multiple calls in order", async () => { + const rectifier = new RecordingDataRectify(); + await rectifier.updateSubjectField("alice", "users", "name", "A"); + await rectifier.updateSubjectField("alice", "users", "email", "a@ex.com"); + + expect(rectifier.calls).toHaveLength(2); + expect(rectifier.calls[0]?.field).toBe("name"); + expect(rectifier.calls[1]?.field).toBe("email"); + }); + + it("reset() clears recorded calls", async () => { + const rectifier = new RecordingDataRectify(); + await rectifier.updateSubjectField("alice", "users", "name", "A"); + rectifier.reset(); + + expect(rectifier.calls).toHaveLength(0); + }); +}); diff --git a/packages/core-testing/src/instrumentation/recording-data-rectify.ts b/packages/core-testing/src/instrumentation/recording-data-rectify.ts new file mode 100644 index 0000000..2fa7719 --- /dev/null +++ b/packages/core-testing/src/instrumentation/recording-data-rectify.ts @@ -0,0 +1,39 @@ +// Local type aliases mirroring exports from `@repo/core-dsr`. +// Kept inline to avoid a build-graph cycle between core-testing (tooling) +// and core-dsr (optional core). Same pattern used by RecordingAuditLog, +// RecordingConsent, and other recording doubles. + +/** Recorded call to updateSubjectField. */ +export type RecordedRectifyCall = { + subjectId: string; + collection: string; + field: string; + value: unknown; +}; + +/** + * Test-side recording double for IDataRectify. Records all updateSubjectField + * calls for assertion while making no persistent changes. + * + * Use directly via constructor injection: + * const rectifier = new RecordingDataRectify(); + * const uc = myUseCase(rectifier); + * await uc({ ... }); + * expect(rectifier.calls[0]?.field).toBe("name"); + */ +export class RecordingDataRectify { + public calls: RecordedRectifyCall[] = []; + + async updateSubjectField( + subjectId: string, + collection: string, + field: string, + value: unknown, + ): Promise { + this.calls.push({ subjectId, collection, field, value }); + } + + reset(): void { + this.calls = []; + } +} diff --git a/packages/core-testing/src/instrumentation/recording-processing-restriction.test.ts b/packages/core-testing/src/instrumentation/recording-processing-restriction.test.ts new file mode 100644 index 0000000..c957063 --- /dev/null +++ b/packages/core-testing/src/instrumentation/recording-processing-restriction.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { RecordingProcessingRestriction } from "./recording-processing-restriction"; + +describe("RecordingProcessingRestriction", () => { + it("records setRestriction calls", async () => { + const restriction = new RecordingProcessingRestriction(); + await restriction.setRestriction("alice", true); + + expect(restriction.sets).toHaveLength(1); + expect(restriction.sets[0]).toEqual({ subjectId: "alice", granted: true }); + }); + + it("isRestricted returns true after setRestriction(true)", async () => { + const restriction = new RecordingProcessingRestriction(); + await restriction.setRestriction("alice", true); + + expect(await restriction.isRestricted("alice")).toBe(true); + }); + + it("isRestricted returns false after setRestriction(false)", async () => { + const restriction = new RecordingProcessingRestriction(); + await restriction.setRestriction("alice", true); + await restriction.setRestriction("alice", false); + + expect(await restriction.isRestricted("alice")).toBe(false); + }); + + it("isRestricted returns false for unknown subjects", async () => { + const restriction = new RecordingProcessingRestriction(); + + expect(await restriction.isRestricted("ghost")).toBe(false); + }); + + it("tracks restriction per subject independently", async () => { + const restriction = new RecordingProcessingRestriction(); + await restriction.setRestriction("alice", true); + await restriction.setRestriction("bob", false); + + expect(await restriction.isRestricted("alice")).toBe(true); + expect(await restriction.isRestricted("bob")).toBe(false); + }); + + it("reset() clears sets and state", async () => { + const restriction = new RecordingProcessingRestriction(); + await restriction.setRestriction("alice", true); + restriction.reset(); + + expect(restriction.sets).toHaveLength(0); + expect(await restriction.isRestricted("alice")).toBe(false); + }); +}); diff --git a/packages/core-testing/src/instrumentation/recording-processing-restriction.ts b/packages/core-testing/src/instrumentation/recording-processing-restriction.ts new file mode 100644 index 0000000..b59f820 --- /dev/null +++ b/packages/core-testing/src/instrumentation/recording-processing-restriction.ts @@ -0,0 +1,42 @@ +// Local type aliases mirroring exports from `@repo/core-dsr`. +// Kept inline to avoid a build-graph cycle between core-testing (tooling) +// and core-dsr (optional core). Same pattern used by RecordingAuditLog, +// RecordingConsent, and other recording doubles. + +/** Recorded setRestriction call. */ +export type RecordedRestrictionSet = { + subjectId: string; + granted: boolean; +}; + +/** + * Test-side recording double for IProcessingRestriction. Records all + * setRestriction / isRestricted calls for assertion while maintaining the same + * state semantics as the real impl (isRestricted returns true after + * setRestriction(true), false after setRestriction(false)). + * + * Use directly via constructor injection: + * const restriction = new RecordingProcessingRestriction(); + * const uc = myUseCase(restriction); + * await uc({ ... }); + * expect(restriction.sets[0]?.granted).toBe(true); + * expect(await restriction.isRestricted("alice")).toBe(true); + */ +export class RecordingProcessingRestriction { + public sets: RecordedRestrictionSet[] = []; + private readonly state = new Map(); + + async setRestriction(subjectId: string, granted: boolean): Promise { + this.sets.push({ subjectId, granted }); + this.state.set(subjectId, granted); + } + + async isRestricted(subjectId: string): Promise { + return this.state.get(subjectId) ?? false; + } + + reset(): void { + this.sets = []; + this.state.clear(); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4d6150..de16662 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -638,6 +638,9 @@ importers: "@repo/core-shared": specifier: workspace:* version: link:../core-shared + payload: + specifier: ^3.0.0 + version: 3.81.0(graphql@16.13.2)(typescript@5.9.3) devDependencies: "@repo/core-eslint": specifier: workspace:* diff --git a/scripts/coverage/diff.mjs b/scripts/coverage/diff.mjs index b711fc6..35a9723 100644 --- a/scripts/coverage/diff.mjs +++ b/scripts/coverage/diff.mjs @@ -44,6 +44,7 @@ const ALLOWED_GLOBS = [ // Docs / data /\.md$/, /\.json$/, + /\.jsonld$/, // JSON-LD context files (e.g. core-dsr/contexts/user-data.jsonld) /\.ya?ml$/, /\.gitignore$/, /\.prettierignore$/, diff --git a/scripts/coverage/diff.test.mjs b/scripts/coverage/diff.test.mjs index d0c4dda..1ad7dec 100644 --- a/scripts/coverage/diff.test.mjs +++ b/scripts/coverage/diff.test.mjs @@ -154,6 +154,20 @@ describe("computeDiffCoverage", () => { assert.equal(result.summary.filesChanged, 4); }); + test("skips JSON-LD context files (.jsonld)", () => { + const lcov = parseLcov(lcovText); + const diff = new Map([ + // JSON-LD context files are static data assets with no executable code + // (e.g. packages/core-dsr/src/contexts/user-data.jsonld). v8 coverage + // never sees them, so they must be exempted from the no-coverage-data gate. + ["packages/core-dsr/src/contexts/user-data.jsonld", new Set([1, 2, 3])], + ]); + const result = computeDiffCoverage(diff, lcov); + assert.equal(result.status, "pass"); + assert.equal(result.summary.filesGated, 0); + assert.equal(result.summary.filesChanged, 1); + }); + test("skips TypeScript ambient declaration files (.d.ts)", () => { const lcov = parseLcov(lcovText); const diff = new Map([