OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / poker-ws-adapter.test.js

246 lines · javascript ref:
import test from 'brittle'
import { once } from 'node:events'
import { createServer } from 'node:http'
import { WebSocket } from 'ws'
import { PokerWsAdapter } from '../../packages/services/builtin/poker/ws-adapter.js'

const TABLE = 'a'.repeat(64)

test('poker ws adapter preserves public event reads without an api key', async (t) => {
  const { adapter, server, url } = await startPokerWs()
  t.teardown(async () => {
    adapter.stop()
    await closeServer(server)
  })

  const ws = new WebSocket(url)
  t.teardown(() => {
    try { ws.terminate() } catch {}
  })

  const [raw] = await once(ws, 'message')
  const msg = JSON.parse(raw.toString())
  t.is(msg.type, 'state')
  t.is(msg.state.tableKey, TABLE)
  t.is(adapter.clientCount(TABLE), 1)
})

test('poker ws adapter rejects URL tokens and waits for in-band auth before state', async (t) => {
  const authFailures = []
  const { adapter, server, url } = await startPokerWs({
    apiKey: 'sekrit',
    authTimeoutMs: 100,
    log: (label, info) => {
      if (label === 'ws-auth-failed') authFailures.push(info.reason)
    }
  })
  t.teardown(async () => {
    adapter.stop()
    await closeServer(server)
  })

  const queryToken = new WebSocket(url + '?token=sekrit')
  t.teardown(() => {
    try { queryToken.terminate() } catch {}
  })
  const [queryErr] = await once(queryToken, 'error')
  t.ok(queryErr.message.includes('401'), 'upgrade rejects query-token auth')
  t.alike(authFailures, ['query-token'])

  const ws = new WebSocket(url)
  t.teardown(() => {
    try { ws.terminate() } catch {}
  })
  const messages = []
  ws.on('message', (data) => messages.push(data.toString()))

  await once(ws, 'open')
  await delay(30)
  t.is(adapter.clientCount(TABLE), 0, 'client is not counted before auth')
  t.is(messages.length, 0, 'state frame is not sent before auth')

  ws.send(JSON.stringify({ type: 'auth', token: 'sekrit' }))
  await waitUntil(() => messages.length > 0)

  const state = JSON.parse(messages[0])
  t.is(state.type, 'state')
  t.is(state.state.tableKey, TABLE)
  t.is(adapter.clientCount(TABLE), 1, 'client is counted after valid auth')
})

test('poker ws adapter closes invalid auth frames without attaching clients', async (t) => {
  const authFailures = []
  const { adapter, server, url } = await startPokerWs({
    apiKey: 'sekrit',
    authTimeoutMs: 100,
    log: (label, info) => {
      if (label === 'ws-auth-failed') authFailures.push(info.reason)
    }
  })
  t.teardown(async () => {
    adapter.stop()
    await closeServer(server)
  })

  const ws = new WebSocket(url)
  t.teardown(() => {
    try { ws.terminate() } catch {}
  })

  await once(ws, 'open')
  ws.send(JSON.stringify({ type: 'auth', token: 'wrong' }))
  const [code, reason] = await once(ws, 'close')

  t.is(code, 1008)
  t.is(reason.toString(), 'auth-failed')
  t.alike(authFailures, ['invalid'])
  t.is(adapter.clientCount(TABLE), 0)
})

test('poker ws adapter redacts subscribe failures from clients', async (t) => {
  const logs = []
  const secretPath = '/data/poker/private/subscription-secret'
  const { adapter, server, url } = await startPokerWs({
    log: (label, info) => logs.push({ label, info }),
    pokerApp: {
      getState (tableKey) {
        return tableKey === TABLE ? { tableKey, players: [] } : null
      },
      subscribe () {
        throw new Error('subscribe failed at ' + secretPath)
      }
    }
  })
  t.teardown(async () => {
    adapter.stop()
    await closeServer(server)
  })

  const ws = new WebSocket(url)
  t.teardown(() => {
    try { ws.terminate() } catch {}
  })
  const [raw] = await once(ws, 'message')
  const msg = JSON.parse(raw.toString())
  t.alike(msg, { type: 'error', error: 'subscribe-failed' })
  t.absent(raw.toString().includes(secretPath), 'client frame does not include internal error text')
  t.is(logs[0].label, 'ws-subscribe-error')
  t.ok(logs[0].info.error.includes(secretPath), 'internal log keeps diagnostic detail')
})

