current commit
261014.3 · fix: reject unsupported FormData request bodies (#5913)
/* globals AbortController */

'use strict'

const { tspl } = require('@matteo.collina/tspl')
const { test, after, describe, before } = require('node:test')
const { Client, errors } = require('..')
const { createServer } = require('node:http')
const EE = require('node:events')
const { kConnect } = require('../lib/core/symbols')
const { Readable } = require('node:stream')
const net = require('node:net')
const { promisify } = require('node:util')
const { NotSupportedError, InvalidArgumentError, AbortError } = require('../lib/core/errors')
const { parseFormDataString } = require('./utils/formdata')

test('request dump head', async (t) => {
  t = tspl(t, { plan: 3 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.setHeader('content-length', 5 * 100)
    res.flushHeaders()
    res.write('hello'.repeat(100))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    let dumped = false
    client.on('disconnect', () => {
      t.strictEqual(dumped, true)
    })
    client.request({
      path: '/',
      method: 'HEAD'
    }, (err, { body }) => {
      t.ifError(err)
      body.dump({ limit: 1 }).then(() => {
        dumped = true
        t.ok(true, 'pass')
      })
    })
  })

  await t.completed
})

test('request dump big', async (t) => {
  t = tspl(t, { plan: 3 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.setHeader('content-length', 999999999)
    while (res.write('asd')) {
      // Do nothing...
    }
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    let dumped = false
    client.on('disconnect', () => {
      t.strictEqual(dumped, true)
    })
    client.request({
      path: '/',
      method: 'GET'
    }, (err, { body }) => {
      t.ifError(err)
      body.on('data', () => t.fail())
      body.dump().then(() => {
        dumped = true
        t.ok(true, 'pass')
      })
    })
  })

  await t.completed
})

test('request dump', async (t) => {
  t = tspl(t, { plan: 3 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.shouldKeepAlive = false
    res.setHeader('content-length', 5)
    res.end('hello')
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    let dumped = false
    client.on('disconnect', () => {
      t.strictEqual(dumped, true)
    })
    client.request({
      path: '/',
      method: 'GET'
    }, (err, { body }) => {
      t.ifError(err)
      body.dump().then(() => {
        dumped = true
        t.ok(true, 'pass')
      })
    })
  })

  await t.completed
})

test('request dump with abort signal', async (t) => {
  t = tspl(t, { plan: 10 })
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.write('hello')
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    client.request({
      path: '/',
      method: 'GET'
    }, (err, { body }) => {
      t.ifError(err)
      const ac = new AbortController()
      body.dump({ signal: ac.signal }).catch((err) => {
        t.strictEqual(err.name, 'AbortError')
        t.strictEqual(err.message, 'This operation was aborted')
        const stackLines = err.stack.split('\n').map((l) => l.trim())

        t.ok(stackLines[0].startsWith('AbortError: This operation was aborted'))
        t.ok(stackLines[1].startsWith('at new DOMException'))
        t.ok(stackLines[2].startsWith('at AbortController.abort'))
        t.ok(/client-request.js/.test(stackLines[3]))
        t.ok(stackLines[4].startsWith('at RequestHandler.runInAsyncScope'))
        t.ok(stackLines[5].startsWith('at RequestHandler.onResponseStart'))
        t.ok(stackLines[6].startsWith('at Request.onResponseStart'))
      })
      ac.abort()
    })
  })

  await t.completed
})

test('request dump with POJO as invalid signal', async (t) => {
  t = tspl(t, { plan: 9 })
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.write('hello')
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    client.request({
      path: '/',
      method: 'GET'
    }, (err, { body }) => {
      t.ifError(err)
      body.dump({ signal: {} }).catch((err) => {
        t.strictEqual(err.name, 'InvalidArgumentError')
        t.strictEqual(err.message, 'signal must be an AbortSignal')
        const stackLines = err.stack.split('\n').map((l) => l.trim())

        t.ok(stackLines[0].startsWith('InvalidArgumentError: signal must be an AbortSignal'))
        t.ok(stackLines[1].startsWith('at BodyReadable.dump'))
        t.ok(/client-request.js/.test(stackLines[2]))
        t.ok(stackLines[3].startsWith('at RequestHandler.runInAsyncScope'))
        t.ok(stackLines[4].startsWith('at RequestHandler.onResponseStart'))
        t.ok(stackLines[5].startsWith('at Request.onResponseStart'))
      })
    })
  })

  await t.completed
})

