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:
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user