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.
- package/.agent/rules/memory.md +5 -0
- package/.releaserc.js +9 -2
- package/CHANGELOG.md +64 -0
- package/README.md +1 -1
- package/bin/odac.js +3 -2
- package/client/odac.js +124 -28
- package/docs/ai/skills/backend/database.md +19 -0
- package/docs/ai/skills/backend/forms.md +107 -13
- package/docs/ai/skills/backend/migrations.md +8 -2
- package/docs/ai/skills/backend/validation.md +132 -32
- package/docs/ai/skills/frontend/forms.md +43 -15
- package/docs/backend/08-database/02-basics.md +49 -9
- package/docs/backend/08-database/04-migrations.md +1 -0
- package/package.json +1 -1
- package/src/Auth.js +15 -2
- package/src/Database/ConnectionFactory.js +1 -0
- package/src/Database/Migration.js +26 -1
- package/src/Database/nanoid.js +30 -0
- package/src/Database.js +122 -11
- package/src/Ipc.js +37 -0
- package/src/Odac.js +1 -1
- package/src/Route/Cron.js +11 -0
- package/src/Route.js +49 -30
- package/src/Server.js +77 -23
- package/src/Storage.js +15 -1
- package/src/Validator.js +22 -20
- package/test/{Auth.test.js → Auth/check.test.js} +91 -5
- package/test/Client/data.test.js +91 -0
- package/test/Client/get.test.js +90 -0
- package/test/Client/storage.test.js +87 -0
- package/test/Client/token.test.js +82 -0
- package/test/Client/ws.test.js +118 -0
- package/test/Config/deepMerge.test.js +14 -0
- package/test/Config/init.test.js +66 -0
- package/test/Config/interpolate.test.js +35 -0
- package/test/Database/ConnectionFactory/buildConnectionConfig.test.js +13 -0
- package/test/Database/ConnectionFactory/buildConnections.test.js +31 -0
- package/test/Database/ConnectionFactory/resolveClient.test.js +12 -0
- package/test/Database/Migration/migrate_column.test.js +52 -0
- package/test/Database/Migration/migrate_files.test.js +70 -0
- package/test/Database/Migration/migrate_index.test.js +89 -0
- package/test/Database/Migration/migrate_nanoid.test.js +160 -0
- package/test/Database/Migration/migrate_seed.test.js +77 -0
- package/test/Database/Migration/migrate_table.test.js +88 -0
- package/test/Database/Migration/rollback.test.js +61 -0
- package/test/Database/Migration/snapshot.test.js +38 -0
- package/test/Database/Migration/status.test.js +41 -0
- package/test/Database/autoNanoid.test.js +215 -0
- package/test/Database/nanoid.test.js +19 -0
- package/test/Lang/constructor.test.js +25 -0
- package/test/Lang/get.test.js +65 -0
- package/test/Lang/set.test.js +49 -0
- package/test/Odac/init.test.js +42 -0
- package/test/Odac/instance.test.js +58 -0
- package/test/Route/{Middleware.test.js → Middleware/chaining.test.js} +5 -29
- package/test/Route/Middleware/use.test.js +35 -0
- package/test/{Route.test.js → Route/check.test.js} +100 -50
- package/test/Route/set.test.js +52 -0
- package/test/Route/ws.test.js +23 -0
- package/test/View/EarlyHints/cache.test.js +32 -0
- package/test/View/EarlyHints/extractFromHtml.test.js +143 -0
- package/test/View/EarlyHints/formatLinkHeader.test.js +33 -0
- package/test/View/EarlyHints/send.test.js +99 -0
- package/test/View/{Form.test.js → Form/generateFieldHtml.test.js} +2 -2
- package/test/View/constructor.test.js +22 -0
- package/test/View/print.test.js +19 -0
- package/test/WebSocket/Client/limits.test.js +55 -0
- package/test/WebSocket/Server/broadcast.test.js +33 -0
- package/test/WebSocket/Server/route.test.js +37 -0
- package/test/Client.test.js +0 -197
- package/test/Config.test.js +0 -119
- package/test/Database/ConnectionFactory.test.js +0 -80
- package/test/Lang.test.js +0 -92
- package/test/Migration.test.js +0 -943
- package/test/Odac.test.js +0 -88
- package/test/View/EarlyHints.test.js +0 -282
- package/test/WebSocket.test.js +0 -238
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const Route = require('
|
|
1
|
+
const Route = require('../../src/Route')
|
|
2
2
|
|
|
3
|
-
describe('Route', () => {
|
|
3
|
+
describe('Route.check()', () => {
|
|
4
4
|
let route
|
|
5
5
|
|
|
6
6
|
beforeEach(() => {
|
|
@@ -17,7 +17,7 @@ describe('Route', () => {
|
|
|
17
17
|
delete global.__dir
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
-
describe('
|
|
20
|
+
describe('token request', () => {
|
|
21
21
|
it('should handle token request with undefined route gracefully', async () => {
|
|
22
22
|
const mockOdac = {
|
|
23
23
|
Request: {
|
|
@@ -205,42 +205,7 @@ describe('Route', () => {
|
|
|
205
205
|
})
|
|
206
206
|
})
|
|
207
207
|
|
|
208
|
-
describe('
|
|
209
|
-
it('should register a route with function handler', () => {
|
|
210
|
-
global.Odac.Route.buff = 'test_route'
|
|
211
|
-
const handler = jest.fn()
|
|
212
|
-
|
|
213
|
-
route.set('get', '/test', handler)
|
|
214
|
-
|
|
215
|
-
expect(route.routes.test_route).toBeDefined()
|
|
216
|
-
expect(route.routes.test_route.get).toBeDefined()
|
|
217
|
-
expect(route.routes.test_route.get['/test']).toBeDefined()
|
|
218
|
-
expect(route.routes.test_route.get['/test'].cache).toBe(handler)
|
|
219
|
-
expect(route.routes.test_route.get['/test'].type).toBe('function')
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
it('should handle array of methods', () => {
|
|
223
|
-
global.Odac.Route.buff = 'test_route'
|
|
224
|
-
const handler = jest.fn()
|
|
225
|
-
|
|
226
|
-
route.set(['get', 'post'], '/test', handler)
|
|
227
|
-
|
|
228
|
-
expect(route.routes.test_route.get['/test']).toBeDefined()
|
|
229
|
-
expect(route.routes.test_route.post['/test']).toBeDefined()
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
it('should strip trailing slash from url', () => {
|
|
233
|
-
global.Odac.Route.buff = 'test_route'
|
|
234
|
-
const handler = jest.fn()
|
|
235
|
-
|
|
236
|
-
route.set('get', '/test/', handler)
|
|
237
|
-
|
|
238
|
-
expect(route.routes.test_route.get['/test']).toBeDefined()
|
|
239
|
-
expect(route.routes.test_route.get['/test/']).toBeUndefined()
|
|
240
|
-
})
|
|
241
|
-
})
|
|
242
|
-
|
|
243
|
-
describe('parametric route matching (#controller via check)', () => {
|
|
208
|
+
describe('parametric route matching', () => {
|
|
244
209
|
const createMockOdac = (url, method = 'get') => ({
|
|
245
210
|
Auth: {check: jest.fn().mockResolvedValue(true)},
|
|
246
211
|
Config: {},
|
|
@@ -344,19 +309,104 @@ describe('Route', () => {
|
|
|
344
309
|
})
|
|
345
310
|
})
|
|
346
311
|
|
|
347
|
-
describe('
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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()
|
|
353
338
|
})
|
|
354
339
|
|
|
355
|
-
it('should
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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')
|
|
360
410
|
})
|
|
361
411
|
})
|
|
362
412
|
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const Route = require('../../src/Route')
|
|
2
|
+
|
|
3
|
+
describe('Route.set()', () => {
|
|
4
|
+
let route
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
route = new Route()
|
|
8
|
+
global.Odac = {
|
|
9
|
+
Route: {},
|
|
10
|
+
Config: {}
|
|
11
|
+
}
|
|
12
|
+
global.__dir = process.cwd()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
delete global.Odac
|
|
17
|
+
delete global.__dir
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('should register a route with function handler', () => {
|
|
21
|
+
global.Odac.Route.buff = 'test_route'
|
|
22
|
+
const handler = jest.fn()
|
|
23
|
+
|
|
24
|
+
route.set('get', '/test', handler)
|
|
25
|
+
|
|
26
|
+
expect(route.routes.test_route).toBeDefined()
|
|
27
|
+
expect(route.routes.test_route.get).toBeDefined()
|
|
28
|
+
expect(route.routes.test_route.get['/test']).toBeDefined()
|
|
29
|
+
expect(route.routes.test_route.get['/test'].cache).toBe(handler)
|
|
30
|
+
expect(route.routes.test_route.get['/test'].type).toBe('function')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should handle array of methods', () => {
|
|
34
|
+
global.Odac.Route.buff = 'test_route'
|
|
35
|
+
const handler = jest.fn()
|
|
36
|
+
|
|
37
|
+
route.set(['get', 'post'], '/test', handler)
|
|
38
|
+
|
|
39
|
+
expect(route.routes.test_route.get['/test']).toBeDefined()
|
|
40
|
+
expect(route.routes.test_route.post['/test']).toBeDefined()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should strip trailing slash from url', () => {
|
|
44
|
+
global.Odac.Route.buff = 'test_route'
|
|
45
|
+
const handler = jest.fn()
|
|
46
|
+
|
|
47
|
+
route.set('get', '/test/', handler)
|
|
48
|
+
|
|
49
|
+
expect(route.routes.test_route.get['/test']).toBeDefined()
|
|
50
|
+
expect(route.routes.test_route.get['/test/']).toBeUndefined()
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const Route = require('../../src/Route')
|
|
2
|
+
|
|
3
|
+
describe('Route WebSocket methods', () => {
|
|
4
|
+
let route
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
route = new Route()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('should call ws() method successfully', () => {
|
|
11
|
+
const handler = jest.fn()
|
|
12
|
+
expect(() => {
|
|
13
|
+
route.ws('/test', handler, {token: false})
|
|
14
|
+
}).not.toThrow()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should call authWs() method successfully', () => {
|
|
18
|
+
const handler = jest.fn()
|
|
19
|
+
expect(() => {
|
|
20
|
+
route.authWs('/test', handler)
|
|
21
|
+
}).not.toThrow()
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const EarlyHints = require('../../../src/View/EarlyHints')
|
|
2
|
+
|
|
3
|
+
describe('EarlyHints Caching', () => {
|
|
4
|
+
let earlyHints
|
|
5
|
+
let mockConfig
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockConfig = {
|
|
9
|
+
enabled: true,
|
|
10
|
+
auto: true,
|
|
11
|
+
maxResources: 5
|
|
12
|
+
}
|
|
13
|
+
earlyHints = new EarlyHints(mockConfig)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should cache hints for route', () => {
|
|
17
|
+
const resources = [{href: '/css/main.css', as: 'style'}]
|
|
18
|
+
earlyHints.cacheHints('/home', resources)
|
|
19
|
+
|
|
20
|
+
const cached = earlyHints.getHints(null, '/home')
|
|
21
|
+
expect(cached).toEqual(resources)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should not cache when disabled', () => {
|
|
25
|
+
const hints = new EarlyHints({enabled: false})
|
|
26
|
+
const resources = [{href: '/css/main.css', as: 'style'}]
|
|
27
|
+
hints.cacheHints('/home', resources)
|
|
28
|
+
|
|
29
|
+
const cached = hints.getHints(null, '/home')
|
|
30
|
+
expect(cached).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const EarlyHints = require('../../../src/View/EarlyHints')
|
|
2
|
+
|
|
3
|
+
describe('EarlyHints.extractFromHtml()', () => {
|
|
4
|
+
let earlyHints
|
|
5
|
+
let mockConfig
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockConfig = {
|
|
9
|
+
enabled: true,
|
|
10
|
+
auto: true,
|
|
11
|
+
maxResources: 5
|
|
12
|
+
}
|
|
13
|
+
earlyHints = new EarlyHints(mockConfig)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should extract CSS resources from head', () => {
|
|
17
|
+
const html = `
|
|
18
|
+
<html>
|
|
19
|
+
<head>
|
|
20
|
+
<link rel="stylesheet" href="/css/main.css">
|
|
21
|
+
<link rel="stylesheet" href="/css/theme.css">
|
|
22
|
+
</head>
|
|
23
|
+
<body></body>
|
|
24
|
+
</html>
|
|
25
|
+
`
|
|
26
|
+
const resources = earlyHints.extractFromHtml(html)
|
|
27
|
+
expect(resources).toHaveLength(2)
|
|
28
|
+
expect(resources[0]).toEqual({href: '/css/main.css', as: 'style'})
|
|
29
|
+
expect(resources[1]).toEqual({href: '/css/theme.css', as: 'style'})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should extract JS resources from head', () => {
|
|
33
|
+
const html = `
|
|
34
|
+
<html>
|
|
35
|
+
<head>
|
|
36
|
+
<script src="/js/app.js"></script>
|
|
37
|
+
</head>
|
|
38
|
+
<body></body>
|
|
39
|
+
</html>
|
|
40
|
+
`
|
|
41
|
+
const resources = earlyHints.extractFromHtml(html)
|
|
42
|
+
expect(resources).toHaveLength(1)
|
|
43
|
+
expect(resources[0]).toEqual({href: '/js/app.js', as: 'script'})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should not extract deferred JS', () => {
|
|
47
|
+
const html = `
|
|
48
|
+
<html>
|
|
49
|
+
<head>
|
|
50
|
+
<script src="/js/app.js" defer></script>
|
|
51
|
+
<script src="/js/async.js" async></script>
|
|
52
|
+
</head>
|
|
53
|
+
<body></body>
|
|
54
|
+
</html>
|
|
55
|
+
`
|
|
56
|
+
const resources = earlyHints.extractFromHtml(html)
|
|
57
|
+
expect(resources).toHaveLength(0)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should extract font resources', () => {
|
|
61
|
+
const html = `
|
|
62
|
+
<html>
|
|
63
|
+
<head>
|
|
64
|
+
<link rel="preload" href="/fonts/main.woff2" as="font">
|
|
65
|
+
</head>
|
|
66
|
+
<body></body>
|
|
67
|
+
</html>
|
|
68
|
+
`
|
|
69
|
+
const resources = earlyHints.extractFromHtml(html)
|
|
70
|
+
expect(resources).toHaveLength(1)
|
|
71
|
+
expect(resources[0]).toEqual({
|
|
72
|
+
href: '/fonts/main.woff2',
|
|
73
|
+
as: 'font',
|
|
74
|
+
crossorigin: 'anonymous'
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should limit resources to maxResources', () => {
|
|
79
|
+
const html = `
|
|
80
|
+
<html>
|
|
81
|
+
<head>
|
|
82
|
+
<link rel="stylesheet" href="/css/1.css">
|
|
83
|
+
<link rel="stylesheet" href="/css/2.css">
|
|
84
|
+
<link rel="stylesheet" href="/css/3.css">
|
|
85
|
+
<link rel="stylesheet" href="/css/4.css">
|
|
86
|
+
<link rel="stylesheet" href="/css/5.css">
|
|
87
|
+
<link rel="stylesheet" href="/css/6.css">
|
|
88
|
+
</head>
|
|
89
|
+
<body></body>
|
|
90
|
+
</html>
|
|
91
|
+
`
|
|
92
|
+
const resources = earlyHints.extractFromHtml(html)
|
|
93
|
+
expect(resources).toHaveLength(5)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should return empty array when no head tag', () => {
|
|
97
|
+
const html = '<html><body></body></html>'
|
|
98
|
+
const resources = earlyHints.extractFromHtml(html)
|
|
99
|
+
expect(resources).toEqual([])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('should return empty array when disabled', () => {
|
|
103
|
+
const hints = new EarlyHints({enabled: false})
|
|
104
|
+
const html = '<html><head><link rel="stylesheet" href="/css/main.css"></head></html>'
|
|
105
|
+
const resources = hints.extractFromHtml(html)
|
|
106
|
+
expect(resources).toEqual([])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('should skip resources with defer attribute', () => {
|
|
110
|
+
const html = `
|
|
111
|
+
<html>
|
|
112
|
+
<head>
|
|
113
|
+
<link rel="stylesheet" href="/css/critical.css">
|
|
114
|
+
<link rel="stylesheet" href="/css/non-critical.css" defer>
|
|
115
|
+
<script src="/js/app.js"></script>
|
|
116
|
+
<script src="/js/analytics.js" defer></script>
|
|
117
|
+
</head>
|
|
118
|
+
<body></body>
|
|
119
|
+
</html>
|
|
120
|
+
`
|
|
121
|
+
const resources = earlyHints.extractFromHtml(html)
|
|
122
|
+
expect(resources).toHaveLength(2)
|
|
123
|
+
expect(resources[0]).toEqual({href: '/css/critical.css', as: 'style'})
|
|
124
|
+
expect(resources[1]).toEqual({href: '/js/app.js', as: 'script'})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('should only detect stylesheets with rel="stylesheet"', () => {
|
|
128
|
+
const html = `
|
|
129
|
+
<html>
|
|
130
|
+
<head>
|
|
131
|
+
<link rel="stylesheet" href="/css/main.css">
|
|
132
|
+
<link rel="icon" href="/favicon.css">
|
|
133
|
+
<link rel="preload" href="/data.css" as="fetch">
|
|
134
|
+
<link href="/other.css">
|
|
135
|
+
</head>
|
|
136
|
+
<body></body>
|
|
137
|
+
</html>
|
|
138
|
+
`
|
|
139
|
+
const resources = earlyHints.extractFromHtml(html)
|
|
140
|
+
expect(resources).toHaveLength(1)
|
|
141
|
+
expect(resources[0]).toEqual({href: '/css/main.css', as: 'style'})
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const EarlyHints = require('../../../src/View/EarlyHints')
|
|
2
|
+
|
|
3
|
+
describe('EarlyHints.formatLinkHeader()', () => {
|
|
4
|
+
let earlyHints
|
|
5
|
+
let mockConfig
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockConfig = {
|
|
9
|
+
enabled: true,
|
|
10
|
+
auto: true,
|
|
11
|
+
maxResources: 5
|
|
12
|
+
}
|
|
13
|
+
earlyHints = new EarlyHints(mockConfig)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should format basic resource', () => {
|
|
17
|
+
const resource = {href: '/css/main.css', as: 'style'}
|
|
18
|
+
const header = earlyHints.formatLinkHeader(resource)
|
|
19
|
+
expect(header).toBe('</css/main.css>; rel=preload; as=style')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should format resource with crossorigin', () => {
|
|
23
|
+
const resource = {href: '/font.woff2', as: 'font', crossorigin: 'anonymous'}
|
|
24
|
+
const header = earlyHints.formatLinkHeader(resource)
|
|
25
|
+
expect(header).toBe('</font.woff2>; rel=preload; as=font; crossorigin')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('should format resource with type', () => {
|
|
29
|
+
const resource = {href: '/data.json', as: 'fetch', type: 'application/json'}
|
|
30
|
+
const header = earlyHints.formatLinkHeader(resource)
|
|
31
|
+
expect(header).toBe('</data.json>; rel=preload; as=fetch; type=application/json')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const EarlyHints = require('../../../src/View/EarlyHints')
|
|
2
|
+
|
|
3
|
+
describe('EarlyHints.send()', () => {
|
|
4
|
+
let earlyHints
|
|
5
|
+
let mockConfig
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockConfig = {
|
|
9
|
+
enabled: true,
|
|
10
|
+
auto: true,
|
|
11
|
+
maxResources: 5
|
|
12
|
+
}
|
|
13
|
+
earlyHints = new EarlyHints(mockConfig)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should return false when disabled', () => {
|
|
17
|
+
const hints = new EarlyHints({enabled: false})
|
|
18
|
+
const mockRes = {
|
|
19
|
+
headersSent: false,
|
|
20
|
+
writableEnded: false,
|
|
21
|
+
writeEarlyHints: jest.fn()
|
|
22
|
+
}
|
|
23
|
+
const resources = [{href: '/css/main.css', as: 'style'}]
|
|
24
|
+
|
|
25
|
+
const result = hints.send(mockRes, resources)
|
|
26
|
+
expect(result).toBe(false)
|
|
27
|
+
expect(mockRes.writeEarlyHints).not.toHaveBeenCalled()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should return false when headers already sent', () => {
|
|
31
|
+
const mockRes = {
|
|
32
|
+
headersSent: true,
|
|
33
|
+
writableEnded: false,
|
|
34
|
+
writeEarlyHints: jest.fn()
|
|
35
|
+
}
|
|
36
|
+
const resources = [{href: '/css/main.css', as: 'style'}]
|
|
37
|
+
|
|
38
|
+
const result = earlyHints.send(mockRes, resources)
|
|
39
|
+
expect(result).toBe(false)
|
|
40
|
+
expect(mockRes.writeEarlyHints).not.toHaveBeenCalled()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should return false when response ended', () => {
|
|
44
|
+
const mockRes = {
|
|
45
|
+
headersSent: false,
|
|
46
|
+
writableEnded: true,
|
|
47
|
+
writeEarlyHints: jest.fn()
|
|
48
|
+
}
|
|
49
|
+
const resources = [{href: '/css/main.css', as: 'style'}]
|
|
50
|
+
|
|
51
|
+
const result = earlyHints.send(mockRes, resources)
|
|
52
|
+
expect(result).toBe(false)
|
|
53
|
+
expect(mockRes.writeEarlyHints).not.toHaveBeenCalled()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should return true even when writeEarlyHints not available', () => {
|
|
57
|
+
const mockRes = {
|
|
58
|
+
headersSent: false,
|
|
59
|
+
writableEnded: false,
|
|
60
|
+
setHeader: jest.fn()
|
|
61
|
+
}
|
|
62
|
+
const resources = [{href: '/css/main.css', as: 'style'}]
|
|
63
|
+
|
|
64
|
+
const result = earlyHints.send(mockRes, resources)
|
|
65
|
+
expect(result).toBe(true)
|
|
66
|
+
expect(mockRes.setHeader).toHaveBeenCalledWith('X-Odac-Early-Hints', JSON.stringify(['</css/main.css>; rel=preload; as=style']))
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should send early hints successfully', () => {
|
|
70
|
+
const mockRes = {
|
|
71
|
+
headersSent: false,
|
|
72
|
+
writableEnded: false,
|
|
73
|
+
writeEarlyHints: jest.fn(),
|
|
74
|
+
setHeader: jest.fn()
|
|
75
|
+
}
|
|
76
|
+
const resources = [{href: '/css/main.css', as: 'style'}]
|
|
77
|
+
|
|
78
|
+
const result = earlyHints.send(mockRes, resources)
|
|
79
|
+
expect(result).toBe(true)
|
|
80
|
+
expect(mockRes.writeEarlyHints).toHaveBeenCalledWith({
|
|
81
|
+
link: ['</css/main.css>; rel=preload; as=style']
|
|
82
|
+
})
|
|
83
|
+
expect(mockRes.setHeader).toHaveBeenCalledWith('X-Odac-Early-Hints', JSON.stringify(['</css/main.css>; rel=preload; as=style']))
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should handle writeEarlyHints errors gracefully', () => {
|
|
87
|
+
const mockRes = {
|
|
88
|
+
headersSent: false,
|
|
89
|
+
writableEnded: false,
|
|
90
|
+
writeEarlyHints: jest.fn(() => {
|
|
91
|
+
throw new Error('Write error')
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
const resources = [{href: '/css/main.css', as: 'style'}]
|
|
95
|
+
|
|
96
|
+
const result = earlyHints.send(mockRes, resources)
|
|
97
|
+
expect(result).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const Form = require('
|
|
1
|
+
const Form = require('../../../src/View/Form')
|
|
2
2
|
|
|
3
|
-
describe('Form
|
|
3
|
+
describe('Form.generateFieldHtml()', () => {
|
|
4
4
|
test('should escape textarea content to prevent tag breakout XSS', () => {
|
|
5
5
|
const html = Form.generateFieldHtml({
|
|
6
6
|
name: 'bio',
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const View = require('../../src/View')
|
|
2
|
+
|
|
3
|
+
describe('View.constructor()', () => {
|
|
4
|
+
let mockOdac
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
mockOdac = {
|
|
8
|
+
Config: {view: {earlyHints: {enabled: true}}},
|
|
9
|
+
View: {}
|
|
10
|
+
}
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should initialize with EarlyHints if enabled', () => {
|
|
14
|
+
const view = new View(mockOdac)
|
|
15
|
+
expect(view).toBeDefined()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('should set global.Odac.View.Form', () => {
|
|
19
|
+
new View(mockOdac)
|
|
20
|
+
expect(global.Odac.View.Form).toBeDefined()
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const View = require('../../src/View')
|
|
2
|
+
|
|
3
|
+
describe('View.print()', () => {
|
|
4
|
+
let view
|
|
5
|
+
let mockOdac
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockOdac = {
|
|
9
|
+
Config: {view: {earlyHints: {enabled: false}}},
|
|
10
|
+
View: {},
|
|
11
|
+
Request: {data: {all: {}}}
|
|
12
|
+
}
|
|
13
|
+
view = new View(mockOdac)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should be a function', () => {
|
|
17
|
+
expect(typeof view.print).toBe('function')
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const {WebSocketServer, WebSocketClient} = require('../../../src/WebSocket.js')
|
|
2
|
+
|
|
3
|
+
describe('WebSocketClient Limits', () => {
|
|
4
|
+
let server
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
server = new WebSocketServer()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
describe('maxPayload', () => {
|
|
11
|
+
it('should close connection if payload exceeds limit', () => {
|
|
12
|
+
const socket = {
|
|
13
|
+
pause: jest.fn(),
|
|
14
|
+
on: jest.fn(),
|
|
15
|
+
write: jest.fn(),
|
|
16
|
+
end: jest.fn(),
|
|
17
|
+
removeAllListeners: jest.fn()
|
|
18
|
+
}
|
|
19
|
+
new WebSocketClient(socket, server, 'test-id', {maxPayload: 10})
|
|
20
|
+
|
|
21
|
+
const buffer = Buffer.alloc(100)
|
|
22
|
+
buffer[0] = 0x81
|
|
23
|
+
buffer[1] = 0x80 | 20
|
|
24
|
+
const dataHandler = socket.on.mock.calls.find(call => call[0] === 'data')[1]
|
|
25
|
+
dataHandler(buffer)
|
|
26
|
+
|
|
27
|
+
expect(socket.end).toHaveBeenCalled()
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('rateLimit', () => {
|
|
32
|
+
it('should close connection if rate limit exceeded', () => {
|
|
33
|
+
const socket = {
|
|
34
|
+
pause: jest.fn(),
|
|
35
|
+
on: jest.fn(),
|
|
36
|
+
write: jest.fn(),
|
|
37
|
+
end: jest.fn(),
|
|
38
|
+
removeAllListeners: jest.fn()
|
|
39
|
+
}
|
|
40
|
+
new WebSocketClient(socket, server, 'test-id', {rateLimit: {max: 2, window: 1000}})
|
|
41
|
+
|
|
42
|
+
const buffer = Buffer.alloc(7)
|
|
43
|
+
buffer[0] = 0x81
|
|
44
|
+
buffer[1] = 0x80 | 1
|
|
45
|
+
buffer[2] = buffer[3] = buffer[4] = buffer[5] = 0
|
|
46
|
+
buffer[6] = 0x61
|
|
47
|
+
|
|
48
|
+
const dataHandler = socket.on.mock.calls.find(call => call[0] === 'data')[1]
|
|
49
|
+
dataHandler(buffer)
|
|
50
|
+
dataHandler(buffer)
|
|
51
|
+
dataHandler(buffer)
|
|
52
|
+
expect(socket.end).toHaveBeenCalled()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
})
|