odac 1.4.1 → 1.4.3

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 (77) hide show
  1. package/.agent/rules/memory.md +5 -0
  2. package/.releaserc.js +9 -2
  3. package/CHANGELOG.md +64 -0
  4. package/README.md +1 -1
  5. package/bin/odac.js +3 -2
  6. package/client/odac.js +124 -28
  7. package/docs/ai/skills/backend/database.md +19 -0
  8. package/docs/ai/skills/backend/forms.md +107 -13
  9. package/docs/ai/skills/backend/migrations.md +8 -2
  10. package/docs/ai/skills/backend/validation.md +132 -32
  11. package/docs/ai/skills/frontend/forms.md +43 -15
  12. package/docs/backend/08-database/02-basics.md +49 -9
  13. package/docs/backend/08-database/04-migrations.md +1 -0
  14. package/package.json +1 -1
  15. package/src/Auth.js +15 -2
  16. package/src/Database/ConnectionFactory.js +1 -0
  17. package/src/Database/Migration.js +26 -1
  18. package/src/Database/nanoid.js +30 -0
  19. package/src/Database.js +122 -11
  20. package/src/Ipc.js +37 -0
  21. package/src/Odac.js +1 -1
  22. package/src/Route/Cron.js +11 -0
  23. package/src/Route.js +49 -30
  24. package/src/Server.js +77 -23
  25. package/src/Storage.js +15 -1
  26. package/src/Validator.js +22 -20
  27. package/test/{Auth.test.js → Auth/check.test.js} +91 -5
  28. package/test/Client/data.test.js +91 -0
  29. package/test/Client/get.test.js +90 -0
  30. package/test/Client/storage.test.js +87 -0
  31. package/test/Client/token.test.js +82 -0
  32. package/test/Client/ws.test.js +118 -0
  33. package/test/Config/deepMerge.test.js +14 -0
  34. package/test/Config/init.test.js +66 -0
  35. package/test/Config/interpolate.test.js +35 -0
  36. package/test/Database/ConnectionFactory/buildConnectionConfig.test.js +13 -0
  37. package/test/Database/ConnectionFactory/buildConnections.test.js +31 -0
  38. package/test/Database/ConnectionFactory/resolveClient.test.js +12 -0
  39. package/test/Database/Migration/migrate_column.test.js +52 -0
  40. package/test/Database/Migration/migrate_files.test.js +70 -0
  41. package/test/Database/Migration/migrate_index.test.js +89 -0
  42. package/test/Database/Migration/migrate_nanoid.test.js +160 -0
  43. package/test/Database/Migration/migrate_seed.test.js +77 -0
  44. package/test/Database/Migration/migrate_table.test.js +88 -0
  45. package/test/Database/Migration/rollback.test.js +61 -0
  46. package/test/Database/Migration/snapshot.test.js +38 -0
  47. package/test/Database/Migration/status.test.js +41 -0
  48. package/test/Database/autoNanoid.test.js +215 -0
  49. package/test/Database/nanoid.test.js +19 -0
  50. package/test/Lang/constructor.test.js +25 -0
  51. package/test/Lang/get.test.js +65 -0
  52. package/test/Lang/set.test.js +49 -0
  53. package/test/Odac/init.test.js +42 -0
  54. package/test/Odac/instance.test.js +58 -0
  55. package/test/Route/{Middleware.test.js → Middleware/chaining.test.js} +5 -29
  56. package/test/Route/Middleware/use.test.js +35 -0
  57. package/test/{Route.test.js → Route/check.test.js} +100 -50
  58. package/test/Route/set.test.js +52 -0
  59. package/test/Route/ws.test.js +23 -0
  60. package/test/View/EarlyHints/cache.test.js +32 -0
  61. package/test/View/EarlyHints/extractFromHtml.test.js +143 -0
  62. package/test/View/EarlyHints/formatLinkHeader.test.js +33 -0
  63. package/test/View/EarlyHints/send.test.js +99 -0
  64. package/test/View/{Form.test.js → Form/generateFieldHtml.test.js} +2 -2
  65. package/test/View/constructor.test.js +22 -0
  66. package/test/View/print.test.js +19 -0
  67. package/test/WebSocket/Client/limits.test.js +55 -0
  68. package/test/WebSocket/Server/broadcast.test.js +33 -0
  69. package/test/WebSocket/Server/route.test.js +37 -0
  70. package/test/Client.test.js +0 -197
  71. package/test/Config.test.js +0 -119
  72. package/test/Database/ConnectionFactory.test.js +0 -80
  73. package/test/Lang.test.js +0 -92
  74. package/test/Migration.test.js +0 -943
  75. package/test/Odac.test.js +0 -88
  76. package/test/View/EarlyHints.test.js +0 -282
  77. package/test/WebSocket.test.js +0 -238
