OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / api-poker.test.js

175 lines · javascript ref:
/**
 * Poker services wiring — the bundle/expand logic + the /api/poker/* gateway
 * mount. Boots a real RelayAPI over HTTP with a fake 'poker' provider in the
 * registry (the substrate + handlePokerRoute itself are covered by
 * scripts/test-poker-app.js). trustProxy:true so localhost doesn't bypass the
 * create auth gate.
 */
import test from 'brittle'
import http from 'http'
import { RelayAPI } from 'p2p-hiverelay/core/relay-node/api.js'
import { BUILTIN_SERVICE_NAMES, SERVICE_BUNDLES, expandServiceDeps } from 'p2p-hiverelay/core/plugin-loader.js'

const API_KEY = 'poker-test-key'
const AUTH = { Authorization: 'Bearer ' + API_KEY }

// ── plugin-loader bundle logic (pure) ──
test('poker is a builtin + the bundle expands to its support services', (t) => {
  t.ok(BUILTIN_SERVICE_NAMES.includes('poker'), 'poker registered as a builtin')
  t.alike(SERVICE_BUNDLES.poker.slice().sort(), ['arbitration', 'poker', 'vrf', 'zk'])
  t.alike(expandServiceDeps(['poker']).sort(), ['arbitration', 'poker', 'vrf', 'zk'], 'enabling poker pulls its deps')
  t.alike(expandServiceDeps(['ai', 'poker']).sort(), ['ai', 'arbitration', 'poker', 'vrf', 'zk'], 'never clobbers existing services')
  t.alike(expandServiceDeps(['ai']), ['ai'], 'a non-bundle list is unchanged')
  t.alike(expandServiceDeps(['bogus', 'vrf']), ['vrf'], 'non-builtins are filtered out')
})

// ── fakes ──
function fakePokerApp () {
  const tables = new Map()
  return {
    listTables () {
      return Array.from(tables.entries()).map(([k, t]) => ({ tableKey: k, length: t.entries.length, writers: t.writers.length, lastTs: 0 }))
    },
    createTable (args) {
      if (tables.has(args.tableKey)) throw new Error('createTable: table already exists')
      tables.set(args.tableKey, { entries: [], writers: args.writers || [] })
      return { tableKey: args.tableKey, writers: args.writers || [] }
    },
    getState (k) { return tables.has(k) ? { tableKey: k, length: tables.get(k).entries.length } : null },
    getLog (k, from = 0, limit = Infinity) {
      if (!tables.has(k)) return null
      const all = tables.get(k).entries
      const entries = all.slice(from, limit === Infinity ? undefined : from + limit)
      return { from, to: from + entries.length, entries }
    },
    submitEntry (k, entry) {
      if (!tables.has(k)) return { ok: false, reason: 'unknown-table' }
      tables.get(k).entries.push(entry)
      return { ok: true, index: tables.get(k).entries.length - 1, ts: 1 }
    },
    subscribe () { return () => {} }
  }
}
function pokerRegistry (provider) {
  const entry = { name: 'poker', version: '0.1.0', status: 'running', capabilities: ['createTable', 'submitEntry'], provider }
  return { services: new Map([['poker', entry]]) }
}
function mockNode (opts = {}) {
  return {
    running: true,
    config: { storage: null, plugins: opts.plugins || [], trustProxy: true },
    metrics: { getSummary () { return { uptime: 1 } } },
    seededApps: new Map(),
    appRegistry: { apps: new Map(), catalog () { return [] }, catalogForBroadcast () { return [] } },
    getStats () { return { running: true } },
    getHealthStatus () { return { healthy: true } },
    serviceRegistry: opts.registry || null,
    async stop () {},
    async start () {},
    on () {},
    emit () {}
  }
}
function request (port, method, path, body, headers = {}) {
  return new Promise((resolve, reject) => {
    const req = http.request({ hostname: '127.0.0.1', port, method, path, headers: { 'Content-Type': 'application/json', ...headers } }, (res) => {
      let d = ''
      res.on('data', (c) => { d += c })
      res.on('end', () => { let p; try { p = JSON.parse(d) } catch { p = d } resolve({ statusCode: res.statusCode, body: p }) })
    })
    req.on('error', reject)
    if (body != null) req.write(JSON.stringify(body))
    req.end()
  })
}
async function serverWithApi (t, node) {
  const api = new RelayAPI(node, { apiPort: 0, apiHost: '127.0.0.1', apiKey: API_KEY })
  await api.start()
  const port = api.server.address().port
  t.teardown(async () => {
    if (api._rateLimitCleanup) clearInterval(api._rateLimitCleanup)
    if (api._dashboardFeed) { try { api._dashboardFeed.stop() } catch (_) {} }
    if (api._pokerFeed) { try { api._pokerFeed.stop() } catch (_) {} }
    await new Promise((resolve) => api.server.close(resolve))
  })
  return { api, port }
}
async function server (t, node) {
  const { port } = await serverWithApi(t, node)
  return port
}

