@ 261107.5
current
'use strict'

const { tspl } = require('@matteo.collina/tspl')
const assert = require('node:assert')
const { test, after } = require('node:test')
const { Client, errors } = require('..')
const { createServer } = require('node:http')
const net = require('node:net')
const { once } = require('node:events')
const { Readable } = require('node:stream')
const { maybeWrapStream, consts } = require('./utils/async-iterators')

test('request invalid content-length', async (t) => {
  t = tspl(t, { plan: 7 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end()
  })
  after(() => server.close())
  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.close())

    client.request({
      path: '/',
      method: 'PUT',
      headers: {
        'content-length': 10
      },
      body: 'asd'
    }, (err, data) => {
      t.ok(err instanceof errors.RequestContentLengthMismatchError)
    })

    client.request({
      path: '/',
      method: 'PUT',
      headers: {
        'content-length': 10
      },
      body: 'asdasdasdasdasdasda'
    }, (err, data) => {
      t.ok(err instanceof errors.RequestContentLengthMismatchError)
    })

    client.request({
      path: '/',
      method: 'PUT',
      headers: {
        'content-length': 10
      },
      body: Buffer.alloc(9)
    }, (err, data) => {
      t.ok(err instanceof errors.RequestContentLengthMismatchError)
    })

    client.request({
      path: '/',
      method: 'PUT',
      headers: {
        'content-length': 10
      },
      body: Buffer.alloc(11)
    }, (err, data) => {
      t.ok(err instanceof errors.RequestContentLengthMismatchError)
    })

    client.request({
      path: '/',
      method: 'GET',
      headers: {
        'content-length': 4
      },
      body: ['asd']
    }, (err, data) => {
      t.ok(err instanceof errors.RequestContentLengthMismatchError)
    })

    client.request({
      path: '/',
      method: 'GET',
      headers: {
        'content-length': 4
      },
      body: ['asasdasdasdd']
    }, (err, data) => {
      t.ok(err instanceof errors.RequestContentLengthMismatchError)
    })

    client.request({
      path: '/',
      method: 'DELETE',
      headers: {
        'content-length': 4
      },
      body: ['asasdasdasdd']
    }, (err, data) => {
      t.ok(err instanceof errors.RequestContentLengthMismatchError)
    })
  })

  await t.completed
})

function invalidContentLength (bodyType) {
  test(`request streaming ${bodyType} invalid content-length`, async (t) => {
    t = tspl(t, { plan: 4 })

    const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
      res.end()
    })
    after(() => server.close())
    server.listen(0, () => {
      const client = new Client(`http://localhost:${server.address().port}`)
      after(() => client.close())

      client.once('disconnect', () => {
        t.ok(true, 'pass')
        client.once('disconnect', () => {
          t.ok(true, 'pass')
        })
      })

      client.request({
        path: '/',
        method: 'PUT',
        headers: {
          'content-length': 10
        },
        body: maybeWrapStream(new Readable({
          read () {
            setImmediate(() => {
              this.push('asdasdasdkajsdnasdkjasnd')
              this.push(null)
            })
          }
        }), bodyType)
      }, (err, data) => {
        t.ok(err instanceof errors.RequestContentLengthMismatchError)
      })

      client.request({
        path: '/',
        method: 'PUT',
        headers: {
          'content-length': 10
        },
        body: maybeWrapStream(new Readable({
          read () {
            setImmediate(() => {
              this.push('asd')
              this.push(null)
            })
          }
        }), bodyType)
      }, (err, data) => {
        t.ok(err instanceof errors.RequestContentLengthMismatchError)
      })
    })
    await t.completed
  })
}

invalidContentLength(consts.STREAM)
invalidContentLength(consts.ASYNC_ITERATOR)

function zeroContentLength (bodyType) {
  test(`request ${bodyType} streaming data when content-length=0`, async (t) => {
    t = tspl(t, { plan: 1 })

    const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
      res.end()
    })
    after(() => server.close())
    server.listen(0, () => {
      const client = new Client(`http://localhost:${server.address().port}`)
      after(() => client.close())

      client.request({
        path: '/',
        method: 'PUT',
        headers: {
          'content-length': 0
        },
        body: maybeWrapStream(new Readable({
          read () {
            setImmediate(() => {
              this.push('asdasdasdkajsdnasdkjasnd')
              this.push(null)
            })
          }
        }), bodyType)
      }, (err, data) => {
        t.ok(err instanceof errors.RequestContentLengthMismatchError)
      })
    })
    await t.completed
  })
}

zeroContentLength(consts.STREAM)
zeroContentLength(consts.ASYNC_ITERATOR)

test('request streaming no body data when content-length=0', async (t) => {
  t = tspl(t, { plan: 2 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    req.pipe(res)
  })
  after(() => server.close())
  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.close())

    client.on('disconnect', () => {
      if (!client.closed && !client.destroyed) {
        t.fail('unexpected disconnect')
      }
    })

    client.request({
      path: '/',
      method: 'PUT',
      headers: {
        'content-length': 0
      }
    }, (err, data) => {
      t.ifError(err)
      data.body
        .on('data', () => {
          t.fail()
        })
        .on('end', () => {
          t.ok(true, 'pass')
        })
    })
  })

  await t.completed
})

