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