mockaton 8.3.1 → 8.3.3

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
@@ -51,7 +51,7 @@ import { Mockaton } from 'mockaton'
51
51
 
52
52
  // See the Config section for more options
53
53
  Mockaton({
54
- mocksDir: resolve('my-mocks-dir'),
54
+ mocksDir: resolve('my-mocks-dir'), // must exist
55
55
  port: 2345
56
56
  })
57
57
  ```
package/index.d.ts CHANGED
@@ -11,9 +11,8 @@ type Plugin = (
11
11
 
12
12
  interface Config {
13
13
  mocksDir: string
14
- ignore?: RegExp
15
-
16
14
  staticDir?: string
15
+ ignore?: RegExp
17
16
 
18
17
  host?: string,
19
18
  port?: number
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": "8.3.1",
5
+ "version": "8.3.3",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
8
8
  "license": "MIT",
package/src/Api.js CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { join } from 'node:path'
7
7
  import { cookie } from './cookie.js'
8
- import { Config } from './Config.js'
8
+ import { config } from './config.js'
9
9
  import { DF, API } from './ApiConstants.js'
10
10
  import { parseJSON } from './utils/http-request.js'
11
11
  import { listFilesRecursively } from './utils/fs.js'
@@ -35,11 +35,12 @@ export const apiPatchRequests = new Map([
35
35
  [API.reset, reinitialize],
36
36
  [API.cookies, selectCookie],
37
37
  [API.fallback, updateProxyFallback],
38
+ [API.collectProxied, setCollectProxied],
38
39
  [API.bulkSelect, bulkUpdateBrokersByCommentTag],
39
40
  [API.cors, setCorsAllowed]
40
41
  ])
41
42
 
42
- /* GET */
43
+ /* === GET === */
43
44
 
44
45
  function serveDashboard(_, response) { sendFile(response, join(import.meta.dirname, 'Dashboard.html')) }
45
46
  function serveDashboardAsset(req, response) { sendFile(response, join(import.meta.dirname, req.url)) }
@@ -47,13 +48,13 @@ function serveDashboardAsset(req, response) { sendFile(response, join(import.met
47
48
  function listCookies(_, response) { sendJSON(response, cookie.list()) }
48
49
  function listComments(_, response) { sendJSON(response, mockBrokersCollection.extractAllComments()) }
49
50
  function listMockBrokers(_, response) { sendJSON(response, mockBrokersCollection.getAll()) }
50
- function getProxyFallback(_, response) { sendJSON(response, Config.proxyFallback) }
51
- function getIsCorsAllowed(_, response) { sendJSON(response, Config.corsAllowed) }
51
+ function getProxyFallback(_, response) { sendJSON(response, config.proxyFallback) }
52
+ function getIsCorsAllowed(_, response) { sendJSON(response, config.corsAllowed) }
52
53
 
53
- async function listStaticFiles(req, response) { // TESTME
54
+ function listStaticFiles(req, response) {
54
55
  try {
55
- const files = Config.staticDir
56
- ? listFilesRecursively(Config.staticDir).filter(f => !Config.ignore.test(f))
56
+ const files = config.staticDir
57
+ ? listFilesRecursively(config.staticDir).filter(f => !config.ignore.test(f))
57
58
  : []
58
59
  sendJSON(response, files)
59
60
  }
@@ -63,7 +64,7 @@ async function listStaticFiles(req, response) { // TESTME
63
64
  }
64
65
 
65
66
 
66
- /* PATCH */
67
+ /* === PATCH === */
67
68
 
68
69
  function reinitialize(_, response) {
69
70
  mockBrokersCollection.init()
@@ -72,8 +73,11 @@ function reinitialize(_, response) {
72
73
 
73
74
  async function selectCookie(req, response) {
74
75
  try {
75
- cookie.setCurrent(await parseJSON(req))
76
- sendOK(response)
76
+ const error = cookie.setCurrent(await parseJSON(req))
77
+ if (error)
78
+ sendUnprocessableContent(response, error)
79
+ else
80
+ sendOK(response)
77
81
  }
78
82
  catch (error) {
79
83
  sendBadRequest(response, error)
@@ -113,10 +117,10 @@ async function setRouteIsDelayed(req, response) {
113
117
  async function updateProxyFallback(req, response) {
114
118
  try {
115
119
  const fallback = await parseJSON(req)
116
- if (fallback && !URL.canParse(fallback)) // TESTME
120
+ if (fallback && !URL.canParse(fallback))
117
121
  sendUnprocessableContent(response)
118
122
  else {
119
- Config.proxyFallback = fallback
123
+ config.proxyFallback = fallback
120
124
  sendOK(response)
121
125
  }
122
126
  }
@@ -125,6 +129,16 @@ async function updateProxyFallback(req, response) {
125
129
  }
126
130
  }
127
131
 
132
+ async function setCollectProxied(req, response) {
133
+ try {
134
+ config.collectProxied = await parseJSON(req)
135
+ sendOK(response)
136
+ }
137
+ catch (error) {
138
+ sendBadRequest(response, error)
139
+ }
140
+ }
141
+
128
142
  async function bulkUpdateBrokersByCommentTag(req, response) {
129
143
  try {
130
144
  mockBrokersCollection.setMocksMatchingComment(await parseJSON(req))
@@ -137,7 +151,7 @@ async function bulkUpdateBrokersByCommentTag(req, response) {
137
151
 
138
152
  async function setCorsAllowed(req, response) {
139
153
  try {
140
- Config.corsAllowed = await parseJSON(req)
154
+ config.corsAllowed = await parseJSON(req)
141
155
  sendOK(response)
142
156
  }
143
157
  catch (error) {
@@ -9,6 +9,7 @@ export const API = {
9
9
  reset: MOUNT + '/reset',
10
10
  cookies: MOUNT + '/cookies',
11
11
  fallback: MOUNT + '/fallback',
12
+ collectProxied: MOUNT + '/collect-proxied',
12
13
  cors: MOUNT + '/cors',
13
14
  static: MOUNT + '/static'
14
15
  }
@@ -21,5 +22,4 @@ export const DF = { // Dashboard Fields (XHR)
21
22
 
22
23
  export const DEFAULT_500_COMMENT = '(Mockaton 500)'
23
24
  export const DEFAULT_MOCK_COMMENT = '(default)'
24
-
25
25
  export const EXT_FOR_UNKNOWN_MIME = 'unknown'
package/src/Commander.js CHANGED
@@ -54,6 +54,10 @@ export class Commander {
54
54
  return this.#patch(API.fallback, proxyAddr)
55
55
  }
56
56
 
57
+ setCollectProxied(shouldCollect) {
58
+ return this.#patch(API.collectProxied, shouldCollect)
59
+ }
60
+
57
61
  reset() {
58
62
  return this.#patch(API.reset)
59
63
  }
package/src/Dashboard.css CHANGED
@@ -54,6 +54,13 @@ body {
54
54
  margin: 0;
55
55
  font-family: system-ui, sans-serif;
56
56
  font-size: 100%;
57
+ outline: 0
58
+ }
59
+
60
+ select, a, input, button, summary {
61
+ &:focus-visible {
62
+ outline: 2px solid var(--colorAccent);
63
+ }
57
64
  }
58
65
 
59
66
  select {
@@ -116,7 +123,6 @@ menu {
116
123
  }
117
124
 
118
125
  input[type=url] {
119
- outline: 0;
120
126
  padding: 0 6px;
121
127
  box-shadow: var(--boxShadow1);
122
128
  color: var(--colorText);
@@ -244,7 +250,14 @@ main {
244
250
  cursor: pointer;
245
251
 
246
252
  > input {
247
- display: none;
253
+ appearance: none;
254
+
255
+ &:focus-visible {
256
+ outline: 0;
257
+ & ~ svg {
258
+ outline: 2px solid var(--colorAccent)
259
+ }
260
+ }
248
261
 
249
262
  &:checked ~ svg {
250
263
  background: var(--colorAccent);
@@ -274,7 +287,14 @@ main {
274
287
  cursor: pointer;
275
288
 
276
289
  > input {
277
- display: none;
290
+ appearance: none;
291
+
292
+ &:focus-visible {
293
+ outline: 0;
294
+ & ~ span {
295
+ outline: 2px solid var(--colorAccent)
296
+ }
297
+ }
278
298
 
279
299
  &:checked ~ span {
280
300
  color: white;
@@ -331,18 +351,25 @@ main {
331
351
  margin-top: 40px;
332
352
 
333
353
  summary {
354
+ width: max-content;
334
355
  margin-bottom: 8px;
335
356
  cursor: pointer;
336
357
  font-weight: bold;
337
358
  }
338
359
 
360
+ ul {
361
+ position: relative;
362
+ left: -6px;
363
+ }
364
+
339
365
  li {
340
366
  list-style: none;
341
367
  }
342
368
 
343
369
  a {
344
370
  display: inline-block;
345
- padding: 6px 0;
371
+ border-radius: 6px;
372
+ padding: 6px;
346
373
  color: var(--colorAccentAlt);
347
374
  text-decoration: none;
348
375
 
package/src/Dashboard.js CHANGED
@@ -138,7 +138,6 @@ function ProxyFallbackField({ fallbackAddress = '' }) {
138
138
  mockaton.setProxyFallback(input.value)
139
139
  .catch(onError)
140
140
  }
141
-
142
141
  return (
143
142
  r('label', null,
144
143
  r('span', null, Strings.fallback_server),
package/src/MockBroker.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Config } from './Config.js'
1
+ import { config } from './config.js'
2
2
  import { DEFAULT_500_COMMENT, DEFAULT_MOCK_COMMENT } from './ApiConstants.js'
3
3
  import { includesComment, extractComments, parseFilename } from './Filename.js'
4
4
 
@@ -38,7 +38,7 @@ export class MockBroker {
38
38
 
39
39
  selectDefaultFile() {
40
40
  const userSpecifiedDefault = this.#findMockWithDefaultComment()
41
- if (userSpecifiedDefault) // Sort for dashboard list TESTME
41
+ if (userSpecifiedDefault) // Sort for dashboard list
42
42
  this.mocks = [
43
43
  userSpecifiedDefault,
44
44
  ...this.mocks.filter(m => m !== userSpecifiedDefault)
@@ -53,7 +53,7 @@ export class MockBroker {
53
53
 
54
54
  mockExists(file) { return this.mocks.includes(file) }
55
55
  updateFile(filename) { this.currentMock.file = filename }
56
- updateDelay(delayed) { this.currentMock.delay = Number(delayed) * Config.delay }
56
+ updateDelay(delayed) { this.currentMock.delay = Number(delayed) * config.delay }
57
57
 
58
58
  setByMatchingComment(comment) {
59
59
  for (const file of this.mocks)
@@ -79,8 +79,8 @@ export class MockBroker {
79
79
  }
80
80
  #registerTemp500() {
81
81
  const { urlMask, method } = parseFilename(this.mocks[0])
82
- const file = urlMask.replace(/^\//, '') // Removes leading slash TESTME
83
- this.register(`${file}${DEFAULT_500_COMMENT}.${method}.500.txt`)
82
+ const file = urlMask.replace(/^\//, '') // Removes leading slash
83
+ this.register(`${file}${DEFAULT_500_COMMENT}.${method}.500.empty`)
84
84
  }
85
85
  }
86
86
 
@@ -2,7 +2,7 @@ import { join } from 'node:path'
2
2
 
3
3
  import { proxy } from './ProxyRelay.js'
4
4
  import { cookie } from './cookie.js'
5
- import { Config } from './Config.js'
5
+ import { config } from './config.js'
6
6
  import { applyPlugins } from './MockDispatcherPlugins.js'
7
7
  import * as mockBrokerCollection from './mockBrokersCollection.js'
8
8
  import { BodyReaderError } from './utils/http-request.js'
@@ -13,7 +13,7 @@ export async function dispatchMock(req, response) {
13
13
  try {
14
14
  const broker = mockBrokerCollection.getBrokerForUrl(req.method, req.url)
15
15
  if (!broker) {
16
- if (Config.proxyFallback)
16
+ if (config.proxyFallback)
17
17
  await proxy(req, response)
18
18
  else
19
19
  sendNotFound(response)
@@ -26,12 +26,12 @@ export async function dispatchMock(req, response) {
26
26
  if (cookie.getCurrent())
27
27
  response.setHeader('Set-Cookie', cookie.getCurrent())
28
28
 
29
- for (let i = 0; i < Config.extraHeaders.length; i += 2)
30
- response.setHeader(Config.extraHeaders[i], Config.extraHeaders[i + 1])
29
+ for (let i = 0; i < config.extraHeaders.length; i += 2)
30
+ response.setHeader(config.extraHeaders[i], config.extraHeaders[i + 1])
31
31
 
32
32
  const { mime, body } = broker.isTemp500
33
33
  ? { mime: '', body: '' }
34
- : await applyPlugins(join(Config.mocksDir, broker.file), req, response)
34
+ : await applyPlugins(join(config.mocksDir, broker.file), req, response)
35
35
 
36
36
  response.setHeader('Content-Type', mime)
37
37
  setTimeout(() => response.end(body), broker.delay)
@@ -1,10 +1,10 @@
1
1
  import { readFileSync as read } from 'node:fs'
2
2
  import { mimeFor } from './utils/mime.js'
3
- import { Config } from './Config.js'
3
+ import { config } from './config.js'
4
4
 
5
5
 
6
6
  export async function applyPlugins(filePath, req, response) {
7
- for (const [regex, plugin] of Config.plugins) // TESTME capitalizePlugin
7
+ for (const [regex, plugin] of config.plugins)
8
8
  if (regex.test(filePath))
9
9
  return await plugin(filePath, req, response)
10
10
  return {
package/src/Mockaton.js CHANGED
@@ -2,7 +2,7 @@ import { createServer } from 'node:http'
2
2
 
3
3
  import { API } from './ApiConstants.js'
4
4
  import { dispatchMock } from './MockDispatcher.js'
5
- import { Config, setup } from './Config.js'
5
+ import { config, setup } from './config.js'
6
6
  import { sendNoContent } from './utils/http-response.js'
7
7
  import * as mockBrokerCollection from './mockBrokersCollection.js'
8
8
  import { dispatchStatic, isStatic } from './StaticDispatcher.js'
@@ -13,7 +13,7 @@ import { apiPatchRequests, apiGetRequests } from './Api.js'
13
13
  export function Mockaton(options) {
14
14
  setup(options)
15
15
  mockBrokerCollection.init()
16
- return createServer(onRequest).listen(Config.port, Config.host, function (error) {
16
+ return createServer(onRequest).listen(config.port, config.host, function (error) {
17
17
  const { address, port } = this.address()
18
18
  const url = `http://${address}:${port}`
