From cbea635facc05bb4939f0d255cbe32f8db0160ea Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 19 May 2026 20:12:41 +0000 Subject: [PATCH] fix(core-dsr): flip processingRestrictedAt in soft-delete for self role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PayloadDataDelete.deleteSubjectData('soft') was NULLing PII fields and emitting RESTRICT audit entries, but never setting processingRestrictedAt on self-kind rows — violating GDPR Art. 17 restriction semantics. softRedactOwnerRows now accepts optional extraData; processOwnerRows passes { processingRestrictedAt: new Date().toISOString() } when kind === 'self' and mode === 'soft'. Owner-kind rows are intentionally excluded (restriction flag belongs on the subject record, not owned rows). Added two dedicated tests: one asserting the field is present for self, one asserting it is absent for owner. Co-Authored-By: Claude Sonnet 4.6 --- coverage/summary.json | 28 +++++----- .../src/__tests__/payload-data-delete.test.ts | 55 ++++++++++++++++++- packages/core-dsr/src/payload-data-delete.ts | 21 ++++++- 3 files changed, 86 insertions(+), 18 deletions(-) diff --git a/coverage/summary.json b/coverage/summary.json index ffb5f54..b95c1ef 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,16 +1,16 @@ { - "generatedAt": "2026-05-19T19:59:56.279Z", - "commit": "8068d1b", + "generatedAt": "2026-05-19T20:12:30.651Z", + "commit": "6606b59", "repo": { - "statements": 97.04, - "branches": 92.09, + "statements": 97.08, + "branches": 92.03, "functions": 96.87, - "lines": 97.04, + "lines": 97.08, "counts": { - "lf": 5107, - "lh": 4956, - "brf": 1012, - "brh": 932, + "lf": 5172, + "lh": 5021, + "brf": 1016, + "brh": 935, "fnf": 319, "fnh": 309 } @@ -74,14 +74,14 @@ }, "@repo/core-dsr": { "statements": 100, - "branches": 92, + "branches": 91.35, "functions": 96.88, "lines": 100, "counts": { - "lf": 475, - "lh": 475, - "brf": 100, - "brh": 92, + "lf": 540, + "lh": 540, + "brf": 104, + "brh": 95, "fnf": 32, "fnh": 31 } diff --git a/packages/core-dsr/src/__tests__/payload-data-delete.test.ts b/packages/core-dsr/src/__tests__/payload-data-delete.test.ts index 1a09a87..01d6c7b 100644 --- a/packages/core-dsr/src/__tests__/payload-data-delete.test.ts +++ b/packages/core-dsr/src/__tests__/payload-data-delete.test.ts @@ -56,7 +56,7 @@ describe("PayloadDataDelete", () => { expect(mockPayload.update).toHaveBeenCalledWith({ collection: "users", id: "alice", - data: { email: null, name: null }, + data: expect.objectContaining({ email: null, name: null }), overrideAccess: true, }); @@ -70,6 +70,59 @@ describe("PayloadDataDelete", () => { }); }); + it("self role: sets processingRestrictedAt in the update call", 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); + await deleter.deleteSubjectData("alice", "soft"); + + expect(mockPayload.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + processingRestrictedAt: expect.any(String), + }), + }), + ); + }); + + it("owner role: does NOT set processingRestrictedAt", 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); + await deleter.deleteSubjectData("alice", "soft"); + + expect(mockPayload.update).toHaveBeenCalledWith({ + collection: "orders", + id: "o-1", + data: { shippingAddress: null }, + overrideAccess: true, + }); + }); + it("happy path — owner role: NULLs exportable fields in owned rows", async () => { const config = makeMockConfig([ { diff --git a/packages/core-dsr/src/payload-data-delete.ts b/packages/core-dsr/src/payload-data-delete.ts index 360aed3..6513a13 100644 --- a/packages/core-dsr/src/payload-data-delete.ts +++ b/packages/core-dsr/src/payload-data-delete.ts @@ -46,14 +46,18 @@ async function softRedactOwnerRows( slug: string, docs: PayloadDoc[], exportableFields: string[], + extraData: Record = {}, ): Promise { - if (exportableFields.length === 0) return; + const hasContent = + exportableFields.length > 0 || Object.keys(extraData).length > 0; + if (!hasContent) return; const nullData = Object.fromEntries(exportableFields.map((f) => [f, null])); + const data = { ...nullData, ...extraData }; for (const doc of docs) { await payload.update({ collection: slug, id: String(doc["id"]), - data: nullData, + data, overrideAccess: true, }); } @@ -178,7 +182,18 @@ export class PayloadDataDelete implements IDataDelete { .map(([name]) => name); if (mode === "soft") { - await softRedactOwnerRows(payload, slug, docs, exportableFields); + const kind = custom.subject?.kind; + const extraData: Record = + kind === "self" + ? { processingRestrictedAt: new Date().toISOString() } + : {}; + await softRedactOwnerRows( + payload, + slug, + docs, + exportableFields, + extraData, + ); affected.push({ collection: slug, rowsAffected: docs.length,