feat(core-dsr): handlers, dsrRouter, integration tests
Add four protocol-agnostic handlers (export, delete, rectify, restrict)
returning normalized { status, body, headers } responses, and a tRPC
dsrRouter via createDsrRouter(binding) following the factory pattern.
Auth checks: requireAuthenticated middleware gates all four procedures;
cascade-hard delete additionally requires admin role. Integration tests
assert happy-path response shapes, UNAUTHORIZED/FORBIDDEN error codes,
and error passthrough from the DSR service layer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
209
packages/core-dsr/src/__tests__/dsr.router.test.ts
Normal file
209
packages/core-dsr/src/__tests__/dsr.router.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createDsrRouter } from "@/dsr.router";
|
||||
import type { DsrTrpcUser } from "@/dsr.router";
|
||||
import type { DsrBinding } from "@/di/bind-production";
|
||||
import {
|
||||
RecordingDataExport,
|
||||
RecordingDataDelete,
|
||||
RecordingDataRectify,
|
||||
RecordingProcessingRestriction,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
|
||||
// Recording doubles use local type aliases to avoid circular deps with core-dsr.
|
||||
// The alias types are structurally compatible at runtime; cast via unknown to
|
||||
// satisfy the DsrBinding constraint without modifying core-testing.
|
||||
function makeBinding() {
|
||||
return {
|
||||
dataExport: new RecordingDataExport(),
|
||||
dataDelete: new RecordingDataDelete(),
|
||||
dataRectify: new RecordingDataRectify(),
|
||||
processingRestriction: new RecordingProcessingRestriction(),
|
||||
};
|
||||
}
|
||||
|
||||
type TestBinding = ReturnType<typeof makeBinding>;
|
||||
|
||||
function makeCaller(binding: TestBinding, user?: DsrTrpcUser) {
|
||||
const router = createDsrRouter(binding as unknown as DsrBinding);
|
||||
return router.createCaller({ user } as Record<string, unknown>);
|
||||
}
|
||||
|
||||
const authenticatedUser: DsrTrpcUser = { id: "alice", roles: ["user"] };
|
||||
const adminUser: DsrTrpcUser = { id: "admin-user", roles: ["admin"] };
|
||||
|
||||
describe("dsrRouter.export", () => {
|
||||
it("returns UserDataBundle body for authenticated user", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
const result = await caller.export({ subjectId: "alice", format: "json" });
|
||||
|
||||
expect(result.subjectId).toBe("alice");
|
||||
expect(result.format).toBe("json");
|
||||
expect(binding.dataExport.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("works with json-ld format", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
const result = await caller.export({
|
||||
subjectId: "alice",
|
||||
format: "json-ld",
|
||||
});
|
||||
expect(result.format).toBe("json-ld");
|
||||
});
|
||||
|
||||
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, undefined);
|
||||
await expect(
|
||||
caller.export({ subjectId: "alice", format: "json" }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
|
||||
it("propagates errors from dataExport", async () => {
|
||||
const binding = makeBinding();
|
||||
vi.spyOn(binding.dataExport, "exportSubjectData").mockRejectedValue(
|
||||
new Error("export failed"),
|
||||
);
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
await expect(
|
||||
caller.export({ subjectId: "alice", format: "json" }),
|
||||
).rejects.toThrow("export failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dsrRouter.delete", () => {
|
||||
it("returns DeletionCertificate for soft mode (any authenticated user)", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
const result = await caller.delete({ subjectId: "alice", mode: "soft" });
|
||||
|
||||
expect(result.subjectId).toBe("alice");
|
||||
expect(result.mode).toBe("soft");
|
||||
expect(binding.dataDelete.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns DeletionCertificate for cascade-hard mode with admin user", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, adminUser);
|
||||
const result = await caller.delete({
|
||||
subjectId: "alice",
|
||||
mode: "cascade-hard",
|
||||
});
|
||||
|
||||
expect(result.mode).toBe("cascade-hard");
|
||||
expect(binding.dataDelete.calls[0]?.mode).toBe("cascade-hard");
|
||||
});
|
||||
|
||||
it("throws FORBIDDEN for cascade-hard mode with non-admin user", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
await expect(
|
||||
caller.delete({ subjectId: "alice", mode: "cascade-hard" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(binding.dataDelete.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("throws FORBIDDEN for cascade-hard when user has no roles", async () => {
|
||||
const binding = makeBinding();
|
||||
const noRolesUser: DsrTrpcUser = { id: "alice" };
|
||||
const caller = makeCaller(binding, noRolesUser);
|
||||
await expect(
|
||||
caller.delete({ subjectId: "alice", mode: "cascade-hard" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, undefined);
|
||||
await expect(
|
||||
caller.delete({ subjectId: "alice", mode: "soft" }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("dsrRouter.rectify", () => {
|
||||
it("returns { ok: true } for authenticated user", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
const result = await caller.rectify({
|
||||
subjectId: "alice",
|
||||
collection: "users",
|
||||
field: "name",
|
||||
value: "Alice New",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(binding.dataRectify.calls[0]).toEqual({
|
||||
subjectId: "alice",
|
||||
collection: "users",
|
||||
field: "name",
|
||||
value: "Alice New",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, undefined);
|
||||
await expect(
|
||||
caller.rectify({
|
||||
subjectId: "alice",
|
||||
collection: "users",
|
||||
field: "name",
|
||||
value: "x",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
|
||||
it("propagates errors from dataRectify", async () => {
|
||||
const binding = makeBinding();
|
||||
vi.spyOn(binding.dataRectify, "updateSubjectField").mockRejectedValue(
|
||||
new Error('Field "secret" is not tagged as PII'),
|
||||
);
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
await expect(
|
||||
caller.rectify({
|
||||
subjectId: "alice",
|
||||
collection: "users",
|
||||
field: "secret",
|
||||
value: "x",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dsrRouter.restrict", () => {
|
||||
it("returns { ok: true } when granting restriction", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
const result = await caller.restrict({
|
||||
subjectId: "alice",
|
||||
granted: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(binding.processingRestriction.sets[0]).toEqual({
|
||||
subjectId: "alice",
|
||||
granted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns { ok: true } when lifting restriction", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
const result = await caller.restrict({
|
||||
subjectId: "alice",
|
||||
granted: false,
|
||||
});
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(binding.processingRestriction.sets[0]?.granted).toBe(false);
|
||||
});
|
||||
|
||||
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, undefined);
|
||||
await expect(
|
||||
caller.restrict({ subjectId: "alice", granted: true }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
});
|
||||
124
packages/core-dsr/src/__tests__/handlers.test.ts
Normal file
124
packages/core-dsr/src/__tests__/handlers.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createExportHandler } from "@/handlers/export-handler";
|
||||
import { createDeleteHandler } from "@/handlers/delete-handler";
|
||||
import { createRectifyHandler } from "@/handlers/rectify-handler";
|
||||
import { createRestrictHandler } from "@/handlers/restrict-handler";
|
||||
import type { IDataExport } from "@/data-export.interface";
|
||||
import type { IDataDelete } from "@/data-delete.interface";
|
||||
import type { IDataRectify } from "@/data-rectify.interface";
|
||||
import type { IProcessingRestriction } from "@/processing-restriction.interface";
|
||||
import {
|
||||
RecordingDataExport,
|
||||
RecordingDataDelete,
|
||||
RecordingDataRectify,
|
||||
RecordingProcessingRestriction,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
|
||||
// Recording doubles use local type aliases to avoid circular deps with core-dsr.
|
||||
// Cast via unknown so they satisfy the interface at the TypeScript level while
|
||||
// remaining structurally compatible at runtime.
|
||||
|
||||
describe("createExportHandler", () => {
|
||||
it("calls exportSubjectData and returns status 200 with bundle body", async () => {
|
||||
const dataExport = new RecordingDataExport();
|
||||
const handler = createExportHandler(dataExport as unknown as IDataExport);
|
||||
const res = await handler({ subjectId: "alice", format: "json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.subjectId).toBe("alice");
|
||||
expect(res.body.format).toBe("json");
|
||||
expect(dataExport.calls).toHaveLength(1);
|
||||
expect(dataExport.calls[0]).toEqual({ subjectId: "alice", format: "json" });
|
||||
});
|
||||
|
||||
it("sets Content-Type: application/json for json format", async () => {
|
||||
const dataExport = new RecordingDataExport();
|
||||
const handler = createExportHandler(dataExport as unknown as IDataExport);
|
||||
const res = await handler({ subjectId: "alice", format: "json" });
|
||||
expect(res.headers?.["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("sets Content-Type: application/ld+json for json-ld format", async () => {
|
||||
const dataExport = new RecordingDataExport();
|
||||
const handler = createExportHandler(dataExport as unknown as IDataExport);
|
||||
const res = await handler({ subjectId: "alice", format: "json-ld" });
|
||||
expect(res.headers?.["Content-Type"]).toBe("application/ld+json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDeleteHandler", () => {
|
||||
it("calls deleteSubjectData and returns status 200 with certificate body", async () => {
|
||||
const dataDelete = new RecordingDataDelete();
|
||||
const handler = createDeleteHandler(dataDelete as unknown as IDataDelete);
|
||||
const res = await handler({ subjectId: "alice", mode: "soft" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.subjectId).toBe("alice");
|
||||
expect(res.body.mode).toBe("soft");
|
||||
expect(dataDelete.calls).toHaveLength(1);
|
||||
expect(dataDelete.calls[0]).toEqual({ subjectId: "alice", mode: "soft" });
|
||||
});
|
||||
|
||||
it("passes cascade-hard mode to deleteSubjectData", async () => {
|
||||
const dataDelete = new RecordingDataDelete();
|
||||
const handler = createDeleteHandler(dataDelete as unknown as IDataDelete);
|
||||
const res = await handler({ subjectId: "alice", mode: "cascade-hard" });
|
||||
expect(res.body.mode).toBe("cascade-hard");
|
||||
expect(dataDelete.calls[0]?.mode).toBe("cascade-hard");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRectifyHandler", () => {
|
||||
it("calls updateSubjectField and returns status 200 with ok body", async () => {
|
||||
const dataRectify = new RecordingDataRectify();
|
||||
const handler = createRectifyHandler(
|
||||
dataRectify as unknown as IDataRectify,
|
||||
);
|
||||
const res = await handler({
|
||||
subjectId: "alice",
|
||||
collection: "users",
|
||||
field: "name",
|
||||
value: "Alice New",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
expect(dataRectify.calls).toHaveLength(1);
|
||||
expect(dataRectify.calls[0]).toEqual({
|
||||
subjectId: "alice",
|
||||
collection: "users",
|
||||
field: "name",
|
||||
value: "Alice New",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRestrictHandler", () => {
|
||||
it("calls setRestriction with granted=true and returns status 200", async () => {
|
||||
const processingRestriction = new RecordingProcessingRestriction();
|
||||
const handler = createRestrictHandler(
|
||||
processingRestriction as unknown as IProcessingRestriction,
|
||||
);
|
||||
const res = await handler({ subjectId: "alice", granted: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
expect(processingRestriction.sets).toHaveLength(1);
|
||||
expect(processingRestriction.sets[0]).toEqual({
|
||||
subjectId: "alice",
|
||||
granted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls setRestriction with granted=false", async () => {
|
||||
const processingRestriction = new RecordingProcessingRestriction();
|
||||
const handler = createRestrictHandler(
|
||||
processingRestriction as unknown as IProcessingRestriction,
|
||||
);
|
||||
await handler({ subjectId: "alice", granted: false });
|
||||
expect(processingRestriction.sets[0]).toEqual({
|
||||
subjectId: "alice",
|
||||
granted: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user