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

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

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import type { SanitizedConfig } from "payload";
import { bindProductionDsr } from "@/di/bind-production";
import { bindDevSeedDsr } from "@/di/bind-dev-seed";
const emptyConfig = { collections: [] } as unknown as SanitizedConfig;
describe("bindProductionDsr", () => {
it("returns a DsrBinding with all four implementations", () => {
const binding = bindProductionDsr({ config: emptyConfig });
expect(binding.dataExport).toBeDefined();
expect(binding.dataDelete).toBeDefined();
expect(binding.dataRectify).toBeDefined();
expect(binding.processingRestriction).toBeDefined();
});
it("each binding exposes the expected interface methods", () => {
const binding = bindProductionDsr({ config: emptyConfig });
expect(typeof binding.dataExport.exportSubjectData).toBe("function");
expect(typeof binding.dataDelete.deleteSubjectData).toBe("function");
expect(typeof binding.dataRectify.updateSubjectField).toBe("function");
expect(typeof binding.processingRestriction.setRestriction).toBe(
"function",
);
expect(typeof binding.processingRestriction.isRestricted).toBe("function");
});
it("uses the noop auditLog when none is provided", () => {
expect(() => bindProductionDsr({ config: emptyConfig })).not.toThrow();
});
});
describe("bindDevSeedDsr", () => {
it("returns a DsrBinding with all four in-memory implementations", () => {
const binding = bindDevSeedDsr();
expect(binding.dataExport).toBeDefined();
expect(binding.dataDelete).toBeDefined();
expect(binding.dataRectify).toBeDefined();
expect(binding.processingRestriction).toBeDefined();
});
it("each binding exposes the expected interface methods", () => {
const binding = bindDevSeedDsr();
expect(typeof binding.dataExport.exportSubjectData).toBe("function");
expect(typeof binding.dataDelete.deleteSubjectData).toBe("function");
expect(typeof binding.dataRectify.updateSubjectField).toBe("function");
expect(typeof binding.processingRestriction.setRestriction).toBe(
"function",
);
expect(typeof binding.processingRestriction.isRestricted).toBe("function");
});
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import { InMemoryDataExport } from "@/in-memory-data-export";
import { InMemoryDataDelete } from "@/in-memory-data-delete";
import { InMemoryDataRectify } from "@/in-memory-data-rectify";
import { InMemoryProcessingRestriction } from "@/in-memory-processing-restriction";
describe("InMemoryDataExport", () => {
it("returns an empty bundle with the correct shape", async () => {
const exporter = new InMemoryDataExport();
const bundle = await exporter.exportSubjectData("alice", "json");
expect(bundle.subjectId).toBe("alice");
expect(bundle.format).toBe("json");
expect(typeof bundle.exportedAt).toBe("string");
expect(bundle.data).toEqual({});
});
it("preserves format in the returned bundle", async () => {
const exporter = new InMemoryDataExport();
const bundle = await exporter.exportSubjectData("bob", "json-ld");
expect(bundle.format).toBe("json-ld");
});
});
describe("InMemoryDataDelete", () => {
it("returns a DeletionCertificate with the correct shape", async () => {
const deleter = new InMemoryDataDelete();
const cert = await deleter.deleteSubjectData("alice", "soft");
expect(cert.subjectId).toBe("alice");
expect(cert.mode).toBe("soft");
expect(cert.reason).toBe("art-17-request");
expect(cert.affected).toEqual([]);
expect(typeof cert.auditEntryId).toBe("string");
expect(typeof cert.timestamp).toBe("string");
});
it("preserves mode in the certificate", async () => {
const deleter = new InMemoryDataDelete();
const cert = await deleter.deleteSubjectData("alice", "cascade-hard");
expect(cert.mode).toBe("cascade-hard");
});
});
describe("InMemoryDataRectify", () => {
it("resolves without error", async () => {
const rectifier = new InMemoryDataRectify();
await expect(
rectifier.updateSubjectField("alice", "users", "name", "Alice New"),
).resolves.toBeUndefined();
});
});
describe("InMemoryProcessingRestriction", () => {
it("isRestricted returns false for unknown subjects", async () => {
const restriction = new InMemoryProcessingRestriction();
expect(await restriction.isRestricted("alice")).toBe(false);
});
it("setRestriction(true) makes isRestricted return true", async () => {
const restriction = new InMemoryProcessingRestriction();
await restriction.setRestriction("alice", true);
expect(await restriction.isRestricted("alice")).toBe(true);
});
it("setRestriction(false) makes isRestricted return false", async () => {
const restriction = new InMemoryProcessingRestriction();
await restriction.setRestriction("alice", true);
await restriction.setRestriction("alice", false);
expect(await restriction.isRestricted("alice")).toBe(false);
});
it("tracks restriction per subject independently", async () => {
const restriction = new InMemoryProcessingRestriction();
await restriction.setRestriction("alice", true);
await restriction.setRestriction("bob", false);
expect(await restriction.isRestricted("alice")).toBe(true);
expect(await restriction.isRestricted("bob")).toBe(false);
});
});

View File

@@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { join, dirname } from "node:path";
import { vi } from "vitest";
import type { SanitizedConfig } from "payload";
import { PayloadDataExport } from "@/payload-data-export";
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
const __dir = dirname(fileURLToPath(import.meta.url));
const contextFilePath = join(__dir, "../../src/contexts/user-data.jsonld");
function loadContextFile(): Record<string, unknown> {
const raw = JSON.parse(readFileSync(contextFilePath, "utf-8")) as {
"@context": Record<string, unknown>;
};
return raw["@context"];
}
describe("user-data.jsonld context", () => {
it("is valid JSON with an @context key", () => {
const raw = JSON.parse(readFileSync(contextFilePath, "utf-8")) as unknown;
expect(raw).toMatchObject({ "@context": expect.any(Object) });
});
it("declares @vocab pointing to schema.org", () => {
const ctx = loadContextFile();
expect(ctx["@vocab"]).toBe("https://schema.org/");
});
it("declares dsr namespace pointing to DPV", () => {
const ctx = loadContextFile();
expect(ctx["dsr"]).toBe("https://w3.org/ns/dpv#");
});
it("declares prov namespace", () => {
const ctx = loadContextFile();
expect(ctx["prov"]).toBe("https://www.w3.org/ns/prov#");
});
it("maps subjectId to schema.org identifier", () => {
const ctx = loadContextFile();
expect(ctx["subjectId"]).toBe("identifier");
});
it("maps exportedAt to schema.org dateCreated", () => {
const ctx = loadContextFile();
expect(ctx["exportedAt"]).toBe("dateCreated");
});
it("maps data as an @index container", () => {
const ctx = loadContextFile();
expect(ctx["data"]).toMatchObject({ "@container": "@index" });
});
it("maps asSelf to a DSR handling type", () => {
const ctx = loadContextFile();
expect(ctx["asSelf"]).toMatchObject({ "@type": "@id" });
});
it("maps asReference to a data subject right type", () => {
const ctx = loadContextFile();
expect(ctx["asReference"]).toMatchObject({ "@type": "@id" });
});
it("json-ld bundle includes the inline context matching the file", async () => {
const auditLog = new RecordingAuditLog();
const mockPayload = { find: vi.fn().mockResolvedValue({ docs: [] }) };
const mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
const config = { collections: [] } as unknown as SanitizedConfig;
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json-ld");
const fileCtx = loadContextFile();
const bundleCtx = bundle["@context"] as Record<string, unknown>;
// Core keys must match between the file and the inline constant
expect(bundleCtx["@vocab"]).toBe(fileCtx["@vocab"]);
expect(bundleCtx["dsr"]).toBe(fileCtx["dsr"]);
expect(bundleCtx["subjectId"]).toBe(fileCtx["subjectId"]);
expect(bundleCtx["exportedAt"]).toBe(fileCtx["exportedAt"]);
});
});

View File

@@ -0,0 +1,316 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { SanitizedConfig } from "payload";
import { PayloadDataDelete } from "@/payload-data-delete";
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
type MockPayload = {
find: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
function makeMockPayload(): MockPayload {
return {
find: vi.fn(),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
};
}
function makeMockConfig(
collections: Array<{ slug: string; custom?: unknown }>,
): SanitizedConfig {
return { collections } as unknown as SanitizedConfig;
}
describe("PayloadDataDelete", () => {
let auditLog: RecordingAuditLog;
let mockPayload: MockPayload;
let mockGetPayload: ReturnType<typeof vi.fn>;
beforeEach(() => {
auditLog = new RecordingAuditLog();
mockPayload = makeMockPayload();
mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
});
describe("soft mode", () => {
it("happy path — self role: NULLs exportable PII fields and emits RESTRICT", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true }, name: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice", email: "a@ex.com", name: "Alice" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
const cert = await deleter.deleteSubjectData("alice", "soft");
expect(mockPayload.update).toHaveBeenCalledWith({
collection: "users",
id: "alice",
data: { email: null, name: null },
overrideAccess: true,
});
expect(cert.subjectId).toBe("alice");
expect(cert.mode).toBe("soft");
expect(cert.affected).toHaveLength(1);
expect(cert.affected[0]).toMatchObject({
collection: "users",
action: "redacted",
fields: expect.arrayContaining(["email", "name"]),
});
});
it("happy path — owner role: NULLs exportable fields in owned rows", async () => {
const config = makeMockConfig([
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { shippingAddress: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "o-1", userId: "alice", shippingAddress: "1 Main" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
const cert = await deleter.deleteSubjectData("alice", "soft");
expect(mockPayload.update).toHaveBeenCalledWith({
collection: "orders",
id: "o-1",
data: { shippingAddress: null },
overrideAccess: true,
});
expect(cert.affected[0]?.action).toBe("redacted");
});
it("happy path — reference role: NULLs only the linked field, preserves row", async () => {
const config = makeMockConfig([
{
slug: "comments",
custom: {
subject: { field: "mentionedUser", kind: "reference" },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "c-1", mentionedUser: "alice", body: "hello alice" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "soft");
// Only the linking field is NULLed — not the body
expect(mockPayload.update).toHaveBeenCalledWith({
collection: "comments",
id: "c-1",
data: { mentionedUser: null },
overrideAccess: true,
});
// Row is NOT deleted
expect(mockPayload.delete).not.toHaveBeenCalled();
});
it("multi-subject row: only the requesting subject's link is redacted", async () => {
// Two rows in a reference collection: one for alice, one for bob
const config = makeMockConfig([
{
slug: "comments",
custom: {
subject: { field: "mentionedUser", kind: "reference" },
},
},
]);
// find returns only alice's row (Payload's where clause filters for alice)
mockPayload.find.mockResolvedValue({
docs: [{ id: "c-alice", mentionedUser: "alice", body: "hi alice" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "soft");
// Only c-alice is updated
expect(mockPayload.update).toHaveBeenCalledTimes(1);
expect(mockPayload.update).toHaveBeenCalledWith(
expect.objectContaining({ id: "c-alice" }),
);
});
it("emits RESTRICT audit entries per affected collection", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { total: { exportable: true } },
},
},
]);
mockPayload.find
.mockResolvedValueOnce({ docs: [{ id: "alice", email: "a@ex.com" }] })
.mockResolvedValueOnce({
docs: [{ id: "o-1", userId: "alice", total: 50 }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "soft");
const restrictEntries = auditLog.recorded.filter(
(e) => e.action === "RESTRICT",
);
expect(restrictEntries).toHaveLength(2);
expect(restrictEntries.map((e) => e.resource.type)).toEqual(
expect.arrayContaining(["users", "orders"]),
);
});
it("returns DeletionCertificate with correct shape", async () => {
const config = makeMockConfig([{ slug: "posts" }]);
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
const cert = await deleter.deleteSubjectData("alice", "soft");
expect(cert.subjectId).toBe("alice");
expect(cert.mode).toBe("soft");
expect(cert.reason).toBe("art-17-request");
expect(cert.auditEntryId).toBeTruthy();
expect(typeof cert.timestamp).toBe("string");
});
});
describe("cascade-hard mode", () => {
it("happy path — self/owner: hard-deletes rows", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice", email: "a@ex.com" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
const cert = await deleter.deleteSubjectData("alice", "cascade-hard");
expect(mockPayload.delete).toHaveBeenCalledWith({
collection: "users",
id: "alice",
overrideAccess: true,
});
expect(mockPayload.update).not.toHaveBeenCalled();
expect(cert.affected[0]?.action).toBe("deleted");
});
it("cascade-hard — reference: NULLs linked field (does not delete row)", async () => {
const config = makeMockConfig([
{
slug: "comments",
custom: { subject: { field: "mentionedUser", kind: "reference" } },
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "c-1", mentionedUser: "alice" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "cascade-hard");
expect(mockPayload.delete).not.toHaveBeenCalled();
expect(mockPayload.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { mentionedUser: null } }),
);
});
it("emits DELETE audit entries for self/owner collections", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "cascade-hard");
const deleteEntries = auditLog.recorded.filter(
(e) => e.action === "DELETE",
);
expect(deleteEntries).toHaveLength(1);
expect(deleteEntries[0]!.resource.type).toBe("users");
});
it("anonymises the subjectId in the certificate", async () => {
const config = makeMockConfig([{ slug: "posts" }]);
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
const cert = await deleter.deleteSubjectData("alice", "cascade-hard");
expect(cert.subjectId).toMatch(/^erased-[0-9a-f]+$/);
expect(cert.subjectId).not.toContain("alice");
});
it("correlationId is shared across all audit entries for one operation", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { total: { exportable: true } },
},
},
]);
mockPayload.find
.mockResolvedValueOnce({ docs: [{ id: "alice" }] })
.mockResolvedValueOnce({ docs: [{ id: "o-1", userId: "alice" }] });
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
const cert = await deleter.deleteSubjectData("alice", "cascade-hard");
const ids = auditLog.recorded.map((e) => e.correlationId);
expect(new Set(ids).size).toBe(1);
expect(ids[0]).toBe(cert.auditEntryId);
});
});
});

