mockaton 8.3.2 → 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/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.2",
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,18 +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'
12
14
  import { readBody } from './utils/http-request.js'
13
15
  import { Commander } from './Commander.js'
14
- import { parseFilename } from './Filename.js'
15
16
  import { CorsHeader } from './utils/http-cors.js'
17
+ import { parseFilename } from './Filename.js'
18
+ import { listFilesRecursively, read } from './utils/fs.js'
16
19
  import { API, DEFAULT_500_COMMENT, DEFAULT_MOCK_COMMENT } from './ApiConstants.js'
17
20
 
18
21
 
@@ -27,7 +30,7 @@ const fixtureCustomMime = [
27
30
  ]
28
31
  const fixtureNonDefaultInName = [
29
32
  '/api/the-route',
30
- 'api/the-route(default).GET.200.json',
33
+ 'api/the-route.GET.200.json',
31
34
  'default my route body content'
32
35
  ]
33
36
  const fixtureDefaultInName = [
@@ -138,11 +141,14 @@ write('api/ignored.GET.200.json~', '')
138
141
  // JavaScript to JSON
139
142
  write('/api/object.GET.200.js', 'export default { JSON_FROM_JS: true }')
140
143
 
141
- writeStatic('index.html', '<h1>Static</h1>')
142
- writeStatic('assets/app.js', 'const app = 1')
143
- writeStatic('another-entry/index.html', '<h1>Another</h1>')
144
-
145
-
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)
146
152
 
