odac 1.4.15 → 1.4.17
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/client/odac.js +38 -1
- package/docs/ai/skills/backend/forms.md +54 -3
- package/docs/ai/skills/backend/validation.md +30 -2
- package/docs/backend/05-forms/01-custom-forms.md +52 -0
- package/docs/backend/09-validation/01-the-validator-service.md +40 -0
- package/docs/backend/13-utilities/02-ipc.md +26 -1
- package/docs/frontend/03-forms/01-form-handling.md +30 -13
- package/package.json +2 -1
- package/src/Auth.js +252 -24
- package/src/Config.js +5 -1
- package/src/Ipc.js +87 -14
- package/src/Odac.js +23 -4
- package/src/Request.js +235 -34
- package/src/Route/Internal.js +32 -3
- package/src/Validator.js +143 -2
- package/src/View/Form.js +56 -0
- package/src/View/Image.js +15 -6
- package/src/View.js +2 -2
- package/src/WebSocket.js +20 -1
- package/test/Auth/check.test.js +219 -18
- package/test/Auth/verifyMagicLink.test.js +8 -1
- package/test/Ipc/subscribe.test.js +154 -0
- package/test/Ipc/subscribeRedis.test.js +141 -0
- package/test/Odac/cache.test.js +5 -2
- package/test/Odac/image.test.js +8 -5
- package/test/Odac/instance.test.js +5 -2
- package/test/Request/cache.test.js +1 -0
- package/test/Request/multipart.test.js +164 -0
- package/test/Validator/file.test.js +172 -0
- package/test/View/Form/generateFieldHtml.test.js +56 -0
- package/test/View/Image/serve.test.js +9 -4
- package/test/View/Image/url.test.js +10 -4
- package/test/View/addNavigateAttribute.test.js +10 -1
- package/test/View/print.test.js +10 -1
- package/test/WebSocket/Client/fragmentation.test.js +24 -5
- package/test/WebSocket/Client/limits.test.js +21 -2
- package/test/WebSocket/Client/readyState.test.js +29 -10
- package/test/WebSocket/Client/send.test.js +148 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests Ipc pub/sub on the redis driver.
|
|
5
|
+
* Why: node-redis v4+ delivers messages to a listener passed to subscribe() and emits no
|
|
6
|
+
* client-wide 'message' event, so the bridge must be wired per channel. These tests pin
|
|
7
|
+
* that wiring and assert the driver removes subscriptions with the same per-callback
|
|
8
|
+
* semantics as the memory driver.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Channel -> the listener node-redis was handed. Lets a test push a message the way a real
|
|
12
|
+
// server would, without a live Redis.
|
|
13
|
+
const mockChannels = new Map()
|
|
14
|
+
|
|
15
|
+
const mockSubClient = {
|
|
16
|
+
connect: jest.fn().mockResolvedValue(undefined),
|
|
17
|
+
subscribe: jest.fn(async (channel, listener) => mockChannels.set(channel, listener)),
|
|
18
|
+
unsubscribe: jest.fn(async channel => mockChannels.delete(channel)),
|
|
19
|
+
quit: jest.fn().mockResolvedValue(undefined)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const mockClient = {
|
|
23
|
+
connect: jest.fn().mockResolvedValue(undefined),
|
|
24
|
+
duplicate: jest.fn(() => mockSubClient),
|
|
25
|
+
publish: jest.fn().mockResolvedValue(1),
|
|
26
|
+
quit: jest.fn().mockResolvedValue(undefined)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
jest.mock('redis', () => ({createClient: jest.fn(() => mockClient)}), {virtual: true})
|
|
30
|
+
|
|
31
|
+
let Ipc
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
jest.clearAllMocks()
|
|
35
|
+
jest.resetModules()
|
|
36
|
+
mockChannels.clear()
|
|
37
|
+
|
|
38
|
+
Ipc = require('../../src/Ipc')
|
|
39
|
+
global.Odac = {Config: {ipc: {driver: 'redis'}}}
|
|
40
|
+
await Ipc.init()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
await Ipc.close()
|
|
45
|
+
delete global.Odac
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/** Simulates Redis pushing a message on a channel, as node-redis would. */
|
|
49
|
+
const deliver = (channel, message) => mockChannels.get(channel)?.(JSON.stringify(message), channel)
|
|
50
|
+
|
|
51
|
+
describe('Ipc redis - subscribe()', () => {
|
|
52
|
+
it('should register a listener with redis and deliver parsed messages', async () => {
|
|
53
|
+
const cb = jest.fn()
|
|
54
|
+
await Ipc.subscribe('chan', cb)
|
|
55
|
+
|
|
56
|
+
expect(mockSubClient.subscribe).toHaveBeenCalledWith('chan', expect.any(Function))
|
|
57
|
+
|
|
58
|
+
deliver('chan', {hello: 'world'})
|
|
59
|
+
expect(cb).toHaveBeenCalledWith({hello: 'world'})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should subscribe to a channel only once, however many consumers join', async () => {
|
|
63
|
+
await Ipc.subscribe('stream', jest.fn())
|
|
64
|
+
await Ipc.subscribe('stream', jest.fn())
|
|
65
|
+
|
|
66
|
+
expect(mockSubClient.subscribe).toHaveBeenCalledTimes(1)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should fan out to every consumer on the same channel', async () => {
|
|
70
|
+
const stats = jest.fn()
|
|
71
|
+
const ack = jest.fn()
|
|
72
|
+
await Ipc.subscribe('stream', stats)
|
|
73
|
+
await Ipc.subscribe('stream', ack)
|
|
74
|
+
|
|
75
|
+
deliver('stream', 'tick')
|
|
76
|
+
|
|
77
|
+
expect(stats).toHaveBeenCalledWith('tick')
|
|
78
|
+
expect(ack).toHaveBeenCalledWith('tick')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should reject a missing callback instead of registering undefined', async () => {
|
|
82
|
+
await expect(Ipc.subscribe('chan')).rejects.toThrow(TypeError)
|
|
83
|
+
expect(mockSubClient.subscribe).not.toHaveBeenCalled()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('Ipc redis - unsubscribe()', () => {
|
|
88
|
+
it('should keep co-tenants alive and hold the redis subscription open', async () => {
|
|
89
|
+
const stats = jest.fn()
|
|
90
|
+
const ack = jest.fn()
|
|
91
|
+
await Ipc.subscribe('stream', stats)
|
|
92
|
+
const ackSub = await Ipc.subscribe('stream', ack)
|
|
93
|
+
|
|
94
|
+
await ackSub.unsubscribe()
|
|
95
|
+
|
|
96
|
+
expect(mockSubClient.unsubscribe).not.toHaveBeenCalled()
|
|
97
|
+
|
|
98
|
+
deliver('stream', 'tick')
|
|
99
|
+
expect(ack).not.toHaveBeenCalled()
|
|
100
|
+
expect(stats).toHaveBeenCalledWith('tick')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should drop the redis subscription once the last consumer leaves', async () => {
|
|
104
|
+
const stats = jest.fn()
|
|
105
|
+
const ack = jest.fn()
|
|
106
|
+
await Ipc.subscribe('stream', stats)
|
|
107
|
+
await Ipc.subscribe('stream', ack)
|
|
108
|
+
|
|
109
|
+
await Ipc.unsubscribe('stream', ack)
|
|
110
|
+
await Ipc.unsubscribe('stream', stats)
|
|
111
|
+
|
|
112
|
+
expect(mockSubClient.unsubscribe).toHaveBeenCalledWith('stream', expect.any(Function))
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should warn instead of throwing when the callback is omitted', async () => {
|
|
116
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
117
|
+
const stats = jest.fn()
|
|
118
|
+
await Ipc.subscribe('stream', stats)
|
|
119
|
+
|
|
120
|
+
await expect(Ipc.unsubscribe('stream')).resolves.toBeUndefined()
|
|
121
|
+
|
|
122
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('without a callback'))
|
|
123
|
+
deliver('stream', 'tick')
|
|
124
|
+
expect(stats).toHaveBeenCalledWith('tick')
|
|
125
|
+
warn.mockRestore()
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('Ipc redis - unsubscribeAll()', () => {
|
|
130
|
+
it('should remove every consumer and drop the redis subscription', async () => {
|
|
131
|
+
const stats = jest.fn()
|
|
132
|
+
const ack = jest.fn()
|
|
133
|
+
await Ipc.subscribe('stream', stats)
|
|
134
|
+
await Ipc.subscribe('stream', ack)
|
|
135
|
+
|
|
136
|
+
await Ipc.unsubscribeAll('stream')
|
|
137
|
+
|
|
138
|
+
expect(mockSubClient.unsubscribe).toHaveBeenCalledWith('stream', expect.any(Function))
|
|
139
|
+
expect(Ipc.listenerCount('stream')).toBe(0)
|
|
140
|
+
})
|
|
141
|
+
})
|
package/test/Odac/cache.test.js
CHANGED
|
@@ -2,6 +2,7 @@ const Odac = require('../../src/Odac')
|
|
|
2
2
|
|
|
3
3
|
describe('Odac.cache()', () => {
|
|
4
4
|
let mockOdac
|
|
5
|
+
let ctx
|
|
5
6
|
|
|
6
7
|
beforeEach(() => {
|
|
7
8
|
mockOdac = {
|
|
@@ -14,6 +15,8 @@ describe('Odac.cache()', () => {
|
|
|
14
15
|
})
|
|
15
16
|
|
|
16
17
|
afterEach(() => {
|
|
18
|
+
ctx?.Request.clearTimeout()
|
|
19
|
+
ctx = null
|
|
17
20
|
delete global.Odac
|
|
18
21
|
delete global.__dir
|
|
19
22
|
})
|
|
@@ -28,7 +31,7 @@ describe('Odac.cache()', () => {
|
|
|
28
31
|
}
|
|
29
32
|
const mockRes = {on: jest.fn(), writeHead: jest.fn(), end: jest.fn()}
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
ctx = Odac.instance('id', mockReq, mockRes)
|
|
32
35
|
|
|
33
36
|
expect(typeof ctx.cache).toBe('function')
|
|
34
37
|
})
|
|
@@ -43,7 +46,7 @@ describe('Odac.cache()', () => {
|
|
|
43
46
|
}
|
|
44
47
|
const mockRes = {on: jest.fn(), writeHead: jest.fn(), end: jest.fn(), finished: false}
|
|
45
48
|
|
|
46
|
-
|
|
49
|
+
ctx = Odac.instance('id', mockReq, mockRes)
|
|
47
50
|
ctx.cache(3600)
|
|
48
51
|
|
|
49
52
|
ctx.Request.print()
|
package/test/Odac/image.test.js
CHANGED
|
@@ -2,6 +2,7 @@ const Odac = require('../../src/Odac')
|
|
|
2
2
|
|
|
3
3
|
describe('Odac.image()', () => {
|
|
4
4
|
let mockOdac
|
|
5
|
+
let ctx
|
|
5
6
|
|
|
6
7
|
beforeEach(() => {
|
|
7
8
|
mockOdac = {
|
|
@@ -19,12 +20,14 @@ describe('Odac.image()', () => {
|
|
|
19
20
|
})
|
|
20
21
|
|
|
21
22
|
afterEach(() => {
|
|
23
|
+
ctx?.Request?.clearTimeout()
|
|
24
|
+
ctx = null
|
|
22
25
|
delete global.Odac
|
|
23
26
|
delete global.__dir
|
|
24
27
|
})
|
|
25
28
|
|
|
26
29
|
test('should be available on instance without req/res (cron context)', () => {
|
|
27
|
-
|
|
30
|
+
ctx = Odac.instance(null, 'cron')
|
|
28
31
|
expect(typeof ctx.image).toBe('function')
|
|
29
32
|
})
|
|
30
33
|
|
|
@@ -37,25 +40,25 @@ describe('Odac.image()', () => {
|
|
|
37
40
|
on: jest.fn()
|
|
38
41
|
}
|
|
39
42
|
const mockRes = {on: jest.fn()}
|
|
40
|
-
|
|
43
|
+
ctx = Odac.instance('id', mockReq, mockRes)
|
|
41
44
|
expect(typeof ctx.image).toBe('function')
|
|
42
45
|
})
|
|
43
46
|
|
|
44
47
|
test('should return a promise', () => {
|
|
45
|
-
|
|
48
|
+
ctx = Odac.instance(null, 'cron')
|
|
46
49
|
const result = ctx.image('/images/test.jpg', {width: 300})
|
|
47
50
|
expect(result).toBeInstanceOf(Promise)
|
|
48
51
|
})
|
|
49
52
|
|
|
50
53
|
test('should return original src when sharp is unavailable', async () => {
|
|
51
|
-
|
|
54
|
+
ctx = Odac.instance(null, 'cron')
|
|
52
55
|
const result = await ctx.image('/images/test.jpg')
|
|
53
56
|
// sharp not installed in test env → returns original src
|
|
54
57
|
expect(result).toBe('/images/test.jpg')
|
|
55
58
|
})
|
|
56
59
|
|
|
57
60
|
test('should return empty string for empty src', async () => {
|
|
58
|
-
|
|
61
|
+
ctx = Odac.instance(null, 'cron')
|
|
59
62
|
expect(await ctx.image('')).toBe('')
|
|
60
63
|
})
|
|
61
64
|
})
|
|
@@ -2,6 +2,7 @@ const Odac = require('../../src/Odac')
|
|
|
2
2
|
|
|
3
3
|
describe('Odac.instance()', () => {
|
|
4
4
|
let mockOdac
|
|
5
|
+
let ctx
|
|
5
6
|
|
|
6
7
|
beforeEach(() => {
|
|
7
8
|
mockOdac = {
|
|
@@ -20,6 +21,8 @@ describe('Odac.instance()', () => {
|
|
|
20
21
|
})
|
|
21
22
|
|
|
22
23
|
afterEach(() => {
|
|
24
|
+
ctx?.Request.clearTimeout()
|
|
25
|
+
ctx = null
|
|
23
26
|
delete global.Odac
|
|
24
27
|
delete global.__dir
|
|
25
28
|
})
|
|
@@ -34,7 +37,7 @@ describe('Odac.instance()', () => {
|
|
|
34
37
|
}
|
|
35
38
|
const mockRes = {on: jest.fn(), writeHead: jest.fn(), end: jest.fn()}
|
|
36
39
|
|
|
37
|
-
|
|
40
|
+
ctx = Odac.instance('id', mockReq, mockRes)
|
|
38
41
|
|
|
39
42
|
expect(ctx.Request).toBeDefined()
|
|
40
43
|
expect(ctx.Request.id).toBe('id')
|
|
@@ -49,7 +52,7 @@ describe('Odac.instance()', () => {
|
|
|
49
52
|
on: jest.fn()
|
|
50
53
|
}
|
|
51
54
|
const mockRes = {on: jest.fn()}
|
|
52
|
-
|
|
55
|
+
ctx = Odac.instance('id', mockReq, mockRes)
|
|
53
56
|
|
|
54
57
|
expect(typeof ctx.abort).toBe('function')
|
|
55
58
|
expect(typeof ctx.cookie).toBe('function')
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const os = require('os')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const {PassThrough} = require('stream')
|
|
5
|
+
const OdacRequest = require('../../src/Request')
|
|
6
|
+
|
|
7
|
+
const BOUNDARY = '----odacmultiparttest'
|
|
8
|
+
|
|
9
|
+
// Build a raw multipart/form-data body as a Buffer so binary parts (with \r\n
|
|
10
|
+
// and null bytes inside) survive intact — the regression case the old
|
|
11
|
+
// string-splitting parser corrupted.
|
|
12
|
+
function buildBody(parts) {
|
|
13
|
+
const chunks = []
|
|
14
|
+
for (const p of parts) {
|
|
15
|
+
chunks.push(Buffer.from(`--${BOUNDARY}\r\n`))
|
|
16
|
+
if (p.filename !== undefined) {
|
|
17
|
+
chunks.push(Buffer.from(`Content-Disposition: form-data; name="${p.name}"; filename="${p.filename}"\r\n`))
|
|
18
|
+
chunks.push(Buffer.from(`Content-Type: ${p.contentType || 'application/octet-stream'}\r\n\r\n`))
|
|
19
|
+
chunks.push(p.data)
|
|
20
|
+
chunks.push(Buffer.from('\r\n'))
|
|
21
|
+
} else {
|
|
22
|
+
chunks.push(Buffer.from(`Content-Disposition: form-data; name="${p.name}"\r\n\r\n`))
|
|
23
|
+
chunks.push(Buffer.from(`${p.value}\r\n`))
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
chunks.push(Buffer.from(`--${BOUNDARY}--\r\n`))
|
|
27
|
+
return Buffer.concat(chunks)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeReq() {
|
|
31
|
+
const req = new PassThrough()
|
|
32
|
+
req.method = 'POST'
|
|
33
|
+
req.url = '/upload'
|
|
34
|
+
req.headers = {
|
|
35
|
+
host: 'www.example.com',
|
|
36
|
+
'content-type': `multipart/form-data; boundary=${BOUNDARY}`
|
|
37
|
+
}
|
|
38
|
+
req.connection = {remoteAddress: '127.0.0.1', destroy: jest.fn()}
|
|
39
|
+
return req
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeRes() {
|
|
43
|
+
return {writeHead: jest.fn(), end: jest.fn(), finished: false, on: jest.fn()}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Temp-file cleanup in Request.js uses fire-and-forget fs.unlink(), which
|
|
47
|
+
// completes via libuv's thread pool. A single event-loop tick isn't a
|
|
48
|
+
// reliable signal that it finished, especially under CI I/O contention, so
|
|
49
|
+
// poll for the expected end-state instead of assuming a fixed number of ticks.
|
|
50
|
+
async function waitUntil(conditionFn, {timeoutMs = 2000, intervalMs = 10} = {}) {
|
|
51
|
+
const start = Date.now()
|
|
52
|
+
while (!conditionFn()) {
|
|
53
|
+
if (Date.now() - start > timeoutMs) return false
|
|
54
|
+
await new Promise(resolve => setTimeout(resolve, intervalMs))
|
|
55
|
+
}
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('Request multipart parsing', () => {
|
|
60
|
+
let tmpDir, request
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odac-mp-'))
|
|
64
|
+
global.Odac = {
|
|
65
|
+
Config: {request: {timeout: 5000, maxBodySize: 1e6, maxFileSize: 5 * 1024 * 1024, maxFiles: 10, uploadDir: tmpDir}},
|
|
66
|
+
Route: {routes: {www: {}}},
|
|
67
|
+
Request: {}
|
|
68
|
+
}
|
|
69
|
+
global.__dir = tmpDir
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
if (request) request.clearTimeout()
|
|
74
|
+
request = null
|
|
75
|
+
delete global.Odac
|
|
76
|
+
delete global.__dir
|
|
77
|
+
fs.rmSync(tmpDir, {recursive: true, force: true})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
function run(body) {
|
|
81
|
+
const req = makeReq()
|
|
82
|
+
const res = makeRes()
|
|
83
|
+
request = new OdacRequest('id', req, res, {setTimeout: (fn, ms) => setTimeout(fn, ms)})
|
|
84
|
+
req.end(body)
|
|
85
|
+
return request
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
it('parses text fields into data.post', async () => {
|
|
89
|
+
const r = run(buildBody([{name: 'title', value: 'hello world'}]))
|
|
90
|
+
const val = await r.request('title')
|
|
91
|
+
expect(val).toBe('hello world')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('round-trips binary file content byte-for-byte', async () => {
|
|
95
|
+
// Contains a PNG header, an embedded CRLF, and null bytes — exactly what the
|
|
96
|
+
// old toString()-based parser mangled.
|
|
97
|
+
const binary = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0xff, 0x0d, 0x0a, 0x42])
|
|
98
|
+
const r = run(buildBody([{name: 'avatar', filename: 'photo.png', contentType: 'image/png', data: binary}]))
|
|
99
|
+
|
|
100
|
+
const file = await r.file('avatar')
|
|
101
|
+
expect(file).toBeTruthy()
|
|
102
|
+
expect(file.name).toBe('photo.png')
|
|
103
|
+
expect(file.ext).toBe('png')
|
|
104
|
+
expect(file.mimetype).toBe('image/png')
|
|
105
|
+
expect(file.size).toBe(binary.length)
|
|
106
|
+
expect(file.truncated).toBe(false)
|
|
107
|
+
|
|
108
|
+
const written = fs.readFileSync(file.path)
|
|
109
|
+
expect(Buffer.compare(written, binary)).toBe(0)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('mixes text fields and a file in one request', async () => {
|
|
113
|
+
const r = run(
|
|
114
|
+
buildBody([
|
|
115
|
+
{name: 'name', value: 'Ada'},
|
|
116
|
+
{name: 'doc', filename: 'a.txt', contentType: 'text/plain', data: Buffer.from('content')}
|
|
117
|
+
])
|
|
118
|
+
)
|
|
119
|
+
expect(await r.request('name')).toBe('Ada')
|
|
120
|
+
const file = await r.file('doc')
|
|
121
|
+
expect(file.name).toBe('a.txt')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('returns null for an untouched (empty filename) file input', async () => {
|
|
125
|
+
const r = run(buildBody([{name: 'avatar', filename: '', contentType: 'application/octet-stream', data: Buffer.alloc(0)}]))
|
|
126
|
+
expect(await r.file('avatar')).toBeNull()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('returns an array when the same field has multiple files', async () => {
|
|
130
|
+
const r = run(
|
|
131
|
+
buildBody([
|
|
132
|
+
{name: 'docs', filename: '1.txt', contentType: 'text/plain', data: Buffer.from('one')},
|
|
133
|
+
{name: 'docs', filename: '2.txt', contentType: 'text/plain', data: Buffer.from('two')}
|
|
134
|
+
])
|
|
135
|
+
)
|
|
136
|
+
const files = await r.file('docs')
|
|
137
|
+
expect(Array.isArray(files)).toBe(true)
|
|
138
|
+
expect(files).toHaveLength(2)
|
|
139
|
+
expect(files.map(f => f.name).sort()).toEqual(['1.txt', '2.txt'])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('flags an oversize file as truncated with no leftover temp file', async () => {
|
|
143
|
+
global.Odac.Config.request.maxFileSize = 10 // bytes
|
|
144
|
+
const big = Buffer.alloc(1000, 0x41)
|
|
145
|
+
const r = run(buildBody([{name: 'avatar', filename: 'big.bin', contentType: 'application/octet-stream', data: big}]))
|
|
146
|
+
|
|
147
|
+
const file = await r.file('avatar')
|
|
148
|
+
expect(file.truncated).toBe(true)
|
|
149
|
+
expect(file.path).toBeNull()
|
|
150
|
+
// No orphaned odac-* temp file remains in the upload dir.
|
|
151
|
+
const noLeftovers = await waitUntil(() => fs.readdirSync(tmpDir).filter(n => n.startsWith('odac-')).length === 0)
|
|
152
|
+
expect(noLeftovers).toBe(true)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('cleans up unstored temp files after the response ends', async () => {
|
|
156
|
+
const r = run(buildBody([{name: 'avatar', filename: 'x.bin', contentType: 'application/octet-stream', data: Buffer.from('abc')}]))
|
|
157
|
+
const file = await r.file('avatar')
|
|
158
|
+
expect(fs.existsSync(file.path)).toBe(true)
|
|
159
|
+
|
|
160
|
+
r.end('ok')
|
|
161
|
+
const removed = await waitUntil(() => !fs.existsSync(file.path))
|
|
162
|
+
expect(removed).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const os = require('os')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const Validator = require('../../src/Validator')
|
|
5
|
+
|
|
6
|
+
// Minimal Odac stub — file rules never touch Var()/Auth(), but other rules do,
|
|
7
|
+
// so provide a small shim to keep the constructor happy.
|
|
8
|
+
const odacStub = {
|
|
9
|
+
Var: () => ({is: () => false})
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function mockRequest(filesByField) {
|
|
13
|
+
return {
|
|
14
|
+
file: async name => {
|
|
15
|
+
const arr = filesByField[name]
|
|
16
|
+
if (!arr) return null
|
|
17
|
+
return arr.length === 1 ? arr[0] : arr
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fileObj(overrides = {}) {
|
|
23
|
+
return {
|
|
24
|
+
field: 'upload',
|
|
25
|
+
name: 'file.bin',
|
|
26
|
+
ext: 'bin',
|
|
27
|
+
mimetype: 'application/octet-stream',
|
|
28
|
+
size: 1000,
|
|
29
|
+
path: null,
|
|
30
|
+
truncated: false,
|
|
31
|
+
stored: false,
|
|
32
|
+
...overrides
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('Validator file rules', () => {
|
|
37
|
+
let tmpDir
|
|
38
|
+
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odac-file-test-'))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
fs.rmSync(tmpDir, {recursive: true, force: true})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
function writeTmp(name, buf) {
|
|
48
|
+
const p = path.join(tmpDir, name)
|
|
49
|
+
fs.writeFileSync(p, buf)
|
|
50
|
+
return p
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('required', () => {
|
|
54
|
+
it('fails when no file is present', async () => {
|
|
55
|
+
const v = new Validator(mockRequest({}), odacStub)
|
|
56
|
+
v.file('avatar').check('required').message('Avatar required')
|
|
57
|
+
expect(await v.error()).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('passes when a file is present', async () => {
|
|
61
|
+
const v = new Validator(mockRequest({avatar: [fileObj()]}), odacStub)
|
|
62
|
+
v.file('avatar').check('required').message('Avatar required')
|
|
63
|
+
expect(await v.error()).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('maxsize / minsize', () => {
|
|
68
|
+
it('rejects a file above maxsize', async () => {
|
|
69
|
+
const v = new Validator(mockRequest({doc: [fileObj({size: 3 * 1024 * 1024})]}), odacStub)
|
|
70
|
+
v.file('doc').check('maxsize:2MB').message('Too large')
|
|
71
|
+
expect(await v.error()).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('accepts a file at/below maxsize', async () => {
|
|
75
|
+
const v = new Validator(mockRequest({doc: [fileObj({size: 1024 * 1024})]}), odacStub)
|
|
76
|
+
v.file('doc').check('maxsize:2MB').message('Too large')
|
|
77
|
+
expect(await v.error()).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('rejects a file below minsize', async () => {
|
|
81
|
+
const v = new Validator(mockRequest({doc: [fileObj({size: 500})]}), odacStub)
|
|
82
|
+
v.file('doc').check('minsize:1KB').message('Too small')
|
|
83
|
+
expect(await v.error()).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('ext', () => {
|
|
88
|
+
it('rejects a disallowed extension', async () => {
|
|
89
|
+
const v = new Validator(mockRequest({img: [fileObj({ext: 'gif'})]}), odacStub)
|
|
90
|
+
v.file('img').check('ext:jpg,png').message('Bad ext')
|
|
91
|
+
expect(await v.error()).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('accepts an allowed extension', async () => {
|
|
95
|
+
const v = new Validator(mockRequest({img: [fileObj({ext: 'png'})]}), odacStub)
|
|
96
|
+
v.file('img').check('ext:jpg,png').message('Bad ext')
|
|
97
|
+
expect(await v.error()).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('maxfiles', () => {
|
|
102
|
+
it('rejects too many files', async () => {
|
|
103
|
+
const files = [fileObj(), fileObj(), fileObj()]
|
|
104
|
+
const v = new Validator(mockRequest({docs: files}), odacStub)
|
|
105
|
+
v.file('docs').check('maxfiles:2').message('Too many')
|
|
106
|
+
expect(await v.error()).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('accepts within the limit', async () => {
|
|
110
|
+
const files = [fileObj(), fileObj()]
|
|
111
|
+
const v = new Validator(mockRequest({docs: files}), odacStub)
|
|
112
|
+
v.file('docs').check('maxfiles:2').message('Too many')
|
|
113
|
+
expect(await v.error()).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('mimetype', () => {
|
|
118
|
+
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0])
|
|
119
|
+
|
|
120
|
+
it('accepts a genuine PNG matching the whitelist', async () => {
|
|
121
|
+
const p = writeTmp('real.png', PNG)
|
|
122
|
+
const file = fileObj({ext: 'png', mimetype: 'image/png', path: p})
|
|
123
|
+
const v = new Validator(mockRequest({avatar: [file]}), odacStub)
|
|
124
|
+
v.file('avatar').check('mimetype:image/png,image/jpeg').message('Bad type')
|
|
125
|
+
expect(await v.error()).toBe(false)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('rejects a spoofed .png whose bytes are not PNG', async () => {
|
|
129
|
+
const p = writeTmp('fake.png', Buffer.from('<script>alert(1)</script>'))
|
|
130
|
+
const file = fileObj({ext: 'png', mimetype: 'image/png', path: p})
|
|
131
|
+
const v = new Validator(mockRequest({avatar: [file]}), odacStub)
|
|
132
|
+
v.file('avatar').check('mimetype:image/png,image/jpeg').message('Bad type')
|
|
133
|
+
expect(await v.error()).toBe(true)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('rejects a type outside the whitelist', async () => {
|
|
137
|
+
const file = fileObj({ext: 'pdf', mimetype: 'application/pdf'})
|
|
138
|
+
const v = new Validator(mockRequest({avatar: [file]}), odacStub)
|
|
139
|
+
v.file('avatar').check('mimetype:image/png,image/jpeg').message('Bad type')
|
|
140
|
+
expect(await v.error()).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('supports image/* wildcard', async () => {
|
|
144
|
+
const p = writeTmp('w.png', PNG)
|
|
145
|
+
const file = fileObj({ext: 'png', mimetype: 'image/png', path: p})
|
|
146
|
+
const v = new Validator(mockRequest({avatar: [file]}), odacStub)
|
|
147
|
+
v.file('avatar').check('accept:image/*').message('Bad type')
|
|
148
|
+
expect(await v.error()).toBe(false)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('truncated files', () => {
|
|
153
|
+
it('always fails, even with only a size rule that it would pass', async () => {
|
|
154
|
+
const file = fileObj({truncated: true, path: null, size: 0})
|
|
155
|
+
const v = new Validator(mockRequest({big: [file]}), odacStub)
|
|
156
|
+
v.file('big').check('maxsize:2MB').message('File too large')
|
|
157
|
+
expect(await v.error()).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('parameterized-rule split regression', () => {
|
|
162
|
+
it('parses maxsize suffix correctly via split(/:(.+)/)', async () => {
|
|
163
|
+
const under = new Validator(mockRequest({d: [fileObj({size: 1024})]}), odacStub)
|
|
164
|
+
under.file('d').check('maxsize:2KB').message('x')
|
|
165
|
+
expect(await under.error()).toBe(false)
|
|
166
|
+
|
|
167
|
+
const over = new Validator(mockRequest({d: [fileObj({size: 4096})]}), odacStub)
|
|
168
|
+
over.file('d').check('maxsize:2KB').message('x')
|
|
169
|
+
expect(await over.error()).toBe(true)
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
})
|
|
@@ -34,4 +34,60 @@ describe('Form.generateFieldHtml()', () => {
|
|
|
34
34
|
expect(html).toContain('value="a&b"c<d>e'f"')
|
|
35
35
|
expect(html).not.toContain('value="a&b"c<d>e\'f"')
|
|
36
36
|
})
|
|
37
|
+
|
|
38
|
+
test('should render a file input without value/placeholder attributes', () => {
|
|
39
|
+
const html = Form.generateFieldHtml({
|
|
40
|
+
name: 'avatar',
|
|
41
|
+
type: 'file',
|
|
42
|
+
placeholder: 'ignored',
|
|
43
|
+
label: null,
|
|
44
|
+
class: '',
|
|
45
|
+
id: null,
|
|
46
|
+
value: null,
|
|
47
|
+
validations: [],
|
|
48
|
+
extraAttributes: {}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
expect(html).toContain('type="file"')
|
|
52
|
+
expect(html).toContain('name="avatar"')
|
|
53
|
+
expect(html).not.toContain('value=')
|
|
54
|
+
expect(html).not.toContain('placeholder=')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('should map file validation rules to accept/multiple/data-* attributes', () => {
|
|
58
|
+
const html = Form.generateFieldHtml({
|
|
59
|
+
name: 'photos',
|
|
60
|
+
type: 'file',
|
|
61
|
+
placeholder: '',
|
|
62
|
+
label: null,
|
|
63
|
+
class: '',
|
|
64
|
+
id: null,
|
|
65
|
+
value: null,
|
|
66
|
+
validations: [{rule: 'required|maxsize:2MB|mimetype:image/png,image/jpeg|maxfiles:3', message: 'Bad file'}],
|
|
67
|
+
extraAttributes: {}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
expect(html).toContain('required')
|
|
71
|
+
expect(html).toContain('accept="image/png,image/jpeg"')
|
|
72
|
+
expect(html).toContain(`data-maxsize="${2 * 1024 * 1024}"`)
|
|
73
|
+
expect(html).toContain('multiple')
|
|
74
|
+
expect(html).toContain('data-maxfiles="3"')
|
|
75
|
+
expect(html).toContain('data-error-maxsize="Bad file"')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('should convert ext rule to a dot-prefixed accept list', () => {
|
|
79
|
+
const html = Form.generateFieldHtml({
|
|
80
|
+
name: 'doc',
|
|
81
|
+
type: 'file',
|
|
82
|
+
placeholder: '',
|
|
83
|
+
label: null,
|
|
84
|
+
class: '',
|
|
85
|
+
id: null,
|
|
86
|
+
value: null,
|
|
87
|
+
validations: [{rule: 'ext:pdf,docx', message: null}],
|
|
88
|
+
extraAttributes: {}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(html).toContain('accept=".pdf,.docx"')
|
|
92
|
+
})
|
|
37
93
|
})
|