@crewhaus/data-retention-engine 0.1.1 → 0.1.3

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 (2) hide show
  1. package/package.json +6 -11
  2. 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.1",
3
+ "version": "0.1.3",
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.1.1"
15
+ "@crewhaus/errors": "0.1.3"
16
16
  },
17
17
  "license": "Apache-2.0",
18
18
  "author": {
19
19
  "name": "Max Meier",
20
- "email": "max@studiomax.io",
21
- "url": "https://studiomax.io"
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": "restricted"
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
+ });