feat(core-dsr): Payload impls, recording doubles, DI binders, contract tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 20:05:06 +00:00
parent 8068d1bf98
commit 6606b59d1e
35 changed files with 2375 additions and 24 deletions

View File

@@ -1,18 +1,18 @@
{ {
"generatedAt": "2026-05-19T19:30:03.273Z", "generatedAt": "2026-05-19T19:59:56.279Z",
"commit": "86d9492", "commit": "8068d1b",
"repo": { "repo": {
"statements": 96.74, "statements": 97.04,
"branches": 92.15, "branches": 92.09,
"functions": 96.92, "functions": 96.87,
"lines": 96.74, "lines": 97.04,
"counts": { "counts": {
"lf": 4632, "lf": 5107,
"lh": 4481, "lh": 4956,
"brf": 917, "brf": 1012,
"brh": 845, "brh": 932,
"fnf": 292, "fnf": 319,
"fnh": 283 "fnh": 309
} }
}, },
"byPackage": { "byPackage": {
@@ -74,16 +74,16 @@
}, },
"@repo/core-dsr": { "@repo/core-dsr": {
"statements": 100, "statements": 100,
"branches": 100, "branches": 92,
"functions": 100, "functions": 96.88,
"lines": 100, "lines": 100,
"counts": { "counts": {
"lf": 0, "lf": 475,
"lh": 0, "lh": 475,
"brf": 5, "brf": 100,
"brh": 5, "brh": 92,
"fnf": 5, "fnf": 32,
"fnh": 5 "fnh": 31
} }
}, },
"@repo/core-shared": { "@repo/core-shared": {

View File

@@ -71,7 +71,7 @@ Payload CMS is a fully self-hosted solution. No data is transmitted to Payload-c
<!-- Result: pass --> <!-- Result: pass -->
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 ## Prompt: replaces

View File

@@ -13,7 +13,8 @@
"test": "vitest run --passWithNoTests" "test": "vitest run --passWithNoTests"
}, },
"dependencies": { "dependencies": {
"@repo/core-shared": "workspace:*" "@repo/core-shared": "workspace:*",
"payload": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@repo/core-eslint": "workspace:*", "@repo/core-eslint": "workspace:*",

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

@@ -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<string, unknown> {
const raw = JSON.parse(readFileSync(contextFilePath, "utf-8")) as {
"@context": Record<string, unknown>;
};
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<string, unknown>;
// 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"]);
});
});

View File

@@ -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<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
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<typeof vi.fn>;
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);
});
});
});

View File

@@ -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<typeof vi.fn>;
};
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<typeof vi.fn>;
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<string, unknown>;
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"]);
});
});

View File

@@ -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<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
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<typeof vi.fn>;
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");
});
});

View File

@@ -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<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
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<typeof vi.fn>;
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);
});
});
});

View File

@@ -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(),
};
}

View File

@@ -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,
),
};
}

View File

@@ -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<string, FieldPii>;
};

View File

@@ -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<DeletionCertificate> {
return {
subjectId,
mode,
timestamp: new Date().toISOString(),
reason: "art-17-request",
affected: [],
auditEntryId: randomUUID(),
};
}
}

View File

@@ -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<UserDataBundle> {
return {
subjectId,
exportedAt: new Date().toISOString(),
format,
data: {},
};
}
}

View File

@@ -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<void> {
// no-op in dev-seed
}
}

View File

@@ -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<string>();
async setRestriction(subjectId: string, granted: boolean): Promise<void> {
if (granted) {
this.restricted.add(subjectId);
} else {
this.restricted.delete(subjectId);
}
}
async isRestricted(subjectId: string): Promise<boolean> {
return this.restricted.has(subjectId);
}
}

View File

@@ -14,3 +14,24 @@ export type { IDataExport } from "./data-export.interface";
export type { IDataDelete } from "./data-delete.interface"; export type { IDataDelete } from "./data-delete.interface";
export type { IDataRectify } from "./data-rectify.interface"; export type { IDataRectify } from "./data-rectify.interface";
export type { IProcessingRestriction } from "./processing-restriction.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";

View File

