From b687592c4e2103ac88a7f813b7e1019264e48d01 Mon Sep 17 00:00:00 2001 From: "Matt R. Wilson" Date: Sat, 8 Jun 2019 08:53:21 -0600 Subject: [PATCH] feat(reply): Response headers to more closely match Node's functionality. (#1564) * Response headers to more closely match Node. Updates the header handling in the `Interceptor` and `RequestOverrider` with the intention of mimicking the native behavior of `http.IncomingMessage.rawHeaders`. > The raw request/response headers list exactly as they were received. There are three fundamental changes in this changeset: 1) Header Input Type Previously, headers could be provided to: - `Scope.defaultReplyHeaders` as a plain object - `Interceptor.reply(status, body, headers)` as a plain object or an array of raw headers - `Interceptor.reply(() => [status, body, headers]` as a plain object Now, all three allow consistent inputs where the headers can be provided as a plain object, an array of raw headers, or a `Map`. 2) Duplicate Headers Folding This change deviates from the suggested guidelines laid out in #1553 because those guidelines didn't properly handle duplicate headers, especially when some are defined as defaults. This change was modeled to duplicate [Node's implementation](https://github.com/nodejs/node/blob/908292cf1f551c614a733d858528ffb13fb3a524/lib/_http_incoming.js#L245) ([relevant docs](https://nodejs.org/api/http.html#http_message_headers)). It specifically lays out how duplicate headers are handled depending on the field name. In the case of default headers, they are not included on the `Response` (not even in the raw headers) if the field name exists in the reply headers (using a case-insensitive comparison). 3) Raw Headers are the Source of Truth Previously, the `Interceptor` and `RequestOverrider` mostly keep track of headers as a plain object and the array of raw headers was created by looping that object. This was the cause for inconsistencies with the final result of the raw headers. The problem with that approach is that converting raw headers to an object is a lossy process, so going backwards makes it impossible to guarantee the correct results. This change reverses that logic and now the `Interceptor` and `RequestOverrider` maintain the header data in raw arrays. All additions to headers are only added to raw headers. The plain headers object is never mutated directly, and instead is [re]created from the raw headers as needed. --- lib/common.js | 165 +++++++++++++++--- lib/interceptor.js | 61 +++---- lib/mixin.js | 12 -- lib/recorder.js | 2 +- lib/request_overrider.js | 77 ++++----- lib/scope.js | 5 +- tests/test_common.js | 4 +- tests/test_default_reply_headers.js | 125 +++++++++++++- tests/test_logging.js | 11 +- tests/test_reply_headers.js | 250 +++++++++++++++++++--------- 10 files changed, 514 insertions(+), 198 deletions(-) delete mode 100644 lib/mixin.js diff --git a/lib/common.js b/lib/common.js index 8d4c66b9e..7a2c2b8c1 100644 --- a/lib/common.js +++ b/lib/common.js @@ -186,23 +186,19 @@ function stringifyRequest(options, body) { } function isContentEncoded(headers) { - const contentEncoding = _.get(headers, 'content-encoding') + const contentEncoding = headers['content-encoding'] return _.isString(contentEncoding) && contentEncoding !== '' } function contentEncoding(headers, encoder) { - const contentEncoding = _.get(headers, 'content-encoding') + const contentEncoding = headers['content-encoding'] return contentEncoding === encoder } function isJSONContent(headers) { - let contentType = _.get(headers, 'content-type') - if (Array.isArray(contentType)) { - contentType = contentType[0] - } - contentType = (contentType || '').toLocaleLowerCase() - - return contentType === 'application/json' + // https://tools.ietf.org/html/rfc8259 + const contentType = (headers['content-type'] || '').toLowerCase() + return contentType.startsWith('application/json') } const headersFieldNamesToLowerCase = function(headers) { @@ -236,26 +232,139 @@ const headersFieldsArrayToLowerCase = function(headers) { ) } +/** + * Converts the various accepted formats of headers into a flat array representing "raw headers". + * + * Nock allows headers to be provided as a raw array, a plain object, or a Map. + * + * While all the header names are expected to be strings, the values are left intact as they can + * be functions, strings, or arrays of strings. + * + * https://nodejs.org/api/http.html#http_message_rawheaders + */ +const headersInputToRawArray = function(headers) { + if (headers === undefined) { + return [] + } + + if (Array.isArray(headers)) { + // If the input is an array, assume it's already in the raw format and simply return a copy + // but throw an error if there aren't an even number of items in the array + if (headers.length % 2) { + throw new Error( + `Raw headers must be provided as an array with an even number of items. [fieldName, value, ...]` + ) + } + return [...headers] + } + + // [].concat(...) is used instead of Array.flat until v11 is the minimum Node version + if (_.isMap(headers)) { + return [].concat(...Array.from(headers, ([k, v]) => [k.toString(), v])) + } + + if (_.isPlainObject(headers)) { + return [].concat(...Object.entries(headers)) + } + + throw new Error( + `Headers must be provided as an array of raw values, a Map, or a plain Object. ${headers}` + ) +} + +/** + * Converts an array of raw headers to an object, using the same rules as Nodes `http.IncomingMessage.headers`. + * + * Header names/keys are lower-cased. + */ const headersArrayToObject = function(rawHeaders) { if (!Array.isArray(rawHeaders)) { throw Error('Expected a header array') } - const headers = {} + const accumulator = {} - for (let i = 0, len = rawHeaders.length; i < len; i = i + 2) { - const key = rawHeaders[i].toLowerCase() - const value = rawHeaders[i + 1] + forEachHeader(rawHeaders, (value, fieldName) => { + addHeaderLine(accumulator, fieldName, value) + }) + + return accumulator +} - if (headers[key]) { - headers[key] = Array.isArray(headers[key]) ? headers[key] : [headers[key]] - headers[key].push(value) +const noDuplicatesHeaders = new Set([ + 'age', + 'authorization', + 'content-length', + 'content-type', + 'etag', + 'expires', + 'from', + 'host', + 'if-modified-since', + 'if-unmodified-since', + 'last-modified', + 'location', + 'max-forwards', + 'proxy-authorization', + 'referer', + 'retry-after', + 'user-agent', +]) + +/** + * Set key/value data in accordance with Node's logic for folding duplicate headers. + * + * The `value` param should be a function, string, or array of strings. + * + * Node's docs and source: + * https://nodejs.org/api/http.html#http_message_headers + * https://github.com/nodejs/node/blob/908292cf1f551c614a733d858528ffb13fb3a524/lib/_http_incoming.js#L245 + * + * Header names are lower-cased. + * Duplicates in raw headers are handled in the following ways, depending on the header name: + * - Duplicates of field names listed in `noDuplicatesHeaders` (above) are discarded. + * - `set-cookie` is always an array. Duplicates are added to the array. + * - For duplicate `cookie` headers, the values are joined together with '; '. + * - For all other headers, the values are joined together with ', '. + * + * Node's implementation is larger because it highly optimizes for not having to call `toLowerCase()`. + * We've opted to always call `toLowerCase` in exchange for a more concise function. + * + * While Node has the luxury of knowing `value` is always a string, we do an extra step of coercion at the top. + */ +const addHeaderLine = function(headers, name, value) { + let values // code below expects `values` to be an array of strings + if (typeof value === 'function') { + // Function values are evaluated towards the end of the response, before that we use a placeholder + // string just to designate that the header exists. Useful when `Content-Type` is set with a function. + values = [value.name] + } else if (Array.isArray(value)) { + values = value.map(String) + } else { + values = [String(value)] + } + + const key = name.toLowerCase() + if (key === 'set-cookie') { + // Array header -- only Set-Cookie at the moment + if (headers['set-cookie'] === undefined) { + headers['set-cookie'] = values } else { - headers[key] = value + headers['set-cookie'].push(...values) + } + } else if (noDuplicatesHeaders.has(key)) { + if (headers[key] === undefined) { + // Drop duplicates + headers[key] = values[0] + } + } else { + if (headers[key] !== undefined) { + values = [headers[key], ...values] } - } - return headers + const separator = key === 'cookie' ? '; ' : ', ' + headers[key] = values.join(separator) + } } /** @@ -284,11 +393,25 @@ const deleteHeadersField = function(headers, fieldNameToDelete) { if (lowerCaseFieldName === lowerCaseFieldNameToDelete) { delete headers[fieldName] // We don't stop here but continue in order to remove *all* matching field names - // (even though if seen regorously there shouldn't be any) + // (even though if seen rigorously there shouldn't be any) } }) } +/** + * Utility for iterating over a raw headers array. + * + * The callback is called with: + * - The header value. string, array of strings, or a function + * - The header field name. string + * - Index of the header field in the raw header array. + */ +const forEachHeader = function(rawHeaders, callback) { + for (let i = 0; i < rawHeaders.length; i += 2) { + callback(rawHeaders[i + 1], rawHeaders[i], i) + } +} + function percentDecode(str) { try { return decodeURIComponent(str.replace(/\+/g, ' ')) @@ -391,7 +514,9 @@ exports.isJSONContent = isJSONContent exports.headersFieldNamesToLowerCase = headersFieldNamesToLowerCase exports.headersFieldsArrayToLowerCase = headersFieldsArrayToLowerCase exports.headersArrayToObject = headersArrayToObject +exports.headersInputToRawArray = headersInputToRawArray exports.deleteHeadersField = deleteHeadersField +exports.forEachHeader = forEachHeader exports.percentEncode = percentEncode exports.percentDecode = percentDecode exports.matchStringOrRegexp = matchStringOrRegexp diff --git a/lib/interceptor.js b/lib/interceptor.js index 181341f09..8299dd393 100644 --- a/lib/interceptor.js +++ b/lib/interceptor.js @@ -1,6 +1,5 @@ 'use strict' -const mixin = require('./mixin') const matchBody = require('./match_body') const common = require('./common') const _ = require('lodash') @@ -103,64 +102,50 @@ Interceptor.prototype.reply = function reply(statusCode, body, rawHeaders) { _.defaults(this.options, this.scope.scopeOptions) - // If needed, convert rawHeaders from Array to Object. - let headers = Array.isArray(rawHeaders) - ? common.headersArrayToObject(rawHeaders) - : rawHeaders - - if (this.scope._defaultReplyHeaders) { - headers = headers || {} - // Because of this, this.rawHeaders gets lower-cased versions of the `rawHeaders` param. - // https://github.com/nock/nock/issues/1553 - headers = common.headersFieldNamesToLowerCase(headers) - headers = mixin(this.scope._defaultReplyHeaders, headers) - } + this.rawHeaders = common.headersInputToRawArray(rawHeaders) if (this.scope.date) { - headers = headers || {} - headers['date'] = this.scope.date.toUTCString() + // https://tools.ietf.org/html/rfc7231#section-7.1.1.2 + this.rawHeaders.push('Date', this.scope.date.toUTCString()) } - if (headers !== undefined) { - this.rawHeaders = [] - - for (const key in headers) { - this.rawHeaders.push(key, headers[key]) - } - - // We use lower-case headers throughout Nock. - this.headers = common.headersFieldNamesToLowerCase(headers) - - debug('reply.headers:', this.headers) - debug('reply.rawHeaders:', this.rawHeaders) - } + // Prepare the headers temporarily so we can make best guesses about content-encoding and content-type + // below as well as while the response is being processed in RequestOverrider.end(). + // Including all the default headers is safe for our purposes because of the specific headers we introspect. + // A more thoughtful process is used to merge the default headers when the response headers are finally computed. + this.headers = common.headersArrayToObject( + this.rawHeaders.concat(this.scope._defaultReplyHeaders) + ) // If the content is not encoded we may need to transform the response body. // Otherwise we leave it as it is. if ( body && typeof body !== 'string' && - typeof body !== 'function' && !Buffer.isBuffer(body) && !common.isStream(body) && !common.isContentEncoded(this.headers) ) { try { body = stringify(body) - if (!this.headers) { - this.headers = {} - } - if (!this.headers['content-type']) { - this.headers['content-type'] = 'application/json' - } - if (this.scope.contentLen) { - this.headers['content-length'] = body.length - } } catch (err) { throw new Error('Error encoding response body into JSON') } + + if (!this.headers['content-type']) { + // https://tools.ietf.org/html/rfc7231#section-3.1.1.5 + this.rawHeaders.push('Content-Type', 'application/json') + } + + if (this.scope.contentLen) { + // https://tools.ietf.org/html/rfc7230#section-3.3.2 + this.rawHeaders.push('Content-Length', body.length) + } } + debug('reply.headers:', this.headers) + debug('reply.rawHeaders:', this.rawHeaders) + this.body = body this.scope.add(this._key, this, this.scope, this.scopeOptions) diff --git a/lib/mixin.js b/lib/mixin.js deleted file mode 100644 index afc3cfa31..000000000 --- a/lib/mixin.js +++ /dev/null @@ -1,12 +0,0 @@ -'use strict' -const _ = require('lodash') - -function mixin(a, b) { - a = _.cloneDeep(a) - for (const prop in b) { - a[prop] = b[prop] - } - return a -} - -module.exports = mixin diff --git a/lib/recorder.js b/lib/recorder.js index 308fe8a34..4b7910352 100644 --- a/lib/recorder.js +++ b/lib/recorder.js @@ -46,7 +46,7 @@ const getBodyFromChunks = function(chunks, headers) { // If we have headers and there is content-encoding it means that // the body shouldn't be merged but instead persisted as an array // of hex strings so that the responses can be mocked one by one. - if (common.isContentEncoded(headers)) { + if (headers && common.isContentEncoded(headers)) { return { body: _.map(chunks, chunk => chunk.toString('hex')), } diff --git a/lib/request_overrider.js b/lib/request_overrider.js index e2f19dff3..a6b27ef3e 100644 --- a/lib/request_overrider.js +++ b/lib/request_overrider.js @@ -71,7 +71,7 @@ function RequestOverrider(req, options, interceptors, remove, cb) { response.req = req if (options.headers) { - // We use lower-case header field names throught Nock. + // We use lower-case header field names throughout Nock. options.headers = common.headersFieldNamesToLowerCase(options.headers) headers = options.headers @@ -299,8 +299,7 @@ function RequestOverrider(req, options, interceptors, remove, cb) { response.statusCode = interceptor.statusCode // Clone headers/rawHeaders to not override them when evaluating later - response.headers = { ...interceptor.headers } - response.rawHeaders = (interceptor.rawHeaders || []).slice() + response.rawHeaders = [...interceptor.rawHeaders] debug('response.rawHeaders:', response.rawHeaders) if (interceptor.replyFunction) { @@ -345,7 +344,7 @@ function RequestOverrider(req, options, interceptors, remove, cb) { } if ( - common.isContentEncoded(response.headers) && + common.isContentEncoded(interceptor.headers) && !common.isStream(interceptor.body) ) { // If the content is encoded we know that the response body *must* be an array @@ -454,7 +453,7 @@ function RequestOverrider(req, options, interceptors, remove, cb) { responseBody = Buffer.from(responseBody) } else { responseBody = JSON.stringify(responseBody) - response.headers['content-type'] = 'application/json' + response.rawHeaders.push('Content-Type', 'application/json') } } // Why are strings converted to a Buffer, but JSON data is left as a string? @@ -481,30 +480,21 @@ function RequestOverrider(req, options, interceptors, remove, cb) { // https://github.com/nodejs/node/blob/2e613a9c301165d121b19b86e382860323abc22f/lib/_http_incoming.js#L67 response.client = response.socket + response.rawHeaders.push( + ...selectDefaultHeaders( + response.rawHeaders, + interceptor.scope._defaultReplyHeaders + ) + ) + // Evaluate functional headers. - const evaluatedHeaders = {} - Object.entries(response.headers).forEach(([key, value]) => { + common.forEachHeader(response.rawHeaders, (value, fieldName, i) => { if (typeof value === 'function') { - response.headers[key] = evaluatedHeaders[key] = value( - req, - response, - responseBody - ) + response.rawHeaders[i + 1] = value(req, response, responseBody) } }) - for ( - let rawHeaderIndex = 0; - rawHeaderIndex < response.rawHeaders.length; - rawHeaderIndex += 2 - ) { - const key = response.rawHeaders[rawHeaderIndex] - const value = response.rawHeaders[rawHeaderIndex + 1] - if (typeof value === 'function') { - response.rawHeaders[rawHeaderIndex + 1] = - evaluatedHeaders[key.toLowerCase()] - } - } + response.headers = common.headersArrayToObject(response.rawHeaders) process.nextTick(respond) @@ -608,28 +598,35 @@ function parseFullReplyResult(response, fullReplyResult) { } response.statusCode = status + response.rawHeaders.push(...common.headersInputToRawArray(headers)) + debug('response.rawHeaders after reply: %j', response.rawHeaders) - if (headers) { - debug('new headers: %j', headers) + return body +} - const rawHeaders = Array.isArray(headers) - ? headers - : [].concat(...Object.entries(headers)) - response.rawHeaders.push(...rawHeaders) +/** + * Determine which of the default headers should be added to the response. + * + * Don't include any defaults whose case-insensitive keys are already on the response. + */ +function selectDefaultHeaders(existingHeaders, defaultHeaders) { + if (!defaultHeaders.length) { + return [] // return early if we don't need to bother + } - const castHeaders = Array.isArray(headers) - ? common.headersArrayToObject(headers) - : common.headersFieldNamesToLowerCase(headers) + const definedHeaders = new Set() + const result = [] - response.headers = { - ...response.headers, - ...castHeaders, + common.forEachHeader(existingHeaders, (_, fieldName) => { + definedHeaders.add(fieldName.toLowerCase()) + }) + common.forEachHeader(defaultHeaders, (value, fieldName) => { + if (!definedHeaders.has(fieldName.toLowerCase())) { + result.push(fieldName, value) } + }) - debug('response.headers after: %j', response.headers) - } - - return body + return result } module.exports = RequestOverrider diff --git a/lib/scope.js b/lib/scope.js index 8311b020a..b59a9840e 100644 --- a/lib/scope.js +++ b/lib/scope.js @@ -42,6 +42,7 @@ function Scope(basePath, options) { this.basePath = basePath this.basePathname = '' this.port = null + this._defaultReplyHeaders = [] if (!(basePath instanceof RegExp)) { this.urlParts = url.parse(basePath) @@ -219,9 +220,7 @@ Scope.prototype.matchHeader = function matchHeader(name, value) { } Scope.prototype.defaultReplyHeaders = function defaultReplyHeaders(headers) { - // Because these are lower-cased, res.rawHeaders can have the wrong keys. - // https://github.com/nock/nock/issues/1553 - this._defaultReplyHeaders = common.headersFieldNamesToLowerCase(headers) + this._defaultReplyHeaders = common.headersInputToRawArray(headers) return this } diff --git a/tests/test_common.js b/tests/test_common.js index 3f09e61e5..efacdb164 100644 --- a/tests/test_common.js +++ b/tests/test_common.js @@ -115,7 +115,9 @@ test('isUtf8Representable works', t => { test('isJSONContent', t => { t.true(common.isJSONContent({ 'content-type': 'application/json' })) - t.true(common.isJSONContent({ 'content-type': ['application/json'] })) + t.true( + common.isJSONContent({ 'content-type': 'application/json; charset=utf-8' }) + ) t.false(common.isJSONContent({ 'content-type': 'text/plain' })) t.end() }) diff --git a/tests/test_default_reply_headers.js b/tests/test_default_reply_headers.js index 60994fcf6..a2639ca66 100644 --- a/tests/test_default_reply_headers.js +++ b/tests/test_default_reply_headers.js @@ -12,17 +12,71 @@ test('when no headers are specified on the request, default reply headers work', nock('http://example.test') .defaultReplyHeaders({ 'X-Powered-By': 'Meeee', - 'X-Another-Header': 'Hey man!', + 'X-Another-Header': ['foo', 'bar'], }) .get('/') .reply(200, '') - const { headers } = await got('http://example.test/') + const { headers, rawHeaders } = await got('http://example.test/') t.deepEqual(headers, { 'x-powered-by': 'Meeee', - 'x-another-header': 'Hey man!', + 'x-another-header': 'foo, bar', }) + + t.deepEqual(rawHeaders, [ + 'X-Powered-By', + 'Meeee', + 'X-Another-Header', + ['foo', 'bar'], + ]) +}) + +test('default reply headers can be provided as a raw array', async t => { + const defaultHeaders = [ + 'X-Powered-By', + 'Meeee', + 'X-Another-Header', + ['foo', 'bar'], + ] + nock('http://example.test') + .defaultReplyHeaders(defaultHeaders) + .get('/') + .reply(200, '') + + const { headers, rawHeaders } = await got('http://example.test/') + + t.deepEqual(headers, { + 'x-powered-by': 'Meeee', + 'x-another-header': 'foo, bar', + }) + + t.deepEqual(rawHeaders, defaultHeaders) +}) + +test('default reply headers can be provided as a Map', async t => { + const defaultHeaders = new Map([ + ['X-Powered-By', 'Meeee'], + ['X-Another-Header', ['foo', 'bar']], + ]) + nock('http://example.test') + .defaultReplyHeaders(defaultHeaders) + .get('/') + .reply(200, '') + + const { headers, rawHeaders } = await got('http://example.test/') + + t.deepEqual(headers, { + 'x-powered-by': 'Meeee', + 'x-another-header': 'foo, bar', + }) + + t.deepEqual(rawHeaders, [ + 'X-Powered-By', + 'Meeee', + 'X-Another-Header', + ['foo', 'bar'], + ]) }) test('when headers are specified on the request, they override default reply headers', async t => { @@ -80,3 +134,68 @@ test('reply should not cause an error on header conflict', async t => { t.equal(headers['content-type'], 'application/xml') t.equal(body, '') }) + +test('direct reply headers override defaults when casing differs', async t => { + const scope = nock('http://example.com') + .defaultReplyHeaders({ + 'X-Default-Only': 'default', + 'X-Overridden': 'default', + }) + .get('/') + .reply(200, 'Success!', { + 'X-Reply-Only': 'from-reply', + 'x-overridden': 'from-reply', + }) + + const { headers, rawHeaders } = await got('http://example.com/') + + t.deepEqual(headers, { + 'x-default-only': 'default', + 'x-reply-only': 'from-reply', + 'x-overridden': 'from-reply', // note this overrode the default value, despite the case difference + }) + t.deepEqual(rawHeaders, [ + 'X-Reply-Only', + 'from-reply', + 'x-overridden', + 'from-reply', + 'X-Default-Only', + 'default', + // note 'X-Overridden' from the defaults is not included + ]) + scope.done() +}) + +test('dynamic reply headers override defaults when casing differs', async t => { + const scope = nock('http://example.com') + .defaultReplyHeaders({ + 'X-Default-Only': 'default', + 'X-Overridden': 'default', + }) + .get('/') + .reply(() => [ + 200, + 'Success!', + { + 'X-Reply-Only': 'from-reply', + 'x-overridden': 'from-reply', + }, + ]) + + const { headers, rawHeaders } = await got('http://example.com/') + + t.deepEqual(headers, { + 'x-default-only': 'default', + 'x-reply-only': 'from-reply', + 'x-overridden': 'from-reply', + }) + t.deepEqual(rawHeaders, [ + 'X-Reply-Only', + 'from-reply', + 'x-overridden', + 'from-reply', + 'X-Default-Only', + 'default', + ]) + scope.done() +}) diff --git a/tests/test_logging.js b/tests/test_logging.js index f00b38c44..aa8aa9991 100644 --- a/tests/test_logging.js +++ b/tests/test_logging.js @@ -23,12 +23,13 @@ test('match debugging works', async t => { const exampleBody = 'Hello yourself!' await got.post('http://example.test/deep/link', { body: exampleBody }) - t.ok(log.calledOnce) - t.equal( - JSON.parse(log.getCall(0).args[1]).href, - 'http://example.test/deep/link' + t.ok( + log.calledWith( + sinon.match.string, + sinon.match('http://example.test/deep/link'), + sinon.match(exampleBody) + ) ) - t.equal(JSON.parse(log.getCall(0).args[2]), exampleBody) }) test('should log matching', async t => { diff --git a/tests/test_reply_headers.js b/tests/test_reply_headers.js index eb7ae7130..668262f09 100644 --- a/tests/test_reply_headers.js +++ b/tests/test_reply_headers.js @@ -12,127 +12,161 @@ const got = require('./got_client') require('./cleanup_after_each')() -test('reply header is sent in the mock response', async t => { +test('reply headers directly with a raw array', async t => { + const scope = nock('http://example.test') + .get('/') + .reply(200, 'Hello World!', [ + 'X-My-Header', + 'My Header value', + 'X-Other-Header', + 'My Other Value', + ]) + + const { headers, rawHeaders } = await got('http://example.test/') + + t.equivalent(headers, { + 'x-my-header': 'My Header value', + 'x-other-header': 'My Other Value', + }) + t.equivalent(rawHeaders, [ + 'X-My-Header', + 'My Header value', + 'X-Other-Header', + 'My Other Value', + ]) + scope.done() +}) + +test('reply headers directly with an object', async t => { const scope = nock('http://example.test') .get('/') .reply(200, 'Hello World!', { 'X-My-Headers': 'My Header value' }) - const { headers } = await got('http://example.test/') + const { headers, rawHeaders } = await got('http://example.test/') t.equivalent(headers, { 'x-my-headers': 'My Header value' }) + t.equivalent(rawHeaders, ['X-My-Headers', 'My Header value']) scope.done() }) -test('content-length header is sent with response', async t => { +test('reply headers directly with a Map', async t => { + const replyHeaders = new Map([ + ['X-My-Header', 'My Header value'], + ['X-Other-Header', 'My Other Value'], + ]) const scope = nock('http://example.test') - .replyContentLength() .get('/') - .reply(200, { hello: 'world' }) + .reply(200, 'Hello World!', replyHeaders) - const { headers } = await got('http://example.test/') + const { headers, rawHeaders } = await got('http://example.test/') - t.equal(headers['content-length'], 17) + t.equivalent(headers, { + 'x-my-header': 'My Header value', + 'x-other-header': 'My Other Value', + }) + t.equivalent(rawHeaders, [ + 'X-My-Header', + 'My Header value', + 'X-Other-Header', + 'My Other Value', + ]) scope.done() }) -test('header array sends multiple reply headers', async t => { +test('reply headers dynamically with a raw array', async t => { const scope = nock('http://example.test') .get('/') - .reply(200, 'Hello World!', { - 'Set-Cookie': ['cookie1=foo', 'cookie2=bar'], - }) + .reply(() => [ + 200, + 'Hello World!', + ['X-My-Header', 'My Header value', 'X-Other-Header', 'My Other Value'], + ]) const { headers, rawHeaders } = await got('http://example.test/') + t.equivalent(headers, { - 'set-cookie': ['cookie1=foo', 'cookie2=bar'], + 'x-my-header': 'My Header value', + 'x-other-header': 'My Other Value', }) - t.equivalent(rawHeaders, ['Set-Cookie', ['cookie1=foo', 'cookie2=bar']]) - + t.equivalent(rawHeaders, [ + 'X-My-Header', + 'My Header value', + 'X-Other-Header', + 'My Other Value', + ]) scope.done() }) -test('reply header function is evaluated and the result sent in the mock response', async t => { +test('reply headers dynamically with an object', async t => { const scope = nock('http://example.test') .get('/') - .reply(200, 'boo!', { - 'X-My-Headers': () => 'yo!', - }) + .reply(() => [200, 'Hello World!', { 'X-My-Headers': 'My Header value' }]) const { headers, rawHeaders } = await got('http://example.test/') - t.equivalent(headers, { 'x-my-headers': 'yo!' }) - t.equivalent(rawHeaders, ['X-My-Headers', 'yo!']) + t.equivalent(headers, { 'x-my-headers': 'My Header value' }) + t.equivalent(rawHeaders, ['X-My-Headers', 'My Header value']) scope.done() }) -// Skipping these two test because of the inconsistencies around raw headers. -// - they often receive the lower-cased versions of the keys -// - the resulting order differs depending if overrides are provided to .reply directly or via a callback -// - replacing values with function results isn't guaranteed to keep the correct order -// - the resulting `headers` object itself is fine and these assertions pass -// https://github.com/nock/nock/issues/1553 -test('reply headers and defaults', { skip: true }, async t => { - const scope = nock('http://example.com') - .defaultReplyHeaders({ - 'X-Powered-By': 'Meeee', - 'X-Another-Header': 'Hey man!', - }) +test('reply headers dynamically with a Map', async t => { + const replyHeaders = new Map([ + ['X-My-Header', 'My Header value'], + ['X-Other-Header', 'My Other Value'], + ]) + const scope = nock('http://example.test') .get('/') - .reply(200, 'Success!', { - 'X-Custom-Header': 'boo!', - 'x-another-header': 'foobar', - }) + .reply(() => [200, 'Hello World!', replyHeaders]) - const { headers, rawHeaders } = await got('http://example.com/') + const { headers, rawHeaders } = await got('http://example.test/') t.equivalent(headers, { - 'x-custom-header': 'boo!', - 'x-another-header': 'foobar', // note this overrode the default value, despite the case difference - 'x-powered-by': 'Meeee', + 'x-my-header': 'My Header value', + 'x-other-header': 'My Other Value', }) t.equivalent(rawHeaders, [ - 'X-Powered-By', - 'Meeee', - 'X-Another-Header', - 'Hey man!', - 'X-Custom-Header', - 'boo!', - 'x-another-header', - 'foobar', + 'X-My-Header', + 'My Header value', + 'X-Other-Header', + 'My Other Value', ]) scope.done() }) -test('reply headers from callback and defaults', { skip: true }, async t => { - const scope = nock('http://example.com') - .defaultReplyHeaders({ - 'X-Powered-By': 'Meeee', - 'X-Another-Header': 'Hey man!', - }) - .get('/') - .reply(() => [ - 200, - 'Success!', - { 'X-Custom-Header': 'boo!', 'x-another-header': 'foobar' }, - ]) +test('reply headers throws for invalid data', async t => { + const scope = nock('http://example.test').get('/') - const { headers, rawHeaders } = await got('http://example.com/') + t.throws(() => scope.reply(200, 'Hello World!', 'foo: bar'), { + message: + 'Headers must be provided as an array of raw values, a Map, or a plain Object. foo: bar', + }) - t.equivalent(headers, { - 'x-custom-header': 'boo!', - 'x-another-header': 'foobar', - 'x-powered-by': 'Meeee', + t.throws(() => scope.reply(200, 'Hello World!', false), { + message: + 'Headers must be provided as an array of raw values, a Map, or a plain Object. false', }) - t.equivalent(rawHeaders, [ - 'X-Powered-By', - 'Meeee', - 'X-Another-Header', - 'Hey man!', - 'X-Custom-Header', - 'boo!', - 'x-another-header', - 'foobar', - ]) +}) + +test('reply headers throws for raw array with an odd number of items', async t => { + const scope = nock('http://example.test').get('/') + + t.throws(() => scope.reply(200, 'Hello World!', ['one', 'two', 'three']), { + message: + 'Raw headers must be provided as an array with an even number of items. [fieldName, value, ...]', + }) +}) + +test('reply header function is evaluated and the result sent in the mock response', async t => { + const scope = nock('http://example.test') + .get('/') + .reply(200, 'boo!', { + 'X-My-Headers': () => 'yo!', + }) + + const { headers, rawHeaders } = await got('http://example.test/') + + t.equivalent(headers, { 'x-my-headers': 'yo!' }) + t.equivalent(rawHeaders, ['X-My-Headers', 'yo!']) scope.done() }) @@ -175,6 +209,23 @@ test('reply headers function is evaluated exactly once', async t => { t.equal(counter, 1) }) +test('duplicate reply headers function is evaluated once per input field name, in correct order', async t => { + const replyHeaders = ['X-MY-HEADER', () => 'one', 'x-my-header', () => 'two'] + + const scope = nock('http://example.test') + .get('/') + .reply(200, 'Hello World!', replyHeaders) + + const { headers, rawHeaders } = await got('http://example.test/') + + t.equivalent(headers, { + 'x-my-header': 'one, two', + }) + t.equivalent(rawHeaders, ['X-MY-HEADER', 'one', 'x-my-header', 'two']) + + scope.done() +}) + test('reply header function are re-evaluated for every matching request', async t => { let counter = 0 const scope = nock('http://example.test') @@ -201,6 +252,55 @@ test('reply header function are re-evaluated for every matching request', async scope.done() }) +// https://nodejs.org/api/http.html#http_message_headers +test('duplicate headers are folded the same as Node', async t => { + const replyHeaders = [ + 'Content-Type', + 'text/html; charset=utf-8', + 'set-cookie', + ['set-cookie1=foo', 'set-cookie2=bar'], + 'set-cookie', + 'set-cookie3=baz', + 'CONTENT-TYPE', + 'text/xml', + 'cookie', + 'cookie1=foo; cookie2=bar', + 'cookie', + 'cookie3=baz', + 'x-custom', + 'custom1', + 'X-Custom', + ['custom2', 'custom3'], + ] + const scope = nock('http://example.test') + .get('/') + .reply(200, 'Hello World!', replyHeaders) + + const { headers, rawHeaders } = await got('http://example.test/') + + t.equivalent(headers, { + 'content-type': 'text/html; charset=utf-8', + 'set-cookie': ['set-cookie1=foo', 'set-cookie2=bar', 'set-cookie3=baz'], + cookie: 'cookie1=foo; cookie2=bar; cookie3=baz', + 'x-custom': 'custom1, custom2, custom3', + }) + t.equivalent(rawHeaders, replyHeaders) + + scope.done() +}) + +test('replyContentLength() sends explicit content-length header with response', async t => { + const scope = nock('http://example.test') + .replyContentLength() + .get('/') + .reply(200, { hello: 'world' }) + + const { headers } = await got('http://example.test/') + + t.equal(headers['content-length'], '17') + scope.done() +}) + test('replyDate() sends explicit date header with response', async t => { const date = new Date()