mockaton 7.0.0 → 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 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
- ## What do I use it for?
16
- - I’m a frontend dev, so I don’t have to spin up and maintain hefty or complex backends.
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.
@@ -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.
@@ -296,32 +312,32 @@ import { Commander } from 'mockaton'
296
312
 
297
313
 
298
314
  const myMockatonAddr = 'http://localhost:2345'
299
- const commander = new Commander(myMockatonAddr)
315
+ const mockaton = new Commander(myMockatonAddr)
300
316
  ```
301
317
 
302
318
  ### Select a mock file for a route
303
319
  ```js
304
- await commander.select('api/foo.200.GET.json')
320
+ await mockaton.select('api/foo.200.GET.json')
305
321
  ```
306
322
  ### Select all mocks that have a particular comment
307
323
  ```js
308
- await commander.bulkSelectByComment('(demo-a)')
324
+ await mockaton.bulkSelectByComment('(demo-a)')
309
325
  ```
310
326
 
311
327
  ### Set Route is Delayed Flag
312
328
  ```js
313
- await commander.setRouteIsDelayed('GET', '/api/foo', true)
329
+ await mockaton.setRouteIsDelayed('GET', '/api/foo', true)
314
330
  ```
315
331
 
316
332
  ### Select a cookie
317
333
  In `Config.cookies`, each key is the label used for selecting it.
318
334
  ```js
319
- await commander.selectCookie('My Normal User')
335
+ await mockaton.selectCookie('My Normal User')
320
336
  ```
321
337
 
322
338
  ### Set Fallback Proxy
323
339
  ```js
324
- await commander.setProxyFallback('http://example.com')
340
+ await mockaton.setProxyFallback('http://example.com')
325
341
  ```
326
342
  Pass an empty string to disable it.
327
343
 
@@ -330,7 +346,7 @@ Re-initialize the collection. So if you added or removed mocks they
330
346
  will be considered. The selected mocks, cookies, and delays go
331
347
  back to default, but `Config.proxyFallback` is not affected.
332
348
  ```js
333
- await commander.reset()
349
+ await mockaton.reset()
334
350
  ```
335
351
 
336
352
 
package/Tests.js CHANGED
@@ -13,6 +13,7 @@ import { mimeFor } from './src/utils/mime.js'
13
13
  import { Mockaton } from './src/Mockaton.js'
14
14
  import { Commander } from './src/Commander.js'
15
15
  import { parseFilename } from './src/Filename.js'
16
+ import { PreflightHeader } from './src/utils/http-cors.js'
16
17
  import { API, DEFAULT_500_COMMENT, DEFAULT_MOCK_COMMENT } from './src/ApiConstants.js'
17
18
 
18
19
 
@@ -157,7 +158,9 @@ const server = Mockaton({
157
158
  extraHeaders: ['Server', 'MockatonTester'],
158
159
  extraMimes: {
159
160
  my_custom_extension: 'my_custom_mime'
160
- }
161
+ },
162
+ corsAllowed: true,
163
+ corsOrigins: ['http://example.com']
161
164
  })
162
165
  server.on('listening', runTests)
163
166
 
@@ -223,10 +226,12 @@ async function runTests() {
223
226
  await testMockDispatching(...fixtureCustomMime, 'my_custom_mime')
224
227
  await testJsFunctionMocks()
225
228
 
229
+ await testCorsAllowed()
226
230
  await testItUpdatesUserRole()
227
231
  await testStaticFileServing()
228
232
  await testInvalidFilenamesAreIgnored()
229
233
  await testEnableFallbackSoRoutesWithoutMocksGetRelayed()
234
+
230
235
  server.close()
231
236
  }
232
237
 
@@ -435,6 +440,23 @@ async function testEnableFallbackSoRoutesWithoutMocksGetRelayed() {
435
440
  })
436
441
  }
437
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
+
438
460
  // Utils
439
461
 
440
462
  function write(filename, data) {
package/index.d.ts CHANGED
@@ -15,6 +15,14 @@ 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
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "mockaton",
3
3
  "description": "A deterministic server-side for developing and testing frontend clients",
4
4
  "type": "module",
5
- "version": "7.0.0",
5
+ "version": "7.1.0",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
8
8
  "license": "MIT",
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/Mockaton.js CHANGED
@@ -1,16 +1,18 @@
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
 
11
12
  export function Mockaton(options) {
12
13
  setup(options)
13
14
  mockBrokerCollection.init()
15
+
14
16
  const server = createServer(onRequest)
15
17
  server.listen(Config.port, Config.host, (error) => {
16
18
  const { address, port } = server.address()
@@ -26,10 +28,25 @@ export function Mockaton(options) {
26
28
  }
27
29
 
28
30
  async function onRequest(req, response) {
31
+ const { url, method } = req
29
32
  response.setHeader('Server', 'Mockaton')
30
33
 
31
- const { url, method } = req
32
- if (method === 'GET' && apiGetRequests.has(url))
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
+ })
43
+
44
+
45
+ if (isPreflight(req)) {
46
+ response.statusCode = 204
47
+ response.end()
48
+ }
49
+ else if (method === 'GET' && apiGetRequests.has(url))
33
50
  apiGetRequests.get(url)(req, response)
34
51
 
35
52
  else if (method === 'PATCH' && apiPatchRequests.has(url))
@@ -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
+ }