test('request dump with aborted signal', async (t) => {
  t = tspl(t, { plan: 8 })
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.write('hello')
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    client.request({
      path: '/',
      method: 'GET'
    }, (err, { body }) => {
      t.ifError(err)
      const ac = new AbortController()
      ac.abort(new AbortError('This operation was with purpose aborted'))

      body.dump({ signal: ac.signal }).catch((err) => {
        t.strictEqual(err.name, 'AbortError')
        t.strictEqual(err.message, 'This operation was with purpose aborted')
        const stackLines = err.stack.split('\n').map((l) => l.trim())

        t.ok(stackLines[0].startsWith('AbortError: This operation was with purpose aborted'))
        t.ok(/client-request.js/.test(stackLines[1]))
        t.ok(stackLines[2].startsWith('at RequestHandler.runInAsyncScope'))
        t.ok(stackLines[3].startsWith('at RequestHandler.onResponseStart'))
        t.ok(stackLines[4].startsWith('at Request.onResponseStart'))
      })
      ac.abort()
    })
  })

  await t.completed
})

test('request hwm', async (t) => {
  t = tspl(t, { plan: 2 })
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.write('hello')
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    client.request({
      path: '/',
      method: 'GET',
      highWaterMark: 1000
    }, (err, { body }) => {
      t.ifError(err)
      t.deepStrictEqual(body.readableHighWaterMark, 1000)
      body.dump()
    })
  })

  await t.completed
})

test('request abort before headers', async (t) => {
  t = tspl(t, { plan: 6 })

  const signal = new EE()
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end('hello')
    signal.emit('abort')
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    client[kConnect](() => {
      client.request({
        path: '/',
        method: 'GET',
        signal
      }, (err) => {
        t.ok(err instanceof errors.RequestAbortedError)
        t.strictEqual(signal.listenerCount('abort'), 0)
      })
      t.strictEqual(signal.listenerCount('abort'), 1)

      client.request({
        path: '/',
        method: 'GET',
        signal
      }, (err) => {
        t.ok(err instanceof errors.RequestAbortedError)
        t.strictEqual(signal.listenerCount('abort'), 0)
      })
      t.strictEqual(signal.listenerCount('abort'), 2)
    })
  })

  await t.completed
})

test('request body destroyed on invalid callback', async (t) => {
  t = tspl(t, { plan: 1 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const body = new Readable({
      read () { }
    })
    try {
      client.request({
        path: '/',
        method: 'GET',
        body
      }, null)
    } catch (err) {
      t.strictEqual(body.destroyed, true)
    }
  })

  await t.completed
})

test('trailers', async (t) => {
  t = tspl(t, { plan: 1 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.writeHead(200, { Trailer: 'Content-MD5' })
    res.addTrailers({ 'Content-MD5': 'test' })
    res.end()
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.close())

    const { body, trailers } = await client.request({
      path: '/',
      method: 'GET'
    })

    body
      .on('data', () => t.fail())
      .on('end', () => {
        t.deepStrictEqual(trailers, { 'content-md5': 'test' })
      })
  })

  await t.completed
})

