mockaton 11.2.5 → 11.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -841
- package/index.js +1 -1
- package/package.json +7 -1
- package/src/client/ApiCommander.js +54 -39
- package/src/client/ApiConstants.js +1 -1
- package/src/client/Filename.js +3 -3
- package/src/client/app-store.test.js +81 -0
- package/src/client/app.js +83 -68
- package/src/client/dom-utils.js +5 -3
- package/src/client/styles.css +83 -48
- package/src/server/Api.js +112 -152
- package/src/server/Filename.js +8 -6
- package/src/server/MockBroker.js +1 -1
- package/src/server/MockDispatcher.js +5 -5
- package/src/server/Mockaton.js +23 -20
- package/src/server/Mockaton.test.js +1190 -0
- package/src/server/ProxyRelay.js +5 -5
- package/src/server/StaticDispatcher.js +4 -4
- package/src/server/Watcher.js +30 -3
- package/src/server/WatcherDevClient.js +37 -5
- package/src/server/cli.js +1 -0
- package/src/server/config.js +3 -2
- package/src/server/mockBrokersCollection.js +2 -1
- package/src/server/utils/{http-request.js → HttpIncomingMessage.js} +7 -1
- package/src/server/utils/HttpServerResponse.js +117 -0
- package/src/server/utils/fs.js +1 -0
- package/src/server/utils/http-cors.js +1 -1
- package/src/server/utils/http-cors.test.js +225 -0
- package/src/server/utils/logger.js +1 -1
- package/src/server/utils/mime.test.js +24 -0
- package/src/server/utils/validate.test.js +47 -0
- package/src/server/utils/http-response.js +0 -107
package/src/server/ProxyRelay.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { randomUUID } from 'node:crypto'
|
|
3
3
|
|
|
4
|
-
import { config } from './config.js'
|
|
5
4
|
import { extFor } from './utils/mime.js'
|
|
6
5
|
import { write, isFile } from './utils/fs.js'
|
|
6
|
+
import { readBody, BodyReaderError } from './utils/HttpIncomingMessage.js'
|
|
7
|
+
|
|
8
|
+
import { config } from './config.js'
|
|
7
9
|
import { makeMockFilename } from './Filename.js'
|
|
8
|
-
import { readBody, BodyReaderError } from './utils/http-request.js'
|
|
9
|
-
import { sendUnprocessable, sendBadGateway } from './utils/http-response.js'
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
export async function proxy(req, response, delay) {
|
|
@@ -22,9 +22,9 @@ export async function proxy(req, response, delay) {
|
|
|
22
22
|
}
|
|
23
23
|
catch (error) { // TESTME
|
|
24
24
|
if (error instanceof BodyReaderError)
|
|
25
|
-
|
|
25
|
+
response.unprocessable(error.name)
|
|
26
26
|
else
|
|
27
|
-
|
|
27
|
+
response.badGateway(error)
|
|
28
28
|
return
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs'
|
|
|
4
4
|
import { isFile } from './utils/fs.js'
|
|
5
5
|
import { logger } from './utils/logger.js'
|
|
6
6
|
import { mimeFor } from './utils/mime.js'
|
|
7
|
+
|
|
7
8
|
import { brokerByRoute } from './staticCollection.js'
|
|
8
9
|
import { config, calcDelay } from './config.js'
|
|
9
|
-
import { sendMockNotFound, sendPartialContent } from './utils/http-response.js'
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
// TODO HEAD
|
|
@@ -15,19 +15,19 @@ export async function dispatchStatic(req, response) {
|
|
|
15
15
|
|
|
16
16
|
setTimeout(async () => {
|
|
17
17
|
if (!broker || broker.status === 404) {
|
|
18
|
-
|
|
18
|
+
response.mockNotFound()
|
|
19
19
|
return
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const file = join(config.staticDir, broker.route)
|
|
23
23
|
if (!isFile(file)) {
|
|
24
|
-
|
|
24
|
+
response.mockNotFound()
|
|
25
25
|
return
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
logger.accessMock(req.url, 'static200')
|
|
29
29
|
if (req.headers.range)
|
|
30
|
-
await
|
|
30
|
+
await response.partialContent(req.headers.range, file)
|
|
31
31
|
else {
|
|
32
32
|
response.setHeader('Content-Type', mimeFor(file))
|
|
33
33
|
response.end(readFileSync(file))
|
package/src/server/Watcher.js
CHANGED
|
@@ -2,19 +2,25 @@ import { join } from 'node:path'
|
|
|
2
2
|
import { watch } from 'node:fs'
|
|
3
3
|
import { EventEmitter } from 'node:events'
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
HEADER_SYNC_VERSION,
|
|
7
|
+
LONG_POLL_SERVER_TIMEOUT
|
|
8
|
+
} from './ApiConstants.js'
|
|
9
|
+
|
|
5
10
|
import { config } from './config.js'
|
|
6
11
|
import { isFile, isDirectory } from './utils/fs.js'
|
|
12
|
+
|
|
7
13
|
import * as staticCollection from './staticCollection.js'
|
|
8
14
|
import * as mockBrokerCollection from './mockBrokersCollection.js'
|
|
9
15
|
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
|
-
* ARR = Add, Remove, or Rename Mock
|
|
18
|
+
* ARR Event = Add, Remove, or Rename Mock
|
|
13
19
|
*
|
|
14
20
|
* The emitter is debounced so it handles e.g. bulk deletes,
|
|
15
21
|
* and also renames, which are two events (delete + add).
|
|
16
22
|
*/
|
|
17
|
-
|
|
23
|
+
const uiSyncVersion = new class extends EventEmitter {
|
|
18
24
|
delay = Number(process.env.MOCKATON_WATCHER_DEBOUNCE_MS ?? 80)
|
|
19
25
|
version = 0
|
|
20
26
|
|
|
@@ -39,6 +45,7 @@ export const uiSyncVersion = new class extends EventEmitter {
|
|
|
39
45
|
}
|
|
40
46
|
}
|
|
41
47
|
|
|
48
|
+
|
|
42
49
|
export function watchMocksDir() {
|
|
43
50
|
const dir = config.mocksDir
|
|
44
51
|
watch(dir, { recursive: true, persistent: false }, (_, file) => {
|
|
@@ -61,6 +68,7 @@ export function watchMocksDir() {
|
|
|
61
68
|
})
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
|
|
64
72
|
export function watchStaticDir() {
|
|
65
73
|
const dir = config.staticDir
|
|
66
74
|
if (!dir)
|
|
@@ -86,4 +94,23 @@ export function watchStaticDir() {
|
|
|
86
94
|
})
|
|
87
95
|
}
|
|
88
96
|
|
|
89
|
-
|
|
97
|
+
|
|
98
|
+
/** Realtime notify ARR Events */
|
|
99
|
+
export function longPollClientSyncVersion(req, response) {
|
|
100
|
+
const clientVersion = req.headers[HEADER_SYNC_VERSION]
|
|
101
|
+
if (clientVersion !== undefined && uiSyncVersion.version !== Number(clientVersion)) {
|
|
102
|
+
// e.g., tab was hidden while new mocks were added or removed
|
|
103
|
+
response.json(uiSyncVersion.version)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
function onARR() {
|
|
107
|
+
uiSyncVersion.unsubscribe(onARR)
|
|
108
|
+
response.json(uiSyncVersion.version)
|
|
109
|
+
}
|
|
110
|
+
response.setTimeout(LONG_POLL_SERVER_TIMEOUT, onARR)
|
|
111
|
+
req.on('error', () => {
|
|
112
|
+
uiSyncVersion.unsubscribe(onARR)
|
|
113
|
+
response.destroy()
|
|
114
|
+
})
|
|
115
|
+
uiSyncVersion.subscribe(onARR)
|
|
116
|
+
}
|
|
@@ -1,17 +1,49 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { join } from 'node:path'
|
|
2
2
|
import { EventEmitter } from 'node:events'
|
|
3
|
+
import { watch, readdirSync } from 'node:fs'
|
|
4
|
+
import { LONG_POLL_SERVER_TIMEOUT } from './ApiConstants.js'
|
|
3
5
|
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
const DEV = process.env.NODE_ENV === 'development'
|
|
8
|
+
|
|
9
|
+
export const CLIENT_DIR = join(import.meta.dirname, '../client')
|
|
10
|
+
export const DASHBOARD_ASSETS = readdirSync(CLIENT_DIR)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
const devClientWatcher = new class extends EventEmitter {
|
|
6
14
|
emit(file) { super.emit('RELOAD', file) }
|
|
7
15
|
subscribe(listener) { this.once('RELOAD', listener) }
|
|
8
16
|
unsubscribe(listener) { this.removeListener('RELOAD', listener) }
|
|
9
17
|
}
|
|
10
18
|
|
|
11
|
-
|
|
12
|
-
//
|
|
19
|
+
|
|
20
|
+
// Although `client/indexHtml.js` is watched, it returns a stale version.
|
|
21
|
+
// i.e., it would need to be a dynamic import + cache busting.
|
|
13
22
|
export function watchDevSPA() {
|
|
14
|
-
watch(
|
|
23
|
+
watch(CLIENT_DIR, (_, file) => {
|
|
15
24
|
devClientWatcher.emit(file)
|
|
16
25
|
})
|
|
17
26
|
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
/** Realtime notify Dev UI changes */
|
|
30
|
+
export function longPollDevClientHotReload(req, response) {
|
|
31
|
+
if (!DEV) {
|
|
32
|
+
response.notFound()
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function onDevChange(file) {
|
|
37
|
+
devClientWatcher.unsubscribe(onDevChange)
|
|
38
|
+
response.json(file)
|
|
39
|
+
}
|
|
40
|
+
response.setTimeout(LONG_POLL_SERVER_TIMEOUT, () => {
|
|
41
|
+
devClientWatcher.unsubscribe(onDevChange)
|
|
42
|
+
response.json('')
|
|
43
|
+
})
|
|
44
|
+
req.on('error', () => {
|
|
45
|
+
devClientWatcher.unsubscribe(onDevChange)
|
|
46
|
+
response.destroy()
|
|
47
|
+
})
|
|
48
|
+
devClientWatcher.subscribe(onDevChange)
|
|
49
|
+
}
|
package/src/server/cli.js
CHANGED
package/src/server/config.js
CHANGED
|
@@ -3,11 +3,12 @@ import { resolve } from 'node:path'
|
|
|
3
3
|
import { logger } from './utils/logger.js'
|
|
4
4
|
import { isDirectory } from './utils/fs.js'
|
|
5
5
|
import { openInBrowser } from './utils/openInBrowser.js'
|
|
6
|
-
import { jsToJsonPlugin } from './MockDispatcher.js'
|
|
7
6
|
import { optional, is, validate } from './utils/validate.js'
|
|
8
|
-
import { SUPPORTED_METHODS } from './utils/
|
|
7
|
+
import { SUPPORTED_METHODS } from './utils/HttpIncomingMessage.js'
|
|
9
8
|
import { validateCorsAllowedMethods, validateCorsAllowedOrigins } from './utils/http-cors.js'
|
|
10
9
|
|
|
10
|
+
import { jsToJsonPlugin } from './MockDispatcher.js'
|
|
11
|
+
|
|
11
12
|
|
|
12
13
|
/** @type {{
|
|
13
14
|
* [K in keyof Config]-?: [
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { basename } from 'node:path'
|
|
2
2
|
|
|
3
3
|
import { logger } from './utils/logger.js'
|
|
4
|
+
import { listFilesRecursively } from './utils/fs.js'
|
|
5
|
+
|
|
4
6
|
import { cookie } from './cookie.js'
|
|
5
7
|
import { MockBroker } from './MockBroker.js'
|
|
6
|
-
import { listFilesRecursively } from './utils/fs.js'
|
|
7
8
|
import { config, isFileAllowed } from './config.js'
|
|
8
9
|
import { parseFilename, validateFilename } from './Filename.js'
|
|
9
10
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { METHODS } from 'node:http'
|
|
1
|
+
import http, { METHODS } from 'node:http'
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
export const SUPPORTED_METHODS = METHODS
|
|
@@ -12,6 +12,12 @@ export class BodyReaderError extends Error {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export class IncomingMessage extends http.IncomingMessage {
|
|
16
|
+
json() {
|
|
17
|
+
return readBody(this, JSON.parse)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
export const parseJSON = req => readBody(req, JSON.parse)
|
|
16
22
|
|
|
17
23
|
export function readBody(req, parser = a => a) {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
import fs, { readFileSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
import { logger } from './logger.js'
|
|
5
|
+
import { mimeFor } from './mime.js'
|
|
6
|
+
import { HEADER_502 } from '../ApiConstants.js'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
export class ServerResponse extends http.ServerResponse {
|
|
10
|
+
setHeaderList(headers) {
|
|
11
|
+
for (let i = 0; i < headers.length; i += 2)
|
|
12
|
+
this.setHeader(headers[i], headers[i + 1])
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
ok() {
|
|
16
|
+
logger.access(this)
|
|
17
|
+
this.end()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
html(html, csp) {
|
|
21
|
+
logger.access(this)
|
|
22
|
+
this.setHeader('Content-Type', mimeFor('html'))
|
|
23
|
+
this.setHeader('Content-Security-Policy', csp)
|
|
24
|
+
this.end(html)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
json(payload) {
|
|
28
|
+
logger.access(this)
|
|
29
|
+
this.setHeader('Content-Type', 'application/json')
|
|
30
|
+
this.end(JSON.stringify(payload))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
file(file) {
|
|
34
|
+
logger.access(this)
|
|
35
|
+
this.setHeader('Content-Type', mimeFor(file))
|
|
36
|
+
this.end(readFileSync(file, 'utf8'))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
noContent() {
|
|
40
|
+
this.statusCode = 204
|
|
41
|
+
logger.access(this)
|
|
42
|
+
this.end()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
badRequest() {
|
|
47
|
+
this.statusCode = 400
|
|
48
|
+
logger.access(this)
|
|
49
|
+
this.end()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
notFound() {
|
|
53
|
+
this.statusCode = 404
|
|
54
|
+
logger.access(this)
|
|
55
|
+
this.end()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
mockNotFound() {
|
|
59
|
+
this.statusCode = 404
|
|
60
|
+
logger.accessMock(this.req.url, '404')
|
|
61
|
+
this.end()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
uriTooLong() {
|
|
65
|
+
this.statusCode = 414
|
|
66
|
+
logger.access(this)
|
|
67
|
+
this.end()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
unprocessable(error) {
|
|
71
|
+
logger.access(this, error)
|
|
72
|
+
this.statusCode = 422
|
|
73
|
+
this.end(error)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
internalServerError(error) {
|
|
78
|
+
logger.error(500, this.req.url, error?.message || error, error?.stack || '')
|
|
79
|
+
this.statusCode = 500
|
|
80
|
+
this.end()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
badGateway(error) {
|
|
84
|
+
logger.warn('Fallback Proxy Error:', error.cause.message)
|
|
85
|
+
this.statusCode = 502
|
|
86
|
+
this.setHeader(HEADER_502, 1)
|
|
87
|
+
this.end()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async partialContent(range, file) {
|
|
92
|
+
const { size } = await fs.promises.lstat(file)
|
|
93
|
+
let [start, end] = range.replace(/bytes=/, '').split('-').map(n => parseInt(n, 10))
|
|
94
|
+
if (isNaN(end)) end = size - 1
|
|
95
|
+
if (isNaN(start)) start = size - end
|
|
96
|
+
|
|
97
|
+
if (start < 0 || start > end || start >= size || end >= size) {
|
|
98
|
+
this.statusCode = 416 // Range Not Satisfiable
|
|
99
|
+
this.setHeader('Content-Range', `bytes */${size}`)
|
|
100
|
+
this.end()
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
this.statusCode = 206 // Partial Content
|
|
104
|
+
this.setHeader('Accept-Ranges', 'bytes')
|
|
105
|
+
this.setHeader('Content-Range', `bytes ${start}-${end}/${size}`)
|
|
106
|
+
this.setHeader('Content-Type', mimeFor(file))
|
|
107
|
+
const reader = fs.createReadStream(file, { start, end })
|
|
108
|
+
const response = this
|
|
109
|
+
reader.on('open', function () {
|
|
110
|
+
this.pipe(response)
|
|
111
|
+
})
|
|
112
|
+
reader.on('error', error => {
|
|
113
|
+
this.internalServerError(error)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/server/utils/fs.js
CHANGED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { equal } from 'node:assert/strict'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import { createServer } from 'node:http'
|
|
4
|
+
import { describe, test, after } from 'node:test'
|
|
5
|
+
import { isPreflight, setCorsHeaders, CorsHeader as CH } from './http-cors.js'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
function headerIs(response, header, value) {
|
|
9
|
+
equal(response.headers.get(header), value)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const FooDotCom = 'http://foo.com'
|
|
13
|
+
const AllowedDotCom = 'http://allowed.com'
|
|
14
|
+
const NotAllowedDotCom = 'http://not-allowed.com'
|
|
15
|
+
|
|
16
|
+
await describe('CORS', async () => {
|
|
17
|
+
let corsConfig = {}
|
|
18
|
+
|
|
19
|
+
const server = createServer((req, response) => {
|
|
20
|
+
setCorsHeaders(req, response, corsConfig)
|
|
21
|
+
if (isPreflight(req)) {
|
|
22
|
+
response.statusCode = 204
|
|
23
|
+
response.end()
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
response.end('NON_PREFLIGHT')
|
|
27
|
+
})
|
|
28
|
+
await promisify(server.listen).bind(server, 0, '127.0.0.1')()
|
|
29
|
+
after(() => {
|
|
30
|
+
server.close()
|
|
31
|
+
})
|
|
32
|
+
function preflight(headers, method = 'OPTIONS') {
|
|
33
|
+
const { address, port } = server.address()
|
|
34
|
+
return fetch(`http://${address}:${port}/`, { method, headers })
|
|
35
|
+
}
|
|
36
|
+
function request(headers, method) {
|
|
37
|
+
const { address, port } = server.address()
|
|
38
|
+
return fetch(`http://${address}:${port}/`, { method, headers })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await describe('Identifies Preflight Requests', async () => {
|
|
42
|
+
const requiredRequestHeaders = {
|
|
43
|
+
[CH.Origin]: 'http://localhost:9999',
|
|
44
|
+
[CH.AcRequestMethod]: 'POST'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await test('Ignores non-OPTIONS requests', async () => {
|
|
48
|
+
const res = await request(requiredRequestHeaders, 'POST')
|
|
49
|
+
equal(await res.text(), 'NON_PREFLIGHT')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
await test(`Ignores non-parseable req ${CH.Origin} header`, async () => {
|
|
53
|
+
const headers = {
|
|
54
|
+
...requiredRequestHeaders,
|
|
55
|
+
[CH.Origin]: 'non-url'
|
|
56
|
+
}
|
|
57
|
+
const res = await preflight(headers)
|
|
58
|
+
equal(await res.text(), 'NON_PREFLIGHT')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
await test(`Ignores missing method in ${CH.AcRequestMethod} header`, async () => {
|
|
62
|
+
const headers = { ...requiredRequestHeaders }
|
|
63
|
+
delete headers[CH.AcRequestMethod]
|
|
64
|
+
const res = await preflight(headers)
|
|
65
|
+
equal(await res.text(), 'NON_PREFLIGHT')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
await test(`Ignores non-standard method in ${CH.AcRequestMethod} header`, async () => {
|
|
69
|
+
const headers = {
|
|
70
|
+
...requiredRequestHeaders,
|
|
71
|
+
[CH.AcRequestMethod]: 'NON_STANDARD'
|
|
72
|
+
}
|
|
73
|
+
const res = await preflight(headers)
|
|
74
|
+
equal(await res.text(), 'NON_PREFLIGHT')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
await test('204 valid preflights', async () => {
|
|
78
|
+
const res = await preflight(requiredRequestHeaders)
|
|
79
|
+
equal(res.status, 204)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
await describe('Preflight Response Headers', async () => {
|
|
84
|
+
await test('no origins allowed', async () => {
|
|
85
|
+
corsConfig = {
|
|
86
|
+
corsOrigins: [],
|
|
87
|
+
corsMethods: ['GET']
|
|
88
|
+
}
|
|
89
|
+
const p = await preflight({
|
|
90
|
+
[CH.Origin]: FooDotCom,
|
|
91
|
+
[CH.AcRequestMethod]: 'GET'
|
|
92
|
+
})
|
|
93
|
+
headerIs(p, CH.AcAllowOrigin, null)
|
|
94
|
+
headerIs(p, CH.AcAllowMethods, null)
|
|
95
|
+
headerIs(p, CH.AcAllowCredentials, null)
|
|
96
|
+
headerIs(p, CH.AcAllowHeaders, null)
|
|
97
|
+
headerIs(p, CH.AcMaxAge, null)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
await test('not in allowed origins', async () => {
|
|
101
|
+
corsConfig = {
|
|
102
|
+
corsOrigins: [AllowedDotCom],
|
|
103
|
+
corsMethods: ['GET']
|
|
104
|
+
}
|
|
105
|
+
const p = await preflight({
|
|
106
|
+
[CH.Origin]: NotAllowedDotCom,
|
|
107
|
+
[CH.AcRequestMethod]: 'GET'
|
|
108
|
+
})
|
|
109
|
+
headerIs(p, CH.AcAllowOrigin, null)
|
|
110
|
+
headerIs(p, CH.AcAllowMethods, null)
|
|
111
|
+
headerIs(p, CH.AcAllowCredentials, null)
|
|
112
|
+
headerIs(p, CH.AcAllowHeaders, null)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
await test('origin and method match', async () => {
|
|
116
|
+
corsConfig = {
|
|
117
|
+
corsOrigins: [AllowedDotCom],
|
|
118
|
+
corsMethods: ['GET']
|
|
119
|
+
}
|
|
120
|
+
const p = await preflight({
|
|
121
|
+
[CH.Origin]: AllowedDotCom,
|
|
122
|
+
[CH.AcRequestMethod]: 'GET'
|
|
123
|
+
})
|
|
124
|
+
headerIs(p, CH.AcAllowOrigin, AllowedDotCom)
|
|
125
|
+
headerIs(p, CH.AcAllowMethods, 'GET')
|
|
126
|
+
headerIs(p, CH.AcAllowCredentials, null)
|
|
127
|
+
headerIs(p, CH.AcAllowHeaders, null)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
await test('origin matches from multiple', async () => {
|
|
131
|
+
corsConfig = {
|
|
132
|
+
corsOrigins: [AllowedDotCom, FooDotCom],
|
|
133
|
+
corsMethods: ['GET']
|
|
134
|
+
}
|
|
135
|
+
const p = await preflight({
|
|
136
|
+
[CH.Origin]: AllowedDotCom,
|
|
137
|
+
[CH.AcRequestMethod]: 'GET'
|
|
138
|
+
})
|
|
139
|
+
headerIs(p, CH.AcAllowOrigin, AllowedDotCom)
|
|
140
|
+
headerIs(p, CH.AcAllowMethods, 'GET')
|
|
141
|
+
headerIs(p, CH.AcAllowCredentials, null)
|
|
142
|
+
headerIs(p, CH.AcAllowHeaders, null)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
await test('wildcard origin', async () => {
|
|
146
|
+
corsConfig = {
|
|
147
|
+
corsOrigins: ['*'],
|
|
148
|
+
corsMethods: ['GET']
|
|
149
|
+
}
|
|
150
|
+
const p = await preflight({
|
|
151
|
+
[CH.Origin]: FooDotCom,
|
|
152
|
+
[CH.AcRequestMethod]: 'GET'
|
|
153
|
+
})
|
|
154
|
+
headerIs(p, CH.AcAllowOrigin, FooDotCom)
|
|
155
|
+
headerIs(p, CH.AcAllowMethods, 'GET')
|
|
156
|
+
headerIs(p, CH.AcAllowCredentials, null)
|
|
157
|
+
headerIs(p, CH.AcAllowHeaders, null)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
await test(`wildcard and credentials`, async () => {
|
|
161
|
+
corsConfig = {
|
|
162
|
+
corsOrigins: ['*'],
|
|
163
|
+
corsMethods: ['GET'],
|
|
164
|
+
corsCredentials: true
|
|
165
|
+
}
|
|
166
|
+
const p = await preflight({
|
|
167
|
+
[CH.Origin]: FooDotCom,
|
|
168
|
+
[CH.AcRequestMethod]: 'GET'
|
|
169
|
+
})
|
|
170
|
+
headerIs(p, CH.AcAllowOrigin, FooDotCom)
|
|
171
|
+
headerIs(p, CH.AcAllowMethods, 'GET')
|
|
172
|
+
headerIs(p, CH.AcAllowCredentials, 'true')
|
|
173
|
+
headerIs(p, CH.AcAllowHeaders, null)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
await test(`wildcard, credentials, and headers`, async () => {
|
|
177
|
+
corsConfig = {
|
|
178
|
+
corsOrigins: ['*'],
|
|
179
|
+
corsMethods: ['GET'],
|
|
180
|
+
corsCredentials: true,
|
|
181
|
+
corsHeaders: ['content-type', 'my-header']
|
|
182
|
+
}
|
|
183
|
+
const p = await preflight({
|
|
184
|
+
[CH.Origin]: FooDotCom,
|
|
185
|
+
[CH.AcRequestMethod]: 'GET'
|
|
186
|
+
})
|
|
187
|
+
headerIs(p, CH.AcAllowOrigin, FooDotCom)
|
|
188
|
+
headerIs(p, CH.AcAllowMethods, 'GET')
|
|
189
|
+
headerIs(p, CH.AcAllowCredentials, 'true')
|
|
190
|
+
headerIs(p, CH.AcAllowHeaders, 'content-type,my-header')
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
await describe('Non-Preflight (Actual Response) Headers', async () => {
|
|
195
|
+
await test('no origins allowed', async () => {
|
|
196
|
+
corsConfig = {
|
|
197
|
+
corsOrigins: [],
|
|
198
|
+
corsMethods: ['GET']
|
|
199
|
+
}
|
|
200
|
+
const p = await request({
|
|
201
|
+
[CH.Origin]: NotAllowedDotCom
|
|
202
|
+
})
|
|
203
|
+
equal(p.status, 200)
|
|
204
|
+
headerIs(p, CH.AcAllowOrigin, null)
|
|
205
|
+
headerIs(p, CH.AcAllowCredentials, null)
|
|
206
|
+
headerIs(p, CH.AcExposeHeaders, null)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
await test('origin allowed', async () => {
|
|
210
|
+
corsConfig = {
|
|
211
|
+
corsOrigins: [AllowedDotCom],
|
|
212
|
+
corsMethods: ['GET'],
|
|
213
|
+
corsCredentials: true,
|
|
214
|
+
corsExposedHeaders: ['x-h1', 'x-h2']
|
|
215
|
+
}
|
|
216
|
+
const p = await request({
|
|
217
|
+
[CH.Origin]: AllowedDotCom
|
|
218
|
+
})
|
|
219
|
+
equal(p.status, 200)
|
|
220
|
+
headerIs(p, CH.AcAllowOrigin, AllowedDotCom)
|
|
221
|
+
headerIs(p, CH.AcAllowCredentials, 'true')
|
|
222
|
+
headerIs(p, CH.AcExposeHeaders, 'x-h1,x-h2')
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import { equal } from 'node:assert/strict'
|
|
3
|
+
import { parseMime, extFor, mimeFor } from './mime.js'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
test('parseMime', () => [
|
|
7
|
+
'text/html',
|
|
8
|
+
'TEXT/html',
|
|
9
|
+
'text/html; charset=utf-8'
|
|
10
|
+
].map(input =>
|
|
11
|
+
equal(parseMime(input), 'text/html')))
|
|
12
|
+
|
|
13
|
+
test('extFor', () => [
|
|
14
|
+
'text/html',
|
|
15
|
+
'Text/html',
|
|
16
|
+
'text/Html; charset=UTF-16'
|
|
17
|
+
].map(input =>
|
|
18
|
+
equal(extFor(input), 'html')))
|
|
19
|
+
|
|
20
|
+
test('mimeFor', () => [
|
|
21
|
+
'file.html',
|
|
22
|
+
'file.HTmL'
|
|
23
|
+
].map(input =>
|
|
24
|
+
equal(mimeFor(input), 'text/html')))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import { doesNotThrow, throws } from 'node:assert/strict'
|
|
3
|
+
import { validate, is, optional } from './validate.js'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('validate', () => {
|
|
7
|
+
describe('optional', () => {
|
|
8
|
+
test('accepts undefined', () =>
|
|
9
|
+
doesNotThrow(() =>
|
|
10
|
+
validate({}, { field: optional(Number.isInteger) })))
|
|
11
|
+
|
|
12
|
+
test('accepts falsy value regardless of type', () =>
|
|
13
|
+
doesNotThrow(() =>
|
|
14
|
+
validate({ field: 0 }, { field: optional(Array.isArray) })))
|
|
15
|
+
|
|
16
|
+
test('accepts when tester func returns truthy', () =>
|
|
17
|
+
doesNotThrow(() =>
|
|
18
|
+
validate({ field: [] }, { field: optional(Array.isArray) })))
|
|
19
|
+
|
|
20
|
+
test('rejects when tester func returns falsy', () =>
|
|
21
|
+
throws(() =>
|
|
22
|
+
validate({ field: 1 }, { field: optional(Array.isArray) }),
|
|
23
|
+
/field=1 is invalid/))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('is', () => {
|
|
27
|
+
test('rejects mismatched type', () =>
|
|
28
|
+
throws(() =>
|
|
29
|
+
validate({ field: 1 }, { field: is(String) }),
|
|
30
|
+
/field=1 is invalid/))
|
|
31
|
+
|
|
32
|
+
test('accepts matched type', () =>
|
|
33
|
+
doesNotThrow(() =>
|
|
34
|
+
validate({ field: '' }, { field: is(String) })))
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('custom tester func', () => {
|
|
38
|
+
test('rejects mismatched type', () =>
|
|
39
|
+
throws(() =>
|
|
40
|
+
validate({ field: 0.1 }, { field: Number.isInteger }),
|
|
41
|
+
/field=0.1 is invalid/))
|
|
42
|
+
|
|
43
|
+
test('accepts matched type', () =>
|
|
44
|
+
doesNotThrow(() =>
|
|
45
|
+
validate({ field: 1 }, { field: Number.isInteger })))
|
|
46
|
+
})
|
|
47
|
+
})
|