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,478 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import type { Sql } from "@/types";
|
|
3
|
+
import { workerError } from "@/errors";
|
|
4
|
+
import { createTestSql, dropSchemas, uniqueSchema } from "./helpers";
|
|
5
|
+
import { runMigrations } from "@modules/db";
|
|
6
|
+
import { processOutbox, type OutboxEvent } from "@modules/network";
|
|
7
|
+
import { enqueueOutboxEvent } from "@modules/engine";
|
|
8
|
+
import { calculateRetryDelayMs } from "@modules/network/outbox/shared";
|
|
9
|
+
|
|
10
|
+
describe("Network Outbox Relay", () => {
|
|
11
|
+
let sql: Sql;
|
|
12
|
+
const schemasToDrop: string[] = [];
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
sql = createTestSql();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterAll(async () => {
|
|
19
|
+
await dropSchemas(sql, ...schemasToDrop);
|
|
20
|
+
await sql.end();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
async function prepare() {
|
|
24
|
+
const engineSchema = uniqueSchema("outbox_engine");
|
|
25
|
+
schemasToDrop.push(engineSchema);
|
|
26
|
+
await dropSchemas(sql, engineSchema);
|
|
27
|
+
await runMigrations(sql, engineSchema);
|
|
28
|
+
return { engineSchema };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toHex(buffer: ArrayBuffer): string {
|
|
32
|
+
return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function seedEvent(
|
|
36
|
+
engineSchema: string,
|
|
37
|
+
idempotencyKey: string,
|
|
38
|
+
userHash: string,
|
|
39
|
+
eventType: string,
|
|
40
|
+
nextAttemptAt: Date = new Date(),
|
|
41
|
+
createdAt: Date = new Date(),
|
|
42
|
+
previousHash: string = "GENESIS"
|
|
43
|
+
) {
|
|
44
|
+
await sql`
|
|
45
|
+
INSERT INTO ${sql(engineSchema)}.outbox (
|
|
46
|
+
idempotency_key,
|
|
47
|
+
user_uuid_hash,
|
|
48
|
+
event_type,
|
|
49
|
+
payload,
|
|
50
|
+
previous_hash,
|
|
51
|
+
current_hash,
|
|
52
|
+
status,
|
|
53
|
+
attempt_count,
|
|
54
|
+
next_attempt_at,
|
|
55
|
+
created_at,
|
|
56
|
+
updated_at
|
|
57
|
+
)
|
|
58
|
+
VALUES (
|
|
59
|
+
${idempotencyKey},
|
|
60
|
+
${userHash},
|
|
61
|
+
${eventType},
|
|
62
|
+
'{}'::jsonb,
|
|
63
|
+
${previousHash},
|
|
64
|
+
${`${idempotencyKey}-hash`},
|
|
65
|
+
'pending',
|
|
66
|
+
0,
|
|
67
|
+
${nextAttemptAt},
|
|
68
|
+
${createdAt},
|
|
69
|
+
${createdAt}
|
|
70
|
+
)
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
it("processes due events and marks them as processed", async () => {
|
|
75
|
+
const { engineSchema } = await prepare();
|
|
76
|
+
const baseTime = new Date("2026-04-15T00:00:00.000Z");
|
|
77
|
+
const laterTime = new Date("2026-04-15T00:00:01.000Z");
|
|
78
|
+
await seedEvent(engineSchema, "event-1", "user1", "TEST_EVENT", baseTime, baseTime);
|
|
79
|
+
await seedEvent(engineSchema, "event-2", "user2", "TEST_EVENT", baseTime, laterTime, "event-1-hash");
|
|
80
|
+
|
|
81
|
+
const result = await processOutbox(sql, async () => true, { engineSchema, batchSize: 10 });
|
|
82
|
+
expect(result).toEqual({
|
|
83
|
+
claimed: 2,
|
|
84
|
+
processed: 2,
|
|
85
|
+
failed: 0,
|
|
86
|
+
deadLettered: 0,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const rows = await sql`SELECT status, processed_at FROM ${sql(engineSchema)}.outbox ORDER BY idempotency_key ASC`;
|
|
90
|
+
expect(rows.every((row) => row.status === "processed")).toBe(true);
|
|
91
|
+
expect(rows.every((row) => row.processed_at !== null)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("preserves WORM order instead of prioritizing terminal events ahead of predecessors", async () => {
|
|
95
|
+
const { engineSchema } = await prepare();
|
|
96
|
+
const baseTime = new Date("2026-04-15T00:00:00.000Z");
|
|
97
|
+
const laterTime = new Date("2026-04-15T00:00:01.000Z");
|
|
98
|
+
|
|
99
|
+
await seedEvent(
|
|
100
|
+
engineSchema,
|
|
101
|
+
"vault-old",
|
|
102
|
+
"user-old",
|
|
103
|
+
"USER_VAULTED",
|
|
104
|
+
baseTime,
|
|
105
|
+
baseTime
|
|
106
|
+
);
|
|
107
|
+
await seedEvent(
|
|
108
|
+
engineSchema,
|
|
109
|
+
"shred-new",
|
|
110
|
+
"user-new",
|
|
111
|
+
"SHRED_SUCCESS",
|
|
112
|
+
baseTime,
|
|
113
|
+
laterTime,
|
|
114
|
+
"vault-old-hash"
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const delivered: string[] = [];
|
|
118
|
+
const result = await processOutbox(
|
|
119
|
+
sql,
|
|
120
|
+
async (event) => {
|
|
121
|
+
delivered.push(event.idempotency_key);
|
|
122
|
+
return true;
|
|
123
|
+
},
|
|
124
|
+
{ engineSchema, batchSize: 2, now: laterTime }
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(result.processed).toBe(2);
|
|
128
|
+
expect(delivered).toEqual(["vault-old", "shred-new"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("chains outbox events with tamper-evident hashes", async () => {
|
|
132
|
+
const { engineSchema } = await prepare();
|
|
133
|
+
const now = new Date("2026-04-15T00:00:00.000Z");
|
|
134
|
+
const next = new Date("2026-04-15T00:00:01.000Z");
|
|
135
|
+
|
|
136
|
+
await sql.begin((tx) =>
|
|
137
|
+
enqueueOutboxEvent(
|
|
138
|
+
tx,
|
|
139
|
+
engineSchema,
|
|
140
|
+
"user-hash-1",
|
|
141
|
+
"USER_VAULTED",
|
|
142
|
+
{ rootId: "1", state: "vaulted" },
|
|
143
|
+
"vault:tenant:users:1",
|
|
144
|
+
now
|
|
145
|
+
)
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await sql.begin((tx) =>
|
|
149
|
+
enqueueOutboxEvent(
|
|
150
|
+
tx,
|
|
151
|
+
engineSchema,
|
|
152
|
+
"user-hash-2",
|
|
153
|
+
"NOTIFICATION_SENT",
|
|
154
|
+
{ rootId: "2", state: "notified" },
|
|
155
|
+
"notice:tenant:users:2",
|
|
156
|
+
next
|
|
157
|
+
)
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
await processOutbox(sql, async () => true, { engineSchema, batchSize: 10, now: next });
|
|
161
|
+
|
|
162
|
+
const expectedFirstBuffer = await globalThis.crypto.subtle.digest(
|
|
163
|
+
"SHA-256",
|
|
164
|
+
new TextEncoder().encode(
|
|
165
|
+
`GENESIS${JSON.stringify({ rootId: "1", state: "vaulted" })}vault:tenant:users:1`
|
|
166
|
+
)
|
|
167
|
+
);
|
|
168
|
+
const expectedFirstHash = toHex(expectedFirstBuffer);
|
|
169
|
+
const expectedSecondBuffer = await globalThis.crypto.subtle.digest(
|
|
170
|
+
"SHA-256",
|
|
171
|
+
new TextEncoder().encode(
|
|
172
|
+
`${expectedFirstHash}${JSON.stringify({ rootId: "2", state: "notified" })}notice:tenant:users:2`
|
|
173
|
+
)
|
|
174
|
+
);
|
|
175
|
+
const expectedSecondHash = toHex(expectedSecondBuffer);
|
|
176
|
+
|
|
177
|
+
const rows = await sql`
|
|
178
|
+
SELECT idempotency_key, previous_hash, current_hash, chain_status
|
|
179
|
+
FROM ${sql(engineSchema)}.outbox
|
|
180
|
+
ORDER BY created_at ASC, id ASC
|
|
181
|
+
`;
|
|
182
|
+
|
|
183
|
+
expect(rows).toEqual([
|
|
184
|
+
{
|
|
185
|
+
idempotency_key: "vault:tenant:users:1",
|
|
186
|
+
previous_hash: "GENESIS",
|
|
187
|
+
current_hash: expectedFirstHash,
|
|
188
|
+
chain_status: "finalized",
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
idempotency_key: "notice:tenant:users:2",
|
|
192
|
+
previous_hash: expectedFirstHash,
|
|
193
|
+
current_hash: expectedSecondHash,
|
|
194
|
+
chain_status: "finalized",
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("returns the existing finalized row on idempotent replay without mutating the chain", async () => {
|
|
200
|
+
const { engineSchema } = await prepare();
|
|
201
|
+
const now = new Date("2026-04-15T00:00:00.000Z");
|
|
202
|
+
|
|
203
|
+
const first = await sql.begin((tx) =>
|
|
204
|
+
enqueueOutboxEvent(
|
|
205
|
+
tx,
|
|
206
|
+
engineSchema,
|
|
207
|
+
"user-hash-1",
|
|
208
|
+
"USER_VAULTED",
|
|
209
|
+
{ rootId: "1", state: "vaulted" },
|
|
210
|
+
"vault:tenant:users:1",
|
|
211
|
+
now
|
|
212
|
+
)
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
await processOutbox(sql, async () => true, { engineSchema, batchSize: 10, now });
|
|
216
|
+
|
|
217
|
+
const replay = await sql.begin((tx) =>
|
|
218
|
+
enqueueOutboxEvent(
|
|
219
|
+
tx,
|
|
220
|
+
engineSchema,
|
|
221
|
+
"user-hash-1",
|
|
222
|
+
"USER_VAULTED",
|
|
223
|
+
{ rootId: "1", state: "mutated" },
|
|
224
|
+
"vault:tenant:users:1",
|
|
225
|
+
new Date("2026-04-15T00:00:30.000Z")
|
|
226
|
+
)
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
expect(replay.id).toBe(first.id);
|
|
230
|
+
expect(replay.previous_hash).toBe("GENESIS");
|
|
231
|
+
expect(replay.chain_status).toBe("finalized");
|
|
232
|
+
|
|
233
|
+
const rows = await sql`
|
|
234
|
+
SELECT payload, previous_hash, current_hash, chain_status
|
|
235
|
+
FROM ${sql(engineSchema)}.outbox
|
|
236
|
+
WHERE idempotency_key = 'vault:tenant:users:1'
|
|
237
|
+
`;
|
|
238
|
+
|
|
239
|
+
expect(rows).toHaveLength(1);
|
|
240
|
+
expect(rows[0]?.payload).toEqual({ rootId: "1", state: "vaulted" });
|
|
241
|
+
expect(rows[0]?.previous_hash).toBe("GENESIS");
|
|
242
|
+
expect(rows[0]?.current_hash).toBe(replay.current_hash);
|
|
243
|
+
expect(rows[0]?.chain_status).toBe("finalized");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("produces identical hashes for semantically equivalent payloads with different key order", async () => {
|
|
247
|
+
const { engineSchema } = await prepare();
|
|
248
|
+
const now = new Date("2026-04-15T00:00:00.000Z");
|
|
249
|
+
|
|
250
|
+
const first = await sql.begin((tx) =>
|
|
251
|
+
enqueueOutboxEvent(
|
|
252
|
+
tx,
|
|
253
|
+
engineSchema,
|
|
254
|
+
"user-hash-1",
|
|
255
|
+
"USER_VAULTED",
|
|
256
|
+
{ b: "second", a: "first", nested: { y: 2, x: 1 } },
|
|
257
|
+
"vault:tenant:users:ordered",
|
|
258
|
+
now
|
|
259
|
+
)
|
|
260
|
+
);
|
|
261
|
+
await processOutbox(sql, async () => true, { engineSchema, batchSize: 10, now });
|
|
262
|
+
const replay = await sql.begin((tx) =>
|
|
263
|
+
enqueueOutboxEvent(
|
|
264
|
+
tx,
|
|
265
|
+
engineSchema,
|
|
266
|
+
"user-hash-1",
|
|
267
|
+
"USER_VAULTED",
|
|
268
|
+
{ nested: { x: 1, y: 2 }, a: "first", b: "second" },
|
|
269
|
+
"vault:tenant:users:ordered",
|
|
270
|
+
new Date("2026-04-15T00:00:30.000Z")
|
|
271
|
+
)
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
expect(replay.id).toBe(first.id);
|
|
275
|
+
expect(replay.chain_status).toBe("finalized");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("requeues failed events with backoff and error context", async () => {
|
|
279
|
+
const { engineSchema } = await prepare();
|
|
280
|
+
const now = new Date("2026-04-15T00:00:00.000Z");
|
|
281
|
+
await seedEvent(engineSchema, "event-3", "user3", "FAIL_EVENT", now);
|
|
282
|
+
|
|
283
|
+
const result = await processOutbox(
|
|
284
|
+
sql,
|
|
285
|
+
async () => {
|
|
286
|
+
throw new Error("Network Error");
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
engineSchema,
|
|
290
|
+
batchSize: 10,
|
|
291
|
+
now,
|
|
292
|
+
baseBackoffMs: 500,
|
|
293
|
+
maxAttempts: 3,
|
|
294
|
+
}
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
expect(result).toEqual({
|
|
298
|
+
claimed: 1,
|
|
299
|
+
processed: 0,
|
|
300
|
+
failed: 1,
|
|
301
|
+
deadLettered: 0,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const [row] = await sql`
|
|
305
|
+
SELECT status, attempt_count, next_attempt_at, last_error
|
|
306
|
+
FROM ${sql(engineSchema)}.outbox
|
|
307
|
+
WHERE idempotency_key = 'event-3'
|
|
308
|
+
`;
|
|
309
|
+
|
|
310
|
+
expect(row?.status).toBe("pending");
|
|
311
|
+
expect(row?.attempt_count).toBe(1);
|
|
312
|
+
expect(row?.last_error).toContain("Network Error");
|
|
313
|
+
expect(new Date(row!.next_attempt_at).getTime()).toBe(now.getTime() + calculateRetryDelayMs(1, 500));
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("moves an event to dead_letter after the maximum retry count is reached", async () => {
|
|
317
|
+
const { engineSchema } = await prepare();
|
|
318
|
+
await seedEvent(engineSchema, "event-4", "user4", "FAIL_EVENT");
|
|
319
|
+
|
|
320
|
+
const result = await processOutbox(
|
|
321
|
+
sql,
|
|
322
|
+
async () => {
|
|
323
|
+
throw new Error("Permanent Failure");
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
engineSchema,
|
|
327
|
+
batchSize: 10,
|
|
328
|
+
maxAttempts: 1,
|
|
329
|
+
}
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
expect(result).toEqual({
|
|
333
|
+
claimed: 1,
|
|
334
|
+
processed: 0,
|
|
335
|
+
failed: 1,
|
|
336
|
+
deadLettered: 1,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const [row] = await sql`
|
|
340
|
+
SELECT status, attempt_count, last_error
|
|
341
|
+
FROM ${sql(engineSchema)}.outbox
|
|
342
|
+
WHERE idempotency_key = 'event-4'
|
|
343
|
+
`;
|
|
344
|
+
|
|
345
|
+
expect(row?.status).toBe("dead_letter");
|
|
346
|
+
expect(row?.attempt_count).toBe(1);
|
|
347
|
+
expect(row?.last_error).toContain("Permanent Failure");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("releases the lease and rethrows fatal delivery errors without burning retry attempts", async () => {
|
|
351
|
+
const { engineSchema } = await prepare();
|
|
352
|
+
await seedEvent(engineSchema, "event-fatal", "user-fatal", "AUTH_FAIL");
|
|
353
|
+
|
|
354
|
+
await expect(
|
|
355
|
+
processOutbox(
|
|
356
|
+
sql,
|
|
357
|
+
async () => {
|
|
358
|
+
throw workerError({
|
|
359
|
+
code: "OUTBOX_AUTH_REJECTED",
|
|
360
|
+
title: "Control Plane authentication rejected outbox event",
|
|
361
|
+
detail: "Brain API responded with HTTP 401.",
|
|
362
|
+
category: "configuration",
|
|
363
|
+
retryable: false,
|
|
364
|
+
fatal: true,
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
engineSchema,
|
|
369
|
+
batchSize: 10,
|
|
370
|
+
}
|
|
371
|
+
)
|
|
372
|
+
).rejects.toMatchObject({
|
|
373
|
+
code: "OUTBOX_AUTH_REJECTED",
|
|
374
|
+
fatal: true,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const [row] = await sql`
|
|
378
|
+
SELECT status, attempt_count, lease_token, lease_expires_at, last_error
|
|
379
|
+
FROM ${sql(engineSchema)}.outbox
|
|
380
|
+
WHERE idempotency_key = 'event-fatal'
|
|
381
|
+
`;
|
|
382
|
+
|
|
383
|
+
expect(row?.status).toBe("pending");
|
|
384
|
+
expect(row?.attempt_count).toBe(0);
|
|
385
|
+
expect(row?.lease_token).toBeNull();
|
|
386
|
+
expect(row?.lease_expires_at).toBeNull();
|
|
387
|
+
expect(row?.last_error).toContain("HTTP 401");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("handles concurrent workers without duplicating event delivery", async () => {
|
|
391
|
+
const { engineSchema } = await prepare();
|
|
392
|
+
await enqueueOutboxEvent(sql, engineSchema, "user5", "CONCURRENT", {}, "event-5", new Date());
|
|
393
|
+
await enqueueOutboxEvent(sql, engineSchema, "user6", "CONCURRENT", {}, "event-6", new Date());
|
|
394
|
+
await enqueueOutboxEvent(sql, engineSchema, "user7", "CONCURRENT", {}, "event-7", new Date());
|
|
395
|
+
|
|
396
|
+
const deliveredIds = new Set<string>();
|
|
397
|
+
|
|
398
|
+
const syncFn = async (event: OutboxEvent) => {
|
|
399
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
400
|
+
if (deliveredIds.has(event.id)) {
|
|
401
|
+
throw new Error(`Duplicate delivery for ${event.id}`);
|
|
402
|
+
}
|
|
403
|
+
deliveredIds.add(event.id);
|
|
404
|
+
return true;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
let processed = 0;
|
|
408
|
+
for (let round = 0; round < 10 && processed < 3; round += 1) {
|
|
409
|
+
const results = await Promise.all([
|
|
410
|
+
processOutbox(sql, syncFn, { engineSchema, batchSize: 1 }),
|
|
411
|
+
processOutbox(sql, syncFn, { engineSchema, batchSize: 1 }),
|
|
412
|
+
processOutbox(sql, syncFn, { engineSchema, batchSize: 1 }),
|
|
413
|
+
]);
|
|
414
|
+
processed += results.reduce((sum, result) => sum + result.processed, 0);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
expect(processed).toBe(3);
|
|
418
|
+
expect(deliveredIds.size).toBe(3);
|
|
419
|
+
|
|
420
|
+
const forkedLinks = await sql`
|
|
421
|
+
SELECT previous_hash, COUNT(*)::INT AS count
|
|
422
|
+
FROM ${sql(engineSchema)}.outbox
|
|
423
|
+
WHERE status = 'processed'
|
|
424
|
+
GROUP BY previous_hash
|
|
425
|
+
HAVING COUNT(*) > 1
|
|
426
|
+
`;
|
|
427
|
+
expect(forkedLinks).toHaveLength(0);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("reclaims an event whose lease has already expired", async () => {
|
|
431
|
+
const { engineSchema } = await prepare();
|
|
432
|
+
|
|
433
|
+
await sql`
|
|
434
|
+
INSERT INTO ${sql(engineSchema)}.outbox (
|
|
435
|
+
idempotency_key,
|
|
436
|
+
user_uuid_hash,
|
|
437
|
+
event_type,
|
|
438
|
+
payload,
|
|
439
|
+
previous_hash,
|
|
440
|
+
current_hash,
|
|
441
|
+
status,
|
|
442
|
+
attempt_count,
|
|
443
|
+
lease_token,
|
|
444
|
+
lease_expires_at,
|
|
445
|
+
next_attempt_at,
|
|
446
|
+
created_at,
|
|
447
|
+
updated_at
|
|
448
|
+
)
|
|
449
|
+
VALUES (
|
|
450
|
+
'event-8',
|
|
451
|
+
'user8',
|
|
452
|
+
'LEASED_EVENT',
|
|
453
|
+
'{}'::jsonb,
|
|
454
|
+
'GENESIS',
|
|
455
|
+
'event-8-hash',
|
|
456
|
+
'leased',
|
|
457
|
+
2,
|
|
458
|
+
gen_random_uuid(),
|
|
459
|
+
NOW() - INTERVAL '10 minutes',
|
|
460
|
+
NOW() - INTERVAL '10 minutes',
|
|
461
|
+
NOW(),
|
|
462
|
+
NOW()
|
|
463
|
+
)
|
|
464
|
+
`;
|
|
465
|
+
|
|
466
|
+
const result = await processOutbox(sql, async () => true, { engineSchema, batchSize: 10 });
|
|
467
|
+
expect(result.processed).toBe(1);
|
|
468
|
+
|
|
469
|
+
const [row] = await sql`
|
|
470
|
+
SELECT status, processed_at
|
|
471
|
+
FROM ${sql(engineSchema)}.outbox
|
|
472
|
+
WHERE idempotency_key = 'event-8'
|
|
473
|
+
`;
|
|
474
|
+
|
|
475
|
+
expect(row?.status).toBe("processed");
|
|
476
|
+
expect(row?.processed_at).toBeDefined();
|
|
477
|
+
});
|
|
478
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { selectPurgeCandidates } from "@modules/engine/vault/purge";
|
|
3
|
+
import { createTestSql, dropSchemas, uniqueSchema } from "./helpers";
|
|
4
|
+
import type { Sql } from "@/types";
|
|
5
|
+
|
|
6
|
+
describe("DPO-attested purge policy", () => {
|
|
7
|
+
let sql: Sql;
|
|
8
|
+
const schemasToDrop: string[] = [];
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
sql = createTestSql();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterAll(async () => {
|
|
15
|
+
await dropSchemas(sql, ...schemasToDrop);
|
|
16
|
+
await sql.end();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function createUsers(schema: string): Promise<void> {
|
|
20
|
+
schemasToDrop.push(schema);
|
|
21
|
+
await dropSchemas(sql, schema);
|
|
22
|
+
await sql`CREATE SCHEMA ${sql(schema)}`;
|
|
23
|
+
await sql`
|
|
24
|
+
CREATE TABLE ${sql(schema)}.users (
|
|
25
|
+
id TEXT PRIMARY KEY,
|
|
26
|
+
purge_eligible BOOLEAN NOT NULL DEFAULT FALSE,
|
|
27
|
+
account_state TEXT NOT NULL DEFAULT 'active',
|
|
28
|
+
disabled_at TIMESTAMPTZ
|
|
29
|
+
)
|
|
30
|
+
`;
|
|
31
|
+
await sql`
|
|
32
|
+
INSERT INTO ${sql(schema)}.users (id, purge_eligible, account_state, disabled_at)
|
|
33
|
+
VALUES
|
|
34
|
+
('usr_001', true, 'deleted', '2026-01-01T00:00:00.000Z'),
|
|
35
|
+
('usr_002', false, 'active', NULL),
|
|
36
|
+
('usr_003', true, 'deleted', '2026-01-02T00:00:00.000Z'),
|
|
37
|
+
('usr_004', false, 'closed', '2026-04-01T00:00:00.000Z')
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
it("selects purge candidates only through the configured boolean selector", async () => {
|
|
42
|
+
const schema = uniqueSchema("purge_bool");
|
|
43
|
+
await createUsers(schema);
|
|
44
|
+
|
|
45
|
+
await expect(
|
|
46
|
+
selectPurgeCandidates(sql, {
|
|
47
|
+
appSchema: schema,
|
|
48
|
+
rootTable: "users",
|
|
49
|
+
rootIdColumn: "id",
|
|
50
|
+
purgePolicy: {
|
|
51
|
+
enabled: true,
|
|
52
|
+
selector: {
|
|
53
|
+
kind: "boolean_column",
|
|
54
|
+
column: "purge_eligible",
|
|
55
|
+
value: true,
|
|
56
|
+
},
|
|
57
|
+
max_batch_size: 100,
|
|
58
|
+
actor_opaque_id: "system:purge",
|
|
59
|
+
legal_framework: "DPDP_2023",
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
).resolves.toEqual(["usr_001", "usr_003"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("supports enum and timestamp selectors without scanning unbounded rows in application code", async () => {
|
|
66
|
+
const schema = uniqueSchema("purge_selectors");
|
|
67
|
+
await createUsers(schema);
|
|
68
|
+
|
|
69
|
+
const enumCandidates = await selectPurgeCandidates(sql, {
|
|
70
|
+
appSchema: schema,
|
|
71
|
+
rootTable: "users",
|
|
72
|
+
rootIdColumn: "id",
|
|
73
|
+
purgePolicy: {
|
|
74
|
+
enabled: true,
|
|
75
|
+
selector: {
|
|
76
|
+
kind: "enum_column",
|
|
77
|
+
column: "account_state",
|
|
78
|
+
values: ["closed", "deleted"],
|
|
79
|
+
},
|
|
80
|
+
max_batch_size: 2,
|
|
81
|
+
actor_opaque_id: "system:purge",
|
|
82
|
+
legal_framework: "DPDP_2023",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
expect(enumCandidates).toEqual(["usr_001", "usr_003"]);
|
|
86
|
+
|
|
87
|
+
const timestampCandidates = await selectPurgeCandidates(sql, {
|
|
88
|
+
appSchema: schema,
|
|
89
|
+
rootTable: "users",
|
|
90
|
+
rootIdColumn: "id",
|
|
91
|
+
purgePolicy: {
|
|
92
|
+
enabled: true,
|
|
93
|
+
selector: {
|
|
94
|
+
kind: "timestamp_before",
|
|
95
|
+
column: "disabled_at",
|
|
96
|
+
before: "2026-03-01T00:00:00.000Z",
|
|
97
|
+
},
|
|
98
|
+
max_batch_size: 100,
|
|
99
|
+
actor_opaque_id: "system:purge",
|
|
100
|
+
legal_framework: "DPDP_2023",
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
expect(timestampCandidates).toEqual(["usr_001", "usr_003"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("fails closed when purge automation is disabled", async () => {
|
|
107
|
+
const schema = uniqueSchema("purge_disabled");
|
|
108
|
+
await createUsers(schema);
|
|
109
|
+
|
|
110
|
+
await expect(
|
|
111
|
+
selectPurgeCandidates(sql, {
|
|
112
|
+
appSchema: schema,
|
|
113
|
+
rootTable: "users",
|
|
114
|
+
rootIdColumn: "id",
|
|
115
|
+
purgePolicy: {
|
|
116
|
+
enabled: false,
|
|
117
|
+
max_batch_size: 100,
|
|
118
|
+
actor_opaque_id: "system:purge",
|
|
119
|
+
legal_framework: "DPDP_2023",
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
).rejects.toThrow(/without an enabled purge_policy selector/i);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { createTestSql, dropSchemas, uniqueSchema } from "./helpers";
|
|
3
|
+
import { evaluateRetention } from "@modules/engine/vault/retention";
|
|
4
|
+
import type { Sql } from "@/types";
|
|
5
|
+
|
|
6
|
+
describe("Retention Evaluation Engine", () => {
|
|
7
|
+
let sql: Sql;
|
|
8
|
+
const schemasToDrop: string[] = [];
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
sql = createTestSql();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterAll(async () => {
|
|
15
|
+
await dropSchemas(sql, ...schemasToDrop);
|
|
16
|
+
await sql.end();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("selects the longest retention rule when evidence exists in multiple tables", async () => {
|
|
20
|
+
const appSchema = uniqueSchema("retention_app");
|
|
21
|
+
schemasToDrop.push(appSchema);
|
|
22
|
+
|
|
23
|
+
await dropSchemas(sql, appSchema);
|
|
24
|
+
await sql`CREATE SCHEMA ${sql(appSchema)}`;
|
|
25
|
+
await sql`CREATE TABLE ${sql(appSchema)}.transactions (id SERIAL PRIMARY KEY, idempotent_user_id TEXT NOT NULL)`;
|
|
26
|
+
await sql`CREATE TABLE ${sql(appSchema)}.kyc_documents (id SERIAL PRIMARY KEY, idempotent_user_id TEXT NOT NULL)`;
|
|
27
|
+
await sql`
|
|
28
|
+
INSERT INTO ${sql(appSchema)}.transactions (idempotent_user_id)
|
|
29
|
+
VALUES ('usr_multi')
|
|
30
|
+
`;
|
|
31
|
+
await sql`
|
|
32
|
+
INSERT INTO ${sql(appSchema)}.kyc_documents (idempotent_user_id)
|
|
33
|
+
VALUES ('usr_multi')
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const result = await sql.begin(async (tx) =>
|
|
37
|
+
evaluateRetention(
|
|
38
|
+
tx,
|
|
39
|
+
"usr_multi",
|
|
40
|
+
{
|
|
41
|
+
default_retention_years: 0,
|
|
42
|
+
root_id_column: "idempotent_user_id",
|
|
43
|
+
retention_rules: [
|
|
44
|
+
{
|
|
45
|
+
rule_name: "RBI_KYC",
|
|
46
|
+
legal_citation: "RBI KYC Directions, 2016, Sec 38",
|
|
47
|
+
if_has_data_in: ["kyc_documents"],
|
|
48
|
+
retention_years: 5,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
rule_name: "PMLA_FINANCIAL",
|
|
52
|
+
legal_citation: "Prevention of Money Laundering Act, 2002, Sec 12",
|
|
53
|
+
if_has_data_in: ["transactions"],
|
|
54
|
+
retention_years: 10,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
app_schema: appSchema,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(result).toEqual({
|
|
63
|
+
retentionYears: 10,
|
|
64
|
+
appliedRuleName: "PMLA_FINANCIAL",
|
|
65
|
+
appliedRuleCitation: "Prevention of Money Laundering Act, 2002, Sec 12",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("falls back to the default retention window when no evidence rule matches", async () => {
|
|
70
|
+
const appSchema = uniqueSchema("retention_default");
|
|
71
|
+
schemasToDrop.push(appSchema);
|
|
72
|
+
|
|
73
|
+
await dropSchemas(sql, appSchema);
|
|
74
|
+
await sql`CREATE SCHEMA ${sql(appSchema)}`;
|
|
75
|
+
await sql`CREATE TABLE ${sql(appSchema)}.transactions (id SERIAL PRIMARY KEY, subject_id TEXT NOT NULL)`;
|
|
76
|
+
|
|
77
|
+
const result = await sql.begin(async (tx) =>
|
|
78
|
+
evaluateRetention(
|
|
79
|
+
tx,
|
|
80
|
+
"usr_none",
|
|
81
|
+
{
|
|
82
|
+
default_retention_years: 1,
|
|
83
|
+
root_id_column: "subject_id",
|
|
84
|
+
retention_rules: [
|
|
85
|
+
{
|
|
86
|
+
rule_name: "PMLA_FINANCIAL",
|
|
87
|
+
legal_citation: "Prevention of Money Laundering Act, 2002, Sec 12",
|
|
88
|
+
if_has_data_in: ["transactions"],
|
|
89
|
+
retention_years: 10,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
app_schema: appSchema,
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(result).toEqual({
|
|
98
|
+
retentionYears: 1,
|
|
99
|
+
appliedRuleName: "DEFAULT",
|
|
100
|
+
appliedRuleCitation: "Configured default_retention_years policy",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|