odac 1.4.14 → 1.4.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/bin/odac.js +1 -0
  3. package/client/odac.js +57 -1
  4. package/docs/ai/skills/backend/forms.md +54 -3
  5. package/docs/ai/skills/backend/validation.md +30 -2
  6. package/docs/ai/skills/frontend/core.md +5 -5
  7. package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +1 -1
  8. package/docs/backend/05-forms/01-custom-forms.md +96 -0
  9. package/docs/backend/09-validation/01-the-validator-service.md +40 -0
  10. package/docs/frontend/01-overview/01-introduction.md +8 -8
  11. package/docs/frontend/02-ajax-navigation/01-quick-start.md +14 -14
  12. package/docs/frontend/02-ajax-navigation/02-configuration.md +4 -4
  13. package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +16 -16
  14. package/docs/frontend/03-forms/01-form-handling.md +84 -31
  15. package/docs/frontend/04-api-requests/01-get-post.md +25 -25
  16. package/docs/frontend/05-streaming/01-client-streaming.md +14 -14
  17. package/docs/frontend/06-websocket/00-overview.md +1 -1
  18. package/index.js +11 -1
  19. package/package.json +2 -1
  20. package/src/Auth.js +252 -24
  21. package/src/Config.js +5 -1
  22. package/src/Odac.js +3 -0
  23. package/src/Request.js +235 -34
  24. package/src/Route/Internal.js +32 -3
  25. package/src/Validator.js +143 -2
  26. package/src/View/Form.js +56 -0
  27. package/src/View/Image.js +15 -6
  28. package/src/View.js +2 -2
  29. package/test/Auth/check.test.js +219 -18
  30. package/test/Auth/verifyMagicLink.test.js +8 -1
  31. package/test/Odac/cache.test.js +5 -2
  32. package/test/Odac/image.test.js +8 -5
  33. package/test/Odac/instance.test.js +5 -2
  34. package/test/Request/cache.test.js +1 -0
  35. package/test/Request/multipart.test.js +164 -0
  36. package/test/Validator/file.test.js +172 -0
  37. package/test/View/Form/generateFieldHtml.test.js +56 -0
  38. package/test/View/Image/serve.test.js +9 -4
  39. package/test/View/Image/url.test.js +10 -4
  40. package/test/View/addNavigateAttribute.test.js +10 -1
  41. package/test/View/print.test.js +10 -1
package/src/View/Image.js CHANGED
@@ -3,8 +3,6 @@ const fs = require('fs')
3
3
  const fsPromises = fs.promises
4
4
  const path = require('path')
5
5
 
6
- const IMG_CACHE_DIR = './storage/.cache/img'
7
-
8
6
  /**
9
7
  * Handles on-demand image processing (resize + format conversion) for the
10
8
  * ODAC template engine's `<odac:img>` tag. Uses sharp as an optional dependency
@@ -61,6 +59,17 @@ class Image {
61
59
  return this.#sharpAvailable
62
60
  }
63
61
 
62
+ /**
63
+ * Resolves the on-disk image cache directory relative to global.__dir,
64
+ * consistent with how the public/ source directory is resolved elsewhere
65
+ * in this file. Read lazily (not cached at module scope) so it stays in
66
+ * sync if __dir changes, e.g. across test suites.
67
+ * @returns {string}
68
+ */
69
+ static #cacheDir() {
70
+ return path.join(global.__dir || process.cwd(), 'storage/.cache/img')
71
+ }
72
+
64
73
  /**
65
74
  * Generates a deterministic hash from the image transformation parameters.
66
75
  * Identical source + options + mtime always produce the same hash, enabling
@@ -186,7 +195,7 @@ class Image {
186
195
  * @returns {Promise<{path: string, type: string}|null>}
187
196
  */
188
197
  static async #processInternal(src, cacheKey, format, options, sourceVerified = false) {
