odac 1.3.0 → 1.4.0

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 (42) hide show
  1. package/.agent/rules/memory.md +7 -1
  2. package/.github/workflows/release.yml +0 -4
  3. package/AGENTS.md +47 -0
  4. package/CHANGELOG.md +32 -0
  5. package/README.md +1 -1
  6. package/bin/odac.js +169 -6
  7. package/client/odac.js +15 -11
  8. package/docs/ai/README.md +49 -0
  9. package/docs/ai/skills/SKILL.md +39 -0
  10. package/docs/ai/skills/backend/authentication.md +67 -0
  11. package/docs/ai/skills/backend/config.md +32 -0
  12. package/docs/ai/skills/backend/controllers.md +62 -0
  13. package/docs/ai/skills/backend/cron.md +50 -0
  14. package/docs/ai/skills/backend/database.md +21 -0
  15. package/docs/ai/skills/backend/forms.md +19 -0
  16. package/docs/ai/skills/backend/ipc.md +55 -0
  17. package/docs/ai/skills/backend/mail.md +34 -0
  18. package/docs/ai/skills/backend/request_response.md +35 -0
  19. package/docs/ai/skills/backend/routing.md +51 -0
  20. package/docs/ai/skills/backend/storage.md +43 -0
  21. package/docs/ai/skills/backend/streaming.md +34 -0
  22. package/docs/ai/skills/backend/structure.md +57 -0
  23. package/docs/ai/skills/backend/translations.md +42 -0
  24. package/docs/ai/skills/backend/utilities.md +24 -0
  25. package/docs/ai/skills/backend/validation.md +53 -0
  26. package/docs/ai/skills/backend/views.md +61 -0
  27. package/docs/ai/skills/frontend/core.md +66 -0
  28. package/docs/ai/skills/frontend/forms.md +21 -0
  29. package/docs/ai/skills/frontend/navigation.md +20 -0
  30. package/docs/ai/skills/frontend/realtime.md +47 -0
  31. package/docs/backend/10-authentication/01-user-logins-with-authjs.md +2 -0
  32. package/docs/backend/10-authentication/05-session-management.md +25 -3
  33. package/package.json +1 -1
  34. package/src/Auth.js +100 -15
  35. package/src/Route/Internal.js +21 -18
  36. package/src/Route/MimeTypes.js +56 -0
  37. package/src/Route.js +40 -63
  38. package/src/View/Form.js +91 -51
  39. package/src/View.js +8 -3
  40. package/test/Auth.test.js +249 -0
  41. package/test/Client.test.js +29 -0
  42. package/test/View/Form.test.js +37 -0
