current commit
260507.3 · test: drain request bodies in request tests (#5247)
'use strict'

const { tspl } = require('@matteo.collina/tspl')
const { createServer } = require('node:http')
const { test, after, describe } = require('node:test')
const { request, errors } = require('..')

test('no-slash/one-slash pathname should be included in req.path', async (t) => {
  t = tspl(t, { plan: 24 })

  const pathServer = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    t.fail('it shouldn\'t be called')
    res.statusCode = 200
    res.end('hello')
  })

  const requestedServer = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    t.strictEqual(`/localhost:${pathServer.address().port}`, req.url)
    t.strictEqual('GET', req.method)
    t.strictEqual(`localhost:${requestedServer.address().port}`, req.headers.host)
    res.statusCode = 200
    res.end('hello')
  })

  after(() => {
    requestedServer.close()
    pathServer.close()
  })

  await Promise.all([
    requestedServer.listen(0),
    pathServer.listen(0)
  ])

  const noSlashPathname = await request({
    method: 'GET',
    origin: `http://localhost:${requestedServer.address().port}`,
    pathname: `localhost:${pathServer.address().port}`
  })
  t.strictEqual(noSlashPathname.statusCode, 200)
  await noSlashPathname.body.dump()
  const noSlashPath = await request({
    method: 'GET',
    origin: `http://localhost:${requestedServer.address().port}`,
    path: `localhost:${pathServer.address().port}`
  })
  t.strictEqual(noSlashPath.statusCode, 200)
  await noSlashPath.body.dump()
  const noSlashPath2Arg = await request(
    `http://localhost:${requestedServer.address().port}`,
    { path: `localhost:${pathServer.address().port}` }
  )
  t.strictEqual(noSlashPath2Arg.statusCode, 200)
  await noSlashPath2Arg.body.dump()
  const oneSlashPathname = await request({
    method: 'GET',
    origin: `http://localhost:${requestedServer.address().port}`,
    pathname: `/localhost:${pathServer.address().port}`
  })
  t.strictEqual(oneSlashPathname.statusCode, 200)
  await oneSlashPathname.body.dump()
  const oneSlashPath = await request({
    method: 'GET',
    origin: `http://localhost:${requestedServer.address().port}`,
    path: `/localhost:${pathServer.address().port}`
  })
  t.strictEqual(oneSlashPath.statusCode, 200)
  await oneSlashPath.body.dump()
  const oneSlashPath2Arg = await request(
    `http://localhost:${requestedServer.address().port}`,
    { path: `/localhost:${pathServer.address().port}` }
  )
  t.strictEqual(oneSlashPath2Arg.statusCode, 200)
  await oneSlashPath2Arg.body.dump()
  t.end()
})

test('protocol-relative URL as pathname should be included in req.path', async (t) => {
  t = tspl(t, { plan: 12 })

  const pathServer = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    t.fail('it shouldn\'t be called')
    res.statusCode = 200
    res.end('hello')
  })

  const requestedServer = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    t.strictEqual(`//localhost:${pathServer.address().port}`, req.url)
    t.strictEqual('GET', req.method)
    t.strictEqual(`localhost:${requestedServer.address().port}`, req.headers.host)
    res.statusCode = 200
    res.end('hello')
  })

  after(() => {
    requestedServer.close()
    pathServer.close()
  })

  await Promise.all([
    requestedServer.listen(0),
    pathServer.listen(0)
  ])

  const noSlashPathname = await request({
    method: 'GET',
    origin: `http://localhost:${requestedServer.address().port}`,
    pathname: `//localhost:${pathServer.address().port}`
  })
  t.strictEqual(noSlashPathname.statusCode, 200)
  await noSlashPathname.body.dump()
  const noSlashPath = await request({
    method: 'GET',
    origin: `http://localhost:${requestedServer.address().port}`,
    path: `//localhost:${pathServer.address().port}`
  })
  t.strictEqual(noSlashPath.statusCode, 200)
  await noSlashPath.body.dump()
  const noSlashPath2Arg = await request(
    `http://localhost:${requestedServer.address().port}`,
    { path: `//localhost:${pathServer.address().port}` }
  )
  t.strictEqual(noSlashPath2Arg.statusCode, 200)
  await noSlashPath2Arg.body.dump()
  t.end()
})

