1'use strict' 2 3const { MockNotMatchedError } = require('./mock-errors') 4const { 5 kDispatches, 6 kMockAgent, 7 kOriginalDispatch, 8 kOrigin, 9 kGetNetConnect 10} = require('./mock-symbols') 11const { buildURL, nop } = require('../core/util') 12const { STATUS_CODES } = require('http') 13const { 14 types: { 15 isPromise 16 } 17} = require('util') 18 19function matchValue (match, value) { 20 if (typeof match === 'string') { 21 return match === value 22 } 23 if (match instanceof RegExp) { 24 return match.test(value) 25 } 26 if (typeof match === 'function') { 27 return match(value) === true 28 } 29 return false 30} 31 32function lowerCaseEntries (headers) { 33 return Object.fromEntries( 34 Object.entries(headers).map(([headerName, headerValue]) => { 35 return [headerName.toLocaleLowerCase(), headerValue] 36 }) 37 ) 38} 39 40/** 41 * @param {import('../../index').Headers|string[]|Record<string, string>} headers 42 * @param {string} key 43 */ 44function getHeaderByName (headers, key) { 45 if (Array.isArray(headers)) { 46 for (let i = 0; i < headers.length; i += 2) { 47 if (headers[i].toLocaleLowerCase() === key.toLocaleLowerCase()) { 48 return headers[i + 1] 49 } 50 } 51 52 return undefined 53 } else if (typeof headers.get === 'function') { 54 return headers.get(key) 55 } else { 56 return lowerCaseEntries(headers)[key.toLocaleLowerCase()] 57 } 58} 59 60/** @param {string[]} headers */ 61function buildHeadersFromArray (headers) { // fetch HeadersList 62 const clone = headers.slice() 63 const entries = [] 64 for (let index = 0; index < clone.length; index += 2) { 65 entries.push([clone[index], clone[index + 1]]) 66 } 67 return Object.fromEntries(entries) 68} 69 70function matchHeaders (mockDispatch, headers) { 71 if (typeof mockDispatch.headers === 'function') { 72 if (Array.isArray(headers)) { // fetch HeadersList 73 headers = buildHeadersFromArray(headers) 74 } 75 return mockDispatch.headers(headers ? lowerCaseEntries(headers) : {}) 76 } 77 if (typeof mockDispatch.headers === 'undefined') { 78 return true 79 } 80 if (typeof headers !== 'object' || typeof mockDispatch.headers !== 'object') { 81 return false 82 } 83 84 for (const [matchHeaderName, matchHeaderValue] of Object.entries(mockDispatch.headers)) { 85 const headerValue = getHeaderByName(headers, matchHeaderName) 86 87 if (!matchValue(matchHeaderValue, headerValue)) { 88 return false 89 } 90 } 91 return true 92} 93 94function safeUrl (path) { 95 if (typeof path !== 'string') { 96 return path 97 } 98 99 const pathSegments = path.split('?') 100 101 if (pathSegments.length !== 2) { 102 return path 103 } 104 105 const qp = new URLSearchParams(pathSegments.pop()) 106 qp.sort() 107 return [...pathSegments, qp.toString()].join('?') 108} 109 110function matchKey (mockDispatch, { path, method, body, headers }) { 111 const pathMatch = matchValue(mockDispatch.path, path) 112 const methodMatch = matchValue(mockDispatch.method, method) 113 const bodyMatch = typeof mockDispatch.body !== 'undefined' ? matchValue(mockDispatch.body, body) : true 114 const headersMatch = matchHeaders(mockDispatch, headers) 115 return pathMatch && methodMatch && bodyMatch && headersMatch 116} 117 118function getResponseData (data) { 119 if (Buffer.isBuffer(data)) { 120 return data 121 } else if (typeof data === 'object') { 122 return JSON.stringify(data) 123 } else { 124 return data.toString() 125 } 126} 127 128function getMockDispatch (mockDispatches, key) { 129 const basePath = key.query ? buildURL(key.path, key.query) : key.path 130 const resolvedPath = typeof basePath === 'string' ? safeUrl(basePath) : basePath 131 132 // Match path 133 let matchedMockDispatches = mockDispatches.filter(({ consumed }) => !consumed).filter(({ path }) => matchValue(safeUrl(path), resolvedPath)) 134 if (matchedMockDispatches.length === 0) { 135 throw new MockNotMatchedError(`Mock dispatch not matched for path '${resolvedPath}'`) 136 } 137 138 // Match method 139 matchedMockDispatches = matchedMockDispatches.filter(({ method }) => matchValue(method, key.method)) 140 if (matchedMockDispatches.length === 0) { 141 throw new MockNotMatchedError(`Mock dispatch not matched for method '${key.method}'`) 142 } 143 144 // Match body 145 matchedMockDispatches = matchedMockDispatches.filter(({ body }) => typeof body !== 'undefined' ? matchValue(body, key.body) : true) 146 if (matchedMockDispatches.length === 0) { 147 throw new MockNotMatchedError(`Mock dispatch not matched for body '${key.body}'`) 148 } 149 150 // Match headers 151 matchedMockDispatches = matchedMockDispatches.filter((mockDispatch) => matchHeaders(mockDispatch, key.headers)) 152 if (matchedMockDispatches.length === 0) { 153 throw new MockNotMatchedError(`Mock dispatch not matched for headers '${typeof key.headers === 'object' ? JSON.stringify(key.headers) : key.headers}'`) 154 } 155 156 return matchedMockDispatches[0] 157} 158 159function addMockDispatch (mockDispatches, key, data) { 160 const baseData = { timesInvoked: 0, times: 1, persist: false, consumed: false } 161 const replyData = typeof data === 'function' ? { callback: data } : { ...data } 162 const newMockDispatch = { ...baseData, ...key, pending: true, data: { error: null, ...replyData } } 163 mockDispatches.push(newMockDispatch) 164 return newMockDispatch 165} 166 167function deleteMockDispatch (mockDispatches, key) { 168 const index = mockDispatches.findIndex(dispatch => { 169 if (!dispatch.consumed) { 170 return false 171 } 172 return matchKey(dispatch, key) 173 }) 174 if (index !== -1) { 175 mockDispatches.splice(index, 1) 176 } 177} 178 179function buildKey (opts) { 180 const { path, method, body, headers, query } = opts 181 return { 182 path, 183 method, 184 body, 185 headers, 186 query 187 } 188} 189 190function generateKeyValues (data) { 191 return Object.entries(data).reduce((keyValuePairs, [key, value]) => [ 192 ...keyValuePairs, 193 Buffer.from(`${key}`), 194 Array.isArray(value) ? value.map(x => Buffer.from(`${x}`)) : Buffer.from(`${value}`) 195 ], []) 196} 197 198/** 199 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status 200 * @param {number} statusCode 201 */ 202function getStatusText (statusCode) { 203 return STATUS_CODES[statusCode] || 'unknown' 204} 205 206async function getResponse (body) { 207 const buffers = [] 208 for await (const data of body) { 209 buffers.push(data) 210 } 211 return Buffer.concat(buffers).toString('utf8') 212} 213 214/** 215 * Mock dispatch function used to simulate undici dispatches 216 */ 217function mockDispatch (opts, handler) { 218 // Get mock dispatch from built key 219 const key = buildKey(opts) 220 const mockDispatch = getMockDispatch(this[kDispatches], key) 221 222 mockDispatch.timesInvoked++ 223 224 // Here's where we resolve a callback if a callback is present for the dispatch data. 225 if (mockDispatch.data.callback) { 226 mockDispatch.data = { ...mockDispatch.data, ...mockDispatch.data.callback(opts) } 227 } 228 229 // Parse mockDispatch data 230 const { data: { statusCode, data, headers, trailers, error }, delay, persist } = mockDispatch 231 const { timesInvoked, times } = mockDispatch 232 233 // If it's used up and not persistent, mark as consumed 234 mockDispatch.consumed = !persist && timesInvoked >= times 235 mockDispatch.pending = timesInvoked < times 236 237 // If specified, trigger dispatch error 238 if (error !== null) { 239 deleteMockDispatch(this[kDispatches], key) 240 handler.onError(error) 241 return true 242 } 243 244 // Handle the request with a delay if necessary 245 if (typeof delay === 'number' && delay > 0) { 246 setTimeout(() => { 247 handleReply(this[kDispatches]) 248 }, delay) 249 } else { 250 handleReply(this[kDispatches]) 251 } 252 253 function handleReply (mockDispatches, _data = data) { 254 // fetch's HeadersList is a 1D string array 255 const optsHeaders = Array.isArray(opts.headers) 256 ? buildHeadersFromArray(opts.headers) 257 : opts.headers 258 const body = typeof _data === 'function' 259 ? _data({ ...opts, headers: optsHeaders }) 260 : _data 261 262 // util.types.isPromise is likely needed for jest. 263 if (isPromise(body)) { 264 // If handleReply is asynchronous, throwing an error 265 // in the callback will reject the promise, rather than 266 // synchronously throw the error, which breaks some tests. 267 // Rather, we wait for the callback to resolve if it is a 268 // promise, and then re-run handleReply with the new body. 269 body.then((newData) => handleReply(mockDispatches, newData)) 270 return 271 } 272 273 const responseData = getResponseData(body) 274 const responseHeaders = generateKeyValues(headers) 275 const responseTrailers = generateKeyValues(trailers) 276 277 handler.abort = nop 278 handler.onHeaders(statusCode, responseHeaders, resume, getStatusText(statusCode)) 279 handler.onData(Buffer.from(responseData)) 280 handler.onComplete(responseTrailers) 281 deleteMockDispatch(mockDispatches, key) 282 } 283 284 function resume () {} 285 286 return true 287} 288 289function buildMockDispatch () { 290 const agent = this[kMockAgent] 291 const origin = this[kOrigin] 292 const originalDispatch = this[kOriginalDispatch] 293 294 return function dispatch (opts, handler) { 295 if (agent.isMockActive) { 296 try { 297 mockDispatch.call(this, opts, handler) 298 } catch (error) { 299 if (error instanceof MockNotMatchedError) { 300 const netConnect = agent[kGetNetConnect]() 301 if (netConnect === false) { 302 throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect disabled)`) 303 } 304 if (checkNetConnect(netConnect, origin)) { 305 originalDispatch.call(this, opts, handler) 306 } else { 307 throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect is not enabled for this origin)`) 308 } 309 } else { 310 throw error 311 } 312 } 313 } else { 314 originalDispatch.call(this, opts, handler) 315 } 316 } 317} 318 319function checkNetConnect (netConnect, origin) { 320 const url = new URL(origin) 321 if (netConnect === true) { 322 return true 323 } else if (Array.isArray(netConnect) && netConnect.some((matcher) => matchValue(matcher, url.host))) { 324 return true 325 } 326 return false 327} 328 329function buildMockOptions (opts) { 330 if (opts) { 331 const { agent, ...mockOptions } = opts 332 return mockOptions 333 } 334} 335 336module.exports = { 337 getResponseData, 338 getMockDispatch, 339 addMockDispatch, 340 deleteMockDispatch, 341 buildKey, 342 generateKeyValues, 343 matchValue, 344 getResponse, 345 getStatusText, 346 mockDispatch, 347 buildMockDispatch, 348 checkNetConnect, 349 buildMockOptions, 350 getHeaderByName 351} 352