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,395 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { readWorkerConfig } from "@modules/config";
6
+
7
+ const masterKeyHex = "42".repeat(32);
8
+ const hmacKeyBase64 = Buffer.from(new Uint8Array(32).fill(0x24)).toString("base64");
9
+
10
+ async function writeYaml(contents: string): Promise<string> {
11
+ const directory = await mkdtemp(join(tmpdir(), "worker-config-"));
12
+ const path = join(directory, "compliance.worker.yml");
13
+ await writeFile(path, contents, "utf8");
14
+ return path;
15
+ }
16
+
17
+ async function removeYaml(path: string) {
18
+ await rm(path, { force: true });
19
+ await rm(dirname(path), { recursive: true, force: true });
20
+ }
21
+
22
+ describe("Worker configuration", () => {
23
+ const pathsToDelete: string[] = [];
24
+
25
+ afterEach(async () => {
26
+ for (const path of pathsToDelete.splice(0, pathsToDelete.length)) {
27
+ await removeYaml(path);
28
+ }
29
+ });
30
+
31
+ it("parses strict YAML config with strongly typed graph and satellite definitions", async () => {
32
+ const path = await writeYaml(`
33
+ version: "1.0"
34
+ database:
35
+ app_schema: tenant_app
36
+ engine_schema: tenant_engine
37
+ replica_db_url: postgres://replica:replica@replica-host:5432/postgres
38
+ compliance_policy:
39
+ default_retention_years: 0
40
+ notice_window_hours: 72
41
+ retention_rules:
42
+ - rule_name: PMLA_FINANCIAL
43
+ legal_citation: "Prevention of Money Laundering Act, 2002, Sec 12"
44
+ if_has_data_in:
45
+ - transactions
46
+ retention_years: 10
47
+ - rule_name: RBI_KYC
48
+ legal_citation: "RBI KYC Directions, 2016, Sec 38"
49
+ if_has_data_in:
50
+ - kyc_documents
51
+ retention_years: 5
52
+ graph:
53
+ root_table: users
54
+ root_id_column: id
55
+ max_depth: 32
56
+ root_pii_columns:
57
+ email: HMAC
58
+ full_name: STATIC_MASK
59
+ satellite_targets:
60
+ - table: marketing_leads
61
+ lookup_column: email
62
+ action: redact
63
+ masking_rules:
64
+ email: HMAC
65
+ - table: audit_logs
66
+ lookup_column: user_identifier
67
+ action: hard_delete
68
+ blob_targets:
69
+ - table: users
70
+ column: kyc_document_url
71
+ provider: aws_s3
72
+ region: ap-south-1
73
+ action: versioned_hard_delete
74
+ retention_mode: governance
75
+ expected_bucket_owner: "123456789012"
76
+ purge_policy:
77
+ enabled: true
78
+ selector:
79
+ kind: boolean_column
80
+ column: purge_eligible
81
+ value: true
82
+ max_batch_size: 50000
83
+ actor_opaque_id: system:dpo-purge
84
+ legal_framework: DPDP_2023
85
+ legal_citation: "DPDP Act, 2023 Sec 12; client-approved purge schedule"
86
+ outbox:
87
+ batch_size: 20
88
+ lease_seconds: 90
89
+ max_attempts: 12
90
+ base_backoff_ms: 1500
91
+ security:
92
+ notification_lease_seconds: 180
93
+ master_key_env: DPDP_MASTER_KEY
94
+ hmac_key_env: DPDP_HMAC_KEY
95
+ integrity:
96
+ expected_schema_hash: "${"1".repeat(64)}"
97
+ legal_attestation:
98
+ dpo_identifier: "dpo-name@client.com"
99
+ configuration_version: "v1.2.0"
100
+ legal_review_date: "2026-04-20"
101
+ acknowledgment: "I confirm this configuration accurately reflects our obligations."
102
+ `);
103
+ pathsToDelete.push(path);
104
+
105
+ const config = await readWorkerConfig(
106
+ {
107
+ DPDP_MASTER_KEY: masterKeyHex,
108
+ DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
109
+ },
110
+ path
111
+ );
112
+
113
+ expect(config.database.app_schema).toBe("tenant_app");
114
+ expect(config.database.engine_schema).toBe("tenant_engine");
115
+ expect(config.database.replica_db_url).toBe("postgres://replica:replica@replica-host:5432/postgres");
116
+ expect(config.compliance_policy.default_retention_years).toBe(0);
117
+ expect(config.compliance_policy.notice_window_hours).toBe(72);
118
+ expect(config.compliance_policy.retention_rules).toEqual([
119
+ {
120
+ rule_name: "PMLA_FINANCIAL",
121
+ legal_citation: "Prevention of Money Laundering Act, 2002, Sec 12",
122
+ if_has_data_in: ["transactions"],
123
+ retention_years: 10,
124
+ },
125
+ {
126
+ rule_name: "RBI_KYC",
127
+ legal_citation: "RBI KYC Directions, 2016, Sec 38",
128
+ if_has_data_in: ["kyc_documents"],
129
+ retention_years: 5,
130
+ },
131
+ ]);
132
+ expect(config.graph.root_table).toBe("users");
133
+ expect(config.graph.root_id_column).toBe("id");
134
+ expect(config.graph.root_pii_columns).toEqual({
135
+ email: "HMAC",
136
+ full_name: "STATIC_MASK",
137
+ });
138
+ expect(config.satellite_targets).toHaveLength(2);
139
+ expect(config.blob_targets).toEqual([
140
+ {
141
+ table: "users",
142
+ column: "kyc_document_url",
143
+ lookup_column: undefined,
144
+ provider: "aws_s3",
145
+ region: "ap-south-1",
146
+ action: "versioned_hard_delete",
147
+ retention_mode: "governance",
148
+ expected_bucket_owner: "123456789012",
149
+ require_version_id: true,
150
+ masking_blob_path: undefined,
151
+ },
152
+ ]);
153
+ expect(config.purge_policy).toEqual({
154
+ enabled: true,
155
+ selector: {
156
+ kind: "boolean_column",
157
+ column: "purge_eligible",
158
+ value: true,
159
+ },
160
+ max_batch_size: 50000,
161
+ actor_opaque_id: "system:dpo-purge",
162
+ legal_framework: "DPDP_2023",
163
+ legal_citation: "DPDP Act, 2023 Sec 12; client-approved purge schedule",
164
+ });
165
+ expect(config.outbox.batch_size).toBe(20);
166
+ expect(config.security.notification_lease_seconds).toBe(180);
167
+ expect(config.legal_attestation).toEqual({
168
+ dpo_identifier: "dpo-name@client.com",
169
+ configuration_version: "v1.2.0",
170
+ legal_review_date: "2026-04-20",
171
+ acknowledgment: "I confirm this configuration accurately reflects our obligations.",
172
+ });
173
+ expect(Buffer.from(config.masterKey).toString("hex")).toBe(masterKeyHex);
174
+ expect(Buffer.from(config.hmacKey).toString("base64")).toBe(hmacKeyBase64);
175
+ });
176
+
177
+ it("fails closed when required compliance fields are null", async () => {
178
+ const path = await writeYaml(`
179
+ version: "1.0"
180
+ database:
181
+ app_schema: tenant_app
182
+ engine_schema: tenant_engine
183
+ compliance_policy:
184
+ default_retention_years: null
185
+ notice_window_hours: 48
186
+ retention_rules:
187
+ - rule_name: RBI_KYC
188
+ legal_citation: "RBI KYC Directions, 2016, Sec 38"
189
+ if_has_data_in:
190
+ - kyc_documents
191
+ retention_years: 5
192
+ graph:
193
+ root_table: users
194
+ root_id_column: id
195
+ max_depth: 32
196
+ root_pii_columns:
197
+ email: HMAC
198
+ satellite_targets:
199
+ - table: marketing_leads
200
+ lookup_column: email
201
+ action: redact
202
+ masking_rules:
203
+ email: HMAC
204
+ outbox:
205
+ batch_size: 10
206
+ lease_seconds: 60
207
+ max_attempts: 10
208
+ base_backoff_ms: 1000
209
+ security:
210
+ notification_lease_seconds: 120
211
+ master_key_env: DPDP_MASTER_KEY
212
+ hmac_key_env: DPDP_HMAC_KEY
213
+ integrity:
214
+ expected_schema_hash: "${"1".repeat(64)}"
215
+ legal_attestation:
216
+ dpo_identifier: "dpo-name@client.com"
217
+ configuration_version: "v1.2.0"
218
+ legal_review_date: "2026-04-20"
219
+ acknowledgment: "I confirm this configuration accurately reflects our obligations."
220
+ `);
221
+ pathsToDelete.push(path);
222
+
223
+ await expect(
224
+ readWorkerConfig(
225
+ {
226
+ DPDP_MASTER_KEY: masterKeyHex,
227
+ DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
228
+ },
229
+ path
230
+ )
231
+ ).rejects.toThrow(/default_retention_years/i);
232
+ });
233
+
234
+ it("rejects malicious identifier injection in root_table", async () => {
235
+ const path = await writeYaml(`
236
+ version: "1.0"
237
+ database:
238
+ app_schema: tenant_app
239
+ engine_schema: tenant_engine
240
+ compliance_policy:
241
+ default_retention_years: 0
242
+ notice_window_hours: 48
243
+ retention_rules:
244
+ - rule_name: RBI_KYC
245
+ legal_citation: "RBI KYC Directions, 2016, Sec 38"
246
+ if_has_data_in:
247
+ - kyc_documents
248
+ retention_years: 5
249
+ graph:
250
+ root_table: "users; DROP TABLE clients;--"
251
+ root_id_column: id
252
+ max_depth: 32
253
+ root_pii_columns:
254
+ email: HMAC
255
+ satellite_targets:
256
+ - table: marketing_leads
257
+ lookup_column: email
258
+ action: redact
259
+ masking_rules:
260
+ email: HMAC
261
+ outbox:
262
+ batch_size: 10
263
+ lease_seconds: 60
264
+ max_attempts: 10
265
+ base_backoff_ms: 1000
266
+ security:
267
+ notification_lease_seconds: 120
268
+ master_key_env: DPDP_MASTER_KEY
269
+ hmac_key_env: DPDP_HMAC_KEY
270
+ integrity:
271
+ expected_schema_hash: "${"1".repeat(64)}"
272
+ legal_attestation:
273
+ dpo_identifier: "dpo-name@client.com"
274
+ configuration_version: "v1.2.0"
275
+ legal_review_date: "2026-04-20"
276
+ acknowledgment: "I confirm this configuration accurately reflects our obligations."
277
+ `);
278
+ pathsToDelete.push(path);
279
+
280
+ await expect(
281
+ readWorkerConfig(
282
+ {
283
+ DPDP_MASTER_KEY: masterKeyHex,
284
+ DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
285
+ },
286
+ path
287
+ )
288
+ ).rejects.toThrow(/invalid graph root table/i);
289
+ });
290
+
291
+ it("fails closed when legal_attestation is missing", async () => {
292
+ const path = await writeYaml(`
293
+ version: "1.0"
294
+ database:
295
+ app_schema: tenant_app
296
+ engine_schema: tenant_engine
297
+ compliance_policy:
298
+ default_retention_years: 0
299
+ notice_window_hours: 48
300
+ retention_rules:
301
+ - rule_name: RBI_KYC
302
+ legal_citation: "RBI KYC Directions, 2016, Sec 38"
303
+ if_has_data_in:
304
+ - kyc_documents
305
+ retention_years: 5
306
+ graph:
307
+ root_table: users
308
+ root_id_column: id
309
+ max_depth: 32
310
+ root_pii_columns:
311
+ email: HMAC
312
+ satellite_targets:
313
+ - table: marketing_leads
314
+ lookup_column: email
315
+ action: redact
316
+ masking_rules:
317
+ email: HMAC
318
+ outbox:
319
+ batch_size: 10
320
+ lease_seconds: 60
321
+ max_attempts: 10
322
+ base_backoff_ms: 1000
323
+ security:
324
+ notification_lease_seconds: 120
325
+ master_key_env: DPDP_MASTER_KEY
326
+ hmac_key_env: DPDP_HMAC_KEY
327
+ integrity:
328
+ expected_schema_hash: "${"1".repeat(64)}"
329
+ `);
330
+ pathsToDelete.push(path);
331
+
332
+ await expect(
333
+ readWorkerConfig(
334
+ {
335
+ DPDP_MASTER_KEY: masterKeyHex,
336
+ DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
337
+ },
338
+ path
339
+ )
340
+ ).rejects.toThrow(/legal_attestation/i);
341
+ });
342
+
343
+ it("fails closed when purge automation is enabled without an attested selector", async () => {
344
+ const path = await writeYaml(`
345
+ version: "1.0"
346
+ database:
347
+ app_schema: tenant_app
348
+ engine_schema: tenant_engine
349
+ compliance_policy:
350
+ default_retention_years: 0
351
+ notice_window_hours: 48
352
+ retention_rules:
353
+ - rule_name: RBI_KYC
354
+ legal_citation: "RBI KYC Directions, 2016, Sec 38"
355
+ if_has_data_in:
356
+ - kyc_documents
357
+ retention_years: 5
358
+ graph:
359
+ root_table: users
360
+ root_id_column: id
361
+ max_depth: 32
362
+ root_pii_columns:
363
+ email: HMAC
364
+ purge_policy:
365
+ enabled: true
366
+ outbox:
367
+ batch_size: 10
368
+ lease_seconds: 60
369
+ max_attempts: 10
370
+ base_backoff_ms: 1000
371
+ security:
372
+ notification_lease_seconds: 120
373
+ master_key_env: DPDP_MASTER_KEY
374
+ hmac_key_env: DPDP_HMAC_KEY
375
+ integrity:
376
+ expected_schema_hash: "${"1".repeat(64)}"
377
+ legal_attestation:
378
+ dpo_identifier: "dpo-name@client.com"
379
+ configuration_version: "v1.2.0"
380
+ legal_review_date: "2026-04-20"
381
+ acknowledgment: "I confirm this configuration accurately reflects our obligations."
382
+ `);
383
+ pathsToDelete.push(path);
384
+
385
+ await expect(
386
+ readWorkerConfig(
387
+ {
388
+ DPDP_MASTER_KEY: masterKeyHex,
389
+ DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
390
+ },
391
+ path
392
+ )
393
+ ).rejects.toThrow(/purge_policy\.selector/i);
394
+ });
395
+ });
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createControlPlaneApiClient } from "@modules/network";
3
+
4
+ describe("Control Plane API client", () => {
5
+ it("accepts offset-form ISO timestamps in worker sync payloads", async () => {
6
+ const fetchMock = vi.fn(async () => ({
7
+ ok: true,
8
+ status: 200,
9
+ json: async () => ({
10
+ pending: true,
11
+ task: {
12
+ id: "task-notify-1",
13
+ task_type: "NOTIFY_USER",
14
+ payload: {
15
+ request_id: "01ce9849-189c-4c3d-ab91-b35eff852b9f",
16
+ subject_opaque_id: "usr_local_zero",
17
+ idempotency_key: "9943912a-1897-4860-ad9c-d32e9b3c2876",
18
+ trigger_source: "USER_CONSENT_WITHDRAWAL",
19
+ actor_opaque_id: "usr_local_zero",
20
+ legal_framework: "DPDP_2023",
21
+ request_timestamp: "2026-04-20T14:49:04.477+00:00",
22
+ cooldown_days: 0,
23
+ shadow_mode: false,
24
+ },
25
+ },
26
+ }),
27
+ }));
28
+
29
+ vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as any);
30
+
31
+ const client = createControlPlaneApiClient({
32
+ syncUrl: "https://control-plane.example/api/v1/worker/sync",
33
+ ackBaseUrl: "https://control-plane.example/api/v1/worker/tasks",
34
+ workerAuthHeaders: {
35
+ "x-client-id": "worker-1",
36
+ authorization: "Bearer worker-secret",
37
+ },
38
+ workerConfigHash: "ab".repeat(32),
39
+ workerConfigVersion: "v-test",
40
+ workerDpoIdentifier: "dpo@example.com",
41
+ pushOutboxEvent: async () => true,
42
+ });
43
+
44
+ const response = await client.syncTask();
45
+ expect(response).toEqual({
46
+ pending: true,
47
+ task: {
48
+ id: "task-notify-1",
49
+ task_type: "NOTIFY_USER",
50
+ payload: {
51
+ request_id: "01ce9849-189c-4c3d-ab91-b35eff852b9f",
52
+ subject_opaque_id: "usr_local_zero",
53
+ idempotency_key: "9943912a-1897-4860-ad9c-d32e9b3c2876",
54
+ trigger_source: "USER_CONSENT_WITHDRAWAL",
55
+ actor_opaque_id: "usr_local_zero",
56
+ legal_framework: "DPDP_2023",
57
+ request_timestamp: "2026-04-20T14:49:04.477+00:00",
58
+ cooldown_days: 0,
59
+ shadow_mode: false,
60
+ },
61
+ },
62
+ });
63
+
64
+ expect(globalThis.fetch).toHaveBeenCalledWith(
65
+ "https://control-plane.example/api/v1/worker/sync",
66
+ expect.objectContaining({
67
+ headers: expect.objectContaining({
68
+ "x-worker-config-hash": "ab".repeat(32),
69
+ "x-worker-config-version": "v-test",
70
+ "x-worker-dpo-identifier": "dpo@example.com",
71
+ }),
72
+ })
73
+ );
74
+ });
75
+
76
+ it("sends authenticated task heartbeat requests to extend long-running leases", async () => {
77
+ const fetchMock = vi.fn(async () => ({
78
+ ok: true,
79
+ status: 200,
80
+ json: async () => ({ ok: true }),
81
+ }));
82
+
83
+ vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as any);
84
+
85
+ const client = createControlPlaneApiClient({
86
+ syncUrl: "https://control-plane.example/api/v1/worker/sync",
87
+ ackBaseUrl: "https://control-plane.example/api/v1/worker/tasks",
88
+ workerAuthHeaders: {
89
+ "x-client-id": "worker-1",
90
+ authorization: "Bearer worker-secret",
91
+ },
92
+ workerConfigHash: "ab".repeat(32),
93
+ pushOutboxEvent: async () => true,
94
+ });
95
+
96
+ await expect(client.heartbeatTask?.("task-long-1")).resolves.toBe(true);
97
+ expect(globalThis.fetch).toHaveBeenCalledWith(
98
+ "https://control-plane.example/api/v1/worker/tasks/task-long-1/heartbeat",
99
+ expect.objectContaining({
100
+ method: "POST",
101
+ headers: expect.objectContaining({
102
+ "x-client-id": "worker-1",
103
+ authorization: "Bearer worker-secret",
104
+ }),
105
+ })
106
+ );
107
+ });
108
+ });
@@ -0,0 +1,106 @@
1
+ import { decryptGCM, decryptGCMBytes, encryptGCM, encryptGCMBytes, generateDEK, generateHMAC, unwrapKey, wrapKey } from "@modules/crypto";
2
+ import { describe, it, expect } from "vitest";
3
+
4
+
5
+ describe("Cryptographic Core (AES-256-GCM + Envelope + HMAC)", () => {
6
+ const KEK = new Uint8Array(32).fill(0x42); // Dummy Master Key
7
+ const rawPII = "User Email: john.doe@example.com, Phone: +91 9876543210";
8
+
9
+ describe("AES-256-GCM & Envelope Encryption", () => {
10
+ it("should successfully encrypt and decrypt PII using a unique DEK", async () => {
11
+ const userDEK = generateDEK();
12
+
13
+ // 1. Encrypt
14
+ const encryptedPII = await encryptGCM(rawPII, userDEK);
15
+ expect(encryptedPII.length).toBeGreaterThan(12 + 16); // IV (12) + Tag (16) + min 1 byte data
16
+
17
+ // 2. Decrypt
18
+ const decryptedPII = await decryptGCM(encryptedPII, userDEK);
19
+ expect(decryptedPII).toBe(rawPII);
20
+ });
21
+
22
+ it("should expose decrypted bytes for callers that need explicit memory wiping", async () => {
23
+ const userDEK = generateDEK();
24
+ const encryptedPII = await encryptGCM(rawPII, userDEK);
25
+
26
+ const decryptedBytes = await decryptGCMBytes(encryptedPII, userDEK);
27
+ expect(new TextDecoder().decode(decryptedBytes)).toBe(rawPII);
28
+
29
+ decryptedBytes.fill(0);
30
+ expect(Array.from(decryptedBytes).every((byte) => byte === 0)).toBe(true);
31
+ });
32
+
33
+ it("should successfully wrap and unwrap a DEK using a Master KEK", async () => {
34
+ const originalDEK = generateDEK();
35
+
36
+ // 1. Wrap
37
+ const wrappedDEK = await wrapKey(originalDEK, KEK);
38
+
39
+ // 2. Unwrap
40
+ const recoveredDEK = await unwrapKey(wrappedDEK, KEK);
41
+
42
+ expect(recoveredDEK).toEqual(originalDEK);
43
+ });
44
+
45
+ it("should fail decryption if the wrong KEK is used", async () => {
46
+ const originalDEK = generateDEK();
47
+ const wrappedDEK = await wrapKey(originalDEK, KEK);
48
+ const wrongKEK = new Uint8Array(32).fill(0x99);
49
+
50
+ await expect(unwrapKey(wrappedDEK, wrongKEK)).rejects.toThrow();
51
+ });
52
+
53
+ it("should fail decryption if the ciphertext is tampered with", async () => {
54
+ const userDEK = generateDEK();
55
+ const encryptedPII = await encryptGCM(rawPII, userDEK);
56
+
57
+ // Tamper with one byte in the middle of the ciphertext
58
+ if (encryptedPII[20] !== undefined) {
59
+ encryptedPII[20] ^= 0xFF;
60
+ }
61
+
62
+ await expect(decryptGCM(encryptedPII, userDEK)).rejects.toThrow();
63
+ });
64
+
65
+ it("should throw an error if encryptGCM is given an invalid key length", async () => {
66
+ const invalidKey = new Uint8Array(16); // 128-bit instead of 256-bit
67
+ await expect(encryptGCM(rawPII, invalidKey)).rejects.toThrow(/Invalid key length/);
68
+ });
69
+
70
+ it("should throw an error if decryptGCM is given an invalid key length", async () => {
71
+ const userDEK = generateDEK();
72
+ const encryptedPII = await encryptGCM(rawPII, userDEK);
73
+ const invalidKey = new Uint8Array(16);
74
+ await expect(decryptGCM(encryptedPII, invalidKey)).rejects.toThrow(/Invalid key length/);
75
+ });
76
+
77
+ it("should throw an error if decryptGCM is given a payload too short to be valid", async () => {
78
+ const userDEK = generateDEK();
79
+ const shortCiphertext = new Uint8Array(10); // Less than IV + Tag length
80
+ await expect(decryptGCM(shortCiphertext, userDEK)).rejects.toThrow(/Invalid ciphertext/);
81
+ });
82
+ });
83
+
84
+ describe("HMAC Pseudonymization", () => {
85
+ it("should generate a consistent hash for the same input and salt", async () => {
86
+ const salt = "somesalt123";
87
+ const hash1 = await generateHMAC(rawPII, salt);
88
+ const hash2 = await generateHMAC(rawPII, salt);
89
+ expect(hash1).toBe(hash2);
90
+ expect(hash1).toHaveLength(64); // SHA-256 hex length
91
+ });
92
+
93
+ it("should generate a different hash for the same input but different salt", async () => {
94
+ const hash1 = await generateHMAC(rawPII, "saltA");
95
+ const hash2 = await generateHMAC(rawPII, "saltB");
96
+ expect(hash1).not.toBe(hash2);
97
+ });
98
+
99
+ it("should generate a different hash for different inputs with the same salt", async () => {
100
+ const salt = "somesalt123";
101
+ const hash1 = await generateHMAC("user1@example.com", salt);
102
+ const hash2 = await generateHMAC("user2@example.com", salt);
103
+ expect(hash1).not.toBe(hash2);
104
+ });
105
+ });
106
+ });
@@ -0,0 +1,69 @@
1
+ import { asWorkerError, serializeWorkerError, workerError } from "@/errors";
2
+ import { describe, expect, it } from "vitest";
3
+ import { ZodError, z } from "zod";
4
+
5
+ describe("WorkerError normalization", () => {
6
+ it("preserves explicit worker error metadata in RFC-9457-style problem details", () => {
7
+ const error = workerError({
8
+ code: "TEST_EXPLICIT",
9
+ title: "Explicit worker error",
10
+ detail: "The worker emitted a classified error.",
11
+ category: "internal",
12
+ retryable: false,
13
+ fatal: true,
14
+ context: { component: "test" },
15
+ });
16
+
17
+ expect(serializeWorkerError(error, "worker:test")).toEqual({
18
+ type: "urn:dpdp:worker:error:test_explicit",
19
+ title: "Explicit worker error",
20
+ detail: "The worker emitted a classified error.",
21
+ code: "TEST_EXPLICIT",
22
+ category: "internal",
23
+ retryable: false,
24
+ fatal: true,
25
+ instance: "worker:test",
26
+ context: { component: "test" },
27
+ });
28
+ });
29
+
30
+ it("classifies transient postgres failures as retryable concurrency/database errors", () => {
31
+ const postgresError = Object.assign(new Error("deadlock detected"), {
32
+ code: "40P01",
33
+ });
34
+
35
+ const normalized = asWorkerError(postgresError);
36
+
37
+ expect(normalized.code).toBe("DB_DEADLOCK_DETECTED");
38
+ expect(normalized.category).toBe("concurrency");
39
+ expect(normalized.retryable).toBe(true);
40
+ expect(normalized.fatal).toBe(false);
41
+ });
42
+
43
+ it("converts zod validation failures into structured validation errors", () => {
44
+ let validationError: ZodError | null = null;
45
+
46
+ try {
47
+ z.object({ default_retention_years: z.number().int().min(0) }).parse({
48
+ default_retention_years: null,
49
+ });
50
+ } catch (error) {
51
+ validationError = error as ZodError;
52
+ }
53
+
54
+ const normalized = asWorkerError(validationError);
55
+
56
+ expect(normalized.code).toBe("VALIDATION_FAILED");
57
+ expect(normalized.category).toBe("validation");
58
+ expect(normalized.detail).toContain("default_retention_years");
59
+ expect(normalized.toProblem("worker:test").issues).toEqual([
60
+ {
61
+ path: "default_retention_years",
62
+ param: "default_retention_years",
63
+ code: "invalid_type",
64
+ message: "Invalid input: expected number, received null",
65
+ },
66
+ ]);
67
+ expect(normalized.retryable).toBe(false);
68
+ });
69
+ });