diff --git a/README.md b/README.md index 519a8dd..ff1ca37 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Injects a fake request into an HTTP server. - `method` - a string specifying the HTTP request method, defaulting to `'GET'`. - `authority` - a string specifying the HTTP HOST header value to be used if no header is provided, and the `url` does not include an authority component. Defaults to `'localhost'`. - - `headers` - an optional object containing request headers. + - `headers` - an optional object containing request headers. A header value may be an array of strings to simulate the same header being sent multiple times; the values are aggregated exactly as a Node.js HTTP server would expose them on `request.headers` (`set-cookie` is kept as an array, `cookie` values are joined with `'; '`, other values with `', '`), while every individual value is preserved in `request.rawHeaders`. - `cookies` - an optional object containing key-value pairs that will be encoded and added to `cookie` header. If the header is already set, the data will be appended. - `remoteAddress` - an optional string specifying the client remote address. Defaults to `'127.0.0.1'`. - `payload` - an optional request payload. Can be a string, Buffer, Stream, or object. If the payload is string, Buffer or Stream is used as is as the request payload. Otherwise, it is serialized with `JSON.stringify` forcing the request to have the `Content-type` equal to `application/json` diff --git a/lib/request.js b/lib/request.js index 2ddc0f7..0a6009b 100644 --- a/lib/request.js +++ b/lib/request.js @@ -31,6 +31,38 @@ function hostHeaderFromURL (parsedURL) { : parsedURL.hostname + (parsedURL.protocol === 'https:' ? ':443' : ':80') } +// Header field names for which Node's HTTP parser discards duplicate +// values, keeping only the first occurrence. Mirrors the aggregation +// performed by `http.IncomingMessage`, see the `message.headers` docs: +// https://nodejs.org/api/http.html#messageheaders +const SINGLE_VALUE_HEADERS = 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', 'server', 'user-agent' +]) + +/** + * Aggregate an array of header values the same way Node's HTTP server + * combines multiple occurrences of a header line into `request.headers`. + * + * @param {String} field the lower-cased header field name + * @param {String[]} values the individual header values + * @return {(String|String[])} + */ +function joinHeaderValues (field, values) { + if (field === 'set-cookie') { + // `set-cookie` is always exposed as an array of its individual values. + return values.slice() + } + if (SINGLE_VALUE_HEADERS.has(field)) { + // Duplicates are discarded, only the first value is kept. + return values[0] + } + // `cookie` duplicates are joined with '; ', everything else with ', '. + return values.join(field === 'cookie' ? '; ' : ', ') +} + /** * Mock socket object used to fake access to a socket for a request * @@ -110,13 +142,32 @@ function Request (options) { const headers = options.headers || {} + // Records the individual values of headers supplied as an array, so the + // raw occurrences can be reflected back in `rawHeaders`. + const multiValueHeaders = new Map() + for (const field in headers) { const fieldLowerCase = field.toLowerCase() - if ((fieldLowerCase === 'user-agent' || fieldLowerCase === 'content-type') && headers[field] === undefined) { + const value = headers[field] + if ((fieldLowerCase === 'user-agent' || fieldLowerCase === 'content-type') && value === undefined) { this.headers[fieldLowerCase] = undefined continue } - const value = headers[field] + if (Array.isArray(value)) { + // An array represents the same header sent multiple times. Aggregate + // the values the way Node's HTTP server exposes them on the request. + const rawValues = [] + for (const item of value) { + assert( + item !== undefined, + 'invalid value "undefined" for header ' + field + ) + rawValues.push('' + item) + } + multiValueHeaders.set(fieldLowerCase, rawValues) + this.headers[fieldLowerCase] = joinHeaderValues(fieldLowerCase, rawValues) + continue + } assert( value !== undefined, 'invalid value "undefined" for header ' + field @@ -139,6 +190,9 @@ function Request (options) { cookieValues.unshift(this.headers.cookie) } this.headers.cookie = cookieValues.join('; ') + // The cookie header has been rebuilt, so its raw per-value breakdown is + // no longer accurate. + multiValueHeaders.delete('cookie') } this.socket = new MockSocket(options.remoteAddress || '127.0.0.1') @@ -184,7 +238,15 @@ function Request (options) { } for (const header of Object.keys(this.headers)) { - this.rawHeaders.push(header, this.headers[header]) + const rawValues = multiValueHeaders.get(header) + if (rawValues !== undefined) { + // Expand back to one raw entry per supplied value, as Node does. + for (const value of rawValues) { + this.rawHeaders.push(header, value) + } + } else { + this.rawHeaders.push(header, this.headers[header]) + } } // Use _lightMyRequest namespace to avoid collision with Node diff --git a/test/index.test.js b/test/index.test.js index b4db714..0dca786 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1754,7 +1754,7 @@ test('correctly handles no string headers', (t, done) => { null: 'null', string: 'string', object: '[object Object]', - array: '1,two,3', + array: '1, two, 3', date: date.toString(), true: 'true', false: 'false', @@ -1775,6 +1775,106 @@ test('errors for invalid undefined header value', (t, done) => { } }) +test('aggregates array header values like a real HTTP server', (t, done) => { + t.plan(4) + const dispatch = function (req, res) { + // General headers are joined with ', ' (comma + space), matching Node. + t.assert.strictEqual(req.headers['x-multi'], 'first, second') + // Numeric values are coerced to strings before joining. + t.assert.strictEqual(req.headers['x-num'], '1, 2') + // Each supplied value is preserved as a separate rawHeaders entry. + t.assert.deepStrictEqual( + req.rawHeaders.slice(0, 8), + ['x-multi', 'first', 'x-multi', 'second', 'x-num', '1', 'x-num', '2'] + ) + res.writeHead(200) + res.end() + } + + inject(dispatch, { + method: 'GET', + url: 'http://example.com:8080/hello', + headers: { 'x-multi': ['first', 'second'], 'x-num': [1, 2] } + }, (err) => { + t.assert.ifError(err) + done() + }) +}) + +test('keeps set-cookie request header as an array', (t, done) => { + t.plan(3) + const dispatch = function (req, res) { + t.assert.deepStrictEqual(req.headers['set-cookie'], ['a=1', 'b=2']) + t.assert.deepStrictEqual( + req.rawHeaders.slice(0, 4), + ['set-cookie', 'a=1', 'set-cookie', 'b=2'] + ) + res.writeHead(200) + res.end() + } + + inject(dispatch, { + method: 'GET', + url: 'http://example.com:8080/hello', + headers: { 'set-cookie': ['a=1', 'b=2'] } + }, (err) => { + t.assert.ifError(err) + done() + }) +}) + +test('joins array cookie request header with "; "', (t, done) => { + t.plan(2) + const dispatch = function (req, res) { + t.assert.strictEqual(req.headers.cookie, 'x=1; y=2') + res.writeHead(200) + res.end() + } + + inject(dispatch, { + method: 'GET', + url: 'http://example.com:8080/hello', + headers: { cookie: ['x=1', 'y=2'] } + }, (err) => { + t.assert.ifError(err) + done() + }) +}) + +test('discards duplicates of single-value array headers, keeping the first', (t, done) => { + t.plan(3) + const dispatch = function (req, res) { + // `content-type` is a single-value header: Node keeps only the first. + t.assert.strictEqual(req.headers['content-type'], 'text/plain') + // rawHeaders still lists every supplied occurrence. + t.assert.deepStrictEqual( + req.rawHeaders.slice(0, 4), + ['content-type', 'text/plain', 'content-type', 'application/json'] + ) + res.writeHead(200) + res.end() + } + + inject(dispatch, { + method: 'GET', + url: 'http://example.com:8080/hello', + headers: { 'content-type': ['text/plain', 'application/json'] } + }, (err) => { + t.assert.ifError(err) + done() + }) +}) + +test('errors for an undefined value inside an array header', (t, done) => { + t.plan(1) + try { + inject(() => {}, { url: '/', headers: { 'header-key': ['a', undefined] } }, () => {}) + } catch (err) { + t.assert.ok(err) + done() + } +}) + test('example with form-auto-content', (t, done) => { t.plan(4) const dispatch = function (req, res) {