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:
@@ -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": {
|
||||
|
||||
@@ -71,7 +71,7 @@ Payload CMS is a fully self-hosted solution. No data is transmitted to Payload-c
|
||||
|
||||
<!-- 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
|
||||
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
56
packages/core-dsr/src/__tests__/di-binders.test.ts
Normal file
56
packages/core-dsr/src/__tests__/di-binders.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
83
packages/core-dsr/src/__tests__/in-memory.test.ts
Normal file
83
packages/core-dsr/src/__tests__/in-memory.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
84
packages/core-dsr/src/__tests__/jsonld-context.test.ts
Normal file
84
packages/core-dsr/src/__tests__/jsonld-context.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
316
packages/core-dsr/src/__tests__/payload-data-delete.test.ts
Normal file
316
packages/core-dsr/src/__tests__/payload-data-delete.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
209
packages/core-dsr/src/__tests__/payload-data-export.test.ts
Normal file
209
packages/core-dsr/src/__tests__/payload-data-export.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
146
packages/core-dsr/src/__tests__/payload-data-rectify.test.ts
Normal file
146
packages/core-dsr/src/__tests__/payload-data-rectify.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
21
packages/core-dsr/src/di/bind-dev-seed.ts
Normal file
21
packages/core-dsr/src/di/bind-dev-seed.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
44
packages/core-dsr/src/di/bind-production.ts
Normal file
44
packages/core-dsr/src/di/bind-production.ts
Normal 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
65
packages/core-dsr/src/dsr-collection-custom.ts
Normal file
65
packages/core-dsr/src/dsr-collection-custom.ts
Normal 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>;
|
||||
};
|
||||
27
packages/core-dsr/src/in-memory-data-delete.ts
Normal file
27
packages/core-dsr/src/in-memory-data-delete.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
24
packages/core-dsr/src/in-memory-data-export.ts
Normal file
24
packages/core-dsr/src/in-memory-data-export.ts
Normal 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: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
19
packages/core-dsr/src/in-memory-data-rectify.ts
Normal file
19
packages/core-dsr/src/in-memory-data-rectify.ts
Normal 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
|
||||
}
|
||||
}
|
||||
25
packages/core-dsr/src/in-memory-processing-restriction.ts
Normal file
25
packages/core-dsr/src/in-memory-processing-restriction.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
292
packages/core-dsr/src/payload-data-delete.ts
Normal file
292
packages/core-dsr/src/payload-data-delete.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
145
packages/core-dsr/src/payload-data-export.ts
Normal file
145
packages/core-dsr/src/payload-data-export.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
105
packages/core-dsr/src/payload-data-rectify.ts
Normal file
105
packages/core-dsr/src/payload-data-rectify.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
87
packages/core-dsr/src/payload-processing-restriction.ts
Normal file
87
packages/core-dsr/src/payload-processing-restriction.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<void> {
|
||||
async eraseSubject(
|
||||
actorId: string,
|
||||
mode: "pseudonymize" | "delete",
|
||||
): Promise<void> {
|
||||
this.erasures.push({ actorId, mode });
|
||||
if (mode === "pseudonymize") {
|
||||
for (const r of this.recorded) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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:*
|
||||
|
||||
@@ -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$/,
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user