UNPKG

17.9 kBJavaScriptView Raw
1'use strict'
2
3const stringify = require('json-stringify-safe')
4const querystring = require('querystring')
5const { URL, URLSearchParams } = require('url')
6
7const common = require('./common')
8const { remove } = require('./intercept')
9const matchBody = require('./match_body')
10
11let fs
12try {
13 fs = require('fs')
14} catch (err) {
15 // do nothing, we're in the browser
16}
17
18module.exports = class Interceptor {
19 /**
20 *
21 * Valid argument types for `uri`:
22 * - A string used for strict comparisons with pathname.
23 * The search portion of the URI may also be postfixed, in which case the search params
24 * are striped and added via the `query` method.
25 * - A RegExp instance that tests against only the pathname of requests.
26 * - A synchronous function bound to this Interceptor instance. It's provided the pathname
27 * of requests and must return a boolean denoting if the request is considered a match.
28 */
29 constructor(scope, uri, method, requestBody, interceptorOptions) {
30 const uriIsStr = typeof uri === 'string'
31 // Check for leading slash. Uri can be either a string or a regexp, but
32 // When enabled filteringScope ignores the passed URL entirely so we skip validation.
33
34 if (
35 !scope.scopeOptions.filteringScope &&
36 uriIsStr &&
37 !uri.startsWith('/') &&
38 !uri.startsWith('*')
39 ) {
40 throw Error(
41 `Non-wildcard URL path strings must begin with a slash (otherwise they won't match anything) (got: ${uri})`
42 )
43 }
44
45 if (!method) {
46 throw new Error(
47 'The "method" parameter is required for an intercept call.'
48 )
49 }
50
51 this.scope = scope
52 this.interceptorMatchHeaders = []
53 this.method = method.toUpperCase()
54 this.uri = uri
55 this._key = `${this.method} ${scope.basePath}${scope.basePathname}${
56 uriIsStr ? '' : '/'
57 }${uri}`
58 this.basePath = this.scope.basePath
59 this.path = uriIsStr ? scope.basePathname + uri : uri
60 this.queries = null
61
62 this.options = interceptorOptions || {}
63 this.counter = 1
64 this._requestBody = requestBody
65
66 // We use lower-case header field names throughout Nock.
67 this.reqheaders = common.headersFieldNamesToLowerCase(
68 scope.scopeOptions.reqheaders || {}
69 )
70 this.badheaders = common.headersFieldsArrayToLowerCase(
71 scope.scopeOptions.badheaders || []
72 )
73
74 this.delayBodyInMs = 0
75 this.delayConnectionInMs = 0
76
77 this.optional = false
78
79 // strip off literal query parameters if they were provided as part of the URI
80 if (uriIsStr && uri.includes('?')) {
81 // localhost is a dummy value because the URL constructor errors for only relative inputs
82 const parsedURL = new URL(this.path, 'http://localhost')
83 this.path = parsedURL.pathname
84 this.query(parsedURL.searchParams)
85 this._key = `${this.method} ${scope.basePath}${this.path}`
86 }
87 }
88
89 optionally(flag = true) {
90 // The default behaviour of optionally() with no arguments is to make the mock optional.
91 if (typeof flag !== 'boolean') {
92 throw new Error('Invalid arguments: argument should be a boolean')
93 }
94
95 this.optional = flag
96
97 return this
98 }
99
100 replyWithError(errorMessage) {
101 this.errorMessage = errorMessage
102
103 this.options = {
104 ...this.scope.scopeOptions,
105 ...this.options,
106 }
107
108 this.scope.add(this._key, this)
109 return this.scope
110 }
111
112 reply(statusCode, body, rawHeaders) {
113 // support the format of only passing in a callback
114 if (typeof statusCode === 'function') {
115 if (arguments.length > 1) {
116 // It's not very Javascript-y to throw an error for extra args to a function, but because
117 // of legacy behavior, this error was added to reduce confusion for those migrating.
118 throw Error(
119 'Invalid arguments. When providing a function for the first argument, .reply does not accept other arguments.'
120 )
121 }
122 this.statusCode = null
123 this.fullReplyFunction = statusCode
124 } else {
125 if (statusCode !== undefined && !Number.isInteger(statusCode)) {
126 throw new Error(`Invalid ${typeof statusCode} value for status code`)
127 }
128
129 this.statusCode = statusCode || 200
130 if (typeof body === 'function') {
131 this.replyFunction = body
132 body = null
133 }
134 }
135
136 this.options = {
137 ...this.scope.scopeOptions,
138 ...this.options,
139 }
140
141 this.rawHeaders = common.headersInputToRawArray(rawHeaders)
142
143 if (this.scope.date) {
144 // https://tools.ietf.org/html/rfc7231#section-7.1.1.2
145 this.rawHeaders.push('Date', this.scope.date.toUTCString())
146 }
147
148 // Prepare the headers temporarily so we can make best guesses about content-encoding and content-type
149 // below as well as while the response is being processed in RequestOverrider.end().
150 // Including all the default headers is safe for our purposes because of the specific headers we introspect.
151 // A more thoughtful process is used to merge the default headers when the response headers are finally computed.
152 this.headers = common.headersArrayToObject(
153 this.rawHeaders.concat(this.scope._defaultReplyHeaders)
154 )
155
156 // If the content is not encoded we may need to transform the response body.
157 // Otherwise we leave it as it is.
158 if (
159 body &&
160 typeof body !== 'string' &&
161 !Buffer.isBuffer(body) &&
162 !common.isStream(body) &&
163 !common.isContentEncoded(this.headers)
164 ) {
165 try {
166 body = stringify(body)
167 } catch (err) {
168 throw new Error('Error encoding response body into JSON')
169 }
170
171 if (!this.headers['content-type']) {
172 // https://tools.ietf.org/html/rfc7231#section-3.1.1.5
173 this.rawHeaders.push('Content-Type', 'application/json')
174 }
175
176 if (this.scope.contentLen) {
177 // https://tools.ietf.org/html/rfc7230#section-3.3.2
178 this.rawHeaders.push('Content-Length', body.length)
179 }
180 }
181
182 this.scope.logger('reply.headers:', this.headers)
183 this.scope.logger('reply.rawHeaders:', this.rawHeaders)
184
185 this.body = body
186
187 this.scope.add(this._key, this)
188 return this.scope
189 }
190
191 replyWithFile(statusCode, filePath, headers) {
192 if (!fs) {
193 throw new Error('No fs')
194 }
195 const readStream = fs.createReadStream(filePath)
196 readStream.pause()
197 this.filePath = filePath
198 return this.reply(statusCode, readStream, headers)
199 }
200
201 // Also match request headers
202 // https://github.com/nock/nock/issues/163
203 reqheaderMatches(options, key) {
204 const reqHeader = this.reqheaders[key]
205 let header = options.headers[key]
206
207 // https://github.com/nock/nock/issues/399
208 // https://github.com/nock/nock/issues/822
209 if (header && typeof header !== 'string' && header.toString) {
210 header = header.toString()
211 }
212
213 // We skip 'host' header comparison unless it's available in both mock and
214 // actual request. This because 'host' may get inserted by Nock itself and
215 // then get recorded. NOTE: We use lower-case header field names throughout
216 // Nock. See https://github.com/nock/nock/pull/196.
217 if (key === 'host' && (header === undefined || reqHeader === undefined)) {
218 return true
219 }
220
221 if (reqHeader !== undefined && header !== undefined) {
222 if (typeof reqHeader === 'function') {
223 return reqHeader(header)
224 } else if (common.matchStringOrRegexp(header, reqHeader)) {
225 return true
226 }
227 }
228
229 this.scope.logger(
230 "request header field doesn't match:",
231 key,
232 header,
233 reqHeader
234 )
235 return false
236 }
237
238 match(req, options, body) {
239 // check if the logger is enabled because the stringifies can be expensive.
240 if (this.scope.logger.enabled) {
241 this.scope.logger(
242 'attempting match %s, body = %s',
243 stringify(options),
244 stringify(body)
245 )
246 }
247
248 const method = (options.method || 'GET').toUpperCase()
249 let { path = '/' } = options
250 let matches
251 let matchKey
252 const { proto } = options
253
254 if (this.method !== method) {
255 this.scope.logger(
256 `Method did not match. Request ${method} Interceptor ${this.method}`
257 )
258 return false
259 }
260
261 if (this.scope.transformPathFunction) {
262 path = this.scope.transformPathFunction(path)
263 }
264
265 const requestMatchesFilter = ({ name, value: predicate }) => {
266 const headerValue = req.getHeader(name)
267 if (typeof predicate === 'function') {
268 return predicate(headerValue)
269 } else {
270 return common.matchStringOrRegexp(headerValue, predicate)
271 }
272 }
273
274 if (
275 !this.scope.matchHeaders.every(requestMatchesFilter) ||
276 !this.interceptorMatchHeaders.every(requestMatchesFilter)
277 ) {
278 this.scope.logger("headers don't match")
279 return false
280 }
281
282 const reqHeadersMatch = Object.keys(this.reqheaders).every(key =>
283 this.reqheaderMatches(options, key)
284 )
285
286 if (!reqHeadersMatch) {
287 this.scope.logger("headers don't match")
288 return false
289 }
290
291 if (
292 this.scope.scopeOptions.conditionally &&
293 !this.scope.scopeOptions.conditionally()
294 ) {
295 this.scope.logger(
296 'matching failed because Scope.conditionally() did not validate'
297 )
298 return false
299 }
300
301 const badHeaders = this.badheaders.filter(
302 header => header in options.headers
303 )
304
305 if (badHeaders.length) {
306 this.scope.logger('request contains bad headers', ...badHeaders)
307 return false
308 }
309
310 // Match query strings when using query()
311 if (this.queries === null) {
312 this.scope.logger('query matching skipped')
313 } else {
314 // can't rely on pathname or search being in the options, but path has a default
315 const [pathname, search] = path.split('?')
316 const matchQueries = this.matchQuery({ search })
317
318 this.scope.logger(
319 matchQueries ? 'query matching succeeded' : 'query matching failed'
320 )
321
322 if (!matchQueries) {
323 return false
324 }
325
326 // If the query string was explicitly checked then subsequent checks against
327 // the path using a callback or regexp only validate the pathname.
328 path = pathname
329 }
330
331 // If we have a filtered scope then we use it instead reconstructing the
332 // scope from the request options (proto, host and port) as these two won't
333 // necessarily match and we have to remove the scope that was matched (vs.
334 // that was defined).
335 if (this.__nock_filteredScope) {
336 matchKey = this.__nock_filteredScope
337 } else {
338 matchKey = common.normalizeOrigin(proto, options.host, options.port)
339 }
340
341 if (typeof this.uri === 'function') {
342 matches =
343 common.matchStringOrRegexp(matchKey, this.basePath) &&
344 // This is a false positive, as `uri` is not bound to `this`.
345 // eslint-disable-next-line no-useless-call
346 this.uri.call(this, path)
347 } else {
348 matches =
349 common.matchStringOrRegexp(matchKey, this.basePath) &&
350 common.matchStringOrRegexp(path, this.path)
351 }
352
353 this.scope.logger(`matching ${matchKey}${path} to ${this._key}: ${matches}`)
354
355 if (matches && this._requestBody !== undefined) {
356 if (this.scope.transformRequestBodyFunction) {
357 body = this.scope.transformRequestBodyFunction(body, this._requestBody)
358 }
359
360 matches = matchBody(options, this._requestBody, body)
361 if (!matches) {
362 this.scope.logger(
363 "bodies don't match: \n",
364 this._requestBody,
365 '\n',
366 body
367 )
368 }
369 }
370
371 return matches
372 }
373
374 /**
375 * Return true when the interceptor's method, protocol, host, port, and path
376 * match the provided options.
377 */
378 matchOrigin(options) {
379 const isPathFn = typeof this.path === 'function'
380 const isRegex = this.path instanceof RegExp
381 const isRegexBasePath = this.scope.basePath instanceof RegExp
382
383 const method = (options.method || 'GET').toUpperCase()
384 let { path } = options
385 const { proto } = options
386
387 // NOTE: Do not split off the query params as the regex could use them
388 if (!isRegex) {
389 path = path ? path.split('?')[0] : ''
390 }
391
392 if (this.scope.transformPathFunction) {
393 path = this.scope.transformPathFunction(path)
394 }
395 const comparisonKey = isPathFn || isRegex ? this.__nock_scopeKey : this._key
396 const matchKey = `${method} ${proto}://${options.host}${path}`
397
398 if (isPathFn) {
399 return !!(matchKey.match(comparisonKey) && this.path(path))
400 }
401
402 if (isRegex && !isRegexBasePath) {
403 return !!matchKey.match(comparisonKey) && this.path.test(path)
404 }
405
406 if (isRegexBasePath) {
407 return this.scope.basePath.test(matchKey) && !!path.match(this.path)
408 }
409
410 return comparisonKey === matchKey
411 }
412
413 matchHostName(options) {
414 return options.hostname === this.scope.urlParts.hostname
415 }
416
417 matchQuery(options) {
418 if (this.queries === true) {
419 return true
420 }
421
422 const reqQueries = querystring.parse(options.search)
423 this.scope.logger('Interceptor queries: %j', this.queries)
424 this.scope.logger(' Request queries: %j', reqQueries)
425
426 if (typeof this.queries === 'function') {
427 return this.queries(reqQueries)
428 }
429
430 return common.dataEqual(this.queries, reqQueries)
431 }
432
433 filteringPath(...args) {
434 this.scope.filteringPath(...args)
435 return this
436 }
437
438 // TODO filtering by path is valid on the intercept level, but not filtering
439 // by request body?
440
441 markConsumed() {
442 this.interceptionCounter++
443
444 remove(this)
445
446 if ((this.scope.shouldPersist() || this.counter > 0) && this.filePath) {
447 this.body = fs.createReadStream(this.filePath)
448 this.body.pause()
449 }
450
451 if (!this.scope.shouldPersist() && this.counter < 1) {
452 this.scope.remove(this._key, this)
453 }
454 }
455
456 matchHeader(name, value) {
457 this.interceptorMatchHeaders.push({ name, value })
458 return this
459 }
460
461 basicAuth({ user, pass = '' }) {
462 const encoded = Buffer.from(`${user}:${pass}`).toString('base64')
463 this.matchHeader('authorization', `Basic ${encoded}`)
464 return this
465 }
466
467 /**
468 * Set query strings for the interceptor
469 * @name query
470 * @param queries Object of query string name,values (accepts regexp values)
471 * @public
472 * @example
473 * // Will match 'http://zombo.com/?q=t'
474 * nock('http://zombo.com').get('/').query({q: 't'});
475 */
476 query(queries) {
477 if (this.queries !== null) {
478 throw Error(`Query parameters have already been defined`)
479 }
480
481 // Allow all query strings to match this route
482 if (queries === true) {
483 this.queries = queries
484 return this
485 }
486
487 if (typeof queries === 'function') {
488 this.queries = queries
489 return this
490 }
491
492 let strFormattingFn
493 if (this.scope.scopeOptions.encodedQueryParams) {
494 strFormattingFn = common.percentDecode
495 }
496
497 if (queries instanceof URLSearchParams) {
498 // Normalize the data into the shape that is matched against.
499 // Duplicate keys are handled by combining the values into an array.
500 queries = querystring.parse(queries.toString())
501 } else if (!common.isPlainObject(queries)) {
502 throw Error(`Argument Error: ${queries}`)
503 }
504
505 this.queries = {}
506 for (const [key, value] of Object.entries(queries)) {
507 const formatted = common.formatQueryValue(key, value, strFormattingFn)
508 const [formattedKey, formattedValue] = formatted
509 this.queries[formattedKey] = formattedValue
510 }
511
512 return this
513 }
514
515 /**
516 * Set number of times will repeat the interceptor
517 * @name times
518 * @param newCounter Number of times to repeat (should be > 0)
519 * @public
520 * @example
521 * // Will repeat mock 5 times for same king of request
522 * nock('http://zombo.com).get('/').times(5).reply(200, 'Ok');
523 */
524 times(newCounter) {
525 if (newCounter < 1) {
526 return this
527 }
528
529 this.counter = newCounter
530
531 return this
532 }
533
534 /**
535 * An sugar syntax for times(1)
536 * @name once
537 * @see {@link times}
538 * @public
539 * @example
540 * nock('http://zombo.com).get('/').once().reply(200, 'Ok');
541 */
542 once() {
543 return this.times(1)
544 }
545
546 /**
547 * An sugar syntax for times(2)
548 * @name twice
549 * @see {@link times}
550 * @public
551 * @example
552 * nock('http://zombo.com).get('/').twice().reply(200, 'Ok');
553 */
554 twice() {
555 return this.times(2)
556 }
557
558 /**
559 * An sugar syntax for times(3).
560 * @name thrice
561 * @see {@link times}
562 * @public
563 * @example
564 * nock('http://zombo.com).get('/').thrice().reply(200, 'Ok');
565 */
566 thrice() {
567 return this.times(3)
568 }
569
570 /**
571 * Delay the response by a certain number of ms.
572 *
573 * @param {(integer|object)} opts - Number of milliseconds to wait, or an object
574 * @param {integer} [opts.head] - Number of milliseconds to wait before response is sent
575 * @param {integer} [opts.body] - Number of milliseconds to wait before response body is sent
576 * @return {Interceptor} - the current interceptor for chaining
577 */
578 delay(opts) {
579 let headDelay
580 let bodyDelay
581 if (typeof opts === 'number') {
582 headDelay = opts
583 bodyDelay = 0
584 } else if (typeof opts === 'object') {
585 headDelay = opts.head || 0
586 bodyDelay = opts.body || 0
587 } else {
588 throw new Error(`Unexpected input opts ${opts}`)
589 }
590
591 return this.delayConnection(headDelay).delayBody(bodyDelay)
592 }
593
594 /**
595 * Delay the response body by a certain number of ms.
596 *
597 * @param {integer} ms - Number of milliseconds to wait before response is sent
598 * @return {Interceptor} - the current interceptor for chaining
599 */
600 delayBody(ms) {
601 this.delayBodyInMs = ms
602 return this
603 }
604
605 /**
606 * Delay the connection by a certain number of ms.
607 *
608 * @param {integer} ms - Number of milliseconds to wait
609 * @return {Interceptor} - the current interceptor for chaining
610 */
611 delayConnection(ms) {
612 this.delayConnectionInMs = ms
613 return this
614 }
615}