test('destroy socket abruptly', async (t) => {
  t = tspl(t, { plan: 2 })

  const server = net.createServer({ joinDuplicateHeaders: true }, (socket) => {
    const lines = [
      'HTTP/1.1 200 OK',
      'Date: Sat, 09 Oct 2010 14:28:02 GMT',
      'Connection: close',
      '',
      'the body'
    ]
    socket.end(lines.join('\r\n'))

    // Unfortunately calling destroy synchronously might get us flaky results,
    // therefore we delay it to the next event loop run.
    setImmediate(socket.destroy.bind(socket))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  await promisify(server.listen.bind(server))(0)
  const client = new Client(`http://localhost:${server.address().port}`)
  after(() => client.close())

  const { statusCode, body } = await client.request({
    path: '/',
    method: 'GET'
  })

  t.strictEqual(statusCode, 200)

  body.setEncoding('utf8')

  let actual = ''

  for await (const chunk of body) {
    actual += chunk
  }

  t.strictEqual(actual, 'the body')
})

test('destroy socket abruptly with keep-alive', async (t) => {
  t = tspl(t, { plan: 2 })

  const server = net.createServer({ joinDuplicateHeaders: true }, (socket) => {
    const lines = [
      'HTTP/1.1 200 OK',
      'Date: Sat, 09 Oct 2010 14:28:02 GMT',
      'Connection: keep-alive',
      'Content-Length: 42',
      '',
      'the body'
    ]
    socket.end(lines.join('\r\n'))

    // Unfortunately calling destroy synchronously might get us flaky results,
    // therefore we delay it to the next event loop run.
    setImmediate(socket.destroy.bind(socket))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  await promisify(server.listen.bind(server))(0)
  const client = new Client(`http://localhost:${server.address().port}`)
  after(() => client.close())

  const { statusCode, body } = await client.request({
    path: '/',
    method: 'GET'
  })

  t.strictEqual(statusCode, 200)

  body.setEncoding('utf8')

  try {
    /* eslint-disable */
    for await (const _ of body) {
      // empty on purpose
    }
    /* eslint-enable */
    t.fail('no error')
  } catch (err) {
    t.ok(true, 'error happened')
  }
})

test('request json', async (t) => {
  t = tspl(t, { plan: 1 })

  const obj = { asd: true }
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end(JSON.stringify(obj))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'GET'
    })
    t.deepStrictEqual(obj, await body.json())
  })

  await t.completed
})

test('request long multibyte json', async (t) => {
  t = tspl(t, { plan: 1 })

  const obj = { asd: 'あ'.repeat(100000) }
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end(JSON.stringify(obj))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'GET'
    })
    t.deepStrictEqual(obj, await body.json())
  })

  await t.completed
})

test('request text', async (t) => {
  t = tspl(t, { plan: 1 })

  const obj = { asd: true }
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end(JSON.stringify(obj))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'GET'
    })
    t.strictEqual(JSON.stringify(obj), await body.text())
  })

  await t.completed
})

