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.
- package/.env.example +55 -0
- package/Dockerfile +33 -0
- package/compliance.worker.yaml +64 -0
- package/package.json +41 -0
- package/src/constants/index.ts +1 -0
- package/src/errors/fail.ts +110 -0
- package/src/errors/index.ts +4 -0
- package/src/errors/inferer.ts +166 -0
- package/src/errors/registry.ts +122 -0
- package/src/errors/types.ts +65 -0
- package/src/errors/worker.ts +161 -0
- package/src/index.ts +328 -0
- package/src/lib/crypto/digest.ts +22 -0
- package/src/lib/crypto/encoding.ts +78 -0
- package/src/lib/crypto/index.ts +2 -0
- package/src/lib/index.ts +1 -0
- package/src/modules/bootstrap/index.ts +2 -0
- package/src/modules/bootstrap/integrity.ts +38 -0
- package/src/modules/bootstrap/preflight.ts +296 -0
- package/src/modules/cli/check-integrity.ts +48 -0
- package/src/modules/cli/dry-run.ts +90 -0
- package/src/modules/cli/graph.ts +87 -0
- package/src/modules/cli/index.ts +184 -0
- package/src/modules/cli/init.ts +115 -0
- package/src/modules/cli/inspect.ts +86 -0
- package/src/modules/cli/introspector.ts +117 -0
- package/src/modules/cli/keygen.ts +38 -0
- package/src/modules/cli/scan.ts +126 -0
- package/src/modules/cli/sign.ts +50 -0
- package/src/modules/cli/ui.ts +61 -0
- package/src/modules/cli/verify-schema.ts +31 -0
- package/src/modules/cli/verify.ts +85 -0
- package/src/modules/config/compatibility.ts +271 -0
- package/src/modules/config/index.ts +4 -0
- package/src/modules/config/reader.ts +149 -0
- package/src/modules/config/signature.ts +69 -0
- package/src/modules/config/validation.ts +658 -0
- package/src/modules/crypto/aes.ts +158 -0
- package/src/modules/crypto/envelope.ts +48 -0
- package/src/modules/crypto/hmac.ts +60 -0
- package/src/modules/crypto/index.ts +3 -0
- package/src/modules/db/drift.ts +36 -0
- package/src/modules/db/graph.ts +203 -0
- package/src/modules/db/index.ts +4 -0
- package/src/modules/db/migrations.ts +254 -0
- package/src/modules/db/sql-debug.ts +61 -0
- package/src/modules/engine/blob/index.ts +3 -0
- package/src/modules/engine/blob/s3.ts +455 -0
- package/src/modules/engine/blob/store.ts +236 -0
- package/src/modules/engine/blob/types.ts +44 -0
- package/src/modules/engine/helpers/identity.ts +47 -0
- package/src/modules/engine/helpers/index.ts +4 -0
- package/src/modules/engine/helpers/outbox.ts +118 -0
- package/src/modules/engine/helpers/runtime.ts +115 -0
- package/src/modules/engine/helpers/types.ts +61 -0
- package/src/modules/engine/index.ts +6 -0
- package/src/modules/engine/notifier/config.ts +147 -0
- package/src/modules/engine/notifier/dispatcher.ts +300 -0
- package/src/modules/engine/notifier/index.ts +3 -0
- package/src/modules/engine/notifier/payload.ts +51 -0
- package/src/modules/engine/notifier/reservation.ts +153 -0
- package/src/modules/engine/notifier/types.ts +38 -0
- package/src/modules/engine/shredder.ts +254 -0
- package/src/modules/engine/types.ts +146 -0
- package/src/modules/engine/vault/compiled-targets.ts +562 -0
- package/src/modules/engine/vault/context.ts +254 -0
- package/src/modules/engine/vault/dry-run.ts +94 -0
- package/src/modules/engine/vault/execution.ts +485 -0
- package/src/modules/engine/vault/index.ts +3 -0
- package/src/modules/engine/vault/purge.ts +82 -0
- package/src/modules/engine/vault/retention.ts +124 -0
- package/src/modules/engine/vault/satellite-mutation.ts +193 -0
- package/src/modules/engine/vault/satellite.ts +103 -0
- package/src/modules/engine/vault/shadow.ts +36 -0
- package/src/modules/engine/vault/static-plan.ts +116 -0
- package/src/modules/engine/vault/store.ts +34 -0
- package/src/modules/engine/vault/vault.ts +84 -0
- package/src/modules/introspector/classifier.ts +502 -0
- package/src/modules/introspector/dag.ts +276 -0
- package/src/modules/introspector/index.ts +7 -0
- package/src/modules/introspector/naming.ts +75 -0
- package/src/modules/introspector/report.ts +153 -0
- package/src/modules/introspector/run.ts +123 -0
- package/src/modules/introspector/s3-sampler.ts +227 -0
- package/src/modules/introspector/types.ts +131 -0
- package/src/modules/introspector/yaml.ts +101 -0
- package/src/modules/network/api/control-plane.ts +275 -0
- package/src/modules/network/api/index.ts +1 -0
- package/src/modules/network/api/validation.ts +71 -0
- package/src/modules/network/index.ts +4 -0
- package/src/modules/network/object-store/aws/client.ts +444 -0
- package/src/modules/network/object-store/aws/credentials.ts +271 -0
- package/src/modules/network/object-store/aws/index.ts +2 -0
- package/src/modules/network/object-store/aws/sigv4.ts +190 -0
- package/src/modules/network/object-store/aws/type.ts +6 -0
- package/src/modules/network/object-store/index.ts +1 -0
- package/src/modules/network/outbox/dispatcher.ts +183 -0
- package/src/modules/network/outbox/index.ts +3 -0
- package/src/modules/network/outbox/process.ts +133 -0
- package/src/modules/network/outbox/shared.ts +56 -0
- package/src/modules/network/outbox/store.ts +346 -0
- package/src/modules/network/outbox/types.ts +54 -0
- package/src/modules/network/request-signing.ts +61 -0
- package/src/modules/worker/index.ts +2 -0
- package/src/modules/worker/tasks.ts +58 -0
- package/src/modules/worker/types.ts +89 -0
- package/src/modules/worker/worker.ts +243 -0
- package/src/secrets/index.ts +4 -0
- package/src/secrets/kms/index.ts +2 -0
- package/src/secrets/kms/signature.ts +82 -0
- package/src/secrets/kms/validation.ts +64 -0
- package/src/secrets/reader.ts +42 -0
- package/src/secrets/repository/crypto.ts +89 -0
- package/src/secrets/repository/index.ts +2 -0
- package/src/secrets/repository/methods.ts +37 -0
- package/src/secrets/resolvers.ts +247 -0
- package/src/secrets/signature.ts +78 -0
- package/src/types/index.ts +1 -0
- package/src/types/types.ts +23 -0
- package/src/utils/identifiers.ts +48 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/json.ts +35 -0
- package/src/utils/logger.ts +161 -0
- package/src/validation/zod.ts +70 -0
- package/tests/adversarial.test.ts +464 -0
- package/tests/blob-s3.test.ts +216 -0
- package/tests/config.test.ts +395 -0
- package/tests/control-plane-client.test.ts +108 -0
- package/tests/crypto.test.ts +106 -0
- package/tests/errors.test.ts +69 -0
- package/tests/fetch-dispatcher.test.ts +213 -0
- package/tests/graph.test.ts +84 -0
- package/tests/helpers/index.ts +101 -0
- package/tests/index-preflight.test.ts +168 -0
- package/tests/introspector-classifier.test.ts +62 -0
- package/tests/introspector-report.test.ts +85 -0
- package/tests/introspector.test.ts +394 -0
- package/tests/kms.test.ts +124 -0
- package/tests/logger.test.ts +61 -0
- package/tests/notifier.test.ts +303 -0
- package/tests/outbox.test.ts +478 -0
- package/tests/purge-policy.test.ts +124 -0
- package/tests/retention.test.ts +103 -0
- package/tests/s3-client.test.ts +110 -0
- package/tests/satellite.test.ts +119 -0
- package/tests/schema-compatibility.test.ts +237 -0
- package/tests/schema-integrity.test.ts +64 -0
- package/tests/shredder.test.ts +163 -0
- package/tests/vault.compiled-targets.test.ts +243 -0
- package/tests/vault.replica.test.ts +59 -0
- package/tests/vault.test.ts +279 -0
- package/tests/worker.retry.test.ts +291 -0
- package/tests/worker.test.ts +200 -0
- package/tsconfig.json +19 -0
- 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
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -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
|
+
});
|