mockaton 13.0.1 → 13.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/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export interface Config {
14
14
  ignore?: RegExp
15
15
  watcherEnabled?: boolean
16
16
  watcherDebounceMs?: number
17
+ readOnly?: boolean
17
18
 
18
19
  host?: string,
19
20
  port?: number
@@ -96,5 +97,7 @@ export interface State {
96
97
  collectProxied: boolean
97
98
  proxyFallback: string
98
99
 
100
+ readOnly: boolean
101
+
99
102
  corsAllowed?: boolean
100
103
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "mockaton",
3
3
  "description": "HTTP Mock Server",
4
4
  "type": "module",
5
- "version": "13.0.1",
5
+ "version": "13.1.0",
6
6
  "exports": {
7
7
  ".": {
8
8
  "import": "./index.js",
@@ -40,7 +40,7 @@ export class Commander {
40
40
 
41
41
 
42
42
  /** @returns {JsonPromise<ClientMockBroker>} */
43
- toggleStatus = (status, method, urlMask) => this.#patch(API.toggleStatus, [status, method, urlMask])
43
+ toggleStatus = (method, urlMask, status) => this.#patch(API.toggleStatus, [method, urlMask, status])
44
44
 
45
45
  // TODO change Status or Toggle404?
46
46
 
@@ -51,6 +51,11 @@ export class Commander {
51
51
  setRouteIsDelayed = (method, urlMask, delayed) => this.#patch(API.delay, [method, urlMask, delayed])
52
52
 
53
53
 
54
+ writeMock = (file, content) => this.#patch(API.writeMock, [file, content])
55
+
56
+ deleteMock = file => this.#patch(API.deleteMock, file)
57
+
58
+
54
59
  /** @returns {JsonPromise<State>} */
55
60
  getState = () => fetch(this.addr + API.state)
56
61
 
@@ -21,6 +21,8 @@ export const API = {
21
21
  toggleStatus: MOUNT + '/toggle-status',
22
22
  watchHotReload: MOUNT + '/watch-hot-reload',
23
23
  watchMocks: MOUNT + '/watch-mocks',
24
+ writeMock: MOUNT + '/write-mock',
25
+ deleteMock: MOUNT + '/delete-mock',
24
26
  }
25
27
 
26
28
  export const HEADER_502 = 'Mockaton502'
@@ -80,9 +80,9 @@ export async function previewMock() {
80
80
  signal: previewMock.controller.signal
81
81
  })
82
82
  clearTimeout(spinnerTimer)
83
- const { proxied, file } = store.brokerFor(method, urlMask)
84
- if (proxied || file)
85
- await updatePayloadViewer(proxied, file, response)
83
+ const broker = store.brokerFor(method, urlMask)
84
+ if (broker?.proxied || broker?.file)
85
+ await updatePayloadViewer(broker.proxied, broker.file, response)
86
86
  }
87
87
  catch (error) {
88
88
  clearTimeout(spinnerTimer)
@@ -99,7 +99,7 @@ async function updatePayloadViewer(proxied, file, response) {
99
99
  ? PayloadViewerTitleWhenProxied(response)
100
100
  : PayloadViewerTitle(file, response.statusText))
101
101
 
102
- if (!response.ok) {
102
+ if (!response.ok || response.status === 204) {
103
103
  codeRef.elem.textContent = await bodyAsText()
104
104
  return
105
105
  }
@@ -161,8 +161,8 @@ export const store = {
161
161
  })
162
162
  },
163
163
 
164
- toggleStatus(status, method, urlMask) {
165
- store._request(() => api.toggleStatus(status, method, urlMask), async response => {
164
+ toggleStatus(method, urlMask, status) {
165
+ store._request(() => api.toggleStatus(method, urlMask, status), async response => {
166
166
  store.setBroker(await response.json())
167
167
  store.setChosenLink(method, urlMask)
168
168
  store.renderRow(method, urlMask)
package/src/client/app.js CHANGED
@@ -131,7 +131,7 @@ function Row(row, i) {
131
131
  disabled: row.opts.length === 1 && (row.isStatic ? row.status === 404 : row.status === 500),
132
132
  checked: !row.proxied && (row.isStatic ? row.status === 404 : row.status === 500),
133
133
  commit() {
134
- store.toggleStatus(row.isStatic ? 404 : 500, method, urlMask)
134
+ store.toggleStatus(method, urlMask, row.isStatic ? 404 : 500)
135
135
  }
136
136
  }),
137
137
 
package/src/server/Api.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  DASHBOARD_ASSETS,
11
11
  CLIENT_DIR
12
12
  } from './WatcherDevClient.js'
13
- import { startWatchers, stopWatchers, sseClientSyncVersion } from './Watcher.js'
13
+ import { startWatchers, stopWatchers, sseClientSyncVersion, notifyARR } from './Watcher.js'
14
14
 
15
15
  import pkgJSON from '../../package.json' with { type: 'json' }
16
16
 
@@ -20,6 +20,8 @@ import { IndexHtml, CSP } from '../client/IndexHtml.js'
20
20
  import { cookie } from './cookie.js'
21
21
  import { config, ConfigValidator } from './config.js'
22
22
 
23
+ import { write, rm, isFile, resolveIn } from './utils/fs.js'
24
+
23
25
  import * as mockBrokersCollection from './mockBrokersCollection.js'
24
26
 
25
27
 
@@ -52,6 +54,8 @@ export const apiPatchReqs = new Map([
52
54
  [API.proxied, setRouteIsProxied],
53
55
  [API.toggleStatus, toggleRouteStatus],
54
56
 
57
+ [API.writeMock, writeMock],
58
+ [API.deleteMock, deleteMock],
55
59
  [API.watchMocks, setWatchMocks]
56
60
  ])
57
61
 
@@ -80,6 +84,7 @@ function getState(_, response) {
80
84
 
81
85
  proxyFallback: config.proxyFallback,
82
86
  collectProxied: config.collectProxied,
87
+ readOnly: config.readOnly,
83
88
  corsAllowed: config.corsAllowed
84
89
  })
85
90
  }
@@ -200,7 +205,7 @@ async function selectMock(req, response) {
200
205
 
201
206
 
202
207
  async function toggleRouteStatus(req, response) {
203
- const [status, method, urlMask] = await req.json()
208
+ const [method, urlMask, status] = await req.json()
204
209
 
205
210
  const broker = mockBrokersCollection.brokerByRoute(method, urlMask)
206
211
  if (!broker)
@@ -244,4 +249,47 @@ async function setRouteIsProxied(req, response) {
244
249
  }
245
250
 
246
251
 
252
+ async function writeMock(req, response) {
253
+ if (config.readOnly)
254
+ return response.forbidden()
255
+
256
+ const [file, content] = await req.json()
257
+ const path = await resolveIn(config.mocksDir, file)
258
+
259
+ if (!path)
260
+ return response.forbidden()
261
+
262
+ await write(path, content)
263
+
264
+ if (!config.watcherEnabled) {
265
+ mockBrokersCollection.registerMock(file, true)
266
+ notifyARR()
267
+ }
268
+ response.ok()
269
+ }
270
+
271
+
272
+ async function deleteMock(req, response) {
273
+ if (config.readOnly)
274
+ return response.forbidden()
275
+
276
+ const file = await req.json()
277
+ const path = await resolveIn(config.mocksDir, file)
278
+
279
+ if (!path)
280
+ return response.forbidden()
281
+
282
+ if (!isFile(path))
283
+ return response.unprocessable(`Missing Mock: ${file}`)
284
+
285
+ await rm(path)
286
+
287
+ if (!config.watcherEnabled) {
288
+ mockBrokersCollection.unregisterMock(file)
289
+ notifyARR()
290
+ }
291
+ response.ok()
292
+ }
293
+
294
+
247
295
 
@@ -10,6 +10,7 @@ export default {
10
10
  logLevel: 'verbose',
11
11
  corsOrigins: ['https://example.test'],
12
12
  corsExposedHeaders: ['Content-Encoding'],
13
+ readOnly: false,
13
14
  watcherEnabled: false, // But we enable it at run-time
14
15
  watcherDebounceMs: 0
15
16
  }
@@ -7,7 +7,7 @@ import { mkdtempSync } from 'node:fs'
7
7
  import { randomUUID } from 'node:crypto'
8
8
  import { equal, deepEqual, match } from 'node:assert/strict'
9
9
  import { describe, test, before, beforeEach, after } from 'node:test'
10
- import { writeFile, unlink, mkdir, readFile, rename, readdir } from 'node:fs/promises'
10
+ import { unlink, mkdir, readFile, rename, readdir, writeFile } from 'node:fs/promises'
11
11
 
12
12
  import { mimeFor } from './utils/mime.js'
13
13
  import { parseFilename } from '../client/Filename.js'
@@ -26,8 +26,8 @@ const proc = spawn(join(import.meta.dirname, 'cli.js'), [
26
26
  '--no-open'
27
27
  ])
28
28
 
29
- proc.stdout.on('data', data => { stdout.push(data.toString()) })
30
- proc.stderr.on('data', data => { stderr.push(data.toString()) })
29
+ proc.stdout.on('data', data => stdout.push(data.toString()))
30
+ proc.stderr.on('data', data => stderr.push(data.toString()))
31
31
 
32
32
  const serverAddr = await new Promise((resolve, reject) => {
33
33
  proc.stdout.once('data', () => {
@@ -43,9 +43,8 @@ after(() => proc.kill('SIGUSR2'))
43
43
  const rmFromMocksDir = f => unlink(join(mocksDir, f))
44
44
  const listFromMocksDir = d => readdir(join(mocksDir, d))
45
45
  const readFromMocksDir = f => readFile(join(mocksDir, f), 'utf8')
46
-
46
+ const writeInMocksDir = (f, data) => writeFile(join(mocksDir, f), data)
47
47
  const makeDirInMocks = dir => mkdir(join(mocksDir, dir), { recursive: true })
48
-
49
48
  const renameInMocksDir = (src, target) => rename(join(mocksDir, src), join(mocksDir, target))
50
49
 
51
50
 
@@ -71,33 +70,8 @@ class BaseFixture {
71
70
  this.file = file
72
71
  this.body = body || `Body for ${file}`
73
72
  }
74
-
75
- async #nextMacroTask() {
76
- await new Promise(resolve => setTimeout(resolve, 0))
77
- }
78
-
79
- async register() {
80
- const nextVerPromise = resolveOnNextSyncVersion()
81
- await this.#nextMacroTask()
82
- await this.write()
83
- await nextVerPromise
84
- }
85
-
86
- async unregister() {
87
- const nextVerPromise = resolveOnNextSyncVersion()
88
- await this.#nextMacroTask()
89
- await this.unlink()
90
- await nextVerPromise
91
- }
92
-
93
- async write() { await writeFile(this.#path(), this.body, 'utf8') }
94
- async unlink() { await unlink(this.#path()) }
95
- #path() { return join(this.dir, this.file) }
96
-
97
- async sync() {
98
- await this.write()
99
- await api.reset()
100
- }
73
+ write() { return api.writeMock(this.file, this.body) }
74
+ delete() { return api.deleteMock(this.file) }
101
75
 
102
76
  request(options = {}) {
103
77
  options.method ??= this.method
@@ -135,10 +109,10 @@ class FixtureStatic extends BaseFixture {
135
109
  describe('Windows', () => {
136
110
  test('path separators are normalized to forward slashes', async () => {
137
111
  const fx = new Fixture('win-paths.GET.200.json')
138
- await fx.sync()
112
+ await fx.write()
139
113
  const b = await fx.fetchBroker()
140
114
  equal(b.file, fx.file)
141
- await fx.unlink()
115
+ await fx.delete()
142
116
  })
143
117
  })
144
118
 
@@ -163,22 +137,22 @@ describe('Rejects malicious URLs', () => {
163
137
 
164
138
  describe('Filename Convention', () => {
165
139
  test('registers invalid filenames as GET 200', async () => {
140
+ await api.reset()
166
141
  const fx0 = new Fixture('bar.GET._INVALID_STATUS_.json')
167
142
  const fx1 = new Fixture('foo._INVALID_METHOD_.202.json')
168
143
  const fx2 = new Fixture('missing-method-and-status.json')
169
144
  await fx0.write()
170
145
  await fx1.write()
171
146
  await fx2.write()
172
- await api.reset()
173
147
 
174
148
  const s = await fetchState()
175
149
  equal(s.brokersByMethod.GET['/bar.GET._INVALID_STATUS_.json'].file, 'bar.GET._INVALID_STATUS_.json')
176
150
  equal(s.brokersByMethod.GET['/foo._INVALID_METHOD_.202.json'].file, 'foo._INVALID_METHOD_.202.json')
177
151
  equal(s.brokersByMethod.GET['/missing-method-and-status.json'].file, 'missing-method-and-status.json')
178
152
 
179
- await fx0.unlink()
180
- await fx1.unlink()
181
- await fx2.unlink()
153
+ await fx0.delete()
154
+ await fx1.delete()
155
+ await fx2.delete()
182
156
  })
183
157
 
184
158
  test('body parser rejects invalid JSON in API requests', async () => {
@@ -232,7 +206,7 @@ describe('CORS', () => {
232
206
 
233
207
  test('responds', async () => {
234
208
  const fx = new Fixture('cors-response.GET.200.json')
235
- await fx.sync()
209
+ await fx.write()
236
210
  const r = await fx.request({
237
211
  headers: {
238
212
  'origin': CONFIG.corsOrigins[0]
@@ -241,7 +215,7 @@ describe('CORS', () => {
241
215
  equal(r.status, 200)
242
216
  equal(r.headers.get('access-control-allow-origin'), CONFIG.corsOrigins[0])
243
217
  equal(r.headers.get('access-control-expose-headers'), 'Content-Encoding')
244
- await fx.unlink()
218
+ await fx.delete()
245
219
  })
246
220
  })
247
221
 
@@ -278,7 +252,7 @@ describe('Cookie', () => {
278
252
 
279
253
  test('updates selected cookie', async () => {
280
254
  const fx = new Fixture('update-cookie.GET.200.json')
281
- await fx.sync()
255
+ await fx.write()
282
256
  const resA = await fx.request()
283
257
  equal(resA.headers.get('set-cookie'), CONFIG.cookies.userA)
284
258
 
@@ -290,7 +264,7 @@ describe('Cookie', () => {
290
264
 
291
265
  const resB = await fx.request()
292
266
  equal(resB.headers.get('set-cookie'), CONFIG.cookies.userB)
293
- await fx.unlink()
267
+ await fx.delete()
294
268
  })
295
269
  })
296
270
 
@@ -324,7 +298,7 @@ describe('Delay', () => {
324
298
 
325
299
  test('updates route delay', async () => {
326
300
  const fx = new Fixture('route-delay.GET.200.json')
327
- await fx.sync()
301
+ await fx.write()
328
302
  const DELAY = 100
329
303
  await api.setGlobalDelay(DELAY)
330
304
  await api.setRouteIsDelayed(fx.method, fx.urlMask, true)
@@ -332,7 +306,7 @@ describe('Delay', () => {
332
306
  const r = await fx.request()
333
307
  equal(await r.text(), fx.body)
334
308
  equal(performance.now() - t0 > DELAY, true)
335
- await fx.unlink()
309
+ await fx.delete()
336
310
  })
337
311
 
338
312
  describe('Set Route is Delayed', () => {
@@ -344,18 +318,18 @@ describe('Delay', () => {
344
318
 
345
319
  test('422 for invalid delayed value', async () => {
346
320
  const fx = new Fixture('set-route-delay.GET.200.json')
347
- await fx.sync()
321
+ await fx.write()
348
322
  const r = await api.setRouteIsDelayed(fx.method, fx.urlMask, 'not-a-boolean')
349
323
  equal(await r.text(), 'Expected boolean for "delayed"')
350
- await fx.unlink()
324
+ await fx.delete()
351
325
  })
352
326
 
353
327
  test('200', async () => {
354
328
  const fx = new Fixture('set-route-delay.GET.200.json')
355
- await fx.sync()
329
+ await fx.write()
356
330
  const r = await api.setRouteIsDelayed(fx.method, fx.urlMask, true)
357
331
  equal((await r.json()).delayed, true)
358
- await fx.unlink()
332
+ await fx.delete()
359
333
  })
360
334
  })
361
335
  })
@@ -451,11 +425,11 @@ describe('Proxy Fallback', () => {
451
425
  describe('Set Route is Proxied', () => {
452
426
  const fx = new Fixture('route-is-proxied.GET.200.json')
453
427
  beforeEach(async () => {
454
- await fx.sync()
428
+ await fx.write()
455
429
  await api.setProxyFallback('')
456
430
  })
457
431
  after(async () => {
458
- await fx.unlink()
432
+ await fx.delete()
459
433
  })
460
434
 
461
435
  test('422 for non-existing route', async () => {
@@ -495,10 +469,10 @@ describe('Proxy Fallback', () => {
495
469
 
496
470
  test('unsets autoStatus', async () => {
497
471
  const fx = new Fixture('unset-500-on-proxy.GET.200.txt')
498
- await fx.sync()
472
+ await fx.write()
499
473
  await api.setProxyFallback('https://example.test')
500
474
 
501
- const r0 = await api.toggleStatus(500, fx.method, fx.urlMask)
475
+ const r0 = await api.toggleStatus(fx.method, fx.urlMask, 500)
502
476
  const b0 = await r0.json()
503
477
  equal(b0.proxied, false)
504
478
  equal(b0.autoStatus, 500)
@@ -508,14 +482,14 @@ describe('Proxy Fallback', () => {
508
482
  equal(b1.proxied, true)
509
483
  equal(b1.autoStatus, 0)
510
484
 
511
- await fx.unlink()
485
+ await fx.delete()
512
486
  await api.setProxyFallback('')
513
487
  })
514
488
  })
515
489
 
516
490
  test('updating selected mock resets proxied flag', async () => {
517
491
  const fx = new Fixture('select-resets-proxied.GET.200.txt')
518
- await fx.sync()
492
+ await fx.write()
519
493
  await api.setProxyFallback('https://example.test')
520
494
  const r0 = await api.setRouteIsProxied(fx.method, fx.urlMask, true)
521
495
  equal((await r0.json()).proxied, true)
@@ -524,7 +498,7 @@ describe('Proxy Fallback', () => {
524
498
  equal((await r1.json()).proxied, false)
525
499
 
526
500
  await api.setProxyFallback('')
527
- await fx.unlink()
501
+ await fx.delete()
528
502
  })
529
503
  })
530
504
 
@@ -543,12 +517,10 @@ describe('404', () => {
543
517
  test('404s ignored files', async () => {
544
518
  const fx = new Fixture('ignored.GET.200.json~')
545
519
  await fx.write()
546
- await api.reset()
547
520
  const r = await fx.request()
548
521
  equal(r.status, 404)
549
- await fx.unlink()
522
+ await fx.delete()
550
523
  })
551
-
552
524
  })
553
525
 
554
526
 
@@ -561,8 +533,8 @@ describe('Default Mock', () => {
561
533
  await api.reset()
562
534
  })
563
535
  after(async () => {
564
- await fxA.unlink()
565
- await fxB.unlink()
536
+ await fxA.delete()
537
+ await fxB.delete()
566
538
  })
567
539
 
568
540
  test('sorts mocks list with the user specified default first for dashboard display', async () => {
@@ -585,22 +557,22 @@ describe('Dynamic Mocks', () => {
585
557
  const fx = new Fixture(
586
558
  'js-object.GET.200.js',
587
559
  'export default { FROM_JS: true }')
588
- await fx.sync()
560
+ await fx.write()
589
561
  const r = await fx.request()
590
562
  equal(r.headers.get('content-type'), mimeFor('.json'))
591
563
  deepEqual(await r.json(), { FROM_JS: true })
592
- await fx.unlink()
564
+ await fx.delete()
593
565
  })
594
566
 
595
567
  test('TS array is sent as JSON', async () => {
596
568
  const fx = new Fixture(
597
569
  'js-object.GET.200.ts',
598
570
  'export default ["from ts"]')
599
- await fx.sync()
571
+ await fx.write()
600
572
  const r = await fx.request()
601
573
  equal(r.headers.get('content-type'), mimeFor('.json'))
602
574
  deepEqual(await r.json(), ['from ts'])
603
- await fx.unlink()
575
+ await fx.delete()
604
576
  })
605
577
  })
606
578
 
@@ -611,14 +583,14 @@ describe('Dynamic Function Mocks', () => {
611
583
  export default function (req, response) {
612
584
  return Buffer.from('A')
613
585
  }`)
614
- await fx.sync()
586
+ await fx.write()
615
587
  const r = await fx.request()
616
588
  equal(r.status, 200)
617
589
  equal(r.headers.get('content-length'), '1')
618
590
  equal(r.headers.get('content-type'), mimeFor('.json'))
619
591
  equal(r.headers.get('set-cookie'), CONFIG.cookies.userA)
620
592
  equal(await r.text(), 'A')
621
- await fx.unlink()
593
+ await fx.delete()
622
594
  })
623
595
 
624
596
  test('can override filename convention (also supports TS)', async () => {
@@ -629,14 +601,14 @@ describe('Dynamic Function Mocks', () => {
629
601
  response.setHeader('set-cookie', 'custom-cookie')
630
602
  return new Uint8Array([65, 65])
631
603
  }`)
632
- await fx.sync()
604
+ await fx.write()
633
605
  const r = await fx.request({ method: 'POST' })
634
606
  equal(r.status, 201)
635
607
  equal(r.headers.get('content-length'), String(2))
636
608
  equal(r.headers.get('content-type'), 'custom-mime')
637
609
  equal(r.headers.get('set-cookie'), 'custom-cookie')
638
610
  equal(await r.text(), 'AA')
639
- await fx.unlink()
611
+ await fx.delete()
640
612
  })
641
613
  })
642
614
 
@@ -645,9 +617,9 @@ describe('Static Files', () => {
645
617
  const fxsIndex = new FixtureStatic('index.html', '<h1>Index</h1>')
646
618
  const fxsAsset = new FixtureStatic('asset-script.js', 'const a = 1')
647
619
  before(async () => {
620
+ await api.reset()
648
621
  await fxsIndex.write()
649
622
  await fxsAsset.write()
650
- await api.reset()
651
623
  })
652
624
 
653
625
  describe('Static File Serving', () => {
@@ -690,9 +662,9 @@ describe('Static Files', () => {
690
662
  })
691
663
 
692
664
  test('unregisters static route', async () => {
693
- await fxsIndex.unlink()
694
- await fxsAsset.unlink()
695
665
  await api.reset()
666
+ await fxsIndex.delete()
667
+ await fxsAsset.delete()
696
668
  const s = await fetchState()
697
669
  equal(s.brokersByMethod.GET?.[fxsIndex.urlMask], undefined)
698
670
  equal(s.brokersByMethod.GET?.[fxsAsset.urlMask], undefined)
@@ -703,70 +675,70 @@ describe('Static Files', () => {
703
675
  describe('Auto Status', () => {
704
676
  test('toggling ON 500 on a route without 500 auto-generates one', async () => {
705
677
  const fx = new Fixture('toggling-500-without-500.GET.200.json')
706
- await fx.sync()
678
+ await fx.write()
707
679
  equal((await fx.request()).status, fx.status)
708
680
 
709
- const bp0 = await api.toggleStatus(500, fx.method, fx.urlMask)
681
+ const bp0 = await api.toggleStatus(fx.method, fx.urlMask, 500)
710
682
  const b0 = await bp0.json()
711
683
  equal(b0.autoStatus, 500)
712
684
  equal(b0.status, 500)
713
685
  equal((await fx.request()).status, 500)
714
686
 
715
- const r1 = await api.toggleStatus(500, fx.method, fx.urlMask)
687
+ const r1 = await api.toggleStatus(fx.method, fx.urlMask, 500)
716
688
  equal((await r1.json()).autoStatus, 0)
717
689
  equal((await fx.request()).status, fx.status)
718
690
  })
719
691
 
720
692
  test('toggling ON 500 picks existing 500 and toggling OFF selects default', async () => {
693
+ await api.reset()
721
694
  const fx200 = new Fixture('reg-error.GET.200.txt')
722
695
  const fx500 = new Fixture('reg-error.GET.500.txt')
723
696
  await fx200.write()
724
697
  await fx500.write()
725
- await api.reset()
726
698
 
727
- const bp0 = await api.toggleStatus(500, fx200.method, fx200.urlMask)
699
+ const bp0 = await api.toggleStatus(fx200.method, fx200.urlMask, 500)
728
700
  const b0 = await bp0.json()
729
701
  equal(b0.autoStatus, 0)
730
702
  equal(b0.status, 500)
731
703
  equal(await (await fx200.request()).text(), fx500.body)
732
704
 
733
- const bp1 = await api.toggleStatus(500, fx200.method, fx200.urlMask)
705
+ const bp1 = await api.toggleStatus(fx200.method, fx200.urlMask, 500)
734
706
  const b1 = await bp1.json()
735
707
  equal(b0.autoStatus, 0)
736
708
  equal(b1.status, 200)
737
709
  equal(await (await fx200.request()).text(), fx200.body)
738
710
 
739
- await fx200.unlink()
740
- await fx500.unlink()
711
+ await fx200.delete()
712
+ await fx500.delete()
741
713
  })
742
714
 
743
715
  test('toggling ON 500 unsets `proxied` flag', async () => {
744
716
  const fx = new Fixture('proxied-to-500.GET.200.txt')
745
- await fx.sync()
717
+ await fx.write()
746
718
  await api.setProxyFallback('https://example.test')
747
719
  await api.setRouteIsProxied(fx.method, fx.urlMask, true)
748
- await api.toggleStatus(500, fx.method, fx.urlMask)
720
+ await api.toggleStatus(fx.method, fx.urlMask, 500)
749
721
  equal((await fx.fetchBroker()).proxied, false)
750
- await fx.unlink()
722
+ await fx.delete()
751
723
  await api.setProxyFallback('')
752
724
  })
753
725
 
754
726
  test('toggling ON 404 for static routes', async () => {
755
727
  const fx = new FixtureStatic('static-404.txt')
756
- await fx.sync()
728
+ await fx.write()
757
729
  equal((await fx.request()).status, 200)
758
730
 
759
- const bp0 = await api.toggleStatus(404, fx.method, fx.urlMask)
731
+ const bp0 = await api.toggleStatus(fx.method, fx.urlMask, 404)
760
732
  const b0 = await bp0.json()
761
733
  equal(b0.autoStatus, 404)
762
734
  equal(b0.status, 404)
763
735
  equal((await fx.request()).status, 404)
764
736
 
765
- const r1 = await api.toggleStatus(404, fx.method, fx.urlMask)
737
+ const r1 = await api.toggleStatus(fx.method, fx.urlMask, 404)
766
738
  equal((await r1.json()).autoStatus, 0)
767
739
  equal((await fx.request()).status, 200)
768
740
 
769
- await fx.unlink()
741
+ await fx.delete()
770
742
  })
771
743
  })
772
744
 
@@ -774,10 +746,10 @@ describe('Auto Status', () => {
774
746
  describe('Index-like routes', () => {
775
747
  test('resolves dirs to the file without urlMask', async () => {
776
748
  const fx = new Fixture('.GET.200.json')
777
- await fx.sync()
749
+ await fx.write()
778
750
  const r = await request('/')
779
751
  equal(await r.text(), fx.body)
780
- await fx.unlink()
752
+ await fx.delete()
781
753
  })
782
754
  })
783
755
 
@@ -785,20 +757,20 @@ describe('Index-like routes', () => {
785
757
  describe('MIME', () => {
786
758
  test('derives content-type from known mime', async () => {
787
759
  const fx = new Fixture('tmp.GET.200.json')
788
- await fx.sync()
760
+ await fx.write()
789
761
  const r = await fx.request()
790
762
  equal(r.headers.get('content-type'), 'application/json')
791
- await fx.unlink()
763
+ await fx.delete()
792
764
  })
793
765
 
794
766
  test('derives content-type from custom mime', async () => {
795
767
  const ext = Object.keys(CONFIG.extraMimes)[0]
796
768
  const mime = Object.values(CONFIG.extraMimes)[0]
797
769
  const fx = new Fixture(`tmp.GET.200.${ext}`)
798
- await fx.sync()
770
+ await fx.write()
799
771
  const r = await fx.request()
800
772
  equal(r.headers.get('content-type'), mime)
801
- await fx.unlink()
773
+ await fx.delete()
802
774
  })
803
775
  })
804
776
 
@@ -819,8 +791,8 @@ describe('Headers', () => {
819
791
 
820
792
  describe('Method and Status', () => {
821
793
  const fx = new Fixture('uncommon-method.ACL.201.txt')
822
- before(async () => await fx.sync())
823
- after(async () => await fx.unlink())
794
+ before(async () => await fx.write())
795
+ after(async () => await fx.delete())
824
796
 
825
797
  test('dispatches the response status', async () => {
826
798
  const r = await fx.request()
@@ -846,11 +818,10 @@ describe('Select', () => {
846
818
  before(async () => {
847
819
  await fx.write()
848
820
  await fxAlt.write()
849
- await api.reset()
850
821
  })
851
822
  after(async () => {
852
- await fx.unlink()
853
- await fxAlt.unlink()
823
+ await fx.delete()
824
+ await fxAlt.delete()
854
825
  })
855
826
 
856
827
  test('422 when updating non-existing mock', async () => {
@@ -887,13 +858,12 @@ describe('Bulk Select', () => {
887
858
  await fxIotaB.write()
888
859
  await fxKappaA.write()
889
860
  await fxKappaB.write()
890
- await api.reset()
891
861
  })
892
862
  after(async () => {
893
- await fxIota.unlink()
894
- await fxIotaB.unlink()
895
- await fxKappaA.unlink()
896
- await fxKappaB.unlink()
863
+ await fxIota.delete()
864
+ await fxIotaB.delete()
865
+ await fxKappaA.delete()
866
+ await fxKappaB.delete()
897
867
  })
898
868
 
899
869
  test('extracts all comments without duplicates', async () =>
@@ -919,9 +889,9 @@ describe('Bulk Select', () => {
919
889
  describe('Decoding URLs', () => {
920
890
  test('allows dots, spaces, amp, etc.', async () => {
921
891
  const fx = new Fixture('dot.in.path and amp & and colon:.GET.200.txt')
922
- await fx.sync()
892
+ await fx.write()
923
893
  equal(await (await fx.request()).text(), fx.body)
924
- await fx.unlink()
894
+ await fx.delete()
925
895
  })
926
896
  })
927
897
 
@@ -937,13 +907,12 @@ describe('Dynamic Params', () => {
937
907
  await fx1.write()
938
908
  await fx2.write()
939
909
  await fx3.write()
940
- await api.reset()
941
910
  })
942
911
  after(async () => {
943
- await fx0.unlink()
944
- await fx1.unlink()
945
- await fx2.unlink()
946
- await fx3.unlink()
912
+ await fx0.delete()
913
+ await fx1.delete()
914
+ await fx2.delete()
915
+ await fx3.delete()
947
916
  })
948
917
 
949
918
  test('variable at end', async () => {
@@ -978,8 +947,8 @@ describe('Query String', () => {
978
947
  await api.reset()
979
948
  })
980
949
  after(async () => {
981
- await fx0.unlink()
982
- await fx1.unlink()
950
+ await fx0.delete()
951
+ await fx1.delete()
983
952
  })
984
953
 
985
954
  test('multiple params', async () => {
@@ -1003,12 +972,54 @@ describe('Query String', () => {
1003
972
 
1004
973
  test('head for get. returns the headers without body only for GETs requested as HEAD', async () => {
1005
974
  const fx = new Fixture('head-get.GET.200.json')
1006
- await fx.sync()
975
+ await fx.write()
1007
976
  const r = await fx.request({ method: 'HEAD' })
1008
977
  equal(r.status, 200)
1009
978
  equal(r.headers.get('content-length'), String(Buffer.byteLength(fx.body)))
1010
979
  equal(await r.text(), '')
1011
- await fx.unlink()
980
+ await fx.delete()
981
+ })
982
+
983
+
984
+ describe('Write and Delete Mock', () => {
985
+ test('guards mocksDir', async () => {
986
+ const r = await api.writeMock('../outside.txt', '')
987
+ equal(r.status, 403)
988
+
989
+ const r2 = await api.deleteMock('../outside.txt')
990
+ equal(r2.status, 403)
991
+ })
992
+
993
+ test('write and delete (with watcher)', async () => {
994
+ await api.setWatchMocks(true)
995
+ const file = 'new-mock.GET.200.txt'
996
+
997
+ const nextVerPromise = resolveOnNextSyncVersion()
998
+ const res = await api.writeMock(file, '')
999
+ equal(res.status, 200)
1000
+ await nextVerPromise
1001
+ const r = await request('/new-mock')
1002
+ equal(r.status, 200)
1003
+
1004
+ const nextVerPromise2 = resolveOnNextSyncVersion()
1005
+ await api.deleteMock(file)
1006
+ await nextVerPromise2
1007
+ const r2 = await request('/new-mock')
1008
+ equal(r2.status, 404)
1009
+ })
1010
+
1011
+ test('write and delete (without watcher)', async () => {
1012
+ await api.setWatchMocks(false)
1013
+ const file = 'manual-mock.GET.200.txt'
1014
+
1015
+ await api.writeMock(file, '')
1016
+ const r = await request('/manual-mock')
1017
+ equal(r.status, 200)
1018
+
1019
+ await api.deleteMock(file)
1020
+ const r2 = await request('/manual-mock')
1021
+ equal(r2.status, 404)
1022
+ })
1012
1023
  })
1013
1024
 
1014
1025
 
@@ -1027,76 +1038,102 @@ describe('Watch mocks API toggler', () => {
1027
1038
 
1028
1039
 
1029
1040
  describe('Registering Mocks', () => {
1030
- const fxA = new Fixture('register(default).GET.200.json')
1031
- const fxB = new Fixture('register(alt).GET.200.json')
1041
+ // simulates user interacting with the file-system directly
1042
+ class FixtureExternal extends Fixture {
1043
+ constructor(props) {
1044
+ super(props)
1045
+ }
1046
+
1047
+ async writeExternally() {
1048
+ const nextVerPromise = resolveOnNextSyncVersion()
1049
+ await sleep(0) // next macro task
1050
+ await this.write()
1051
+ await nextVerPromise
1052
+ }
1053
+
1054
+ async deleteExternally() {
1055
+ const nextVerPromise = resolveOnNextSyncVersion()
1056
+ await sleep(0)
1057
+ await this.delete()
1058
+ await nextVerPromise
1059
+ }
1060
+ }
1061
+
1062
+ function sleep(ms) {
1063
+ return new Promise(resolve => setTimeout(resolve, ms))
1064
+ }
1065
+
1066
+
1067
+ const fxA = new FixtureExternal('register(default).GET.200.json')
1068
+ const fxB = new FixtureExternal('register(alt).GET.200.json')
1032
1069
 
1033
1070
  test('when watcher is off, newly added mocks do not get registered', async () => {
1034
1071
  await api.setWatchMocks(false)
1035
- const fx = new Fixture('non-auto-registered-file.GET.200.json')
1036
- await fx.write()
1037
- await sleep()
1072
+ const fx = new FixtureExternal('non-auto-registered-file.GET.200.json')
1073
+ await writeInMocksDir(fx.file, fx.body)
1074
+ await sleep(100)
1038
1075
  equal(await fx.fetchBroker(), undefined)
1039
- await fx.unlink()
1076
+ await rmFromMocksDir(fx.file)
1040
1077
  })
1041
1078
 
1042
1079
  test('register', async () => {
1043
1080
  await api.setWatchMocks(true)
1044
- await fxA.register()
1045
- await fxB.register()
1081
+ await fxA.writeExternally()
1082
+ await fxB.writeExternally()
1046
1083
  const b = await fxA.fetchBroker()
1047
1084
  deepEqual(b.mocks, [fxA.file, fxB.file])
1048
1085
  })
1049
1086
 
1050
1087
  test('unregistering selected ensures a mock is selected', async () => {
1051
1088
  await api.select(fxA.file)
1052
- await fxA.unregister()
1089
+ await fxA.deleteExternally()
1053
1090
  const b = await fxA.fetchBroker()
1054
1091
  deepEqual(b.mocks, [fxB.file])
1055
1092
  })
1056
1093
 
1057
1094
  test('unregistering the last mock removes broker', async () => {
1058
- await fxB.unregister()
1095
+ await fxB.deleteExternally()
1059
1096
  const b = await fxB.fetchBroker()
1060
1097
  equal(b, undefined)
1061
1098
  })
1062
1099
 
1063
1100
  test('registering a 500 unsets autoStatus', async () => {
1064
- const fx200 = new Fixture('reg-error.GET.200.txt')
1065
- const fx500 = new Fixture('reg-error.GET.500.txt')
1066
- await fx200.register()
1067
- await api.toggleStatus(500, fx200.method, fx200.urlMask)
1101
+ const fx200 = new FixtureExternal('reg-error.GET.200.txt')
1102
+ const fx500 = new FixtureExternal('reg-error.GET.500.txt')
1103
+ await fx200.writeExternally()
1104
+ await api.toggleStatus(fx200.method, fx200.urlMask, 500)
1068
1105
  const b0 = await fx200.fetchBroker()
1069
1106
  equal(b0.autoStatus, 500)
1070
- await fx500.register()
1107
+ await fx500.writeExternally()
1071
1108
  const b1 = await fx200.fetchBroker()
1072
1109
  equal(b1.autoStatus, 0)
1073
1110
  deepEqual(b1.mocks, [
1074
1111
  fx200.file,
1075
1112
  fx500.file
1076
1113
  ])
1077
- await fx200.unregister()
1078
- await fx500.unregister()
1114
+ await fx200.deleteExternally()
1115
+ await fx500.deleteExternally()
1079
1116
  })
1080
1117
 
1081
1118
  describe('getSyncVersion', () => {
1082
- const fx0 = new Fixture('reg0/runtime0.GET.200.txt')
1119
+ const fx0 = new FixtureExternal('reg0/runtime0.GET.200.txt')
1083
1120
  let version
1084
1121
  before(async () => {
1085
1122
  await makeDirInMocks('reg0')
1086
- await fx0.sync()
1123
+ await writeInMocksDir(fx0.file, fx0.body)
1087
1124
  version = await resolveOnNextSyncVersion(-1)
1088
1125
  })
1089
1126
 
1090
- const fx = new Fixture('runtime1.GET.200.txt')
1127
+ const fx = new FixtureExternal('runtime1.GET.200.txt')
1091
1128
  test('responds when a file is added', async () => {
1092
1129
  const prom = resolveOnNextSyncVersion(version)
1093
- await fx.write()
1130
+ await writeInMocksDir(fx.file, fx.body)
1094
1131
  equal(await prom, version + 1)
1095
1132
  })
1096
1133
 
1097
1134
  test('responds when a file is deleted', async () => {
1098
1135
  const prom = resolveOnNextSyncVersion(version + 1)
1099
- await fx.unlink()
1136
+ await rmFromMocksDir(fx.file)
1100
1137
  equal(await prom, version + 2)
1101
1138
  })
1102
1139
 
@@ -1112,13 +1149,6 @@ describe('Registering Mocks', () => {
1112
1149
  })
1113
1150
 
1114
1151
 
1115
-
1116
-
1117
- function sleep(ms = 100) {
1118
- return new Promise(resolve => setTimeout(resolve, ms))
1119
- }
1120
-
1121
-
1122
1152
  /** In Node, there's no EventSource, so we work around it like this.
1123
1153
  * This is for listening to real-time updates. It responds when a new mock is added, deleted, or renamed. */
1124
1154
  async function resolveOnNextSyncVersion(currSyncVer = undefined) {
@@ -1143,3 +1173,4 @@ async function resolveOnNextSyncVersion(currSyncVer = undefined) {
1143
1173
  }
1144
1174
  }
1145
1175
  }
1176
+
@@ -38,11 +38,11 @@ export async function proxy(req, response, delay) {
38
38
 
39
39
  if (config.collectProxied) {
40
40
  const ext = extFor(proxyResponse.headers.get('content-type'))
41
- saveMockToDisk(req.url, req.method, proxyResponse.status, ext, body)
41
+ await saveMockToDisk(req.url, req.method, proxyResponse.status, ext, body)
42
42
  }
43
43
  }
44
44
 
45
- function saveMockToDisk(url, method, status, ext, body) {
45
+ async function saveMockToDisk(url, method, status, ext, body) {
46
46
  if (config.formatCollectedJSON && ext === 'json')
47
47
  try {
48
48
  body = JSON.stringify(JSON.parse(body), null, ' ')
@@ -52,7 +52,7 @@ function saveMockToDisk(url, method, status, ext, body) {
52
52
  }
53
53
 
54
54
  try {
55
- write(makeUniqueMockFilename(url, method, status, ext), body)
55
+ await write(makeUniqueMockFilename(url, method, status, ext), body)
56
56
  }
57
57
  catch (err) {
58
58
  logger.warn('Write access denied', err)
@@ -42,6 +42,11 @@ const uiSyncVersion = new class extends EventEmitter {
42
42
  }
43
43
 
44
44
 
45
+ export function notifyARR() {
46
+ uiSyncVersion.increment()
47
+ }
48
+
49
+
45
50
  export function watchMocksDir() {
46
51
  const dir = config.mocksDir
47
52
  mocksWatcher = mocksWatcher || watch(dir, { recursive: true, persistent: false }, (_, file) => {
@@ -19,6 +19,7 @@ import { jsToJsonPlugin } from './MockDispatcherPlugins.js'
19
19
  const schema = {
20
20
  mocksDir: [resolve('mockaton-mocks'), isDirectory],
21
21
  ignore: [/(\.DS_Store|~)$/, is(RegExp)],
22
+ readOnly: [true, is(Boolean)],
22
23
  watcherEnabled: [true, is(Boolean)],
23
24
  watcherDebounceMs: [80, ms => Number.isInteger(ms) && ms >= 0],
24
25
 
@@ -49,6 +49,12 @@ export class ServerResponse extends http.ServerResponse {
49
49
  this.end()
50
50
  }
51
51
 
52
+ forbidden() {
53
+ this.statusCode = 403
54
+ logger.access(this)
55
+ this.end()
56
+ }
57
+
52
58
  notFound() {
53
59
  this.statusCode = 404
54
60
  logger.access(this)
@@ -1,5 +1,6 @@
1
- import { join, dirname, sep, posix } from 'node:path'
2
- import { lstatSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs'
1
+ import { join, dirname, sep, posix, resolve } from 'node:path'
2
+ import { lstatSync, readdirSync } from 'node:fs'
3
+ import { mkdir, writeFile, unlink, realpath } from 'node:fs/promises'
3
4
 
4
5
 
5
6
  export const isFile = path => lstatSync(path, { throwIfNoEntry: false })?.isFile()
@@ -19,7 +20,27 @@ export function listFilesRecursively(dir) {
19
20
  }
20
21
  }
21
22
 
22
- export function write(path, body) {
23
- mkdirSync(dirname(path), { recursive: true })
24
- writeFileSync(path, body)
23
+ export async function write(path, body) {
24
+ await mkdir(dirname(path), { recursive: true })
25
+ await writeFile(path, body)
26
+ }
27
+
28
+ export async function rm(path) {
29
+ await unlink(path)
30
+ }
31
+
32
+
33
+ /** @returns {string | null} absolute path if it’s within `baseDir` */
34
+ export async function resolveIn(baseDir, file) {
35
+ try {
36
+ const parent = await realpath(baseDir)
37
+ const child = resolve(parent, file)
38
+ return child.startsWith(parent + sep)
39
+ ? child
40
+ : null
41
+ }
42
+ catch (e) {
43
+ console.error('DDDDDD', e)
44
+ return null
45
+ }
25
46
  }