OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / websocket-transport.test.js

222 lines · javascript ref:
import test from 'brittle'
import { WebSocketTransport } from 'p2p-hiverelay/transports/websocket/index.js'
import { WebSocketStream } from 'p2p-hiverelay/transports/websocket/stream.js'
import WebSocket from 'ws'

function makeTransport (opts = {}) {
  return new WebSocketTransport({ port: 0, host: '127.0.0.1', ...opts })
}

function url (transport) {
  return `ws://127.0.0.1:${transport.port}`
}

test('WebSocketTransport - starts and records assigned port', async (t) => {
  const transport = makeTransport()

  t.teardown(async () => { await transport.stop() })

  await transport.start()
  t.is(transport.running, true, 'transport is running')
  t.ok(transport.port > 0, 'assigned port recorded')
})

test('WebSocketTransport - client connects and emits privacy-safe connection event', async (t) => {
  t.plan(5)

  const transport = makeTransport()

  t.teardown(async () => {
    await transport.stop()
  })

  await transport.start()

  transport.on('connection', (stream, info) => {
    t.ok(stream, 'received stream on connection')
    t.is(info.type, 'websocket', 'info.type is websocket')
    t.is(typeof info.remoteAddressHash, 'string', 'remote address hash is present')
    t.absent(info.remoteAddress, 'raw remote address is not emitted')
    t.absent(JSON.stringify(info).includes('127.0.0.1'), 'serialized info does not include raw loopback IP')
  })

  const ws = new WebSocket(url(transport))
  await new Promise((resolve) => { ws.on('open', resolve) })
  ws.close()
})

test('WebSocketTransport - IP hash is stable per transport and rotated across transports', (t) => {
  const a = makeTransport()
  const b = makeTransport()

  const h1 = a._hashIp('192.0.2.42')
  const h2 = a._hashIp('192.0.2.42')
  const h3 = a._hashIp('192.0.2.43')

  t.is(typeof h1, 'string', 'hash is a string')
  t.is(h1.length, 16, 'hash is a short prefix')
  t.is(h1, h2, 'same IP is stable within a transport')
  t.unlike(h1, h3, 'different IPs produce different hashes')
  t.unlike(h1, b._hashIp('192.0.2.42'), 'salt rotates across transport instances')
  t.absent(a._safeInfo('192.0.2.42', 1234).remoteAddress, 'safe info omits raw address')
})

test('WebSocketTransport - bidirectional data flow', async (t) => {
  const transport = makeTransport()

  t.teardown(async () => { await transport.stop() })

  await transport.start()

  const serverReceived = []
  transport.on('connection', (stream) => {
    stream.on('data', (chunk) => {
      serverReceived.push(chunk)
      // Echo back
      stream.write(Buffer.from('pong'))
    })
  })

  const ws = new WebSocket(url(transport))
  await new Promise((resolve) => { ws.on('open', resolve) })

  const clientReceived = []
  ws.on('message', (data) => {
    clientReceived.push(Buffer.from(data))
  })

  ws.send(Buffer.from('ping'))

  await new Promise((resolve) => setTimeout(resolve, 200))

  t.is(serverReceived.length, 1, 'server received one message')
  t.ok(Buffer.from(serverReceived[0]).toString() === 'ping', 'server got ping')
  t.is(clientReceived.length, 1, 'client received one message')
  t.ok(clientReceived[0].toString() === 'pong', 'client got pong')

  ws.close()
})

test('WebSocketTransport - binary data passes through correctly', async (t) => {
  const transport = makeTransport()

  t.teardown(async () => { await transport.stop() })

  await transport.start()

  // Generate binary data with all byte values
  const binaryData = Buffer.alloc(256)
  for (let i = 0; i < 256; i++) binaryData[i] = i

  const serverReceived = []
  transport.on('connection', (stream) => {
    stream.on('data', (chunk) => {
      serverReceived.push(chunk)
    })
  })

  const ws = new WebSocket(url(transport))
  await new Promise((resolve) => { ws.on('open', resolve) })

  ws.send(binaryData)

  await new Promise((resolve) => setTimeout(resolve, 200))

  t.is(serverReceived.length, 1, 'received one chunk')
  t.is(Buffer.compare(Buffer.from(serverReceived[0]), binaryData), 0, 'binary data matches')

  ws.close()
})

test('WebSocketTransport - multiple concurrent connections', async (t) => {
  const transport = makeTransport()

  t.teardown(async () => { await transport.stop() })

  await transport.start()

  let connCount = 0
  transport.on('connection', () => { connCount++ })

  const clients = []
  for (let i = 0; i < 5; i++) {
    const ws = new WebSocket(url(transport))
    await new Promise((resolve) => { ws.on('open', resolve) })
    clients.push(ws)
  }

  await new Promise((resolve) => setTimeout(resolve, 100))

  t.is(connCount, 5, 'received 5 connections')
  t.is(transport.connections.size, 5, 'tracking 5 connections')

  for (const ws of clients) ws.close()

  await new Promise((resolve) => setTimeout(resolve, 200))

  t.is(transport.connections.size, 0, 'all connections cleaned up')
})

test('WebSocketTransport - stop closes all connections and server', async (t) => {
  const transport = makeTransport()

  await transport.start()

  const ws = new WebSocket(url(transport))
  await new Promise((resolve) => { ws.on('open', resolve) })

  t.is(transport.connections.size, 1, 'one connection')

  await transport.stop()

  t.is(transport.running, false, 'not running')
  t.is(transport.connections.size, 0, 'connections cleared')
  t.is(transport.server, null, 'server nulled')
})

test('WebSocketTransport - rejects connections at capacity', async (t) => {
  const transport = makeTransport({ maxConnections: 2 })

  t.teardown(async () => { await transport.stop() })

  await transport.start()

  const ws1 = new WebSocket(url(transport))
  await new Promise((resolve) => { ws1.on('open', resolve) })
  const ws2 = new WebSocket(url(transport))
  await new Promise((resolve) => { ws2.on('open', resolve) })

  t.is(transport.connections.size, 2, 'at capacity')

  // Third connection should be rejected
  const ws3 = new WebSocket(url(transport))
  const closeCode = await new Promise((resolve) => {
    ws3.on('close', (code) => resolve(code))
  })
  t.is(closeCode, 1013, 'rejected with 1013 (Try Again Later)')

  ws1.close()
  ws2.close()
})

test('WebSocketStream - wraps WebSocket into duplex stream', async (t) => {
  const transport = makeTransport()

  t.teardown(async () => { await transport.stop() })

  await transport.start()

  transport.on('connection', (stream) => {
    t.ok(stream instanceof WebSocketStream, 'stream is WebSocketStream instance')
    t.ok(typeof stream.write === 'function', 'has write method')
    t.ok(typeof stream.on === 'function', 'has on method')
    t.ok(typeof stream.destroy === 'function', 'has destroy method')
  })

  const ws = new WebSocket(url(transport))
  await new Promise((resolve) => { ws.on('open', resolve) })

  await new Promise((resolve) => setTimeout(resolve, 100))
  ws.close()
})