View File

@@ -0,0 +1,209 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { SanitizedConfig } from "payload";
import { PayloadDataExport } from "@/payload-data-export";
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
type MockPayload = {
find: ReturnType<typeof vi.fn>;
};
function makeMockPayload(): MockPayload {
return { find: vi.fn() };
}
function makeMockConfig(
collections: Array<{ slug: string; custom?: unknown }>,
): SanitizedConfig {
return { collections } as unknown as SanitizedConfig;
}
describe("PayloadDataExport", () => {
let auditLog: RecordingAuditLog;
let mockPayload: MockPayload;
let mockGetPayload: ReturnType<typeof vi.fn>;
beforeEach(() => {
auditLog = new RecordingAuditLog();
mockPayload = makeMockPayload();
mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
});
it("returns an empty bundle when no collections have custom.subject", async () => {
const config = makeMockConfig([{ slug: "posts" }]);
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
expect(bundle.subjectId).toBe("alice");
expect(bundle.format).toBe("json");
expect(bundle.data).toEqual({});
expect(mockPayload.find).not.toHaveBeenCalled();
});
it("happy path — self role: includes exportable PII fields only", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: {
email: { exportable: true },
name: { exportable: true },
secret: { exportable: false },
},
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice", email: "a@ex.com", name: "Alice", secret: "x" }],
});
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
expect(bundle.data["users"]?.asSelf).toHaveLength(1);
const row = bundle.data["users"]!.asSelf![0]!;
expect(row).toMatchObject({
id: "alice",
email: "a@ex.com",
name: "Alice",
});
expect(row).not.toHaveProperty("secret");
expect(bundle.data["users"]?.asReference).toBeUndefined();
});
it("happy path — owner role: includes exportable PII fields", async () => {
const config = makeMockConfig([
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { shippingAddress: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "order-1", userId: "alice", shippingAddress: "1 Main St" }],
});
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
const row = bundle.data["orders"]?.asSelf?.[0];
expect(row).toMatchObject({ id: "order-1", shippingAddress: "1 Main St" });
});
it("happy path — reference role: returns SubjectReference, not row content", async () => {
const config = makeMockConfig([
{
slug: "comments",
custom: {
subject: { field: "mentionedUser", kind: "reference" },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "comment-42", mentionedUser: "alice", body: "hello" }],
});
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
expect(bundle.data["comments"]?.asSelf).toBeUndefined();
const refs = bundle.data["comments"]?.asReference;
expect(refs).toHaveLength(1);
expect(refs![0]).toEqual({
rowId: "comment-42",
linkedField: "mentionedUser",
linkedThrough: "comments",
});
// Row content must NOT appear in the reference bucket
expect(JSON.stringify(refs)).not.toContain("hello");
});
it("skips collections that return no matching rows", async () => {
const config = makeMockConfig([
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { total: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({ docs: [] });
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
expect(bundle.data).toEqual({});
});
it("emits an EXPORT audit entry", async () => {
const config = makeMockConfig([{ slug: "posts" }]);
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
await exporter.exportSubjectData("alice", "json");
expect(auditLog.recorded).toHaveLength(1);
const entry = auditLog.recorded[0]!;
expect(entry.action).toBe("EXPORT");
expect(entry.actorId).toBe("alice");
expect(entry.resource.type).toBe("subject-data");
expect(entry.outcome).toBe("success");
});
it("attaches @context when format is json-ld", async () => {
const config = makeMockConfig([{ slug: "posts" }]);
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json-ld");
expect(bundle["@context"]).toBeDefined();
expect(typeof bundle["@context"]).toBe("object");
const ctx = bundle["@context"] as Record<string, unknown>;
expect(ctx["@vocab"]).toBe("https://schema.org/");
expect(ctx["dsr"]).toBe("https://w3.org/ns/dpv#");
expect(ctx["subjectId"]).toBe("identifier");
});
it("does NOT attach @context when format is json", async () => {
const config = makeMockConfig([{ slug: "posts" }]);
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
expect(bundle["@context"]).toBeUndefined();
});
it("multi-collection: walks all subject-linked collections", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { total: { exportable: true } },
},
},
{ slug: "posts" }, // no subject linkage — should be skipped
]);
mockPayload.find
.mockResolvedValueOnce({ docs: [{ id: "alice", email: "a@ex.com" }] })
.mockResolvedValueOnce({
docs: [{ id: "o-1", userId: "alice", total: 99 }],
});
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
expect(Object.keys(bundle.data)).toEqual(["users", "orders"]);
});
});

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { SanitizedConfig } from "payload";
import { PayloadDataRectify } from "@/payload-data-rectify";
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
type MockPayload = {
find: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
function makeMockPayload(): MockPayload {
return {
find: vi.fn(),
update: vi.fn().mockResolvedValue({}),
};
}
function makeMockConfig(
collections: Array<{ slug: string; custom?: unknown }>,
): SanitizedConfig {
return { collections } as unknown as SanitizedConfig;
}
describe("PayloadDataRectify", () => {
let auditLog: RecordingAuditLog;
let mockPayload: MockPayload;
let mockGetPayload: ReturnType<typeof vi.fn>;
beforeEach(() => {
auditLog = new RecordingAuditLog();
mockPayload = makeMockPayload();
mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
});
it("happy path: updates a PII-tagged field and emits RESTRICT with art-16-request reason", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { name: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice", name: "Alice Old" }],
});
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
await rectifier.updateSubjectField("alice", "users", "name", "Alice New");
expect(mockPayload.update).toHaveBeenCalledWith({
collection: "users",
id: "alice",
data: { name: "Alice New" },
overrideAccess: true,
});
expect(auditLog.recorded).toHaveLength(1);
const entry = auditLog.recorded[0]!;
expect(entry.action).toBe("RESTRICT");
expect(entry.reason).toBe("art-16-request");
expect(entry.actorId).toBe("alice");
expect(entry.changedFields).toContain("name");
});
it("updates all subject rows if multiple exist", async () => {
const config = makeMockConfig([
{
slug: "profiles",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { bio: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [
{ id: "p-1", userId: "alice", bio: "old" },
{ id: "p-2", userId: "alice", bio: "old2" },
],
});
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
await rectifier.updateSubjectField("alice", "profiles", "bio", "new bio");
expect(mockPayload.update).toHaveBeenCalledTimes(2);
});
it("throws when collection not found in config", async () => {
const config = makeMockConfig([{ slug: "users" }]);
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
await expect(
rectifier.updateSubjectField("alice", "nonexistent", "field", "val"),
).rejects.toThrow(/not found/i);
});
it("throws when collection has no DSR subject linkage", async () => {
const config = makeMockConfig([{ slug: "posts" }]);
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
await expect(
rectifier.updateSubjectField("alice", "posts", "title", "new"),
).rejects.toThrow(/no DSR subject linkage/i);
});
it("throws when field is not PII-tagged", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
]);
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
await expect(
rectifier.updateSubjectField("alice", "users", "internalNote", "oops"),
).rejects.toThrow(/not tagged as PII/i);
});
it("emits audit entry with resource type matching the collection slug", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { name: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({ docs: [{ id: "alice" }] });
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
await rectifier.updateSubjectField("alice", "users", "name", "New");
expect(auditLog.recorded[0]!.resource.type).toBe("users");
});
});

