OverviewFilesCommitsIssuesDocsReleasesPeers

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

259 lines · javascript ref:
import test from 'brittle'
import { Readable } from 'node:stream'
import { handlePokerRoute } from 'p2p-hiveservices/builtin/poker/http-adapter.js'

function fakeReq (method, url, body = null, headers = {}) {
  const chunks = body === null || body === undefined ? [] : [body]
  const req = Readable.from(chunks)
  req.method = method
  req.url = url
  req.headers = { ...headers }
  return req
}

function jsonReq (method, url, body) {
  const text = JSON.stringify(body)
  return fakeReq(method, url, text, {
    'content-type': 'application/json',
    'content-length': String(Buffer.byteLength(text))
  })
}

function fakeRes () {
  const headers = {}
  return {
    headers,
    statusCode: null,
    body: null,
    setHeader (name, value) {
      headers[name] = value
    },
    getHeader (name) {
      return this.header(name)
    },
    hasHeader (name) {
      return this.header(name) !== undefined
    },
    header (name) {
      const lower = name.toLowerCase()
      const key = Object.keys(headers).find(key => key.toLowerCase() === lower)
      return key ? headers[key] : undefined
    },
    writeHead (status) {
      this.statusCode = status
    },
    end (body = '') {
      this.body = String(body)
    }
  }
}

function parseBody (res) {
  return JSON.parse(res.body)
}

test('poker http adapter uses hardened JSON response headers', async (t) => {
  const res = fakeRes()
  const handled = await handlePokerRoute(
    fakeReq('GET', '/api/poker/tables'),
    res,
    {
      pokerApp: {
        listTables () {
          return [{ tableKey: 'table-1', length: 2 }]
        }
      }
    }
  )

  t.is(handled, true)
  t.is(res.statusCode, 200)
  t.is(res.header('Content-Type'), 'application/json; charset=utf-8')
  t.is(res.header('X-Content-Type-Options'), 'nosniff')
  t.is(res.header('Cache-Control'), 'no-store, max-age=0')
  t.is(res.header('Access-Control-Allow-Origin'), '*')
  t.alike(parseBody(res), { tables: [{ tableKey: 'table-1', length: 2 }] })
})

test('poker http adapter rejects body-bearing non-json posts before parsing', async (t) => {
  const res = fakeRes()
  const body = '{"tableKey":'
  let created = false
  const handled = await handlePokerRoute(
    fakeReq('POST', '/api/poker/tables', body, {
      'content-type': 'text/plain',
      'content-length': String(Buffer.byteLength(body))
    }),
    res,
    {
      pokerApp: {
        createTable () {
          created = true
          return { tableKey: 'table-1' }
        }
      }
    }
  )

  t.is(handled, true)
  t.is(res.statusCode, 400)
  t.alike(parseBody(res), { error: 'Content-Type must be application/json' })
  t.is(res.header('Connection'), 'close')
  t.is(res.header('X-Content-Type-Options'), 'nosniff')
  t.is(res.header('Cache-Control'), 'no-store, max-age=0')
  t.is(created, false)
})

test('poker http adapter rejects oversized JSON posts with close hint', async (t) => {
  const res = fakeRes()
  const body = JSON.stringify({ tableKey: 't', payload: 'x'.repeat(128 * 1024) })
  const handled = await handlePokerRoute(
    fakeReq('POST', '/api/poker/tables', body, {
      'content-type': 'application/json',
      'content-length': String(Buffer.byteLength(body))
    }),
    res,
    {
      pokerApp: {
        createTable () {
          t.fail('createTable should not run for oversized bodies')
        }
      }
    }
  )

  t.is(handled, true)
  t.is(res.statusCode, 413)
  t.alike(parseBody(res), { error: 'Request body too large' })
  t.is(res.header('Connection'), 'close')
  t.is(res.header('Content-Type'), 'application/json; charset=utf-8')
})

test('poker http adapter still creates tables from valid JSON posts', async (t) => {
  const res = fakeRes()
  let received = null
  const handled = await handlePokerRoute(
    jsonReq('POST', '/api/poker/tables', {
      tableKey: 'table-1',
      writers: ['a'.repeat(64), 'b'.repeat(64)],
      options: { seats: 2 }
    }),
    res,
    {
      pokerApp: {
        createTable (body) {
          received = body
          return { tableKey: body.tableKey, writers: body.writers, seats: body.options.seats }
        }
      }
    }
  )

  t.is(handled, true)
  t.is(res.statusCode, 201)
  t.alike(received, {
    tableKey: 'table-1',
    writers: ['a'.repeat(64), 'b'.repeat(64)],
    options: { seats: 2 }
  })
  t.alike(parseBody(res), {
    tableKey: 'table-1',
    writers: ['a'.repeat(64), 'b'.repeat(64)],
    seats: 2
  })
})

test('poker http adapter redacts unexpected create table errors', async (t) => {
  const res = fakeRes()
  const secretPath = '/data/poker/private/corestore/key-material'
  const handled = await handlePokerRoute(
    jsonReq('POST', '/api/poker/tables', {
      tableKey: 'a'.repeat(64),
      writers: ['b'.repeat(64)]
    }),
    res,
    {
      pokerApp: {
        createTable () {
          throw new Error('corestore failed at ' + secretPath)
        }
      }
    }
  )

  t.is(handled, true)
  t.is(res.statusCode, 500)
  t.alike(parseBody(res), { error: 'Poker table create failed' })
  t.absent(res.body.includes(secretPath), 'internal path is not exposed')
  t.is(res.header('X-Content-Type-Options'), 'nosniff')
  t.is(res.header('Cache-Control'), 'no-store, max-age=0')
})

test('poker http adapter normalizes invalid writer errors without reflecting input', async (t) => {
  const res = fakeRes()
  const reflected = '../private/writer-secret'.repeat(20)
  const handled = await handlePokerRoute(
    jsonReq('POST', '/api/poker/tables', {
      tableKey: 'a'.repeat(64),
      writers: [reflected]
    }),
    res,
    {
      pokerApp: {
        createTable () {
          throw new Error('SignedLog: bad writer pubkey: ' + reflected)
        }
      }
    }
  )

  t.is(handled, true)
  t.is(res.statusCode, 400)
  t.alike(parseBody(res), { error: 'SignedLog: bad writer pubkey' })
  t.absent(res.body.includes(reflected), 'untrusted writer input is not reflected')
})

test('poker http adapter redacts unexpected read and move failures', async (t) => {
  const tables = fakeRes()
  const listHandled = await handlePokerRoute(
    fakeReq('GET', '/api/poker/tables'),
    tables,
    {
      pokerApp: {
        listTables () {
          throw new Error('list failed with /private/service-state')
        }
      }
    }
  )

  t.is(listHandled, true)
  t.is(tables.statusCode, 500)
  t.alike(parseBody(tables), { error: 'Poker table list failed' })
  t.absent(tables.body.includes('/private/service-state'))

  const move = fakeRes()
  const moveHandled = await handlePokerRoute(
    jsonReq('POST', '/api/poker/' + 'a'.repeat(64) + '/move', {
      tableKey: 'a'.repeat(64),
      writer: 'b'.repeat(64),
      seq: 0,
      ts: Date.now(),
      payload: {}
    }),
    move,
    {
      pokerApp: {
        submitEntry () {
          throw new Error('append failed with token=super-secret')
        }
      }
    }
  )

  t.is(moveHandled, true)
  t.is(move.statusCode, 500)
  t.alike(parseBody(move), { error: 'Poker move failed' })
  t.absent(move.body.includes('super-secret'))
})