189
- const cachePath = path.join(IMG_CACHE_DIR, cacheKey)
198
+ const cachePath = path.join(this.#cacheDir(), cacheKey)
190
199
 
191
200
  // Disk cache hit — populate in-memory index without reprocessing
192
201
  try {
@@ -242,7 +251,7 @@ class Image {
242
251
  // Format conversion with quality setting
243
252
  pipeline = pipeline.toFormat(format, {quality})
244
253
 
245
- await fsPromises.mkdir(IMG_CACHE_DIR, {recursive: true})
254
+ await fsPromises.mkdir(this.#cacheDir(), {recursive: true})
246
255
  await pipeline.toFile(cachePath)
247
256
 
248
257
  const result = {path: cachePath, type: `image/${format}`, cacheKey}
@@ -280,11 +289,11 @@ class Image {
280
289
  * @returns {Promise<{stream: ReadableStream, type: string, size: number}|null>}
281
290
  */
282
291
  static async serve(filename) {
283
- const cachePath = path.join(IMG_CACHE_DIR, filename)
292
+ const cachePath = path.join(this.#cacheDir(), filename)
284
293
 
285
294
  // Prevent directory traversal in the filename
286
295
  const resolvedCache = path.resolve(cachePath)
287
- const cacheDir = path.resolve(IMG_CACHE_DIR)
296
+ const cacheDir = path.resolve(this.#cacheDir())
288
297
  if (!resolvedCache.startsWith(cacheDir + path.sep) && resolvedCache !== cacheDir) {
289
298
  return null
290
299
  }
package/src/View.js CHANGED
@@ -521,9 +521,9 @@ class View {
521
521
  })
522
522
 
523
523
  let cache = `${nodeCrypto.createHash('md5').update(file).digest('hex')}`
524
- await fsPromises.mkdir(CACHE_DIR, {recursive: true})
524
+ await fsPromises.mkdir(`${__dir}/${CACHE_DIR}`, {recursive: true})
525
525
  await fsPromises.writeFile(
526
- `${CACHE_DIR}/${cache}`,
526
+ `${__dir}/${CACHE_DIR}/${cache}`,
527
527
  `module.exports = async (Odac, get, __) => {\nlet html = '';\n${result}\nreturn html.trim()\n}`
528
528
  )
529
529
  delete require.cache[require.resolve(`${__dir}/${CACHE_DIR}/${cache}`)]
@@ -1,5 +1,9 @@
1
+ const nodeCrypto = require('crypto')
1
2
  const Auth = require('../../src/Auth.js')
2
3
 
4
+ // token_y is stored as a SHA-256 hash ($sha256$<hex>) of the odac_y cookie ('old_y').
5
+ const HASHED_OLD_Y = '$sha256$' + nodeCrypto.createHash('sha256').update('old_y').digest('hex')
6
+
3
7
  describe('Auth.check()', () => {
4
8
  let reqMock
5
9
  let authInstance
@@ -64,7 +68,7 @@ describe('Auth.check()', () => {
64
68
  // Getter mode: 1 argument
65
69
  return cookieStore[name] || null
66
70
  }),
67
- header: jest.fn(() => 'TestBrowser'),
71
+ header: jest.fn(name => (name === 'user-agent' ? 'TestBrowser' : null)),
68
72
  ip: '127.0.0.1',
69
73
  res: {} // HTTP context (non-null res indicates Set-Cookie can be delivered)
70
74
  }
@@ -82,7 +86,14 @@ describe('Auth.check()', () => {
82
86
  },
83
87
  DB: {
84
88
  fn: {now: () => new Date()},
85
- nanoid: () => 'nano_' + Date.now()
89
+ nanoid: () => 'nano_' + Date.now(),
90
+ // schema: used by #ensureTokenTableV2 migration (hasTable/hasColumn/alterTable)
91
+ schema: {
92
+ alterTable: jest.fn(() => Promise.resolve()),
93
+ createTable: jest.fn(() => Promise.resolve()),
94
+ hasColumn: jest.fn(() => Promise.resolve(true)),
95
+ hasTable: jest.fn(() => Promise.resolve(true))
96
+ }
86
97
  },
87
98
  Var: jest.fn(() => ({
88
99
  hash: jest.fn(() => 'hashed_value'),
@@ -105,7 +116,7 @@ describe('Auth.check()', () => {
105
116
  id: 'token_1',
106
117
  ip: '127.0.0.1',
107
118
  token_x: 'old_x',
108
- token_y: 'hashed_old_y',
119
+ token_y: HASHED_OLD_Y,
109
120
  user: 'user_10'
110
121
  }
111
122
 
@@ -122,7 +133,8 @@ describe('Auth.check()', () => {
122
133
  const inserted = dbMock.tracker.insertCalls[0]
123
134
  expect(inserted.user).toBe('user_10')
124
135
  expect(inserted.token_x).toBeDefined()
125
- expect(inserted.token_y).toBe('hashed_value')
136
+ // token_y is now a fast SHA-256 hash of the fresh secret
137
+ expect(inserted.token_y).toMatch(/^\$sha256\$[a-f0-9]{64}$/)
126
138
 
127
139
  // Verify old token was marked with Epoch Date
128
140
  expect(dbMock.tracker.updateCalls.length).toBe(1)
@@ -147,7 +159,7 @@ describe('Auth.check()', () => {
147
159
  // Simulate a rotated token where active was set to give ~60s grace period
148
160
  // and the rotation JUST happened (< 5 seconds ago)
149
161
  const maxAge = 30 * 24 * 60 * 60 * 1000
150
- const rotatedActiveDate = new Date(Date.now() - maxAge + 60000) // Grace period: ~60s left
162
+ const rotatedActiveDate = new Date(Date.now() - maxAge + 30000) // Grace period (30s) just started
151
163
 
152
164
  const mockRecord = {
153
165
  active: rotatedActiveDate,
@@ -156,7 +168,7 @@ describe('Auth.check()', () => {
156
168
  id: 'token_2',
157
169
  ip: '127.0.0.1',
158
170
  token_x: 'old_x',
159
- token_y: 'hashed_old_y',
171
+ token_y: HASHED_OLD_Y,
160
172
  user: 'user_10'
161
173
  }
162
174
 
@@ -177,9 +189,9 @@ describe('Auth.check()', () => {
177
189
  // Client still has old cookies -> recovery rotation should trigger
178
190
  const maxAge = 30 * 24 * 60 * 60 * 1000
179
191
  const timeSinceRotation = 10000 // 10 seconds since original rotation
180
- // active was set to: rotationTime - maxAge + 60000
181
- // So: inactiveAge = now - active = now - (rotationTime - maxAge + 60000) = timeSinceRotation + maxAge - 60000
182
- const rotatedActiveDate = new Date(Date.now() - maxAge + 60000 - timeSinceRotation)
192
+ // active was set to: rotationTime - maxAge + grace (30s)
193
+ // So: inactiveAge = now - active = timeSinceRotation + maxAge - grace
194
+ const rotatedActiveDate = new Date(Date.now() - maxAge + 30000 - timeSinceRotation)
183
195
 
184
196
  const mockRecord = {
185
197
  active: rotatedActiveDate,
@@ -188,7 +200,7 @@ describe('Auth.check()', () => {
188
200
  id: 'token_recovery',
189
201
  ip: '127.0.0.1',
190
202
  token_x: 'old_x',
191
- token_y: 'hashed_old_y',
203
+ token_y: HASHED_OLD_Y,
192
204
  user: 'user_10'
193
205
  }
194
206
 
@@ -234,7 +246,7 @@ describe('Auth.check()', () => {
234
246
  id: 'token_3',
235
247
  ip: '127.0.0.1',
236
248
  token_x: 'old_x',
237
- token_y: 'hashed_old_y',
249
+ token_y: HASHED_OLD_Y,
238
250
  user: 'user_10'
239
251
  }
240
252
 
@@ -259,7 +271,7 @@ describe('Auth.check()', () => {
259
271
  id: 'token_4',
260
272
  ip: '127.0.0.1',
261
273
  token_x: 'old_x',
262
- token_y: 'hashed_old_y',
274
+ token_y: HASHED_OLD_Y,
263
275
  user: 'user_10'
264
276
  }
265
277
 
@@ -284,7 +296,7 @@ describe('Auth.check()', () => {
284
296
  if (value !== undefined) return
285
297
  return {odac_x: 'old_x', odac_y: 'old_y'}[name] || null
286
298
  }),
287
- header: jest.fn(() => 'TestBrowser'),
299
+ header: jest.fn(name => (name === 'user-agent' ? 'TestBrowser' : null)),
288
300
  ip: '127.0.0.1',
289
301
  res: null // WebSocket context: no HTTP response available
290
302
  }
@@ -298,7 +310,7 @@ describe('Auth.check()', () => {
298
310
  id: 'token_ws',
299
311
  ip: '127.0.0.1',
300
312
  token_x: 'old_x',
301
- token_y: 'hashed_old_y',
313
+ token_y: HASHED_OLD_Y,
302
314
  user: 'user_10'
303
315
  }
304
316
 
@@ -322,14 +334,14 @@ describe('Auth.check()', () => {
322
334
  it('should skip recovery rotation for WebSocket connections (res === null)', async () => {
323
335
  const maxAge = 30 * 24 * 60 * 60 * 1000
324
336
  const timeSinceRotation = 10000 // 10 seconds since original rotation
325
- const rotatedActiveDate = new Date(Date.now() - maxAge + 60000 - timeSinceRotation)
337
+ const rotatedActiveDate = new Date(Date.now() - maxAge + 30000 - timeSinceRotation)
326
338
 
327
339
  const wsReqMock = {
328
340
  cookie: jest.fn((name, value) => {
329
341
  if (value !== undefined) return
330
342
  return {odac_x: 'old_x', odac_y: 'old_y'}[name] || null
331
343
  }),
332
- header: jest.fn(() => 'TestBrowser'),
344
+ header: jest.fn(name => (name === 'user-agent' ? 'TestBrowser' : null)),
333
345
  ip: '127.0.0.1',
334
346
  res: null // WebSocket context
335
347
  }
@@ -343,7 +355,7 @@ describe('Auth.check()', () => {
343
355
  id: 'token_ws_recovery',
344
356
  ip: '127.0.0.1',
345
357
  token_x: 'old_x',
346
- token_y: 'hashed_old_y',
358
+ token_y: HASHED_OLD_Y,
347
359
  user: 'user_10'
348
360
  }
349
361
 
@@ -373,7 +385,7 @@ describe('Auth.check()', () => {
373
385
  id: 'token_5',
374
386
  ip: '127.0.0.1',
375
387
  token_x: 'old_x',
376
- token_y: 'hashed_old_y',
388
+ token_y: HASHED_OLD_Y,
377
389
  user: 'user_10'
378
390
  }
379
391
 
@@ -393,4 +405,193 @@ describe('Auth.check()', () => {
393
405
  // Should NOT have Epoch marker
394
406
  expect(updatePayload.date).toBeUndefined()
395
407
  })
408
+
409
+ // ─── SESSION ANOMALY DETECTION ────────────────────────────────────────────
410
+
411
+ const CHROME_WIN = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
412
+ const FIREFOX_WIN = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0'
413
+ const CHROME_MAC = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
414
+ const CHROME_WIN_OLD = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36'
415
+
416
+ // Builds a request whose headers/ip/cookies can differ from the stored token.
417
+ const buildReq = ({ua = CHROME_WIN, lang = 'en-US', ip = '127.0.0.1', y = 'old_y'} = {}) => {
418
+ const store = {odac_x: 'old_x', odac_y: y}
419
+ return {
420
+ cookie: jest.fn((name, value) => {
421
+ if (value !== undefined) {
422
+ store[name] = value
423
+ return
424
+ }
425
+ return store[name] || null
426
+ }),
427
+ header: jest.fn(name => (name === 'user-agent' ? ua : name === 'accept-language' ? lang : null)),
428
+ ip,
429
+ res: {}
430
+ }
431
+ }
432
+
433
+ // Fresh, non-rotating token record (recent active + date) so only the anomaly
434
+ // path is exercised.
435
+ const freshRecord = (overrides = {}) => ({
436
+ accept_language: 'en-US',
437
+ active: new Date(),
438
+ browser: CHROME_WIN,
439
+ date: new Date(),
440
+ id: 'tok_anomaly',
441
+ ip: '127.0.0.1',
442
+ token_x: 'old_x',
443
+ token_y: HASHED_OLD_Y,
444
+ user: 'user_10',
445
+ ...overrides
446
+ })
447
+
448
+ it('deletes the token and fails when the browser family changes', async () => {
449
+ const dbMock = createDbMock([freshRecord()])
450
+ global.Odac.DB.user_tokens = dbMock
451
+ global.Odac.DB.users = dbMock
452
+
453
+ const result = await new Auth(buildReq({ua: FIREFOX_WIN})).check()
454
+
455
+ expect(result).toBe(false)
456
+ expect(dbMock.tracker.deleteCalls.length).toBe(1)
457
+ expect(dbMock.insert).not.toHaveBeenCalled()
458
+ })
459
+
460
+ it('deletes the token and fails when the OS family changes', async () => {
461
+ const dbMock = createDbMock([freshRecord()])
462
+ global.Odac.DB.user_tokens = dbMock
463
+ global.Odac.DB.users = dbMock
464
+
465
+ const result = await new Auth(buildReq({ua: CHROME_MAC})).check()
466
+
467
+ expect(result).toBe(false)
468
+ expect(dbMock.tracker.deleteCalls.length).toBe(1)
469
+ })
470
+
471
+ it('deletes the token and fails on a browser version downgrade', async () => {
472
+ const dbMock = createDbMock([freshRecord()])
473
+ global.Odac.DB.user_tokens = dbMock
474
+ global.Odac.DB.users = dbMock
475
+
476
+ const result = await new Auth(buildReq({ua: CHROME_WIN_OLD})).check()
477
+
478
+ expect(result).toBe(false)
479
+ expect(dbMock.tracker.deleteCalls.length).toBe(1)
480
+ })
481
+
482
+ it('deletes the token when the IP moves and Accept-Language differs', async () => {
483
+ const dbMock = createDbMock([freshRecord()])
484
+ global.Odac.DB.user_tokens = dbMock
485
+ global.Odac.DB.users = dbMock
486
+
487
+ const result = await new Auth(buildReq({ip: '8.8.8.8', lang: 'fr-FR'})).check()
488
+
489
+ expect(result).toBe(false)
490
+ expect(dbMock.tracker.deleteCalls.length).toBe(1)
491
+ })
492
+
493
+ it('allows an IP change alone when UA and language are unchanged', async () => {
494
+ const dbMock = createDbMock([freshRecord()])
495
+ global.Odac.DB.user_tokens = dbMock
496
+ global.Odac.DB.users = dbMock
497
+
498
+ const result = await new Auth(buildReq({ip: '8.8.8.8'})).check()
499
+
500
+ expect(result).toBe(true)
501
+ expect(dbMock.tracker.deleteCalls.length).toBe(0)
502
+ })
503
+
504
+ it('skips the language rule and backfills when a legacy row has no accept_language baseline', async () => {
505
+ // Pre-migration row: accept_language is NULL. An IP move with a differing
506
+ // language must NOT kill the session; instead the baseline is backfilled.
507
+ const dbMock = createDbMock([freshRecord({accept_language: null})])
508
+ global.Odac.DB.user_tokens = dbMock
509
+ global.Odac.DB.users = dbMock
510
+
511
+ const result = await new Auth(buildReq({ip: '8.8.8.8', lang: 'fr-FR'})).check()
512
+
513
+ expect(result).toBe(true)
514
+ expect(dbMock.tracker.deleteCalls.length).toBe(0)
515
+ // Backfill update stores the current language as the new baseline
516
+ const backfill = dbMock.tracker.updateCalls.find(p => p.accept_language === 'fr-FR')
517
+ expect(backfill).toBeDefined()
518
+ })
519
+
520
+ it('refreshes the UA baseline during activity updates so legitimate drift does not accumulate', async () => {
521
+ const CHROME_WIN_121 = CHROME_WIN.replace('Chrome/120', 'Chrome/121')
522
+ // inactiveAge > updateAge (24h) triggers the activity update; date recent
523
+ // enough to stay under rotationAge is impossible here, so disable rotation
524
+ // to exercise the non-rotating fallback path.
525
+ global.Odac.Config.auth.rotation = false
526
+
527
+ const dbMock = createDbMock([
528
+ freshRecord({
529
+ active: new Date(Date.now() - 25 * 60 * 60 * 1000), // 25h ago -> exceeds updateAge
530
+ date: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000) // old token, rotation disabled
531
+ })
532
+ ])
533
+ global.Odac.DB.user_tokens = dbMock
534
+ global.Odac.DB.users = dbMock
535
+
536
+ // Same family/OS, +1 major version: legitimate drift, not an anomaly
537
+ const result = await new Auth(buildReq({ua: CHROME_WIN_121})).check()
538
+
539
+ expect(result).toBe(true)
540
+ expect(dbMock.tracker.deleteCalls.length).toBe(0)
541
+ // Activity update must carry the new UA as the refreshed baseline
542
+ const refresh = dbMock.tracker.updateCalls.find(p => p.browser === CHROME_WIN_121)
543
+ expect(refresh).toBeDefined()
544
+ expect(refresh.active).toBeInstanceOf(Date)
545
+ })
546
+
547
+ it('classifies iOS browser variants (CriOS) consistently with their desktop family', async () => {
548
+ const CHROME_IOS =
549
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1'
550
+ const SAFARI_IOS =
551
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
552
+
553
+ // Stored: Chrome on iOS. Presented: Safari on iOS -> browser family change -> delete.
554
+ const dbMock = createDbMock([freshRecord({browser: CHROME_IOS})])
555
+ global.Odac.DB.user_tokens = dbMock
556
+ global.Odac.DB.users = dbMock
557
+
558
+ const result = await new Auth(buildReq({ua: SAFARI_IOS})).check()
559
+
560
+ expect(result).toBe(false)
561
+ expect(dbMock.tracker.deleteCalls.length).toBe(1)
562
+ })
563
+
564
+ it('sweeps expired tokens from the request path, not only on login', async () => {
565
+ // Force the throttle window open (static #lastTokenSweep starts at process
566
+ // start, so default-interval tests never sweep)
567
+ global.Odac.Config.auth.sweepInterval = -1
568
+
569
+ const dbMock = createDbMock([freshRecord()])
570
+ global.Odac.DB.user_tokens = dbMock
571
+ global.Odac.DB.users = dbMock
572
+
573
+ const result = await new Auth(buildReq()).check()
574
+
575
+ expect(result).toBe(true)
576
+ // The only delete in this healthy-session scenario is the range sweep
577
+ expect(dbMock.tracker.deleteCalls.length).toBe(1)
578
+ })
579
+
580
+ it('accepts a legacy scrypt-hashed token for backward compatibility', async () => {
581
+ // #verifyToken routes $scrypt$ tokens through Odac.Var().hashCheck() (mocked true).
582
+ const dbMock = createDbMock([freshRecord({token_y: '$scrypt$deadbeef$cafe'})])
583
+ global.Odac.DB.user_tokens = dbMock
584
+ global.Odac.DB.users = dbMock
585
+
586
+ const result = await new Auth(buildReq()).check()
587
+
588
+ expect(result).toBe(true)
589
+ expect(dbMock.tracker.deleteCalls.length).toBe(0)
590
+
591
+ // In-place upgrade: the same secret must be rehashed to SHA-256 so the
592
+ // scrypt hash disappears after the first successful verification
593
+ const upgrade = dbMock.tracker.updateCalls.find(p => p.token_y)
594
+ expect(upgrade).toBeDefined()
595
+ expect(upgrade.token_y).toBe(HASHED_OLD_Y)
596
+ })
396
597
  })
@@ -102,7 +102,14 @@ describe('Auth.verifyMagicLink()', () => {
102
102
  },
103
103
  DB: {
104
104
  fn: {now: () => new Date()},
105
- nanoid: () => 'nano_' + Date.now()
105
+ nanoid: () => 'nano_' + Date.now(),
106
+ // schema: used by #ensureTokenTableV2 migration (hasTable/hasColumn/alterTable)
107
+ schema: {
108
+ alterTable: jest.fn(() => Promise.resolve()),
109
+ createTable: jest.fn(() => Promise.resolve()),
110
+ hasColumn: jest.fn(() => Promise.resolve(true)),
111
+ hasTable: jest.fn(() => Promise.resolve(true))
112
+ }
106
113
  },
107
114
  Var: jest.fn(() => ({
108
115
  hash: jest.fn(() => 'hashed_value'),
@@ -2,6 +2,7 @@ const Odac = require('../../src/Odac')
2
2
 
3
3
  describe('Odac.cache()', () => {
4
4
  let mockOdac
5
+ let ctx
5
6
 
6
7
  beforeEach(() => {
7
8
  mockOdac = {
@@ -14,6 +15,8 @@ describe('Odac.cache()', () => {
14
15
  })
15
16
 
16
17
  afterEach(() => {
18
+ ctx?.Request.clearTimeout()
19
+ ctx = null
17
20
  delete global.Odac
18
21
  delete global.__dir
19
22
  })
@@ -28,7 +31,7 @@ describe('Odac.cache()', () => {
28
31
  }
29
32
  const mockRes = {on: jest.fn(), writeHead: jest.fn(), end: jest.fn()}
30
33
 
31
- const ctx = Odac.instance('id', mockReq, mockRes)
34
+ ctx = Odac.instance('id', mockReq, mockRes)
32
35
 
33
36
  expect(typeof ctx.cache).toBe('function')
34
37
  })
@@ -43,7 +46,7 @@ describe('Odac.cache()', () => {
43
46
  }
44
47
  const mockRes = {on: jest.fn(), writeHead: jest.fn(), end: jest.fn(), finished: false}
45
48
 
46
- const ctx = Odac.instance('id', mockReq, mockRes)
49
+ ctx = Odac.instance('id', mockReq, mockRes)
47
50
  ctx.cache(3600)
48
51
 
49
52
  ctx.Request.print()
@@ -2,6 +2,7 @@ const Odac = require('../../src/Odac')
2
2
 
3
3
  describe('Odac.image()', () => {
4
4
  let mockOdac
5
+ let ctx
5
6
 
6
7
  beforeEach(() => {
7
8
  mockOdac = {
@@ -19,12 +20,14 @@ describe('Odac.image()', () => {
19
20
  })
20
21
 
21
22
  afterEach(() => {
23
+ ctx?.Request?.clearTimeout()
24
+ ctx = null
22
25
  delete global.Odac
23
26
  delete global.__dir
24
27
  })
25
28
 
26
29
  test('should be available on instance without req/res (cron context)', () => {
27
- const ctx = Odac.instance(null, 'cron')
30
+ ctx = Odac.instance(null, 'cron')
28
31
  expect(typeof ctx.image).toBe('function')
29
32
  })
30
33
 
@@ -37,25 +40,25 @@ describe('Odac.image()', () => {
37
40
  on: jest.fn()
38
41
  }
39
42
  const mockRes = {on: jest.fn()}
40
- const ctx = Odac.instance('id', mockReq, mockRes)
43
+ ctx = Odac.instance('id', mockReq, mockRes)
41
44
  expect(typeof ctx.image).toBe('function')
42
45
  })
43
46
 
44
47
  test('should return a promise', () => {
45
- const ctx = Odac.instance(null, 'cron')
48
+ ctx = Odac.instance(null, 'cron')
46
49
  const result = ctx.image('/images/test.jpg', {width: 300})
47
50
  expect(result).toBeInstanceOf(Promise)
48
51
  })
49
52
 
50
53
  test('should return original src when sharp is unavailable', async () => {
51
- const ctx = Odac.instance(null, 'cron')
54
+ ctx = Odac.instance(null, 'cron')
52
55
  const result = await ctx.image('/images/test.jpg')
53
56
  // sharp not installed in test env → returns original src
54
57
  expect(result).toBe('/images/test.jpg')
55
58
  })
56
59
 
57
60
  test('should return empty string for empty src', async () => {
58
- const ctx = Odac.instance(null, 'cron')
61
+ ctx = Odac.instance(null, 'cron')
59
62
  expect(await ctx.image('')).toBe('')
60
63
  })
61
64
  })
@@ -2,6 +2,7 @@ const Odac = require('../../src/Odac')
2
2
 
3
3
  describe('Odac.instance()', () => {
4
4
  let mockOdac
5
+ let ctx
5
6
 
6
7
  beforeEach(() => {
7
8
  mockOdac = {
@@ -20,6 +21,8 @@ describe('Odac.instance()', () => {
20
21
  })
21
22
 
22
23
  afterEach(() => {
24
+ ctx?.Request.clearTimeout()
25
+ ctx = null
23
26
  delete global.Odac
24
27
  delete global.__dir
25
28
  })
@@ -34,7 +37,7 @@ describe('Odac.instance()', () => {
34
37
  }
35
38
  const mockRes = {on: jest.fn(), writeHead: jest.fn(), end: jest.fn()}
36
39
 
37
- const ctx = Odac.instance('id', mockReq, mockRes)
40
+ ctx = Odac.instance('id', mockReq, mockRes)
38
41
 
39
42
  expect(ctx.Request).toBeDefined()
40
43
  expect(ctx.Request.id).toBe('id')
@@ -49,7 +52,7 @@ describe('Odac.instance()', () => {
49
52
  on: jest.fn()
50
53
  }
51
54
  const mockRes = {on: jest.fn()}
52
- const ctx = Odac.instance('id', mockReq, mockRes)
55
+ ctx = Odac.instance('id', mockReq, mockRes)
53
56
 
54
57
  expect(typeof ctx.abort).toBe('function')
55
58
  expect(typeof ctx.cookie).toBe('function')
@@ -29,6 +29,7 @@ describe('Request.cache()', () => {
29
29
  })
30
30
 
31
31
  afterEach(() => {
32
+ request.clearTimeout()
32
33
  delete global.Odac
33
34
  delete global.__dir
34
35
  })