19
19
  console.log('Listening', url)
@@ -21,21 +21,21 @@ export function Mockaton(options) {
21
21
  if (error)
22
22
  console.error(error)
23
23
  else
24
- Config.onReady(url + API.dashboard)
24
+ config.onReady(url + API.dashboard)
25
25
  })
26
26
  }
27
27
 
28
28
  async function onRequest(req, response) {
29
29
  response.setHeader('Server', 'Mockaton')
30
30
 
31
- if (Config.corsAllowed)
31
+ if (config.corsAllowed)
32
32
  setCorsHeaders(req, response, {
33
- origins: Config.corsOrigins,
34
- headers: Config.corsHeaders,
35
- methods: Config.corsMethods,
36
- maxAge: Config.corsMaxAge,
37
- credentials: Config.corsCredentials,
38
- exposedHeaders: Config.corsExposedHeaders
33
+ origins: config.corsOrigins,
34
+ headers: config.corsHeaders,
35
+ methods: config.corsMethods,
36
+ maxAge: config.corsMaxAge,
37
+ credentials: config.corsCredentials,
38
+ exposedHeaders: config.corsExposedHeaders
39
39
  })
40
40
 
41
41
  const { url, method } = req
@@ -1,17 +1,21 @@
1
1
  import { tmpdir } from 'node:os'
2
- import { dirname } from 'node:path'
2
+ import { dirname, join } from 'node:path'
3
3
  import { promisify } from 'node:util'
4
4
  import { describe, it } from 'node:test'
5
5
  import { createServer } from 'node:http'
6
+ import { randomUUID } from 'node:crypto'
6
7
  import { equal, deepEqual, match } from 'node:assert/strict'
7
8
  import { writeFileSync, mkdtempSync, mkdirSync } from 'node:fs'
8
9
 
9
- import { Config } from './Config.js'
10
+
11
+ import { config } from './config.js'
10
12
  import { mimeFor } from './utils/mime.js'
11
13
  import { Mockaton } from './Mockaton.js'
14
+ import { readBody } from './utils/http-request.js'
12
15
  import { Commander } from './Commander.js'
13
- import { parseFilename } from './Filename.js'
14
16
  import { CorsHeader } from './utils/http-cors.js'
17
+ import { parseFilename } from './Filename.js'
18
+ import { listFilesRecursively, read } from './utils/fs.js'
15
19
  import { API, DEFAULT_500_COMMENT, DEFAULT_MOCK_COMMENT } from './ApiConstants.js'
16
20
 
17
21
 
@@ -26,7 +30,7 @@ const fixtureCustomMime = [
26
30
  ]
27
31
  const fixtureNonDefaultInName = [
28
32
  '/api/the-route',
29
- 'api/the-route(default).GET.200.json',
33
+ 'api/the-route.GET.200.json',
30
34
  'default my route body content'
31
35
  ]
32
36
  const fixtureDefaultInName = [
@@ -137,11 +141,14 @@ write('api/ignored.GET.200.json~', '')
137
141
  // JavaScript to JSON
138
142
  write('/api/object.GET.200.js', 'export default { JSON_FROM_JS: true }')
139
143
 
140
- writeStatic('index.html', '<h1>Static</h1>')
141
- writeStatic('assets/app.js', 'const app = 1')
142
- writeStatic('another-entry/index.html', '<h1>Another</h1>')
143
-
144
-
144
+ const staticFiles = [
145
+ ['index.html', '<h1>Static</h1>'],
146
+ ['assets/app.js', 'const app = 1'],
147
+ ['another-entry/index.html', '<h1>Another</h1>']
148
+ ]
149
+ writeStatic('ignored.js~', 'ignored_file_body')
150
+ for (const [file, body] of staticFiles)
151
+ writeStatic(file, body)
145
152
 
146
153
  const server = Mockaton({
147
154
  mocksDir: tmpDir,
@@ -156,7 +163,8 @@ const server = Mockaton({
156
163
  extraMimes: {
157
164
  my_custom_extension: 'my_custom_mime'
158
165
  },
159
- corsOrigins: ['http://example.com']
166
+ corsOrigins: ['http://example.com'],
167
+ corsExposedHeaders: ['Content-Encoding']
160
168
  })
161
169
  server.on('listening', runTests)
162
170
 
@@ -186,7 +194,7 @@ async function runTests() {
186
194
 
187
195
  await testAutogenerates500(
188
196
  '/api/alternative',
189
- `api/alternative${DEFAULT_500_COMMENT}.GET.500.txt`)
197
+ `api/alternative${DEFAULT_500_COMMENT}.GET.500.empty`)
190
198
 
191
199
  await testPreservesExiting500(
192
200
  '/api',
@@ -225,11 +233,14 @@ async function runTests() {
225
233
  await testMockDispatching(...fixtureCustomMime, 'my_custom_mime')
226
234
  await testJsFunctionMocks()
227
235
 
228
- await testItUpdatesUserRole()
236
+ await testItUpdatesCookie()
229
237
  await testStaticFileServing()
238
+ await testStaticFileList()
230
239
  await testInvalidFilenamesAreIgnored()
231
240
  await testEnableFallbackSoRoutesWithoutMocksGetRelayed()
241
+ await testValidatesProxyFallbackURL()
232
242
  await testCorsAllowed()
243
+ testWindowsPaths()
233
244
 
234
245
  server.close()
235
246
  }
@@ -255,6 +266,10 @@ async function test404() {
255
266
  const res = await request('/api/ignored')
256
267
  equal(res.status, 404)
257
268
  })
269
+ await it('Ignores static files ending in ~ by default, e.g. JetBrains temp files', async () => {
270
+ const res = await request('/ignored.js~')
271
+ equal(res.status, 404)
272
+ })
258
273
  }
259
274
 
260
275
  async function testMockDispatching(url, file, expectedBody, forcedMime = undefined) {
@@ -275,6 +290,13 @@ async function testMockDispatching(url, file, expectedBody, forcedMime = undefin
275
290
 
276
291
  async function testDefaultMock() {
277
292
  await testMockDispatching(...fixtureDefaultInName)
293
+ await it('sorts mocks list with the user specified default first for dashboard display', async () => {
294
+ const res = await commander.listMocks()
295
+ const body = await res.json()
296
+ const { mocks } = body.GET[fixtureDefaultInName[0]]
297
+ equal(mocks[0], fixtureDefaultInName[1])
298
+ equal(mocks[1], fixtureNonDefaultInName[1])
299
+ })
278
300
  }
279
301
 
280
302
  async function testItUpdatesTheCurrentSelectedMock(url, file, expectedStatus, expectedBody) {
@@ -295,7 +317,7 @@ async function testItUpdatesRouteDelay(url, file, expectedBody) {
295
317
  const body = await res.text()
296
318
  await describe('url: ' + url, () => {
297
319
  it('body is: ' + expectedBody, () => equal(body, JSON.stringify(expectedBody)))
298
- it('delay', () => equal((new Date()).getTime() - now.getTime() > Config.delay, true))
320
+ it('delay', () => equal((new Date()).getTime() - now.getTime() > config.delay, true))
299
321
  })
300
322
  }
301
323
 
@@ -342,7 +364,7 @@ async function testItBulkSelectsByComment(comment, tests) {
342
364
  }
343
365
 
344
366
 
345
- async function testItUpdatesUserRole() {
367
+ async function testItUpdatesCookie() {
346
368
  await describe('Cookie', () => {
347
369
  it('Defaults to the first key:value', async () => {
348
370
  const res = await commander.listCookies()
@@ -352,7 +374,7 @@ async function testItUpdatesUserRole() {
352
374
  ])
353
375
  })
354
376
 
355
- it('Update the selected cookie', async () => {
377
+ it('Updates selected cookie', async () => {
356
378
  await commander.selectCookie('userB')
357
379
  const res = await commander.listCookies()
358
380
  deepEqual(await res.json(), [
@@ -360,6 +382,11 @@ async function testItUpdatesUserRole() {
360
382
  ['userB', true]
361
383
  ])
362
384
  })
385
+
386
+ it('422 when trying to select non-existing cookie', async () => {
387
+ const res = await commander.selectCookie('non-existing-cookie-key')
388
+ equal(res.status, 422)
389
+ })
363
390
  })
364
391
  }
365
392
 
@@ -404,6 +431,13 @@ async function testStaticFileServing() {
404
431
  })
405
432
  }
406
433
 
434
+ async function testStaticFileList() {
435
+ await it('Static File List', async () => {
436
+ const res = await commander.listStaticFiles()
437
+ deepEqual((await res.json()).sort(), staticFiles.map(([file]) => file).sort())
438
+ })
439
+ }
440
+
407
441
  async function testInvalidFilenamesAreIgnored() {
408
442
  await it('Invalid filenames get skipped, so they don’t crash the server', async (t) => {
409
443
  const consoleErrorSpy = t.mock.method(console, 'error')
@@ -421,26 +455,50 @@ async function testInvalidFilenamesAreIgnored() {
421
455
 
422
456
  async function testEnableFallbackSoRoutesWithoutMocksGetRelayed() {
423
457
  await describe('Fallback', async () => {
424
- const fallbackServer = createServer((_, response) => {
425
- response.setHeader('custom_header', 'my_custom_header')
426
- response.statusCode = 423
427
- response.end('From_Fallback_Server')
458
+ const fallbackServer = createServer(async (req, response) => {
459
+ response.writeHead(423, {
460
+ 'custom_header': 'my_custom_header',
461
+ 'content-type': mimeFor('txt'),
462
+ 'set-cookie': [
463
+ 'cookieA=A',
464
+ 'cookieB=B'
465
+ ]
466
+ })
467
+ response.end(await readBody(req)) // echoes they req body payload
428
468
  })
429
469
  await promisify(fallbackServer.listen).bind(fallbackServer, 0, '127.0.0.1')()
430
470
 
431
471
  await commander.setProxyFallback(`http://localhost:${fallbackServer.address().port}`)
432
- await it('Relays to fallback server', async () => {
433
- const res = await request('/non-existing-mock')
434
- equal(res.headers.get('custom_header'), 'my_custom_header')
472
+ await commander.setCollectProxied(true)
473
+ await it('Relays to fallback server and saves the mock', async () => {
474
+ const reqBodyPayload = 'text_req_body'
475
+
476
+ const res = await request(`/api/non-existing-mock/${randomUUID()}`, {
477
+ method: 'POST',
478
+ body: reqBodyPayload
479
+ })
435
480
  equal(res.status, 423)
436
- equal(await res.text(), 'From_Fallback_Server')
481
+ equal(res.headers.get('custom_header'), 'my_custom_header')
482
+ equal(res.headers.get('set-cookie'), ['cookieA=A', 'cookieB=B'].join(', '))
483
+ equal(await res.text(), reqBodyPayload)
484
+
485
+ const savedBody = read(join(tmpDir, 'api/non-existing-mock/[id].POST.423.txt'))
486
+ equal(savedBody, reqBodyPayload)
487
+
437
488
  fallbackServer.close()
438
489
  })
439
490
  })
440
491
  }
441
492
 
493
+ async function testValidatesProxyFallbackURL() {
494
+ await it('422 when value is not a valid URL', async () => {
495
+ const res = await commander.setProxyFallback('bad url')
496
+ equal(res.status, 422)
497
+ })
498
+ }
499
+
442
500
  async function testCorsAllowed() {
443
- await it('cors', async () => {
501
+ await it('cors preflight', async () => {
444
502
  await commander.setCorsAllowed(true)
445
503
  const res = await request('/does-not-matter', {
446
504
  method: 'OPTIONS',
@@ -453,6 +511,23 @@ async function testCorsAllowed() {
453
511
  equal(res.headers.get(CorsHeader.AccessControlAllowOrigin), 'http://example.com')
454
512
  equal(res.headers.get(CorsHeader.AccessControlAllowMethods), 'GET')
455
513
  })
514
+ await it('cors actual response', async () => {
515
+ const res = await request(fixtureDefaultInName[0], {
516
+ headers: {
517
+ [CorsHeader.Origin]: 'http://example.com'
518
+ }
519
+ })
520
+ equal(res.status, 200)
521
+ equal(res.headers.get(CorsHeader.AccessControlAllowOrigin), 'http://example.com')
522
+ equal(res.headers.get(CorsHeader.AccessControlExposeHeaders), 'Content-Encoding')
523
+ })
524
+ }
525
+
526
+ function testWindowsPaths() {
527
+ it('normalizes backslashes with forward ones', () => {
528
+ const files = listFilesRecursively(config.mocksDir)
529
+ equal(files[0], 'api/.GET.200.json')
530
+ })
456
531
  }
457
532
 
458
533
 
package/src/ProxyRelay.js CHANGED
@@ -1,27 +1,29 @@
1
1
  import { join } from 'node:path'
2
2
  import { write } from './utils/fs.js'
3
- import { Config } from './Config.js'
3
+ import { config } from './config.js'
4
4
  import { extFor } from './utils/mime.js'
5
5
  import { readBody } from './utils/http-request.js'
6
6
  import { makeMockFilename } from './Filename.js'
7
7
 
8
8
 
9
9
  export async function proxy(req, response) {
10
- const proxyResponse = await fetch(Config.proxyFallback + req.url, {
10
+ const proxyResponse = await fetch(config.proxyFallback + req.url, {
11
11
  method: req.method,
12
12
  headers: req.headers,
13
- body: req.method === 'GET' || req.method === 'HEAD' // TESTME
13
+ body: req.method === 'GET' || req.method === 'HEAD'
14
14
  ? undefined
15
15
  : await readBody(req)
16
16
  })
17
- // TODO investigate how to include many repeated headers such as set-cookie
18
- response.writeHead(proxyResponse.status, Object.fromEntries(proxyResponse.headers))
17
+
18
+ const headers = Object.fromEntries(proxyResponse.headers)
19
+ headers['set-cookie'] = proxyResponse.headers.getSetCookie() // parses multiple into an array
20
+ response.writeHead(proxyResponse.status, headers)
19
21
  const body = await proxyResponse.text()
20
22
  response.end(body)
21
23
 
22
- if (Config.collectProxied) { // TESTME
24
+ if (config.collectProxied) {
23
25
  const ext = extFor(proxyResponse.headers.get('content-type'))
24
26
  const filename = makeMockFilename(req.url, req.method, proxyResponse.status, ext)
25
- write(join(Config.mocksDir, filename), body)
27
+ write(join(config.mocksDir, filename), body)
26
28
  }
27
29
  }
@@ -1,16 +1,14 @@
1
1
  import { join } from 'node:path'
2
- import { Config } from './Config.js'
2
+ import { config } from './config.js'
3
3
  import { isDirectory, isFile } from './utils/fs.js'
4
4
  import { sendFile, sendPartialContent, sendNotFound } from './utils/http-response.js'
5
5
 
6
6
 
7
7
  export function isStatic(req) {
8
- if (!Config.staticDir)
8
+ if (!config.staticDir)
9
9
  return false
10
-
11
10
  const f = resolvePath(req.url)
12
- return !Config.ignore.test(f) // TESTME
13
- && Boolean(f)
11
+ return !config.ignore.test(f) && Boolean(f)
14
12
  }
15
13
 
16
14
  export async function dispatchStatic(req, response) {
@@ -24,7 +22,7 @@ export async function dispatchStatic(req, response) {
24
22
  }
25
23
 
26
24
  function resolvePath(url) {
27
- let candidate = join(Config.staticDir, url)
25
+ let candidate = join(config.staticDir, url)
28
26
  if (isDirectory(candidate))
29
27
  candidate += '/index.html'
30
28
  if (isFile(candidate))
@@ -2,14 +2,13 @@ import { isDirectory } from './utils/fs.js'
2
2
  import { openInBrowser } from './utils/openInBrowser.js'
3
3
  import { jsToJsonPlugin } from './MockDispatcherPlugins.js'
4
4
  import { StandardMethods } from './utils/http-request.js'
5
- import { validate, is, optional } from './utils/validate.js'
5
+ import { validateCorsAllowedMethods, validateCorsAllowedOrigins } from './utils/http-cors.js'
6
6
 
7
7
 
8
- export const Config = Object.seal({
8
+ export const config = Object.seal({
9
9
  mocksDir: '',
10
- ignore: /(\.DS_Store|~)$/,
11
-
12
10
  staticDir: '',
11
+ ignore: /(\.DS_Store|~)$/,
13
12
 
14
13
  host: '127.0.0.1',
15
14
  port: 0, // auto-assigned
@@ -38,12 +37,11 @@ export const Config = Object.seal({
38
37
 
39
38
 
40
39
  export function setup(options) {
41
- Object.assign(Config, options)
42
- validate(Config, {
40
+ Object.assign(config, options)
41
+ validate(config, {
43
42
  mocksDir: isDirectory,
44
- ignore: is(RegExp),
45
-
46
43
  staticDir: optional(isDirectory),
44
+ ignore: is(RegExp),
47
45
 
48
46
  host: is(String),
49
47
  port: port => Number.isInteger(port) && port >= 0 && port < 2 ** 16,
@@ -70,20 +68,16 @@ export function setup(options) {
70
68
  }
71
69
 
72
70
 
73
- function validateCorsAllowedOrigins(arr) {
74
- if (!Array.isArray(arr))
75
- return false
76
-
77
- if (arr.length === 1 && arr[0] === '*')
78
- return true
79
-
80
- return arr.every(o => URL.canParse(o))
71
+ function validate(obj, shape) {
72
+ for (const [field, value] of Object.entries(obj))
73
+ if (!shape[field](value))
74
+ throw new TypeError(`config.${field}=${JSON.stringify(value)} is invalid`)
81
75
  }
82
76
 
77
+ function is(ctor) {
78
+ return val => val.constructor === ctor
79
+ }
83
80
 
84
- function validateCorsAllowedMethods(arr) {
85
- if (!Array.isArray(arr))
86
- return false
87
-
88
- return arr.every(m => StandardMethods.includes(m))
81
+ function optional(tester) {
82
+ return val => !val || tester(val)
89
83
  }
package/src/cookie.js CHANGED
@@ -17,7 +17,7 @@ export const cookie = new class {
17
17
  if (key in this.#cookies)
18
18
  this.#currentKey = key
19
19
  else
20
- throw 'Cookie key not found' // TESTME
20
+ return 'Cookie key not found'
21
21
  }
22
22
 
23
23
  list() {
@@ -1,4 +1,4 @@
1
- import { Config } from './Config.js'
1
+ import { config } from './config.js'
2
2
  import { cookie } from './cookie.js'
3
3
  import { MockBroker } from './MockBroker.js'
4
4
  import { listFilesRecursively } from './utils/fs.js'
@@ -19,11 +19,11 @@ let collection = {}
19
19
 
20
20
  export function init() {
21
21
  collection = {}
22
- cookie.init(Config.cookies)
22
+ cookie.init(config.cookies)
23
23
 
24
- const files = listFilesRecursively(Config.mocksDir)
24
+ const files = listFilesRecursively(config.mocksDir)
25
25
  .sort()
26
- .filter(f => !Config.ignore.test(f) && filenameIsValid(f))
26
+ .filter(f => !config.ignore.test(f) && filenameIsValid(f))
27
27
 
28
28
  for (const file of files) {
29
29
  const { method, urlMask } = parseFilename(file)
package/src/utils/fs.js CHANGED
@@ -1,21 +1,21 @@
1
- import path, { join } from 'node:path'
1
+ import { join, dirname, sep, posix } from 'node:path'
2
2
  import { lstatSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs'
3
3
 
4
4
 
5
5
  export const isFile = path => lstatSync(path, { throwIfNoEntry: false })?.isFile()
6
6
  export const isDirectory = path => lstatSync(path, { throwIfNoEntry: false })?.isDirectory()
7
7
 
8
- export const read = path => readFileSync(path)
8
+ export const read = path => readFileSync(path, 'utf8')
9
9
 
10
10
  /** @returns {Array<string>} paths relative to `dir` */
11
11
  export const listFilesRecursively = dir => {
12
12
  const files = readdirSync(dir, { recursive: true }).filter(f => isFile(join(dir, f)))
13
13
  return process.platform === 'win32'
14
- ? files.map(f => f.replaceAll(path.sep, path.posix.sep)) // TESTME
14
+ ? files.map(f => f.replaceAll(sep, posix.sep))
15
15
  : files
16
16
  }
17
17
 
18
- export const write = (fPath, body) => {
19
- mkdirSync(path.dirname(fPath), { recursive: true })
20
- writeFileSync(fPath, body)
18
+ export const write = (path, body) => {
19
+ mkdirSync(dirname(path), { recursive: true })
20
+ writeFileSync(path, body)
21
21
  }
@@ -1,19 +1,35 @@
1
1
  import { StandardMethods } from './http-request.js'
2
2
 
3
- // https://www.w3.org/TR/2020/SPSD-cors-20200602/#resource-processing-model
3
+
4
+ /* https://www.w3.org/TR/2020/SPSD-cors-20200602/#resource-processing-model */
5
+
6
+
7
+ export function validateCorsAllowedOrigins(arr) {
8
+ if (!Array.isArray(arr))
9
+ return false
10
+ if (arr.length === 1 && arr[0] === '*')
11
+ return true
12
+ return arr.every(o => URL.canParse(o))
13
+ }
14
+
15
+ export function validateCorsAllowedMethods(arr) {
16
+ return Array.isArray(arr)
17
+ && arr.every(m => StandardMethods.includes(m))
18
+ }
19
+
4
20
 
5
21
  export const CorsHeader = {
6
- // request
22
+ // Request
7
23
  Origin: 'origin',
8
24
  AccessControlRequestMethod: 'access-control-request-method',
9
25
  AccessControlRequestHeaders: 'access-control-request-headers', // Comma separated
10
26
 
11
- // response
27
+ // Response
12
28
  AccessControlMaxAge: 'Access-Control-Max-Age',
13
29
  AccessControlAllowOrigin: 'Access-Control-Allow-Origin', // '*' | Space delimited | null
14
30
  AccessControlAllowMethods: 'Access-Control-Allow-Methods', // '*' | Comma delimited
15
31
  AccessControlAllowHeaders: 'Access-Control-Allow-Headers', // '*' | Comma delimited
16
- AccessControlExposeHeaders: 'Access-Control-Expose-Headers', // '*' | Comma delimited
32
+ AccessControlExposeHeaders: 'Access-Control-Expose-Headers', // '*' | Comma delimited (headers client-side JS can read)
17
33
  AccessControlAllowCredentials: 'Access-Control-Allow-Credentials' // 'true'
18
34
  }
19
35
  const CH = CorsHeader
@@ -45,26 +61,16 @@ export function setCorsHeaders(req, response, {
45
61
 
46
62
  if (req.headers[CH.AccessControlRequestMethod])
47
63
  setPreflightSpecificHeaders(req, response, methods, headers, maxAge)
48
- else
49
- setActualRequestHeaders(response, exposedHeaders)
64
+ else if (exposedHeaders.length)
65
+ response.setHeader(CH.AccessControlExposeHeaders, exposedHeaders.join(','))
50
66
  }
51
67
 
52
-
53
68
  function setPreflightSpecificHeaders(req, response, methods, headers, maxAge) {
54
69
  const methodAskingFor = req.headers[CH.AccessControlRequestMethod]
55
70
  if (!methods.includes(methodAskingFor))
56
71
  return
57
-
58
72
  response.setHeader(CH.AccessControlMaxAge, maxAge)
59
73
  response.setHeader(CH.AccessControlAllowMethods, methodAskingFor)
60
74
  if (headers.length)
61
75
  response.setHeader(CH.AccessControlAllowHeaders, headers.join(','))
62
76
  }
63
-
64
-
65
- // TESTME
66
- function setActualRequestHeaders(response, exposedHeaders) {
67
- // Exposed means the client-side JavaScript can read them
68
- if (exposedHeaders.length)
69
- response.setHeader(CH.AccessControlExposeHeaders, exposedHeaders.join(','))
70
- }
package/src/utils/mime.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Config } from '../Config.js'
1
+ import { config } from '../config.js'
2
2
  import { EXT_FOR_UNKNOWN_MIME } from '../ApiConstants.js'
3
3
 
4
4
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
@@ -89,7 +89,7 @@ const mimes = {
89
89
 
90
90
  export function mimeFor(filename) {
91
91
  const ext = filename.replace(/.*\./, '').toLowerCase()
92
- return Config.extraMimes[ext] || mimes[ext] || ''
92
+ return config.extraMimes[ext] || mimes[ext] || ''
93
93
  }
94
94
 
95
95
  export function extFor(mime) {
@@ -99,7 +99,7 @@ export function extFor(mime) {
99
99
  }
100
100
 
101
101
  function findExt(targetMime) {
102
- for (const [ext, mime] of Object.entries(Config.extraMimes))
102
+ for (const [ext, mime] of Object.entries(config.extraMimes))
103
103
  if (targetMime === mime)
104
104
  return ext
105
105
  for (const [ext, mime] of Object.entries(mimes))
@@ -1,8 +0,0 @@
1
- export function validate(obj, shape) {
2
- for (const [field, value] of Object.entries(obj))
3
- if (!shape[field](value))
4
- throw new TypeError(`Config.${field}=${JSON.stringify(value)} is invalid`)
5
- }
6
-
7
- export const is = ctor => val => val.constructor === ctor
8
- export const optional = tester => val => !val || tester(val)