describe('headers', () => {
  describe('invalid headers', () => {
    test('invalid header value - array with string with invalid character', async (t) => {
      t = tspl(t, { plan: 1 })

      const client = new Client('http://localhost:8080')
      after(() => client.destroy())

      t.rejects(client.request({
        path: '/',
        method: 'GET',
        headers: { name: ['test\0'] }
      }), new InvalidArgumentError('invalid name header'))

      await t.completed
    })
    test('invalid header value - array with POJO', async (t) => {
      t = tspl(t, { plan: 1 })

      const client = new Client('http://localhost:8080')
      after(() => client.destroy())

      t.rejects(client.request({
        path: '/',
        method: 'GET',
        headers: { name: [{}] }
      }), new InvalidArgumentError('invalid name header'))

      await t.completed
    })

    test('invalid header value - string with invalid character', async (t) => {
      t = tspl(t, { plan: 1 })

      const client = new Client('http://localhost:8080')
      after(() => client.destroy())

      t.rejects(client.request({
        path: '/',
        method: 'GET',
        headers: { name: 'test\0' }
      }), new InvalidArgumentError('invalid name header'))

      await t.completed
    })

    test('invalid header value - object', async (t) => {
      t = tspl(t, { plan: 1 })

      const client = new Client('http://localhost:8080')
      after(() => client.destroy())

      t.rejects(client.request({
        path: '/',
        method: 'GET',
        headers: { name: new Date() }
      }), new InvalidArgumentError('invalid name header'))

      await t.completed
    })
  })

  describe('array', () => {
    let serverAddress
    const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
      res.end(JSON.stringify(req.headers))
    })

    before(async () => {
      server.listen(0)
      await EE.once(server, 'listening')
      serverAddress = `localhost:${server.address().port}`
    })

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

    test('empty host header', async (t) => {
      t = tspl(t, { plan: 4 })

      const client = new Client(`http://${serverAddress}`)
      after(() => client.destroy())

      const testCase = async (expected, actual) => {
        const { body } = await client.request({
          path: '/',
          method: 'GET',
          headers: expected
        })

        const result = await body.json()
        t.deepStrictEqual(result, { ...result, ...actual })
      }

      await testCase({ key: [null] }, { key: '' })
      await testCase({ key: ['test'] }, { key: 'test' })
      await testCase({ key: ['test', 'true'] }, { key: 'test, true' })
      await testCase({ key: ['test', true] }, { key: 'test, true' })

      await t.completed
    })
  })

  describe('host', () => {
    let serverAddress
    const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
      res.end(req.headers.host)
    })

    before(async () => {
      server.listen(0)
      await EE.once(server, 'listening')
      serverAddress = `localhost:${server.address().port}`
    })

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

    test('invalid host header', async (t) => {
      t = tspl(t, { plan: 1 })

      const client = new Client(`http://${serverAddress}`)
      after(() => client.destroy())

      t.rejects(client.request({
        path: '/',
        method: 'GET',
        headers: {
          host: [
            'www.example.com'
          ]
        }
      }), new InvalidArgumentError('invalid host header'))

      await t.completed
    })

    test('empty host header', async (t) => {
      t = tspl(t, { plan: 3 })

      const client = new Client(`http://${serverAddress}`)
      after(() => client.destroy())

      const getWithHost = async (host, wanted) => {
        const { body } = await client.request({
          path: '/',
          method: 'GET',
          headers: { host }
        })
        t.strictEqual(await body.text(), wanted)
      }

      await getWithHost('test', 'test')
      await getWithHost(undefined, serverAddress)
      await getWithHost('', '')

      await t.completed
    })
  })
})

test('request long multibyte text', async (t) => {
  t = tspl(t, { plan: 1 })

  const obj = { asd: 'あ'.repeat(100000) }
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end(JSON.stringify(obj))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'GET'
    })
    t.strictEqual(JSON.stringify(obj), await body.text())
  })

  await t.completed
})

test('request blob', async (t) => {
  t = tspl(t, { plan: 2 })

  const obj = { asd: true }
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.setHeader('Content-Type', 'application/json')
    res.end(JSON.stringify(obj))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'GET'
    })

    const blob = await body.blob()
    t.deepStrictEqual(obj, JSON.parse(await blob.text()))
    t.strictEqual(blob.type, 'application/json')
  })

  await t.completed
})

test('request arrayBuffer', async (t) => {
  t = tspl(t, { plan: 2 })

  const obj = { asd: true }
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end(JSON.stringify(obj))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'GET'
    })
    const ab = await body.arrayBuffer()

    t.deepStrictEqual(Buffer.from(JSON.stringify(obj)), Buffer.from(ab))
    t.ok(ab instanceof ArrayBuffer)
  })

  await t.completed
})

test('request bytes', async (t) => {
  t = tspl(t, { plan: 2 })

  const obj = { asd: true }
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end(JSON.stringify(obj))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'GET'
    })
    const bytes = await body.bytes()

    t.deepStrictEqual(new TextEncoder().encode(JSON.stringify(obj)), bytes)
    t.ok(bytes instanceof Uint8Array)
  })

  await t.completed
})

test('request body', async (t) => {
  t = tspl(t, { plan: 1 })

  const obj = { asd: true }
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end(JSON.stringify(obj))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'GET'
    })

    let x = ''
    for await (const chunk of body.body) {
      x += Buffer.from(chunk)
    }
    t.strictEqual(JSON.stringify(obj), x)
  })

  await t.completed
})

