Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
68 changes: 65 additions & 3 deletions lib/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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
Expand All @@ -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')
Expand Down Expand Up @@ -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
Expand Down
102 changes: 101 additions & 1 deletion test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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) {
Expand Down
Loading