test('/available surfaces the poker bundle', async (t) => {
  const port = await server(t, mockNode({ registry: pokerRegistry(fakePokerApp()) }))
  const res = await request(port, 'GET', '/api/manage/services/available', null, AUTH)
  t.is(res.statusCode, 200)
  t.ok(res.body.bundles && Array.isArray(res.body.bundles.poker), 'bundles.poker present')
  t.ok(res.body.available.includes('poker'), 'poker in available list')
})

test('/api/poker/* → 503 when poker is not enabled', async (t) => {
  const port = await server(t, mockNode({ registry: null }))
  const res = await request(port, 'GET', '/api/poker/tables')
  t.is(res.statusCode, 503)
})

test('/api/poker/* redacts adapter load failures and emits internals', async (t) => {
  const app = fakePokerApp()
  const { api, port } = await serverWithApi(t, mockNode({ registry: pokerRegistry(app) }))
  const events = []
  api.on('poker-http-adapter-error', (event) => events.push(event))
  api._loadPokerHttpAdapter = async function () {
    throw new Error('internal adapter path /data/hiverelay/private/poker/http-adapter.js failed')
  }

  const res = await request(port, 'GET', '/api/poker/tables')
  t.is(res.statusCode, 503)
  t.is(res.body.error, 'unsupported: poker HTTP adapter unavailable')
  t.is(res.body.errorCode, 'poker-http-adapter-unavailable')
  t.absent(JSON.stringify(res.body).includes('/data/hiverelay/private'), 'public payload omits internal adapter failure')
  t.is(events.length, 1)
  t.ok(events[0].error.message.includes('/data/hiverelay/private'), 'internal event keeps operator diagnostics')
})

test('GET /api/poker/tables is open + lists tables', async (t) => {
  const app = fakePokerApp(); app.createTable({ tableKey: 'aa', writers: ['w1'] })
  const port = await server(t, mockNode({ registry: pokerRegistry(app) }))
  const res = await request(port, 'GET', '/api/poker/tables') // no auth — reads are open
  t.is(res.statusCode, 200)
  t.is(res.body.tables.length, 1)
})

test('POST /api/poker/tables (create) is auth-gated', async (t) => {
  const app = fakePokerApp()
  const port = await server(t, mockNode({ registry: pokerRegistry(app) }))
  const noauth = await request(port, 'POST', '/api/poker/tables', { tableKey: 'bb', writers: ['w1'] })
  t.is(noauth.statusCode, 401, 'create requires auth (anti-DoS on maxTables)')
  const ok = await request(port, 'POST', '/api/poker/tables', { tableKey: 'bb', writers: ['w1'] }, AUTH)
  t.ok(ok.statusCode === 200 || ok.statusCode === 201, 'create succeeds with auth')
  t.ok(app.getState('bb'), 'table actually created')
})

test('GET /api/poker/usage — auth-gated, blind-safe append/seat tally', async (t) => {
  const app = fakePokerApp()
  app.createTable({ tableKey: 'dd', writers: ['w1', 'w2'] })
  app.submitEntry('dd', { sig: 'x' }); app.submitEntry('dd', { sig: 'y' }); app.submitEntry('dd', { sig: 'z' })
  const port = await server(t, mockNode({ registry: pokerRegistry(app) }))
  t.is((await request(port, 'GET', '/api/poker/usage')).statusCode, 401, 'operator view requires auth')
  const res = await request(port, 'GET', '/api/poker/usage', null, AUTH)
  t.is(res.statusCode, 200)
  t.is(res.body.tables, 1)
  t.is(res.body.appends, 3, 'counts player-signed appends')
  t.is(res.body.seats, 2, 'counts writer seats')
  t.is(res.body.perTable[0].tableKey, 'dd')
  const { note, ...data } = res.body // note is human-readable docs, not leaked data
  const blob = JSON.stringify(data).toLowerCase()
  for (const bad of ['card', 'hole', 'payload']) t.absent(blob.includes(bad), 'no "' + bad + '" leaked')
})

test('GET /api/poker/:table/state — 200 known, 404 unknown (open reads)', async (t) => {
  const app = fakePokerApp(); app.createTable({ tableKey: 'cc', writers: ['w1'] })
  const port = await server(t, mockNode({ registry: pokerRegistry(app) }))
  t.is((await request(port, 'GET', '/api/poker/cc/state')).statusCode, 200)
  t.is((await request(port, 'GET', '/api/poker/zz/state')).statusCode, 404)
})