test('request post body no missing data', async (t) => {
  t = tspl(t, { plan: 2 })

  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
    let ret = ''
    for await (const chunk of req) {
      ret += chunk
    }
    t.strictEqual(ret, 'asd')
    res.end()
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'GET',
      body: new Readable({
        read () {
          this.push('asd')
          this.push(null)
        }
      })
    })
    await body.text()
    t.ok(true, 'pass')
  })

  await t.completed
})

test('request post body no extra data handler', async (t) => {
  t = tspl(t, { plan: 3 })

  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
    let ret = ''
    for await (const chunk of req) {
      ret += chunk
    }
    t.strictEqual(ret, 'asd')
    res.end()
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const reqBody = new Readable({
      read () {
        this.push('asd')
        this.push(null)
      }
    })
    process.nextTick(() => {
      t.strictEqual(reqBody.listenerCount('data'), 0)
    })
    const { body } = await client.request({
      path: '/',
      method: 'GET',
      body: reqBody
    })
    await body.text()
    t.ok(true, 'pass')
  })

  await t.completed
})

test('request with onInfo callback', async (t) => {
  t = tspl(t, { plan: 3 })
  const infos = []
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.writeProcessing()
    res.setHeader('Content-Type', 'application/json')
    res.end(JSON.stringify({ foo: 'bar' }))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    await client.request({
      path: '/',
      method: 'GET',
      onInfo: (x) => { infos.push(x) }
    })
    t.strictEqual(infos.length, 1)
    t.strictEqual(infos[0].statusCode, 102)
    t.ok(true, 'pass')
  })

  await t.completed
})

test('request with onInfo callback but socket is destroyed before end of response', async (t) => {
  t = tspl(t, { plan: 5 })
  const infos = []
  let response
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    response = res
    res.writeProcessing()
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())
    try {
      await client.request({
        path: '/',
        method: 'GET',
        onInfo: (x) => {
          infos.push(x)
          response.destroy()
        }
      })
      t.fail()
    } catch (e) {
      t.ok(e)
      t.strictEqual(e.message, 'other side closed')
    }
    t.strictEqual(infos.length, 1)
    t.strictEqual(infos[0].statusCode, 102)
    t.ok(true, 'pass')
  })

  await t.completed
})

test('request onInfo callback headers parsing', async (t) => {
  t = tspl(t, { plan: 4 })
  const infos = []

  const server = net.createServer({ joinDuplicateHeaders: true }, (socket) => {
    const lines = [
      'HTTP/1.1 103 Early Hints',
      'Link: </style.css>; rel=preload; as=style',
      '',
      'HTTP/1.1 200 OK',
      'Date: Sat, 09 Oct 2010 14:28:02 GMT',
      'Connection: close',
      '',
      'the body'
    ]
    socket.end(lines.join('\r\n'))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  await promisify(server.listen.bind(server))(0)

  const client = new Client(`http://localhost:${server.address().port}`)
  after(() => client.close())

  const { body } = await client.request({
    path: '/',
    method: 'GET',
    onInfo: (x) => { infos.push(x) }
  })
  await body.dump()
  t.strictEqual(infos.length, 1)
  t.strictEqual(infos[0].statusCode, 103)
  t.deepStrictEqual(infos[0].headers, { link: '</style.css>; rel=preload; as=style' })
  t.ok(true, 'pass')
})

test('request raw responseHeaders', async (t) => {
  t = tspl(t, { plan: 4 })
  const infos = []

  const server = net.createServer({ joinDuplicateHeaders: true }, (socket) => {
    const lines = [
      'HTTP/1.1 103 Early Hints',
      'Link: </style.css>; rel=preload; as=style',
      '',
      'HTTP/1.1 200 OK',
      'Date: Sat, 09 Oct 2010 14:28:02 GMT',
      'Connection: close',
      '',
      'the body'
    ]
    socket.end(lines.join('\r\n'))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  await promisify(server.listen.bind(server))(0)

  const client = new Client(`http://localhost:${server.address().port}`)
  after(() => client.close())

  const { body, headers } = await client.request({
    path: '/',
    method: 'GET',
    responseHeaders: 'raw',
    onInfo: (x) => { infos.push(x) }
  })
  await body.dump()
  t.strictEqual(infos.length, 1)
  t.deepStrictEqual(infos[0].headers, ['Link', '</style.css>; rel=preload; as=style'])
  t.deepStrictEqual(headers, ['Date', 'Sat, 09 Oct 2010 14:28:02 GMT', 'Connection', 'close'])
  t.ok(true, 'pass')
})

test('request formData', async (t) => {
  t = tspl(t, { plan: 1 })

  const obj = { asd: true }
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end(JSON.stringify(obj))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'GET'
    })

    try {
      await body.formData()
      t.fail('should throw NotSupportedError')
    } catch (error) {
      t.ok(error instanceof NotSupportedError)
    }
  })

  await t.completed
})

