@blamejs/blamejs-shop 0.5.14 → 0.5.16

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.
@@ -0,0 +1,593 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * b.session — canonical lifecycle + adversarial coverage for lib/session.js.
6
+ *
7
+ * Drives the real b.session consumer surface (create / verify / touch /
8
+ * rotate / destroy / destroyAllForUser / updateData / purgeExpired / count /
9
+ * logout) against a live encrypted test DB — the production path, no
10
+ * NODE_ENV=test bypass. The device-binding / valid-from / store-backed
11
+ * fingerprint paths are exercised by their dedicated siblings; this file
12
+ * targets the remaining lifecycle branches those tests don't reach:
13
+ *
14
+ * - config-time input validation (ttl bounds, missing userId, bad
15
+ * cookieName, bad extendBy)
16
+ * - token-shape guards on every mutator (empty / non-string /
17
+ * pre-sealed-format / tampered-envelope tokens)
18
+ * - the idle + absolute + operator-ttl timeout floors on verify() and
19
+ * touch() (rows aged deterministically via a localDbThin store, so no
20
+ * wall-clock sleeps)
21
+ * - purgeExpired() and count()
22
+ * - rotate() ttl / data-replace / carry-forward / unknown-token branches
23
+ * - the decrypt-then-parse failure path on verify() + updateData() (a
24
+ * valid envelope whose plaintext is not JSON — key-skew / corruption)
25
+ * - custom function-form fingerprint fields
26
+ *
27
+ * Run standalone: node test/layer-0-primitives/session.test.js
28
+ * Or via smoke: node test/smoke.js
29
+ */
30
+
31
+ var helpers = require("../helpers");
32
+ var b = helpers.b;
33
+ var check = helpers.check;
34
+ var setupTestDb = helpers.setupTestDb;
35
+ var teardownTestDb = helpers.teardownTestDb;
36
+ var fs = require("fs");
37
+ var os = require("os");
38
+ var path = require("path");
39
+
40
+ var TIME = b.constants.TIME;
41
+ var SESSION_TABLE = "_blamejs_sessions";
42
+
43
+ function _mktmp(tag) {
44
+ return fs.mkdtempSync(path.join(os.tmpdir(), "ses-" + tag + "-"));
45
+ }
46
+
47
+ // A localDbThin session store whose rows we can age with raw SQL so the
48
+ // idle / absolute / operator-ttl timeout floors trip deterministically —
49
+ // no reliance on wall-clock elapsed time (poll-don't-sleep).
50
+ function _thinStore(tmpDir, tag) {
51
+ return b.session.stores.localDbThin({ file: path.join(tmpDir, tag + ".db"), audit: false });
52
+ }
53
+
54
+ // Age every row in the store's session table by rewriting the plain int
55
+ // columns (createdAt / lastActivity / expiresAt are not sealed).
56
+ function _ageAll(store, cols) {
57
+ var sets = [];
58
+ var params = [];
59
+ Object.keys(cols).forEach(function (c) { sets.push('"' + c + '" = ?'); params.push(cols[c]); });
60
+ return store.execute('UPDATE "' + SESSION_TABLE + '" SET ' + sets.join(", "), params);
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Block A — default store (framework DB). No row aging needed.
65
+ // ---------------------------------------------------------------------------
66
+
67
+ async function testBasicLifecycle() {
68
+ var tmpDir = _mktmp("life");
69
+ try {
70
+ await setupTestDb(tmpDir);
71
+ var s = await b.session.create({ userId: "u-1", data: { role: "admin" } });
72
+ check("create returns a sealed token + expiresAt",
73
+ typeof s.token === "string" && s.token.indexOf("vault:") === 0 && typeof s.expiresAt === "number");
74
+
75
+ var info = await b.session.verify(s.token);
76
+ check("verify returns the userId + data + timestamps",
77
+ info && info.userId === "u-1" && info.data.role === "admin" &&
78
+ typeof info.createdAt === "number" && typeof info.lastActivity === "number");
79
+ check("verify surfaces fingerprintDrift:false / anomalyScore:null on an unbound session",
80
+ info.fingerprintDrift === false && info.fingerprintAnomalyScore === null);
81
+
82
+ var n = await b.session.count();
83
+ check("count reports the one live session", n === 1);
84
+
85
+ var gone = await b.session.destroy(s.token);
86
+ check("destroy returns true", gone === true);
87
+ check("verify returns null after destroy", (await b.session.verify(s.token)) === null);
88
+ check("destroy again returns false (already gone)", (await b.session.destroy(s.token)) === false);
89
+ } finally {
90
+ await teardownTestDb(tmpDir);
91
+ }
92
+ }
93
+
94
+ async function testCreateInputValidation() {
95
+ var tmpDir = _mktmp("cval");
96
+ try {
97
+ await setupTestDb(tmpDir);
98
+
99
+ async function throws(fn) {
100
+ try { await fn(); return null; } catch (e) { return e; }
101
+ }
102
+
103
+ var e1 = await throws(function () { return b.session.create({}); });
104
+ check("create({}) with no userId throws INVALID_ARG", e1 && e1.code === "INVALID_ARG");
105
+ var e2 = await throws(function () { return b.session.create(); });
106
+ check("create() with no opts throws INVALID_ARG", e2 && e2.code === "INVALID_ARG");
107
+
108
+ var eNeg = await throws(function () { return b.session.create({ userId: "u", ttlMs: -5 }); });
109
+ check("create ttlMs negative throws", eNeg && eNeg.code === "INVALID_ARG");
110
+ var eZero = await throws(function () { return b.session.create({ userId: "u", ttlMs: 0 }); });
111
+ check("create ttlMs 0 throws", eZero && eZero.code === "INVALID_ARG");
112
+ var eInf = await throws(function () { return b.session.create({ userId: "u", ttlMs: Infinity }); });
113
+ check("create ttlMs Infinity throws (non-finite)", eInf && eInf.code === "INVALID_ARG");
114
+ var eNaN = await throws(function () { return b.session.create({ userId: "u", ttlMs: NaN }); });
115
+ check("create ttlMs NaN throws", eNaN && eNaN.code === "INVALID_ARG");
116
+ var eStr = await throws(function () { return b.session.create({ userId: "u", ttlMs: "3600000" }); });
117
+ check("create ttlMs string throws (not a number)", eStr && eStr.code === "INVALID_ARG");
118
+ var eMax = await throws(function () { return b.session.create({ userId: "u", ttlMs: TIME.days(4000) }); });
119
+ check("create ttlMs beyond ~10y ceiling throws", eMax && eMax.code === "INVALID_ARG" && /exceeds maximum/.test(eMax.message));
120
+ } finally {
121
+ await teardownTestDb(tmpDir);
122
+ }
123
+ }
124
+
125
+ async function testVerifyTokenGuards() {
126
+ var tmpDir = _mktmp("vguard");
127
+ try {
128
+ await setupTestDb(tmpDir);
129
+ check("verify('') returns null", (await b.session.verify("")) === null);
130
+ check("verify(null) returns null", (await b.session.verify(null)) === null);
131
+ check("verify(number) returns null", (await b.session.verify(12345)) === null);
132
+ check("verify(pre-sealed raw hex) returns null",
133
+ (await b.session.verify("deadbeefcafef00d".repeat(4))) === null);
134
+ check("verify(tampered vault: envelope) returns null",
135
+ (await b.session.verify("vault:not-real-ciphertext")) === null);
136
+ } finally {
137
+ await teardownTestDb(tmpDir);
138
+ }
139
+ }
140
+
141
+ async function testDestroyGuards() {
142
+ var tmpDir = _mktmp("dguard");
143
+ try {
144
+ await setupTestDb(tmpDir);
145
+ check("destroy('') returns false", (await b.session.destroy("")) === false);
146
+ check("destroy(null) returns false", (await b.session.destroy(null)) === false);
147
+ check("destroy(pre-sealed raw) returns false",
148
+ (await b.session.destroy("deadbeefcafef00d".repeat(4))) === false);
149
+ check("destroy(tampered vault: envelope) returns false",
150
+ (await b.session.destroy("vault:garbage")) === false);
151
+ } finally {
152
+ await teardownTestDb(tmpDir);
153
+ }
154
+ }
155
+
156
+ async function testDestroyAllForUserGuards() {
157
+ var tmpDir = _mktmp("daguard");
158
+ try {
159
+ await setupTestDb(tmpDir);
160
+ async function throws(fn) { try { await fn(); return null; } catch (e) { return e; } }
161
+ var e1 = await throws(function () { return b.session.destroyAllForUser(""); });
162
+ check("destroyAllForUser('') throws INVALID_ARG", e1 && e1.code === "INVALID_ARG");
163
+ var e2 = await throws(function () { return b.session.destroyAllForUser(null); });
164
+ check("destroyAllForUser(null) throws INVALID_ARG", e2 && e2.code === "INVALID_ARG");
165
+
166
+ // Happy path: two sessions for one user, both revoked in one call.
167
+ await b.session.create({ userId: "multi" });
168
+ await b.session.create({ userId: "multi" });
169
+ var revoked = await b.session.destroyAllForUser("multi");
170
+ check("destroyAllForUser revokes every live session for the user", revoked === 2);
171
+ check("destroyAllForUser on a user with no sessions returns 0",
172
+ (await b.session.destroyAllForUser("nobody")) === 0);
173
+ } finally {
174
+ await teardownTestDb(tmpDir);
175
+ }
176
+ }
177
+
178
+ async function testRotateVariants() {
179
+ var tmpDir = _mktmp("rot");
180
+ try {
181
+ await setupTestDb(tmpDir);
182
+ check("rotate('') returns null", (await b.session.rotate("")) === null);
183
+ check("rotate(tampered vault: envelope) returns null",
184
+ (await b.session.rotate("vault:garbage")) === null);
185
+
186
+ // Rotate on a destroyed (unknown) session → null.
187
+ var dead = await b.session.create({ userId: "u-dead" });
188
+ await b.session.destroy(dead.token);
189
+ check("rotate on a destroyed session returns null", (await b.session.rotate(dead.token)) === null);
190
+
191
+ // Rotate replacing data + shrinking ttl to 1h (verifies both the
192
+ // data-replace and expiresAt-set branches).
193
+ var s1 = await b.session.create({ userId: "u-rot1", data: { a: 1, keep: "x" }, ttlMs: TIME.days(7) });
194
+ var r1 = await b.session.rotate(s1.token, { data: { b: 2 }, ttlMs: TIME.hours(1), reason: "login" });
195
+ check("rotate returns a fresh sealed token", r1 && r1.token.indexOf("vault:") === 0 && r1.token !== s1.token);
196
+ check("old token no longer verifies after rotate", (await b.session.verify(s1.token)) === null);
197
+ var v1 = await b.session.verify(r1.token);
198
+ check("rotate replaced the data payload (b present, a/keep gone)",
199
+ v1 && v1.data && v1.data.b === 2 && v1.data.a === undefined && v1.data.keep === undefined);
200
+ check("rotate ttlMs shrank expiresAt to ~now+1h (well under 7d)",
201
+ v1.expiresAt <= Date.now() + TIME.hours(2));
202
+
203
+ // Rotate WITHOUT opts.data carries the existing payload forward unchanged.
204
+ var s2 = await b.session.create({ userId: "u-rot2", data: { carried: true } });
205
+ var r2 = await b.session.rotate(s2.token);
206
+ var v2 = await b.session.verify(r2.token);
207
+ check("rotate without opts.data carries the existing payload forward",
208
+ v2 && v2.data && v2.data.carried === true);
209
+
210
+ // Rotate rejects a bad ttlMs (config-time throw).
211
+ var s3 = await b.session.create({ userId: "u-rot3" });
212
+ var eTtl = null;
213
+ try { await b.session.rotate(s3.token, { ttlMs: -1 }); } catch (e) { eTtl = e; }
214
+ check("rotate rejects a negative ttlMs", eTtl && eTtl.code === "INVALID_ARG");
215
+ } finally {
216
+ await teardownTestDb(tmpDir);
217
+ }
218
+ }
219
+
220
+ async function testLogoutValidation() {
221
+ var tmpDir = _mktmp("lval");
222
+ try {
223
+ await setupTestDb(tmpDir);
224
+ var s = await b.session.create({ userId: "u-lo" });
225
+
226
+ async function throws(fn) { try { await fn(); return null; } catch (e) { return e; } }
227
+
228
+ var eEmpty = await throws(function () {
229
+ return b.session.logout({ setHeader: function () {} }, s.token, { cookieName: "" });
230
+ });
231
+ check("logout rejects an empty cookieName", eEmpty && eEmpty.code === "session/bad-cookie-name");
232
+ var eNonStr = await throws(function () {
233
+ return b.session.logout({ setHeader: function () {} }, s.token, { cookieName: 42 });
234
+ });
235
+ check("logout rejects a non-string cookieName", eNonStr && eNonStr.code === "session/bad-cookie-name");
236
+
237
+ // The rejected logouts must not have destroyed the row (validate-before-revoke).
238
+ check("logout validation throw left the session intact", (await b.session.verify(s.token)) !== null);
239
+ } finally {
240
+ await teardownTestDb(tmpDir);
241
+ }
242
+ }
243
+
244
+ // Custom function-form fingerprint fields — both a value-returning fn and a
245
+ // throwing fn (the catch substitutes ""), proven to bind + match on verify.
246
+ async function testFingerprintFunctionField() {
247
+ var tmpDir = _mktmp("fpfn");
248
+ try {
249
+ await setupTestDb(tmpDir);
250
+ function tenantTag(req) { return String((req.headers && req.headers["x-tenant"]) || ""); }
251
+ function boom() { throw new Error("scorer-style throw"); }
252
+ // An anonymous field (name "") exercises the "custom<i>" naming fallback.
253
+ function makeAnon() { return function (req) { return String((req.headers && req.headers["x-anon"]) || ""); }; }
254
+ var req = { headers: { "x-tenant": "acme", "x-anon": "z" }, socket: { remoteAddress: "203.0.113.9" } };
255
+ var fields1 = [tenantTag, boom, makeAnon()];
256
+ var fields2 = [tenantTag, boom, makeAnon()];
257
+
258
+ var s = await b.session.create({ userId: "u-fp", req: req, fingerprintFields: fields1 });
259
+ var same = await b.session.verify(s.token, { req: req, fingerprintFields: fields2 });
260
+ check("custom fn fingerprint field binds + matches (no drift)", same && same.fingerprintDrift === false);
261
+
262
+ var other = { headers: { "x-tenant": "evilcorp", "x-anon": "z" }, socket: { remoteAddress: "203.0.113.9" } };
263
+ var drift = await b.session.verify(s.token, { req: other, fingerprintFields: fields2 });
264
+ check("custom fn fingerprint field drifts when its value changes", drift && drift.fingerprintDrift === true);
265
+ } finally {
266
+ await teardownTestDb(tmpDir);
267
+ }
268
+ }
269
+
270
+ // Odds-and-ends guards: validFrom on an empty subject, and updateData on a
271
+ // token whose sid unseals fine but whose row is already gone.
272
+ async function testMiscGuards() {
273
+ var tmpDir = _mktmp("misc");
274
+ try {
275
+ await setupTestDb(tmpDir);
276
+ check("validFrom('') returns 0 (no subject)", (await b.session.validFrom("")) === 0);
277
+
278
+ var s = await b.session.create({ userId: "u-misc", data: { a: 1 } });
279
+ await b.session.destroy(s.token);
280
+ check("updateData on a valid-sid but deleted row returns false",
281
+ (await b.session.updateData(s.token, { b: 2 })) === false);
282
+ } finally {
283
+ await teardownTestDb(tmpDir);
284
+ }
285
+ }
286
+
287
+ // count()'s defensive null-guard: a store whose executeOne yields no COUNT
288
+ // row makes count() return 0 rather than crash. Real SQL backends always
289
+ // return a row for COUNT(*), so this exercises the fail-safe path only a
290
+ // store contract violation can reach.
291
+ async function testCountDefensiveNullRow() {
292
+ var tmpDir = _mktmp("cnt0");
293
+ try {
294
+ await setupTestDb(tmpDir);
295
+ b.session.useStore({
296
+ execute: function () { return Promise.resolve({ rows: [], rowCount: 0 }); },
297
+ executeOne: function () { return Promise.resolve(null); },
298
+ });
299
+ check("count() returns 0 when the store yields no count row", (await b.session.count()) === 0);
300
+ } finally {
301
+ b.session.useStore(null);
302
+ await teardownTestDb(tmpDir);
303
+ }
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Block B — localDbThin store, rows aged with raw SQL so timeout floors trip
308
+ // deterministically.
309
+ // ---------------------------------------------------------------------------
310
+
311
+ async function testVerifyExpiredTtl() {
312
+ var tmpDir = _mktmp("vexp");
313
+ var store = _thinStore(tmpDir, "vexp");
314
+ try {
315
+ await setupTestDb(tmpDir);
316
+ b.session.useStore(store);
317
+ var s = await b.session.create({ userId: "u-exp" });
318
+ // Age the operator-set expiry into the past.
319
+ await _ageAll(store, { expiresAt: Date.now() - TIME.minutes(1) });
320
+ check("verify returns null for a session past its operator ttl",
321
+ (await b.session.verify(s.token)) === null);
322
+ // The leader-side cleanup deleted the row.
323
+ check("expired-ttl verify purged the row (count 0)", (await b.session.count()) === 0);
324
+ } finally {
325
+ b.session.useStore(null);
326
+ try { store.close(); } catch (_e) { /* best-effort */ }
327
+ await teardownTestDb(tmpDir);
328
+ }
329
+ }
330
+
331
+ async function testVerifyIdleFloor() {
332
+ var tmpDir = _mktmp("vidle");
333
+ var store = _thinStore(tmpDir, "vidle");
334
+ try {
335
+ await setupTestDb(tmpDir);
336
+ b.session.useStore(store);
337
+ var s = await b.session.create({ userId: "u-idle" });
338
+ // expiresAt stays in the future; lastActivity is aged past the 30m idle floor.
339
+ await _ageAll(store, { lastActivity: Date.now() - TIME.minutes(45) });
340
+ check("verify returns null when the idle floor is breached",
341
+ (await b.session.verify(s.token)) === null);
342
+ check("idle-floor verify purged the row (count 0)", (await b.session.count()) === 0);
343
+
344
+ // Explicit idle opt-out (idleTimeoutMs:0) keeps an idle-aged session usable.
345
+ var s2 = await b.session.create({ userId: "u-idle2" });
346
+ await _ageAll(store, { lastActivity: Date.now() - TIME.minutes(45) });
347
+ var ok = await b.session.verify(s2.token, { idleTimeoutMs: 0, absoluteTimeoutMs: 0 });
348
+ check("verify with idle+absolute disabled returns an idle-aged session", ok && ok.userId === "u-idle2");
349
+ } finally {
350
+ b.session.useStore(null);
351
+ try { store.close(); } catch (_e) { /* best-effort */ }
352
+ await teardownTestDb(tmpDir);
353
+ }
354
+ }
355
+
356
+ async function testVerifyAbsoluteFloor() {
357
+ var tmpDir = _mktmp("vabs");
358
+ var store = _thinStore(tmpDir, "vabs");
359
+ try {
360
+ await setupTestDb(tmpDir);
361
+ b.session.useStore(store);
362
+ var s = await b.session.create({ userId: "u-abs" });
363
+ // lastActivity recent (idle OK), createdAt past the 12h absolute ceiling.
364
+ await _ageAll(store, { lastActivity: Date.now(), createdAt: Date.now() - TIME.hours(13) });
365
+ check("verify returns null when the absolute floor is breached",
366
+ (await b.session.verify(s.token)) === null);
367
+ check("absolute-floor verify purged the row (count 0)", (await b.session.count()) === 0);
368
+ } finally {
369
+ b.session.useStore(null);
370
+ try { store.close(); } catch (_e) { /* best-effort */ }
371
+ await teardownTestDb(tmpDir);
372
+ }
373
+ }
374
+
375
+ async function testTouchLifecycleAndFloor() {
376
+ var tmpDir = _mktmp("touch");
377
+ var store = _thinStore(tmpDir, "touch");
378
+ try {
379
+ await setupTestDb(tmpDir);
380
+ b.session.useStore(store);
381
+
382
+ // Token-shape guards.
383
+ check("touch('') returns false", (await b.session.touch("")) === false);
384
+ check("touch(pre-sealed raw) returns false",
385
+ (await b.session.touch("deadbeefcafef00d".repeat(4))) === false);
386
+
387
+ // touch on an unknown (destroyed) session → false (no floorRow).
388
+ var dead = await b.session.create({ userId: "u-t-dead" });
389
+ await b.session.destroy(dead.token);
390
+ check("touch on a destroyed session returns false", (await b.session.touch(dead.token)) === false);
391
+
392
+ // Plain touch bumps lastActivity forward: age it into the past, touch,
393
+ // then read it back through verify.
394
+ var s = await b.session.create({ userId: "u-touch" });
395
+ await _ageAll(store, { lastActivity: Date.now() - TIME.minutes(10) });
396
+ var touched = await b.session.touch(s.token);
397
+ check("touch returns true for a live session", touched === true);
398
+ var after = await b.session.verify(s.token);
399
+ check("touch bumped lastActivity forward to ~now",
400
+ after && after.lastActivity >= Date.now() - TIME.minutes(1));
401
+
402
+ // extendBy resets expiresAt relative to now.
403
+ var ext = await b.session.touch(s.token, { extendBy: TIME.hours(3) });
404
+ check("touch({extendBy}) returns true", ext === true);
405
+ var afterExt = await b.session.verify(s.token);
406
+ check("touch extendBy moved expiresAt to ~now+3h",
407
+ afterExt && afterExt.expiresAt <= Date.now() + TIME.hours(4) && afterExt.expiresAt > Date.now() + TIME.hours(2));
408
+
409
+ // touch rejects a bad extendBy (config-time throw).
410
+ var eExt = null;
411
+ try { await b.session.touch(s.token, { extendBy: -1 }); } catch (e) { eExt = e; }
412
+ check("touch rejects a negative extendBy", eExt && eExt.code === "INVALID_ARG");
413
+
414
+ // touch on an idle-aged session refuses AND purges (floor breach).
415
+ var s2 = await b.session.create({ userId: "u-touch-idle" });
416
+ await _ageAll(store, { lastActivity: Date.now() - TIME.minutes(45) });
417
+ var refused = await b.session.touch(s2.token);
418
+ check("touch on an idle-floor-breached session returns false", refused === false);
419
+ check("touch floor breach purged the row (verify null)", (await b.session.verify(s2.token)) === null);
420
+ } finally {
421
+ b.session.useStore(null);
422
+ try { store.close(); } catch (_e) { /* best-effort */ }
423
+ await teardownTestDb(tmpDir);
424
+ }
425
+ }
426
+
427
+ async function testPurgeExpired() {
428
+ var tmpDir = _mktmp("purge");
429
+ var store = _thinStore(tmpDir, "purge");
430
+ try {
431
+ await setupTestDb(tmpDir);
432
+ b.session.useStore(store);
433
+
434
+ await b.session.create({ userId: "u-p1" });
435
+ await b.session.create({ userId: "u-p2" });
436
+ check("count sees both live sessions", (await b.session.count()) === 2);
437
+
438
+ // Age both past expiry — count() now excludes them but they remain on disk.
439
+ await _ageAll(store, { expiresAt: Date.now() - TIME.minutes(5) });
440
+ check("count excludes expired-but-unpurged rows", (await b.session.count()) === 0);
441
+
442
+ var dropped = await b.session.purgeExpired();
443
+ check("purgeExpired removes the expired rows", dropped === 2);
444
+ check("purgeExpired again removes nothing (returns 0)", (await b.session.purgeExpired()) === 0);
445
+
446
+ // A fresh live session survives a purge.
447
+ var live = await b.session.create({ userId: "u-live" });
448
+ check("purgeExpired leaves a live session (returns 0)", (await b.session.purgeExpired()) === 0);
449
+ check("the live session still verifies after purge", (await b.session.verify(live.token)) !== null);
450
+ } finally {
451
+ b.session.useStore(null);
452
+ try { store.close(); } catch (_e) { /* best-effort */ }
453
+ await teardownTestDb(tmpDir);
454
+ }
455
+ }
456
+
457
+ // A sealed `data` cell that decrypts to a valid envelope but non-JSON
458
+ // plaintext (key-rotation skew / corruption). verify() must null the data,
459
+ // keep the session usable for non-data flows, and updateData() must recover
460
+ // by writing the fresh payload over the unparseable one.
461
+ async function testUnparseableDataRecovery() {
462
+ var tmpDir = _mktmp("garbage");
463
+ var store = _thinStore(tmpDir, "garbage");
464
+ try {
465
+ await setupTestDb(tmpDir);
466
+ b.session.useStore(store);
467
+
468
+ // A valid vault: envelope whose plaintext is not JSON.
469
+ var garbage = b.cryptoField.sealRow(SESSION_TABLE, { data: "not-json{{{" }).data;
470
+ check("prepared a sealed envelope with non-JSON plaintext",
471
+ typeof garbage === "string" && garbage.indexOf("vault:") === 0);
472
+
473
+ var s = await b.session.create({ userId: "u-garbage", data: { real: 1 } });
474
+ await store.execute('UPDATE "' + SESSION_TABLE + '" SET "data" = ?', [garbage]);
475
+
476
+ var info = await b.session.verify(s.token);
477
+ check("verify returns the session with data=null when data can't be parsed",
478
+ info && info.userId === "u-garbage" && info.data === null);
479
+
480
+ // updateData over an unparseable payload still lands the new data.
481
+ var wrote = await b.session.updateData(s.token, { fresh: true });
482
+ check("updateData recovers from an unparseable existing payload", wrote === true);
483
+ var after = await b.session.verify(s.token);
484
+ check("updateData wrote the fresh payload", after && after.data && after.data.fresh === true);
485
+ } finally {
486
+ b.session.useStore(null);
487
+ try { store.close(); } catch (_e) { /* best-effort */ }
488
+ await teardownTestDb(tmpDir);
489
+ }
490
+ }
491
+
492
+ async function run() {
493
+ // Block A — default store.
494
+ await testBasicLifecycle();
495
+ await testCreateInputValidation();
496
+ await testVerifyTokenGuards();
497
+ await testDestroyGuards();
498
+ await testDestroyAllForUserGuards();
499
+ await testRotateVariants();
500
+ await testLogoutValidation();
501
+ await testFingerprintFunctionField();
502
+ await testMiscGuards();
503
+ await testCountDefensiveNullRow();
504
+ // Block B — localDbThin store with deterministic row aging.
505
+ await testVerifyExpiredTtl();
506
+ await testVerifyIdleFloor();
507
+ await testVerifyAbsoluteFloor();
508
+ await testTouchLifecycleAndFloor();
509
+ await testRotateUpdateDataEnforceFloor();
510
+ await testStrictBindingWithoutReqFailsClosed();
511
+ await testPurgeExpired();
512
+ await testUnparseableDataRecovery();
513
+ }
514
+
515
+ // rotate() and updateData() must enforce the SAME idle/absolute floor
516
+ // verify()/touch() do — a refresh/write must never resurrect a session
517
+ // verify() would expire (OWASP ASVS 5.0 §3.3 / NIST SP 800-63B-4).
518
+ async function testRotateUpdateDataEnforceFloor() {
519
+ var tmpDir = _mktmp("vfloor");
520
+ var store = _thinStore(tmpDir, "vfloor");
521
+ try {
522
+ await setupTestDb(tmpDir);
523
+ b.session.useStore(store);
524
+
525
+ var s1 = await b.session.create({ userId: "u-rot-idle" });
526
+ await _ageAll(store, { lastActivity: Date.now() - TIME.minutes(45) }); // idle floor breached, TTL still live
527
+ check("rotate on an idle-floor-breached session returns null",
528
+ (await b.session.rotate(s1.token)) === null);
529
+ check("rotate floor breach purged the row (count 0)", (await b.session.count()) === 0);
530
+
531
+ var s2 = await b.session.create({ userId: "u-upd-idle" });
532
+ await _ageAll(store, { lastActivity: Date.now() - TIME.minutes(45) });
533
+ check("updateData on an idle-floor-breached session returns false",
534
+ (await b.session.updateData(s2.token, { a: 1 })) === false);
535
+ check("updateData floor breach purged the row (verify null)",
536
+ (await b.session.verify(s2.token)) === null);
537
+
538
+ var s3 = await b.session.create({ userId: "u-rot-abs" });
539
+ await _ageAll(store, { lastActivity: Date.now(), createdAt: Date.now() - TIME.hours(13) }); // absolute floor breached
540
+ check("rotate on an absolute-floor-breached session returns null",
541
+ (await b.session.rotate(s3.token)) === null);
542
+
543
+ // The floor policy is per-call: a deployment disabling the idle floor via
544
+ // idleTimeoutMs:0 must have that honored by rotate/updateData too, exactly
545
+ // as verify() does — a long-idle session is NOT purged when the override is
546
+ // passed.
547
+ var s4 = await b.session.create({ userId: "u-ov-rot" });
548
+ await _ageAll(store, { lastActivity: Date.now() - TIME.minutes(45) });
549
+ check("rotate with idleTimeoutMs:0 keeps a long-idle session (override respected)",
550
+ (await b.session.rotate(s4.token, { idleTimeoutMs: 0, absoluteTimeoutMs: 0 })) !== null);
551
+
552
+ var s5 = await b.session.create({ userId: "u-ov-upd" });
553
+ await _ageAll(store, { lastActivity: Date.now() - TIME.minutes(45) });
554
+ check("updateData with idleTimeoutMs:0 keeps a long-idle session (override respected)",
555
+ (await b.session.updateData(s5.token, { x: 1 }, { idleTimeoutMs: 0, absoluteTimeoutMs: 0 })) === true);
556
+ } finally {
557
+ b.session.useStore(null);
558
+ try { store.close(); } catch (_e) { /* best-effort */ }
559
+ await teardownTestDb(tmpDir);
560
+ }
561
+ }
562
+
563
+ // A strict binding flag (requireFingerprintMatch / maxAnomalyScore) asserted
564
+ // WITHOUT a req cannot compute the current device fingerprint, so verify must
565
+ // fail CLOSED — never silently admit the session from any device.
566
+ async function testStrictBindingWithoutReqFailsClosed() {
567
+ var tmpDir = _mktmp("sbind");
568
+ var store = _thinStore(tmpDir, "sbind");
569
+ try {
570
+ await setupTestDb(tmpDir);
571
+ b.session.useStore(store);
572
+ var s = await b.session.create({ userId: "u-strict" });
573
+ check("verify requireFingerprintMatch without req → null (fail closed)",
574
+ (await b.session.verify(s.token, { requireFingerprintMatch: true })) === null);
575
+ check("verify maxAnomalyScore without req → null (fail closed)",
576
+ (await b.session.verify(s.token, { maxAnomalyScore: 0.5 })) === null);
577
+ check("non-strict verify without req still admits (unchanged)",
578
+ (await b.session.verify(s.token)) !== null);
579
+ } finally {
580
+ b.session.useStore(null);
581
+ try { store.close(); } catch (_e) { /* best-effort */ }
582
+ await teardownTestDb(tmpDir);
583
+ }
584
+ }
585
+
586
+ module.exports = { run: run };
587
+
588
+ if (require.main === module) {
589
+ run().then(
590
+ function () { console.log("OK session — " + helpers.getChecks() + " checks passed"); },
591
+ function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
592
+ );
593
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.5.14",
3
+ "version": "0.5.16",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {