OverviewFilesCommitsIssuesDocsReleasesPeers

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

234 lines · javascript ref:
import test from 'brittle'
import http from 'http'
import { RelayAPI } from 'p2p-hiverelay/core/relay-node/api.js'
import { BUILTIN_SERVICE_NAMES } from 'p2p-hiverelay/core/plugin-loader.js'

const API_KEY = 'outboxlog-test-key'

function fakeOutboxLogApp () {
  const groups = new Map()
  let channelSeq = 0
  const channels = new Map()
  const suppressed = new Set()

  function ensureGroup (appId) {
    let group = groups.get(appId)
    if (!group) {
      group = { inviteKey: 'i'.repeat(64), rows: new Map(), version: 0 }
      groups.set(appId, group)
    }
    return group
  }

  return {
    sync: {
      create (appId) {
        const group = ensureGroup(appId)
        return { appId, inviteKey: group.inviteKey, writerPublicKey: appId }
      },
      append (appId, op) {
        const group = ensureGroup(appId)
        const key = op.type.replace(':', '!') + '!' + op.data.id
        group.rows.set(key, op.data)
        group.version++
        return { ok: true, key }
      },
      get (appId, key) {
        const group = groups.get(appId)
        return group ? (group.rows.get(key) || null) : null
      },
      list () { return [] },
      range () { return [] },
      count () { return { count: 0 } },
      status (appId) { return { appId, inviteKey: null, writerCount: 0, viewLength: 0 } },
      heads (appIds) {
        const heads = {}
        for (const appId of appIds || []) heads[appId] = groups.get(appId)?.version || 0
        return { heads }
      },
      directory () { return { heads: {}, count: 0 } },
      takedown (appId, key) {
        suppressed.add(appId + '|' + key)
        return { appId, key, suppressed: true }
      },
      restore (appId, key) {
        suppressed.delete(appId + '|' + key)
        return { appId, key, suppressed: false }
      },
      takedowns () {
        const list = [...suppressed].map((id) => {
          const [appId, key] = id.split('|')
          return { appId, key }
        })
        return { takedowns: list, count: list.length }
      }
    },
    swarm: {
      join (topicHex) {
        const channelId = 'ch-' + (++channelSeq)
        channels.set(channelId, { topicHex, onEvent: null })
        return { channelId, topicHex, protocol: 'pear.swarm.v1', version: 1, tier: 'A' }
      },
      send () { return { ok: true } },
      leave (channelId) { channels.delete(channelId); return { ok: true } },
      subscribe (channelId, fn) {
        const channel = channels.get(channelId)
        if (channel) channel.onEvent = fn
        return () => channels.delete(channelId)
      }
    }
  }
}

function outboxRegistry (provider) {
  const entry = {
    name: 'outboxlog',
    version: '0.1.0',
    status: 'running',
    capabilities: ['outboxlog.sync'],
    provider
  }
  return { services: new Map([['outboxlog', 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 data = ''
      res.on('data', (chunk) => { data += chunk })
      res.on('end', () => {
        let parsed
        try { parsed = JSON.parse(data) } catch { parsed = data }
        resolve({ statusCode: res.statusCode, body: parsed, headers: res.headers })
      })
    })
    req.on('error', reject)
    if (body != null) req.write(JSON.stringify(body))
    req.end()
  })
}

async function serverWithApi (t, node, opts = {}) {
  const api = new RelayAPI(node, { apiPort: 0, apiHost: '127.0.0.1', apiKey: API_KEY, ...opts })
  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 }
}

test('outboxlog is a builtin service provider', (t) => {
  t.ok(BUILTIN_SERVICE_NAMES.includes('outboxlog'))
})

