@crewhaus/data-retention-engine 0.1.0 → 0.1.2
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/package.json +6 -11
- package/src/index.test.ts +168 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/data-retention-engine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "GDPR-shaped data retention: retain / export / purge with cron-style sweeper + audit window override (Section 39)",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.
|
|
15
|
+
"@crewhaus/errors": "0.1.2"
|
|
16
16
|
},
|
|
17
17
|
"license": "Apache-2.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Max Meier",
|
|
20
|
-
"email": "max@
|
|
21
|
-
"url": "https://
|
|
20
|
+
"email": "max@crewhaus.ai",
|
|
21
|
+
"url": "https://crewhaus.ai"
|
|
22
22
|
},
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
@@ -30,12 +30,7 @@
|
|
|
30
30
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
|
-
"access": "
|
|
33
|
+
"access": "public"
|
|
34
34
|
},
|
|
35
|
-
"files": [
|
|
36
|
-
"src",
|
|
37
|
-
"README.md",
|
|
38
|
-
"LICENSE",
|
|
39
|
-
"NOTICE"
|
|
40
|
-
]
|
|
35
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
41
36
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
DataRetentionError,
|
|
4
4
|
InMemoryRecordStore,
|
|
5
|
+
type RecordStore,
|
|
5
6
|
type RetentionRecord,
|
|
6
7
|
_msPerDayForTest,
|
|
7
8
|
createDataRetentionEngine,
|
|
@@ -278,3 +279,170 @@ describe("sweep (cron-style)", () => {
|
|
|
278
279
|
expect(result.recordsKept).toBe(2);
|
|
279
280
|
});
|
|
280
281
|
});
|
|
282
|
+
|
|
283
|
+
describe("InMemoryRecordStore", () => {
|
|
284
|
+
test("size() reports the current record count and tracks deletes", async () => {
|
|
285
|
+
const store = new InMemoryRecordStore([
|
|
286
|
+
record("a-1", "tenant-a", "audit", 5),
|
|
287
|
+
record("a-2", "tenant-a", "audit", 5),
|
|
288
|
+
]);
|
|
289
|
+
expect(store.size()).toBe(2);
|
|
290
|
+
await store.delete("a-1");
|
|
291
|
+
expect(store.size()).toBe(1);
|
|
292
|
+
await store.delete("nope"); // no-op
|
|
293
|
+
expect(store.size()).toBe(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("delete() returns false when the id is absent", async () => {
|
|
297
|
+
const store = new InMemoryRecordStore([record("a-1", "tenant-a", "audit", 5)]);
|
|
298
|
+
expect(await store.delete("ghost")).toBe(false);
|
|
299
|
+
expect(await store.delete("a-1")).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("default clock", () => {
|
|
304
|
+
// Exercises the fallback `now` (Date.now) path when no `now` is injected.
|
|
305
|
+
test("addAuditWindow uses Date.now() when no clock is supplied", () => {
|
|
306
|
+
const eng = createDataRetentionEngine({ recordStore: new InMemoryRecordStore() });
|
|
307
|
+
// A window far in the future must be accepted under the real clock...
|
|
308
|
+
eng.addAuditWindow({
|
|
309
|
+
frameworkId: "soc2",
|
|
310
|
+
controlId: "CC6.1",
|
|
311
|
+
expiresAt: Date.now() + 365 * DAY_MS,
|
|
312
|
+
});
|
|
313
|
+
expect(eng.listAuditWindows()).toHaveLength(1);
|
|
314
|
+
// ...and an already-past window must be rejected under the real clock.
|
|
315
|
+
expect(() =>
|
|
316
|
+
eng.addAuditWindow({
|
|
317
|
+
frameworkId: "soc2",
|
|
318
|
+
controlId: "CC6.2",
|
|
319
|
+
expiresAt: Date.now() - 1000,
|
|
320
|
+
}),
|
|
321
|
+
).toThrow(DataRetentionError);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("defaultRetentionDays falls back to 90 when unset", async () => {
|
|
325
|
+
const now = 1_700_000_000_000;
|
|
326
|
+
const store = new InMemoryRecordStore([
|
|
327
|
+
record("fresh", "tenant-a", "audit", 80, {}, now), // < 90d → kept
|
|
328
|
+
record("stale", "tenant-a", "audit", 100, {}, now), // > 90d → purged
|
|
329
|
+
]);
|
|
330
|
+
const eng = createDataRetentionEngine({ recordStore: store, now: () => now });
|
|
331
|
+
const result = await eng.purge("tenant-a");
|
|
332
|
+
expect(result.deleted).toBe(1);
|
|
333
|
+
expect(store.ids()).toEqual(["fresh"]);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe("defensive cross-tenant guards (misbehaving store)", () => {
|
|
338
|
+
// A custom store that ignores the tenant filter and leaks foreign records.
|
|
339
|
+
// The engine's per-record `record.tenantId !== tenantId` guard must still
|
|
340
|
+
// refuse to act on them (T8 belt-and-suspenders).
|
|
341
|
+
class LeakyStore implements RecordStore {
|
|
342
|
+
constructor(private readonly recs: ReadonlyArray<RetentionRecord>) {}
|
|
343
|
+
async *listAll(): AsyncIterable<RetentionRecord> {
|
|
344
|
+
for (const r of this.recs) yield r;
|
|
345
|
+
}
|
|
346
|
+
// Deliberately ignores `_tenantId` and yields EVERYTHING.
|
|
347
|
+
async *listByTenant(_tenantId: string): AsyncIterable<RetentionRecord> {
|
|
348
|
+
for (const r of this.recs) yield r;
|
|
349
|
+
}
|
|
350
|
+
async delete(): Promise<boolean> {
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
test("purge skips foreign-tenant records a leaky store yields", async () => {
|
|
356
|
+
const now = 1_700_000_000_000;
|
|
357
|
+
const deleted: string[] = [];
|
|
358
|
+
const recs = [
|
|
359
|
+
record("a-old", "tenant-a", "audit", 200, {}, now),
|
|
360
|
+
record("b-old", "tenant-b", "audit", 200, {}, now),
|
|
361
|
+
];
|
|
362
|
+
// Inline store so `delete` can record which ids the engine acts on.
|
|
363
|
+
const store: RecordStore = {
|
|
364
|
+
async *listAll() {
|
|
365
|
+
for (const r of recs) yield r;
|
|
366
|
+
},
|
|
367
|
+
async *listByTenant() {
|
|
368
|
+
for (const r of recs) yield r; // leaks every tenant
|
|
369
|
+
},
|
|
370
|
+
async delete(id: string): Promise<boolean> {
|
|
371
|
+
deleted.push(id);
|
|
372
|
+
return true;
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
const eng = createDataRetentionEngine({
|
|
376
|
+
recordStore: store,
|
|
377
|
+
now: () => now,
|
|
378
|
+
defaultRetentionDays: 30,
|
|
379
|
+
});
|
|
380
|
+
const result = await eng.purge("tenant-a");
|
|
381
|
+
expect(result.deleted).toBe(1);
|
|
382
|
+
expect(deleted).toEqual(["a-old"]); // tenant-b never touched
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("export omits foreign-tenant records a leaky store yields", async () => {
|
|
386
|
+
const store = new LeakyStore([
|
|
387
|
+
record("a-1", "tenant-a", "audit", 5),
|
|
388
|
+
record("b-1", "tenant-b", "audit", 5),
|
|
389
|
+
]);
|
|
390
|
+
const eng = createDataRetentionEngine({ recordStore: store });
|
|
391
|
+
const out = await eng.export("tenant-a", { format: "json" });
|
|
392
|
+
const parsed = JSON.parse(out) as RetentionRecord[];
|
|
393
|
+
expect(parsed).toHaveLength(1);
|
|
394
|
+
expect(parsed[0]?.id).toBe("a-1");
|
|
395
|
+
expect(out.includes("b-1")).toBe(false);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
describe("delete-returns-false branches", () => {
|
|
400
|
+
// When the store reports `delete` -> false (record already gone), neither
|
|
401
|
+
// purge nor sweep should count it as deleted.
|
|
402
|
+
class NoopDeleteStore implements RecordStore {
|
|
403
|
+
constructor(private readonly recs: ReadonlyArray<RetentionRecord>) {}
|
|
404
|
+
async *listAll(): AsyncIterable<RetentionRecord> {
|
|
405
|
+
for (const r of this.recs) yield r;
|
|
406
|
+
}
|
|
407
|
+
async *listByTenant(tenantId: string): AsyncIterable<RetentionRecord> {
|
|
408
|
+
for (const r of this.recs) if (r.tenantId === tenantId) yield r;
|
|
409
|
+
}
|
|
410
|
+
async delete(): Promise<boolean> {
|
|
411
|
+
return false; // pretend the record vanished between list and delete
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
test("purge does not count a delete that returns false", async () => {
|
|
416
|
+
const now = 1_700_000_000_000;
|
|
417
|
+
const store = new NoopDeleteStore([record("a-old", "tenant-a", "audit", 200, {}, now)]);
|
|
418
|
+
const eng = createDataRetentionEngine({
|
|
419
|
+
recordStore: store,
|
|
420
|
+
now: () => now,
|
|
421
|
+
defaultRetentionDays: 30,
|
|
422
|
+
});
|
|
423
|
+
const result = await eng.purge("tenant-a");
|
|
424
|
+
expect(result.deleted).toBe(0);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("sweep counts a delete that returns false as kept", async () => {
|
|
428
|
+
const now = 1_700_000_000_000;
|
|
429
|
+
const store = new NoopDeleteStore([record("a-old", "tenant-a", "audit", 200, {}, now)]);
|
|
430
|
+
const eng = createDataRetentionEngine({
|
|
431
|
+
recordStore: store,
|
|
432
|
+
now: () => now,
|
|
433
|
+
defaultRetentionDays: 30,
|
|
434
|
+
});
|
|
435
|
+
const result = await eng.sweep();
|
|
436
|
+
expect(result.deletedCount).toBe(0);
|
|
437
|
+
expect(result.recordsKept).toBe(1);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe("createDataRetentionEngine guard", () => {
|
|
442
|
+
test("throws when recordStore is missing", () => {
|
|
443
|
+
// Force the undefined-recordStore branch through the public factory.
|
|
444
|
+
expect(() =>
|
|
445
|
+
createDataRetentionEngine({ recordStore: undefined as unknown as RecordStore }),
|
|
446
|
+
).toThrow(DataRetentionError);
|
|
447
|
+
});
|
|
448
|
+
});
|