mbkauthe 5.1.1 → 5.2.1

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,592 @@
1
+ import { readFile } from 'fs/promises';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ import { SqlitePool } from '../db/sqlitePool.js';
6
+ import { translatePgToSqlite } from '../db/sqlSqliteTranslate.js';
7
+ import { SqliteSessionStore } from '../session/SqliteSessionStore.js';
8
+ import { sqliteDialect } from '../db/dialects/sqlite.js';
9
+ import { AuthRepository } from '../db/AuthRepository.js';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const SCHEMA_PATH = path.join(__dirname, '../../docs/schema/db.sqlite.sql');
13
+
14
+ let schemaSql;
15
+
16
+ beforeAll(async () => {
17
+ schemaSql = await readFile(SCHEMA_PATH, 'utf8');
18
+ });
19
+
20
+ /** Fresh in-memory database with the real schema applied. */
21
+ function createPool() {
22
+ const pool = new SqlitePool(':memory:');
23
+ pool.execScript(schemaSql);
24
+ return pool;
25
+ }
26
+
27
+ async function insertUser(pool, { username, role = 'NormalUser', active = 1, allowedApps } = {}) {
28
+ const columns = ['"UserName"', '"PasswordEnc"', '"Role"', '"Active"'];
29
+ const values = [username, 'test-hash', role, active];
30
+ if (allowedApps !== undefined) {
31
+ columns.push('"AllowedApps"');
32
+ values.push(JSON.stringify(allowedApps));
33
+ }
34
+ await pool.query(
35
+ `INSERT INTO "Users" (${columns.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`,
36
+ values
37
+ );
38
+ }
39
+
40
+ describe('translatePgToSqlite', () => {
41
+ test('passes native ?-placeholder queries through with values intact', () => {
42
+ const { text, values } = translatePgToSqlite(
43
+ 'SELECT sess FROM "session" WHERE sid = ?',
44
+ ['abc']
45
+ );
46
+ expect(text).toBe('SELECT sess FROM "session" WHERE sid = ?');
47
+ expect(values).toEqual(['abc']);
48
+ });
49
+
50
+ test('rewrites $N placeholders to ? preserving order', () => {
51
+ const { text, values } = translatePgToSqlite(
52
+ 'SELECT * FROM "Users" WHERE "UserName" = $1 AND "Role" = $2',
53
+ ['support', 'SuperAdmin']
54
+ );
55
+ expect(text).toBe('SELECT * FROM "Users" WHERE "UserName" = ? AND "Role" = ?');
56
+ expect(values).toEqual(['support', 'SuperAdmin']);
57
+ });
58
+
59
+ test('handles repeated $N references by duplicating the value', () => {
60
+ const { text, values } = translatePgToSqlite(
61
+ 'SELECT * FROM t WHERE a = $1 OR b = $1',
62
+ ['x']
63
+ );
64
+ expect(text).toBe('SELECT * FROM t WHERE a = ? OR b = ?');
65
+ expect(values).toEqual(['x', 'x']);
66
+ });
67
+
68
+ test('expands = ANY($1) with an array into IN (?, ?, ...)', () => {
69
+ const { text, values } = translatePgToSqlite(
70
+ 'DELETE FROM "Sessions" WHERE id = ANY($1)',
71
+ [['a', 'b', 'c']]
72
+ );
73
+ expect(text).toBe('DELETE FROM "Sessions" WHERE id IN (?, ?, ?)');
74
+ expect(values).toEqual(['a', 'b', 'c']);
75
+ });
76
+
77
+ test('expands = ANY($1) with an empty array into IN (NULL)', () => {
78
+ const { text, values } = translatePgToSqlite(
79
+ 'DELETE FROM "Sessions" WHERE id = ANY($1)',
80
+ [[]]
81
+ );
82
+ expect(text).toBe('DELETE FROM "Sessions" WHERE id IN (NULL)');
83
+ expect(values).toEqual([]);
84
+ });
85
+
86
+ test('converts NOW(), TRUE/FALSE literals, and strips ::type casts', () => {
87
+ const { text } = translatePgToSqlite(
88
+ 'SELECT COUNT(*)::int AS c FROM t WHERE ok = TRUE AND bad = FALSE AND ts <= NOW()',
89
+ []
90
+ );
91
+ expect(text).toBe(
92
+ 'SELECT COUNT(*) AS c FROM t WHERE ok = 1 AND bad = 0 AND ts <= CURRENT_TIMESTAMP'
93
+ );
94
+ });
95
+ });
96
+
97
+ describe('SqlitePool bind-value coercion', () => {
98
+ let pool;
99
+
100
+ beforeEach(() => {
101
+ pool = createPool();
102
+ });
103
+
104
+ afterEach(async () => {
105
+ await pool.end();
106
+ });
107
+
108
+ test('binds Date values as UTC CURRENT_TIMESTAMP-format text', async () => {
109
+ await insertUser(pool, { username: 'u1' });
110
+ const expiresAt = new Date('2030-01-02T03:04:05.678Z');
111
+ await pool.query({
112
+ text: 'INSERT INTO "Sessions" ("UserName", expires_at) VALUES ($1, $2)',
113
+ values: ['u1', expiresAt]
114
+ });
115
+
116
+ // Read the raw stored text via a column normalizeRow does not touch.
117
+ const raw = await pool.query(
118
+ 'SELECT CAST(expires_at AS TEXT) AS raw_expires FROM "Sessions"'
119
+ );
120
+ expect(raw.rows[0].raw_expires).toBe('2030-01-02 03:04:05');
121
+ });
122
+
123
+ test('binds booleans as 1/0 and undefined as null', async () => {
124
+ await insertUser(pool, { username: 'u1' });
125
+ await pool.query({
126
+ text: 'UPDATE "Users" SET "Active" = $1, "FullName" = $2 WHERE "UserName" = $3',
127
+ values: [false, undefined, 'u1']
128
+ });
129
+ const row = (await pool.query('SELECT "Active", "FullName" FROM "Users" WHERE "UserName" = ?', ['u1'])).rows[0];
130
+ expect(row.Active).toBe(0);
131
+ expect(row.FullName).toBeNull();
132
+ });
133
+
134
+ test('serializes plain objects and arrays to JSON like the pg driver', async () => {
135
+ await insertUser(pool, { username: 'u1' });
136
+ const meta = { ip: '::1', nested: { depth: 2 } };
137
+ const inserted = await pool.query({
138
+ text: 'INSERT INTO "Sessions" ("UserName", meta) VALUES ($1, $2) RETURNING id',
139
+ values: ['u1', meta]
140
+ });
141
+ const row = (await pool.query({
142
+ text: 'SELECT meta FROM "Sessions" WHERE id = $1',
143
+ values: [inserted.rows[0].id]
144
+ })).rows[0];
145
+ expect(row.meta).toEqual(meta);
146
+ });
147
+ });
148
+
149
+ describe('SqlitePool row normalization (pg result-shape parity)', () => {
150
+ let pool;
151
+
152
+ beforeEach(() => {
153
+ pool = createPool();
154
+ });
155
+
156
+ afterEach(async () => {
157
+ await pool.end();
158
+ });
159
+
160
+ test('returns JSON columns as parsed values, not strings', async () => {
161
+ await insertUser(pool, { username: 'u1', allowedApps: ['Portal', 'mbkauthe'] });
162
+ const row = (await pool.query({
163
+ text: 'SELECT "AllowedApps", "Positions", "SocialAccounts" FROM "Users" WHERE "UserName" = $1',
164
+ values: ['u1']
165
+ })).rows[0];
166
+
167
+ expect(Array.isArray(row.AllowedApps)).toBe(true);
168
+ expect(row.AllowedApps).toEqual(['Portal', 'mbkauthe']);
169
+ expect(typeof row.Positions).toBe('object');
170
+ expect(typeof row.SocialAccounts).toBe('object');
171
+ });
172
+
173
+ test('parses aliased JSON column user_allowed_apps (getApiTokenByHash shape)', async () => {
174
+ await insertUser(pool, { username: 'u1' });
175
+ await pool.query({
176
+ text: 'INSERT INTO "ApiTokens" ("UserName", "Name", "TokenHash", "Prefix") VALUES ($1, $2, $3, $4)',
177
+ values: ['u1', 'token', 'hash-1', 'mbk_']
178
+ });
179
+ const row = (await pool.query({
180
+ text: `SELECT t."Permissions", u."AllowedApps" AS user_allowed_apps
181
+ FROM "ApiTokens" t JOIN "Users" u ON t."UserName" = u."UserName"
182
+ WHERE t."TokenHash" = $1`,
183
+ values: ['hash-1']
184
+ })).rows[0];
185
+
186
+ expect(row.Permissions).toEqual({ scope: 'read-only', allowedApps: null });
187
+ expect(Array.isArray(row.user_allowed_apps)).toBe(true);
188
+ });
189
+
190
+ test('returns timestamp columns as UTC Date objects without timezone shift', async () => {
191
+ await insertUser(pool, { username: 'u1' });
192
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
193
+ await pool.query({
194
+ text: 'INSERT INTO "Sessions" ("UserName", expires_at) VALUES ($1, $2)',
195
+ values: ['u1', expiresAt]
196
+ });
197
+ const row = (await pool.query('SELECT expires_at, created_at FROM "Sessions"')).rows[0];
198
+
199
+ expect(row.expires_at).toBeInstanceOf(Date);
200
+ expect(row.created_at).toBeInstanceOf(Date);
201
+ // Storage truncates milliseconds; anything beyond that indicates the
202
+ // local-time parsing bug (offset would be whole minutes/hours).
203
+ expect(Math.abs(row.expires_at.getTime() - expiresAt.getTime())).toBeLessThan(1000);
204
+ expect(Math.abs(row.created_at.getTime() - Date.now())).toBeLessThan(5000);
205
+ });
206
+
207
+ test('leaves session-store columns sess and expire as raw strings', async () => {
208
+ await pool.query(
209
+ 'INSERT INTO "session" (sid, sess, expire) VALUES (?, ?, ?)',
210
+ ['sid-1', '{"cookie":{}}', '2030-01-01 00:00:00']
211
+ );
212
+ const row = (await pool.query('SELECT sess, expire FROM "session" WHERE sid = ?', ['sid-1'])).rows[0];
213
+ expect(typeof row.sess).toBe('string');
214
+ expect(typeof row.expire).toBe('string');
215
+ });
216
+
217
+ test('leaves non-JSON text that happens to sit in a JSON column intact on parse failure', async () => {
218
+ await pool.query('INSERT INTO "Users" ("UserName", "AllowedApps") VALUES (?, ?)', ['u1', 'not-json']);
219
+ const row = (await pool.query('SELECT "AllowedApps" FROM "Users" WHERE "UserName" = ?', ['u1'])).rows[0];
220
+ expect(row.AllowedApps).toBe('not-json');
221
+ });
222
+ });
223
+
224
+ describe('SqlitePool transaction mutex', () => {
225
+ let pool;
226
+
227
+ beforeEach(() => {
228
+ pool = createPool();
229
+ pool.execScript('CREATE TABLE t (id INTEGER PRIMARY KEY, who TEXT)');
230
+ });
231
+
232
+ afterEach(async () => {
233
+ await pool.end();
234
+ });
235
+
236
+ async function runTx(who, { fail = false, delayMs = 10 } = {}) {
237
+ const client = await pool.connect();
238
+ try {
239
+ await client.query('BEGIN');
240
+ await client.query('INSERT INTO t (who) VALUES ($1)', [who]);
241
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
242
+ await client.query('INSERT INTO t (who) VALUES ($1)', [`${who}-2`]);
243
+ if (fail) throw new Error('boom');
244
+ await client.query('COMMIT');
245
+ } catch (err) {
246
+ await client.query('ROLLBACK').catch(() => {});
247
+ if (err.message !== 'boom') throw err;
248
+ } finally {
249
+ client.release();
250
+ }
251
+ }
252
+
253
+ test('concurrent transactions do not nest or corrupt each other', async () => {
254
+ await Promise.all([runTx('alice'), runTx('bob'), runTx('carol')]);
255
+ const rows = (await pool.query('SELECT who FROM t ORDER BY id')).rows.map((r) => r.who);
256
+ expect(rows).toHaveLength(6);
257
+ for (const who of ['alice', 'bob', 'carol']) {
258
+ expect(rows).toContain(who);
259
+ expect(rows).toContain(`${who}-2`);
260
+ }
261
+ });
262
+
263
+ test('a rolled-back transaction does not take bystander pool queries with it', async () => {
264
+ await Promise.all([
265
+ runTx('good'),
266
+ runTx('bad', { fail: true }),
267
+ pool.query('INSERT INTO t (who) VALUES (?)', ['bystander'])
268
+ ]);
269
+ const rows = (await pool.query('SELECT who FROM t ORDER BY id')).rows.map((r) => r.who);
270
+ expect(rows).toContain('good');
271
+ expect(rows).toContain('bystander');
272
+ expect(rows.filter((w) => w.startsWith('bad'))).toHaveLength(0);
273
+ });
274
+
275
+ test('plain pool queries queue behind an open transaction instead of joining it', async () => {
276
+ const order = [];
277
+ const client = await pool.connect();
278
+ await client.query('BEGIN');
279
+ await client.query('INSERT INTO t (who) VALUES ($1)', ['tx']);
280
+
281
+ const bystander = pool
282
+ .query('INSERT INTO t (who) VALUES (?)', ['queued'])
283
+ .then(() => order.push('bystander-ran'));
284
+
285
+ // Give the bystander a chance to (incorrectly) run inside the transaction.
286
+ await new Promise((resolve) => setTimeout(resolve, 20));
287
+ order.push('rollback');
288
+ await client.query('ROLLBACK');
289
+ client.release();
290
+ await bystander;
291
+
292
+ expect(order).toEqual(['rollback', 'bystander-ran']);
293
+ const rows = (await pool.query('SELECT who FROM t')).rows.map((r) => r.who);
294
+ expect(rows).toEqual(['queued']); // tx row rolled back, bystander survived
295
+ });
296
+
297
+ test('double release is harmless and the pool stays usable', async () => {
298
+ const client = await pool.connect();
299
+ client.release();
300
+ client.release();
301
+ const result = await pool.query('SELECT 1 AS one');
302
+ expect(result.rows[0].one).toBe(1);
303
+ });
304
+ });
305
+
306
+ describe('AuthRepository against the SQLite schema', () => {
307
+ let pool;
308
+ let repo;
309
+
310
+ beforeEach(async () => {
311
+ pool = createPool();
312
+ repo = new AuthRepository({ db: pool, dialect: sqliteDialect });
313
+ await insertUser(pool, { username: 'normal', allowedApps: ['Portal', 'mbkauthe'] });
314
+ });
315
+
316
+ afterEach(async () => {
317
+ await pool.end();
318
+ });
319
+
320
+ test('schema seeds the support SuperAdmin user', async () => {
321
+ const row = (await pool.query({
322
+ text: 'SELECT "UserName", "Role", "Active", "PasswordEnc" FROM "Users" WHERE "UserName" = $1',
323
+ values: ['support']
324
+ })).rows[0];
325
+ expect(row).toBeDefined();
326
+ expect(row.Role).toBe('SuperAdmin');
327
+ expect(row.Active).toBe(1);
328
+ expect(row.PasswordEnc).toBeTruthy();
329
+ });
330
+
331
+ test('getUserWithTwoFA returns the pg-compatible login row shape', async () => {
332
+ const user = await repo.getUserWithTwoFA('normal');
333
+ expect(user.UserName).toBe('normal');
334
+ expect(user.Active).toBeTruthy();
335
+ expect(Array.isArray(user.AllowedApps)).toBe(true);
336
+ // The login route's app-authorization check must not throw.
337
+ expect(user.AllowedApps.some((app) => app && app.toLowerCase() === 'mbkauthe')).toBe(true);
338
+ expect(user.TwoFAStatus ?? null).toBeNull(); // LEFT JOIN with no TwoFA row
339
+ });
340
+
341
+ test('insertAppSession stores a Date expiry and returns a UUID id', async () => {
342
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
343
+ const inserted = await repo.insertAppSession('normal', expiresAt, JSON.stringify({ ip: '::1' }));
344
+ expect(inserted.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
345
+
346
+ const session = await repo.fetchActiveSession(inserted.id);
347
+ expect(session.UserName).toBe('normal');
348
+ expect(session.expires_at).toBeInstanceOf(Date);
349
+ expect(session.expires_at > new Date()).toBe(true);
350
+ });
351
+
352
+ test('cleanupAndCountUserSessions deletes expired rows and counts the rest', async () => {
353
+ await repo.insertAppSession('normal', new Date(Date.now() - 5000), null); // expired
354
+ await repo.insertAppSession('normal', new Date(Date.now() + 3600000), null); // live
355
+ await repo.insertAppSession('normal', null, null); // no expiry -> kept
356
+
357
+ const count = await repo.cleanupAndCountUserSessions('normal');
358
+ expect(count).toBe(2);
359
+ });
360
+
361
+ test('deleteOldestSessionsForUser prunes by created_at with a bound LIMIT', async () => {
362
+ // created_at has second precision; force distinct ordering explicitly.
363
+ for (let i = 0; i < 3; i += 1) {
364
+ const inserted = await repo.insertAppSession('normal', null, null);
365
+ await pool.query({
366
+ text: 'UPDATE "Sessions" SET created_at = $1 WHERE id = $2',
367
+ values: [new Date(Date.UTC(2030, 0, 1, 0, 0, i)), inserted.id]
368
+ });
369
+ }
370
+ const deleted = await repo.deleteOldestSessionsForUser('normal', 2);
371
+ expect(deleted).toBe(2);
372
+ expect(await repo.countActiveSessionsForUser('normal')).toBe(1);
373
+ });
374
+
375
+ test('deleteSessionsByIds expands ANY($1) into an IN list', async () => {
376
+ const a = await repo.insertAppSession('normal', null, null);
377
+ const b = await repo.insertAppSession('normal', null, null);
378
+ const deleted = await repo.deleteSessionsByIds([a.id, b.id]);
379
+ expect(deleted).toBe(2);
380
+ });
381
+
382
+ test('getSessionValidity falls back to the session-store expire via CASE subquery', async () => {
383
+ const inserted = await repo.insertAppSession('normal', null, null);
384
+ await pool.query(
385
+ 'INSERT INTO "session" (sid, sess, expire) VALUES (?, ?, ?)',
386
+ ['store-sid', '{}', '2030-06-01 12:00:00']
387
+ );
388
+ const row = await repo.getSessionValidity(inserted.id, 'store-sid');
389
+ expect(row.expires_at).toBeNull();
390
+ expect(row.connect_expire).toBeInstanceOf(Date);
391
+ expect(row.connect_expire.toISOString()).toBe('2030-06-01T12:00:00.000Z');
392
+ });
393
+
394
+ test('updateLastLoginReturnProfile updates and returns profile columns', async () => {
395
+ const profile = await repo.updateLastLoginReturnProfile('normal');
396
+ expect(profile).toHaveProperty('FullName');
397
+ expect(profile).toHaveProperty('Image');
398
+
399
+ const row = (await pool.query('SELECT last_login FROM "Users" WHERE "UserName" = ?', ['normal'])).rows[0];
400
+ expect(row.last_login).toBeInstanceOf(Date);
401
+ });
402
+
403
+ test('touchTrustedDevice (sqlite branch) touches and joins user data in two steps', async () => {
404
+ await repo.insertTrustedDevice({
405
+ username: 'normal',
406
+ deviceTokenHash: 'device-hash',
407
+ deviceName: 'laptop',
408
+ userAgent: 'jest',
409
+ ipAddress: '::1',
410
+ expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000)
411
+ });
412
+
413
+ const row = await repo.touchTrustedDevice('device-hash', 'normal');
414
+ expect(row.UserName).toBe('normal');
415
+ expect(row.ExpiresAt).toBeInstanceOf(Date);
416
+ expect(row.Active).toBeTruthy();
417
+ expect(Array.isArray(row.AllowedApps)).toBe(true);
418
+ });
419
+
420
+ test('touchTrustedDevice returns null for expired or unknown devices', async () => {
421
+ await repo.insertTrustedDevice({
422
+ username: 'normal',
423
+ deviceTokenHash: 'stale-hash',
424
+ deviceName: null,
425
+ userAgent: null,
426
+ ipAddress: null,
427
+ expiresAt: new Date(Date.now() - 1000)
428
+ });
429
+ expect(await repo.touchTrustedDevice('stale-hash', 'normal')).toBeNull();
430
+ expect(await repo.touchTrustedDevice('missing-hash', 'normal')).toBeNull();
431
+ });
432
+
433
+ test('getApiTokenByHash returns parsed Permissions and user apps', async () => {
434
+ await pool.query({
435
+ text: 'INSERT INTO "ApiTokens" ("UserName", "Name", "TokenHash", "Prefix", "Permissions") VALUES ($1, $2, $3, $4, $5)',
436
+ values: ['normal', 'ci-token', 'token-hash', 'mbk_', JSON.stringify({ scope: 'write', allowedApps: ['mbkauthe'] })]
437
+ });
438
+ const row = await repo.getApiTokenByHash('token-hash');
439
+ expect(row.Permissions).toEqual({ scope: 'write', allowedApps: ['mbkauthe'] });
440
+ expect(Array.isArray(row.user_allowed_apps)).toBe(true);
441
+ expect(row.Active).toBeTruthy();
442
+ });
443
+
444
+ test('updateApiTokenLastUsed (sqlite branch) throttles by interval', async () => {
445
+ await pool.query({
446
+ text: 'INSERT INTO "ApiTokens" ("UserName", "Name", "TokenHash", "Prefix") VALUES ($1, $2, $3, $4)',
447
+ values: ['normal', 'ci-token', 'token-hash', 'mbk_']
448
+ });
449
+ const { id } = (await pool.query('SELECT id FROM "ApiTokens" WHERE "TokenHash" = ?', ['token-hash'])).rows[0];
450
+
451
+ const first = await repo.updateApiTokenLastUsed(id);
452
+ expect(first.rowCount).toBe(1); // LastUsed was NULL
453
+ const second = await repo.updateApiTokenLastUsed(id);
454
+ expect(second.rowCount).toBe(0); // just touched -> inside 15-minute window
455
+ });
456
+
457
+ test('withTransaction commits work and rolls back failures atomically', async () => {
458
+ await repo.withTransaction(async (txRepo) => {
459
+ await txRepo.advisoryTransactionLock('sessions:normal'); // no-op on sqlite
460
+ await txRepo.insertAppSession('normal', null, null);
461
+ });
462
+ expect(await repo.countActiveSessionsForUser('normal')).toBe(1);
463
+
464
+ await expect(
465
+ repo.withTransaction(async (txRepo) => {
466
+ await txRepo.insertAppSession('normal', null, null);
467
+ throw new Error('boom');
468
+ })
469
+ ).rejects.toThrow('boom');
470
+ expect(await repo.countActiveSessionsForUser('normal')).toBe(1);
471
+ });
472
+ });
473
+
474
+ describe('SqliteSessionStore', () => {
475
+ let pool;
476
+ let store;
477
+
478
+ const storeGet = (sid) => new Promise((resolve, reject) => {
479
+ store.get(sid, (err, sess) => (err ? reject(err) : resolve(sess)));
480
+ });
481
+ const storeSet = (sid, sess) => new Promise((resolve, reject) => {
482
+ store.set(sid, sess, (err) => (err ? reject(err) : resolve()));
483
+ });
484
+ const storeDestroy = (sid) => new Promise((resolve, reject) => {
485
+ store.destroy(sid, (err) => (err ? reject(err) : resolve()));
486
+ });
487
+ const storeTouch = (sid, sess) => new Promise((resolve, reject) => {
488
+ store.touch(sid, sess, (err) => (err ? reject(err) : resolve()));
489
+ });
490
+ const storeLength = () => new Promise((resolve, reject) => {
491
+ store.length((err, n) => (err ? reject(err) : resolve(n)));
492
+ });
493
+ const storeAll = () => new Promise((resolve, reject) => {
494
+ store.all((err, sessions) => (err ? reject(err) : resolve(sessions)));
495
+ });
496
+ const storeClear = () => new Promise((resolve, reject) => {
497
+ store.clear((err) => (err ? reject(err) : resolve()));
498
+ });
499
+
500
+ const sessionData = (maxAgeMs) => ({
501
+ cookie: { maxAge: maxAgeMs },
502
+ user: { UserName: 'normal', role: 'NormalUser' }
503
+ });
504
+
505
+ beforeEach(async () => {
506
+ pool = createPool();
507
+ store = new SqliteSessionStore({ db: pool, tableName: 'session', createTableIfMissing: true });
508
+ await insertUser(pool, { username: 'normal' });
509
+ });
510
+
511
+ afterEach(async () => {
512
+ await pool.end();
513
+ });
514
+
515
+ test('set then get round-trips session JSON', async () => {
516
+ await storeSet('sid-1', sessionData(60000));
517
+ const sess = await storeGet('sid-1');
518
+ expect(sess.user.UserName).toBe('normal');
519
+ expect(sess.cookie.maxAge).toBe(60000);
520
+ });
521
+
522
+ test('set upserts on conflict and records the username column', async () => {
523
+ await storeSet('sid-1', sessionData(60000));
524
+ await storeSet('sid-1', { ...sessionData(60000), user: { UserName: 'normal', role: 'SuperAdmin' } });
525
+
526
+ const sess = await storeGet('sid-1');
527
+ expect(sess.user.role).toBe('SuperAdmin');
528
+ expect(await storeLength()).toBe(1);
529
+
530
+ const row = (await pool.query('SELECT username FROM "session" WHERE sid = ?', ['sid-1'])).rows[0];
531
+ expect(row.username).toBe('normal');
532
+ });
533
+
534
+ test('get returns undefined for a missing sid', async () => {
535
+ expect(await storeGet('nope')).toBeUndefined();
536
+ });
537
+
538
+ test('get treats an expired session as missing and deletes the row', async () => {
539
+ await storeSet('sid-exp', sessionData(-1000)); // negative maxAge -> already expired
540
+ expect(await storeGet('sid-exp')).toBeUndefined();
541
+ expect(await storeLength()).toBe(0);
542
+ });
543
+
544
+ test('destroy removes the session row', async () => {
545
+ await storeSet('sid-1', sessionData(60000));
546
+ await storeDestroy('sid-1');
547
+ expect(await storeGet('sid-1')).toBeUndefined();
548
+ });
549
+
550
+ test('touch extends the expiry', async () => {
551
+ await storeSet('sid-1', sessionData(60000));
552
+ const before = (await pool.query('SELECT expire FROM "session" WHERE sid = ?', ['sid-1'])).rows[0].expire;
553
+
554
+ await storeTouch('sid-1', sessionData(60 * 60 * 1000));
555
+ const after = (await pool.query('SELECT expire FROM "session" WHERE sid = ?', ['sid-1'])).rows[0].expire;
556
+ expect(after > before).toBe(true); // CURRENT_TIMESTAMP text sorts chronologically
557
+ });
558
+
559
+ test('touch is a no-op when disableTouch is set (production config)', async () => {
560
+ const quietStore = new SqliteSessionStore({ db: pool, tableName: 'session', disableTouch: true });
561
+ await storeSet('sid-1', sessionData(60000));
562
+ const before = (await pool.query('SELECT expire FROM "session" WHERE sid = ?', ['sid-1'])).rows[0].expire;
563
+
564
+ await new Promise((resolve, reject) => {
565
+ quietStore.touch('sid-1', sessionData(60 * 60 * 1000), (err) => (err ? reject(err) : resolve()));
566
+ });
567
+ const after = (await pool.query('SELECT expire FROM "session" WHERE sid = ?', ['sid-1'])).rows[0].expire;
568
+ expect(after).toBe(before);
569
+ });
570
+
571
+ test('all and clear cover the remaining store surface', async () => {
572
+ await storeSet('sid-1', sessionData(60000));
573
+ await storeSet('sid-2', sessionData(60000));
574
+
575
+ const sessions = await storeAll();
576
+ expect(sessions.map((s) => s.sid).sort()).toEqual(['sid-1', 'sid-2']);
577
+
578
+ await storeClear();
579
+ expect(await storeLength()).toBe(0);
580
+ });
581
+
582
+ test('login regenerate sequence: destroy old sid then set new sid', async () => {
583
+ // Mirrors express-session's Store.regenerate() during completeLoginProcess.
584
+ await storeSet('old-sid', sessionData(60000));
585
+ await storeDestroy('old-sid');
586
+ await storeSet('new-sid', sessionData(60000));
587
+
588
+ expect(await storeGet('old-sid')).toBeUndefined();
589
+ const sess = await storeGet('new-sid');
590
+ expect(sess.user.UserName).toBe('normal');
591
+ });
592
+ });
@@ -13,28 +13,28 @@ process.env.env = 'dev';
13
13
  process.env.dbLogs = 'true';