test('/api/token returns 503 when outboxlog is not enabled', async (t) => {
  const { port } = await serverWithApi(t, mockNode({ registry: null }))
  const res = await request(port, 'POST', '/api/token')

  t.is(res.statusCode, 503)
  t.alike(res.body, { error: 'OutboxLog service is not enabled on this relay' })
})

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

  const res = await request(port, 'POST', '/api/token')

  t.is(res.statusCode, 503)
  t.is(res.body.error, 'unsupported: outboxlog HTTP adapter unavailable')
  t.is(res.body.errorCode, 'outboxlog-http-adapter-unavailable')
  t.absent(JSON.stringify(res.body).includes('/data/hiverelay/private'))
  t.is(events.length, 1)
  t.ok(events[0].error.message.includes('/data/hiverelay/private'))
})

test('/api/outboxlog bridge token gates sync routes through RelayAPI', async (t) => {
  const app = fakeOutboxLogApp()
  const { port } = await serverWithApi(t, mockNode({ registry: outboxRegistry(app) }))
  const tokenRes = await request(port, 'POST', '/api/token')
  const token = tokenRes.body.token

  t.is(tokenRes.statusCode, 200)
  t.ok(token)

  const denied = await request(port, 'GET', '/api/bridge/status')
  t.is(denied.statusCode, 401)

  const status = await request(port, 'GET', '/api/bridge/status', null, { 'X-Pear-Token': token })
  t.alike(status.body, { ready: true, service: 'outboxlog' })

  const created = await request(port, 'POST', '/api/sync/create', { appId: 'a'.repeat(64) }, { 'X-Pear-Token': token })
  t.is(created.statusCode, 200)
  t.is(created.body.appId, 'a'.repeat(64))
})

test('/api/admin/takedown is 404 through RelayAPI when no admin key is configured', async (t) => {
  const app = fakeOutboxLogApp()
  // No outboxLogAdminKey opt and no HIVERELAY_OUTBOXLOG_ADMIN_KEY env => the
  // takedown surface must stay disabled (safe-by-default), even with an admin
  // token supplied by the caller.
  const { port } = await serverWithApi(t, mockNode({ registry: outboxRegistry(app) }))
  const res = await request(port, 'POST', '/api/admin/takedown', { appId: 'a'.repeat(64), key: 'post!p1' }, { 'X-Pear-Admin-Token': 'anything' })

  t.is(res.statusCode, 404, 'admin surface not enabled without an admin key')
})

test('/api/admin/takedown activates through RelayAPI when an admin key is configured', async (t) => {
  const app = fakeOutboxLogApp()
  const ADMIN_KEY = 'outboxlog-admin-secret'
  const { port } = await serverWithApi(t, mockNode({ registry: outboxRegistry(app) }), { outboxLogAdminKey: ADMIN_KEY })
  const appId = 'a'.repeat(64)

  // Reachable (not 404) but rejects an absent admin token with 401.
  const noToken = await request(port, 'POST', '/api/admin/takedown', { appId, key: 'post!p1' })
  t.is(noToken.statusCode, 401, 'missing admin token rejected once the surface is enabled')

  // Rejects a wrong admin token with 401. The browser sync token must NOT work
  // on the admin surface, so exercise a plausible-but-wrong secret.
  const wrongToken = await request(port, 'POST', '/api/admin/takedown', { appId, key: 'post!p1' }, { 'X-Pear-Admin-Token': 'wrong-secret' })
  t.is(wrongToken.statusCode, 401, 'wrong admin token rejected')

  // Accepts the configured admin token and performs the takedown.
  const ok = await request(port, 'POST', '/api/admin/takedown', { appId, key: 'post!p1' }, { 'X-Pear-Admin-Token': ADMIN_KEY })
  t.is(ok.statusCode, 200, 'correct admin token accepted')
  t.alike(ok.body, { appId, key: 'post!p1', suppressed: true })

  // The audit list surface is reachable under the same credential.
  const list = await request(port, 'GET', '/api/admin/takedowns', null, { 'X-Pear-Admin-Token': ADMIN_KEY })
  t.is(list.statusCode, 200)
  t.alike(list.body, { takedowns: [{ appId, key: 'post!p1' }], count: 1 })
})