fastify 5.12.4 → 5.12.6
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/docs/Reference/Errors.md +1 -1
- package/docs/Reference/Reply.md +3 -2
- package/docs/Reference/Routes.md +6 -2
- package/docs/Reference/Validation-and-Serialization.md +16 -0
- package/docs/Reference/Warnings.md +2 -0
- package/fastify.js +1 -1
- package/lib/content-type-parser.js +28 -13
- package/lib/content-type.js +4 -4
- package/lib/errors.js +1 -0
- package/lib/handle-request.js +7 -20
- package/lib/reply.js +10 -3
- package/lib/route.js +6 -2
- package/lib/validation.js +73 -14
- package/lib/warnings.js +9 -0
- package/package.json +1 -1
- package/test/child-logger-factory.test.js +25 -0
- package/test/content-parser.test.js +60 -0
- package/test/content-type-parser-async-context.test.js +76 -0
- package/test/custom-parser.4.test.js +40 -0
- package/test/http2/plain.test.js +45 -0
- package/test/internals/reply.test.js +30 -1
- package/test/internals/validation.test.js +66 -0
- package/test/reply-media-type.test.js +57 -0
- package/test/rfc-10008.test.js +6 -9
- package/test/schema-validation.test.js +251 -1
- package/test/types/route.tst.ts +12 -0
- package/types/instance.d.ts +2 -2
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { AsyncLocalStorage, AsyncResource, createHook, executionAsyncId } = require('node:async_hooks')
|
|
4
|
+
const { setImmediate: immediate } = require('node:timers/promises')
|
|
5
|
+
const { test } = require('node:test')
|
|
6
|
+
const Fastify = require('..')
|
|
7
|
+
|
|
8
|
+
test('content type parser callbacks restore request context and destroy their resources', async t => {
|
|
9
|
+
const storage = new AsyncLocalStorage()
|
|
10
|
+
const external = new AsyncResource('external-parser')
|
|
11
|
+
const resources = new Set()
|
|
12
|
+
const destroyed = new Set()
|
|
13
|
+
const hook = createHook({
|
|
14
|
+
init (id, type) {
|
|
15
|
+
if (type === 'content-type-parser:run') resources.add(id)
|
|
16
|
+
},
|
|
17
|
+
destroy (id) {
|
|
18
|
+
if (resources.has(id)) destroyed.add(id)
|
|
19
|
+
}
|
|
20
|
+
}).enable()
|
|
21
|
+
const app = Fastify()
|
|
22
|
+
t.after(async () => {
|
|
23
|
+
hook.disable()
|
|
24
|
+
external.emitDestroy()
|
|
25
|
+
storage.disable()
|
|
26
|
+
await app.close()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
app.addHook('onRequest', (request, reply, done) => {
|
|
30
|
+
storage.run({ id: request.headers['x-request-id'] }, done)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
app.addContentTypeParser('application/custom', { parseAs: 'string' }, (request, body, done) => {
|
|
34
|
+
external.runInAsyncScope(() => {
|
|
35
|
+
t.assert.strictEqual(storage.getStore(), undefined)
|
|
36
|
+
setImmediate(() => {
|
|
37
|
+
if (body === 'error') {
|
|
38
|
+
done(new Error('parser failed'))
|
|
39
|
+
} else {
|
|
40
|
+
done(null, body)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
function assertContext (request) {
|
|
47
|
+
t.assert.deepStrictEqual(storage.getStore(), { id: request.headers['x-request-id'] })
|
|
48
|
+
t.assert.ok(resources.has(executionAsyncId()))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
app.setErrorHandler((error, request, reply) => {
|
|
52
|
+
assertContext(request)
|
|
53
|
+
reply.code(400).send({ error: error.message, id: storage.getStore().id })
|
|
54
|
+
})
|
|
55
|
+
app.post('/', (request, reply) => {
|
|
56
|
+
assertContext(request)
|
|
57
|
+
return { body: request.body, id: storage.getStore().id }
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const responses = await Promise.all(['first', 'second', 'error'].map(id => app.inject({
|
|
61
|
+
method: 'POST',
|
|
62
|
+
url: '/',
|
|
63
|
+
headers: { 'content-type': 'application/custom', 'x-request-id': id },
|
|
64
|
+
payload: id
|
|
65
|
+
})))
|
|
66
|
+
|
|
67
|
+
t.assert.deepStrictEqual(responses.map(response => response.statusCode), [200, 200, 400])
|
|
68
|
+
t.assert.deepStrictEqual(responses.map(response => response.json()), [
|
|
69
|
+
{ body: 'first', id: 'first' },
|
|
70
|
+
{ body: 'second', id: 'second' },
|
|
71
|
+
{ error: 'parser failed', id: 'error' }
|
|
72
|
+
])
|
|
73
|
+
await immediate()
|
|
74
|
+
t.assert.strictEqual(resources.size, 3)
|
|
75
|
+
t.assert.deepStrictEqual(destroyed, resources)
|
|
76
|
+
})
|
|
@@ -6,6 +6,46 @@ const jsonParser = require('fast-json-body')
|
|
|
6
6
|
|
|
7
7
|
process.removeAllListeners('warning')
|
|
8
8
|
|
|
9
|
+
test('parameterized parser matches preserve precedence and encapsulation on repeated requests', async t => {
|
|
10
|
+
const fastify = Fastify()
|
|
11
|
+
t.after(() => fastify.close())
|
|
12
|
+
|
|
13
|
+
fastify.addContentTypeParser('application/custom', { parseAs: 'string' }, (request, body, done) => {
|
|
14
|
+
done(null, 'parent')
|
|
15
|
+
})
|
|
16
|
+
fastify.addContentTypeParser('application/custom; version=2', { parseAs: 'string' }, (request, body, done) => {
|
|
17
|
+
done(null, 'version 2')
|
|
18
|
+
})
|
|
19
|
+
fastify.post('/', request => request.body)
|
|
20
|
+
|
|
21
|
+
fastify.register(async child => {
|
|
22
|
+
child.removeContentTypeParser('application/custom')
|
|
23
|
+
child.addContentTypeParser('application/custom', { parseAs: 'string' }, (request, body, done) => {
|
|
24
|
+
done(null, 'child')
|
|
25
|
+
})
|
|
26
|
+
child.post('/child', request => request.body)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < 3; i++) {
|
|
30
|
+
for (const [url, contentType, expected] of [
|
|
31
|
+
['/', 'application/custom; charset=utf-8', 'parent'],
|
|
32
|
+
['/child', 'application/custom; charset=utf-8', 'child'],
|
|
33
|
+
['/', 'application/custom; version=2', 'version 2'],
|
|
34
|
+
['/child', 'application/custom; version=2', 'version 2'],
|
|
35
|
+
['/', 'Application/Custom ; Charset="utf-8"', 'parent']
|
|
36
|
+
]) {
|
|
37
|
+
const response = await fastify.inject({
|
|
38
|
+
method: 'POST',
|
|
39
|
+
url,
|
|
40
|
+
headers: { 'content-type': contentType },
|
|
41
|
+
payload: 'body'
|
|
42
|
+
})
|
|
43
|
+
t.assert.strictEqual(response.statusCode, 200)
|
|
44
|
+
t.assert.strictEqual(response.body, expected)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
9
49
|
test('should prefer string content types over RegExp ones', async (t) => {
|
|
10
50
|
t.plan(6)
|
|
11
51
|
const fastify = Fastify()
|
package/test/http2/plain.test.js
CHANGED
|
@@ -68,6 +68,51 @@ test('http2 plain test', async t => {
|
|
|
68
68
|
})
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
+
test('http2 response trailers do not use transfer-encoding', async t => {
|
|
72
|
+
const fastify = Fastify({ http2: true })
|
|
73
|
+
|
|
74
|
+
fastify.get('/', async (request, reply) => {
|
|
75
|
+
reply.trailer('x-checksum', async () => 'abc')
|
|
76
|
+
return 'hello'
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
await fastify.listen({ port: 0, host: '127.0.0.1' })
|
|
80
|
+
|
|
81
|
+
const client = http2.connect(`http://127.0.0.1:${fastify.server.address().port}`)
|
|
82
|
+
t.after(() => {
|
|
83
|
+
client.close()
|
|
84
|
+
return fastify.close()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const response = await new Promise((resolve, reject) => {
|
|
88
|
+
const request = client.request({
|
|
89
|
+
[http2.constants.HTTP2_HEADER_METHOD]: http2.constants.HTTP2_METHOD_GET,
|
|
90
|
+
[http2.constants.HTTP2_HEADER_PATH]: '/'
|
|
91
|
+
})
|
|
92
|
+
const result = { body: '' }
|
|
93
|
+
|
|
94
|
+
request.setEncoding('utf8')
|
|
95
|
+
request.on('response', headers => {
|
|
96
|
+
result.headers = headers
|
|
97
|
+
})
|
|
98
|
+
request.on('trailers', trailers => {
|
|
99
|
+
result.trailers = trailers
|
|
100
|
+
})
|
|
101
|
+
request.on('data', chunk => {
|
|
102
|
+
result.body += chunk
|
|
103
|
+
})
|
|
104
|
+
request.on('error', reject)
|
|
105
|
+
request.on('end', () => resolve(result))
|
|
106
|
+
request.end()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
t.assert.strictEqual(response.headers[':status'], 200)
|
|
110
|
+
t.assert.strictEqual(response.headers['transfer-encoding'], undefined)
|
|
111
|
+
t.assert.strictEqual(response.headers.trailer, 'x-checksum')
|
|
112
|
+
t.assert.strictEqual(response.body, 'hello')
|
|
113
|
+
t.assert.strictEqual(response.trailers['x-checksum'], 'abc')
|
|
114
|
+
})
|
|
115
|
+
|
|
71
116
|
test('http2 large non-stream replies are sent completely', async t => {
|
|
72
117
|
const modes = ['buffer', 'string']
|
|
73
118
|
|
|
@@ -35,7 +35,7 @@ const doGet = async function (url) {
|
|
|
35
35
|
|
|
36
36
|
test('Once called, Reply should return an object with methods', t => {
|
|
37
37
|
t.plan(16)
|
|
38
|
-
const response = { res: 'res' }
|
|
38
|
+
const response = { res: 'res', getHeader: () => undefined }
|
|
39
39
|
const context = {
|
|
40
40
|
config: { onSend: [] },
|
|
41
41
|
schema: {},
|
|
@@ -1003,6 +1003,35 @@ test('reply can set multiple instances of same header', async t => {
|
|
|
1003
1003
|
t.assert.deepStrictEqual(result.headers.getSetCookie(), ['one', 'two'])
|
|
1004
1004
|
})
|
|
1005
1005
|
|
|
1006
|
+
test('reply.header does not mutate a set-cookie array passed by the caller', async t => {
|
|
1007
|
+
t.plan(5)
|
|
1008
|
+
|
|
1009
|
+
const fastify = require('../../')()
|
|
1010
|
+
const defaults = ['a=1']
|
|
1011
|
+
|
|
1012
|
+
fastify.get('/headers', function (req, reply) {
|
|
1013
|
+
reply
|
|
1014
|
+
.header('set-cookie', defaults)
|
|
1015
|
+
.header('set-cookie', 'b=2')
|
|
1016
|
+
.send({})
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
const fastifyServer = await fastify.listen({ port: 0 })
|
|
1020
|
+
t.after(() => fastify.close())
|
|
1021
|
+
|
|
1022
|
+
const first = await fetch(`${fastifyServer}/headers`)
|
|
1023
|
+
t.assert.deepStrictEqual(first.headers.getSetCookie(), ['a=1', 'b=2'])
|
|
1024
|
+
const second = await fetch(`${fastifyServer}/headers`)
|
|
1025
|
+
t.assert.deepStrictEqual(second.headers.getSetCookie(), ['a=1', 'b=2'])
|
|
1026
|
+
t.assert.deepStrictEqual(defaults, ['a=1'])
|
|
1027
|
+
|
|
1028
|
+
const reply = new Reply({ setHeader () {}, removeHeader () {} }, {}, {})
|
|
1029
|
+
const cookies = ['one']
|
|
1030
|
+
reply.header('set-cookie', cookies).header('set-cookie', 'two')
|
|
1031
|
+
t.assert.deepStrictEqual(reply.getHeader('set-cookie'), ['one', 'two'])
|
|
1032
|
+
t.assert.deepStrictEqual(cookies, ['one'])
|
|
1033
|
+
})
|
|
1034
|
+
|
|
1006
1035
|
test('reply.hasHeader returns correct values', async t => {
|
|
1007
1036
|
t.plan(2)
|
|
1008
1037
|
|
|
@@ -10,6 +10,72 @@ const { normalizeSchema } = require('../../lib/schemas')
|
|
|
10
10
|
const symbols = require('../../lib/validation').symbols
|
|
11
11
|
const { kSchemaVisited } = require('../../lib/symbols')
|
|
12
12
|
|
|
13
|
+
for (const [part, symbol] of [
|
|
14
|
+
['params', symbols.paramsSchema],
|
|
15
|
+
['body', symbols.bodySchema],
|
|
16
|
+
['query', symbols.querystringSchema],
|
|
17
|
+
['headers', symbols.headersSchema]
|
|
18
|
+
]) {
|
|
19
|
+
for (const async of [false, true]) {
|
|
20
|
+
test(`validate reads only the validated ${part} once (${async ? 'async' : 'sync'})`, async t => {
|
|
21
|
+
const request = {}
|
|
22
|
+
const value = { hello: 'world' }
|
|
23
|
+
let reads = 0
|
|
24
|
+
let calls = 0
|
|
25
|
+
|
|
26
|
+
for (const name of ['params', 'body', 'query', 'headers']) {
|
|
27
|
+
Object.defineProperty(request, name, {
|
|
28
|
+
get () {
|
|
29
|
+
t.assert.strictEqual(name, part, 'unvalidated request parts must not be read')
|
|
30
|
+
reads++
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const context = {
|
|
37
|
+
[symbol]: data => {
|
|
38
|
+
calls++
|
|
39
|
+
t.assert.strictEqual(data, value)
|
|
40
|
+
return async ? Promise.resolve(data) : true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
t.assert.strictEqual(await validation.validate(context, request), false)
|
|
45
|
+
t.assert.strictEqual(reads, 1)
|
|
46
|
+
t.assert.strictEqual(calls, 1)
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('validate skips a body without a matching content-type schema', t => {
|
|
52
|
+
const context = { [symbols.bodySchema]: { 'application/json': () => true } }
|
|
53
|
+
const request = {
|
|
54
|
+
mediaType: 'text/plain',
|
|
55
|
+
get body () {
|
|
56
|
+
t.assert.fail('an unvalidated body must not be read')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
t.assert.strictEqual(validation.validate(context, request), false)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('validate passes null for an undefined request part and preserves other values', t => {
|
|
64
|
+
for (const value of [undefined, null, false, 0, '']) {
|
|
65
|
+
let calls = 0
|
|
66
|
+
const context = {
|
|
67
|
+
[symbols.bodySchema]: data => {
|
|
68
|
+
calls++
|
|
69
|
+
t.assert.strictEqual(data, value === undefined ? null : value)
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
t.assert.strictEqual(validation.validate(context, { body: value }), false)
|
|
75
|
+
t.assert.strictEqual(calls, 1)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
13
79
|
test('Symbols', t => {
|
|
14
80
|
t.plan(5)
|
|
15
81
|
t.assert.strictEqual(typeof symbols.responseSchema, 'symbol')
|
|
@@ -129,3 +129,60 @@ test('reply.mediaType should reflect the last type set', async (t) => {
|
|
|
129
129
|
const body = response.body
|
|
130
130
|
t.assert.strictEqual(body, 'mediaType = text/plain')
|
|
131
131
|
})
|
|
132
|
+
|
|
133
|
+
test('reply.mediaType should match the content-type set via raw.setHeader', async (t) => {
|
|
134
|
+
t.plan(2)
|
|
135
|
+
|
|
136
|
+
const fastify = Fastify()
|
|
137
|
+
|
|
138
|
+
fastify.get('/', (request, reply) => {
|
|
139
|
+
reply.raw.setHeader('content-type', 'application/json; charset=utf-8')
|
|
140
|
+
t.assert.strictEqual(reply.mediaType, 'application/json')
|
|
141
|
+
reply.send({ mediaType: reply.mediaType })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const response = await fastify.inject({
|
|
145
|
+
method: 'GET',
|
|
146
|
+
url: '/'
|
|
147
|
+
})
|
|
148
|
+
const body = await response.json()
|
|
149
|
+
t.assert.strictEqual(body.mediaType, 'application/json')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('reply.mediaType should match case-insensitive content-type set via raw.setHeader', async (t) => {
|
|
153
|
+
t.plan(2)
|
|
154
|
+
|
|
155
|
+
const fastify = Fastify()
|
|
156
|
+
|
|
157
|
+
fastify.get('/', (request, reply) => {
|
|
158
|
+
reply.raw.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
159
|
+
t.assert.strictEqual(reply.mediaType, 'text/html')
|
|
160
|
+
reply.send(reply.mediaType)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const response = await fastify.inject({
|
|
164
|
+
method: 'GET',
|
|
165
|
+
url: '/'
|
|
166
|
+
})
|
|
167
|
+
t.assert.strictEqual(response.body, 'text/html')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('reply.mediaType should prioritize reply.type over raw.setHeader', async (t) => {
|
|
171
|
+
t.plan(2)
|
|
172
|
+
|
|
173
|
+
const fastify = Fastify()
|
|
174
|
+
|
|
175
|
+
fastify.get('/', (request, reply) => {
|
|
176
|
+
reply.raw.setHeader('content-type', 'application/xml')
|
|
177
|
+
reply.type('application/json')
|
|
178
|
+
t.assert.strictEqual(reply.mediaType, 'application/json')
|
|
179
|
+
reply.send({ mediaType: reply.mediaType })
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const response = await fastify.inject({
|
|
183
|
+
method: 'GET',
|
|
184
|
+
url: '/'
|
|
185
|
+
})
|
|
186
|
+
const body = await response.json()
|
|
187
|
+
t.assert.strictEqual(body.mediaType, 'application/json')
|
|
188
|
+
})
|
package/test/rfc-10008.test.js
CHANGED
|
@@ -99,12 +99,9 @@ describe('RFC 10008', () => {
|
|
|
99
99
|
t.assert.equal(res.json().code, 'FST_ERR_CTP_INVALID_JSON_BODY')
|
|
100
100
|
})
|
|
101
101
|
|
|
102
|
-
test('should return
|
|
103
|
-
// If a media type is
|
|
104
|
-
//
|
|
105
|
-
// can be returned. That is, a server is not allowed to
|
|
106
|
-
// infer a media type from the request content and then
|
|
107
|
-
// override a missing or "erroneous" value (i.e., "content sniffing").
|
|
102
|
+
test('should return 200 if body is empty but media type allows it for QUERY method', async (t) => {
|
|
103
|
+
// If a media type is set and allows the body to be empty,
|
|
104
|
+
// the server must allow it.
|
|
108
105
|
t.plan(2)
|
|
109
106
|
|
|
110
107
|
const fastify = Fastify()
|
|
@@ -116,11 +113,11 @@ describe('RFC 10008', () => {
|
|
|
116
113
|
const res = await fastify.inject({
|
|
117
114
|
method: 'QUERY',
|
|
118
115
|
url: '/query',
|
|
119
|
-
headers: { 'Content-Type': '
|
|
116
|
+
headers: { 'Content-Type': 'text/plain' }
|
|
120
117
|
})
|
|
121
118
|
|
|
122
|
-
t.assert.equal(res.statusCode,
|
|
123
|
-
t.assert.equal(res.
|
|
119
|
+
t.assert.equal(res.statusCode, 200)
|
|
120
|
+
t.assert.equal(res.body, '')
|
|
124
121
|
})
|
|
125
122
|
|
|
126
123
|
test('should return 415 if unsupported media type is provided for QUERY method', async (t) => {
|
|
@@ -8,7 +8,7 @@ const Schema = require('fluent-json-schema')
|
|
|
8
8
|
const { waitForCb } = require('./helper')
|
|
9
9
|
const { kRequestContentType } = require('../lib/symbols')
|
|
10
10
|
const { spyWarning } = require('process-warning')
|
|
11
|
-
const { FSTSEC002 } = require('../lib/warnings')
|
|
11
|
+
const { FSTSEC002, FSTSEC003 } = require('../lib/warnings')
|
|
12
12
|
|
|
13
13
|
const customSchemaCompilers = {
|
|
14
14
|
body: new AJV({
|
|
@@ -1921,6 +1921,52 @@ test('inline and local $ref header schemas do not emit FSTSEC002', async t => {
|
|
|
1921
1921
|
t.assert.strictEqual(spyData.callCount(), 0)
|
|
1922
1922
|
})
|
|
1923
1923
|
|
|
1924
|
+
test('header schema with a $ref inside propertyNames emits FSTSEC003 (case-normalization does not reach it)', async t => {
|
|
1925
|
+
const spyData = spyWarning(FSTSEC003)
|
|
1926
|
+
t.after(spyData.restore)
|
|
1927
|
+
|
|
1928
|
+
const fastify = Fastify()
|
|
1929
|
+
fastify.post('/', {
|
|
1930
|
+
schema: {
|
|
1931
|
+
headers: {
|
|
1932
|
+
type: 'object',
|
|
1933
|
+
propertyNames: { $ref: '#/definitions/denied' },
|
|
1934
|
+
definitions: {
|
|
1935
|
+
denied: { not: { enum: ['X-Debug-Override'] } }
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
}, async () => ({ ok: true }))
|
|
1940
|
+
|
|
1941
|
+
await fastify.ready()
|
|
1942
|
+
|
|
1943
|
+
t.assert.strictEqual(spyData.callCount(), 1)
|
|
1944
|
+
t.assert.deepStrictEqual(spyData.calls[0].arguments, ['POST', '/', '#/definitions/denied'])
|
|
1945
|
+
})
|
|
1946
|
+
|
|
1947
|
+
test('header schema detects a $ref inside propertyNames nested in an array', async t => {
|
|
1948
|
+
const spyData = spyWarning(FSTSEC003)
|
|
1949
|
+
t.after(spyData.restore)
|
|
1950
|
+
|
|
1951
|
+
const fastify = Fastify()
|
|
1952
|
+
fastify.post('/', {
|
|
1953
|
+
schema: {
|
|
1954
|
+
headers: {
|
|
1955
|
+
type: 'object',
|
|
1956
|
+
propertyNames: { not: { anyOf: [{ $ref: '#/definitions/denied' }] } },
|
|
1957
|
+
definitions: {
|
|
1958
|
+
denied: { enum: ['X-Debug-Override'] }
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
}, async () => ({ ok: true }))
|
|
1963
|
+
|
|
1964
|
+
await fastify.ready()
|
|
1965
|
+
|
|
1966
|
+
t.assert.strictEqual(spyData.callCount(), 1)
|
|
1967
|
+
t.assert.deepStrictEqual(spyData.calls[0].arguments, ['POST', '/', '#/definitions/denied'])
|
|
1968
|
+
})
|
|
1969
|
+
|
|
1924
1970
|
test('header schema normalization traverses dependent schema keywords', async t => {
|
|
1925
1971
|
const fastify = Fastify({
|
|
1926
1972
|
ajv: { customOptions: { strictSchema: false } }
|
|
@@ -1993,3 +2039,207 @@ test('header schema detects an external $ref nested in an array', async t => {
|
|
|
1993
2039
|
'http://example.com/nested-admin-headers#'
|
|
1994
2040
|
])
|
|
1995
2041
|
})
|
|
2042
|
+
|
|
2043
|
+
test('header schema propertyNames: not-enum deny-list is lowercased', async t => {
|
|
2044
|
+
const fastify = Fastify()
|
|
2045
|
+
|
|
2046
|
+
// A `propertyNames` subschema validates each header name itself, so strings in
|
|
2047
|
+
// its `enum` are header names. They must be lowercased the same way as
|
|
2048
|
+
// `required` entries, otherwise a deny-list written in the usual header case
|
|
2049
|
+
// never matches the lowercased request object.
|
|
2050
|
+
fastify.get('/', {
|
|
2051
|
+
schema: {
|
|
2052
|
+
headers: {
|
|
2053
|
+
type: 'object',
|
|
2054
|
+
propertyNames: { not: { enum: ['X-Debug-Override'] } }
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
}, async () => ({ ok: true }))
|
|
2058
|
+
|
|
2059
|
+
await fastify.ready()
|
|
2060
|
+
|
|
2061
|
+
const blocked = await fastify.inject({
|
|
2062
|
+
method: 'GET',
|
|
2063
|
+
url: '/',
|
|
2064
|
+
headers: { 'X-Debug-Override': '1' }
|
|
2065
|
+
})
|
|
2066
|
+
t.assert.strictEqual(blocked.statusCode, 400)
|
|
2067
|
+
t.assert.strictEqual(blocked.json().code, 'FST_ERR_VALIDATION')
|
|
2068
|
+
|
|
2069
|
+
const ok = await fastify.inject({
|
|
2070
|
+
method: 'GET',
|
|
2071
|
+
url: '/',
|
|
2072
|
+
headers: { 'X-Other': '1' }
|
|
2073
|
+
})
|
|
2074
|
+
t.assert.strictEqual(ok.statusCode, 200)
|
|
2075
|
+
})
|
|
2076
|
+
|
|
2077
|
+
test('header schema propertyNames: non-string enum items are left untouched', async t => {
|
|
2078
|
+
const fastify = Fastify({
|
|
2079
|
+
ajv: { customOptions: { strictSchema: false } }
|
|
2080
|
+
})
|
|
2081
|
+
|
|
2082
|
+
fastify.get('/', {
|
|
2083
|
+
schema: {
|
|
2084
|
+
headers: {
|
|
2085
|
+
type: 'object',
|
|
2086
|
+
propertyNames: { not: { enum: ['X-Debug-Override', 42, null] } }
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
}, async () => ({ ok: true }))
|
|
2090
|
+
|
|
2091
|
+
await fastify.ready()
|
|
2092
|
+
|
|
2093
|
+
const blocked = await fastify.inject({
|
|
2094
|
+
method: 'GET',
|
|
2095
|
+
url: '/',
|
|
2096
|
+
headers: { 'X-Debug-Override': '1' }
|
|
2097
|
+
})
|
|
2098
|
+
t.assert.strictEqual(blocked.statusCode, 400)
|
|
2099
|
+
|
|
2100
|
+
const ok = await fastify.inject({
|
|
2101
|
+
method: 'GET',
|
|
2102
|
+
url: '/',
|
|
2103
|
+
headers: { 'X-Other': '1' }
|
|
2104
|
+
})
|
|
2105
|
+
t.assert.strictEqual(ok.statusCode, 200)
|
|
2106
|
+
})
|
|
2107
|
+
|
|
2108
|
+
test('header schema propertyNames: not-const deny-list is lowercased', async t => {
|
|
2109
|
+
const fastify = Fastify()
|
|
2110
|
+
|
|
2111
|
+
fastify.get('/', {
|
|
2112
|
+
schema: {
|
|
2113
|
+
headers: {
|
|
2114
|
+
type: 'object',
|
|
2115
|
+
propertyNames: { not: { const: 'X-Debug-Override' } }
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
}, async () => ({ ok: true }))
|
|
2119
|
+
|
|
2120
|
+
await fastify.ready()
|
|
2121
|
+
|
|
2122
|
+
const blocked = await fastify.inject({
|
|
2123
|
+
method: 'GET',
|
|
2124
|
+
url: '/',
|
|
2125
|
+
headers: { 'X-Debug-Override': '1' }
|
|
2126
|
+
})
|
|
2127
|
+
t.assert.strictEqual(blocked.statusCode, 400)
|
|
2128
|
+
t.assert.strictEqual(blocked.json().code, 'FST_ERR_VALIDATION')
|
|
2129
|
+
})
|
|
2130
|
+
|
|
2131
|
+
test('header schema propertyNames: enum/const are lowercased inside combinators', async t => {
|
|
2132
|
+
const fastify = Fastify()
|
|
2133
|
+
|
|
2134
|
+
// The header-name `enum`/`const` can be nested under combinators inside
|
|
2135
|
+
// `propertyNames`; the lowercasing context must propagate through them.
|
|
2136
|
+
fastify.get('/anyOf', {
|
|
2137
|
+
schema: {
|
|
2138
|
+
headers: {
|
|
2139
|
+
type: 'object',
|
|
2140
|
+
propertyNames: { not: { anyOf: [{ enum: ['X-Debug-Override'] }] } }
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
}, async () => ({ ok: true }))
|
|
2144
|
+
|
|
2145
|
+
fastify.get('/oneOf', {
|
|
2146
|
+
schema: {
|
|
2147
|
+
headers: {
|
|
2148
|
+
type: 'object',
|
|
2149
|
+
propertyNames: { not: { oneOf: [{ const: 'X-Debug-Override' }] } }
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
}, async () => ({ ok: true }))
|
|
2153
|
+
|
|
2154
|
+
await fastify.ready()
|
|
2155
|
+
|
|
2156
|
+
for (const url of ['/anyOf', '/oneOf']) {
|
|
2157
|
+
const blocked = await fastify.inject({
|
|
2158
|
+
method: 'GET',
|
|
2159
|
+
url,
|
|
2160
|
+
headers: { 'X-Debug-Override': '1' }
|
|
2161
|
+
})
|
|
2162
|
+
t.assert.strictEqual(blocked.statusCode, 400, `${url} should block the header`)
|
|
2163
|
+
t.assert.strictEqual(blocked.json().code, 'FST_ERR_VALIDATION')
|
|
2164
|
+
}
|
|
2165
|
+
})
|
|
2166
|
+
|
|
2167
|
+
test('header schema propertyNames: lowercase-equivalent schema behaves identically', async t => {
|
|
2168
|
+
const fastify = Fastify()
|
|
2169
|
+
|
|
2170
|
+
fastify.get('/', {
|
|
2171
|
+
schema: {
|
|
2172
|
+
headers: {
|
|
2173
|
+
type: 'object',
|
|
2174
|
+
propertyNames: { not: { enum: ['x-debug-override'] } }
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
}, async () => ({ ok: true }))
|
|
2178
|
+
|
|
2179
|
+
await fastify.ready()
|
|
2180
|
+
|
|
2181
|
+
const blocked = await fastify.inject({
|
|
2182
|
+
method: 'GET',
|
|
2183
|
+
url: '/',
|
|
2184
|
+
headers: { 'X-Debug-Override': '1' }
|
|
2185
|
+
})
|
|
2186
|
+
t.assert.strictEqual(blocked.statusCode, 400)
|
|
2187
|
+
t.assert.strictEqual(blocked.json().code, 'FST_ERR_VALIDATION')
|
|
2188
|
+
})
|
|
2189
|
+
|
|
2190
|
+
test('header schema propertyNames: enum/const values in header value schemas are not lowercased', async t => {
|
|
2191
|
+
const fastify = Fastify()
|
|
2192
|
+
|
|
2193
|
+
// `enum`/`const` inside a property value subschema hold header *values*, not
|
|
2194
|
+
// names, and must be preserved with their original case.
|
|
2195
|
+
fastify.get('/enum', {
|
|
2196
|
+
schema: {
|
|
2197
|
+
headers: {
|
|
2198
|
+
type: 'object',
|
|
2199
|
+
properties: { 'X-Mode': { type: 'string', enum: ['MIXED', 'lowercase'] } }
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
}, async () => ({ ok: true }))
|
|
2203
|
+
|
|
2204
|
+
fastify.get('/const', {
|
|
2205
|
+
schema: {
|
|
2206
|
+
headers: {
|
|
2207
|
+
type: 'object',
|
|
2208
|
+
properties: { 'X-Mode': { type: 'string', const: 'MIXED' } }
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
}, async () => ({ ok: true }))
|
|
2212
|
+
|
|
2213
|
+
await fastify.ready()
|
|
2214
|
+
|
|
2215
|
+
const enumMixed = await fastify.inject({
|
|
2216
|
+
method: 'GET',
|
|
2217
|
+
url: '/enum',
|
|
2218
|
+
headers: { 'X-Mode': 'MIXED' }
|
|
2219
|
+
})
|
|
2220
|
+
t.assert.strictEqual(enumMixed.statusCode, 200)
|
|
2221
|
+
|
|
2222
|
+
const constMixed = await fastify.inject({
|
|
2223
|
+
method: 'GET',
|
|
2224
|
+
url: '/const',
|
|
2225
|
+
headers: { 'X-Mode': 'MIXED' }
|
|
2226
|
+
})
|
|
2227
|
+
t.assert.strictEqual(constMixed.statusCode, 200)
|
|
2228
|
+
})
|
|
2229
|
+
|
|
2230
|
+
test('header schema propertyNames lowercasing does not mutate the input schema', async t => {
|
|
2231
|
+
const headers = {
|
|
2232
|
+
type: 'object',
|
|
2233
|
+
propertyNames: { not: { enum: ['X-Debug-Override'], const: 'X-Other' } }
|
|
2234
|
+
}
|
|
2235
|
+
const schema = { headers }
|
|
2236
|
+
const schemaBefore = JSON.stringify(schema)
|
|
2237
|
+
const headersBefore = JSON.stringify(headers)
|
|
2238
|
+
|
|
2239
|
+
const fastify = Fastify()
|
|
2240
|
+
fastify.get('/', { schema }, async () => ({ ok: true }))
|
|
2241
|
+
await fastify.ready()
|
|
2242
|
+
|
|
2243
|
+
t.assert.strictEqual(JSON.stringify(schema), schemaBefore)
|
|
2244
|
+
t.assert.strictEqual(JSON.stringify(headers), headersBefore)
|
|
2245
|
+
})
|
package/test/types/route.tst.ts
CHANGED
|
@@ -495,6 +495,12 @@ expect(fastify().hasRoute({
|
|
|
495
495
|
method: 'GET'
|
|
496
496
|
})).type.toBe<boolean>()
|
|
497
497
|
|
|
498
|
+
fastify().hasRoute({
|
|
499
|
+
url: '/',
|
|
500
|
+
// @ts-expect-error Type 'string[]' is not assignable to type 'HTTPMethods'.
|
|
501
|
+
method: ['GET', 'POST']
|
|
502
|
+
})
|
|
503
|
+
|
|
498
504
|
expect(fastify().hasRoute({
|
|
499
505
|
url: '/',
|
|
500
506
|
method: 'GET',
|
|
@@ -539,6 +545,12 @@ expect(
|
|
|
539
545
|
})
|
|
540
546
|
).type.toBe<Omit<FindMyWayFindResult<RawServerDefault>, 'store'>>()
|
|
541
547
|
|
|
548
|
+
fastify().findRoute({
|
|
549
|
+
url: '/',
|
|
550
|
+
// @ts-expect-error Type 'string[]' is not assignable to type 'HTTPMethods'.
|
|
551
|
+
method: ['GET', 'POST']
|
|
552
|
+
})
|
|
553
|
+
|
|
542
554
|
// we should not expose store
|
|
543
555
|
expect(fastify().findRoute({
|
|
544
556
|
url: '/',
|