14
14
  process.env.dbLogsCallsite = 'false';
15
15
 
16
- const { default: router } = await import('./lib/main.js');
17
- const { packageJson, mbkautheVar } = await import('./lib/config/index.js');
16
+ const { default: router } = await import('../main.js');
17
+ const { packageJson, mbkautheVar } = await import('../config/index.js');
18
18
  const {
19
19
  resolveCookieDomain,
20
20
  isAllowedOriginHostname,
21
21
  getCookieDomain,
22
22
  cachedCookieOptions
23
- } = await import('./lib/config/cookies.js');
24
- const { dblogin } = await import('./lib/pool.js');
23
+ } = await import('../config/cookies.js');
24
+ const { dblogin } = await import('../pool.js');
25
25
  const {
26
26
  attachDevQueryLogger,
27
27
  resetQueryCount,
28
28
  resetQueryLog,
29
29
  runWithRequestContext
30
- } = await import('./lib/utils/dbQueryLogger.js');
30
+ } = await import('../utils/dbQueryLogger.js');
31
31
 
32
- const { isSafeRelativeRedirect, sanitizeRelativeRedirect } = await import('./lib/utils/redirect.js');
33
- const { isSafeFetchUrl } = await import('./lib/utils/urlSafety.js');
34
- const { hashPassword, verifyPassword } = await import('./lib/config/index.js');
35
- const { hashDeviceToken } = await import('./lib/config/cookies.js');
32
+ const { isSafeRelativeRedirect, sanitizeRelativeRedirect } = await import('../utils/redirect.js');
33
+ const { isSafeFetchUrl } = await import('../utils/urlSafety.js');
34
+ const { hashPassword, verifyPassword } = await import('../config/index.js');
35
+ const { hashDeviceToken } = await import('../config/cookies.js');
36
36
 