test('Absolute URL as pathname should be included in req.path', async (t) => {
  t = tspl(t, { plan: 12 })

  const pathServer = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    t.fail('it shouldn\'t be called')
    res.statusCode = 200
    res.end('hello')
  })

  const requestedServer = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    t.strictEqual(`/http://localhost:${pathServer.address().port}`, req.url)
    t.strictEqual('GET', req.method)
    t.strictEqual(`localhost:${requestedServer.address().port}`, req.headers.host)
    res.statusCode = 200
    res.end('hello')
  })

  after(() => {
    requestedServer.close()
    pathServer.close()
  })

  await Promise.all([
    requestedServer.listen(0),
    pathServer.listen(0)
  ])

  const noSlashPathname = await request({
    method: 'GET',
    origin: `http://localhost:${requestedServer.address().port}`,
    pathname: `http://localhost:${pathServer.address().port}`
  })
  t.strictEqual(noSlashPathname.statusCode, 200)
  await noSlashPathname.body.dump()
  const noSlashPath = await request({
    method: 'GET',
    origin: `http://localhost:${requestedServer.address().port}`,
    path: `http://localhost:${pathServer.address().port}`
  })
  t.strictEqual(noSlashPath.statusCode, 200)
  await noSlashPath.body.dump()
  const noSlashPath2Arg = await request(
    `http://localhost:${requestedServer.address().port}`,
    { path: `http://localhost:${pathServer.address().port}` }
  )
  t.strictEqual(noSlashPath2Arg.statusCode, 200)
  await noSlashPath2Arg.body.dump()
  t.end()
})

describe('DispatchOptions#expectContinue', () => {
  test('Should throw if invalid expectContinue option', async t => {
    t = tspl(t, { plan: 1 })

    await t.rejects(request({
      method: 'GET',
      origin: 'http://somehost.xyz',
      expectContinue: 0
    }), /invalid expectContinue/)

    await t.completed
  })
})

describe('DispatchOptions#maxRedirections', () => {
  test('Should throw if maxRedirections option is used', async t => {
    t = tspl(t, { plan: 2 })

    await t.rejects(request({
      method: 'GET',
      origin: 'http://somehost.xyz',
      maxRedirections: 5
    }), /maxRedirections is not supported, use the redirect interceptor/)

    await t.rejects(request({
      method: 'GET',
      origin: 'http://somehost.xyz',
      maxRedirections: 1
    }), /maxRedirections is not supported, use the redirect interceptor/)

    await t.completed
  })

  test('Should allow maxRedirections: 0 for internal use', async t => {
    t = tspl(t, { plan: 2 })
    const server = createServer((req, res) => {
      t.ok('request received')
      res.end('hello world')
    })
    after(() => {
      server.closeAllConnections?.()
      server.close()
    })
    await new Promise((resolve) => server.listen(0, resolve))

    const res = await request({
      method: 'GET',
      origin: `http://localhost:${server.address().port}`,
      maxRedirections: 0
    })

    const body = await res.body.text()

    t.strictEqual(body, 'hello world')
  })
})

describe('DispatchOptions#reset', () => {
  test('Should throw if invalid reset option', async t => {
    t = tspl(t, { plan: 1 })

    await t.rejects(request({
      method: 'GET',
      origin: 'http://somehost.xyz',
      reset: 0
    }), /invalid reset/)

    await t.completed
  })

  test('Should include "connection:close" if reset true', async t => {
    t = tspl(t, { plan: 3 })

    const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
      t.strictEqual('GET', req.method)
      t.strictEqual(`localhost:${server.address().port}`, req.headers.host)
      t.strictEqual(req.headers.connection, 'close')
      res.statusCode = 200
      res.end('hello')
    })

    after(() => {
      server.closeAllConnections?.()
      server.close()
    })

    await new Promise((resolve, reject) => {
      server.listen(0, (err) => {
        if (err != null) reject(err)
        else resolve()
      })
    })

    const { body } = await request({
      method: 'GET',
      origin: `http://localhost:${server.address().port}`,
      reset: true
    })
    await body.dump()
  })

  test('Should include "connection:keep-alive" if reset false', async t => {
    t = tspl(t, { plan: 3 })

    const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
      t.strictEqual('GET', req.method)
      t.strictEqual(`localhost:${server.address().port}`, req.headers.host)
      t.strictEqual(req.headers.connection, 'keep-alive')
      res.statusCode = 200
      res.end('hello')
    })

    after(() => {
      server.closeAllConnections?.()
      server.close()
    })

    await new Promise((resolve, reject) => {
      server.listen(0, (err) => {
        if (err != null) reject(err)
        else resolve()
      })
    })

    const { body } = await request({
      method: 'GET',
      origin: `http://localhost:${server.address().port}`,
      reset: false
    })
    await body.dump()
  })

  test('Should react to manual set of "connection:close" header', async t => {
    t = tspl(t, { plan: 3 })

    const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
      t.strictEqual('GET', req.method)
      t.strictEqual(`localhost:${server.address().port}`, req.headers.host)
      t.strictEqual(req.headers.connection, 'close')
      res.statusCode = 200
      res.end('hello')
    })

    after(() => {
      server.closeAllConnections?.()
      server.close()
    })

    await new Promise((resolve, reject) => {
      server.listen(0, (err) => {
        if (err != null) reject(err)
        else resolve()
      })
    })

    const { body } = await request({
      method: 'GET',
      origin: `http://localhost:${server.address().port}`,
      headers: {
        connection: 'close'
      }
    })
    await body.dump()
  })
})