test('request text2', async (t) => {
  t = tspl(t, { plan: 2 })

  const obj = { asd: true }
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end(JSON.stringify(obj))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'GET'
    })
    const p = body.text()
    let ret = ''
    body.on('data', chunk => {
      ret += chunk
    }).on('end', () => {
      t.strictEqual(JSON.stringify(obj), ret)
    })
    t.strictEqual(JSON.stringify(obj), await p)
  })

  await t.completed
})

test('request with FormData body', async (t) => {
  const { FormData } = require('../')

  const fd = new FormData()
  fd.set('key', 'value')
  fd.set('file', new Blob(['Hello, world!']), 'hello_world.txt')

  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
    const contentType = req.headers['content-type']
    // ensure we received a multipart/form-data header
    t.ok(/^multipart\/form-data; boundary=-+formdata-undici-0\d+$/.test(contentType))

    const chunks = []

    for await (const chunk of req) {
      chunks.push(chunk)
    }

    const { fileMap, fields } = await parseFormDataString(
      Buffer.concat(chunks),
      contentType
    )

    t.deepStrictEqual(fields[0], { key: 'key', value: 'value' })
    t.ok(fileMap.has('file'))
    t.strictEqual(fileMap.get('file').data.toString(), 'Hello, world!')
    t.deepStrictEqual(fileMap.get('file').info, {
      filename: 'hello_world.txt',
      encoding: '7bit',
      mimeType: 'application/octet-stream'
    })

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

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    await client.request({
      path: '/',
      method: 'POST',
      body: fd
    })

    t.end()
  })

  await t.completed
})

// https://github.com/nodejs/undici/issues/5520
test('request rejects FormData bodies from another implementation', async (t) => {
  class FormDataLike {
    append () {}
    delete () {}
    get () {}
    getAll () {}
    has () {}
    set () {}
    get [Symbol.toStringTag] () { return 'FormData' }
  }

  let connections = 0
  const client = new Client('http://localhost', {
    connect (_, callback) {
      connections++
      callback(new Error('unexpected connection'))
    }
  })
  t.after(() => client.destroy())

  const expectedError = new InvalidArgumentError('body must be a FormData created by this undici package instance; FormData from other sources, such as the built-in FormData provided by Node.js, is not supported')
  const bodies = [new globalThis.FormData(), new FormDataLike()]

  for (const body of bodies) {
    await t.assert.rejects(client.request({
      path: '/',
      method: 'POST',
      body
    }), expectedError)
  }

  t.assert.strictEqual(connections, 0)
})

test('request post body Buffer from string', async (t) => {
  t = tspl(t, { plan: 2 })
  const requestBody = Buffer.from('abcdefghijklmnopqrstuvwxyz')

  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
    let ret = ''
    for await (const chunk of req) {
      ret += chunk
    }
    t.strictEqual(ret, 'abcdefghijklmnopqrstuvwxyz')
    res.end()
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'POST',
      body: requestBody
    })
    await body.text()
    t.ok(true, 'pass')
  })

  await t.completed
})

test('request post body Buffer from buffer', async (t) => {
  t = tspl(t, { plan: 2 })
  const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz')
  const requestBody = Buffer.from(fullBuffer.buffer, 8, 16)

  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
    let ret = ''
    for await (const chunk of req) {
      ret += chunk
    }
    t.strictEqual(ret, 'ijklmnopqrstuvwx')
    res.end()
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'POST',
      body: requestBody
    })
    await body.text()
    t.ok(true, 'pass')
  })

  await t.completed
})

