odac 1.4.2 → 1.4.4
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/.agent/rules/coding.md +2 -2
- package/.github/workflows/release.yml +2 -0
- package/.husky/pre-push +0 -1
- package/.kiro/steering/coding.md +27 -0
- package/.kiro/steering/memory.md +56 -0
- package/.kiro/steering/project.md +30 -0
- package/.kiro/steering/workflow.md +16 -0
- package/CHANGELOG.md +99 -0
- package/README.md +1 -1
- package/client/odac.js +92 -15
- package/docs/ai/skills/backend/authentication.md +7 -5
- package/docs/ai/skills/backend/controllers.md +24 -3
- package/docs/ai/skills/backend/forms.md +8 -6
- package/docs/ai/skills/backend/image-processing.md +93 -0
- package/docs/ai/skills/backend/request_response.md +2 -2
- package/docs/ai/skills/backend/routing.md +11 -0
- package/docs/ai/skills/backend/structure.md +1 -1
- package/docs/ai/skills/frontend/realtime.md +18 -2
- package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +24 -0
- package/docs/backend/07-views/03-template-syntax.md +18 -2
- package/docs/backend/07-views/11-image-optimization.md +197 -0
- package/package.json +5 -2
- package/src/Auth.js +8 -4
- package/src/Config.js +5 -0
- package/src/Database/ConnectionFactory.js +16 -0
- package/src/Ipc.js +3 -2
- package/src/Lang.js +17 -10
- package/src/Odac.js +1 -0
- package/src/Request.js +20 -20
- package/src/Route.js +80 -33
- package/src/Validator.js +5 -5
- package/src/View/Image.js +495 -0
- package/src/View.js +4 -0
- package/test/Auth/verifyMagicLink.test.js +281 -0
- package/test/Client/ws.test.js +32 -0
- package/test/Lang/get.test.js +37 -11
- package/test/Odac/image.test.js +61 -0
- package/test/Route/check.test.js +101 -0
- package/test/Route/set.test.js +102 -0
- package/test/View/Image/buildFilename.test.js +62 -0
- package/test/View/Image/hash.test.js +59 -0
- package/test/View/Image/isAvailable.test.js +15 -0
- package/test/View/Image/parse.test.js +83 -0
- package/test/View/Image/process.test.js +38 -0
- package/test/View/Image/render.test.js +117 -0
- package/test/View/Image/serve.test.js +56 -0
- package/test/View/Image/url.test.js +53 -0
- package/test/View/constructor.test.js +10 -0
package/src/View.js
CHANGED
|
@@ -3,6 +3,7 @@ const fs = require('fs')
|
|
|
3
3
|
const fsPromises = fs.promises
|
|
4
4
|
const Form = require('./View/Form')
|
|
5
5
|
const EarlyHints = require('./View/EarlyHints')
|
|
6
|
+
const Image = require('./View/Image')
|
|
6
7
|
|
|
7
8
|
const TITLE_REGEX = /<title[^>]*>([^<]*)<\/title>/i
|
|
8
9
|
|
|
@@ -119,7 +120,9 @@ class View {
|
|
|
119
120
|
this.#earlyHints = global.Odac.View.EarlyHints
|
|
120
121
|
}
|
|
121
122
|
global.Odac.View.Form = Form
|
|
123
|
+
global.Odac.View.Image = Image
|
|
122
124
|
this.Form = Form
|
|
125
|
+
this.Image = Image
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
all(name) {
|
|
@@ -404,6 +407,7 @@ class View {
|
|
|
404
407
|
|
|
405
408
|
if (content !== null) {
|
|
406
409
|
content = Form.parse(content, this.#odac)
|
|
410
|
+
content = Image.parse(content)
|
|
407
411
|
|
|
408
412
|
const jsBlocks = []
|
|
409
413
|
content = content.replace(/<script:odac([^>]*)>([\s\S]*?)<\/script:odac>/g, (match, attrs, jsContent) => {
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
describe('Auth.verifyMagicLink()', () => {
|
|
2
|
+
let Auth
|
|
3
|
+
let reqMock
|
|
4
|
+
let authInstance
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Why: Builds a chainable DB mock simulating Knex's query builder pattern.
|
|
8
|
+
* Tracks all insert/update/delete calls for assertion.
|
|
9
|
+
* Supports orWhere, columnInfo, and schema — required by check(), register(), and migration helpers.
|
|
10
|
+
*
|
|
11
|
+
* @param {Array} rows - The rows the query should resolve to.
|
|
12
|
+
* @returns {object} Mock with chainable query builder and call tracker.
|
|
13
|
+
*/
|
|
14
|
+
const createDbMock = (rows = []) => {
|
|
15
|
+
const tracker = {
|
|
16
|
+
deleteCalls: [],
|
|
17
|
+
firstResult: rows[0] || null,
|
|
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(() => Promise.resolve(tracker.firstResult)),
|
|
28
|
+
insert: jest.fn(payload => {
|
|
29
|
+
tracker.insertCalls.push(payload)
|
|
30
|
+
return Promise.resolve(true)
|
|
31
|
+
}),
|
|
32
|
+
orWhere: jest.fn(() => chainable()),
|
|
33
|
+
then: cb => cb(rows),
|
|
34
|
+
update: jest.fn(payload => {
|
|
35
|
+
tracker.updateCalls.push(payload)
|
|
36
|
+
return Promise.resolve(true)
|
|
37
|
+
}),
|
|
38
|
+
where: jest.fn(() => chainable())
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
// columnInfo: used by register() to detect PK column type
|
|
43
|
+
columnInfo: jest.fn(() => Promise.resolve({type: 'string'})),
|
|
44
|
+
insert: jest.fn(payload => {
|
|
45
|
+
tracker.insertCalls.push(payload)
|
|
46
|
+
return Promise.resolve(true)
|
|
47
|
+
}),
|
|
48
|
+
// orWhere: entry point used by check(where) before chaining
|
|
49
|
+
orWhere: jest.fn(() => chainable()),
|
|
50
|
+
// schema: used by migration helpers (#ensureUserTableV2, #ensureTokenTableV2)
|
|
51
|
+
schema: jest.fn(() => Promise.resolve()),
|
|
52
|
+
tracker,
|
|
53
|
+
where: jest.fn(() => chainable())
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Why: Builds a minimal magic link record for DB mock responses.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} overrides - Fields to override on the default record.
|
|
61
|
+
* @returns {object} Magic link record.
|
|
62
|
+
*/
|
|
63
|
+
const createMagicRecord = (overrides = {}) => ({
|
|
64
|
+
browser: 'TestBrowser',
|
|
65
|
+
email: 'test@example.com',
|
|
66
|
+
expires_at: new Date(Date.now() + 15 * 60 * 1000),
|
|
67
|
+
id: 1,
|
|
68
|
+
ip: '127.0.0.1',
|
|
69
|
+
token_hash: 'hashed_token',
|
|
70
|
+
...overrides
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
// Isolate module per test to reset the static #migrationCache Set on Auth class
|
|
75
|
+
jest.isolateModules(() => {
|
|
76
|
+
Auth = require('../../src/Auth.js')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
reqMock = {
|
|
80
|
+
cookie: jest.fn((name, value) => {
|
|
81
|
+
if (value !== undefined) return
|
|
82
|
+
return null
|
|
83
|
+
}),
|
|
84
|
+
header: jest.fn(() => 'TestBrowser'),
|
|
85
|
+
host: 'example.com',
|
|
86
|
+
ip: '127.0.0.1',
|
|
87
|
+
res: {},
|
|
88
|
+
session: jest.fn(() => null),
|
|
89
|
+
ssl: false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
authInstance = new Auth(reqMock)
|
|
93
|
+
|
|
94
|
+
global.Odac = {
|
|
95
|
+
Config: {
|
|
96
|
+
auth: {
|
|
97
|
+
key: 'id',
|
|
98
|
+
magicTable: 'odac_magic',
|
|
99
|
+
table: 'users',
|
|
100
|
+
token: 'odac_auth'
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
DB: {
|
|
104
|
+
fn: {now: () => new Date()},
|
|
105
|
+
nanoid: () => 'nano_' + Date.now()
|
|
106
|
+
},
|
|
107
|
+
Var: jest.fn(() => ({
|
|
108
|
+
hash: jest.fn(() => 'hashed_value'),
|
|
109
|
+
hashCheck: jest.fn(() => true),
|
|
110
|
+
is: jest.fn(() => false),
|
|
111
|
+
md5: jest.fn(() => 'md5_value')
|
|
112
|
+
}))
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
delete global.Odac
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// ─── EXISTING USER SCENARIOS ──────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
it('should log in an existing user and return success', async () => {
|
|
123
|
+
const existingUser = {email: 'test@example.com', id: 'user_1', name: 'Test'}
|
|
124
|
+
const magicDbMock = createDbMock([createMagicRecord()])
|
|
125
|
+
// rows array is consumed by check(where) candidate query via .then()
|
|
126
|
+
const usersDbMock = createDbMock([existingUser])
|
|
127
|
+
usersDbMock.tracker.firstResult = existingUser
|
|
128
|
+
const authDbMock = createDbMock()
|
|
129
|
+
|
|
130
|
+
global.Odac.DB.odac_magic = magicDbMock
|
|
131
|
+
global.Odac.DB.users = usersDbMock
|
|
132
|
+
global.Odac.DB.odac_auth = authDbMock
|
|
133
|
+
|
|
134
|
+
const result = await authInstance.verifyMagicLink('raw_token', 'test@example.com')
|
|
135
|
+
|
|
136
|
+
expect(result.success).toBe(true)
|
|
137
|
+
expect(result.user).toEqual(existingUser)
|
|
138
|
+
// Exactly one login token must be inserted — no duplicate
|
|
139
|
+
expect(authDbMock.tracker.insertCalls.length).toBe(1)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should consume (delete) all magic tokens for the email after successful verification', async () => {
|
|
143
|
+
const existingUser = {email: 'test@example.com', id: 'user_1'}
|
|
144
|
+
const magicDbMock = createDbMock([createMagicRecord()])
|
|
145
|
+
const usersDbMock = createDbMock([existingUser])
|
|
146
|
+
usersDbMock.tracker.firstResult = existingUser
|
|
147
|
+
|
|
148
|
+
global.Odac.DB.odac_magic = magicDbMock
|
|
149
|
+
global.Odac.DB.users = usersDbMock
|
|
150
|
+
global.Odac.DB.odac_auth = createDbMock()
|
|
151
|
+
|
|
152
|
+
await authInstance.verifyMagicLink('raw_token', 'test@example.com')
|
|
153
|
+
|
|
154
|
+
// All magic tokens for this email must be deleted to prevent reuse
|
|
155
|
+
expect(magicDbMock.tracker.deleteCalls.length).toBe(1)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// ─── NEW USER (AUTO-REGISTER) SCENARIOS ───────────────────────────────────
|
|
159
|
+
|
|
160
|
+
it('should NOT create duplicate odac_auth tokens when auto-registering a new user', async () => {
|
|
161
|
+
// Regression test: verifyMagicLink must not call login() a second time
|
|
162
|
+
// when register() already performed auto-login internally.
|
|
163
|
+
const newUser = {email: 'new@example.com', id: 'nano_new'}
|
|
164
|
+
const magicDbMock = createDbMock([createMagicRecord({email: 'new@example.com'})])
|
|
165
|
+
|
|
166
|
+
// Query path breakdown:
|
|
167
|
+
// first() calls → (1) verifyMagicLink existence check: null
|
|
168
|
+
// (2) register unique field check: null
|
|
169
|
+
// (3) register post-insert retrieval: newUser
|
|
170
|
+
// then() calls → check(where) candidate query (used by login): always [newUser]
|
|
171
|
+
let usersFirstCallCount = 0
|
|
172
|
+
const usersDbMock = createDbMock()
|
|
173
|
+
const usersChainable = () => ({
|
|
174
|
+
delete: jest.fn(() => Promise.resolve(true)),
|
|
175
|
+
first: jest.fn(() => {
|
|
176
|
+
usersFirstCallCount++
|
|
177
|
+
return Promise.resolve(usersFirstCallCount < 3 ? null : newUser)
|
|
178
|
+
}),
|
|
179
|
+
orWhere: jest.fn(() => usersChainable()),
|
|
180
|
+
then: cb => cb([newUser]),
|
|
181
|
+
update: jest.fn(() => Promise.resolve(true)),
|
|
182
|
+
where: jest.fn(() => usersChainable())
|
|
183
|
+
})
|
|
184
|
+
usersDbMock.where = jest.fn(() => usersChainable())
|
|
185
|
+
usersDbMock.orWhere = jest.fn(() => usersChainable())
|
|
186
|
+
|
|
187
|
+
const authDbMock = createDbMock()
|
|
188
|
+
|
|
189
|
+
global.Odac.DB.odac_magic = magicDbMock
|
|
190
|
+
global.Odac.DB.users = usersDbMock
|
|
191
|
+
global.Odac.DB.odac_auth = authDbMock
|
|
192
|
+
|
|
193
|
+
const result = await authInstance.verifyMagicLink('raw_token', 'new@example.com')
|
|
194
|
+
|
|
195
|
+
expect(result.success).toBe(true)
|
|
196
|
+
// THE CRITICAL ASSERTION: register() auto-login + verifyMagicLink must produce exactly 1 token
|
|
197
|
+
expect(authDbMock.tracker.insertCalls.length).toBe(1)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should return the newly registered user on success', async () => {
|
|
201
|
+
const newUser = {email: 'new@example.com', id: 'nano_new'}
|
|
202
|
+
const magicDbMock = createDbMock([createMagicRecord({email: 'new@example.com'})])
|
|
203
|
+
|
|
204
|
+
let usersFirstCallCount = 0
|
|
205
|
+
const usersDbMock = createDbMock()
|
|
206
|
+
const usersChainable = () => ({
|
|
207
|
+
delete: jest.fn(() => Promise.resolve(true)),
|
|
208
|
+
first: jest.fn(() => {
|
|
209
|
+
usersFirstCallCount++
|
|
210
|
+
return Promise.resolve(usersFirstCallCount < 3 ? null : newUser)
|
|
211
|
+
}),
|
|
212
|
+
orWhere: jest.fn(() => usersChainable()),
|
|
213
|
+
then: cb => cb([newUser]),
|
|
214
|
+
update: jest.fn(() => Promise.resolve(true)),
|
|
215
|
+
where: jest.fn(() => usersChainable())
|
|
216
|
+
})
|
|
217
|
+
usersDbMock.where = jest.fn(() => usersChainable())
|
|
218
|
+
usersDbMock.orWhere = jest.fn(() => usersChainable())
|
|
219
|
+
|
|
220
|
+
global.Odac.DB.odac_magic = magicDbMock
|
|
221
|
+
global.Odac.DB.users = usersDbMock
|
|
222
|
+
global.Odac.DB.odac_auth = createDbMock()
|
|
223
|
+
|
|
224
|
+
const result = await authInstance.verifyMagicLink('raw_token', 'new@example.com')
|
|
225
|
+
|
|
226
|
+
expect(result.success).toBe(true)
|
|
227
|
+
expect(result.user).toBeDefined()
|
|
228
|
+
expect(result.user.email).toBe('new@example.com')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// ─── FAILURE SCENARIOS ────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
it('should return error when tokenRaw is missing', async () => {
|
|
234
|
+
const result = await authInstance.verifyMagicLink(null, 'test@example.com')
|
|
235
|
+
expect(result.success).toBe(false)
|
|
236
|
+
expect(result.error).toBe('Invalid link')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('should return error when email is missing', async () => {
|
|
240
|
+
const result = await authInstance.verifyMagicLink('raw_token', null)
|
|
241
|
+
expect(result.success).toBe(false)
|
|
242
|
+
expect(result.error).toBe('Invalid link')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('should return error when no valid (non-expired) magic records exist', async () => {
|
|
246
|
+
global.Odac.DB.odac_magic = createDbMock([])
|
|
247
|
+
|
|
248
|
+
const result = await authInstance.verifyMagicLink('raw_token', 'test@example.com')
|
|
249
|
+
|
|
250
|
+
expect(result.success).toBe(false)
|
|
251
|
+
expect(result.error).toBe('Link expired or invalid')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('should return error when token hash does not match', async () => {
|
|
255
|
+
global.Odac.Var = jest.fn(() => ({
|
|
256
|
+
hash: jest.fn(() => 'hashed_value'),
|
|
257
|
+
hashCheck: jest.fn(() => false),
|
|
258
|
+
is: jest.fn(() => false)
|
|
259
|
+
}))
|
|
260
|
+
global.Odac.DB.odac_magic = createDbMock([createMagicRecord()])
|
|
261
|
+
|
|
262
|
+
const result = await authInstance.verifyMagicLink('wrong_token', 'test@example.com')
|
|
263
|
+
|
|
264
|
+
expect(result.success).toBe(false)
|
|
265
|
+
expect(result.error).toBe('Invalid token')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('should NOT delete magic tokens when token hash does not match', async () => {
|
|
269
|
+
global.Odac.Var = jest.fn(() => ({
|
|
270
|
+
hash: jest.fn(() => 'hashed_value'),
|
|
271
|
+
hashCheck: jest.fn(() => false),
|
|
272
|
+
is: jest.fn(() => false)
|
|
273
|
+
}))
|
|
274
|
+
const magicDbMock = createDbMock([createMagicRecord()])
|
|
275
|
+
global.Odac.DB.odac_magic = magicDbMock
|
|
276
|
+
|
|
277
|
+
await authInstance.verifyMagicLink('wrong_token', 'test@example.com')
|
|
278
|
+
|
|
279
|
+
expect(magicDbMock.tracker.deleteCalls.length).toBe(0)
|
|
280
|
+
})
|
|
281
|
+
})
|
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/Lang/get.test.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
const fs = require('fs')
|
|
1
|
+
const fs = require('node:fs')
|
|
2
2
|
const Lang = require('../../src/Lang')
|
|
3
3
|
|
|
4
|
-
jest.mock('fs')
|
|
4
|
+
jest.mock('node:fs', () => ({
|
|
5
|
+
promises: {
|
|
6
|
+
mkdir: jest.fn(),
|
|
7
|
+
writeFile: jest.fn(),
|
|
8
|
+
access: jest.fn()
|
|
9
|
+
},
|
|
10
|
+
existsSync: jest.fn(),
|
|
11
|
+
mkdirSync: jest.fn(),
|
|
12
|
+
writeFileSync: jest.fn(),
|
|
13
|
+
readFileSync: jest.fn()
|
|
14
|
+
}))
|
|
5
15
|
|
|
6
16
|
describe('Lang.get()', () => {
|
|
7
17
|
let mockOdac
|
|
@@ -22,8 +32,8 @@ describe('Lang.get()', () => {
|
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
fs.existsSync.mockReturnValue(false)
|
|
25
|
-
fs.
|
|
26
|
-
fs.
|
|
35
|
+
fs.promises.mkdir.mockResolvedValue()
|
|
36
|
+
fs.promises.writeFile.mockResolvedValue()
|
|
27
37
|
fs.readFileSync.mockImplementation(() => {
|
|
28
38
|
throw new Error('ENOENT')
|
|
29
39
|
})
|
|
@@ -31,7 +41,7 @@ describe('Lang.get()', () => {
|
|
|
31
41
|
lang = new Lang(mockOdac)
|
|
32
42
|
})
|
|
33
43
|
|
|
34
|
-
it('should return matching string and support placeholders', () => {
|
|
44
|
+
it('should return matching string and support placeholders', async () => {
|
|
35
45
|
fs.existsSync.mockImplementation(path => path.includes('/tr.json'))
|
|
36
46
|
fs.readFileSync.mockReturnValue(
|
|
37
47
|
JSON.stringify({
|
|
@@ -40,10 +50,10 @@ describe('Lang.get()', () => {
|
|
|
40
50
|
)
|
|
41
51
|
|
|
42
52
|
lang.set('tr')
|
|
43
|
-
expect(lang.get('welcome', 'Emre')).toBe('Merhaba Emre!')
|
|
53
|
+
expect(await lang.get('welcome', 'Emre')).toBe('Merhaba Emre!')
|
|
44
54
|
})
|
|
45
55
|
|
|
46
|
-
it('should support numbered placeholders', () => {
|
|
56
|
+
it('should support numbered placeholders', async () => {
|
|
47
57
|
fs.existsSync.mockImplementation(path => path.includes('/en.json'))
|
|
48
58
|
fs.readFileSync.mockReturnValue(
|
|
49
59
|
JSON.stringify({
|
|
@@ -52,14 +62,30 @@ describe('Lang.get()', () => {
|
|
|
52
62
|
)
|
|
53
63
|
|
|
54
64
|
lang.set('en')
|
|
55
|
-
expect(lang.get('order', 'A', 'B')).toBe('First: A, Second: B')
|
|
65
|
+
expect(await lang.get('order', 'A', 'B')).toBe('First: A, Second: B')
|
|
56
66
|
})
|
|
57
67
|
|
|
58
|
-
it('should auto-save new keys', () => {
|
|
68
|
+
it('should auto-save new keys', async () => {
|
|
59
69
|
lang.set('en')
|
|
60
|
-
const result = lang.get('new_key')
|
|
70
|
+
const result = await lang.get('new_key')
|
|
61
71
|
|
|
62
72
|
expect(result).toBe('new_key')
|
|
63
|
-
expect(fs.
|
|
73
|
+
expect(fs.promises.writeFile).toHaveBeenCalledWith(expect.stringContaining('/en.json'), expect.stringContaining('"new_key": "new_key"'))
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should set language from ACCEPT-LANGUAGE header', () => {
|
|
77
|
+
mockOdac.Request.header.mockImplementation(name => {
|
|
78
|
+
if (name === 'ACCEPT-LANGUAGE') return 'tr-TR,tr;q=0.9'
|
|
79
|
+
return null
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
lang.set()
|
|
83
|
+
// We check internal #lang via side effect or just trust the logic if we can't access private field easily.
|
|
84
|
+
// But we can check if it tries to read tr.json
|
|
85
|
+
fs.existsSync.mockImplementation(path => path.includes('/tr.json'))
|
|
86
|
+
fs.readFileSync.mockReturnValue('{}')
|
|
87
|
+
|
|
88
|
+
lang.set()
|
|
89
|
+
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('/tr.json'), 'utf8')
|
|
64
90
|
})
|
|
65
91
|
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const Odac = require('../../src/Odac')
|
|
2
|
+
|
|
3
|
+
describe('Odac.image()', () => {
|
|
4
|
+
let mockOdac
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
mockOdac = {
|
|
8
|
+
Config: {request: {timeout: 1000}},
|
|
9
|
+
DB: {init: jest.fn(), close: jest.fn()},
|
|
10
|
+
Route: {
|
|
11
|
+
init: jest.fn(),
|
|
12
|
+
routes: {www: {}}
|
|
13
|
+
},
|
|
14
|
+
Storage: {get: jest.fn(), put: jest.fn()},
|
|
15
|
+
Env: {get: jest.fn()}
|
|
16
|
+
}
|
|
17
|
+
global.Odac = mockOdac
|
|
18
|
+
global.__dir = '/mock'
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
delete global.Odac
|
|
23
|
+
delete global.__dir
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('should be available on instance without req/res (cron context)', () => {
|
|
27
|
+
const ctx = Odac.instance(null, 'cron')
|
|
28
|
+
expect(typeof ctx.image).toBe('function')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('should be available on instance with req/res (controller context)', () => {
|
|
32
|
+
const mockReq = {
|
|
33
|
+
method: 'GET',
|
|
34
|
+
url: '/',
|
|
35
|
+
headers: {host: 'www.example.com'},
|
|
36
|
+
connection: {remoteAddress: '127.0.0.1'},
|
|
37
|
+
on: jest.fn()
|
|
38
|
+
}
|
|
39
|
+
const mockRes = {on: jest.fn()}
|
|
40
|
+
const ctx = Odac.instance('id', mockReq, mockRes)
|
|
41
|
+
expect(typeof ctx.image).toBe('function')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('should return a promise', () => {
|
|
45
|
+
const ctx = Odac.instance(null, 'cron')
|
|
46
|
+
const result = ctx.image('/images/test.jpg', {width: 300})
|
|
47
|
+
expect(result).toBeInstanceOf(Promise)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('should return original src when sharp is unavailable', async () => {
|
|
51
|
+
const ctx = Odac.instance(null, 'cron')
|
|
52
|
+
const result = await ctx.image('/images/test.jpg')
|
|
53
|
+
// sharp not installed in test env → returns original src
|
|
54
|
+
expect(result).toBe('/images/test.jpg')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('should return empty string for empty src', async () => {
|
|
58
|
+
const ctx = Odac.instance(null, 'cron')
|
|
59
|
+
expect(await ctx.image('')).toBe('')
|
|
60
|
+
})
|
|
61
|
+
})
|
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
|
})
|