package/src/View/Form.js CHANGED
@@ -3,6 +3,18 @@ const nodeCrypto = require('crypto')
3
3
  class Form {
4
4
  static FORM_TYPES = ['register', 'login', 'magic-login', 'form']
5
5
 
6
+ static escapeHtml(value) {
7
+ if (value === null || value === undefined) return ''
8
+ const map = {
9
+ '&': '&',
10
+ '<': '&lt;',
11
+ '>': '&gt;',
12
+ '"': '&quot;',
13
+ "'": '&#39;'
14
+ }
15
+ return String(value).replace(/[&<>"']/g, ch => map[ch])
16
+ }
17
+
6
18
  static parse(content, Odac) {
7
19
  for (const type of this.FORM_TYPES) {
8
20
  content = this.parseFormType(content, Odac, type)
@@ -14,8 +26,14 @@ class Form {
14
26
  const regex = new RegExp(`<odac:${type}[\\s\\S]*?<\\/odac:${type}>`, 'g')
15
27
  return content.replace(regex, match => {
16
28
  const formConfig = this.extractConfig(match, null, type)
17
- const configStr = JSON.stringify(formConfig)
18
- const matchStr = JSON.stringify(match)
29
+ let configStr = JSON.stringify(formConfig)
30
+ let matchStr = JSON.stringify(match)
31
+
32
+ // Unquote dynamic variables to make them live JS expressions in the compiled view
33
+ // We avoid {{ }} here because View engine would turn them into ${ } which is invalid in naked JS
34
+ configStr = configStr.replace(/"\{\{([\s\S]*?)\}\}"/g, '(await $1)')
35
+ matchStr = matchStr.replace(/\{\{([\s\S]*?)\}\}/g, '" + (await Odac.Var(await $1).html()) + "')
36
+
19
37
  return `<script:odac>html += await Odac.View.Form.runtime(Odac, '${type}', ${configStr}, ${matchStr});</script:odac>`
20
38
  })
21
39
  }
@@ -91,7 +109,7 @@ class Form {
91
109
  if (redirectMatch) config.redirect = redirectMatch[1]
92
110
  if (autologinMatch) config.autologin = autologinMatch[1] !== 'false'
93
111
 
94
- const submitMatch = html.match(/<odac:submit([^>/]*)(?:\/?>|>(.*?)<\/odac:submit>)/)
112
+ const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
95
113
  if (submitMatch) {
96
114
  const submitTag = submitMatch[1]
97
115
  const textMatch = submitTag.match(/text=["']([^"']+)["']/)
@@ -145,22 +163,25 @@ class Form {
145
163
  id: null,
146
164
  unique: false,
147
165
  skip: false,
166
+ value: null,
148
167
  validations: []
149
168
  }
150
169
 
151
- const typeMatch = fieldTag.match(/type=["']([^"']+)["']/)
152
- const placeholderMatch = fieldTag.match(/placeholder=["']([^"']+)["']/)
153
- const labelMatch = fieldTag.match(/label=["']([^"']+)["']/)
154
- const classMatch = fieldTag.match(/class=["']([^"']+)["']/)
155
- const idMatch = fieldTag.match(/id=["']([^"']+)["']/)
170
+ const typeMatch = fieldTag.match(/type=(["'])(.*?)\1/)
171
+ const placeholderMatch = fieldTag.match(/placeholder=(["'])(.*?)\1/)
172
+ const labelMatch = fieldTag.match(/label=(["'])(.*?)\1/)
173
+ const classMatch = fieldTag.match(/class=(["'])(.*?)\1/)
174
+ const idMatch = fieldTag.match(/id=(["'])(.*?)\1/)
175
+ const valueMatch = fieldTag.match(/value=(["'])(.*?)\1/)
156
176
  const uniqueMatch = fieldTag.match(/unique=["']([^"']+)["']/) || fieldTag.match(/\sunique[\s/>]/)
157
177
  const skipMatch = fieldTag.match(/skip=["']([^"']+)["']/) || fieldTag.match(/\sskip[\s/>]/)
158
178
 
159
- if (typeMatch) field.type = typeMatch[1]
160
- if (placeholderMatch) field.placeholder = placeholderMatch[1]
161
- if (labelMatch) field.label = labelMatch[1]
162
- if (classMatch) field.class = classMatch[1]
163
- if (idMatch) field.id = idMatch[1]
179
+ if (typeMatch) field.type = typeMatch[2]
180
+ if (placeholderMatch) field.placeholder = placeholderMatch[2]
181
+ if (labelMatch) field.label = labelMatch[2]
182
+ if (classMatch) field.class = classMatch[2]
183
+ if (idMatch) field.id = idMatch[2]
184
+ if (valueMatch) field.value = valueMatch[2]
164
185
  if (uniqueMatch) field.unique = uniqueMatch[1] !== 'false'
165
186
  if (skipMatch) field.skip = skipMatch[1] !== 'false'
166
187
 
@@ -181,7 +202,7 @@ class Form {
181
202
 
182
203
  // Capture generic attributes
183
204
  const extraAttrs = {}
184
- const knownAttrs = ['name', 'type', 'placeholder', 'label', 'class', 'id', 'unique', 'skip']
205
+ const knownAttrs = ['name', 'type', 'placeholder', 'label', 'class', 'id', 'unique', 'skip', 'value']
185
206
  const attrRegex = /(\w+)(?:=(["'])((?:(?!\2).)*)\2|=([^\s>]+))?/g
186
207
  let attrMatch
187
208
  // Clean tag to just attributes part for safer regex matching if needed,
@@ -203,11 +224,11 @@ class Form {
203
224
  }
204
225
 
205
226
  static parseSet(html) {
206
- const nameMatch = html.match(/name=["']([^"']+)["']/)
227
+ const nameMatch = html.match(/name=(["'])(.*?)\1/)
207
228
  if (!nameMatch) return null
208
229
 
209
230
  const set = {
210
- name: nameMatch[1],
231
+ name: nameMatch[2],
211
232
  value: null,
212
233
  compute: null,
213
234
  callback: null,
@@ -215,14 +236,14 @@ class Form {
215
236
  }
216
237
 
217
238
  const valueMatch = html.match(/value=(["'])(.*?)\1/)
218
- const computeMatch = html.match(/compute=["']([^"']+)["']/)
219
- const callbackMatch = html.match(/callback=["']([^"']+)["']/)
220
- const ifEmptyMatch = html.match(/if-empty=["']([^"']+)["']/) || html.match(/\sif-empty[\s/>]/)
239
+ const computeMatch = html.match(/compute=(["'])(.*?)\1/)
240
+ const callbackMatch = html.match(/callback=(["'])(.*?)\1/)
241
+ const ifEmptyMatch = html.match(/if-empty=(["'])(.*?)\1/) || html.match(/\sif-empty[\s/>]/)
221
242
 
222
243
  if (valueMatch) set.value = valueMatch[2]
223
- if (computeMatch) set.compute = computeMatch[1]
224
- if (callbackMatch) set.callback = callbackMatch[1]
225
- if (ifEmptyMatch) set.ifEmpty = ifEmptyMatch[1] !== 'false'
244
+ if (computeMatch) set.compute = computeMatch[2]
245
+ if (callbackMatch) set.callback = callbackMatch[2]
246
+ if (ifEmptyMatch) set.ifEmpty = ifEmptyMatch[2] !== 'false'
226
247
 
227
248
  return set
228
249
  }
@@ -253,6 +274,9 @@ class Form {
253
274
  innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
254
275
  const field = this.parseInput(fieldMatch)
255
276
  if (!field) return fieldMatch
277
+ // Sync with resolved config value if available
278
+ const configField = config.fields.find(f => f.name === field.name)
279
+ if (configField) field.value = configField.value
256
280
  return this.generateFieldHtml(field)
257
281
  })
258
282
 
@@ -279,31 +303,38 @@ class Form {
279
303
 
280
304
  static generateFieldHtml(field) {
281
305
  let html = ''
306
+ const escapedName = this.escapeHtml(field.name)
307
+ const escapedType = this.escapeHtml(field.type)
308
+ const escapedPlaceholder = this.escapeHtml(field.placeholder)
282
309
 
283
310
  if (field.label && field.type !== 'checkbox') {
284
- const fieldId = field.id || `odac-${field.name}`
285
- html += `<label for="${fieldId}">${field.label}</label>\n`
311
+ const fieldId = this.escapeHtml(field.id || `odac-${field.name}`)
312
+ html += `<label for="${fieldId}">${this.escapeHtml(field.label)}</label>\n`
286
313
  }
287
314
 
288
- const classAttr = field.class ? ` class="${field.class}"` : ''
289
- const idAttr = field.id ? ` id="${field.id}"` : ` id="odac-${field.name}"`
315
+ const classAttr = field.class ? ` class="${this.escapeHtml(field.class)}"` : ''
316
+ const idAttr = field.id ? ` id="${this.escapeHtml(field.id)}"` : ` id="${this.escapeHtml(`odac-${field.name}`)}"`
317
+ const valueAttr = field.value !== null ? ` value="${this.escapeHtml(field.value)}"` : ''
290
318
 
291
319
  if (field.type === 'checkbox') {
292
320
  const attrs = this.buildHtml5Attributes(field)
321
+ const checkedAttr = field.value === '1' || field.value === true || field.value === 'true' ? ' checked' : ''
293
322
  if (field.label) {
294
323
  html += `<label>\n`
295
- html += ` <input type="checkbox"${idAttr} name="${field.name}" value="1"${classAttr}${attrs}>\n`
296
- html += ` ${field.label}\n`
324
+ html += ` <input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
325
+ html += ` ${this.escapeHtml(field.label)}\n`
297
326
  html += `</label>\n`
298
327
  } else {
299
- html += `<input type="checkbox"${idAttr} name="${field.name}" value="1"${classAttr}${attrs}>\n`
328
+ html += `<input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
300
329
  }
301
330
  } else if (field.type === 'textarea') {
302
331
  const attrs = this.buildHtml5Attributes(field)
303
- html += `<textarea${idAttr} name="${field.name}" placeholder="${field.placeholder}"${classAttr}${attrs}></textarea>\n`
332
+ html += `<textarea${idAttr} name="${escapedName}" placeholder="${escapedPlaceholder}"${classAttr}${attrs}>${this.escapeHtml(
333
+ field.value || ''
334
+ )}</textarea>\n`
304
335
  } else {
305
336
  const attrs = this.buildHtml5Attributes(field)
306
- html += `<input type="${field.type}"${idAttr} name="${field.name}" placeholder="${field.placeholder}"${classAttr}${attrs}>\n`
337
+ html += `<input type="${escapedType}"${idAttr} name="${escapedName}"${valueAttr} placeholder="${escapedPlaceholder}"${classAttr}${attrs}>\n`
307
338
  }
308
339
 
309
340
  return html
@@ -319,7 +350,7 @@ class Form {
319
350
  if (val === '') {
320
351
  attrs += ` ${key}`
321
352
  } else {
322
- attrs += ` ${key}="${val.replace(/"/g, '&quot;')}"`
353
+ attrs += ` ${key}="${this.escapeHtml(val)}"`
323
354
  }
324
355
  }
325
356
  }
@@ -406,11 +437,11 @@ class Form {
406
437
  if (html5Rules.max) attrs += ` max="${html5Rules.max}"`
407
438
  if (html5Rules.pattern) attrs += ` pattern="${html5Rules.pattern}"`
408
439
 
409
- if (errorMessages.required) attrs += ` data-error-required="${errorMessages.required.replace(/"/g, '&quot;')}"`
410
- if (errorMessages.minlength) attrs += ` data-error-minlength="${errorMessages.minlength.replace(/"/g, '&quot;')}"`
411
- if (errorMessages.maxlength) attrs += ` data-error-maxlength="${errorMessages.maxlength.replace(/"/g, '&quot;')}"`
412
- if (errorMessages.pattern) attrs += ` data-error-pattern="${errorMessages.pattern.replace(/"/g, '&quot;')}"`
413
- if (errorMessages.email) attrs += ` data-error-email="${errorMessages.email.replace(/"/g, '&quot;')}"`
440
+ if (errorMessages.required) attrs += ` data-error-required="${this.escapeHtml(errorMessages.required)}"`
441
+ if (errorMessages.minlength) attrs += ` data-error-minlength="${this.escapeHtml(errorMessages.minlength)}"`
442
+ if (errorMessages.maxlength) attrs += ` data-error-maxlength="${this.escapeHtml(errorMessages.maxlength)}"`
443
+ if (errorMessages.pattern) attrs += ` data-error-pattern="${this.escapeHtml(errorMessages.pattern)}"`
444
+ if (errorMessages.email) attrs += ` data-error-email="${this.escapeHtml(errorMessages.email)}"`
414
445
 
415
446
  attrs = this.appendExtraAttributes(attrs, field)
416
447
 
@@ -434,7 +465,7 @@ class Form {
434
465
 
435
466
  if (redirectMatch) config.redirect = redirectMatch[1]
436
467
 
437
- const submitMatch = html.match(/<odac:submit([^>/]*)(?:\/?>|>(.*?)<\/odac:submit>)/)
468
+ const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
438
469
  if (submitMatch) {
439
470
  const submitTag = submitMatch[1]
440
471
  const textMatch = submitTag.match(/text=["']([^"']+)["']/)
@@ -489,6 +520,9 @@ class Form {
489
520
  innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
490
521
  const field = this.parseInput(fieldMatch)
491
522
  if (!field) return fieldMatch
523
+ // Sync with resolved config value if available
524
+ const configField = config.fields.find(f => f.name === field.name)
525
+ if (configField) field.value = configField.value
492
526
  return this.generateFieldHtml(field)
493
527
  })
494
528
 
@@ -551,8 +585,10 @@ class Form {
551
585
  if (tableMatch) config.table = tableMatch
552
586
  if (redirectMatch) config.redirect = redirectMatch
553
587
  if (successMatch) config.successMessage = successMatch
588
+ const clearMatch = extractAttr('clear')
589
+ if (clearMatch !== null) config.clear = clearMatch === 'true' || clearMatch === ''
554
590
 
555
- const submitMatch = html.match(/<odac:submit([^>/]*)(?:\/?>|>(.*?)<\/odac:submit>)/)
591
+ const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
556
592
  if (submitMatch) {
557
593
  const submitTag = submitMatch[1]
558
594
  const textMatch = submitTag.match(/text=["']([^"']+)["']/)
@@ -618,29 +654,30 @@ class Form {
618
654
  innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
619
655
  const field = this.parseInput(fieldMatch)
620
656
  if (!field) return fieldMatch
657
+ // Sync with resolved config value if available
658
+ const configField = config.fields.find(f => f.name === field.name)
659
+ if (configField) field.value = configField.value
621
660
  return this.generateFieldHtml(field)
622
661
  })
623
662
 
624
- const escapeHtml = str =>
625
- String(str).replace(/[&<>"']/g, m => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'})[m])
626
-
627
663
  const submitMatch = innerContent.match(/<odac:submit[\s\S]*?(?:<\/odac:submit>|\/?>)/)
628
664
  if (submitMatch) {
629
- let submitAttrs = `type="submit" data-submit-text="${escapeHtml(submitText)}" data-loading-text="${escapeHtml(submitLoading)}"`
630
- if (config.submitClass) submitAttrs += ` class="${escapeHtml(config.submitClass)}"`
631
- if (config.submitStyle) submitAttrs += ` style="${escapeHtml(config.submitStyle)}"`
632
- if (config.submitId) submitAttrs += ` id="${escapeHtml(config.submitId)}"`
633
- const submitButton = `<button ${submitAttrs}>${escapeHtml(submitText)}</button>`
665
+ let submitAttrs = `type="submit" data-submit-text="${this.escapeHtml(submitText)}" data-loading-text="${this.escapeHtml(submitLoading)}"`
666
+ if (config.submitClass) submitAttrs += ` class="${this.escapeHtml(config.submitClass)}"`
667
+ if (config.submitStyle) submitAttrs += ` style="${this.escapeHtml(config.submitStyle)}"`
668
+ if (config.submitId) submitAttrs += ` id="${this.escapeHtml(config.submitId)}"`
669
+ const submitButton = `<button ${submitAttrs}>${this.escapeHtml(submitText)}</button>`
634
670
  innerContent = innerContent.replace(submitMatch[0], submitButton)
635
671
  }
636
672
 
637
673
  innerContent = innerContent.replace(/<odac:set[^>]*\/?>/g, '')
638
674
 
639
- let formAttrs = `class="odac-custom-form${config.class ? ' ' + escapeHtml(config.class) : ''}" data-odac-form="${escapeHtml(formToken)}" method="${escapeHtml(method)}" action="${escapeHtml(formAction)}" novalidate`
640
- if (config.id) formAttrs += ` id="${escapeHtml(config.id)}"`
675
+ let formAttrs = `class="odac-custom-form${config.class ? ' ' + this.escapeHtml(config.class) : ''}" data-odac-form="${this.escapeHtml(formToken)}" method="${this.escapeHtml(method)}" action="${this.escapeHtml(formAction)}" novalidate`
676
+ if (config.id) formAttrs += ` id="${this.escapeHtml(config.id)}"`
677
+ if (config.clear !== undefined) formAttrs += ` clear="${config.clear}"`
641
678
 
642
679
  let html = `<form ${formAttrs}>\n`
643
- html += ` <input type="hidden" name="_odac_form_token" value="${escapeHtml(formToken)}">\n`
680
+ html += ` <input type="hidden" name="_odac_form_token" value="${this.escapeHtml(formToken)}">\n`
644
681
  html += innerContent
645
682
  html += `\n <span class="odac-form-success" style="display:none;"></span>\n`
646
683
  html += `</form>`
@@ -693,7 +730,7 @@ class Form {
693
730
  })
694
731
  }
695
732
 
696
- const submitMatch = html.match(/<odac:submit([^>/]*)(?:\/?>|>(.*?)<\/odac:submit>)/)
733
+ const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
697
734
  if (submitMatch) {
698
735
  const submitTag = submitMatch[1]
699
736
  const textMatch = submitTag.match(/text=["']([^"']+)["']/)
@@ -751,6 +788,9 @@ class Form {
751
788
  innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
752
789
  const field = this.parseInput(fieldMatch)
753
790
  if (!field) return fieldMatch
791
+ // Sync with resolved config value if available
792
+ const configField = config.fields.find(f => f.name === field.name)
793
+ if (configField) field.value = configField.value
754
794
  return this.generateFieldHtml(field)
755
795
  })
756
796
  }
package/src/View.js CHANGED
@@ -58,6 +58,7 @@ class View {
58
58
  function: '{ let _arr = $constructor; for(let $key in _arr){ let $value = _arr[$key];',
59
59
  end: '}}',
60
60
  arguments: {
61
+ in: null,
61
62
  var: null,
62
63
  get: null,
63
64
  key: 'key',
@@ -83,6 +84,7 @@ class View {
83
84
  },
84
85
  list: {
85
86
  arguments: {
87
+ in: null,
86
88
  var: null,
87
89
  get: null,
88
90
  key: 'key',
@@ -453,12 +455,15 @@ class View {
453
455
  let fun = func.function
454
456
 
455
457
  if (key === 'for' || key === 'list') {
456
- if (!vars.var && !vars.get) {
457
- console.error(`"var" or "get" is required for "${match}"\n in "${file}"`)
458
+ if (!vars.var && !vars.get && !vars.in) {
459
+ console.error(`"var", "get" or "in" is required for "${match}"\n in "${file}"`)
458
460
  continue
459
461
  }
460
462
  let constructor
461
- if (vars.var) {
463
+ if (vars.in) {
464
+ constructor = `await ${vars.in}`
465
+ delete vars.in
466
+ } else if (vars.var) {
462
467
  constructor = `await ${vars.var}`
463
468
  delete vars.var
464
469
  } else if (vars.get) {
@@ -0,0 +1,249 @@
1
+ const Auth = require('../src/Auth.js')
2
+
3
+ describe('Auth - Refresh Token Rotation', () => {
4
+ let reqMock
5
+ let authInstance
6
+
7
+ /**
8
+ * Why: Builds a chainable DB mock that resolves query results via .then() (thenable).
9
+ * This simulates Knex's chainable query builder pattern.
10
+ *
11
+ * @param {Array} rows - The rows the query should resolve to.
12
+ * @returns {object} Mock object with insert, update, delete, first, where tracking.
13
+ */
14
+ const createDbMock = rows => {
15
+ const tracker = {
16
+ deleteCalls: [],
17
+ firstCalls: 0,
18
+ insertCalls: [],
19
+ updateCalls: []
20
+ }
21
+
22
+ const chainable = () => ({
23
+ delete: jest.fn((...args) => {
24
+ tracker.deleteCalls.push(args)
25
+ return Promise.resolve(true)
26
+ }),
27
+ first: jest.fn(() => {
28
+ tracker.firstCalls++
29
+ return Promise.resolve(rows[0] ? {id: rows[0].user, name: 'TestUser'} : null)
30
+ }),
31
+ update: jest.fn(payload => {
32
+ tracker.updateCalls.push(payload)
33
+ return Promise.resolve(true)
34
+ }),
35
+ then: cb => cb(rows),
36
+ where: jest.fn(() => chainable())
37
+ })
38
+
39
+ return {
40
+ chainable,
41
+ insert: jest.fn(payload => {
42
+ tracker.insertCalls.push(payload)
43
+ return Promise.resolve(true)
44
+ }),
45
+ tracker,
46
+ where: jest.fn(() => chainable())
47
+ }
48
+ }
49
+
50
+ beforeEach(() => {
51
+ // Cookie storage to separate get/set behavior
52
+ const cookieStore = {
53
+ odac_x: 'old_x',
54
+ odac_y: 'old_y'
55
+ }
56
+
57
+ reqMock = {
58
+ cookie: jest.fn((name, value, options) => {
59
+ // Setter mode: 2+ arguments
60
+ if (value !== undefined) {
61
+ cookieStore[name] = value
62
+ return
63
+ }
64
+ // Getter mode: 1 argument
65
+ return cookieStore[name] || null
66
+ }),
67
+ header: jest.fn(() => 'TestBrowser'),
68
+ ip: '127.0.0.1'
69
+ }
70
+
71
+ authInstance = new Auth(reqMock)
72
+
73
+ global.Odac = {
74
+ Config: {
75
+ auth: {
76
+ key: 'id',
77
+ rotationAge: 15 * 60 * 1000,
78
+ table: 'users',
79
+ token: 'user_tokens'
80
+ }
81
+ },
82
+ DB: {
83
+ fn: {now: () => new Date()},
84
+ nanoid: () => 'nano_' + Date.now()
85
+ },
86
+ Var: jest.fn(() => ({
87
+ hash: jest.fn(() => 'hashed_value'),
88
+ hashCheck: jest.fn(() => true)
89
+ }))
90
+ }
91
+ })
92
+
93
+ afterEach(() => {
94
+ delete global.Odac
95
+ })
96
+
97
+ it('should rotate token when tokenAge exceeds rotationAge and set Epoch Date marker', async () => {
98
+ const createdAt = Date.now() - 20 * 60 * 1000 // 20 mins ago -> exceeds 15 min rotationAge
99
+
100
+ const mockRecord = {
101
+ active: new Date(),
102
+ browser: 'TestBrowser',
103
+ date: new Date(createdAt),
104
+ id: 'token_1',
105
+ ip: '127.0.0.1',
106
+ token_x: 'old_x',
107
+ token_y: 'hashed_old_y',
108
+ user: 'user_10'
109
+ }
110
+
111
+ const dbMock = createDbMock([mockRecord])
112
+ global.Odac.DB.user_tokens = dbMock
113
+ global.Odac.DB.users = dbMock
114
+
115
+ const result = await authInstance.check()
116
+
117
+ expect(result).toBe(true)
118
+
119
+ // Verify new token was inserted
120
+ expect(dbMock.insert).toHaveBeenCalledTimes(1)
121
+ const inserted = dbMock.tracker.insertCalls[0]
122
+ expect(inserted.user).toBe('user_10')
123
+ expect(inserted.token_x).toBeDefined()
124
+ expect(inserted.token_y).toBe('hashed_value')
125
+
126
+ // Verify old token was marked with Epoch Date
127
+ expect(dbMock.tracker.updateCalls.length).toBe(1)
128
+ const updatePayload = dbMock.tracker.updateCalls[0]
129
+ expect(updatePayload.date.getTime()).toBe(0) // Epoch
130
+
131
+ // Verify new cookies were issued (setter calls)
132
+ const setCalls = reqMock.cookie.mock.calls.filter(c => c.length >= 2)
133
+ const xSet = setCalls.find(c => c[0] === 'odac_x' && c[2]?.httpOnly === true)
134
+ const ySet = setCalls.find(c => c[0] === 'odac_y' && c[2]?.httpOnly === true)
135
+ expect(xSet).toBeDefined()
136
+ expect(ySet).toBeDefined()
137
+ // New cookie values must differ from old ones
138
+ expect(xSet[1]).not.toBe('old_x')
139
+ expect(ySet[1]).not.toBe('old_y')
140
+ })
141
+
142
+ it('should NOT rotate a token already marked as rotated (Epoch Date marker)', async () => {
143
+ const mockRecord = {
144
+ active: new Date(), // Still within maxAge
145
+ browser: 'TestBrowser',
146
+ date: new Date(0), // Epoch marker = already rotated
147
+ id: 'token_2',
148
+ ip: '127.0.0.1',
149
+ token_x: 'old_x',
150
+ token_y: 'hashed_old_y',
151
+ user: 'user_10'
152
+ }
153
+
154
+ const dbMock = createDbMock([mockRecord])
155
+ global.Odac.DB.user_tokens = dbMock
156
+ global.Odac.DB.users = dbMock
157
+
158
+ const result = await authInstance.check()
159
+
160
+ expect(result).toBe(true)
161
+ // No rotation should occur
162
+ expect(dbMock.insert).not.toHaveBeenCalled()
163
+ expect(dbMock.tracker.updateCalls.length).toBe(0)
164
+ })
165
+
166
+ it('should NOT rotate when tokenAge is within rotationAge threshold', async () => {
167
+ const recentDate = Date.now() - 5 * 60 * 1000 // 5 mins ago -> within 15 min rotationAge
168
+
169
+ const mockRecord = {
170
+ active: new Date(),
171
+ browser: 'TestBrowser',
172
+ date: new Date(recentDate),
173
+ id: 'token_3',
174
+ ip: '127.0.0.1',
175
+ token_x: 'old_x',
176
+ token_y: 'hashed_old_y',
177
+ user: 'user_10'
178
+ }
179
+
180
+ const dbMock = createDbMock([mockRecord])
181
+ global.Odac.DB.user_tokens = dbMock
182
+ global.Odac.DB.users = dbMock
183
+
184
+ const result = await authInstance.check()
185
+
186
+ expect(result).toBe(true)
187
+ expect(dbMock.insert).not.toHaveBeenCalled()
188
+ expect(dbMock.tracker.updateCalls.length).toBe(0)
189
+ })
190
+
191
+ it('should delete token and return false when inactiveAge exceeds maxAge', async () => {
192
+ const staleActive = Date.now() - 31 * 24 * 60 * 60 * 1000 // 31 days ago -> exceeds 30 day maxAge
193
+
194
+ const mockRecord = {
195
+ active: new Date(staleActive),
196
+ browser: 'TestBrowser',
197
+ date: new Date(),
198
+ id: 'token_4',
199
+ ip: '127.0.0.1',
200
+ token_x: 'old_x',
201
+ token_y: 'hashed_old_y',
202
+ user: 'user_10'
203
+ }
204
+
205
+ const dbMock = createDbMock([mockRecord])
206
+ global.Odac.DB.user_tokens = dbMock
207
+ global.Odac.DB.users = dbMock
208
+
209
+ const result = await authInstance.check()
210
+
211
+ expect(result).toBe(false)
212
+ // Token should be deleted
213
+ expect(dbMock.tracker.deleteCalls.length).toBe(1)
214
+ // No rotation should occur
215
+ expect(dbMock.insert).not.toHaveBeenCalled()
216
+ })
217
+
218
+ it('should update active timestamp when inactiveAge exceeds updateAge but tokenAge is within rotationAge', async () => {
219
+ const staleActive = Date.now() - 25 * 60 * 60 * 1000 // 25 hours ago -> exceeds 24h updateAge
220
+ const recentDate = Date.now() - 5 * 60 * 1000 // 5 mins ago -> within rotationAge
221
+
222
+ const mockRecord = {
223
+ active: new Date(staleActive),
224
+ browser: 'TestBrowser',
225
+ date: new Date(recentDate),
226
+ id: 'token_5',
227
+ ip: '127.0.0.1',
228
+ token_x: 'old_x',
229
+ token_y: 'hashed_old_y',
230
+ user: 'user_10'
231
+ }
232
+
233
+ const dbMock = createDbMock([mockRecord])
234
+ global.Odac.DB.user_tokens = dbMock
235
+ global.Odac.DB.users = dbMock
236
+
237
+ const result = await authInstance.check()
238
+
239
+ expect(result).toBe(true)
240
+ // Should NOT rotate (tokenAge within threshold)
241
+ expect(dbMock.insert).not.toHaveBeenCalled()
242
+ // Should update active timestamp (fallback path)
243
+ expect(dbMock.tracker.updateCalls.length).toBe(1)
244
+ const updatePayload = dbMock.tracker.updateCalls[0]
245
+ expect(updatePayload.active).toBeInstanceOf(Date)
246
+ // Should NOT have Epoch marker
247
+ expect(updatePayload.date).toBeUndefined()
248
+ })
249
+ })
@@ -151,6 +151,35 @@ describe('Client (odac.js)', () => {
151
151
  })
152
152
  })
153
153
 
154
+ describe('get()', () => {
155
+ test('should automatically parse JSON if Content-Type is application/json', () => {
156
+ const mockCallback = jest.fn()
157
+ const mockData = {success: true}
158
+
159
+ mockXhr.open.mockClear()
160
+ mockXhr.send.mockClear()
161
+
162
+ // Mock token() to avoid side effects and XHR calls
163
+ jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
164
+
165
+ // Setup response for the get request
166
+ mockXhr.responseText = JSON.stringify(mockData)
167
+ mockXhr.status = 200
168
+ mockXhr.statusText = 'OK'
169
+ mockXhr.getResponseHeader.mockImplementation(header => {
170
+ if (header === 'Content-Type') return 'application/json'
171
+ return null
172
+ })
173
+
174
+ window.Odac.get('/api/test', mockCallback)
175
+
176
+ // Trigger the completion of the get request
177
+ if (mockXhr.onload) mockXhr.onload()
178
+
179
+ expect(mockCallback).toHaveBeenCalledWith(mockData, expect.anything(), expect.anything())
180
+ })
181
+ })
182
+
154
183
  describe('OdacWebSocket', () => {
155
184
  test('should connect to WebSocket and handle events', () => {
156
185
  const ws = window.Odac.ws('/test-ws', {token: false})
@@ -0,0 +1,37 @@
1
+ const Form = require('../../src/View/Form')
2
+
3
+ describe('Form HTML escaping', () => {
4
+ test('should escape textarea content to prevent tag breakout XSS', () => {
5
+ const html = Form.generateFieldHtml({
6
+ name: 'bio',
7
+ type: 'textarea',
8
+ placeholder: 'About me',
9
+ label: null,
10
+ class: '',
11
+ id: null,
12
+ value: '</textarea><script>alert(1)</script>',
13
+ validations: [],
14
+ extraAttributes: {}
15
+ })
16
+
17
+ expect(html).toContain('&lt;/textarea&gt;&lt;script&gt;alert(1)&lt;/script&gt;')
18
+ expect(html).not.toContain('</textarea><script>alert(1)</script>')
19
+ })
20
+
21
+ test('should escape full HTML entities in input value attributes', () => {
22
+ const html = Form.generateFieldHtml({
23
+ name: 'displayName',
24
+ type: 'text',
25
+ placeholder: 'Name',
26
+ label: null,
27
+ class: '',
28
+ id: null,
29
+ value: 'a&b"c<d>e\'f',
30
+ validations: [],
31
+ extraAttributes: {}
32
+ })
33
+
34
+ expect(html).toContain('value="a&amp;b&quot;c&lt;d&gt;e&#39;f"')
35
+ expect(html).not.toContain('value="a&b"c<d>e\'f"')
36
+ })
37
+ })