test('request post body Uint8Array', async (t) => {
  t = tspl(t, { plan: 2 })
  const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz')
  const requestBody = new Uint8Array(fullBuffer.buffer, 8, 16)

  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
    let ret = ''
    for await (const chunk of req) {
      ret += chunk
    }
    t.strictEqual(ret, 'ijklmnopqrstuvwx')
    res.end()
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'POST',
      body: requestBody
    })
    await body.text()
    t.ok(true, 'pass')
  })

  await t.completed
})

test('request post body Uint32Array', async (t) => {
  t = tspl(t, { plan: 2 })
  const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz')
  const requestBody = new Uint32Array(fullBuffer.buffer, 8, 4)

  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
    let ret = ''
    for await (const chunk of req) {
      ret += chunk
    }
    t.strictEqual(ret, 'ijklmnopqrstuvwx')
    res.end()
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'POST',
      body: requestBody
    })
    await body.text()
    t.ok(true, 'pass')
  })

  await t.completed
})

test('request post body Float64Array', async (t) => {
  t = tspl(t, { plan: 2 })
  const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz')
  const requestBody = new Float64Array(fullBuffer.buffer, 8, 2)

  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
    let ret = ''
    for await (const chunk of req) {
      ret += chunk
    }
    t.strictEqual(ret, 'ijklmnopqrstuvwx')
    res.end()
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'POST',
      body: requestBody
    })
    await body.text()
    t.ok(true, 'pass')
  })

  await t.completed
})

test('request post body BigUint64Array', async (t) => {
  t = tspl(t, { plan: 2 })
  const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz')
  const requestBody = new BigUint64Array(fullBuffer.buffer, 8, 2)

  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
    let ret = ''
    for await (const chunk of req) {
      ret += chunk
    }
    t.strictEqual(ret, 'ijklmnopqrstuvwx')
    res.end()
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'POST',
      body: requestBody
    })
    await body.text()
    t.ok(true, 'pass')
  })

  await t.completed
})

test('request post body DataView', async (t) => {
  t = tspl(t, { plan: 2 })
  const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz')
  const requestBody = new DataView(fullBuffer.buffer, 8, 16)

  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
    let ret = ''
    for await (const chunk of req) {
      ret += chunk
    }
    t.strictEqual(ret, 'ijklmnopqrstuvwx')
    res.end()
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const { body } = await client.request({
      path: '/',
      method: 'POST',
      body: requestBody
    })
    await body.text()
    t.ok(true, 'pass')
  })

  await t.completed
})

test('request multibyte json with setEncoding', async (t) => {
  t = tspl(t, { plan: 1 })

  const asd = Buffer.from('あいうえお')
  const data = JSON.stringify({ asd })
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.write(data.slice(0, 1))
    setTimeout(() => {
      res.write(data.slice(1))
      res.end()
    }, 100)
  })
  after(server.close.bind(server))

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(client.destroy.bind(client))

    const { body } = await client.request({
      path: '/',
      method: 'GET'
    })
    body.setEncoding('utf8')
    t.deepStrictEqual(JSON.parse(data), await body.json())
  })

  await t.completed
})

test('request multibyte text with setEncoding', async (t) => {
  t = tspl(t, { plan: 1 })

  const data = Buffer.from('あいうえお')
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.write(data.slice(0, 1))
    setTimeout(() => {
      res.write(data.slice(1))
      res.end()
    }, 100)
  })
  after(server.close.bind(server))

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(client.destroy.bind(client))

    const { body } = await client.request({
      path: '/',
      method: 'GET'
    })
    body.setEncoding('utf8')
    t.deepStrictEqual(data.toString('utf8'), await body.text())
  })

  await t.completed
})