View File

@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { SanitizedConfig } from "payload";
import { PayloadProcessingRestriction } from "@/payload-processing-restriction";
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
type MockPayload = {
find: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
function makeMockPayload(): MockPayload {
return {
find: vi.fn(),
update: vi.fn().mockResolvedValue({}),
};
}
const emptyConfig = { collections: [] } as unknown as SanitizedConfig;
describe("PayloadProcessingRestriction", () => {
let auditLog: RecordingAuditLog;
let mockPayload: MockPayload;
let mockGetPayload: ReturnType<typeof vi.fn>;
beforeEach(() => {
auditLog = new RecordingAuditLog();
mockPayload = makeMockPayload();
mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
});
describe("setRestriction", () => {
it("sets processingRestrictedAt when granted=true", async () => {
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
await restriction.setRestriction("alice", true);
expect(mockPayload.update).toHaveBeenCalledWith(
expect.objectContaining({
collection: "users",
id: "alice",
data: expect.objectContaining({
processingRestrictedAt: expect.any(String),
}),
overrideAccess: true,
}),
);
});
it("clears processingRestrictedAt when granted=false", async () => {
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
await restriction.setRestriction("alice", false);
expect(mockPayload.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { processingRestrictedAt: null },
}),
);
});
it("emits RESTRICT audit entry when granting restriction", async () => {
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
await restriction.setRestriction("alice", true);
expect(auditLog.recorded).toHaveLength(1);
const entry = auditLog.recorded[0]!;
expect(entry.action).toBe("RESTRICT");
expect(entry.actorId).toBe("alice");
expect(entry.changedFields).toContain("processingRestrictedAt");
});
it("emits UNRESTRICT audit entry when lifting restriction", async () => {
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
await restriction.setRestriction("alice", false);
expect(auditLog.recorded[0]!.action).toBe("UNRESTRICT");
});
});
describe("isRestricted", () => {
it("returns true when processingRestrictedAt is set", async () => {
mockPayload.find.mockResolvedValue({
docs: [
{ id: "alice", processingRestrictedAt: "2024-01-01T00:00:00.000Z" },
],
});
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
expect(await restriction.isRestricted("alice")).toBe(true);
});
it("returns false when processingRestrictedAt is null", async () => {
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice", processingRestrictedAt: null }],
});
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
expect(await restriction.isRestricted("alice")).toBe(false);
});
it("returns false when user record not found", async () => {
mockPayload.find.mockResolvedValue({ docs: [] });
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
expect(await restriction.isRestricted("ghost")).toBe(false);
});
it("restriction flag honored: setRestriction(true) then isRestricted returns true", async () => {
// setRestriction updates the DB; simulate the updated state via find mock
mockPayload.find.mockResolvedValue({
docs: [
{ id: "alice", processingRestrictedAt: new Date().toISOString() },
],
});
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
await restriction.setRestriction("alice", true);
const result = await restriction.isRestricted("alice");
expect(result).toBe(true);
});
});
});