@@ -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<string, unknown>;
type PayloadAPI = {
find(args: {
collection: string;
where: Record<string, unknown>;
overrideAccess: true;
limit: number;
}): Promise<{ docs: PayloadDoc[] }>;
update(args: {
collection: string;
id: string;
data: Record<string, unknown>;
overrideAccess: true;
}): Promise<PayloadDoc>;
delete(args: {
collection: string;
id: string;
overrideAccess: true;
}): Promise<PayloadDoc>;
};
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
function buildWhere(field: string, subjectId: string): Record<string, unknown> {
return field === "id"
? { id: { equals: subjectId } }
: { [field]: { equals: subjectId } };
}
async function softRedactOwnerRows(
payload: PayloadAPI,
slug: string,
docs: PayloadDoc[],
exportableFields: string[],
): Promise<void> {
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<void> {
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<void> {
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<DeletionCertificate> {
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<void> {
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<void> {
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<void> {
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,
};
}
}

View File

@@ -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<string, unknown> = {
"@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<string, unknown>;
type PayloadAPI = {
find(args: {
collection: string;
where: Record<string, unknown>;
overrideAccess: true;
limit: number;
}): Promise<{ docs: PayloadDoc[] }>;
};
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
/**
* 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<UserDataBundle> {
const payload = await this.getPayloadFn({ config: this.config });
const data: Record<string, CollectionDataBucket> = {};
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;
}
}

View File

@@ -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<string, unknown>;
type PayloadAPI = {
find(args: {
collection: string;
where: Record<string, unknown>;
overrideAccess: true;
limit: number;
}): Promise<{ docs: PayloadDoc[] }>;
update(args: {
collection: string;
id: string;
data: Record<string, unknown>;
overrideAccess: true;
}): Promise<PayloadDoc>;
};
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
/**
* 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<void> {
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",
});
}
}

View File

@@ -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<string, unknown>;
type PayloadAPI = {
find(args: {
collection: string;
where: Record<string, unknown>;
overrideAccess: true;
limit: number;
}): Promise<{ docs: PayloadDoc[] }>;
update(args: {
collection: string;
id: string;
data: Record<string, unknown>;
overrideAccess: true;
}): Promise<PayloadDoc>;
};
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
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<void> {
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<boolean> {
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;
}
}

View File

@@ -15,3 +15,19 @@ export {
RecordingConsent, RecordingConsent,
type RecordedConsentGrant, type RecordedConsentGrant,
} from "./recording-consent"; } 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";

View File

@@ -9,7 +9,11 @@ type AuditAction =
| "UPDATE" | "UPDATE"
| "DELETE" | "DELETE"
| "EXPORT" | "EXPORT"
| "PERMISSION_CHANGE"; | "PERMISSION_CHANGE"
| "CONSENT_GRANT"
| "CONSENT_WITHDRAW"
| "RESTRICT"
| "UNRESTRICT";
type AuditEntry = { type AuditEntry = {
actorId: string; actorId: string;
@@ -47,7 +51,10 @@ export class RecordingAuditLog {
this.recorded.push({ ...entry }); this.recorded.push({ ...entry });
} }
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> { async eraseSubject(
actorId: string,
mode: "pseudonymize" | "delete",
): Promise<void> {
this.erasures.push({ actorId, mode }); this.erasures.push({ actorId, mode });
if (mode === "pseudonymize") { if (mode === "pseudonymize") {
for (const r of this.recorded) { for (const r of this.recorded) {

View File

@@ -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);
});
});

View File

@@ -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<DeletionCertificate> {
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 = [];
}
}

View File

@@ -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);
});
});

View File

@@ -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<Record<string, unknown>>;
asReference?: SubjectReference[];
};
type UserDataBundle = {
subjectId: string;
exportedAt: string;
format: DsrFormat;
data: Record<string, CollectionDataBucket>;
auditLog?: unknown[];
"@context"?: string | Record<string, unknown>;
};
/** 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<UserDataBundle> {
this.calls.push({ subjectId, format });
return {
subjectId,
exportedAt: new Date().toISOString(),
format,
data: {},
};
}
reset(): void {
this.calls = [];
}
}

View File

@@ -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);
});
});

View File

@@ -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<void> {
this.calls.push({ subjectId, collection, field, value });
}
reset(): void {
this.calls = [];
}
}

View File

@@ -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);
});
});

View File

@@ -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<string, boolean>();
async setRestriction(subjectId: string, granted: boolean): Promise<void> {
this.sets.push({ subjectId, granted });
this.state.set(subjectId, granted);
}
async isRestricted(subjectId: string): Promise<boolean> {
return this.state.get(subjectId) ?? false;
}
reset(): void {
this.sets = [];
this.state.clear();
}
}

3
pnpm-lock.yaml generated
View File

@@ -638,6 +638,9 @@ importers:
"@repo/core-shared": "@repo/core-shared":
specifier: workspace:* specifier: workspace:*
version: link:../core-shared version: link:../core-shared
payload:
specifier: ^3.0.0
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
devDependencies: devDependencies:
"@repo/core-eslint": "@repo/core-eslint":
specifier: workspace:* specifier: workspace:*

View File

@@ -44,6 +44,7 @@ const ALLOWED_GLOBS = [
// Docs / data // Docs / data
/\.md$/, /\.md$/,
/\.json$/, /\.json$/,
/\.jsonld$/, // JSON-LD context files (e.g. core-dsr/contexts/user-data.jsonld)
/\.ya?ml$/, /\.ya?ml$/,
/\.gitignore$/, /\.gitignore$/,
/\.prettierignore$/, /\.prettierignore$/,

View File

@@ -154,6 +154,20 @@ describe("computeDiffCoverage", () => {
assert.equal(result.summary.filesChanged, 4); 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)", () => { test("skips TypeScript ambient declaration files (.d.ts)", () => {
const lcov = parseLcov(lcovText); const lcov = parseLcov(lcovText);
const diff = new Map([ const diff = new Map([