test('request multibyte text with setEncoding', async (t) => {
  t = tspl(t, { plan: 1 })

  const data = Buffer.from('あいうえお')
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.write(data.slice(0, 1))
    setTimeout(() => {
      res.write(data.slice(1))
      res.end()
    }, 100)
  })
  after(server.close.bind(server))

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(client.destroy.bind(client))

    const { body } = await client.request({
      path: '/',
      method: 'GET'
    })
    body.setEncoding('hex')
    t.deepStrictEqual(data.toString('hex'), await body.text())
  })

  await t.completed
})

test('setEncoding(\'utf8\') handles 3-byte UTF-8 characters split across chunks', async (t) => {
  t = tspl(t, { plan: 2 })

  // CJK character '傳' is 3 bytes: 0xe5, 0x82, 0xb3
  // Build a payload where this character will be split at the chunk boundary
  const cjkChar = '傳' // U+50B3, bytes: e5 82 b3
  const prefix = 'a'.repeat(10) // 10 ASCII bytes
  const text = prefix + cjkChar + 'end'
  const buf = Buffer.from(text) // 10 + 3 + 3 = 16 bytes

  // Split at byte 11, which is in the middle of the 3-byte CJK character
  // prefix (10 bytes) + first byte of '傳' (0xe5) | remaining 2 bytes (0x82 0xb3) + 'end'
  const chunk1 = buf.subarray(0, 11)
  const chunk2 = buf.subarray(11)

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    // Send raw buffers to ensure the split is exactly where we want it
    res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' })
    res.write(chunk1)
    // Use setTimeout to force separate TCP packets / chunks
    setTimeout(() => {
      res.end(chunk2)
    }, 50)
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(client.destroy.bind(client))

    const { body } = await client.request({
      path: '/',
      method: 'GET'
    })
    body.setEncoding('utf8')

    let result = ''
    for await (const chunk of body) {
      result += chunk
    }

    // Must not contain U+FFFD replacement characters
    t.strictEqual(result.includes('\ufffd'), false, 'should not contain U+FFFD replacement characters')
    t.strictEqual(result, text, 'decoded text should match original')
  })

  await t.completed
})

test('#5611 - setEncoding() then .text() does not truncate a body that arrives in several chunks', async (t) => {
  t = tspl(t, { plan: 2 })

  // '傳' is 3 bytes in UTF-8, split across the two chunks below.
  const text = 'abc傳def'
  const buf = Buffer.from(text)

  // The rest of the body is released only once the client has called
  // setEncoding(), which is what puts the second chunk on the wrong side of it.
  const sendRest = new EE()

  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
    res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' })
    res.write(buf.subarray(0, 4))
    await EE.once(sendRest, 'go')
    res.end(buf.subarray(4))
  })
  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  server.listen(0, async () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(client.destroy.bind(client))

    const { body } = await client.request({ path: '/', method: 'GET' })
    body.setEncoding('utf8')

    // 'drain' means the request has run to completion, so the whole body has
    // arrived and is buffered as decoded strings, with the split character
    // held inside the decoder. Waiting on the body itself is not an option:
    // reading it would disturb it and make the consume below unusable.
    const completed = EE.once(client, 'drain')
    sendRest.emit('go')
    await completed

    const result = await body.text()

    t.strictEqual(result, text)
    t.strictEqual(Buffer.byteLength(result), buf.length)
  })

  await t.completed
})

test('#3736 - Aborted Response (without consuming body)', async (t) => {
  const plan = tspl(t, { plan: 1 })

  const controller = new AbortController()
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    setTimeout(() => {
      res.writeHead(200, 'ok', {
        'content-type': 'text/plain'
      })
      res.write('hello from server')
      res.end()
    }, 100)
  })

  server.listen(0)

  await EE.once(server, 'listening')
  const client = new Client(`http://localhost:${server.address().port}`)

  after(server.close.bind(server))
  after(client.destroy.bind(client))

  const { signal } = controller
  const promise = client.request({
    path: '/',
    method: 'GET',
    signal
  })

  controller.abort()

  await plan.rejects(promise, { message: 'This operation was aborted' })

  await plan.completed
})