odac 1.4.15 → 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.
- package/CHANGELOG.md +17 -0
- package/client/odac.js +38 -1
- package/docs/ai/skills/backend/forms.md +54 -3
- package/docs/ai/skills/backend/validation.md +30 -2
- package/docs/backend/05-forms/01-custom-forms.md +52 -0
- package/docs/backend/09-validation/01-the-validator-service.md +40 -0
- package/docs/frontend/03-forms/01-form-handling.md +30 -13
- package/package.json +2 -1
- package/src/Auth.js +252 -24
- package/src/Config.js +5 -1
- package/src/Odac.js +3 -0
- package/src/Request.js +235 -34
- package/src/Route/Internal.js +32 -3
- package/src/Validator.js +143 -2
- package/src/View/Form.js +56 -0
- package/src/View/Image.js +15 -6
- package/src/View.js +2 -2
- package/test/Auth/check.test.js +219 -18
- package/test/Auth/verifyMagicLink.test.js +8 -1
- package/test/Odac/cache.test.js +5 -2
- package/test/Odac/image.test.js +8 -5
- package/test/Odac/instance.test.js +5 -2
- package/test/Request/cache.test.js +1 -0
- package/test/Request/multipart.test.js +164 -0
- package/test/Validator/file.test.js +172 -0
- package/test/View/Form/generateFieldHtml.test.js +56 -0
- package/test/View/Image/serve.test.js +9 -4
- package/test/View/Image/url.test.js +10 -4
- package/test/View/addNavigateAttribute.test.js +10 -1
- 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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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}`)]
|
package/test/Auth/check.test.js
CHANGED
|
@@ -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(
|
|
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:
|
|
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
|
-
|
|
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 +
|
|
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:
|
|
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 +
|
|
181
|
-
// So: inactiveAge = now - active =
|
|
182
|
-
const rotatedActiveDate = new Date(Date.now() - maxAge +
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
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 +
|
|
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(
|
|
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:
|
|
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:
|
|
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'),
|
package/test/Odac/cache.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
49
|
+
ctx = Odac.instance('id', mockReq, mockRes)
|
|
47
50
|
ctx.cache(3600)
|
|
48
51
|
|
|
49
52
|
ctx.Request.print()
|
package/test/Odac/image.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')
|