dpdp-erasure-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/.env.example +55 -0
  2. package/Dockerfile +33 -0
  3. package/compliance.worker.yaml +64 -0
  4. package/package.json +41 -0
  5. package/src/constants/index.ts +1 -0
  6. package/src/errors/fail.ts +110 -0
  7. package/src/errors/index.ts +4 -0
  8. package/src/errors/inferer.ts +166 -0
  9. package/src/errors/registry.ts +122 -0
  10. package/src/errors/types.ts +65 -0
  11. package/src/errors/worker.ts +161 -0
  12. package/src/index.ts +328 -0
  13. package/src/lib/crypto/digest.ts +22 -0
  14. package/src/lib/crypto/encoding.ts +78 -0
  15. package/src/lib/crypto/index.ts +2 -0
  16. package/src/lib/index.ts +1 -0
  17. package/src/modules/bootstrap/index.ts +2 -0
  18. package/src/modules/bootstrap/integrity.ts +38 -0
  19. package/src/modules/bootstrap/preflight.ts +296 -0
  20. package/src/modules/cli/check-integrity.ts +48 -0
  21. package/src/modules/cli/dry-run.ts +90 -0
  22. package/src/modules/cli/graph.ts +87 -0
  23. package/src/modules/cli/index.ts +184 -0
  24. package/src/modules/cli/init.ts +115 -0
  25. package/src/modules/cli/inspect.ts +86 -0
  26. package/src/modules/cli/introspector.ts +117 -0
  27. package/src/modules/cli/keygen.ts +38 -0
  28. package/src/modules/cli/scan.ts +126 -0
  29. package/src/modules/cli/sign.ts +50 -0
  30. package/src/modules/cli/ui.ts +61 -0
  31. package/src/modules/cli/verify-schema.ts +31 -0
  32. package/src/modules/cli/verify.ts +85 -0
  33. package/src/modules/config/compatibility.ts +271 -0
  34. package/src/modules/config/index.ts +4 -0
  35. package/src/modules/config/reader.ts +149 -0
  36. package/src/modules/config/signature.ts +69 -0
  37. package/src/modules/config/validation.ts +658 -0
  38. package/src/modules/crypto/aes.ts +158 -0
  39. package/src/modules/crypto/envelope.ts +48 -0
  40. package/src/modules/crypto/hmac.ts +60 -0
  41. package/src/modules/crypto/index.ts +3 -0
  42. package/src/modules/db/drift.ts +36 -0
  43. package/src/modules/db/graph.ts +203 -0
  44. package/src/modules/db/index.ts +4 -0
  45. package/src/modules/db/migrations.ts +254 -0
  46. package/src/modules/db/sql-debug.ts +61 -0
  47. package/src/modules/engine/blob/index.ts +3 -0
  48. package/src/modules/engine/blob/s3.ts +455 -0
  49. package/src/modules/engine/blob/store.ts +236 -0
  50. package/src/modules/engine/blob/types.ts +44 -0
  51. package/src/modules/engine/helpers/identity.ts +47 -0
  52. package/src/modules/engine/helpers/index.ts +4 -0
  53. package/src/modules/engine/helpers/outbox.ts +118 -0
  54. package/src/modules/engine/helpers/runtime.ts +115 -0
  55. package/src/modules/engine/helpers/types.ts +61 -0
  56. package/src/modules/engine/index.ts +6 -0
  57. package/src/modules/engine/notifier/config.ts +147 -0
  58. package/src/modules/engine/notifier/dispatcher.ts +300 -0
  59. package/src/modules/engine/notifier/index.ts +3 -0
  60. package/src/modules/engine/notifier/payload.ts +51 -0
  61. package/src/modules/engine/notifier/reservation.ts +153 -0
  62. package/src/modules/engine/notifier/types.ts +38 -0
  63. package/src/modules/engine/shredder.ts +254 -0
  64. package/src/modules/engine/types.ts +146 -0
  65. package/src/modules/engine/vault/compiled-targets.ts +562 -0
  66. package/src/modules/engine/vault/context.ts +254 -0
  67. package/src/modules/engine/vault/dry-run.ts +94 -0
  68. package/src/modules/engine/vault/execution.ts +485 -0
  69. package/src/modules/engine/vault/index.ts +3 -0
  70. package/src/modules/engine/vault/purge.ts +82 -0
  71. package/src/modules/engine/vault/retention.ts +124 -0
  72. package/src/modules/engine/vault/satellite-mutation.ts +193 -0
  73. package/src/modules/engine/vault/satellite.ts +103 -0
  74. package/src/modules/engine/vault/shadow.ts +36 -0
  75. package/src/modules/engine/vault/static-plan.ts +116 -0
  76. package/src/modules/engine/vault/store.ts +34 -0
  77. package/src/modules/engine/vault/vault.ts +84 -0
  78. package/src/modules/introspector/classifier.ts +502 -0
  79. package/src/modules/introspector/dag.ts +276 -0
  80. package/src/modules/introspector/index.ts +7 -0
  81. package/src/modules/introspector/naming.ts +75 -0
  82. package/src/modules/introspector/report.ts +153 -0
  83. package/src/modules/introspector/run.ts +123 -0
  84. package/src/modules/introspector/s3-sampler.ts +227 -0
  85. package/src/modules/introspector/types.ts +131 -0
  86. package/src/modules/introspector/yaml.ts +101 -0
  87. package/src/modules/network/api/control-plane.ts +275 -0
  88. package/src/modules/network/api/index.ts +1 -0
  89. package/src/modules/network/api/validation.ts +71 -0
  90. package/src/modules/network/index.ts +4 -0
  91. package/src/modules/network/object-store/aws/client.ts +444 -0
  92. package/src/modules/network/object-store/aws/credentials.ts +271 -0
  93. package/src/modules/network/object-store/aws/index.ts +2 -0
  94. package/src/modules/network/object-store/aws/sigv4.ts +190 -0
  95. package/src/modules/network/object-store/aws/type.ts +6 -0
  96. package/src/modules/network/object-store/index.ts +1 -0
  97. package/src/modules/network/outbox/dispatcher.ts +183 -0
  98. package/src/modules/network/outbox/index.ts +3 -0
  99. package/src/modules/network/outbox/process.ts +133 -0
  100. package/src/modules/network/outbox/shared.ts +56 -0
  101. package/src/modules/network/outbox/store.ts +346 -0
  102. package/src/modules/network/outbox/types.ts +54 -0
  103. package/src/modules/network/request-signing.ts +61 -0
  104. package/src/modules/worker/index.ts +2 -0
  105. package/src/modules/worker/tasks.ts +58 -0
  106. package/src/modules/worker/types.ts +89 -0
  107. package/src/modules/worker/worker.ts +243 -0
  108. package/src/secrets/index.ts +4 -0
  109. package/src/secrets/kms/index.ts +2 -0
  110. package/src/secrets/kms/signature.ts +82 -0
  111. package/src/secrets/kms/validation.ts +64 -0
  112. package/src/secrets/reader.ts +42 -0
  113. package/src/secrets/repository/crypto.ts +89 -0
  114. package/src/secrets/repository/index.ts +2 -0
  115. package/src/secrets/repository/methods.ts +37 -0
  116. package/src/secrets/resolvers.ts +247 -0
  117. package/src/secrets/signature.ts +78 -0
  118. package/src/types/index.ts +1 -0
  119. package/src/types/types.ts +23 -0
  120. package/src/utils/identifiers.ts +48 -0
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/json.ts +35 -0
  123. package/src/utils/logger.ts +161 -0
  124. package/src/validation/zod.ts +70 -0
  125. package/tests/adversarial.test.ts +464 -0
  126. package/tests/blob-s3.test.ts +216 -0
  127. package/tests/config.test.ts +395 -0
  128. package/tests/control-plane-client.test.ts +108 -0
  129. package/tests/crypto.test.ts +106 -0
  130. package/tests/errors.test.ts +69 -0
  131. package/tests/fetch-dispatcher.test.ts +213 -0
  132. package/tests/graph.test.ts +84 -0
  133. package/tests/helpers/index.ts +101 -0
  134. package/tests/index-preflight.test.ts +168 -0
  135. package/tests/introspector-classifier.test.ts +62 -0
  136. package/tests/introspector-report.test.ts +85 -0
  137. package/tests/introspector.test.ts +394 -0
  138. package/tests/kms.test.ts +124 -0
  139. package/tests/logger.test.ts +61 -0
  140. package/tests/notifier.test.ts +303 -0
  141. package/tests/outbox.test.ts +478 -0
  142. package/tests/purge-policy.test.ts +124 -0
  143. package/tests/retention.test.ts +103 -0
  144. package/tests/s3-client.test.ts +110 -0
  145. package/tests/satellite.test.ts +119 -0
  146. package/tests/schema-compatibility.test.ts +237 -0
  147. package/tests/schema-integrity.test.ts +64 -0
  148. package/tests/shredder.test.ts +163 -0
  149. package/tests/vault.compiled-targets.test.ts +243 -0
  150. package/tests/vault.replica.test.ts +59 -0
  151. package/tests/vault.test.ts +279 -0
  152. package/tests/worker.retry.test.ts +291 -0
  153. package/tests/worker.test.ts +200 -0
  154. package/tsconfig.json +19 -0
  155. package/vitest.config.ts +13 -0