describe('Should include headers from iterable objects', scope => {
  test('Should include headers built with Headers global object', { skip: !globalThis.Headers }, async t => {
    t = tspl(t, { plan: 3 })

    const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
      t.strictEqual('GET', req.method)
      t.strictEqual(`localhost:${server.address().port}`, req.headers.host)
      t.strictEqual(req.headers.hello, 'world')
      res.statusCode = 200
      res.end('hello')
    })

    const headers = new globalThis.Headers()
    headers.set('hello', 'world')

    after(() => {
      server.closeAllConnections?.()
      server.close()
    })

    await new Promise((resolve, reject) => {
      server.listen(0, (err) => {
        if (err != null) reject(err)
        else resolve()
      })
    })

    const { body } = await request({
      method: 'GET',
      origin: `http://localhost:${server.address().port}`,
      reset: true,
      headers
    })
    await body.dump()
  })

  test('Should include headers built with Map', async t => {
    t = tspl(t, { plan: 3 })

    const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
      t.strictEqual('GET', req.method)
      t.strictEqual(`localhost:${server.address().port}`, req.headers.host)
      t.strictEqual(req.headers.hello, 'world')
      res.statusCode = 200
      res.end('hello')
    })

    const headers = new Map()
    headers.set('hello', 'world')

    after(() => {
      server.closeAllConnections?.()
      server.close()
    })

    await new Promise((resolve, reject) => {
      server.listen(0, (err) => {
        if (err != null) reject(err)
        else resolve()
      })
    })

    const { body } = await request({
      method: 'GET',
      origin: `http://localhost:${server.address().port}`,
      reset: true,
      headers
    })
    await body.dump()
  })

  test('Should include headers built with custom iterable object', async t => {
    t = tspl(t, { plan: 3 })

    const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
      t.strictEqual('GET', req.method)
      t.strictEqual(`localhost:${server.address().port}`, req.headers.host)
      t.strictEqual(req.headers.hello, 'world')
      res.statusCode = 200
      res.end('hello')
    })

    const headers = {
      * [Symbol.iterator] () {
        yield ['hello', 'world']
      }
    }

    after(() => {
      server.closeAllConnections?.()
      server.close()
    })

    await new Promise((resolve, reject) => {
      server.listen(0, (err) => {
        if (err != null) reject(err)
        else resolve()
      })
    })

    const { body } = await request({
      method: 'GET',
      origin: `http://localhost:${server.address().port}`,
      reset: true,
      headers
    })
    await body.dump()
  })

  test('Should include headers from plain objects with polluted Object.prototype[Symbol.iterator]', async t => {
    t = tspl(t, { plan: 3 })

    const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
      t.strictEqual('GET', req.method)
      t.strictEqual(`localhost:${server.address().port}`, req.headers.host)
      t.strictEqual(req.headers.hello, 'world')
      res.statusCode = 200
      res.end('hello')
    })

    const headers = {
      hello: 'world'
    }

    after(() => {
      server.closeAllConnections?.()
      server.close()
    })

    const originalIterator = Object.prototype[Symbol.iterator]
    // eslint-disable-next-line no-extend-native
    Object.prototype[Symbol.iterator] = function * () {}

    try {
      await new Promise((resolve, reject) => {
        server.listen(0, (err) => {
          if (err != null) reject(err)
          else resolve()
        })
      })

      const { body } = await request({
        method: 'GET',
        origin: `http://localhost:${server.address().port}`,
        reset: true,
        headers
      })
      await body.dump()
    } finally {
      if (originalIterator === undefined) {
        delete Object.prototype[Symbol.iterator]
      } else {
        // eslint-disable-next-line no-extend-native
        Object.prototype[Symbol.iterator] = originalIterator
      }
    }
  })

  test('Should throw error if headers iterable object does not yield key-value pairs', async t => {
    t = tspl(t, { plan: 2 })

    const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
      res.end('hello')
    })

    const headers = {
      * [Symbol.iterator] () {
        yield 'Bad formatted header'
      }
    }

    after(() => {
      server.closeAllConnections?.()
      server.close()
    })

    await new Promise((resolve, reject) => {
      server.listen(0, (err) => {
        if (err != null) reject(err)
        else resolve()
      })
    })

    await request({
      method: 'GET',
      origin: `http://localhost:${server.address().port}`,
      reset: true,
      headers
    }).catch((err) => {
      t.ok(err instanceof errors.InvalidArgumentError)
      t.strictEqual(err.message, 'headers must be in key-value pair format')
    })
  })
})