test('response invalid content length with close', async (t) => {
  t = tspl(t, { plan: 3 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.writeHead(200, {
      'content-length': 10
    })
    res.end('123')
  })
  after(() => server.close())
  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`, {
      pipelining: 0
    })
    after(() => client.close())

    client.on('disconnect', (origin, client, err) => {
      t.strictEqual(err.code, 'UND_ERR_RES_CONTENT_LENGTH_MISMATCH')
    })

    client.request({
      path: '/',
      method: 'GET'
    }, (err, data) => {
      t.ifError(err)
      data.body
        .on('end', () => {
          t.fail()
        })
        .on('error', (err) => {
          t.strictEqual(err.code, 'UND_ERR_RES_CONTENT_LENGTH_MISMATCH')
        })
        .resume()
    })
  })

  await t.completed
})

test('request streaming with Readable.from(buf)', async (t) => {
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    req.pipe(res)
  })
  after(() => server.close())
  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.close())

    client.on('disconnect', () => {
      if (!client.closed && !client.destroyed) {
        t.fail('unexpected disconnect')
      }
    })

    client.request({
      path: '/',
      method: 'PUT',
      body: Readable.from(Buffer.from('hello'))
    }, (err, data) => {
      const chunks = []
      t.ifError(err)
      data.body
        .on('data', (chunk) => {
          chunks.push(chunk)
        })
        .on('end', () => {
          t.strictEqual(Buffer.concat(chunks).toString(), 'hello')
          t.ok(true, 'pass')
          t.end()
        })
    })
  })

  await t.completed
})

test('request DELETE, content-length=0, with body', async (t) => {
  t = tspl(t, { plan: 5 })
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.shouldKeepAlive = false
    res.end()
  })
  server.on('request', (req, res) => {
    t.strictEqual(req.headers['content-length'], undefined)
  })
  after(() => server.close())
  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.close())

    client.request({
      path: '/',
      method: 'DELETE',
      headers: {
        'content-length': 0
      },
      body: new Readable({
        read () {
          this.push('asd')
          this.push(null)
        }
      })
    }, (err) => {
      t.ok(err instanceof errors.RequestContentLengthMismatchError)
    })

    client.request({
      path: '/',
      method: 'DELETE',
      headers: {
        'content-length': 0
      }
    }, (err, resp) => {
      t.strictEqual(resp.headers['content-length'], '0')
      t.ifError(err)
    })

    client.on('disconnect', () => {
      t.ok(true, 'pass')
    })
  })

  await t.completed
})

test('content-length shouldSendContentLength=false', async (t) => {
  t = tspl(t, { plan: 15 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end()
  })
  server.on('request', (req, res) => {
    switch (req.url) {
      case '/put0':
        t.strictEqual(req.headers['content-length'], '0')
        break
      case '/head':
        t.strictEqual(req.headers['content-length'], undefined)
        break
      case '/get':
        t.strictEqual(req.headers['content-length'], undefined)
        break
    }
  })
  after(() => server.close())
  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.close())

    client.request({
      path: '/put0',
      method: 'PUT',
      headers: {
        'content-length': 0
      }
    }, (err, resp) => {
      t.strictEqual(resp.headers['content-length'], '0')
      t.ifError(err)
    })

    client.request({
      path: '/head',
      method: 'HEAD',
      headers: {
        'content-length': 10
      }
    }, (err, resp) => {
      t.strictEqual(resp.headers['content-length'], undefined)
      t.ifError(err)
    })

    client.request({
      path: '/get',
      method: 'GET',
      headers: {
        'content-length': 0
      }
    }, (err) => {
      t.ifError(err)
    })

    client.request({
      path: '/',
      method: 'GET',
      headers: {
        'content-length': 4
      },
      body: new Readable({
        read () {
          this.push('asd')
          this.push(null)
        }
      })
    }, (err) => {
      t.ifError(err)
    })

    client.request({
      path: '/',
      method: 'GET',
      headers: {
        'content-length': 4
      },
      body: new Readable({
        read () {
          this.push('asasdasdasdd')
          this.push(null)
        }
      })
    }, (err) => {
      t.ifError(err)
    })

    client.request({
      path: '/',
      method: 'HEAD',
      headers: {
        'content-length': 4
      },
      body: new Readable({
        read () {
          this.push('asasdasdasdd')
          this.push(null)
        }
      })
    }, (err) => {
      t.ifError(err)
    })

    client.on('disconnect', () => {
      t.ok(true, 'pass')
    })
  })

  await t.completed
})

function createRawServer (t, onData) {
  const sockets = new Set()
  const server = net.createServer((socket) => {
    sockets.add(socket)
    socket.on('close', () => sockets.delete(socket))
    onData(socket)
  })
  t.after(() => {
    for (const socket of sockets) socket.destroy()
    server.close()
  })
  return server
}

function dispatchPaused (client) {
  return new Promise((resolve) => {
    const result = { statusCode: null, body: false, error: null }
    client.dispatch({ path: '/', method: 'GET' }, {
      onRequestStart () {},
      onResponseStart (controller, statusCode) {
        result.statusCode = statusCode
        controller.pause()
        setTimeout(() => controller.resume(), 50)
      },
      onResponseData () {
        result.body = true
      },
      onResponseEnd () {
        resolve(result)
      },
      onResponseError (controller, err) {
        result.error = err
        resolve(result)
      }
    })
  })
}

for (const statusCode of [204, 304]) {
  test(`response ${statusCode} with content-length is not a mismatch but closes the connection`, async (t) => {
    let connections = 0
    const server = createRawServer(t, (socket) => {
      connections++
      socket.on('data', () => {
        // A Content-Length on a 204/304 describes the representation,
        // not bytes on the wire (RFC 9110 §8.6).
        socket.write(`HTTP/1.1 ${statusCode} X\r\ncontent-length: 100\r\n\r\n`)
      })
    })
    server.listen(0)
    await once(server, 'listening')

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

    const disconnects = []
    client.on('disconnect', (origin, targets, err) => { disconnects.push(err.code) })

    for (let i = 0; i < 2; i++) {
      const { statusCode: status, headers, body } = await client.request({ path: '/', method: 'GET' })
      assert.strictEqual(status, statusCode)
      assert.strictEqual(headers['content-length'], '100')
      assert.strictEqual(await body.text(), '')
    }

    // The server may have sent content that would be parsed as the next
    // response, so the connection must not be reused.
    assert.strictEqual(connections, 2)
    assert.ok(disconnects.length >= 1)
    assert.ok(disconnects.every((code) => code === 'UND_ERR_INFO'))
  })

  test(`response ${statusCode} with content-length: 0 keeps the connection`, async (t) => {
    let connections = 0
    const server = createRawServer(t, (socket) => {
      connections++
      socket.on('data', () => {
        socket.write(`HTTP/1.1 ${statusCode} X\r\ncontent-length: 0\r\n\r\n`)
      })
    })
    server.listen(0)
    await once(server, 'listening')

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

    let disconnects = 0
    client.on('disconnect', () => { disconnects++ })

    for (let i = 0; i < 2; i++) {
      const { statusCode: status, body } = await client.request({ path: '/', method: 'GET' })
      assert.strictEqual(status, statusCode)
      assert.strictEqual(await body.text(), '')
    }

    assert.strictEqual(connections, 1)
    assert.strictEqual(disconnects, 0)
  })

  for (const framing of ['content-length', 'transfer-encoding']) {
    for (const blocking of [true, false]) {
      test(`response ${statusCode} with ${framing} does not let its content become a pipelined response (blocking: ${blocking})`, async (t) => {
        const forged = 'HTTP/1.1 200 OK\r\ncontent-length: 6\r\n\r\nFORGED'
        let connections = 0
        const server = createRawServer(t, (socket) => {
          const first = connections++ === 0
          let received = ''
          socket.on('data', (chunk) => {
            received += chunk
            if (first) {
              if (!blocking && received.split('\r\n\r\n').length - 1 < 2) return
              if (socket.replied) return
              socket.replied = true
              const framingValue = framing === 'content-length' ? forged.length : 'chunked'
              socket.write(`HTTP/1.1 ${statusCode} X\r\n${framing}: ${framingValue}\r\n\r\n${forged}`)
            } else {
              socket.write('HTTP/1.1 200 OK\r\ncontent-length: 4\r\n\r\nREAL')
            }
          })
        })
        server.listen(0)
        await once(server, 'listening')

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

        const [r1, r2] = await Promise.all([
          client.request({ path: '/1', method: 'GET', blocking }),
          client.request({ path: '/2', method: 'GET', blocking })
        ])

        assert.strictEqual(r1.statusCode, statusCode)
        assert.strictEqual(await r1.body.text(), '')
        // The second request is sent on a new connection.
        assert.strictEqual(await r2.body.text(), 'REAL')
        assert.strictEqual(connections, 2)
      })
    }
  }

  test(`response ${statusCode} with content-length completes when the connection closes while paused`, async (t) => {
    const server = createRawServer(t, (socket) => {
      socket.once('data', () => {
        socket.end(`HTTP/1.1 ${statusCode} X\r\ncontent-length: 100\r\nconnection: close\r\n\r\n`)
      })
    })
    server.listen(0)
    await once(server, 'listening')

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

    const result = await dispatchPaused(client)
    assert.strictEqual(result.statusCode, statusCode)
    assert.strictEqual(result.body, false)
    assert.ifError(result.error)
  })
}

test('response 200 with short body still errors when the connection closes while paused', async (t) => {
  const server = createRawServer(t, (socket) => {
    socket.once('data', () => {
      socket.end('HTTP/1.1 200 OK\r\ncontent-length: 100\r\nconnection: close\r\n\r\n')
    })
  })
  server.listen(0)
  await once(server, 'listening')

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

  const result = await dispatchPaused(client)
  assert.strictEqual(result.error?.code, 'UND_ERR_RES_CONTENT_LENGTH_MISMATCH')
})