@blamejs/blamejs-shop 0.2.14 → 0.2.17
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/CHANGELOG.md +4 -0
- package/README.md +2 -2
- package/lib/admin.js +970 -1
- package/lib/asset-manifest.json +5 -1
- package/lib/customers.js +71 -4
- package/lib/storefront.js +462 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +20 -0
- package/lib/vendor/blamejs/api-snapshot.json +6 -2
- package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
- package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
- package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
- package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
- package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
- package/lib/vendor/blamejs/lib/cache.js +184 -23
- package/lib/vendor/blamejs/lib/cert.js +68 -11
- package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
- package/lib/vendor/blamejs/lib/db.js +205 -15
- package/lib/vendor/blamejs/lib/dual-control.js +139 -143
- package/lib/vendor/blamejs/lib/i18n.js +10 -1
- package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
- package/lib/vendor/blamejs/lib/network-dns.js +10 -2
- package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
- package/lib/vendor/blamejs/lib/queue-local.js +2 -2
- package/lib/vendor/blamejs/lib/redis-client.js +14 -1
- package/lib/vendor/blamejs/lib/subject.js +8 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
- package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
- package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
- package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
- package/lib/vendor/blamejs/test/20-db.js +191 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
- package/package.json +1 -1
|
@@ -456,12 +456,203 @@ async function testUnsealRowNullsForgedValue() {
|
|
|
456
456
|
}
|
|
457
457
|
}
|
|
458
458
|
|
|
459
|
+
async function testEncryptedTmpfsCorruptionAutoRecovers() {
|
|
460
|
+
// Regression: in encrypted-at-rest mode the live SQLite copy lives in a
|
|
461
|
+
// tmpfs working file; a corrupt newer working copy (unclean shutdown, or
|
|
462
|
+
// a full /dev/shm — Docker's 64 MiB default) must NOT be trusted on
|
|
463
|
+
// boot. decryptToTmp's "newer plaintext wins" crash-recovery path used
|
|
464
|
+
// to keep it unconditionally, so db.init failed its integrity gate with
|
|
465
|
+
// "database disk image is malformed" identically on every boot — an
|
|
466
|
+
// unrecoverable crash loop (the blamejs.com wiki looped 4,625 times).
|
|
467
|
+
// The fix: integrity-probe the newer working copy; if bad, discard it
|
|
468
|
+
// and re-decrypt the last-good db.enc. db.enc is never modified.
|
|
469
|
+
//
|
|
470
|
+
// Scratch dir lives under the repo-local .test-output (not os.tmpdir):
|
|
471
|
+
// this test intentionally corrupts its working file in place, which
|
|
472
|
+
// static analysis (CodeQL js/insecure-temporary-file) flags as an
|
|
473
|
+
// insecure OS-temp-dir write. A gitignored per-test dir outside the
|
|
474
|
+
// shared OS temp dir sidesteps that false positive and is cleaner for
|
|
475
|
+
// test isolation besides.
|
|
476
|
+
var scratchBase = path.join(__dirname, "..", ".test-output");
|
|
477
|
+
fs.mkdirSync(scratchBase, { recursive: true });
|
|
478
|
+
var tmpDir = fs.mkdtempSync(path.join(scratchBase, "db-tmpfs-corrupt-"));
|
|
479
|
+
var schema = [{ name: "recovery_t", columns: { _id: "TEXT PRIMARY KEY", v: "TEXT" } }];
|
|
480
|
+
try {
|
|
481
|
+
await setupTestDb(tmpDir, schema);
|
|
482
|
+
check("recovery test runs in encrypted mode", b.db.getMode() === "encrypted");
|
|
483
|
+
b.db.prepare("INSERT INTO recovery_t (_id, v) VALUES (?, ?)").run("r1", "survives");
|
|
484
|
+
await b.db.flushToDisk(); // persist to db.enc (last-good snapshot)
|
|
485
|
+
b.db._resetForTest(); // close handle; leaves the working file on disk
|
|
486
|
+
|
|
487
|
+
// Locate the tmpfs working copy under our own scratch dir (setupTestDb
|
|
488
|
+
// points the working dir at <tmpDir>/tmpfs). Resolve it from `tmpDir`
|
|
489
|
+
// — our .test-output mkdtemp — rather than b.db.getDbPath(), so the
|
|
490
|
+
// path's non-OS-temp provenance stays visible to static analysis.
|
|
491
|
+
var workingDir = path.join(tmpDir, "tmpfs");
|
|
492
|
+
var workingFile = fs.readdirSync(workingDir).filter(function (f) { return /\.db$/.test(f); })[0];
|
|
493
|
+
var workingPath = path.join(workingDir, workingFile);
|
|
494
|
+
|
|
495
|
+
// Corrupt the existing working copy in place — overwrite the SQLite
|
|
496
|
+
// header so the file is no longer a valid database — and stamp it
|
|
497
|
+
// newer than db.enc (the exact trap shape that produced the crash
|
|
498
|
+
// loop). Open with "r+" (must already exist; never creates a file) so
|
|
499
|
+
// this is an in-place corruption of a known file.
|
|
500
|
+
var corruptFd = fs.openSync(workingPath, "r+");
|
|
501
|
+
try { fs.writeSync(corruptFd, Buffer.from("not a sqlite database -- corrupt header\n".repeat(8)), 0, undefined, 0); }
|
|
502
|
+
finally { fs.closeSync(corruptFd); }
|
|
503
|
+
var future = new Date(Date.now() + 60000);
|
|
504
|
+
fs.utimesSync(workingPath, future, future);
|
|
505
|
+
|
|
506
|
+
// Re-init against the same dataDir. Mirror setupTestDb's pre-init
|
|
507
|
+
// reset (passphrase env + audit reset) so db.init re-wires audit-sign
|
|
508
|
+
// from the same passphrase — but do NOT reset the vault, so the
|
|
509
|
+
// existing db.enc key still decrypts. The corrupt newer working copy
|
|
510
|
+
// must be discarded and db.enc re-decrypted — boot must succeed.
|
|
511
|
+
setTestPassphraseEnv();
|
|
512
|
+
b.audit._resetForTest();
|
|
513
|
+
await b.db.init({ dataDir: tmpDir, tmpDir: path.join(tmpDir, "tmpfs"), schema: schema });
|
|
514
|
+
var row = b.db.prepare("SELECT v FROM recovery_t WHERE _id = ?").get("r1");
|
|
515
|
+
check("corrupt tmpfs working copy auto-recovers from db.enc (no crash loop)",
|
|
516
|
+
row && row.v === "survives");
|
|
517
|
+
} finally {
|
|
518
|
+
try { b.db._resetForTest(); } catch (_e) { /* best effort */ }
|
|
519
|
+
await teardownTestDb(tmpDir);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function testEncryptedCloseKeepsPlaintextWhenEncryptFails() {
|
|
524
|
+
// close()'s final re-encrypt can fail (full /dev/shm, disk-full). When it
|
|
525
|
+
// does, the plaintext working copy is the ONLY carrier of writes since
|
|
526
|
+
// the last periodic flush — close() must NOT unlink it (it used to do so
|
|
527
|
+
// unconditionally), so the next boot's newer-mtime recovery can pick it
|
|
528
|
+
// up. db.enc still holds the prior snapshot, so nothing is lost either
|
|
529
|
+
// way; the bug was discarding the MORE-recent state on an encrypt error.
|
|
530
|
+
var scratchBase = path.join(__dirname, "..", ".test-output");
|
|
531
|
+
fs.mkdirSync(scratchBase, { recursive: true });
|
|
532
|
+
var tmpDir = fs.mkdtempSync(path.join(scratchBase, "db-close-encfail-"));
|
|
533
|
+
var schema = [{ name: "close_t", columns: { _id: "TEXT PRIMARY KEY", v: "TEXT" } }];
|
|
534
|
+
try {
|
|
535
|
+
await setupTestDb(tmpDir, schema);
|
|
536
|
+
check("close-encfail test runs in encrypted mode", b.db.getMode() === "encrypted");
|
|
537
|
+
b.db.prepare("INSERT INTO close_t (_id, v) VALUES (?, ?)").run("c1", "kept");
|
|
538
|
+
await b.db.flushToDisk(); // db.enc now holds the prior snapshot
|
|
539
|
+
|
|
540
|
+
var workingDir = path.join(tmpDir, "tmpfs");
|
|
541
|
+
var workingFile = fs.readdirSync(workingDir).filter(function (f) { return /\.db$/.test(f); })[0];
|
|
542
|
+
var workingPath = path.join(workingDir, workingFile);
|
|
543
|
+
check("working copy exists before close", fs.existsSync(workingPath));
|
|
544
|
+
|
|
545
|
+
// Force the final encrypt to throw: replace db.enc with a directory so
|
|
546
|
+
// atomicFile's rename-into-place fails (portable across platforms).
|
|
547
|
+
var encPath = path.join(tmpDir, "db.enc");
|
|
548
|
+
fs.rmSync(encPath, { force: true });
|
|
549
|
+
fs.mkdirSync(encPath);
|
|
550
|
+
|
|
551
|
+
// The real close() (the appShutdown db-phase path), not _resetForTest.
|
|
552
|
+
b.db.close();
|
|
553
|
+
|
|
554
|
+
check("encrypt-failed close keeps the plaintext working copy for recovery",
|
|
555
|
+
fs.existsSync(workingPath));
|
|
556
|
+
} finally {
|
|
557
|
+
try { b.db._resetForTest(); } catch (_e) { /* best effort */ }
|
|
558
|
+
await teardownTestDb(tmpDir);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function testTmpfsLowSpaceRefusesWritesFailClear() {
|
|
563
|
+
// When the encrypted-mode tmpfs working copy runs low on free space, the
|
|
564
|
+
// DB must REFUSE growth writes (INSERT/UPDATE/REPLACE) with a clear error
|
|
565
|
+
// instead of proceeding into an ENOSPC corruption. DELETE + reads stay
|
|
566
|
+
// available so retention can reclaim space and the app keeps serving. An
|
|
567
|
+
// injected statfs reader drives the probe deterministically.
|
|
568
|
+
var BYTES = b.constants.BYTES;
|
|
569
|
+
var scratchBase = path.join(__dirname, "..", ".test-output");
|
|
570
|
+
fs.mkdirSync(scratchBase, { recursive: true });
|
|
571
|
+
var tmpDir = fs.mkdtempSync(path.join(scratchBase, "db-lowspace-"));
|
|
572
|
+
var schema = [{ name: "space_t", columns: { _id: "TEXT PRIMARY KEY", v: "TEXT" } }];
|
|
573
|
+
var fakeFree = BYTES.mib(100); // start healthy
|
|
574
|
+
var fakeStatfs = function () { return { bavail: fakeFree, bsize: 1 }; };
|
|
575
|
+
try {
|
|
576
|
+
process.env.BLAMEJS_SKIP_NTP_CHECK = "1";
|
|
577
|
+
setTestPassphraseEnv();
|
|
578
|
+
b.cluster._resetForTest();
|
|
579
|
+
b.audit._resetForTest();
|
|
580
|
+
b.vault._resetForTest();
|
|
581
|
+
b.db._resetForTest();
|
|
582
|
+
await b.vault.init({ dataDir: tmpDir });
|
|
583
|
+
await b.db.init({
|
|
584
|
+
dataDir: tmpDir, tmpDir: path.join(tmpDir, "tmpfs"), schema: schema,
|
|
585
|
+
minFreeBytes: BYTES.mib(16), _statfsForTest: fakeStatfs,
|
|
586
|
+
});
|
|
587
|
+
check("low-space test runs in encrypted mode", b.db.getMode() === "encrypted");
|
|
588
|
+
|
|
589
|
+
b.db.prepare("INSERT INTO space_t (_id, v) VALUES (?, ?)").run("a", "1");
|
|
590
|
+
check("healthy free space → INSERT succeeds",
|
|
591
|
+
b.db.prepare("SELECT v FROM space_t WHERE _id = ?").get("a").v === "1");
|
|
592
|
+
|
|
593
|
+
// Drop below the threshold and force a probe.
|
|
594
|
+
fakeFree = BYTES.mib(1);
|
|
595
|
+
var st = b.db._probeStorageForTest();
|
|
596
|
+
check("low free space flips writesRefused", st.writesRefused === true);
|
|
597
|
+
|
|
598
|
+
var threw = null;
|
|
599
|
+
try { b.db.prepare("INSERT INTO space_t (_id, v) VALUES (?, ?)").run("bb", "2"); }
|
|
600
|
+
catch (e) { threw = e; }
|
|
601
|
+
check("low space → INSERT throws db/storage-low (fail-clear, not corruption)",
|
|
602
|
+
threw && threw.code === "db/storage-low");
|
|
603
|
+
|
|
604
|
+
// DELETE + reads must still work — the retention escape valve + serving.
|
|
605
|
+
var delThrew = null;
|
|
606
|
+
try { b.db.prepare("DELETE FROM space_t WHERE _id = ?").run("a"); } catch (e) { delThrew = e; }
|
|
607
|
+
check("low space → DELETE still allowed", delThrew === null);
|
|
608
|
+
check("low space → reads still allowed",
|
|
609
|
+
b.db.prepare("SELECT COUNT(*) AS n FROM space_t").get().n === 0);
|
|
610
|
+
|
|
611
|
+
// Recover: free space returns → flag clears → growth writes resume.
|
|
612
|
+
fakeFree = BYTES.mib(100);
|
|
613
|
+
var st2 = b.db._probeStorageForTest();
|
|
614
|
+
check("recovered free space clears writesRefused", st2.writesRefused === false);
|
|
615
|
+
var okErr = null;
|
|
616
|
+
try { b.db.prepare("INSERT INTO space_t (_id, v) VALUES (?, ?)").run("cc", "3"); }
|
|
617
|
+
catch (e) { okErr = e; }
|
|
618
|
+
check("recovered → INSERT succeeds again", okErr === null);
|
|
619
|
+
} finally {
|
|
620
|
+
try { b.db._resetForTest(); } catch (_e) { /* best effort */ }
|
|
621
|
+
await teardownTestDb(tmpDir);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function testExitHandlerRegisteredOnce() {
|
|
626
|
+
// The process-exit final-flush handler must register ONCE for the process
|
|
627
|
+
// lifetime, not on every init(). Re-registering per init leaked an 'exit'
|
|
628
|
+
// listener on each init/close cycle (MaxListenersExceeded under long runs).
|
|
629
|
+
var scratchBase = path.join(__dirname, "..", ".test-output");
|
|
630
|
+
fs.mkdirSync(scratchBase, { recursive: true });
|
|
631
|
+
var schema = [{ name: "el_t", columns: { _id: "TEXT PRIMARY KEY" } }];
|
|
632
|
+
var before = process.listenerCount("exit");
|
|
633
|
+
for (var i = 0; i < 3; i++) {
|
|
634
|
+
var tmpDir = fs.mkdtempSync(path.join(scratchBase, "db-exit-listener-"));
|
|
635
|
+
try {
|
|
636
|
+
await setupTestDb(tmpDir, schema);
|
|
637
|
+
b.db._resetForTest();
|
|
638
|
+
} finally {
|
|
639
|
+
await teardownTestDb(tmpDir);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
var added = process.listenerCount("exit") - before;
|
|
643
|
+
check("exit handler registered once across init/close cycles (no listener leak)", added <= 1);
|
|
644
|
+
}
|
|
645
|
+
|
|
459
646
|
// ---- run() ----
|
|
460
647
|
|
|
461
648
|
async function run() {
|
|
462
649
|
// db basic
|
|
463
650
|
await testDbBasic();
|
|
464
651
|
await testUnsealRowNullsForgedValue();
|
|
652
|
+
await testEncryptedTmpfsCorruptionAutoRecovers();
|
|
653
|
+
await testEncryptedCloseKeepsPlaintextWhenEncryptFails();
|
|
654
|
+
await testTmpfsLowSpaceRefusesWritesFailClear();
|
|
655
|
+
await testExitHandlerRegisteredOnce();
|
|
465
656
|
await testDbWriteOps();
|
|
466
657
|
await testDbSealedWithoutDerived();
|
|
467
658
|
await testDbTransactions();
|
|
@@ -38,6 +38,25 @@ async function testBasicGetPut() {
|
|
|
38
38
|
check("hit2 replayCount = 2", hit2 && hit2.replayCount === 2);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
async function testInMemoryBackendBounded() {
|
|
42
|
+
// The default in-memory backend caps its entry count so a flood of
|
|
43
|
+
// distinct idempotency keys can't grow it without bound (OOM). Eviction
|
|
44
|
+
// is oldest-first — a dropped record just means that one key re-executes
|
|
45
|
+
// on retry, never a crash. Operators needing a hard guarantee at scale
|
|
46
|
+
// supply a durable opts.store.
|
|
47
|
+
var idem = b.agent.idempotency.create({ maxInMemoryEntries: 3 });
|
|
48
|
+
await idem.put("m", "u", "k1", { n: 1 });
|
|
49
|
+
await idem.put("m", "u", "k2", { n: 2 });
|
|
50
|
+
await idem.put("m", "u", "k3", { n: 3 });
|
|
51
|
+
check("at cap: oldest still present", (await idem.get("m", "u", "k1")) !== null);
|
|
52
|
+
// 4th distinct key pushes past the cap → evicts the oldest (k1).
|
|
53
|
+
await idem.put("m", "u", "k4", { n: 4 });
|
|
54
|
+
check("over cap: newest key retained", (await idem.get("m", "u", "k4")) !== null);
|
|
55
|
+
check("over cap: oldest key evicted", (await idem.get("m", "u", "k1")) === null);
|
|
56
|
+
check("over cap: middle keys retained",
|
|
57
|
+
(await idem.get("m", "u", "k2")) !== null && (await idem.get("m", "u", "k3")) !== null);
|
|
58
|
+
}
|
|
59
|
+
|
|
41
60
|
async function testCrossActorIsolation() {
|
|
42
61
|
var idem = b.agent.idempotency.create({});
|
|
43
62
|
await idem.put("move", "u1", "shared-key", { changed: 1 });
|
|
@@ -275,6 +294,7 @@ async function run() {
|
|
|
275
294
|
testSurface();
|
|
276
295
|
await testAtRestSealingWithVault();
|
|
277
296
|
await testBasicGetPut();
|
|
297
|
+
await testInMemoryBackendBounded();
|
|
278
298
|
await testCrossActorIsolation();
|
|
279
299
|
await testCrossMethodIsolation();
|
|
280
300
|
await testKeyReuseDifferentArgs();
|
|
@@ -66,6 +66,38 @@ async function testList() {
|
|
|
66
66
|
check("list filter tenant", aOnly.length === 2);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
async function testTenantScopeRegistryReads() {
|
|
70
|
+
// With tenantScope on, registry reads are scoped to the actor's tenant:
|
|
71
|
+
// an actor can't enumerate or acquire a handle to another tenant's agent
|
|
72
|
+
// (the leak agent-event-bus already prevented; orchestrator now mirrors).
|
|
73
|
+
var perms = {
|
|
74
|
+
check: function (actor, scope) {
|
|
75
|
+
// cross-tenant-admin only for actor "admin"; everyone may read.
|
|
76
|
+
if (scope === "framework-cross-tenant-admin") return !!(actor && actor.id === "admin");
|
|
77
|
+
return true;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
var orch = b.agent.orchestrator.create({ tenantScope: true, permissions: perms });
|
|
81
|
+
var registrar = { id: "registrar", scopes: ["agent-registry:write"] };
|
|
82
|
+
await orch.register("ta.mail", _fakeAgent("a"), { agentKind: "mail", tenantId: "a", actor: registrar });
|
|
83
|
+
await orch.register("tb.mail", _fakeAgent("b"), { agentKind: "mail", tenantId: "b", actor: registrar });
|
|
84
|
+
|
|
85
|
+
var actorA = { id: "ua", tenantId: "a", scopes: ["agent-registry:read"] };
|
|
86
|
+
var listA = await orch.list({ actor: actorA });
|
|
87
|
+
check("tenant-scope list: actor sees only own-tenant agents",
|
|
88
|
+
listA.length === 1 && listA[0].tenantId === "a");
|
|
89
|
+
var ownLook = await orch.lookup("ta.mail", { actor: actorA });
|
|
90
|
+
check("tenant-scope lookup: own-tenant agent resolves", ownLook && typeof ownLook === "object");
|
|
91
|
+
var crossLook = await orch.lookup("tb.mail", { actor: actorA });
|
|
92
|
+
check("tenant-scope lookup: cross-tenant agent refused (null)", crossLook === null);
|
|
93
|
+
|
|
94
|
+
var admin = { id: "admin", tenantId: "ops", scopes: ["agent-registry:read", "framework-cross-tenant-admin"] };
|
|
95
|
+
var listAdmin = await orch.list({ actor: admin });
|
|
96
|
+
check("tenant-scope list: cross-tenant-admin sees all", listAdmin.length === 2);
|
|
97
|
+
var adminCross = await orch.lookup("ta.mail", { actor: admin });
|
|
98
|
+
check("tenant-scope lookup: cross-tenant-admin resolves any tenant", adminCross && typeof adminCross === "object");
|
|
99
|
+
}
|
|
100
|
+
|
|
69
101
|
async function testGuardRefusals() {
|
|
70
102
|
var orch = b.agent.orchestrator.create({});
|
|
71
103
|
await expectRejection("register refuses bad name",
|
|
@@ -401,6 +433,7 @@ async function run() {
|
|
|
401
433
|
testSurface();
|
|
402
434
|
await testRegisterLookupUnregister();
|
|
403
435
|
await testList();
|
|
436
|
+
await testTenantScopeRegistryReads();
|
|
404
437
|
await testGuardRefusals();
|
|
405
438
|
await testElect();
|
|
406
439
|
await testElectCluster();
|
|
@@ -90,6 +90,40 @@ async function testNonceStoreMemoryPurge() {
|
|
|
90
90
|
store.close();
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
async function testNonceStoreMemoryCapacityFailsClosed() {
|
|
94
|
+
// A replay-protection store must bound its memory against a nonce flood,
|
|
95
|
+
// but must NOT evict a LIVE nonce to admit a new one — that reopens a
|
|
96
|
+
// replay window for the evicted nonce. So at capacity it fails CLOSED:
|
|
97
|
+
// the new (un-recordable) request is refused, not admitted unprotected.
|
|
98
|
+
var store = b.nonceStore.create({ backend: "memory", maxEntries: 3 });
|
|
99
|
+
var future = Date.now() + 60_000;
|
|
100
|
+
check("cap: first 3 live nonces admitted",
|
|
101
|
+
(await store.checkAndInsert("n1", future)) === true &&
|
|
102
|
+
(await store.checkAndInsert("n2", future)) === true &&
|
|
103
|
+
(await store.checkAndInsert("n3", future)) === true);
|
|
104
|
+
check("cap: store holds exactly maxEntries", store._size() === 3);
|
|
105
|
+
// 4th distinct LIVE nonce — store is full of live entries, so fail closed.
|
|
106
|
+
check("cap: 4th live nonce fails closed (refused)",
|
|
107
|
+
(await store.checkAndInsert("n4", future)) === false);
|
|
108
|
+
check("cap: refused nonce was NOT stored", store._size() === 3 && !(store._size() > 3));
|
|
109
|
+
check("cap: capacity rejection counted", store._capacityRejects() >= 1);
|
|
110
|
+
// A previously-seen live nonce still reports as replay (not capacity).
|
|
111
|
+
check("cap: existing nonce still detected as replay",
|
|
112
|
+
(await store.checkAndInsert("n1", future)) === false);
|
|
113
|
+
store.close();
|
|
114
|
+
|
|
115
|
+
// When the ceiling is hit but EXPIRED entries exist, the inline purge
|
|
116
|
+
// reclaims room so a legitimate new nonce is admitted (not falsely
|
|
117
|
+
// refused). Fill with expired nonces, then a fresh one must succeed.
|
|
118
|
+
var store2 = b.nonceStore.create({ backend: "memory", maxEntries: 2 });
|
|
119
|
+
var past = Date.now() - 1000;
|
|
120
|
+
await store2.checkAndInsert("old1", past);
|
|
121
|
+
await store2.checkAndInsert("old2", past);
|
|
122
|
+
check("cap: full of EXPIRED entries → fresh nonce admitted (purge reclaims)",
|
|
123
|
+
(await store2.checkAndInsert("freshOne", Date.now() + 60_000)) === true);
|
|
124
|
+
store2.close();
|
|
125
|
+
}
|
|
126
|
+
|
|
93
127
|
async function testNonceStoreClusterBasics() {
|
|
94
128
|
var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-ns-"));
|
|
95
129
|
try {
|
|
@@ -1030,6 +1064,7 @@ async function run() {
|
|
|
1030
1064
|
await testNonceStoreMemoryBasics();
|
|
1031
1065
|
await testNonceStoreMemoryRejectsBadInput();
|
|
1032
1066
|
await testNonceStoreMemoryPurge();
|
|
1067
|
+
await testNonceStoreMemoryCapacityFailsClosed();
|
|
1033
1068
|
await testNonceStoreClusterBasics();
|
|
1034
1069
|
await testNonceStoreCustomBackend();
|
|
1035
1070
|
await testNonceStoreUnknownBackend();
|
|
@@ -275,6 +275,10 @@ async function testAppShutdownConfigValidation() {
|
|
|
275
275
|
} catch (e) { threw = e; }
|
|
276
276
|
check("create: bad-shaped phase rejected", threw && threw.code === "app-shutdown/bad-phase");
|
|
277
277
|
|
|
278
|
+
threw = null;
|
|
279
|
+
try { b.appShutdown.create({ forceExitMarginMs: -1 }); } catch (e) { threw = e; }
|
|
280
|
+
check("create: bad forceExitMarginMs rejected", threw && threw.code === "app-shutdown/bad-force-exit-margin-ms");
|
|
281
|
+
|
|
278
282
|
threw = null;
|
|
279
283
|
var o = b.appShutdown.create({ phases: [] });
|
|
280
284
|
try { o.addPhase({ name: "x" }); } catch (e) { threw = e; }
|
|
@@ -282,6 +286,65 @@ async function testAppShutdownConfigValidation() {
|
|
|
282
286
|
o._resetForTest();
|
|
283
287
|
}
|
|
284
288
|
|
|
289
|
+
async function testAppShutdownWatchdogForcesExitOnHang() {
|
|
290
|
+
// A shutdown phase that never settles must NOT hold the process open
|
|
291
|
+
// until the supervisor SIGKILLs it (losing the final DB flush). When the
|
|
292
|
+
// operator delegates lifecycle via installSignalHandlers, a watchdog
|
|
293
|
+
// forces a clean exit graceMs + forceExitMarginMs after the signal so
|
|
294
|
+
// exit handlers (the DB re-encrypt) still run. Verified in a child
|
|
295
|
+
// process — the watchdog calls process.exit, which would kill the runner.
|
|
296
|
+
if (process.platform === "win32") {
|
|
297
|
+
// Node can't deliver SIGTERM to a JS handler on Windows (the OS
|
|
298
|
+
// terminates the process), so the graceful signal path the watchdog
|
|
299
|
+
// guards doesn't exist here. This defends a Linux-container SIGTERM
|
|
300
|
+
// deployment; the container smoke leg exercises it for real.
|
|
301
|
+
check("watchdog test skipped on win32 (no deliverable SIGTERM)", true);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
var cp = require("node:child_process");
|
|
305
|
+
var repoRoot = require("node:path").resolve(__dirname, "..", "..");
|
|
306
|
+
var script =
|
|
307
|
+
"var b = require(" + JSON.stringify(repoRoot) + ");" +
|
|
308
|
+
"b.appShutdown.create({" +
|
|
309
|
+
" graceMs: 100, forceExitMarginMs: 150, installSignalHandlers: true," +
|
|
310
|
+
" phases: [{ name: 'hang', run: function () { return new Promise(function () {}); } }]" +
|
|
311
|
+
"});" +
|
|
312
|
+
"setInterval(function () {}, 60000);" + // keep the loop alive until the signal
|
|
313
|
+
"process.stdout.write('READY\\n');";
|
|
314
|
+
var child = cp.spawn(process.execPath, ["-e", script], { stdio: ["ignore", "pipe", "pipe"] });
|
|
315
|
+
|
|
316
|
+
// Single exit observer + bounded waits so a misbehaving child can never
|
|
317
|
+
// hang the test (it fails loudly via waitUntil's timeout instead).
|
|
318
|
+
var sentAt = null;
|
|
319
|
+
var exited = null;
|
|
320
|
+
var stderr = "";
|
|
321
|
+
child.stderr.on("data", function (d) { stderr += d.toString(); });
|
|
322
|
+
child.on("exit", function (code, signal) {
|
|
323
|
+
exited = { code: code, signal: signal, ms: sentAt === null ? 0 : Date.now() - sentAt };
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
var sawReady = false;
|
|
327
|
+
child.stdout.on("data", function (d) { if (d.toString().indexOf("READY") !== -1) sawReady = true; });
|
|
328
|
+
try {
|
|
329
|
+
await helpers.waitUntil(function () { return sawReady || exited !== null; },
|
|
330
|
+
{ timeoutMs: 6000, label: "app-shutdown watchdog: child reached READY" });
|
|
331
|
+
} catch (_e) { /* fall through to the check below */ }
|
|
332
|
+
check("watchdog child reached READY (stderr: " + stderr.slice(0, 160).replace(/\n/g, " ") + ")",
|
|
333
|
+
sawReady && exited === null);
|
|
334
|
+
if (!sawReady || exited !== null) { try { child.kill("SIGKILL"); } catch (_e2) { /* gone */ } return; }
|
|
335
|
+
|
|
336
|
+
sentAt = Date.now();
|
|
337
|
+
child.kill("SIGTERM");
|
|
338
|
+
// The watchdog fires at graceMs(100) + forceExitMarginMs(150) = 250ms.
|
|
339
|
+
await helpers.waitUntil(function () { return exited !== null; },
|
|
340
|
+
{ timeoutMs: 6000, label: "app-shutdown watchdog: child forced exit after SIGTERM" });
|
|
341
|
+
// It EXITS (not hangs) and does so on its own — a forced process.exit
|
|
342
|
+
// (numeric code, no kill signal), not because the OS killed it.
|
|
343
|
+
check("hung shutdown forced to exit by watchdog (not hung)", exited && exited.ms < 4000);
|
|
344
|
+
check("watchdog exit was a clean process.exit (not a kill signal)",
|
|
345
|
+
exited && exited.signal === null && typeof exited.code === "number");
|
|
346
|
+
}
|
|
347
|
+
|
|
285
348
|
async function run() {
|
|
286
349
|
testAppShutdownSurface();
|
|
287
350
|
await testAppShutdownEmpty();
|
|
@@ -299,6 +362,7 @@ async function run() {
|
|
|
299
362
|
await testAppShutdownStandardPhasesOmitsAbsentComponents();
|
|
300
363
|
await testAppShutdownSignalHandlersInstall();
|
|
301
364
|
await testAppShutdownConfigValidation();
|
|
365
|
+
await testAppShutdownWatchdogForcesExitOnHang();
|
|
302
366
|
}
|
|
303
367
|
|
|
304
368
|
module.exports = { run: run };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* bounded-map — entry-count-capped Map facade.
|
|
4
|
+
*
|
|
5
|
+
* Run standalone: `node test/layer-0-primitives/bounded-map.test.js`
|
|
6
|
+
* Or via smoke: `node test/smoke.js`
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
var helpers = require("../helpers");
|
|
10
|
+
var check = helpers.check;
|
|
11
|
+
var { boundedMap, BoundedMapError } = require("../../lib/bounded-map");
|
|
12
|
+
|
|
13
|
+
function testEvictOldest() {
|
|
14
|
+
var evicted = [];
|
|
15
|
+
var m = boundedMap({ maxEntries: 3, onEvict: function (k, v) { evicted.push(k + "=" + v); } });
|
|
16
|
+
m.set("a", 1); m.set("b", 2); m.set("c", 3);
|
|
17
|
+
check("evict-oldest: at capacity, all present", m.size === 3 && m.has("a") && m.has("c"));
|
|
18
|
+
m.set("d", 4);
|
|
19
|
+
check("evict-oldest: size stays at cap", m.size === 3);
|
|
20
|
+
check("evict-oldest: oldest (a) evicted", !m.has("a") && m.has("d"));
|
|
21
|
+
m.set("e", 5);
|
|
22
|
+
check("evict-oldest: next oldest (b) evicted", !m.has("b") && m.has("e"));
|
|
23
|
+
check("evict-oldest: onEvict fired for a,b", evicted.join(",") === "a=1,b=2");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function testUpdateDoesNotGrowOrEvict() {
|
|
27
|
+
var evicted = [];
|
|
28
|
+
var m = boundedMap({ maxEntries: 2, onEvict: function (k) { evicted.push(k); } });
|
|
29
|
+
m.set("x", 1); m.set("y", 1);
|
|
30
|
+
var r = m.set("x", 99); // update existing — must not evict y
|
|
31
|
+
check("update returns true", r === true);
|
|
32
|
+
check("update keeps size at 2", m.size === 2);
|
|
33
|
+
check("update did not evict", m.has("y") && evicted.length === 0);
|
|
34
|
+
check("update changed the value", m.get("x") === 99);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function testRejectPolicy() {
|
|
38
|
+
var m = boundedMap({ maxEntries: 2, policy: "reject" });
|
|
39
|
+
check("reject: first two stored", m.set("a", 1) === true && m.set("b", 1) === true);
|
|
40
|
+
check("reject: third refused", m.set("c", 1) === false);
|
|
41
|
+
check("reject: new key NOT stored", !m.has("c") && m.size === 2);
|
|
42
|
+
check("reject: live entries untouched", m.has("a") && m.has("b"));
|
|
43
|
+
// Updating an existing key is still allowed at capacity (no growth).
|
|
44
|
+
check("reject: update existing allowed", m.set("a", 2) === true && m.get("a") === 2);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function testMapFacade() {
|
|
48
|
+
var m = boundedMap({ maxEntries: 10 });
|
|
49
|
+
m.set("k1", "v1"); m.set("k2", "v2");
|
|
50
|
+
check("get", m.get("k1") === "v1");
|
|
51
|
+
check("has", m.has("k2") && !m.has("nope"));
|
|
52
|
+
check("delete", m.delete("k1") === true && !m.has("k1") && m.size === 1);
|
|
53
|
+
var iter = [];
|
|
54
|
+
for (var e of m) iter.push(e[0] + "=" + e[1]);
|
|
55
|
+
check("iterable yields entries", iter.join(",") === "k2=v2");
|
|
56
|
+
var keys = []; var it = m.keys(); var n; while (!(n = it.next()).done) keys.push(n.value);
|
|
57
|
+
check("keys()", keys.join(",") === "k2");
|
|
58
|
+
var fe = []; m.forEach(function (v, k) { fe.push(k + "=" + v); });
|
|
59
|
+
check("forEach", fe.join(",") === "k2=v2");
|
|
60
|
+
m.clear();
|
|
61
|
+
check("clear", m.size === 0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function testValidation() {
|
|
65
|
+
function threw(fn) { try { fn(); return null; } catch (e) { return e; } }
|
|
66
|
+
check("maxEntries 0 → throws", threw(function () { boundedMap({ maxEntries: 0 }); }) instanceof BoundedMapError);
|
|
67
|
+
check("maxEntries -1 → throws", threw(function () { boundedMap({ maxEntries: -1 }); }));
|
|
68
|
+
check("maxEntries 1.5 → throws", threw(function () { boundedMap({ maxEntries: 1.5 }); }));
|
|
69
|
+
check("maxEntries missing → throws", threw(function () { boundedMap({}); }));
|
|
70
|
+
var e = threw(function () { boundedMap({ maxEntries: 5, policy: "nope" }); });
|
|
71
|
+
check("bad policy → throws bad-policy", e && e.code === "bounded-map/bad-policy");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function run() {
|
|
75
|
+
testEvictOldest();
|
|
76
|
+
testUpdateDoesNotGrowOrEvict();
|
|
77
|
+
testRejectPolicy();
|
|
78
|
+
testMapFacade();
|
|
79
|
+
testValidation();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { run: run };
|
|
83
|
+
|
|
84
|
+
if (require.main === module) {
|
|
85
|
+
try { run(); console.log("[bounded-map] OK — " + helpers.getChecks() + " checks passed"); }
|
|
86
|
+
catch (e) { console.error("FAIL:", e.stack || e); process.exit(1); }
|
|
87
|
+
}
|
|
@@ -639,6 +639,52 @@ function _newCache(extra) {
|
|
|
639
639
|
return b.cache.create(Object.assign({ namespace: "v411", backend: "memory" }, extra || {}));
|
|
640
640
|
}
|
|
641
641
|
|
|
642
|
+
async function testUpdateMemory() {
|
|
643
|
+
var c = _newCache({ namespace: "upd-mem" });
|
|
644
|
+
var r1 = await c.update("n", function (cur) { return { value: (cur || 0) + 1 }; });
|
|
645
|
+
await c.update("n", function (cur) { return { value: cur + 1 }; });
|
|
646
|
+
check("update memory: increments atomically", (await c.get("n")) === 2 && r1.value === 1);
|
|
647
|
+
var ab = await c.update("n", function () { return { abort: { why: "nope" } }; });
|
|
648
|
+
check("update memory: abort leaves value + returns aborted",
|
|
649
|
+
ab.aborted && ab.aborted.why === "nope" && (await c.get("n")) === 2);
|
|
650
|
+
var dl = await c.update("n", function () { return { delete: true }; });
|
|
651
|
+
check("update memory: delete removes the entry",
|
|
652
|
+
dl.deleted === true && (await c.get("n")) === undefined);
|
|
653
|
+
// update on an absent key sees null and may create.
|
|
654
|
+
var cr = await c.update("fresh", function (cur) { return { value: cur === null ? "created" : "x" }; });
|
|
655
|
+
check("update memory: absent key seen as null", cr.value === "created" && (await c.get("fresh")) === "created");
|
|
656
|
+
await c.close();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function testUpdateClusterCas() {
|
|
660
|
+
var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-cache-upd-"));
|
|
661
|
+
try {
|
|
662
|
+
await setupTestDb(tmpDir);
|
|
663
|
+
var c = b.cache.create({ namespace: "upd-clu", backend: "cluster", ttlMs: b.constants.TIME.minutes(5) });
|
|
664
|
+
await c.update("set", function (cur) { return { value: { items: (cur ? cur.items : []).concat("a") } }; });
|
|
665
|
+
await c.update("set", function (cur) { return { value: { items: cur.items.concat("b") } }; });
|
|
666
|
+
// Concurrent appends: the compare-and-set + retry must preserve BOTH
|
|
667
|
+
// (no lost update) — the cluster race the get/set version dropped.
|
|
668
|
+
await Promise.all([
|
|
669
|
+
c.update("set", function (cur) { return { value: { items: cur.items.concat("x") } }; }),
|
|
670
|
+
c.update("set", function (cur) { return { value: { items: cur.items.concat("y") } }; }),
|
|
671
|
+
]);
|
|
672
|
+
var fin = await c.get("set");
|
|
673
|
+
check("update cluster: concurrent CAS loses no write", fin.items.length === 4);
|
|
674
|
+
check("update cluster: all appends present",
|
|
675
|
+
fin.items.indexOf("a") !== -1 && fin.items.indexOf("b") !== -1 &&
|
|
676
|
+
fin.items.indexOf("x") !== -1 && fin.items.indexOf("y") !== -1);
|
|
677
|
+
var ab = await c.update("set", function () { return { abort: { stop: 1 } }; });
|
|
678
|
+
check("update cluster: abort returns aborted + leaves value",
|
|
679
|
+
ab.aborted && ab.aborted.stop === 1 && (await c.get("set")).items.length === 4);
|
|
680
|
+
await c.update("set", function () { return { delete: true }; });
|
|
681
|
+
check("update cluster: delete removes the entry", (await c.get("set")) === undefined);
|
|
682
|
+
await c.close();
|
|
683
|
+
} finally {
|
|
684
|
+
await teardownTestDb(tmpDir);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
642
688
|
async function testMaxBytesEvictsLru() {
|
|
643
689
|
// Cache size budget: 30 bytes. Each entry ~12 bytes JSON ("aaaaaaaa" → 10).
|
|
644
690
|
var c = _newCache({
|
|
@@ -847,6 +893,8 @@ async function run() {
|
|
|
847
893
|
await testClusterBackendBasics();
|
|
848
894
|
await testClusterNamespaceIsolation();
|
|
849
895
|
await testClusterTtlExpiration();
|
|
896
|
+
await testUpdateMemory();
|
|
897
|
+
await testUpdateClusterCas();
|
|
850
898
|
|
|
851
899
|
// v0.4.11
|
|
852
900
|
await testValidationNewOpts();
|