test('request should include statusText in response', async t => {
  t = tspl(t, { plan: 2 })

  const server = createServer((req, res) => {
    res.writeHead(200, 'Custom Status Text', { 'content-type': 'text/plain' })
    res.end('hello')
  })

  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  await new Promise((resolve) => server.listen(0, resolve))

  const { statusText, body } = await request({
    method: 'GET',
    origin: `http://localhost:${server.address().port}`,
    path: '/'
  })

  t.strictEqual(statusText, 'Custom Status Text')
  await body.dump()
  t.ok('request completed')
})

describe('connection header per RFC 7230', () => {
  test('should allow close', async (t) => {
    t = tspl(t, { plan: 1 })

    const server = createServer((req, res) => {
      res.end('ok')
    })

    after(() => server.close())
    await new Promise((resolve) => server.listen(0, resolve))

    const { statusCode, body } = await request({
      method: 'GET',
      origin: `http://localhost:${server.address().port}`,
      headers: { connection: 'close' }
    })
    await body.dump()
    t.strictEqual(statusCode, 200)
  })

  test('should allow keep-alive', async (t) => {
    t = tspl(t, { plan: 1 })

    const server = createServer((req, res) => {
      res.end('ok')
    })

    after(() => server.close())
    await new Promise((resolve) => server.listen(0, resolve))

    const { statusCode, body } = await request({
      method: 'GET',
      origin: `http://localhost:${server.address().port}`,
      headers: { connection: 'keep-alive' }
    })
    await body.dump()
    t.strictEqual(statusCode, 200)
  })

  test('should allow custom header name as connection option', async (t) => {
    t = tspl(t, { plan: 1 })

    const server = createServer((req, res) => {
      res.end('ok')
    })

    after(() => server.close())
    await new Promise((resolve) => server.listen(0, resolve))

    const { statusCode, body } = await request({
      method: 'GET',
      origin: `http://localhost:${server.address().port}`,
      headers: {
        'x-custom-header': 'value',
        connection: 'x-custom-header'
      }
    })
    await body.dump()
    t.strictEqual(statusCode, 200)
  })

  test('should allow comma-separated list of connection options', async (t) => {
    t = tspl(t, { plan: 1 })

    const server = createServer((req, res) => {
      res.end('ok')
    })

    after(() => server.close())
    await new Promise((resolve) => server.listen(0, resolve))

    const { statusCode, body } = await request({
      method: 'GET',
      origin: `http://localhost:${server.address().port}`,
      headers: {
        'x-custom-header': 'value',
        connection: 'close, x-custom-header'
      }
    })
    await body.dump()
    t.strictEqual(statusCode, 200)
  })

  test('should reject invalid tokens in connection header', async (t) => {
    t = tspl(t, { plan: 2 })

    await request({
      method: 'GET',
      origin: 'http://localhost:1234',
      headers: { connection: 'invalid header with spaces' }
    }).catch((err) => {
      t.ok(err instanceof errors.InvalidArgumentError)
      t.strictEqual(err.message, 'invalid connection header')
    })
  })
})