@@ -0,0 +1,291 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { type MockMailer } from "@modules/engine";
3
+ import { workerError } from "@/errors";
4
+
5
+ const vaultUserMock = vi.hoisted(() => vi.fn());
6
+ const dispatchPreErasureNoticeMock = vi.hoisted(() => vi.fn());
7
+ const shredUserMock = vi.hoisted(() => vi.fn());
8
+ const processOutboxMock = vi.hoisted(() => vi.fn());
9
+
10
+
11
+ vi.mock("@modules/engine", async (importOriginal) => {
12
+ const actual = await importOriginal<typeof import("@modules/engine")>();
13
+ return {
14
+ ...actual,
15
+ vaultUser: vaultUserMock,
16
+ dispatchPreErasureNotice: dispatchPreErasureNoticeMock,
17
+ shredUser: shredUserMock,
18
+ };
19
+ });
20
+
21
+ vi.mock("../src/network/outbox", () => ({
22
+ processOutbox: processOutboxMock,
23
+ }));
24
+
25
+ import { ComplianceWorker } from "@modules/worker";
26
+ import { TEST_SECRETS } from "./helpers";
27
+ import type { Sql } from "@/types";
28
+
29
+ function buildConfig() {
30
+ return {
31
+ version: "1.0",
32
+ database: {
33
+ app_schema: "tenant_app",
34
+ engine_schema: "tenant_engine",
35
+ },
36
+ compliance_policy: {
37
+ default_retention_years: 0,
38
+ notice_window_hours: 48,
39
+ retention_rules: [],
40
+ },
41
+ graph: {
42
+ root_table: "users",
43
+ root_id_column: "id",
44
+ max_depth: 32,
45
+ root_pii_columns: {
46
+ email: "HMAC" as const,
47
+ full_name: "STATIC_MASK" as const,
48
+ },
49
+ },
50
+ satellite_targets: [],
51
+ blob_targets: [],
52
+ outbox: {
53
+ batch_size: 10,
54
+ lease_seconds: 60,
55
+ max_attempts: 10,
56
+ base_backoff_ms: 1_000,
57
+ },
58
+ security: {
59
+ notification_lease_seconds: 60,
60
+ master_key_env: "DPDP_MASTER_KEY",
61
+ hmac_key_env: "DPDP_HMAC_KEY",
62
+ },
63
+ integrity: {
64
+ expected_schema_hash: "1".repeat(64),
65
+ },
66
+ legal_attestation: {
67
+ dpo_identifier: "dpo@example.com",
68
+ configuration_version: "v-test",
69
+ legal_review_date: "2026-04-20",
70
+ acknowledgment: "Configuration reviewed by the Data Protection Officer.",
71
+ },
72
+ masterKey: TEST_SECRETS.kek,
73
+ hmacKey: TEST_SECRETS.hmacKey,
74
+ };
75
+ }
76
+
77
+ function buildWorker() {
78
+ const apiClient = {
79
+ syncTask: vi.fn(),
80
+ ackTask: vi.fn(),
81
+ pushOutboxEvent: vi.fn(),
82
+ };
83
+
84
+ const mailer: MockMailer = {
85
+ sendEmail: vi.fn().mockResolvedValue(undefined),
86
+ };
87
+
88
+ const worker = new ComplianceWorker({
89
+ sql: {} as Sql,
90
+ secrets: TEST_SECRETS,
91
+ config: buildConfig(),
92
+ apiClient,
93
+ mailer,
94
+ });
95
+
96
+ return {
97
+ worker,
98
+ apiClient,
99
+ mailer,
100
+ };
101
+ }
102
+
103
+ describe("ComplianceWorker failure handling", () => {
104
+ beforeEach(() => {
105
+ vaultUserMock.mockReset();
106
+ dispatchPreErasureNoticeMock.mockReset();
107
+ shredUserMock.mockReset();
108
+ processOutboxMock.mockReset();
109
+ });
110
+
111
+ it("rethrows retryable execution errors so the task can be retried without an ack", async () => {
112
+ const { worker, apiClient } = buildWorker();
113
+ apiClient.syncTask.mockResolvedValue({
114
+ pending: true,
115
+ task: {
116
+ id: "task-retry",
117
+ task_type: "VAULT_USER",
118
+ payload: { userId: 42 },
119
+ },
120
+ });
121
+ vaultUserMock.mockRejectedValue(
122
+ workerError({
123
+ code: "DB_SERIALIZATION_FAILURE",
124
+ title: "Serialization failure",
125
+ detail: "Transaction rolled back due to concurrent write.",
126
+ category: "concurrency",
127
+ retryable: true,
128
+ })
129
+ );
130
+
131
+ await expect(worker.processNextTask()).rejects.toMatchObject({
132
+ code: "DB_SERIALIZATION_FAILURE",
133
+ retryable: true,
134
+ });
135
+ expect(apiClient.ackTask).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it("rethrows fatal execution errors so the worker can terminate fail-closed", async () => {
139
+ const { worker, apiClient } = buildWorker();
140
+ apiClient.syncTask.mockResolvedValue({
141
+ pending: true,
142
+ task: {
143
+ id: "task-fatal",
144
+ task_type: "VAULT_USER",
145
+ payload: { userId: 42 },
146
+ },
147
+ });
148
+ vaultUserMock.mockRejectedValue(
149
+ workerError({
150
+ code: "SCHEMA_DRIFT_DETECTED",
151
+ title: "Schema drift detected",
152
+ detail: "Live schema no longer matches the expected digest.",
153
+ category: "integrity",
154
+ retryable: false,
155
+ fatal: true,
156
+ })
157
+ );
158
+
159
+ await expect(worker.processNextTask()).rejects.toMatchObject({
160
+ code: "SCHEMA_DRIFT_DETECTED",
161
+ fatal: true,
162
+ });
163
+ expect(apiClient.ackTask).not.toHaveBeenCalled();
164
+ });
165
+
166
+ it("acks non-retryable task failures with standardized problem details", async () => {
167
+ const { worker, apiClient } = buildWorker();
168
+ apiClient.syncTask.mockResolvedValue({
169
+ pending: true,
170
+ task: {
171
+ id: "task-failed",
172
+ task_type: "VAULT_USER",
173
+ payload: { userId: 42 },
174
+ },
175
+ });
176
+ apiClient.ackTask.mockResolvedValue(true);
177
+ vaultUserMock.mockRejectedValue(
178
+ workerError({
179
+ code: "VAULT_ROOT_ROW_NOT_FOUND",
180
+ title: "Root row not found",
181
+ detail: "The target row no longer exists.",
182
+ category: "validation",
183
+ retryable: false,
184
+ })
185
+ );
186
+
187
+ await expect(worker.processNextTask()).resolves.toBe(true);
188
+ expect(apiClient.ackTask).toHaveBeenCalledWith(
189
+ "task-failed",
190
+ "failed",
191
+ expect.objectContaining({
192
+ error: expect.objectContaining({
193
+ code: "VAULT_ROOT_ROW_NOT_FOUND",
194
+ category: "validation",
195
+ retryable: false,
196
+ fatal: false,
197
+ instance: "task:task-failed",
198
+ }),
199
+ })
200
+ );
201
+ });
202
+
203
+ it("rethrows a retryable acknowledgement failure after successful task execution", async () => {
204
+ const { worker, apiClient } = buildWorker();
205
+ apiClient.syncTask.mockResolvedValue({
206
+ pending: true,
207
+ task: {
208
+ id: "task-ack",
209
+ task_type: "VAULT_USER",
210
+ payload: { userId: 42 },
211
+ },
212
+ });
213
+ apiClient.ackTask.mockResolvedValue(false);
214
+ vaultUserMock.mockResolvedValue({
215
+ action: "vaulted",
216
+ userHash: "a".repeat(64),
217
+ dryRun: false,
218
+ dependencyCount: 1,
219
+ retentionYears: 10,
220
+ appliedRuleName: "PMLA_FINANCIAL",
221
+ appliedRuleCitation: "Prevention of Money Laundering Act, 2002, Sec 12",
222
+ retentionExpiry: "2026-01-01T00:00:00.000Z",
223
+ notificationDueAt: "2025-12-30T00:00:00.000Z",
224
+ pseudonym: "dpdp_example@dpdp.invalid",
225
+ outboxEventType: "USER_VAULTED",
226
+ });
227
+
228
+ await expect(worker.processNextTask()).rejects.toMatchObject({
229
+ code: "TASK_ACK_FAILED",
230
+ retryable: true,
231
+ });
232
+ });
233
+
234
+ it("accepts non-numeric subject_opaque_id values for notifier and shredder tasks", async () => {
235
+ const { worker, apiClient } = buildWorker();
236
+ apiClient.ackTask.mockResolvedValue(true);
237
+
238
+ dispatchPreErasureNoticeMock.mockResolvedValue({
239
+ action: "sent",
240
+ userHash: "b".repeat(64),
241
+ dryRun: false,
242
+ retentionExpiry: "2026-01-01T00:00:00.000Z",
243
+ notificationDueAt: "2025-12-30T00:00:00.000Z",
244
+ notificationSentAt: "2025-12-30T00:00:00.000Z",
245
+ outboxEventType: "NOTIFICATION_SENT",
246
+ });
247
+ shredUserMock.mockResolvedValue({
248
+ action: "shredded",
249
+ userHash: "b".repeat(64),
250
+ dryRun: false,
251
+ shreddedAt: "2026-01-01T00:00:00.000Z",
252
+ outboxEventType: "SHRED_SUCCESS",
253
+ });
254
+
255
+ apiClient.syncTask.mockResolvedValueOnce({
256
+ pending: true,
257
+ task: {
258
+ id: "task-notify-opaque",
259
+ task_type: "NOTIFY_USER",
260
+ payload: {
261
+ subject_opaque_id: "usr_8847a92b_4f1c_882a",
262
+ },
263
+ },
264
+ });
265
+ await expect(worker.processNextTask()).resolves.toBe(true);
266
+ expect(dispatchPreErasureNoticeMock).toHaveBeenCalledWith(
267
+ expect.anything(),
268
+ "usr_8847a92b_4f1c_882a",
269
+ expect.anything(),
270
+ expect.anything(),
271
+ expect.anything()
272
+ );
273
+
274
+ apiClient.syncTask.mockResolvedValueOnce({
275
+ pending: true,
276
+ task: {
277
+ id: "task-shred-opaque",
278
+ task_type: "SHRED_USER",
279
+ payload: {
280
+ subject_opaque_id: "usr_8847a92b_4f1c_882a",
281
+ },
282
+ },
283
+ });
284
+ await expect(worker.processNextTask()).resolves.toBe(true);
285
+ expect(shredUserMock).toHaveBeenCalledWith(
286
+ expect.anything(),
287
+ "usr_8847a92b_4f1c_882a",
288
+ expect.anything()
289
+ );
290
+ });
291
+ });
@@ -0,0 +1,200 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ createTestSql,
4
+ dropSchemas,
5
+ insertUser,
6
+ prepareWorkerSchemas,
7
+ uniqueSchema,
8
+ TEST_SECRETS,
9
+ } from "./helpers";
10
+ import { ComplianceWorker } from "@modules/worker";
11
+ import { type MockMailer } from "@modules/engine";
12
+ import type { Sql } from "@/types";
13
+
14
+ describe("Compliance Worker Daemon (E2E Lifecycle)", () => {
15
+ let sql: Sql;
16
+ const schemasToDrop: string[] = [];
17
+
18
+ beforeAll(() => {
19
+ sql = createTestSql();
20
+ });
21
+
22
+ afterAll(async () => {
23
+ await dropSchemas(sql, ...schemasToDrop);
24
+ await sql.end();
25
+ });
26
+
27
+ it("orchestrates the full lifecycle: vault -> outbox sync -> notify -> shred", async () => {
28
+ const appSchema = uniqueSchema("e2e_app");
29
+ const engineSchema = uniqueSchema("e2e_engine");
30
+ schemasToDrop.push(appSchema, engineSchema);
31
+
32
+ // Setup fresh schemas
33
+ await prepareWorkerSchemas(sql, appSchema, engineSchema, {
34
+ withDependencies: true,
35
+ });
36
+
37
+ const userId = await insertUser(sql, appSchema, "e2e@example.com", "E2E User");
38
+
39
+ // 1. Mock the Central API (The Brain)
40
+ const mockApi = {
41
+ syncTask: vi.fn(),
42
+ ackTask: vi.fn(),
43
+ pushOutboxEvent: vi.fn(),
44
+ };
45
+
46
+ // 2. Mock the Mailer (SMTP Transport)
47
+ const mockMailer: MockMailer = {
48
+ sendEmail: vi.fn().mockResolvedValue(undefined),
49
+ };
50
+
51
+ // 3. Initialize the Worker Runtime (The class we will build next!)
52
+ const worker = new ComplianceWorker({
53
+ sql,
54
+ secrets: TEST_SECRETS,
55
+ config: {
56
+ version: "1.0",
57
+ database: {
58
+ app_schema: appSchema,
59
+ engine_schema: engineSchema,
60
+ },
61
+ compliance_policy: {
62
+ default_retention_years: 5,
63
+ notice_window_hours: 48,
64
+ retention_rules: [],
65
+ },
66
+ graph: {
67
+ root_table: "users",
68
+ root_id_column: "id",
69
+ max_depth: 32,
70
+ root_pii_columns: {
71
+ email: "HMAC",
72
+ full_name: "STATIC_MASK",
73
+ },
74
+ },
75
+ satellite_targets: [],
76
+ blob_targets: [],
77
+ rules: [
78
+ {
79
+ id: "dpdp_standard",
80
+ root_table: `${appSchema}.users`,
81
+ targets: [
82
+ {
83
+ table: `${appSchema}.users`,
84
+ parent_columns: [],
85
+ child_columns: [],
86
+ primary_key_columns: [],
87
+ pii_columns: ["email", "full_name"],
88
+ },
89
+ {
90
+ table: `${appSchema}.orders`,
91
+ parent: `${appSchema}.users`,
92
+ join: `${appSchema}.users.id = ${appSchema}.orders.user_id`,
93
+ parent_columns: ["id"],
94
+ child_columns: ["user_id"],
95
+ primary_key_columns: ["id"],
96
+ pii_columns: [],
97
+ },
98
+ ],
99
+ },
100
+ ],
101
+ outbox: {
102
+ batch_size: 10,
103
+ lease_seconds: 60,
104
+ max_attempts: 3,
105
+ base_backoff_ms: 100,
106
+ },
107
+ security: {
108
+ notification_lease_seconds: 60,
109
+ master_key_env: "DPDP_MASTER_KEY",
110
+ hmac_key_env: "DPDP_HMAC_KEY",
111
+ },
112
+ integrity: {
113
+ expected_schema_hash: "1".repeat(64),
114
+ },
115
+ legal_attestation: {
116
+ dpo_identifier: "dpo@example.com",
117
+ configuration_version: "v-test",
118
+ legal_review_date: "2026-04-20",
119
+ acknowledgment: "Configuration reviewed by the Data Protection Officer.",
120
+ },
121
+ masterKey: TEST_SECRETS.kek,
122
+ hmacKey: TEST_SECRETS.hmacKey,
123
+ },
124
+ apiClient: mockApi,
125
+ mailer: mockMailer,
126
+ });
127
+
128
+ // --- STAGE 1: VAULTING ---
129
+ // Simulate the API giving us a VAULT_USER task
130
+ mockApi.syncTask.mockResolvedValueOnce({
131
+ pending: true,
132
+ task: {
133
+ id: "task-1",
134
+ task_type: "VAULT_USER",
135
+ payload: {
136
+ request_id: crypto.randomUUID(),
137
+ subject_opaque_id: userId.toString(),
138
+ idempotency_key: crypto.randomUUID(),
139
+ trigger_source: "USER_CONSENT_WITHDRAWAL",
140
+ actor_opaque_id: userId.toString(),
141
+ legal_framework: "DPDP_2023",
142
+ request_timestamp: new Date().toISOString(),
143
+ cooldown_days: 0,
144
+ shadow_mode: false,
145
+ userId,
146
+ },
147
+ },
148
+ });
149
+ mockApi.ackTask.mockResolvedValueOnce(true);
150
+ mockApi.pushOutboxEvent.mockResolvedValueOnce(true);
151
+
152
+ // Run one loop of the worker to process the task
153
+ await worker.processNextTask();
154
+
155
+ expect(mockApi.syncTask).toHaveBeenCalled();
156
+ expect(mockApi.ackTask).toHaveBeenCalledWith("task-1", "completed", expect.objectContaining({ action: "vaulted" }));
157
+
158
+ // Flush the outbox to ensure the API receives the USER_VAULTED event
159
+ await worker.flushOutbox();
160
+ expect(mockApi.pushOutboxEvent).toHaveBeenCalledWith(
161
+ expect.objectContaining({ event_type: "USER_VAULTED" })
162
+ );
163
+
164
+ // --- STAGE 3: NOTIFYING ---
165
+ // Fast forward time to trigger the notification window (e.g., 4.9 years later)
166
+ const notifyTime = new Date();
167
+ notifyTime.setUTCFullYear(notifyTime.getUTCFullYear() + 5);
168
+ notifyTime.setUTCDate(notifyTime.getUTCDate() - 1);
169
+
170
+ mockApi.syncTask.mockResolvedValueOnce({
171
+ pending: true,
172
+ task: { id: "task-2", task_type: "NOTIFY_USER", payload: { userId, now: notifyTime.toISOString() } },
173
+ });
174
+ mockApi.ackTask.mockResolvedValueOnce(true);
175
+
176
+ await worker.processNextTask();
177
+
178
+ expect(mockMailer.sendEmail).toHaveBeenCalledTimes(1);
179
+ expect(mockApi.ackTask).toHaveBeenCalledWith("task-2", "completed", expect.objectContaining({ action: "sent" }));
180
+
181
+ // --- STAGE 4: SHREDDING ---
182
+ // Fast forward past the expiry date
183
+ const shredTime = new Date(notifyTime);
184
+ shredTime.setUTCDate(shredTime.getUTCDate() + 3);
185
+
186
+ mockApi.syncTask.mockResolvedValueOnce({
187
+ pending: true,
188
+ task: { id: "task-3", task_type: "SHRED_USER", payload: { userId, now: shredTime.toISOString() } },
189
+ });
190
+ mockApi.ackTask.mockResolvedValueOnce(true);
191
+
192
+ await worker.processNextTask();
193
+
194
+ expect(mockApi.ackTask).toHaveBeenCalledWith("task-3", "completed", expect.objectContaining({ action: "shredded" }));
195
+
196
+ // Verify Final Database State: Key is gone, payload is { destroyed: true }
197
+ const [vaultRow] = await sql`SELECT encrypted_pii FROM ${sql(engineSchema)}.pii_vault WHERE root_id = ${userId.toString()}`;
198
+ expect(vaultRow?.encrypted_pii).toEqual({ v: 1, destroyed: true });
199
+ });
200
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "paths": {
5
+ "@/*": [
6
+ "./src/*"
7
+ ],
8
+ "@modules/*": [
9
+ "./src/modules/*"
10
+ ]
11
+ }
12
+ },
13
+ "include": [
14
+ "src/**/*",
15
+ "tests/**/*",
16
+ "**/*.test.ts",
17
+ "**/*.spec.ts"
18
+ ]
19
+ }
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ resolve: {
5
+ alias: {
6
+ '@': new URL('./src', import.meta.url).pathname,
7
+ '@modules': new URL('./src/modules', import.meta.url).pathname,
8
+ },
9
+ },
10
+ test: {
11
+ fileParallelism: false,
12
+ },
13
+ });