package/src/Ipc.js CHANGED
@@ -269,6 +269,43 @@ class Ipc extends EventEmitter {
269
269
  interval.unref()
270
270
  }
271
271
 
272
+ /**
273
+ * Tears down IPC resources. For Redis driver, disconnects clients.
274
+ * For memory driver, clears stores and removes cluster listeners.
275
+ */
276
+ async close() {
277
+ if (this.config.driver === 'redis') {
278
+ if (this.subRedis) {
279
+ await this.subRedis.quit().catch(() => {})
280
+ this.subRedis = null
281
+ }
282
+ if (this.redis) {
283
+ await this.redis.quit().catch(() => {})
284
+ this.redis = null
285
+ }
286
+ } else if (cluster.isPrimary) {
287
+ if (global.__odac_ipc_message_handler) {
288
+ cluster.removeListener('message', global.__odac_ipc_message_handler)
289
+ global.__odac_ipc_message_handler = null
290
+ }
291
+ if (global.__odac_ipc_exit_handler) {
292
+ cluster.removeListener('exit', global.__odac_ipc_exit_handler)
293
+ global.__odac_ipc_exit_handler = null
294
+ }
295
+ if (this._memoryStore) this._memoryStore.clear()
296
+ if (this._memorySubs) this._memorySubs.clear()
297
+ } else {
298
+ // Worker: reject all pending requests so they don't hang
299
+ for (const req of this._requests.values()) {
300
+ clearTimeout(req.timeout)
301
+ req.reject(new Error('IPC shutting down'))
302
+ }
303
+ this._requests.clear()
304
+ this._subs.clear()
305
+ }
306
+ this.initialized = false
307
+ }
308
+
272
309
  _handlePrimaryMessage(worker, msg) {
273
310
  const {type, id, key, value, ttl, channel, message} = msg
274
311
  const action = type.replace('ipc:', '')
package/src/Odac.js CHANGED
@@ -149,7 +149,7 @@ module.exports = {
149
149
  return hash ? _odac.Token.check(hash) : _odac.Token.generate()
150
150
  }
151
151
  _odac.validator = function () {
152
- return new (require('./Validator.js'))(_odac.Request)
152
+ return new (require('./Validator.js'))(_odac.Request, _odac)
153
153
  }
154
154
  _odac.write = function (value) {
155
155
  return _odac.Request.write(value)
package/src/Route/Cron.js CHANGED
@@ -12,6 +12,17 @@ class Cron {
12
12
  }
13
13
  }
14
14
 
15
+ /**
16
+ * Stops the cron scheduler. Called during graceful shutdown to prevent
17
+ * new cron jobs from firing while the process is terminating.
18
+ */
19
+ stop() {
20
+ if (this.#interval) {
21
+ clearInterval(this.#interval)
22
+ this.#interval = null
23
+ }
24
+ }
25
+
15
26
  check() {
16
27
  const now = new Date()
17
28
  const minute = now.getMinutes()
package/src/Route.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs')
2
2
  const fsPromises = fs.promises
3
+ const path = require('path')
3
4
 
4
5
  const Cron = require('./Route/Cron.js')
5
6
  const Internal = require('./Route/Internal.js')
@@ -219,42 +220,52 @@ class Route {
219
220
  if (typeof page === 'string') Odac.Request.page = page
220
221
  return await this.#executeController(Odac, pageController)
221
222
  }
222
- if (url && !url.includes('/../')) {
223
- const publicPath = `${__dir}/public${url}`
224
223
 
225
- // PROD CACHE HIT (Metadata)
226
- if (!Odac.Config.debug && this.#publicCache[publicPath]) {
227
- const cached = this.#publicCache[publicPath]
228
- Odac.Request.header('Content-Type', cached.type)
229
- Odac.Request.header('Cache-Control', 'public, max-age=31536000')
230
- Odac.Request.header('Content-Length', cached.size)
231
- return fs.createReadStream(publicPath)
232
- }
233
-
234
- try {
235
- const stat = await fsPromises.stat(publicPath)
236
- if (stat.isFile()) {
237
- let type = 'text/html'
238
- if (url.includes('.')) {
239
- let arr = url.split('.')
240
- type = mime[arr[arr.length - 1]]
241
- }
224
+ let decodedUrl
225
+ try {
226
+ decodedUrl = decodeURIComponent(url)
227
+ } catch {
228
+ decodedUrl = null // Invalid URI encoding
229
+ }
242
230
 
243
- // PROD CACHE SET (Metadata Only)
244
- if (!Odac.Config.debug) {
245
- this.#publicCache[publicPath] = {
246
- type,
247
- size: stat.size
248
- }
249
- }
231
+ if (decodedUrl && !decodedUrl.includes('\0')) {
232
+ const publicDir = path.normalize(`${__dir}/public`)
233
+ const safeUrl = decodedUrl.replace(/^[/\\]+/, '')
234
+ const publicPath = path.normalize(path.join(publicDir, safeUrl))
235
+ const relativePath = path.relative(publicDir, publicPath)
250
236
 
251
- Odac.Request.header('Content-Type', type)
237
+ if (relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))) {
238
+ // PROD CACHE HIT (Metadata)
239
+ if (!Odac.Config.debug && this.#publicCache[publicPath]) {
240
+ const cached = this.#publicCache[publicPath]
241
+ Odac.Request.header('Content-Type', cached.type)
252
242
  Odac.Request.header('Cache-Control', 'public, max-age=31536000')
253
- Odac.Request.header('Content-Length', stat.size)
243
+ Odac.Request.header('Content-Length', cached.size)
254
244
  return fs.createReadStream(publicPath)
255
245
  }
256
- } catch {
257
- // File not found in public
246
+
247
+ try {
248
+ const stat = await fsPromises.stat(publicPath)
249
+ if (stat.isFile()) {
250
+ const extension = path.extname(publicPath).slice(1)
251
+ const type = mime[extension] || 'text/html'
252
+
253
+ // PROD CACHE SET (Metadata Only)
254
+ if (!Odac.Config.debug) {
255
+ this.#publicCache[publicPath] = {
256
+ type,
257
+ size: stat.size
258
+ }
259
+ }
260
+
261
+ Odac.Request.header('Content-Type', type)
262
+ Odac.Request.header('Cache-Control', 'public, max-age=31536000')
263
+ Odac.Request.header('Content-Length', stat.size)
264
+ return fs.createReadStream(publicPath)
265
+ }
266
+ } catch {
267
+ // File not found in public
268
+ }
258
269
  }
259
270
  }
260
271
 
@@ -806,6 +817,14 @@ class Route {
806
817
  return Cron.job(controller)
807
818
  }
808
819
 
820
+ /**
821
+ * Stops the cron scheduler during graceful shutdown.
822
+ * Prevents new cron jobs from spawning while the process is terminating.
823
+ */
824
+ stopCron() {
825
+ Cron.stop()
826
+ }
827
+
809
828
  ws(path, handler, options = {}) {
810
829
  this.setWs('ws', path, handler, options)
811
830
  return this
package/src/Server.js CHANGED
@@ -33,36 +33,73 @@ module.exports = {
33
33
  }
34
34
  })
35
35
 
36
- // Graceful shutdown handler for primary
37
- const gracefulShutdown = signal => {
36
+ /**
37
+ * GRACEFUL SHUTDOWN PRIMARY PROCESS
38
+ * ────────────────────────────────────
39
+ * Shutdown order (deterministic, sequential):
40
+ * 1. Stop accepting new work → Cron, session GC
41
+ * 2. Drain active workers → send 'shutdown', disconnect, wait
42
+ * 3. Release shared resources → IPC, Database, Storage
43
+ * 4. Exit 0
44
+ *
45
+ * A 30-second hard timeout protects against hung workers or I/O.
46
+ */
47
+ const gracefulShutdown = async signal => {
38
48
  if (isShuttingDown) return
39
49
  isShuttingDown = true
40
50
 
41
51
  console.log(`\n\x1b[33m[Shutdown]\x1b[0m ${signal} received, shutting down gracefully...`)
42
52
 
43
- // Disconnect all workers
44
- for (const id in cluster.workers) {
45
- cluster.workers[id].send('shutdown')
46
- cluster.workers[id].disconnect()
47
- }
53
+ // Force exit safety net — must be set immediately
54
+ const forceTimer = setTimeout(() => {
55
+ console.error('\x1b[31m[Shutdown]\x1b[0m Timeout! Forcing exit...')
56
+ process.exit(1)
57
+ }, 30000)
58
+ forceTimer.unref()
48
59
 
49
- let workersAlive = Object.keys(cluster.workers).length
60
+ // Phase 1: Stop schedulers so no new work is queued
61
+ Odac.Route.stopCron()
62
+ Odac.Storage.stopSessionGC()
50
63
 
51
- cluster.on('exit', () => {
52
- workersAlive--
53
- if (workersAlive === 0) {
54
- console.log('\x1b[32m[Shutdown]\x1b[0m All workers stopped.')
55
- Odac.Storage.close()
56
- console.log('\x1b[32m[Shutdown]\x1b[0m Storage closed. Goodbye!')
57
- process.exit(0)
64
+ // Phase 2: Gracefully drain all workers
65
+ await new Promise(resolve => {
66
+ const workerIds = Object.keys(cluster.workers)
67
+ let workersAlive = workerIds.length
68
+
69
+ if (workersAlive === 0) return resolve()
70
+
71
+ cluster.on('exit', () => {
72
+ workersAlive--
73
+ if (workersAlive <= 0) resolve()
74
+ })
75
+
76
+ for (const id of workerIds) {
77
+ const worker = cluster.workers[id]
78
+ if (worker) {
79
+ worker.send('shutdown')
80
+ worker.disconnect()
81
+ }
58
82
  }
59
83
  })
60
84
 
61
- // Force exit after 30 seconds
62
- setTimeout(() => {
63
- console.error('\x1b[31m[Shutdown]\x1b[0m Timeout! Forcing exit...')
64
- process.exit(1)
65
- }, 30000)
85
+ console.log('\x1b[32m[Shutdown]\x1b[0m All workers stopped.')
86
+
87
+ // Phase 3: Release shared resources (order matters: IPC → DB → Storage)
88
+ try {
89
+ await Odac.Ipc.close()
90
+ } catch (e) {
91
+ console.error(`\x1b[31m[Shutdown]\x1b[0m Error closing IPC: ${e.message}`)
92
+ }
93
+ try {
94
+ await Odac.Database.close()
95
+ } catch (e) {
96
+ console.error(`\x1b[31m[Shutdown]\x1b[0m Error closing Database: ${e.message}`)
97
+ }
98
+ Odac.Storage.close()
99
+
100
+ console.log('\x1b[32m[Shutdown]\x1b[0m Resources released. Goodbye!')
101
+ clearTimeout(forceTimer)
102
+ process.exit(0)
66
103
  }
67
104
 
68
105
  process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
@@ -113,11 +150,28 @@ module.exports = {
113
150
 
114
151
  server.listen(port)
115
152
 
116
- // Graceful shutdown handler for worker
117
- process.on('message', msg => {
153
+ /**
154
+ * GRACEFUL SHUTDOWN — WORKER PROCESS
155
+ * ──────────────────────────────────
156
+ * 1. Stop accepting new connections (server.close)
157
+ * 2. Release worker-scoped resources (IPC pending requests, DB pools)
158
+ * 3. Exit 0
159
+ */
160
+ process.on('message', async msg => {
118
161
  if (msg === 'shutdown') {
119
162
  console.log(`\x1b[36m[Worker ${process.pid}]\x1b[0m Closing server...`)
120
- server.close(() => {
163
+
164
+ server.close(async () => {
165
+ try {
166
+ await Odac.Ipc.close()
167
+ } catch {
168
+ /* best-effort */
169
+ }
170
+ try {
171
+ await Odac.Database.close()
172
+ } catch {
173
+ /* best-effort */
174
+ }
121
175
  console.log(`\x1b[36m[Worker ${process.pid}]\x1b[0m Server closed.`)
122
176
  process.exit(0)
123
177
  })
package/src/Storage.js CHANGED
@@ -5,6 +5,7 @@ class OdacStorage {
5
5
  constructor() {
6
6
  this.db = null
7
7
  this.ready = false
8
+ this.gcInterval = null
8
9
  }
9
10
 
10
11
  init() {
@@ -72,10 +73,14 @@ class OdacStorage {
72
73
  return null
73
74
  }
74
75
 
76
+ if (this.gcInterval) {
77
+ clearInterval(this.gcInterval)
78
+ }
79
+
75
80
  const BATCH_THRESHOLD = 10000
76
81
  const BATCH_SIZE = 1000
77
82
 
78
- return setInterval(() => {
83
+ this.gcInterval = setInterval(() => {
79
84
  try {
80
85
  // Count sessions to decide mode
81
86
  let sessionCount = 0
@@ -94,6 +99,15 @@ class OdacStorage {
94
99
  console.error('\x1b[31m[Storage GC Error]\x1b[0m', error.message)
95
100
  }
96
101
  }, intervalMs)
102
+
103
+ return this.gcInterval
104
+ }
105
+
106
+ stopSessionGC() {
107
+ if (this.gcInterval) {
108
+ clearInterval(this.gcInterval)
109
+ this.gcInterval = null
110
+ }
97
111
  }
98
112
 
99
113
  // Simple mode: Load all sessions at once (fast for small datasets)
package/src/Validator.js CHANGED
@@ -93,9 +93,11 @@ class Validator {
93
93
  #method = 'POST'
94
94
  #name = ''
95
95
  #request
96
+ #odac
96
97
 
97
- constructor(Request) {
98
+ constructor(Request, Odac) {
98
99
  this.#request = Request
100
+ this.#odac = Odac || global.Odac
99
101
  }
100
102
 
101
103
  check(rules) {
@@ -189,37 +191,37 @@ class Validator {
189
191
  error = !value || (value !== 1 && value !== '1' && value !== 'on' && value !== 'yes' && value !== true)
190
192
  break
191
193
  case 'numeric':
192
- error = value && value !== '' && !Odac.Var(value).is('numeric')
194
+ error = value && value !== '' && !this.#odac.Var(value).is('numeric')
193
195
  break
194
196
  case 'alpha':
195
- error = value && value !== '' && !Odac.Var(value).is('alpha')
197
+ error = value && value !== '' && !this.#odac.Var(value).is('alpha')
196
198
  break
197
199
  case 'alphaspace':
198
- error = value && value !== '' && !Odac.Var(value).is('alphaspace')
200
+ error = value && value !== '' && !this.#odac.Var(value).is('alphaspace')
199
201
  break
200
202
  case 'alphanumeric':
201
- error = value && value !== '' && !Odac.Var(value).is('alphanumeric')
203
+ error = value && value !== '' && !this.#odac.Var(value).is('alphanumeric')
202
204
  break
203
205
  case 'alphanumericspace':
204
- error = value && value !== '' && !Odac.Var(value).is('alphanumericspace')
206
+ error = value && value !== '' && !this.#odac.Var(value).is('alphanumericspace')
205
207
  break
206
208
  case 'email':
207
- error = value && value !== '' && !Odac.Var(value).is('email')
209
+ error = value && value !== '' && !this.#odac.Var(value).is('email')
208
210
  break
209
211
  case 'ip':
210
- error = value && value !== '' && !Odac.Var(value).is('ip')
212
+ error = value && value !== '' && !this.#odac.Var(value).is('ip')
211
213
  break
212
214
  case 'float':
213
- error = value && value !== '' && !Odac.Var(value).is('float')
215
+ error = value && value !== '' && !this.#odac.Var(value).is('float')
214
216
  break
215
217
  case 'mac':
216
- error = value && value !== '' && !Odac.Var(value).is('mac')
218
+ error = value && value !== '' && !this.#odac.Var(value).is('mac')
217
219
  break
218
220
  case 'domain':
219
- error = value && value !== '' && !Odac.Var(value).is('domain')
221
+ error = value && value !== '' && !this.#odac.Var(value).is('domain')
220
222
  break
221
223
  case 'url':
222
- error = value && value !== '' && !Odac.Var(value).is('url')
224
+ error = value && value !== '' && !this.#odac.Var(value).is('url')
223
225
  break
224
226
  case 'username':
225
227
  error = value && value !== '' && !/^[a-zA-Z0-9]+$/.test(value)
@@ -228,7 +230,7 @@ class Validator {
228
230
  error = value && value !== '' && /<[^>]*>/g.test(value)
229
231
  break
230
232
  case 'usercheck':
231
- error = !(await Odac.Auth.check())
233
+ error = !(await this.#odac.Auth.check())
232
234
  break
233
235
  case 'array':
234
236
  error = value && !Array.isArray(value)
@@ -283,12 +285,12 @@ class Validator {
283
285
  error = value && value !== '' && vars[1] && !new RegExp(vars[1]).test(value)
284
286
  break
285
287
  case 'user': {
286
- if (!(await Odac.Auth.check())) {
288
+ if (!(await this.#odac.Auth.check())) {
287
289
  error = true
288
290
  } else {
289
- const userData = Odac.Auth.user(vars[1])
290
- if (Odac.Var(userData).is('hash')) {
291
- error = !Odac.Var(userData).hashCheck(value)
291
+ const userData = this.#odac.Auth.user(vars[1])
292
+ if (this.#odac.Var(userData).is('hash')) {
293
+ error = !this.#odac.Var(userData).hashCheck(value)
292
294
  } else {
293
295
  error = value !== userData
294
296
  }
@@ -344,7 +346,7 @@ class Validator {
344
346
  const ip = this.#request.ip()
345
347
  const now = new Date().toISOString().slice(0, 13).replace(/[-:T]/g, '')
346
348
  const page = this.#request.path()
347
- const storage = Odac.storage('sys')
349
+ const storage = this.#odac.storage('sys')
348
350
  const validation = storage.get('validation') || {}
349
351
 
350
352
  this.#name = '_odac_form'
@@ -358,8 +360,8 @@ class Validator {
358
360
  validation.brute[now][page][ip]++
359
361
 
360
362
  if (validation.brute[now][page][ip] >= maxAttempts) {
361
- this.#message['_odac_form'] = Odac.Lang
362
- ? Odac.Lang.get('Too many failed attempts. Please try again later.')
363
+ this.#message['_odac_form'] = this.#odac.Lang
364
+ ? this.#odac.Lang.get('Too many failed attempts. Please try again later.')
363
365
  : 'Too many failed attempts. Please try again later.'
364
366
  }
365
367
  }
@@ -1,6 +1,6 @@
1
- const Auth = require('../src/Auth.js')
1
+ const Auth = require('../../src/Auth.js')
2
2
 
3
- describe('Auth - Refresh Token Rotation', () => {
3
+ describe('Auth.check()', () => {
4
4
  let reqMock
5
5
  let authInstance
6
6
 
@@ -65,7 +65,8 @@ describe('Auth - Refresh Token Rotation', () => {
65
65
  return cookieStore[name] || null
66
66
  }),
67
67
  header: jest.fn(() => 'TestBrowser'),
68
- ip: '127.0.0.1'
68
+ ip: '127.0.0.1',
69
+ res: {} // HTTP context (non-null res indicates Set-Cookie can be delivered)
69
70
  }
70
71
 
71
72
  authInstance = new Auth(reqMock)
@@ -173,12 +174,11 @@ describe('Auth - Refresh Token Rotation', () => {
173
174
 
174
175
  it('should recovery-rotate and DELETE old token when client lost cookies (rotated token > 5s)', async () => {
175
176
  // Simulate a rotated token where 10 seconds have passed since original rotation
176
- // Client still has old cookies recovery rotation should trigger
177
+ // Client still has old cookies -> recovery rotation should trigger
177
178
  const maxAge = 30 * 24 * 60 * 60 * 1000
178
179
  const timeSinceRotation = 10000 // 10 seconds since original rotation
179
180
  // active was set to: rotationTime - maxAge + 60000
180
181
  // So: inactiveAge = now - active = now - (rotationTime - maxAge + 60000) = timeSinceRotation + maxAge - 60000
181
- // timeSinceRotation formula: inactiveAge - maxAge + 60000 = timeSinceRotation = 10000
182
182
  const rotatedActiveDate = new Date(Date.now() - maxAge + 60000 - timeSinceRotation)
183
183
 
184
184
  const mockRecord = {
@@ -276,6 +276,92 @@ describe('Auth - Refresh Token Rotation', () => {
276
276
  expect(dbMock.insert).not.toHaveBeenCalled()
277
277
  })
278
278
 
279
+ it('should skip rotation for WebSocket connections (res === null) and update active instead', async () => {
280
+ const createdAt = Date.now() - 20 * 60 * 1000 // 20 mins ago -> exceeds 15 min rotationAge
281
+
282
+ const wsReqMock = {
283
+ cookie: jest.fn((name, value) => {
284
+ if (value !== undefined) return
285
+ return {odac_x: 'old_x', odac_y: 'old_y'}[name] || null
286
+ }),
287
+ header: jest.fn(() => 'TestBrowser'),
288
+ ip: '127.0.0.1',
289
+ res: null // WebSocket context: no HTTP response available
290
+ }
291
+
292
+ const wsAuth = new Auth(wsReqMock)
293
+
294
+ const mockRecord = {
295
+ active: new Date(),
296
+ browser: 'TestBrowser',
297
+ date: new Date(createdAt),
298
+ id: 'token_ws',
299
+ ip: '127.0.0.1',
300
+ token_x: 'old_x',
301
+ token_y: 'hashed_old_y',
302
+ user: 'user_10'
303
+ }
304
+
305
+ const dbMock = createDbMock([mockRecord])
306
+ global.Odac.DB.user_tokens = dbMock
307
+ global.Odac.DB.users = dbMock
308
+
309
+ const result = await wsAuth.check()
310
+
311
+ expect(result).toBe(true)
312
+ // No rotation: no new token inserted
313
+ expect(dbMock.insert).not.toHaveBeenCalled()
314
+ // Active timestamp should be refreshed instead
315
+ expect(dbMock.tracker.updateCalls.length).toBe(1)
316
+ expect(dbMock.tracker.updateCalls[0].active).toBeInstanceOf(Date)
317
+ // No new cookies set (nothing to deliver over WS)
318
+ const setCalls = wsReqMock.cookie.mock.calls.filter(c => c.length >= 2)
319
+ expect(setCalls.length).toBe(0)
320
+ })
321
+
322
+ it('should skip recovery rotation for WebSocket connections (res === null)', async () => {
323
+ const maxAge = 30 * 24 * 60 * 60 * 1000
324
+ const timeSinceRotation = 10000 // 10 seconds since original rotation
325
+ const rotatedActiveDate = new Date(Date.now() - maxAge + 60000 - timeSinceRotation)
326
+
327
+ const wsReqMock = {
328
+ cookie: jest.fn((name, value) => {
329
+ if (value !== undefined) return
330
+ return {odac_x: 'old_x', odac_y: 'old_y'}[name] || null
331
+ }),
332
+ header: jest.fn(() => 'TestBrowser'),
333
+ ip: '127.0.0.1',
334
+ res: null // WebSocket context
335
+ }
336
+
337
+ const wsAuth = new Auth(wsReqMock)
338
+
339
+ const mockRecord = {
340
+ active: rotatedActiveDate,
341
+ browser: 'TestBrowser',
342
+ date: new Date(0), // Epoch marker = rotated
343
+ id: 'token_ws_recovery',
344
+ ip: '127.0.0.1',
345
+ token_x: 'old_x',
346
+ token_y: 'hashed_old_y',
347
+ user: 'user_10'
348
+ }
349
+
350
+ const dbMock = createDbMock([mockRecord])
351
+ global.Odac.DB.user_tokens = dbMock
352
+ global.Odac.DB.users = dbMock
353
+
354
+ const result = await wsAuth.check()
355
+
356
+ expect(result).toBe(true)
357
+ // No recovery rotation: no insert, no delete
358
+ expect(dbMock.insert).not.toHaveBeenCalled()
359
+ expect(dbMock.tracker.deleteCalls.length).toBe(0)
360
+ // No cookies set
361
+ const setCalls = wsReqMock.cookie.mock.calls.filter(c => c.length >= 2)
362
+ expect(setCalls.length).toBe(0)
363
+ })
364
+
279
365
  it('should update active timestamp when inactiveAge exceeds updateAge but tokenAge is within rotationAge', async () => {
280
366
  const staleActive = Date.now() - 25 * 60 * 60 * 1000 // 25 hours ago -> exceeds 24h updateAge
281
367
  const recentDate = Date.now() - 5 * 60 * 1000 // 5 mins ago -> within rotationAge
@@ -0,0 +1,91 @@
1
+ describe('Odac.data()', () => {
2
+ let mockXhr, mockDocument, mockWindow
3
+
4
+ beforeEach(() => {
5
+ jest.resetModules()
6
+ mockXhr = {
7
+ open: jest.fn(),
8
+ setRequestHeader: jest.fn(),
9
+ send: jest.fn(),
10
+ getResponseHeader: jest.fn(),
11
+ status: 200,
12
+ responseText: '{}',
13
+ response: '{}',
14
+ onload: null,
15
+ onerror: null
16
+ }
17
+ mockDocument = {
18
+ getElementById: jest.fn(),
19
+ querySelectorAll: jest.fn(() => []),
20
+ querySelector: jest.fn(),
21
+ addEventListener: jest.fn(),
22
+ removeEventListener: jest.fn(),
23
+ dispatchEvent: jest.fn(),
24
+ documentElement: {dataset: {}},
25
+ cookie: '',
26
+ readyState: 'complete',
27
+ createElement: jest.fn(() => ({setAttribute: jest.fn(), style: {}, appendChild: jest.fn(), parentNode: {insertBefore: jest.fn()}}))
28
+ }
29
+ mockWindow = {
30
+ location: {protocol: 'http:', host: 'localhost', href: 'http://localhost/'},
31
+ history: {pushState: jest.fn()},
32
+ scrollTo: jest.fn(),
33
+ addEventListener: jest.fn(),
34
+ XMLHttpRequest: jest.fn(() => mockXhr),
35
+ localStorage: {getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn()},
36
+ CustomEvent: jest.fn((name, detail) => ({name, detail})),
37
+ setTimeout: jest.fn(),
38
+ clearTimeout: jest.fn(),
39
+ requestAnimationFrame: jest.fn(cb => cb(Date.now())),
40
+ WebSocket: jest.fn(() => ({send: jest.fn(), close: jest.fn(), readyState: 1})),
41
+ FormData: jest.fn()
42
+ }
43
+ mockWindow.window = mockWindow
44
+ mockWindow.document = mockDocument
45
+ mockWindow.WebSocket.OPEN = 1
46
+ mockWindow.WebSocket.CLOSED = 3
47
+ global.window = mockWindow
48
+ global.document = mockDocument
49
+ global.location = mockWindow.location
50
+ global.XMLHttpRequest = mockWindow.XMLHttpRequest
51
+ global.localStorage = mockWindow.localStorage
52
+ global.CustomEvent = mockWindow.CustomEvent
53
+ global.WebSocket = mockWindow.WebSocket
54
+ global.setTimeout = mockWindow.setTimeout
55
+ global.clearTimeout = mockWindow.clearTimeout
56
+ global.requestAnimationFrame = mockWindow.requestAnimationFrame
57
+ global.FormData = mockWindow.FormData
58
+ delete require.cache[require.resolve('../../client/odac.js')]
59
+ require('../../client/odac.js')
60
+ })
61
+
62
+ afterEach(() => {
63
+ delete global.window
64
+ delete global.document
65
+ delete global.location
66
+ delete global.XMLHttpRequest
67
+ delete global.localStorage
68
+ delete global.CustomEvent
69
+ delete global.WebSocket
70
+ delete global.setTimeout
71
+ delete global.clearTimeout
72
+ delete global.requestAnimationFrame
73
+ delete global.FormData
74
+ delete global.Odac
75
+ })
76
+
77
+ test('should retrieve data from odac-data script tag', () => {
78
+ const mockData = {user: 'emre'}
79
+ document.getElementById.mockReturnValue({textContent: JSON.stringify(mockData)})
80
+ const result = window.Odac.data()
81
+ expect(result).toEqual(mockData)
82
+ expect(document.getElementById).toHaveBeenCalledWith('odac-data')
83
+ })
84
+
85
+ test('should return specific key from data', () => {
86
+ const mockData = {user: 'emre', role: 'admin'}
87
+ document.getElementById.mockReturnValue({textContent: JSON.stringify(mockData)})
88
+ expect(window.Odac.data('user')).toBe('emre')
89
+ expect(window.Odac.data('role')).toBe('admin')
90
+ })
91
+ })