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 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.
@@ -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 edit mock files without resetting Mockaton. The _Reset_
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
- ### Select a mock for a route
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
- fetch(addr + '/mockaton/edit', {
296
- method: 'PATCH',
297
- body: JSON.stringify({
298
- file: 'api/foo.200.GET.json',
299
- delayed: true // optional
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
- fetch(addr + '/mockaton/bulk-select-by-comment', {
307
- method: 'PATCH',
308
- body: JSON.stringify('(demo-a)')
309
- })
324
+ await mockaton.bulkSelectByComment('(demo-a)')
310
325
  ```
311
326
 
312
- ### List Cookies
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
- fetch(addr + '/mockaton/cookies')
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 changing it.
333
+ In `Config.cookies`, each key is the label used for selecting it.
320
334
  ```js
321
- fetch(addr + '/mockaton/cookies', {
322
- method: 'PATCH',
323
- body: JSON.stringify('My Normal User')
324
- })
335
+ await mockaton.selectCookie('My Normal User')
325
336
  ```
326
337
 
327
- ### Update Fallback Proxy
338
+ ### Set Fallback Proxy
328
339
  ```js
329
- fetch(addr + '/mockaton/fallback', {
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.proxyFalllback` is not affected.
347
+ back to default, but `Config.proxyFallback` is not affected.
339
348
  ```js
340
- fetch(addr + '/mockaton/reset', {
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 { API, DF, DEFAULT_500_COMMENT, DEFAULT_MOCK_COMMENT } from './src/ApiConstants.js'
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 testItUpdatesDelayAndFile(
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 request(API.edit, {
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 testItUpdatesDelayAndFile(url, file, expectedBody) {
275
- await request(API.edit, {
276
- method: 'PATCH',
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 request(API.edit, {
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 request(API.edit, {
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 request(API.edit, {
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 request(API.comments)
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 request(API.bulkSelect, {
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 request(API.cookies)
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 request(API.cookies, {
358
- method: 'PATCH',
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 request(API.fallback, { // Enable fallback
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
@@ -1,2 +1,3 @@
1
1
  export { Mockaton } from './src/Mockaton.js'
2
2
  export { jwtCookie } from './src/utils/jwt.js'
3
+ export { Commander } from './src/Commander.js'
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": "6.4.7",
5
+ "version": "7.1.0",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
8
8
  "license": "MIT",
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.edit, updateBroker],
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
- async function updateBroker(req, response) {
60
+
61
+ async function selectMock(req, response) {
59
62
  try {
60
- const body = await parseJSON(req)
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))
@@ -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
- edit: MOUNT + '/edit',
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
- delayed: 'delayed',
15
- file: 'file'
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)'
@@ -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(DelayToggler, { broker })),
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.edit, {
191
+ fetch(API.select, {
192
192
  method: 'PATCH',
193
- body: JSON.stringify({ [DF.file]: this.value })
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 DelayToggler({ broker }) {
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
- fetch(API.edit, {
222
+ const { method, urlMask } = parseFilename(this.name)
223
+ fetch(API.delay, {
223
224
  method: 'PATCH',
224
225
  body: JSON.stringify({
225
- [DF.file]: this.name,
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.edit, {
257
+ fetch(API.select, {
256
258
  method: 'PATCH',
257
- body: JSON.stringify({
258
- [DF.file]: event.currentTarget.checked
259
- ? items.find(f => parseFilename(f).status === 500)
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
- return createServer(async (req, response) => {
16
- response.setHeader('Server', 'Mockaton')
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
- const { url, method } = req
19
- if (method === 'GET' && apiGetRequests.has(url))
20
- apiGetRequests.get(url)(req, response)
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
- else if (isStatic(req))
26
- await dispatchStatic(req, response)
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
- else
29
- await dispatchMock(req, response)
30
- })
31
- .listen(Config.port, Config.host, function (error) {
32
- const { address, port } = this.address()
33
- const url = `http://${address}:${port}`
34
- console.log('Listening', url)
35
- console.log('Dashboard', url + API.dashboard)
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
+ }