37
- const viewsPath = path.join(__dirname, 'views');
37
+ const viewsPath = path.join(__dirname, '../../views');
38
38
 
39
39
  const handlebarsHelpers = {
40
40
  eq: (a, b) => a === b,
@@ -5,6 +5,7 @@ export function getUserContext(req) {
5
5
  return {
6
6
  userLoggedIn: !!user.username,
7
7
  isuserlogin: !!user.username,
8
+ userId: user.userId || 'mbk_notfound',
8
9
  username: user.username || 'N/A',
9
10
  fullname: user.fullname || 'N/A',
10
11
  role: user.role || 'N/A',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mbkauthe",
3
- "version": "5.1.1",
3
+ "version": "5.2.1",
4
4
  "description": "MBKTech's reusable authentication system for Node.js applications.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -47,7 +47,6 @@
47
47
  "index.d.ts",
48
48
  "LICENSE",
49
49
  "README.md",
50
- "test.spec.js",
51
50
  "docs/",
52
51
  "lib/",
53
52
  "public/",
@@ -58,6 +57,7 @@
58
57
  "registry": "https://registry.npmjs.org/"
59
58
  },
60
59
  "dependencies": {
60
+ "better-sqlite3": "^11.9.1",
61
61
  "bytes": "^3.1.2",
62
62
  "connect-pg-simple": "^10.0.0",
63
63
  "content-type": "^1.0.5",