mockaton 6.4.7 → 7.1.0
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/README.md +44 -36
- package/Tests.js +66 -60
- package/index.d.ts +31 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/Api.js +27 -6
- package/src/ApiConstants.js +5 -3
- package/src/Commander.js +54 -0
- package/src/Config.js +39 -2
- package/src/Dashboard.js +12 -12
- package/src/Mockaton.js +42 -23
- package/src/utils/http-cors.js +70 -0
- package/src/utils/http-cors.test.js +190 -0
- package/src/utils/http-request.js +7 -1
package/README.md
CHANGED
|
@@ -12,8 +12,8 @@ my-mocks-dir/api/user/[user-id].GET.200.json
|
|
|
12
12
|
[This browser extension](https://github.com/ericfortis/devtools-ext-tar-http-requests)
|
|
13
13
|
can be used for downloading a TAR of your XHR requests following that convention.
|
|
14
14
|
|
|
15
|
-
##
|
|
16
|
-
-
|
|
15
|
+
## Benefits
|
|
16
|
+
- Avoids having to spin up and maintain hefty or complex backends when developing UIs.
|
|
17
17
|
- For a deterministic and comprehensive backend state. For example, having all the possible
|
|
18
18
|
state variants of a particular collection helps for spotting inadvertent bugs. And having those
|
|
19
19
|
assorted responses are not easy to trigger from the backend.
|
|
@@ -42,7 +42,7 @@ The best way to learn _Mockaton_ is by checking out this repo and
|
|
|
42
42
|
exploring its [sample-mocks/](./sample-mocks) directory. Then, run
|
|
43
43
|
[`./_usage_example.js`](./_usage_example.js) and you’ll see the dashboard.
|
|
44
44
|
|
|
45
|
-
You can
|
|
45
|
+
You can select mock files without resetting Mockaton. The _Reset_
|
|
46
46
|
button is for when you add, remove, or rename a mock file.
|
|
47
47
|
|
|
48
48
|
The dropdown lets you pick a mock variant, details in the next section. Next to it is a
|
|
@@ -71,6 +71,7 @@ node my-mockaton.js
|
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
## Config Options
|
|
74
|
+
There’s a Config section below with more details.
|
|
74
75
|
```ts
|
|
75
76
|
interface Config {
|
|
76
77
|
mocksDir: string
|
|
@@ -87,10 +88,12 @@ interface Config {
|
|
|
87
88
|
extraMimes?: { [fileExt: string]: string }
|
|
88
89
|
extraHeaders?: []
|
|
89
90
|
|
|
91
|
+
corsAllowed?: boolean, // Defaults to false
|
|
92
|
+
// The options for customizing CORS are listed below
|
|
93
|
+
|
|
90
94
|
onReady?: (dashboardUrl: string) => void // Defaults to trying to open macOS and Win default browser.
|
|
91
95
|
}
|
|
92
96
|
```
|
|
93
|
-
There’s a Config section below with more details.
|
|
94
97
|
|
|
95
98
|
---
|
|
96
99
|
|
|
@@ -259,6 +262,19 @@ Config.extraMimes = {
|
|
|
259
262
|
}
|
|
260
263
|
```
|
|
261
264
|
|
|
265
|
+
## `Config.corsAllowed`
|
|
266
|
+
```js
|
|
267
|
+
Config.corsAllowed = true
|
|
268
|
+
|
|
269
|
+
// Defaults when `corsAllowed === true`
|
|
270
|
+
Config.corsOrigins = ['*']
|
|
271
|
+
Config.corsMethods = ['GET', 'PUT', 'DELETE', 'POST', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT']
|
|
272
|
+
Config.corsHeaders = []
|
|
273
|
+
Config.corsCredentials = true
|
|
274
|
+
Config.corsMaxAge = 0
|
|
275
|
+
Config.corsExposedHeaders = []
|
|
276
|
+
```
|
|
277
|
+
|
|
262
278
|
## `Config.onReady`
|
|
263
279
|
This is a callback `(dashboardAddress: string) => void`, which defaults to
|
|
264
280
|
trying to open the dashboard in your default browser in macOS and Windows.
|
|
@@ -288,60 +304,52 @@ Config.onReady = open
|
|
|
288
304
|
|
|
289
305
|
---
|
|
290
306
|
|
|
291
|
-
## API
|
|
292
|
-
|
|
293
|
-
|
|
307
|
+
## HTTP API
|
|
308
|
+
`Commander` is a wrapper for the Mockaton HTTP API.
|
|
309
|
+
All of its methods return their `fetch` response promise.
|
|
294
310
|
```js
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
})
|
|
301
|
-
})
|
|
311
|
+
import { Commander } from 'mockaton'
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
const myMockatonAddr = 'http://localhost:2345'
|
|
315
|
+
const mockaton = new Commander(myMockatonAddr)
|
|
302
316
|
```
|
|
303
317
|
|
|
318
|
+
### Select a mock file for a route
|
|
319
|
+
```js
|
|
320
|
+
await mockaton.select('api/foo.200.GET.json')
|
|
321
|
+
```
|
|
304
322
|
### Select all mocks that have a particular comment
|
|
305
323
|
```js
|
|
306
|
-
|
|
307
|
-
method: 'PATCH',
|
|
308
|
-
body: JSON.stringify('(demo-a)')
|
|
309
|
-
})
|
|
324
|
+
await mockaton.bulkSelectByComment('(demo-a)')
|
|
310
325
|
```
|
|
311
326
|
|
|
312
|
-
###
|
|
313
|
-
Sends a list of the available cookies along with an "is selected" boolean flag.
|
|
327
|
+
### Set Route is Delayed Flag
|
|
314
328
|
```js
|
|
315
|
-
|
|
329
|
+
await mockaton.setRouteIsDelayed('GET', '/api/foo', true)
|
|
316
330
|
```
|
|
317
331
|
|
|
318
332
|
### Select a cookie
|
|
319
|
-
In `Config.cookies`, each key is the label used for
|
|
333
|
+
In `Config.cookies`, each key is the label used for selecting it.
|
|
320
334
|
```js
|
|
321
|
-
|
|
322
|
-
method: 'PATCH',
|
|
323
|
-
body: JSON.stringify('My Normal User')
|
|
324
|
-
})
|
|
335
|
+
await mockaton.selectCookie('My Normal User')
|
|
325
336
|
```
|
|
326
337
|
|
|
327
|
-
###
|
|
338
|
+
### Set Fallback Proxy
|
|
328
339
|
```js
|
|
329
|
-
|
|
330
|
-
method: 'PATCH',
|
|
331
|
-
body: JSON.stringify('http://example.com')
|
|
332
|
-
})
|
|
340
|
+
await mockaton.setProxyFallback('http://example.com')
|
|
333
341
|
```
|
|
342
|
+
Pass an empty string to disable it.
|
|
334
343
|
|
|
335
344
|
### Reset
|
|
336
345
|
Re-initialize the collection. So if you added or removed mocks they
|
|
337
346
|
will be considered. The selected mocks, cookies, and delays go
|
|
338
|
-
back to default, but `Config.
|
|
347
|
+
back to default, but `Config.proxyFallback` is not affected.
|
|
339
348
|
```js
|
|
340
|
-
|
|
341
|
-
method: 'PATCH'
|
|
342
|
-
})
|
|
349
|
+
await mockaton.reset()
|
|
343
350
|
```
|
|
344
351
|
|
|
352
|
+
|
|
345
353
|
## TODO
|
|
346
|
-
- Dashboard. List `staticDir` and indicate if it’s overriding some mock.
|
|
347
354
|
- Refactor Tests
|
|
355
|
+
- Dashboard. List `staticDir` and indicate if it’s overriding some mock.
|
package/Tests.js
CHANGED
|
@@ -11,8 +11,10 @@ import { writeFileSync, mkdtempSync, mkdirSync } from 'node:fs'
|
|
|
11
11
|
import { Config } from './src/Config.js'
|
|
12
12
|
import { mimeFor } from './src/utils/mime.js'
|
|
13
13
|
import { Mockaton } from './src/Mockaton.js'
|
|
14
|
+
import { Commander } from './src/Commander.js'
|
|
14
15
|
import { parseFilename } from './src/Filename.js'
|
|
15
|
-
import {
|
|
16
|
+
import { PreflightHeader } from './src/utils/http-cors.js'
|
|
17
|
+
import { API, DEFAULT_500_COMMENT, DEFAULT_MOCK_COMMENT } from './src/ApiConstants.js'
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
const tmpDir = mkdtempSync(tmpdir()) + '/'
|
|
@@ -36,6 +38,12 @@ const fixtureDefaultInName = [
|
|
|
36
38
|
'default my route body content'
|
|
37
39
|
]
|
|
38
40
|
|
|
41
|
+
const fixtureDelayed = [
|
|
42
|
+
'/api/delayed',
|
|
43
|
+
'api/delayed.GET.200.json',
|
|
44
|
+
'Route_To_Be_Delayed'
|
|
45
|
+
]
|
|
46
|
+
|
|
39
47
|
const fixtures = [
|
|
40
48
|
[
|
|
41
49
|
'/api',
|
|
@@ -45,6 +53,7 @@ const fixtures = [
|
|
|
45
53
|
|
|
46
54
|
// Exact route paths
|
|
47
55
|
fixtureDefaultInName,
|
|
56
|
+
fixtureDelayed,
|
|
48
57
|
[
|
|
49
58
|
'/api/the-route',
|
|
50
59
|
'api/the-route(default).GET.200.json',
|
|
@@ -135,6 +144,8 @@ writeStatic('index.html', '<h1>Static</h1>')
|
|
|
135
144
|
writeStatic('assets/app.js', 'const app = 1')
|
|
136
145
|
writeStatic('another-entry/index.html', '<h1>Another</h1>')
|
|
137
146
|
|
|
147
|
+
|
|
148
|
+
|
|
138
149
|
const server = Mockaton({
|
|
139
150
|
mocksDir: tmpDir,
|
|
140
151
|
staticDir: staticTmpDir,
|
|
@@ -147,11 +158,25 @@ const server = Mockaton({
|
|
|
147
158
|
extraHeaders: ['Server', 'MockatonTester'],
|
|
148
159
|
extraMimes: {
|
|
149
160
|
my_custom_extension: 'my_custom_mime'
|
|
150
|
-
}
|
|
161
|
+
},
|
|
162
|
+
corsAllowed: true,
|
|
163
|
+
corsOrigins: ['http://example.com']
|
|
151
164
|
})
|
|
152
165
|
server.on('listening', runTests)
|
|
153
166
|
|
|
167
|
+
function mockatonAddr() {
|
|
168
|
+
const { address, port } = server.address()
|
|
169
|
+
return `http://${address}:${port}`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function request(path, options = {}) {
|
|
173
|
+
return fetch(`${mockatonAddr()}${path}`, options)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let commander
|
|
154
177
|
async function runTests() {
|
|
178
|
+
commander = new Commander(mockatonAddr())
|
|
179
|
+
|
|
155
180
|
await testItRendersDashboard()
|
|
156
181
|
await test404()
|
|
157
182
|
|
|
@@ -160,11 +185,7 @@ async function runTests() {
|
|
|
160
185
|
|
|
161
186
|
await testDefaultMock()
|
|
162
187
|
|
|
163
|
-
await
|
|
164
|
-
'/api/alternative',
|
|
165
|
-
'api/alternative(comment-2).GET.200.json',
|
|
166
|
-
JSON.stringify({ comment: 2 }))
|
|
167
|
-
|
|
188
|
+
await testItUpdatesRouteDelay(...fixtureDelayed)
|
|
168
189
|
await testBadRequestWhenUpdatingNonExistingMockAlternative()
|
|
169
190
|
|
|
170
191
|
await testAutogenerates500(
|
|
@@ -176,14 +197,14 @@ async function runTests() {
|
|
|
176
197
|
'api/.GET.500.txt',
|
|
177
198
|
'keeps non-autogenerated 500')
|
|
178
199
|
|
|
179
|
-
await reset()
|
|
200
|
+
await commander.reset()
|
|
180
201
|
await testItUpdatesTheCurrentSelectedMock(
|
|
181
202
|
'/api/alternative',
|
|
182
203
|
'api/alternative(comment-2).GET.200.json',
|
|
183
204
|
200,
|
|
184
205
|
JSON.stringify({ comment: 2 }))
|
|
185
206
|
|
|
186
|
-
await reset()
|
|
207
|
+
await commander.reset()
|
|
187
208
|
await testExtractsAllComments([
|
|
188
209
|
'(comment-1)',
|
|
189
210
|
'(comment-2)',
|
|
@@ -197,7 +218,7 @@ async function runTests() {
|
|
|
197
218
|
['/api/my-route', 'api/my-route(comment-2).GET.200.json', { comment: 2 }]
|
|
198
219
|
])
|
|
199
220
|
|
|
200
|
-
await reset()
|
|
221
|
+
await commander.reset()
|
|
201
222
|
for (const [url, file, body] of fixtures)
|
|
202
223
|
await testMockDispatching(url, file, body)
|
|
203
224
|
|
|
@@ -205,16 +226,15 @@ async function runTests() {
|
|
|
205
226
|
await testMockDispatching(...fixtureCustomMime, 'my_custom_mime')
|
|
206
227
|
await testJsFunctionMocks()
|
|
207
228
|
|
|
229
|
+
await testCorsAllowed()
|
|
208
230
|
await testItUpdatesUserRole()
|
|
209
231
|
await testStaticFileServing()
|
|
210
232
|
await testInvalidFilenamesAreIgnored()
|
|
211
233
|
await testEnableFallbackSoRoutesWithoutMocksGetRelayed()
|
|
234
|
+
|
|
212
235
|
server.close()
|
|
213
236
|
}
|
|
214
237
|
|
|
215
|
-
async function reset() {
|
|
216
|
-
await request(API.reset, { method: 'PATCH' })
|
|
217
|
-
}
|
|
218
238
|
|
|
219
239
|
async function testItRendersDashboard() {
|
|
220
240
|
const res = await request(API.dashboard)
|
|
@@ -259,10 +279,7 @@ async function testDefaultMock() {
|
|
|
259
279
|
}
|
|
260
280
|
|
|
261
281
|
async function testItUpdatesTheCurrentSelectedMock(url, file, expectedStatus, expectedBody) {
|
|
262
|
-
await
|
|
263
|
-
method: 'PATCH',
|
|
264
|
-
body: JSON.stringify({ [DF.file]: file })
|
|
265
|
-
})
|
|
282
|
+
await commander.select(file)
|
|
266
283
|
const res = await request(url)
|
|
267
284
|
const body = await res.text()
|
|
268
285
|
await describe('url: ' + url, () => {
|
|
@@ -271,19 +288,14 @@ async function testItUpdatesTheCurrentSelectedMock(url, file, expectedStatus, ex
|
|
|
271
288
|
})
|
|
272
289
|
}
|
|
273
290
|
|
|
274
|
-
async function
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
body: JSON.stringify({
|
|
278
|
-
[DF.file]: file,
|
|
279
|
-
[DF.delayed]: true
|
|
280
|
-
})
|
|
281
|
-
})
|
|
291
|
+
async function testItUpdatesRouteDelay(url, file, expectedBody) {
|
|
292
|
+
const { method } = parseFilename(file)
|
|
293
|
+
await commander.setRouteIsDelayed(method, url, true)
|
|
282
294
|
const now = new Date()
|
|
283
295
|
const res = await request(url)
|
|
284
296
|
const body = await res.text()
|
|
285
297
|
await describe('url: ' + url, () => {
|
|
286
|
-
it('body is: ' + expectedBody, () => equal(body, expectedBody))
|
|
298
|
+
it('body is: ' + expectedBody, () => equal(body, JSON.stringify(expectedBody)))
|
|
287
299
|
it('delay', () => equal((new Date()).getTime() - now.getTime() > Config.delay, true))
|
|
288
300
|
})
|
|
289
301
|
}
|
|
@@ -291,20 +303,14 @@ async function testItUpdatesDelayAndFile(url, file, expectedBody) {
|
|
|
291
303
|
async function testBadRequestWhenUpdatingNonExistingMockAlternative() {
|
|
292
304
|
await it('There are mocks for /api/the-route but not this one', async () => {
|
|
293
305
|
const missingFile = 'api/the-route(non-existing-variant).GET.200.json'
|
|
294
|
-
const res = await
|
|
295
|
-
method: 'PATCH',
|
|
296
|
-
body: JSON.stringify({ [DF.file]: missingFile })
|
|
297
|
-
})
|
|
306
|
+
const res = await commander.select(missingFile)
|
|
298
307
|
equal(res.status, 400)
|
|
299
308
|
equal(await res.text(), `Missing Mock: ${missingFile}`)
|
|
300
309
|
})
|
|
301
310
|
}
|
|
302
311
|
|
|
303
312
|
async function testAutogenerates500(url, file) {
|
|
304
|
-
await
|
|
305
|
-
method: 'PATCH',
|
|
306
|
-
body: JSON.stringify({ [DF.file]: file })
|
|
307
|
-
})
|
|
313
|
+
await commander.select(file)
|
|
308
314
|
const res = await request(url)
|
|
309
315
|
const body = await res.text()
|
|
310
316
|
await describe('autogenerated in-memory 500', () => {
|
|
@@ -314,10 +320,7 @@ async function testAutogenerates500(url, file) {
|
|
|
314
320
|
}
|
|
315
321
|
|
|
316
322
|
async function testPreservesExiting500(url, file, expectedBody) {
|
|
317
|
-
await
|
|
318
|
-
method: 'PATCH',
|
|
319
|
-
body: JSON.stringify({ [DF.file]: file })
|
|
320
|
-
})
|
|
323
|
+
await commander.select(file)
|
|
321
324
|
const res = await request(url)
|
|
322
325
|
const body = await res.text()
|
|
323
326
|
await describe('preserves existing 500', () => {
|
|
@@ -327,17 +330,14 @@ async function testPreservesExiting500(url, file, expectedBody) {
|
|
|
327
330
|
}
|
|
328
331
|
|
|
329
332
|
async function testExtractsAllComments(expected) {
|
|
330
|
-
const res = await
|
|
333
|
+
const res = await commander.listComments()
|
|
331
334
|
const body = await res.json()
|
|
332
335
|
await it('Extracts all comments without duplicates', () =>
|
|
333
336
|
deepEqual(body, expected))
|
|
334
337
|
}
|
|
335
338
|
|
|
336
339
|
async function testItBulkSelectsByComment(comment, tests) {
|
|
337
|
-
await
|
|
338
|
-
method: 'PATCH',
|
|
339
|
-
body: JSON.stringify(comment)
|
|
340
|
-
})
|
|
340
|
+
await commander.bulkSelectByComment(comment)
|
|
341
341
|
for (const [url, file, body] of tests)
|
|
342
342
|
await testMockDispatching(url, file, body)
|
|
343
343
|
}
|
|
@@ -346,7 +346,7 @@ async function testItBulkSelectsByComment(comment, tests) {
|
|
|
346
346
|
async function testItUpdatesUserRole() {
|
|
347
347
|
await describe('Cookie', () => {
|
|
348
348
|
it('Defaults to the first key:value', async () => {
|
|
349
|
-
const res = await
|
|
349
|
+
const res = await commander.listCookies()
|
|
350
350
|
deepEqual(await res.json(), [
|
|
351
351
|
['userA', true],
|
|
352
352
|
['userB', false]
|
|
@@ -354,11 +354,8 @@ async function testItUpdatesUserRole() {
|
|
|
354
354
|
})
|
|
355
355
|
|
|
356
356
|
it('Update the selected cookie', async () => {
|
|
357
|
-
await
|
|
358
|
-
|
|
359
|
-
body: JSON.stringify('userB')
|
|
360
|
-
})
|
|
361
|
-
const res = await request(API.cookies)
|
|
357
|
+
await commander.selectCookie('userB')
|
|
358
|
+
const res = await commander.listCookies()
|
|
362
359
|
deepEqual(await res.json(), [
|
|
363
360
|
['userA', false],
|
|
364
361
|
['userB', true]
|
|
@@ -374,7 +371,7 @@ export default function (req, response) {
|
|
|
374
371
|
response.setHeader('content-type', 'custom-mime')
|
|
375
372
|
return 'SOME_STRING'
|
|
376
373
|
}`)
|
|
377
|
-
await reset() // for registering the file
|
|
374
|
+
await commander.reset() // for registering the file
|
|
378
375
|
await testMockDispatching('/api/js-func',
|
|
379
376
|
'api/js-func.POST.200.js',
|
|
380
377
|
'SOME_STRING',
|
|
@@ -416,7 +413,7 @@ async function testInvalidFilenamesAreIgnored() {
|
|
|
416
413
|
write('api/_INVALID_FILENAME_CONVENTION_.json', '')
|
|
417
414
|
write('api/bad-filename-method._INVALID_METHOD_.200.json', '')
|
|
418
415
|
write('api/bad-filename-status.GET._INVALID_STATUS_.json', '')
|
|
419
|
-
await reset()
|
|
416
|
+
await commander.reset()
|
|
420
417
|
equal(consoleErrorSpy.mock.calls[0].arguments[0], 'Invalid Filename Convention')
|
|
421
418
|
equal(consoleErrorSpy.mock.calls[1].arguments[0], 'Unrecognized HTTP Method: "_INVALID_METHOD_"')
|
|
422
419
|
equal(consoleErrorSpy.mock.calls[2].arguments[0], 'Invalid HTTP Response Status: "NaN"')
|
|
@@ -432,10 +429,7 @@ async function testEnableFallbackSoRoutesWithoutMocksGetRelayed() {
|
|
|
432
429
|
})
|
|
433
430
|
await promisify(fallbackServer.listen).bind(fallbackServer, 0, '127.0.0.1')()
|
|
434
431
|
|
|
435
|
-
await
|
|
436
|
-
method: 'PATCH',
|
|
437
|
-
body: JSON.stringify(`http://localhost:${fallbackServer.address().port}`)
|
|
438
|
-
})
|
|
432
|
+
await commander.setProxyFallback(`http://localhost:${fallbackServer.address().port}`)
|
|
439
433
|
await it('Relays to fallback server', async () => {
|
|
440
434
|
const res = await request('/non-existing-mock')
|
|
441
435
|
equal(res.headers.get('custom_header'), 'my_custom_header')
|
|
@@ -446,6 +440,23 @@ async function testEnableFallbackSoRoutesWithoutMocksGetRelayed() {
|
|
|
446
440
|
})
|
|
447
441
|
}
|
|
448
442
|
|
|
443
|
+
// TODO make API for changing CORS? so we can automate testing?
|
|
444
|
+
async function testCorsAllowed() {
|
|
445
|
+
await it('cors', async () => {
|
|
446
|
+
const res = await request('/does-not-matter', {
|
|
447
|
+
method: 'OPTIONS',
|
|
448
|
+
headers: {
|
|
449
|
+
[PreflightHeader.Origin]: 'http://example.com',
|
|
450
|
+
[PreflightHeader.AccessControlRequestMethod]: 'GET'
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
equal(res.status, 204)
|
|
454
|
+
equal(res.headers.get(PreflightHeader.AccessControlAllowOrigin), 'http://example.com')
|
|
455
|
+
equal(res.headers.get(PreflightHeader.AccessControlAllowMethods), 'GET')
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
|
|
449
460
|
// Utils
|
|
450
461
|
|
|
451
462
|
function write(filename, data) {
|
|
@@ -461,8 +472,3 @@ function _write(absPath, data) {
|
|
|
461
472
|
writeFileSync(absPath, data, 'utf8')
|
|
462
473
|
}
|
|
463
474
|
|
|
464
|
-
function request(path, options = {}) {
|
|
465
|
-
const { address, port } = server.address()
|
|
466
|
-
return fetch(`http://${address}:${port}${path}`, options)
|
|
467
|
-
}
|
|
468
|
-
|
package/index.d.ts
CHANGED
|
@@ -15,9 +15,40 @@ interface Config {
|
|
|
15
15
|
extraHeaders?: [string, string][]
|
|
16
16
|
extraMimes?: { [fileExt: string]: string }
|
|
17
17
|
|
|
18
|
+
corsAllowed?: boolean,
|
|
19
|
+
corsOrigins: string[]
|
|
20
|
+
corsMethods: string[]
|
|
21
|
+
corsHeaders: string[]
|
|
22
|
+
corsExposedHeaders: string[]
|
|
23
|
+
corsCredentials: boolean
|
|
24
|
+
corsMaxAge: number
|
|
25
|
+
|
|
18
26
|
onReady?: (address: string) => void
|
|
19
27
|
}
|
|
20
28
|
|
|
29
|
+
|
|
21
30
|
export function Mockaton(options: Config): Server
|
|
22
31
|
|
|
32
|
+
|
|
23
33
|
export function jwtCookie(cookieName: string, payload: any): string
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
export class Commander {
|
|
37
|
+
constructor(addr: string)
|
|
38
|
+
|
|
39
|
+
select(file: string): Promise<Response>
|
|
40
|
+
|
|
41
|
+
bulkSelectByComment(comment: string): Promise<Response>
|
|
42
|
+
|
|
43
|
+
setRouteIsDelayed(routeMethod: string, routeUrlMask: string, delayed: boolean): Promise<Response>
|
|
44
|
+
|
|
45
|
+
selectCookie(cookieKey: string): Promise<Response>
|
|
46
|
+
|
|
47
|
+
setProxyFallback(proxyAddr: string): Promise<Response>
|
|
48
|
+
|
|
49
|
+
reset(): Promise<Response>
|
|
50
|
+
|
|
51
|
+
listCookies(): Promise<Response>
|
|
52
|
+
|
|
53
|
+
listComments(): Promise<Response>
|
|
54
|
+
}
|
package/index.js
CHANGED
package/package.json
CHANGED
package/src/Api.js
CHANGED
|
@@ -25,7 +25,8 @@ export const apiGetRequests = new Map([
|
|
|
25
25
|
])
|
|
26
26
|
|
|
27
27
|
export const apiPatchRequests = new Map([
|
|
28
|
-
[API.
|
|
28
|
+
[API.select, selectMock],
|
|
29
|
+
[API.delay, setRouteIsDelayed],
|
|
29
30
|
[API.reset, reinitialize],
|
|
30
31
|
[API.cookies, selectCookie],
|
|
31
32
|
[API.fallback, updateProxyFallback],
|
|
@@ -45,6 +46,7 @@ function reinitialize(_, response) {
|
|
|
45
46
|
sendOK(response)
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
|
|
48
50
|
async function selectCookie(req, response) {
|
|
49
51
|
try {
|
|
50
52
|
cookie.setCurrent(await parseJSON(req))
|
|
@@ -55,16 +57,14 @@ async function selectCookie(req, response) {
|
|
|
55
57
|
}
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
|
|
60
|
+
|
|
61
|
+
async function selectMock(req, response) {
|
|
59
62
|
try {
|
|
60
|
-
const
|
|
61
|
-
const file = body[DF.file]
|
|
63
|
+
const file = await parseJSON(req)
|
|
62
64
|
const broker = mockBrokersCollection.getBrokerByFilename(file)
|
|
63
65
|
if (!broker || !broker.mockExists(file))
|
|
64
66
|
throw `Missing Mock: ${file}`
|
|
65
67
|
|
|
66
|
-
if (DF.delayed in body)
|
|
67
|
-
broker.updateDelay(body[DF.delayed])
|
|
68
68
|
broker.updateFile(file)
|
|
69
69
|
sendOK(response)
|
|
70
70
|
}
|
|
@@ -73,6 +73,26 @@ async function updateBroker(req, response) {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
|
|
77
|
+
async function setRouteIsDelayed(req, response) {
|
|
78
|
+
try {
|
|
79
|
+
const body = await parseJSON(req)
|
|
80
|
+
const broker = mockBrokersCollection.getBrokerForUrl(
|
|
81
|
+
body[DF.routeMethod],
|
|
82
|
+
body[DF.routeUrlMask])
|
|
83
|
+
|
|
84
|
+
if (!broker)
|
|
85
|
+
throw `Route does not exist: ${body[DF.routeUrlMask]} ${body[DF.routeUrlMask]}`
|
|
86
|
+
|
|
87
|
+
broker.updateDelay(body[DF.delayed])
|
|
88
|
+
sendOK(response)
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
sendBadRequest(response, error)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
76
96
|
async function updateProxyFallback(req, response) {
|
|
77
97
|
try {
|
|
78
98
|
Config.proxyFallback = await parseJSON(req)
|
|
@@ -83,6 +103,7 @@ async function updateProxyFallback(req, response) {
|
|
|
83
103
|
}
|
|
84
104
|
}
|
|
85
105
|
|
|
106
|
+
|
|
86
107
|
async function bulkUpdateBrokersByCommentTag(req, response) {
|
|
87
108
|
try {
|
|
88
109
|
mockBrokersCollection.setMocksMatchingComment(await parseJSON(req))
|
package/src/ApiConstants.js
CHANGED
|
@@ -3,7 +3,8 @@ export const API = {
|
|
|
3
3
|
dashboard: MOUNT,
|
|
4
4
|
bulkSelect: MOUNT + '/bulk-select-by-comment',
|
|
5
5
|
comments: MOUNT + '/comments',
|
|
6
|
-
|
|
6
|
+
select: MOUNT + '/select',
|
|
7
|
+
delay: MOUNT + '/delay',
|
|
7
8
|
mocks: MOUNT + '/mocks',
|
|
8
9
|
reset: MOUNT + '/reset',
|
|
9
10
|
cookies: MOUNT + '/cookies',
|
|
@@ -11,8 +12,9 @@ export const API = {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export const DF = { // Dashboard Fields (XHR)
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
routeMethod: 'route_method',
|
|
16
|
+
routeUrlMask: 'route_url_mask',
|
|
17
|
+
delayed: 'delayed'
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export const DEFAULT_500_COMMENT = '(Mockaton 500)'
|
package/src/Commander.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { API, DF } from './ApiConstants.js'
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
export class Commander {
|
|
5
|
+
#addr = ''
|
|
6
|
+
constructor(addr) {
|
|
7
|
+
this.#addr = addr
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
select(file) {
|
|
11
|
+
return this.#patch(API.select, file)
|
|
12
|
+
}
|
|
13
|
+
bulkSelectByComment(comment) {
|
|
14
|
+
return this.#patch(API.bulkSelect, comment)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
setRouteIsDelayed(routeMethod, routeUrlMask, delayed) {
|
|
18
|
+
return this.#patch(API.delay, {
|
|
19
|
+
[DF.routeMethod]: routeMethod,
|
|
20
|
+
[DF.routeUrlMask]: routeUrlMask,
|
|
21
|
+
[DF.delayed]: delayed
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
listCookies() {
|
|
26
|
+
return this.#get(API.cookies)
|
|
27
|
+
}
|
|
28
|
+
selectCookie(cookieKey) {
|
|
29
|
+
return this.#patch(API.cookies, cookieKey)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
listComments() {
|
|
33
|
+
return this.#get(API.comments)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setProxyFallback(proxyAddr) {
|
|
37
|
+
return this.#patch(API.fallback, proxyAddr)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
reset() {
|
|
41
|
+
return this.#patch(API.reset)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
#get(api) {
|
|
46
|
+
return fetch(this.#addr + api)
|
|
47
|
+
}
|
|
48
|
+
#patch(api, body) {
|
|
49
|
+
return fetch(this.#addr + api, {
|
|
50
|
+
method: 'PATCH',
|
|
51
|
+
body: JSON.stringify(body)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/Config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { isDirectory } from './utils/fs.js'
|
|
1
2
|
import { openInBrowser } from './utils/openInBrowser.js'
|
|
3
|
+
import { StandardMethods } from './utils/http-request.js'
|
|
2
4
|
import { validate, is, optional } from './utils/validate.js'
|
|
3
|
-
import { isDirectory } from './utils/fs.js'
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
export const Config = Object.seal({
|
|
@@ -18,6 +19,14 @@ export const Config = Object.seal({
|
|
|
18
19
|
extraHeaders: [],
|
|
19
20
|
extraMimes: {},
|
|
20
21
|
|
|
22
|
+
corsAllowed: false,
|
|
23
|
+
corsOrigins: ['*'],
|
|
24
|
+
corsMethods: StandardMethods,
|
|
25
|
+
corsHeaders: [],
|
|
26
|
+
corsExposedHeaders: [],
|
|
27
|
+
corsCredentials: true,
|
|
28
|
+
corsMaxAge: 0,
|
|
29
|
+
|
|
21
30
|
onReady: openInBrowser
|
|
22
31
|
})
|
|
23
32
|
|
|
@@ -36,11 +45,39 @@ export function setup(options) {
|
|
|
36
45
|
|
|
37
46
|
delay: ms => Number.isInteger(ms) && ms > 0,
|
|
38
47
|
cookies: is(Object),
|
|
39
|
-
extraHeaders: Array.isArray,
|
|
48
|
+
extraHeaders: val => Array.isArray(val) && val.length % 2 === 0,
|
|
40
49
|
extraMimes: is(Object),
|
|
41
50
|
|
|
51
|
+
corsAllowed: is(Boolean),
|
|
52
|
+
corsOrigins: validateCorsAllowedOrigins,
|
|
53
|
+
corsMethods: validateCorsAllowedMethods,
|
|
54
|
+
corsHeaders: Array.isArray,
|
|
55
|
+
corsExposedHeaders: Array.isArray,
|
|
56
|
+
corsCredentials: is(Boolean),
|
|
57
|
+
corsMaxAge: is(Number),
|
|
58
|
+
|
|
42
59
|
onReady: is(Function)
|
|
43
60
|
})
|
|
61
|
+
|
|
62
|
+
if (!Config.corsAllowed) // TESTME
|
|
63
|
+
Config.corsOrigins = []
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
function validateCorsAllowedOrigins(arr) {
|
|
68
|
+
if (!Array.isArray(arr))
|
|
69
|
+
return false
|
|
70
|
+
|
|
71
|
+
if (arr.length === 1 && arr[0] === '*')
|
|
72
|
+
return true
|
|
73
|
+
|
|
74
|
+
return arr.every(o => URL.canParse(o))
|
|
44
75
|
}
|
|
45
76
|
|
|
46
77
|
|
|
78
|
+
function validateCorsAllowedMethods(arr) {
|
|
79
|
+
if (!Array.isArray(arr))
|
|
80
|
+
return false
|
|
81
|
+
|
|
82
|
+
return arr.every(m => StandardMethods.includes(m))
|
|
83
|
+
}
|
package/src/Dashboard.js
CHANGED
|
@@ -133,7 +133,7 @@ function SectionByMethod({ method, brokers }) {
|
|
|
133
133
|
r('tr', null,
|
|
134
134
|
r('td', null, r(PreviewLink, { method, urlMask })),
|
|
135
135
|
r('td', null, r(MockSelector, { broker })),
|
|
136
|
-
r('td', null, r(
|
|
136
|
+
r('td', null, r(DelayRouteToggler, { broker })),
|
|
137
137
|
r('td', null, r(InternalServerErrorToggler, { broker }))
|
|
138
138
|
))))
|
|
139
139
|
}
|
|
@@ -188,9 +188,9 @@ function MockSelector({ broker }) {
|
|
|
188
188
|
this.style.fontWeight = this.value === this.options[0].value // default is selected
|
|
189
189
|
? 'normal'
|
|
190
190
|
: 'bold'
|
|
191
|
-
fetch(API.
|
|
191
|
+
fetch(API.select, {
|
|
192
192
|
method: 'PATCH',
|
|
193
|
-
body: JSON.stringify(
|
|
193
|
+
body: JSON.stringify(this.value)
|
|
194
194
|
})
|
|
195
195
|
.then(() => {
|
|
196
196
|
this.closest('tr').querySelector('a').click()
|
|
@@ -205,7 +205,7 @@ function MockSelector({ broker }) {
|
|
|
205
205
|
}, file))))
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
function
|
|
208
|
+
function DelayRouteToggler({ broker }) {
|
|
209
209
|
const name = broker.currentMock.file
|
|
210
210
|
const checked = Boolean(broker.currentMock.delay)
|
|
211
211
|
return (
|
|
@@ -219,10 +219,12 @@ function DelayToggler({ broker }) {
|
|
|
219
219
|
name,
|
|
220
220
|
checked,
|
|
221
221
|
onChange(event) {
|
|
222
|
-
|
|
222
|
+
const { method, urlMask } = parseFilename(this.name)
|
|
223
|
+
fetch(API.delay, {
|
|
223
224
|
method: 'PATCH',
|
|
224
225
|
body: JSON.stringify({
|
|
225
|
-
[DF.
|
|
226
|
+
[DF.routeMethod]: method,
|
|
227
|
+
[DF.routeUrlMask]: urlMask,
|
|
226
228
|
[DF.delayed]: event.currentTarget.checked
|
|
227
229
|
})
|
|
228
230
|
})
|
|
@@ -252,13 +254,11 @@ function InternalServerErrorToggler({ broker }) {
|
|
|
252
254
|
name,
|
|
253
255
|
checked,
|
|
254
256
|
onChange(event) {
|
|
255
|
-
fetch(API.
|
|
257
|
+
fetch(API.select, {
|
|
256
258
|
method: 'PATCH',
|
|
257
|
-
body: JSON.stringify(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
: items[0]
|
|
261
|
-
})
|
|
259
|
+
body: JSON.stringify(event.currentTarget.checked
|
|
260
|
+
? items.find(f => parseFilename(f).status === 500)
|
|
261
|
+
: items[0])
|
|
262
262
|
})
|
|
263
263
|
.then(init)
|
|
264
264
|
.catch(console.error)
|
package/src/Mockaton.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createServer } from 'node:http'
|
|
2
2
|
|
|
3
3
|
import { API } from './ApiConstants.js'
|
|
4
|
-
import { Config, setup } from './Config.js'
|
|
5
4
|
import { dispatchMock } from './MockDispatcher.js'
|
|
5
|
+
import { Config, setup } from './Config.js'
|
|
6
6
|
import * as mockBrokerCollection from './mockBrokersCollection.js'
|
|
7
7
|
import { dispatchStatic, isStatic } from './StaticDispatcher.js'
|
|
8
|
+
import { setCorsHeaders, isPreflight } from './utils/http-cors.js'
|
|
8
9
|
import { apiPatchRequests, apiGetRequests } from './Api.js'
|
|
9
10
|
|
|
10
11
|
|
|
@@ -12,30 +13,48 @@ export function Mockaton(options) {
|
|
|
12
13
|
setup(options)
|
|
13
14
|
mockBrokerCollection.init()
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
const server = createServer(onRequest)
|
|
17
|
+
server.listen(Config.port, Config.host, (error) => {
|
|
18
|
+
const { address, port } = server.address()
|
|
19
|
+
const url = `http://${address}:${port}`
|
|
20
|
+
console.log('Listening', url)
|
|
21
|
+
console.log('Dashboard', url + API.dashboard)
|
|
22
|
+
if (error)
|
|
23
|
+
console.error(error)
|
|
24
|
+
else
|
|
25
|
+
Config.onReady(url + API.dashboard)
|
|
26
|
+
})
|
|
27
|
+
return server
|
|
28
|
+
}
|
|
17
29
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
30
|
+
async function onRequest(req, response) {
|
|
31
|
+
const { url, method } = req
|
|
32
|
+
response.setHeader('Server', 'Mockaton')
|
|
33
|
+
|
|
34
|
+
if (Config.corsAllowed)
|
|
35
|
+
setCorsHeaders(req, response, {
|
|
36
|
+
origins: Config.corsOrigins,
|
|
37
|
+
headers: Config.corsHeaders,
|
|
38
|
+
methods: Config.corsMethods,
|
|
39
|
+
maxAge: Config.corsMaxAge,
|
|
40
|
+
credentials: Config.corsCredentials,
|
|
41
|
+
exposedHeaders: Config.extraHeaders
|
|
42
|
+
})
|
|
21
43
|
|
|
22
|
-
else if (method === 'PATCH' && apiPatchRequests.has(url))
|
|
23
|
-
await apiPatchRequests.get(url)(req, response)
|
|
24
44
|
|
|
25
|
-
|
|
26
|
-
|
|
45
|
+
if (isPreflight(req)) {
|
|
46
|
+
response.statusCode = 204
|
|
47
|
+
response.end()
|
|
48
|
+
}
|
|
49
|
+
else if (method === 'GET' && apiGetRequests.has(url))
|
|
50
|
+
apiGetRequests.get(url)(req, response)
|
|
27
51
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (error)
|
|
37
|
-
console.error(error)
|
|
38
|
-
else
|
|
39
|
-
Config.onReady(url + API.dashboard)
|
|
40
|
-
})
|
|
52
|
+
else if (method === 'PATCH' && apiPatchRequests.has(url))
|
|
53
|
+
await apiPatchRequests.get(url)(req, response)
|
|
54
|
+
|
|
55
|
+
else if (isStatic(req))
|
|
56
|
+
await dispatchStatic(req, response)
|
|
57
|
+
|
|
58
|
+
else
|
|
59
|
+
await dispatchMock(req, response)
|
|
41
60
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { StandardMethods } from './http-request.js'
|
|
2
|
+
|
|
3
|
+
// https://www.w3.org/TR/2020/SPSD-cors-20200602/#resource-processing-model
|
|
4
|
+
|
|
5
|
+
export const PreflightHeader = {
|
|
6
|
+
// request
|
|
7
|
+
Origin: 'origin',
|
|
8
|
+
AccessControlRequestMethod: 'access-control-request-method',
|
|
9
|
+
AccessControlRequestHeaders: 'access-control-request-headers', // Comma separated
|
|
10
|
+
|
|
11
|
+
// response
|
|
12
|
+
AccessControlMaxAge: 'Access-Control-Max-Age',
|
|
13
|
+
AccessControlAllowOrigin: 'Access-Control-Allow-Origin', // '*' | Space delimited | null
|
|
14
|
+
AccessControlAllowMethods: 'Access-Control-Allow-Methods', // '*' | Comma delimited
|
|
15
|
+
AccessControlAllowHeaders: 'Access-Control-Allow-Headers', // '*' | Comma delimited
|
|
16
|
+
AccessControlExposeHeaders: 'Access-Control-Expose-Headers', // '*' | Comma delimited
|
|
17
|
+
AccessControlAllowCredentials: 'Access-Control-Allow-Credentials' // 'true'
|
|
18
|
+
}
|
|
19
|
+
const PH = PreflightHeader
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
export function isPreflight(req) {
|
|
23
|
+
return req.method === 'OPTIONS'
|
|
24
|
+
&& URL.canParse(req.headers[PH.Origin])
|
|
25
|
+
&& StandardMethods.includes(req.headers[PH.AccessControlRequestMethod])
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
export function setCorsHeaders(req, response, {
|
|
30
|
+
origins = [],
|
|
31
|
+
methods = [],
|
|
32
|
+
headers = [],
|
|
33
|
+
exposedHeaders = [],
|
|
34
|
+
credentials = false,
|
|
35
|
+
maxAge = 0
|
|
36
|
+
}) {
|
|
37
|
+
const reqOrigin = req.headers[PH.Origin]
|
|
38
|
+
const hasWildcard = origins.some(ao => ao === '*')
|
|
39
|
+
if (!reqOrigin || (!hasWildcard && !origins.includes(reqOrigin)))
|
|
40
|
+
return
|
|
41
|
+
response.setHeader(PH.AccessControlAllowOrigin, reqOrigin) // Never '*', so no need to `Vary` it
|
|
42
|
+
|
|
43
|
+
if (credentials)
|
|
44
|
+
response.setHeader(PH.AccessControlAllowCredentials, 'true')
|
|
45
|
+
|
|
46
|
+
if (req.headers[PH.AccessControlRequestMethod])
|
|
47
|
+
setPreflightSpecificHeaders(req, response, methods, headers, maxAge)
|
|
48
|
+
else
|
|
49
|
+
setActualRequestHeaders(response, exposedHeaders)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
function setPreflightSpecificHeaders(req, response, methods, headers, maxAge) {
|
|
54
|
+
const methodAskingFor = req.headers[PH.AccessControlRequestMethod]
|
|
55
|
+
if (!methods.includes(methodAskingFor))
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
response.setHeader(PH.AccessControlAllowMethods, methodAskingFor)
|
|
59
|
+
if (headers.length)
|
|
60
|
+
response.setHeader(PH.AccessControlAllowHeaders, headers.join(','))
|
|
61
|
+
|
|
62
|
+
response.setHeader(PH.AccessControlMaxAge, maxAge)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
function setActualRequestHeaders(response, exposedHeaders) {
|
|
67
|
+
// Exposed means the client-side JavaScript can read them
|
|
68
|
+
if (exposedHeaders.length)
|
|
69
|
+
response.setHeader(PH.AccessControlExposeHeaders, exposedHeaders.join(','))
|
|
70
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { equal } from 'node:assert/strict'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import { createServer } from 'node:http'
|
|
4
|
+
import { describe, it, after } from 'node:test'
|
|
5
|
+
import { isPreflight, setCorsHeaders, PreflightHeader as PH } from './http-cors.js'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
function headerIs(response, header, value) {
|
|
9
|
+
equal(response.headers.get(header), value)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const FooDotCom = 'http://foo.com'
|
|
13
|
+
const AllowedDotCom = 'http://allowed.com'
|
|
14
|
+
const NotAllowedDotCom = 'http://not-allowed.com'
|
|
15
|
+
|
|
16
|
+
await describe('CORS', async () => {
|
|
17
|
+
let corsAllow = {}
|
|
18
|
+
|
|
19
|
+
const server = createServer((req, response) => {
|
|
20
|
+
if (isPreflight(req)) {
|
|
21
|
+
setCorsHeaders(req, response, corsAllow)
|
|
22
|
+
response.statusCode = 204
|
|
23
|
+
response.end()
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
response.end('NON_PREFLIGHT')
|
|
27
|
+
})
|
|
28
|
+
await promisify(server.listen).bind(server, 0, '127.0.0.1')()
|
|
29
|
+
after(() => {
|
|
30
|
+
server.close()
|
|
31
|
+
})
|
|
32
|
+
function preflight(headers, method = 'OPTIONS') {
|
|
33
|
+
const { address, port } = server.address()
|
|
34
|
+
return fetch(`http://${address}:${port}/`, { method, headers })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await describe('Identifies Preflight Requests', async () => {
|
|
38
|
+
const requiredRequestHeaders = {
|
|
39
|
+
[PH.Origin]: 'http://locahost:9999',
|
|
40
|
+
[PH.AccessControlRequestMethod]: 'POST'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await it('Ignores non-OPTIONS requests', async () => {
|
|
44
|
+
const res = await preflight(requiredRequestHeaders, 'POST')
|
|
45
|
+
equal(await res.text(), 'NON_PREFLIGHT')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
await it(`Ignores non-parseable req ${PH.Origin} header`, async () => {
|
|
49
|
+
const headers = {
|
|
50
|
+
...requiredRequestHeaders,
|
|
51
|
+
[PH.Origin]: 'non-url'
|
|
52
|
+
}
|
|
53
|
+
const res = await preflight(headers)
|
|
54
|
+
equal(await res.text(), 'NON_PREFLIGHT')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
await it(`Ignores missing method in ${PH.AccessControlRequestMethod} header`, async () => {
|
|
58
|
+
const headers = { ...requiredRequestHeaders }
|
|
59
|
+
delete headers[PH.AccessControlRequestMethod]
|
|
60
|
+
const res = await preflight(headers)
|
|
61
|
+
equal(await res.text(), 'NON_PREFLIGHT')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
await it(`Ignores non-standard method in ${PH.AccessControlRequestMethod} header`, async () => {
|
|
65
|
+
const headers = {
|
|
66
|
+
...requiredRequestHeaders,
|
|
67
|
+
[PH.AccessControlRequestMethod]: 'NON_STANDARD'
|
|
68
|
+
}
|
|
69
|
+
const res = await preflight(headers)
|
|
70
|
+
equal(await res.text(), 'NON_PREFLIGHT')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
await it('204 valid preflights', async () => {
|
|
74
|
+
const res = await preflight(requiredRequestHeaders)
|
|
75
|
+
equal(res.status, 204)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
await describe('Preflight Response Headers', async () => {
|
|
80
|
+
await it('no origins allowed', async () => {
|
|
81
|
+
corsAllow = {
|
|
82
|
+
origins: [],
|
|
83
|
+
methods: ['GET']
|
|
84
|
+
}
|
|
85
|
+
const p = await preflight({
|
|
86
|
+
[PH.Origin]: FooDotCom,
|
|
87
|
+
[PH.AccessControlRequestMethod]: 'GET'
|
|
88
|
+
})
|
|
89
|
+
headerIs(p, PH.AccessControlAllowOrigin, null)
|
|
90
|
+
headerIs(p, PH.AccessControlAllowMethods, null)
|
|
91
|
+
headerIs(p, PH.AccessControlAllowCredentials, null)
|
|
92
|
+
headerIs(p, PH.AccessControlAllowHeaders, null)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
await it('not in allowed origins', async () => {
|
|
96
|
+
corsAllow = {
|
|
97
|
+
origins: [AllowedDotCom],
|
|
98
|
+
methods: ['GET']
|
|
99
|
+
}
|
|
100
|
+
const p = await preflight({
|
|
101
|
+
[PH.Origin]: NotAllowedDotCom,
|
|
102
|
+
[PH.AccessControlRequestMethod]: 'GET'
|
|
103
|
+
})
|
|
104
|
+
headerIs(p, PH.AccessControlAllowOrigin, null)
|
|
105
|
+
headerIs(p, PH.AccessControlAllowMethods, null)
|
|
106
|
+
headerIs(p, PH.AccessControlAllowCredentials, null)
|
|
107
|
+
headerIs(p, PH.AccessControlAllowHeaders, null)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
await it('origin and method match', async () => {
|
|
111
|
+
corsAllow = {
|
|
112
|
+
origins: [AllowedDotCom],
|
|
113
|
+
methods: ['GET']
|
|
114
|
+
}
|
|
115
|
+
const p = await preflight({
|
|
116
|
+
[PH.Origin]: AllowedDotCom,
|
|
117
|
+
[PH.AccessControlRequestMethod]: 'GET'
|
|
118
|
+
})
|
|
119
|
+
headerIs(p, PH.AccessControlAllowOrigin, AllowedDotCom)
|
|
120
|
+
headerIs(p, PH.AccessControlAllowMethods, 'GET')
|
|
121
|
+
headerIs(p, PH.AccessControlAllowCredentials, null)
|
|
122
|
+
headerIs(p, PH.AccessControlAllowHeaders, null)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
await it('origin matches from multiple', async () => {
|
|
126
|
+
corsAllow = {
|
|
127
|
+
origins: [AllowedDotCom, FooDotCom],
|
|
128
|
+
methods: ['GET']
|
|
129
|
+
}
|
|
130
|
+
const p = await preflight({
|
|
131
|
+
[PH.Origin]: AllowedDotCom,
|
|
132
|
+
[PH.AccessControlRequestMethod]: 'GET'
|
|
133
|
+
})
|
|
134
|
+
headerIs(p, PH.AccessControlAllowOrigin, AllowedDotCom)
|
|
135
|
+
headerIs(p, PH.AccessControlAllowMethods, 'GET')
|
|
136
|
+
headerIs(p, PH.AccessControlAllowCredentials, null)
|
|
137
|
+
headerIs(p, PH.AccessControlAllowHeaders, null)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
await it('wildcard origin', async () => {
|
|
141
|
+
corsAllow = {
|
|
142
|
+
origins: ['*'],
|
|
143
|
+
methods: ['GET']
|
|
144
|
+
}
|
|
145
|
+
const p = await preflight({
|
|
146
|
+
[PH.Origin]: FooDotCom,
|
|
147
|
+
[PH.AccessControlRequestMethod]: 'GET'
|
|
148
|
+
})
|
|
149
|
+
headerIs(p, PH.AccessControlAllowOrigin, FooDotCom)
|
|
150
|
+
headerIs(p, PH.AccessControlAllowMethods, 'GET')
|
|
151
|
+
headerIs(p, PH.AccessControlAllowCredentials, null)
|
|
152
|
+
headerIs(p, PH.AccessControlAllowHeaders, null)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
await it(`wildcard and credentials`, async () => {
|
|
156
|
+
corsAllow = {
|
|
157
|
+
origins: ['*'],
|
|
158
|
+
methods: ['GET'],
|
|
159
|
+
credentials: true
|
|
160
|
+
}
|
|
161
|
+
const p = await preflight({
|
|
162
|
+
[PH.Origin]: FooDotCom,
|
|
163
|
+
[PH.AccessControlRequestMethod]: 'GET'
|
|
164
|
+
})
|
|
165
|
+
headerIs(p, PH.AccessControlAllowOrigin, FooDotCom)
|
|
166
|
+
headerIs(p, PH.AccessControlAllowMethods, 'GET')
|
|
167
|
+
headerIs(p, PH.AccessControlAllowCredentials, 'true')
|
|
168
|
+
headerIs(p, PH.AccessControlAllowHeaders, null)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
await it(`wildcard, credentials, and headers`, async () => {
|
|
172
|
+
corsAllow = {
|
|
173
|
+
origins: ['*'],
|
|
174
|
+
methods: ['GET'],
|
|
175
|
+
credentials: true,
|
|
176
|
+
headers: ['content-type', 'my-header']
|
|
177
|
+
}
|
|
178
|
+
const p = await preflight({
|
|
179
|
+
[PH.Origin]: FooDotCom,
|
|
180
|
+
[PH.AccessControlRequestMethod]: 'GET'
|
|
181
|
+
})
|
|
182
|
+
headerIs(p, PH.AccessControlAllowOrigin, FooDotCom)
|
|
183
|
+
headerIs(p, PH.AccessControlAllowMethods, 'GET')
|
|
184
|
+
headerIs(p, PH.AccessControlAllowCredentials, 'true')
|
|
185
|
+
headerIs(p, PH.AccessControlAllowHeaders, 'content-type,my-header')
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// TODO Actual request response headers
|
|
190
|
+
})
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export const StandardMethods = [
|
|
2
|
+
'GET', 'PUT', 'DELETE', 'POST', 'PATCH',
|
|
3
|
+
'HEAD', 'OPTIONS', 'TRACE', 'CONNECT'
|
|
4
|
+
]
|
|
5
|
+
|
|
6
|
+
|
|
1
7
|
export class JsonBodyParserError extends Error {}
|
|
2
8
|
|
|
3
9
|
export function parseJSON(req) {
|
|
@@ -33,4 +39,4 @@ export function parseJSON(req) {
|
|
|
33
39
|
}
|
|
34
40
|
}
|
|
35
41
|
})
|
|
36
|
-
}
|
|
42
|
+
}
|