test('poker ws adapter redacts public state lookup failures during upgrade', async (t) => {
  const secretPath = '/data/poker/private/state-secret'
  const logs = []
  const { adapter, server, url } = await startPokerWs({
    log: (label, info) => logs.push({ label, info }),
    pokerApp: {
      getState () {
        throw new Error('state failed at ' + secretPath)
      },
      subscribe () {
        return () => {}
      }
    }
  })
  t.teardown(async () => {
    adapter.stop()
    await closeServer(server)
  })

  const ws = new WebSocket(url)
  t.teardown(() => {
    try { ws.terminate() } catch {}
  })
  const [err] = await once(ws, 'error')
  t.ok(err.message.includes('503'), 'upgrade fails with stable service unavailable response')
  t.absent(err.message.includes(secretPath), 'client error does not include internal state failure text')
  t.is(logs[0].label, 'ws-state-error')
  t.is(logs[0].info.context, 'upgrade')
  t.ok(logs[0].info.error.includes(secretPath), 'internal log keeps diagnostic detail')
})

test('poker ws adapter redacts authenticated state lookup failures after auth', async (t) => {
  const secretPath = '/data/poker/private/post-auth-state-secret'
  const logs = []
  const { adapter, server, url } = await startPokerWs({
    apiKey: 'sekrit',
    log: (label, info) => logs.push({ label, info }),
    pokerApp: {
      getState () {
        throw new Error('state failed at ' + secretPath)
      },
      subscribe () {
        return () => {}
      }
    }
  })
  t.teardown(async () => {
    adapter.stop()
    await closeServer(server)
  })

  const ws = new WebSocket(url)
  t.teardown(() => {
    try { ws.terminate() } catch {}
  })
  await once(ws, 'open')
  ws.send(JSON.stringify({ type: 'auth', token: 'sekrit' }))

  const [raw] = await once(ws, 'message')
  const msg = JSON.parse(raw.toString())
  t.alike(msg, { type: 'error', error: 'state-unavailable' })
  t.absent(raw.toString().includes(secretPath), 'client frame does not include internal state failure text')
  const [code, reason] = await once(ws, 'close')
  t.is(code, 1011)
  t.is(reason.toString(), 'state-unavailable')
  t.is(logs[0].label, 'ws-state-error')
  t.is(logs[0].info.context, 'auth')
})

async function startPokerWs (opts = {}) {
  const server = createServer((req, res) => {
    res.statusCode = 404
    res.end()
  })
  const pokerApp = opts.pokerApp || {
    getState (tableKey) {
      return tableKey === TABLE ? { tableKey, players: [] } : null
    },
    subscribe () {
      return () => {}
    }
  }
  const adapter = new PokerWsAdapter({
    pokerApp,
    server,
    apiKey: opts.apiKey,
    authTimeoutMs: opts.authTimeoutMs || 100,
    log: opts.log
  })
  adapter.start()
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
  const port = server.address().port
  return { adapter, server, url: `ws://127.0.0.1:${port}/api/poker/${TABLE}/events` }
}

async function closeServer (server) {
  if (!server.listening) return
  await new Promise((resolve) => server.close(resolve))
}

function delay (ms) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

function waitUntil (fn, timeoutMs = 500) {
  const started = Date.now()
  return new Promise((resolve, reject) => {
    const tick = () => {
      if (fn()) return resolve()
      if (Date.now() - started > timeoutMs) return reject(new Error('timed out waiting for condition'))
      setTimeout(tick, 10)
    }
    tick()
  })
}