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

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

View File

@@ -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) {

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { RecordingDataDelete } from "./recording-data-delete";
describe("RecordingDataDelete", () => {
it("records deleteSubjectData calls", async () => {
const deleter = new RecordingDataDelete();
await deleter.deleteSubjectData("alice", "soft");
expect(deleter.calls).toHaveLength(1);
expect(deleter.calls[0]).toEqual({ subjectId: "alice", mode: "soft" });
});
it("returns a DeletionCertificate with the correct shape", async () => {
const deleter = new RecordingDataDelete();
const cert = await deleter.deleteSubjectData("alice", "cascade-hard");
expect(cert.subjectId).toBe("alice");
expect(cert.mode).toBe("cascade-hard");
expect(cert.reason).toBe("art-17-request");
expect(cert.affected).toEqual([]);
expect(typeof cert.auditEntryId).toBe("string");
expect(typeof cert.timestamp).toBe("string");
});
it("records multiple calls in order", async () => {
const deleter = new RecordingDataDelete();
await deleter.deleteSubjectData("alice", "soft");
await deleter.deleteSubjectData("bob", "cascade-hard");
expect(deleter.calls[0]?.mode).toBe("soft");
expect(deleter.calls[1]?.mode).toBe("cascade-hard");
});
it("reset() clears recorded calls", async () => {
const deleter = new RecordingDataDelete();
await deleter.deleteSubjectData("alice", "soft");
deleter.reset();
expect(deleter.calls).toHaveLength(0);
});
});

View File

@@ -0,0 +1,63 @@
// Local type aliases mirroring exports from `@repo/core-dsr`.
// Kept inline to avoid a build-graph cycle between core-testing (tooling)
// and core-dsr (optional core). Same pattern used by RecordingAuditLog,
// RecordingConsent, and other recording doubles.
type DeletionMode = "soft" | "cascade-hard";
type DeletionReason = "art-17-request" | "admin-expunge" | "retention-policy";
type DeletionAction = "deleted" | "redacted" | "pseudonymized";
type DeletionAffected = {
collection: string;
rowsAffected: number;
action: DeletionAction;
fields?: string[];
};
type DeletionCertificate = {
subjectId: string;
mode: DeletionMode;
timestamp: string;
reason: DeletionReason;
affected: DeletionAffected[];
auditEntryId: string;
};
/** Recorded call to deleteSubjectData. */
export type RecordedDeleteCall = {
subjectId: string;
mode: DeletionMode;
};
/**
* Test-side recording double for IDataDelete. Records all deleteSubjectData
* calls for assertion while returning a well-shaped DeletionCertificate.
*
* Use directly via constructor injection:
* const deleter = new RecordingDataDelete();
* const uc = myUseCase(deleter);
* await uc({ ... });
* expect(deleter.calls[0]?.mode).toBe("soft");
*/
export class RecordingDataDelete {
public calls: RecordedDeleteCall[] = [];
async deleteSubjectData(
subjectId: string,
mode: DeletionMode,
): Promise<DeletionCertificate> {
this.calls.push({ subjectId, mode });
return {
subjectId,
mode,
timestamp: new Date().toISOString(),
reason: "art-17-request",
affected: [],
auditEntryId: `recording-audit-${Date.now()}`,
};
}
reset(): void {
this.calls = [];
}
}

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { RecordingDataExport } from "./recording-data-export";
describe("RecordingDataExport", () => {
it("records exportSubjectData calls", async () => {
const exporter = new RecordingDataExport();
await exporter.exportSubjectData("alice", "json");
expect(exporter.calls).toHaveLength(1);
expect(exporter.calls[0]).toEqual({ subjectId: "alice", format: "json" });
});
it("returns a UserDataBundle with the correct shape", async () => {
const exporter = new RecordingDataExport();
const bundle = await exporter.exportSubjectData("bob", "json-ld");
expect(bundle.subjectId).toBe("bob");
expect(bundle.format).toBe("json-ld");
expect(typeof bundle.exportedAt).toBe("string");
expect(bundle.data).toEqual({});
});
it("records multiple calls in order", async () => {
const exporter = new RecordingDataExport();
await exporter.exportSubjectData("alice", "json");
await exporter.exportSubjectData("bob", "json-ld");
expect(exporter.calls).toHaveLength(2);
expect(exporter.calls[0]?.subjectId).toBe("alice");
expect(exporter.calls[1]?.subjectId).toBe("bob");
});
it("reset() clears recorded calls", async () => {
const exporter = new RecordingDataExport();
await exporter.exportSubjectData("alice", "json");
exporter.reset();
expect(exporter.calls).toHaveLength(0);
});
});

View File

@@ -0,0 +1,64 @@
// Local type aliases mirroring exports from `@repo/core-dsr`.
// Kept inline to avoid a build-graph cycle between core-testing (tooling)
// and core-dsr (optional core). Same pattern used by RecordingAuditLog,
// RecordingConsent, and other recording doubles.
type DsrFormat = "json" | "json-ld";
type SubjectReference = {
rowId: string;
linkedField: string;
linkedThrough: string;
};
type CollectionDataBucket = {
asSelf?: Array<Record<string, unknown>>;
asReference?: SubjectReference[];
};
type UserDataBundle = {
subjectId: string;
exportedAt: string;
format: DsrFormat;
data: Record<string, CollectionDataBucket>;
auditLog?: unknown[];
"@context"?: string | Record<string, unknown>;
};
/** Recorded call to exportSubjectData. */
export type RecordedExportCall = {
subjectId: string;
format: DsrFormat;
};
/**
* Test-side recording double for IDataExport. Records all exportSubjectData
* calls for assertion while returning an empty but structurally-correct bundle.
*
* Use directly via constructor injection:
* const exporter = new RecordingDataExport();
* const uc = myUseCase(exporter);
* await uc({ ... });
* expect(exporter.calls).toHaveLength(1);
* expect(exporter.calls[0]?.format).toBe("json");
*/
export class RecordingDataExport {
public calls: RecordedExportCall[] = [];
async exportSubjectData(
subjectId: string,
format: DsrFormat,
): Promise<UserDataBundle> {
this.calls.push({ subjectId, format });
return {
subjectId,
exportedAt: new Date().toISOString(),
format,
data: {},
};
}
reset(): void {
this.calls = [];
}
}

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { RecordingDataRectify } from "./recording-data-rectify";
describe("RecordingDataRectify", () => {
it("records updateSubjectField calls", async () => {
const rectifier = new RecordingDataRectify();
await rectifier.updateSubjectField("alice", "users", "name", "Alice New");
expect(rectifier.calls).toHaveLength(1);
expect(rectifier.calls[0]).toEqual({
subjectId: "alice",
collection: "users",
field: "name",
value: "Alice New",
});
});
it("returns void (undefined)", async () => {
const rectifier = new RecordingDataRectify();
const result = await rectifier.updateSubjectField(
"alice",
"users",
"name",
"x",
);
expect(result).toBeUndefined();
});
it("records multiple calls in order", async () => {
const rectifier = new RecordingDataRectify();
await rectifier.updateSubjectField("alice", "users", "name", "A");
await rectifier.updateSubjectField("alice", "users", "email", "a@ex.com");
expect(rectifier.calls).toHaveLength(2);
expect(rectifier.calls[0]?.field).toBe("name");
expect(rectifier.calls[1]?.field).toBe("email");
});
it("reset() clears recorded calls", async () => {
const rectifier = new RecordingDataRectify();
await rectifier.updateSubjectField("alice", "users", "name", "A");
rectifier.reset();
expect(rectifier.calls).toHaveLength(0);
});
});

View File

@@ -0,0 +1,39 @@
// Local type aliases mirroring exports from `@repo/core-dsr`.
// Kept inline to avoid a build-graph cycle between core-testing (tooling)
// and core-dsr (optional core). Same pattern used by RecordingAuditLog,
// RecordingConsent, and other recording doubles.
/** Recorded call to updateSubjectField. */
export type RecordedRectifyCall = {
subjectId: string;
collection: string;
field: string;
value: unknown;
};
/**
* Test-side recording double for IDataRectify. Records all updateSubjectField
* calls for assertion while making no persistent changes.
*
* Use directly via constructor injection:
* const rectifier = new RecordingDataRectify();
* const uc = myUseCase(rectifier);
* await uc({ ... });
* expect(rectifier.calls[0]?.field).toBe("name");
*/
export class RecordingDataRectify {
public calls: RecordedRectifyCall[] = [];
async updateSubjectField(
subjectId: string,
collection: string,
field: string,
value: unknown,
): Promise<void> {
this.calls.push({ subjectId, collection, field, value });
}
reset(): void {
this.calls = [];
}
}

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import { RecordingProcessingRestriction } from "./recording-processing-restriction";
describe("RecordingProcessingRestriction", () => {
it("records setRestriction calls", async () => {
const restriction = new RecordingProcessingRestriction();
await restriction.setRestriction("alice", true);
expect(restriction.sets).toHaveLength(1);
expect(restriction.sets[0]).toEqual({ subjectId: "alice", granted: true });
});
it("isRestricted returns true after setRestriction(true)", async () => {
const restriction = new RecordingProcessingRestriction();
await restriction.setRestriction("alice", true);
expect(await restriction.isRestricted("alice")).toBe(true);
});
it("isRestricted returns false after setRestriction(false)", async () => {
const restriction = new RecordingProcessingRestriction();
await restriction.setRestriction("alice", true);
await restriction.setRestriction("alice", false);
expect(await restriction.isRestricted("alice")).toBe(false);
});
it("isRestricted returns false for unknown subjects", async () => {
const restriction = new RecordingProcessingRestriction();
expect(await restriction.isRestricted("ghost")).toBe(false);
});
it("tracks restriction per subject independently", async () => {
const restriction = new RecordingProcessingRestriction();
await restriction.setRestriction("alice", true);
await restriction.setRestriction("bob", false);
expect(await restriction.isRestricted("alice")).toBe(true);
expect(await restriction.isRestricted("bob")).toBe(false);
});
it("reset() clears sets and state", async () => {
const restriction = new RecordingProcessingRestriction();
await restriction.setRestriction("alice", true);
restriction.reset();
expect(restriction.sets).toHaveLength(0);
expect(await restriction.isRestricted("alice")).toBe(false);
});
});

View File

@@ -0,0 +1,42 @@
// Local type aliases mirroring exports from `@repo/core-dsr`.
// Kept inline to avoid a build-graph cycle between core-testing (tooling)
// and core-dsr (optional core). Same pattern used by RecordingAuditLog,
// RecordingConsent, and other recording doubles.
/** Recorded setRestriction call. */
export type RecordedRestrictionSet = {
subjectId: string;
granted: boolean;
};
/**
* Test-side recording double for IProcessingRestriction. Records all
* setRestriction / isRestricted calls for assertion while maintaining the same
* state semantics as the real impl (isRestricted returns true after
* setRestriction(true), false after setRestriction(false)).
*
* Use directly via constructor injection:
* const restriction = new RecordingProcessingRestriction();
* const uc = myUseCase(restriction);
* await uc({ ... });
* expect(restriction.sets[0]?.granted).toBe(true);
* expect(await restriction.isRestricted("alice")).toBe(true);
*/
export class RecordingProcessingRestriction {
public sets: RecordedRestrictionSet[] = [];
private readonly state = new Map<string, boolean>();
async setRestriction(subjectId: string, granted: boolean): Promise<void> {
this.sets.push({ subjectId, granted });
this.state.set(subjectId, granted);
}
async isRestricted(subjectId: string): Promise<boolean> {
return this.state.get(subjectId) ?? false;
}
reset(): void {
this.sets = [];
this.state.clear();
}
}