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()