147
153
  const server = Mockaton({
148
154
  mocksDir: tmpDir,
@@ -157,7 +163,8 @@ const server = Mockaton({
157
163
  extraMimes: {
158
164
  my_custom_extension: 'my_custom_mime'
159
165
  },
160
- corsOrigins: ['http://example.com']
166
+ corsOrigins: ['http://example.com'],
167
+ corsExposedHeaders: ['Content-Encoding']
161
168
  })
162
169
  server.on('listening', runTests)
163
170
 
@@ -187,7 +194,7 @@ async function runTests() {
187
194
 
188
195
  await testAutogenerates500(
189
196
  '/api/alternative',
190
- `api/alternative${DEFAULT_500_COMMENT}.GET.500.txt`)
197
+ `api/alternative${DEFAULT_500_COMMENT}.GET.500.empty`)
191
198
 
192
199
  await testPreservesExiting500(
193
200
  '/api',
@@ -226,11 +233,14 @@ async function runTests() {
226
233
  await testMockDispatching(...fixtureCustomMime, 'my_custom_mime')
227
234
  await testJsFunctionMocks()
228
235
 
229
- await testItUpdatesUserRole()
236
+ await testItUpdatesCookie()
230
237
  await testStaticFileServing()
238
+ await testStaticFileList()
231
239
  await testInvalidFilenamesAreIgnored()
232
240
  await testEnableFallbackSoRoutesWithoutMocksGetRelayed()
241
+ await testValidatesProxyFallbackURL()
233
242
  await testCorsAllowed()
243
+ testWindowsPaths()
234
244
 
235
245
  server.close()
236
246
  }
@@ -256,6 +266,10 @@ async function test404() {
256
266
  const res = await request('/api/ignored')
257
267
  equal(res.status, 404)
258
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
+ })
259
273
  }
260
274
 
261
275
  async function testMockDispatching(url, file, expectedBody, forcedMime = undefined) {
@@ -276,6 +290,13 @@ async function testMockDispatching(url, file, expectedBody, forcedMime = undefin
276
290
 
277
291
  async function testDefaultMock() {
278
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
+ })
279
300
  }
280
301
 
281
302
  async function testItUpdatesTheCurrentSelectedMock(url, file, expectedStatus, expectedBody) {
@@ -296,7 +317,7 @@ async function testItUpdatesRouteDelay(url, file, expectedBody) {
296
317
  const body = await res.text()
297
318
  await describe('url: ' + url, () => {
298
319
  it('body is: ' + expectedBody, () => equal(body, JSON.stringify(expectedBody)))
299
- it('delay', () => equal((new Date()).getTime() - now.getTime() > Config.delay, true))
320
+ it('delay', () => equal((new Date()).getTime() - now.getTime() > config.delay, true))
300
321
  })
301
322
  }
302
323
 
@@ -343,7 +364,7 @@ async function testItBulkSelectsByComment(comment, tests) {
343
364
  }
344
365
 
345
366
 
346
- async function testItUpdatesUserRole() {
367
+ async function testItUpdatesCookie() {
347
368
  await describe('Cookie', () => {
348
369
  it('Defaults to the first key:value', async () => {
349
370
  const res = await commander.listCookies()
@@ -353,7 +374,7 @@ async function testItUpdatesUserRole() {
353
374
  ])
354
375
  })
355
376
 
356
- it('Update the selected cookie', async () => {
377
+ it('Updates selected cookie', async () => {
357
378
  await commander.selectCookie('userB')
358
379
  const res = await commander.listCookies()
359
380
  deepEqual(await res.json(), [
@@ -361,6 +382,11 @@ async function testItUpdatesUserRole() {
361
382
  ['userB', true]
362
383
  ])
363
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
+ })
364
390
  })
365
391
  }
366
392
 
@@ -405,6 +431,13 @@ async function testStaticFileServing() {
405
431
  })
406
432
  }
407
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
+
408
441
  async function testInvalidFilenamesAreIgnored() {
409
442
  await it('Invalid filenames get skipped, so they don’t crash the server', async (t) => {
410
443
  const consoleErrorSpy = t.mock.method(console, 'error')
@@ -425,6 +458,7 @@ async function testEnableFallbackSoRoutesWithoutMocksGetRelayed() {
425
458
  const fallbackServer = createServer(async (req, response) => {
426
459
  response.writeHead(423, {
427
460
  'custom_header': 'my_custom_header',
461
+ 'content-type': mimeFor('txt'),
428
462
  'set-cookie': [
429
463
  'cookieA=A',
430
464
  'cookieB=B'
@@ -435,22 +469,36 @@ async function testEnableFallbackSoRoutesWithoutMocksGetRelayed() {
435
469
  await promisify(fallbackServer.listen).bind(fallbackServer, 0, '127.0.0.1')()
436
470
 
437
471
  await commander.setProxyFallback(`http://localhost:${fallbackServer.address().port}`)
438
- await it('Relays to fallback server', async () => {
439
- const res = await request('/non-existing-mock', {
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()}`, {
440
477
  method: 'POST',
441
- body: 'text_body'
478
+ body: reqBodyPayload
442
479
  })
443
480
  equal(res.status, 423)
444
481
  equal(res.headers.get('custom_header'), 'my_custom_header')
445
482
  equal(res.headers.get('set-cookie'), ['cookieA=A', 'cookieB=B'].join(', '))
446
- equal(await res.text(), 'text_body')
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
+
447
488
  fallbackServer.close()
448
489
  })
449
490
  })
450
491
  }
451
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
+
452
500
  async function testCorsAllowed() {
453
- await it('cors', async () => {
501
+ await it('cors preflight', async () => {
454
502
  await commander.setCorsAllowed(true)
455
503
  const res = await request('/does-not-matter', {
456
504
  method: 'OPTIONS',
@@ -463,6 +511,23 @@ async function testCorsAllowed() {
463
511
  equal(res.headers.get(CorsHeader.AccessControlAllowOrigin), 'http://example.com')
464
512
  equal(res.headers.get(CorsHeader.AccessControlAllowMethods), 'GET')
465
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
+ })
466
531
  }
467
532
 
468
533
 
package/src/ProxyRelay.js CHANGED
@@ -1,13 +1,13 @@
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
13
  body: req.method === 'GET' || req.method === 'HEAD'
@@ -21,9 +21,9 @@ export async function proxy(req, response) {
21
21
  const body = await proxyResponse.text()
22
22
  response.end(body)
23
23
 
24
- if (Config.collectProxied) { // TESTME
24
+ if (config.collectProxied) {
25
25
  const ext = extFor(proxyResponse.headers.get('content-type'))
26
26
  const filename = makeMockFilename(req.url, req.method, proxyResponse.status, ext)
27
- write(join(Config.mocksDir, filename), body)
27
+ write(join(config.mocksDir, filename), body)
28
28
  }
29
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)