odac 1.4.2 → 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.
- package/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/client/odac.js +92 -15
- package/package.json +1 -1
- package/src/Route.js +41 -30
- package/test/Client/ws.test.js +32 -0
- package/test/Route/check.test.js +101 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
### ⚙️ Engine Tuning
|
|
2
|
+
|
|
3
|
+
- **client:** extract ws connection logic and fix recursive sharedworker reconnects reconnect bug
|
|
4
|
+
- **client:** remove redundant truthy check for websocket token
|
|
5
|
+
- **route:** remove useless variable assignment for decodedUrl
|
|
6
|
+
|
|
7
|
+
### ⚡️ Performance Upgrades
|
|
8
|
+
|
|
9
|
+
- **client:** remove redundant token consumption during websocket initialization
|
|
10
|
+
|
|
11
|
+
### 📚 Documentation
|
|
12
|
+
|
|
13
|
+
- **README:** enhance security section with detailed CSRF protection features
|
|
14
|
+
|
|
15
|
+
### 🛠️ Fixes & Improvements
|
|
16
|
+
|
|
17
|
+
- **client:** enhance websocket reconnection logic with attempt tracking and timer management
|
|
18
|
+
- **client:** preserve existing websocket subprotocols & add try/catch layer to token provider
|
|
19
|
+
- **route:** improve URL decoding and public path handling for file requests
|
|
20
|
+
- **route:** sanitize decoded URL and improve public path validation
|
|
21
|
+
- **route:** use robust path.extname instead of string splitting for mime type resolution
|
|
22
|
+
- **websocket:** implement token provider for dynamic token handling on reconnect
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
Powered by [⚡ ODAC](https://odac.run)
|
|
29
|
+
|
|
1
30
|
### doc
|
|
2
31
|
|
|
3
32
|
- **forms:** update backend and frontend forms documentation with practical usage patterns and improved descriptions
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 🎨 **Built-in Tailwind CSS:** Zero-config integration with Tailwind CSS v4. Automatic compilation and optimization out of the box.
|
|
10
10
|
* 🔗 **Powerful Routing:** Create clean, custom URLs and manage infinite pages with a flexible routing system.
|
|
11
11
|
* ✨ **Seamless SPA Experience:** Automatic AJAX handling for forms and page transitions eliminates the need for complex client-side code.
|
|
12
|
-
* 🛡️ **Built-in Security:**
|
|
12
|
+
* 🛡️ **Built-in Security:** Enterprise-grade security out of the box. Includes secure default headers and a **Multi-tab Safe, Single-Use CSRF Protection (Nonce)**. Tokens self-replenish in the background, ensuring maximum defense without ever interrupting the user experience.
|
|
13
13
|
* 🔐 **Authentication:** Ready-to-use session management with enterprise-grade **Refresh Token Rotation**, secure password hashing, and authentication helpers.
|
|
14
14
|
* 🗄️ **Database Agnostic:** Integrated support for major databases (PostgreSQL, MySQL, SQLite) and Redis via Knex.js.
|
|
15
15
|
* 🌍 **i18n Support:** Native multi-language support to help you reach a global audience.
|
package/client/odac.js
CHANGED
|
@@ -7,10 +7,12 @@ class OdacWebSocket {
|
|
|
7
7
|
#reconnectAttempts = 0
|
|
8
8
|
#handlers = {}
|
|
9
9
|
#isClosed = false
|
|
10
|
+
#tokenProvider = null
|
|
10
11
|
|
|
11
12
|
constructor(url, protocols = [], options = {}) {
|
|
12
13
|
this.#url = url
|
|
13
14
|
this.#protocols = protocols
|
|
15
|
+
this.#tokenProvider = options.tokenProvider || null
|
|
14
16
|
this.#options = {
|
|
15
17
|
autoReconnect: true,
|
|
16
18
|
reconnectDelay: 3000,
|
|
@@ -23,6 +25,19 @@ class OdacWebSocket {
|
|
|
23
25
|
connect() {
|
|
24
26
|
if (this.#isClosed) return
|
|
25
27
|
|
|
28
|
+
if (this.#tokenProvider) {
|
|
29
|
+
try {
|
|
30
|
+
const freshToken = this.#tokenProvider()
|
|
31
|
+
if (freshToken) {
|
|
32
|
+
let current = Array.isArray(this.#protocols) ? this.#protocols : this.#protocols ? [this.#protocols] : []
|
|
33
|
+
this.#protocols = current.filter(p => !p.startsWith('odac-token-'))
|
|
34
|
+
this.#protocols.push(`odac-token-${freshToken}`)
|
|
35
|
+
}
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.error('Odac WebSocket tokenProvider error:', e)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
this.#socket = this.#protocols.length > 0 ? new WebSocket(this.#url, this.#protocols) : new WebSocket(this.#url)
|
|
27
42
|
|
|
28
43
|
this.#socket.onopen = () => {
|
|
@@ -924,12 +939,9 @@ if (typeof window !== 'undefined') {
|
|
|
924
939
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
925
940
|
const wsUrl = `${protocol}//${window.location.host}${path}`
|
|
926
941
|
const protocols = []
|
|
927
|
-
|
|
928
|
-
const csrfToken = this.token()
|
|
929
|
-
if (csrfToken) protocols.push(`odac-token-${csrfToken}`)
|
|
930
|
-
}
|
|
942
|
+
const tokenProvider = token ? () => this.token() : null
|
|
931
943
|
|
|
932
|
-
return new OdacWebSocket(wsUrl, protocols, options)
|
|
944
|
+
return new OdacWebSocket(wsUrl, protocols, {...options, tokenProvider})
|
|
933
945
|
}
|
|
934
946
|
|
|
935
947
|
#createSharedWebSocket(path, options) {
|
|
@@ -966,6 +978,9 @@ if (typeof window !== 'undefined') {
|
|
|
966
978
|
case 'error':
|
|
967
979
|
emit('error', data)
|
|
968
980
|
break
|
|
981
|
+
case 'requestToken':
|
|
982
|
+
worker.port.postMessage({type: 'provideToken', token: this.token()})
|
|
983
|
+
break
|
|
969
984
|
}
|
|
970
985
|
}
|
|
971
986
|
|
|
@@ -1031,6 +1046,67 @@ if (typeof window !== 'undefined') {
|
|
|
1031
1046
|
|
|
1032
1047
|
const broadcast = (type, data) => ports.forEach(port => port.postMessage({type, data}))
|
|
1033
1048
|
|
|
1049
|
+
let wsConfig = null
|
|
1050
|
+
let reconnectAttempts = 0
|
|
1051
|
+
let reconnectTimer = null
|
|
1052
|
+
|
|
1053
|
+
const requestTokenFromPort = () => {
|
|
1054
|
+
return new Promise(resolve => {
|
|
1055
|
+
const firstPort = ports.values().next().value
|
|
1056
|
+
if (!firstPort) return resolve(null)
|
|
1057
|
+
|
|
1058
|
+
let timeoutTimer = null
|
|
1059
|
+
|
|
1060
|
+
const handler = event => {
|
|
1061
|
+
if (event.data.type === 'provideToken') {
|
|
1062
|
+
clearTimeout(timeoutTimer)
|
|
1063
|
+
firstPort.removeEventListener('message', handler)
|
|
1064
|
+
resolve(event.data.token)
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
timeoutTimer = setTimeout(() => {
|
|
1069
|
+
firstPort.removeEventListener('message', handler)
|
|
1070
|
+
resolve(null)
|
|
1071
|
+
}, 5000)
|
|
1072
|
+
|
|
1073
|
+
firstPort.addEventListener('message', handler)
|
|
1074
|
+
firstPort.postMessage({type: 'requestToken'})
|
|
1075
|
+
})
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const connectSocket = protocols => {
|
|
1079
|
+
if (!wsConfig || ports.size === 0) return
|
|
1080
|
+
const wsUrl = wsConfig.protocol + '//' + wsConfig.host + wsConfig.path
|
|
1081
|
+
socket = new OdacWebSocket(wsUrl, protocols, {
|
|
1082
|
+
...wsConfig.options,
|
|
1083
|
+
tokenProvider: null
|
|
1084
|
+
})
|
|
1085
|
+
socket.on('open', () => {
|
|
1086
|
+
reconnectAttempts = 0
|
|
1087
|
+
broadcast('open')
|
|
1088
|
+
})
|
|
1089
|
+
socket.on('message', data => broadcast('message', data))
|
|
1090
|
+
socket.on('close', e => {
|
|
1091
|
+
broadcast('close', {code: e?.code, reason: e?.reason, wasClean: e?.wasClean})
|
|
1092
|
+
const maxAttempts = wsConfig.options.maxReconnectAttempts || 10
|
|
1093
|
+
if (wsConfig && wsConfig.options.autoReconnect !== false && ports.size > 0 && reconnectAttempts < maxAttempts) {
|
|
1094
|
+
if (socket) {
|
|
1095
|
+
socket.close()
|
|
1096
|
+
socket = null
|
|
1097
|
+
}
|
|
1098
|
+
reconnectAttempts++
|
|
1099
|
+
reconnectTimer = setTimeout(() => {
|
|
1100
|
+
requestTokenFromPort().then(freshToken => {
|
|
1101
|
+
if (!freshToken || ports.size === 0) return
|
|
1102
|
+
connectSocket(['odac-token-' + freshToken])
|
|
1103
|
+
})
|
|
1104
|
+
}, wsConfig.options.reconnectDelay || 1000)
|
|
1105
|
+
}
|
|
1106
|
+
})
|
|
1107
|
+
socket.on('error', e => broadcast('error', {message: e?.message || 'WebSocket error'}))
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1034
1110
|
self.onconnect = e => {
|
|
1035
1111
|
const port = e.ports[0]
|
|
1036
1112
|
ports.add(port)
|
|
@@ -1041,15 +1117,10 @@ if (typeof window !== 'undefined') {
|
|
|
1041
1117
|
switch (type) {
|
|
1042
1118
|
case 'connect':
|
|
1043
1119
|
if (!socket) {
|
|
1044
|
-
|
|
1120
|
+
wsConfig = {host, path, protocol, options}
|
|
1045
1121
|
const protocols = token ? ['odac-token-' + token] : []
|
|
1046
|
-
|
|
1047
|
-
socket.on('open', () => broadcast('open'))
|
|
1048
|
-
socket.on('message', data => broadcast('message', data))
|
|
1049
|
-
socket.on('close', e => broadcast('close', {code: e?.code, reason: e?.reason, wasClean: e?.wasClean}))
|
|
1050
|
-
socket.on('error', e => broadcast('error', {message: e?.message || 'WebSocket error'}))
|
|
1122
|
+
connectSocket(protocols)
|
|
1051
1123
|
} else if (socket.connected) {
|
|
1052
|
-
// If already connected, notify the new port immediately
|
|
1053
1124
|
port.postMessage({type: 'open'})
|
|
1054
1125
|
}
|
|
1055
1126
|
break
|
|
@@ -1058,9 +1129,15 @@ if (typeof window !== 'undefined') {
|
|
|
1058
1129
|
break
|
|
1059
1130
|
case 'close':
|
|
1060
1131
|
ports.delete(port)
|
|
1061
|
-
if (ports.size === 0
|
|
1062
|
-
|
|
1063
|
-
|
|
1132
|
+
if (ports.size === 0) {
|
|
1133
|
+
if (reconnectTimer) {
|
|
1134
|
+
clearTimeout(reconnectTimer)
|
|
1135
|
+
reconnectTimer = null
|
|
1136
|
+
}
|
|
1137
|
+
if (socket) {
|
|
1138
|
+
socket.close()
|
|
1139
|
+
socket = null
|
|
1140
|
+
}
|
|
1064
1141
|
}
|
|
1065
1142
|
break
|
|
1066
1143
|
}
|
package/package.json
CHANGED
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return fs.createReadStream(publicPath)
|
|
232
|
-
}
|
|
224
|
+
let decodedUrl
|
|
225
|
+
try {
|
|
226
|
+
decodedUrl = decodeURIComponent(url)
|
|
227
|
+
} catch {
|
|
228
|
+
decodedUrl = null // Invalid URI encoding
|
|
229
|
+
}
|
|
233
230
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
let arr = url.split('.')
|
|
240
|
-
type = mime[arr[arr.length - 1]]
|
|
241
|
-
}
|
|
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)
|
|
242
236
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
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',
|
|
243
|
+
Odac.Request.header('Content-Length', cached.size)
|
|
254
244
|
return fs.createReadStream(publicPath)
|
|
255
245
|
}
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
package/test/Client/ws.test.js
CHANGED
|
@@ -83,4 +83,36 @@ describe('Odac.ws()', () => {
|
|
|
83
83
|
socketInstance.onopen()
|
|
84
84
|
expect(openHandler).toHaveBeenCalled()
|
|
85
85
|
})
|
|
86
|
+
|
|
87
|
+
test('should use fresh token on reconnect', () => {
|
|
88
|
+
let tokenCounter = 0
|
|
89
|
+
mockXhr.response = JSON.stringify({token: 'initial-token'})
|
|
90
|
+
mockXhr.responseText = JSON.stringify({token: 'initial-token'})
|
|
91
|
+
mockXhr.onload = null
|
|
92
|
+
mockXhr.send = jest.fn(function () {
|
|
93
|
+
tokenCounter++
|
|
94
|
+
this.response = JSON.stringify({token: `token-${tokenCounter}`})
|
|
95
|
+
this.responseText = this.response
|
|
96
|
+
if (this.onload) this.onload()
|
|
97
|
+
})
|
|
98
|
+
mockDocument.cookie = 'odac_client=test-client'
|
|
99
|
+
|
|
100
|
+
window.Odac.ws('/test-ws', {token: true, autoReconnect: true, reconnectDelay: 100})
|
|
101
|
+
const firstCall = WebSocket.mock.calls[0]
|
|
102
|
+
expect(firstCall[1]).toEqual(expect.arrayContaining([expect.stringMatching(/^odac-token-/)]))
|
|
103
|
+
const firstToken = firstCall[1][0]
|
|
104
|
+
|
|
105
|
+
const socketInstance = WebSocket.mock.results[0].value
|
|
106
|
+
socketInstance.onopen()
|
|
107
|
+
|
|
108
|
+
global.setTimeout = jest.fn(fn => fn())
|
|
109
|
+
socketInstance.readyState = 3
|
|
110
|
+
socketInstance.onclose({code: 1006})
|
|
111
|
+
|
|
112
|
+
expect(WebSocket.mock.calls.length).toBeGreaterThan(1)
|
|
113
|
+
const secondCall = WebSocket.mock.calls[1]
|
|
114
|
+
expect(secondCall[1]).toEqual(expect.arrayContaining([expect.stringMatching(/^odac-token-/)]))
|
|
115
|
+
const secondToken = secondCall[1][0]
|
|
116
|
+
expect(secondToken).not.toBe(firstToken)
|
|
117
|
+
})
|
|
86
118
|
})
|
package/test/Route/check.test.js
CHANGED
|
@@ -308,4 +308,105 @@ describe('Route.check()', () => {
|
|
|
308
308
|
expect(paramHandler).not.toHaveBeenCalled()
|
|
309
309
|
})
|
|
310
310
|
})
|
|
311
|
+
|
|
312
|
+
describe('public static file serving', () => {
|
|
313
|
+
const fs = require('fs')
|
|
314
|
+
const path = require('path')
|
|
315
|
+
|
|
316
|
+
const createMockOdac = url => ({
|
|
317
|
+
Auth: {check: jest.fn().mockResolvedValue(true)},
|
|
318
|
+
Config: {debug: true},
|
|
319
|
+
Request: {
|
|
320
|
+
url,
|
|
321
|
+
method: 'get',
|
|
322
|
+
route: 'test_route',
|
|
323
|
+
header: jest.fn(),
|
|
324
|
+
cookie: jest.fn(() => null),
|
|
325
|
+
abort: jest.fn(),
|
|
326
|
+
setSession: jest.fn(),
|
|
327
|
+
data: {url: {}}
|
|
328
|
+
},
|
|
329
|
+
request: jest.fn().mockResolvedValue(null)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
beforeEach(() => {
|
|
333
|
+
global.__dir = '/app'
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
afterEach(() => {
|
|
337
|
+
jest.restoreAllMocks()
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('should serve a public static file successfully', async () => {
|
|
341
|
+
const mockStat = jest.spyOn(fs.promises, 'stat').mockResolvedValue({isFile: () => true, size: 1024})
|
|
342
|
+
const mockStream = {pipe: jest.fn()}
|
|
343
|
+
const mockCreateReadStream = jest.spyOn(fs, 'createReadStream').mockReturnValue(mockStream)
|
|
344
|
+
const expectedPath = path.normalize('/app/public/style.css')
|
|
345
|
+
|
|
346
|
+
const mockOdac = createMockOdac('/style.css')
|
|
347
|
+
const result = await route.check(mockOdac)
|
|
348
|
+
|
|
349
|
+
expect(mockStat).toHaveBeenCalledWith(expectedPath)
|
|
350
|
+
expect(mockOdac.Request.header).toHaveBeenCalledWith('Content-Type', 'text/css')
|
|
351
|
+
expect(mockOdac.Request.header).toHaveBeenCalledWith('Content-Length', 1024)
|
|
352
|
+
expect(mockCreateReadStream).toHaveBeenCalledWith(expectedPath)
|
|
353
|
+
expect(result).toBe(mockStream)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('should prevent path traversal attacks', async () => {
|
|
357
|
+
const mockStat = jest.spyOn(fs.promises, 'stat')
|
|
358
|
+
|
|
359
|
+
const mockOdac = createMockOdac('/../secrets.txt')
|
|
360
|
+
const result = await route.check(mockOdac)
|
|
361
|
+
|
|
362
|
+
expect(mockStat).not.toHaveBeenCalled()
|
|
363
|
+
expect(result).toBeUndefined() // Falls through if blocked
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('should prevent null byte injection attacks', async () => {
|
|
367
|
+
const mockStat = jest.spyOn(fs.promises, 'stat')
|
|
368
|
+
|
|
369
|
+
const mockOdac = createMockOdac('/style%00.css')
|
|
370
|
+
const result = await route.check(mockOdac)
|
|
371
|
+
|
|
372
|
+
expect(mockStat).not.toHaveBeenCalled()
|
|
373
|
+
expect(result).toBeUndefined()
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('should handle invalid URI encoding gracefully', async () => {
|
|
377
|
+
const mockStat = jest.spyOn(fs.promises, 'stat')
|
|
378
|
+
|
|
379
|
+
const mockOdac = createMockOdac('/%E0%A4%A') // Invalid URL encoded string
|
|
380
|
+
const result = await route.check(mockOdac)
|
|
381
|
+
|
|
382
|
+
expect(mockStat).not.toHaveBeenCalled()
|
|
383
|
+
expect(result).toBeUndefined()
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('should cache metadata in production mode', async () => {
|
|
387
|
+
jest.spyOn(fs.promises, 'stat').mockResolvedValue({isFile: () => true, size: 2048})
|
|
388
|
+
const mockCreateReadStream = jest.spyOn(fs, 'createReadStream').mockReturnValue('mock_stream')
|
|
389
|
+
|
|
390
|
+
const mockOdac = createMockOdac('/script.js')
|
|
391
|
+
mockOdac.Config.debug = false // prod mode
|
|
392
|
+
|
|
393
|
+
// first call (cache miss -> set cache)
|
|
394
|
+
await route.check(mockOdac)
|
|
395
|
+
expect(fs.promises.stat).toHaveBeenCalledTimes(1)
|
|
396
|
+
expect(mockOdac.Request.header).toHaveBeenCalledWith('Content-Type', 'text/javascript')
|
|
397
|
+
|
|
398
|
+
// reset mock tracking (cache hit -> no stat)
|
|
399
|
+
jest.clearAllMocks()
|
|
400
|
+
jest.spyOn(fs, 'createReadStream').mockReturnValue('mock_stream_2')
|
|
401
|
+
|
|
402
|
+
// second call
|
|
403
|
+
const result2 = await route.check(mockOdac)
|
|
404
|
+
|
|
405
|
+
expect(fs.promises.stat).not.toHaveBeenCalled() // Not called because metadata is cached
|
|
406
|
+
expect(mockOdac.Request.header).toHaveBeenCalledWith('Content-Type', 'text/javascript')
|
|
407
|
+
expect(mockOdac.Request.header).toHaveBeenCalledWith('Content-Length', 2048)
|
|
408
|
+
expect(mockCreateReadStream).toHaveBeenCalledWith(path.normalize('/app/public/script.js'))
|
|
409
|
+
expect(result2).toBe('mock_stream_2')
|
|
410
|
+
})
|
|
411
|
+
})
|
|
311
412
|
})
|