OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / index-room.test.js

215 lines · javascript ref:
/**
 * Index-room Tier-0 (relay side): the relay advertises a schema-sheets index
 * room published by the out-of-process sidecar, and reverse-proxies the
 * read-only query routes so the desktop hits a single gatewayUrl.
 *
 * Covers: capability-doc additive+signed indexRoom, RelayNode persistence,
 * /catalog.json envelope, the /index/* proxy, and the publish endpoint.
 */

import test from 'brittle'
import http from 'http'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import z32 from 'z32'
import { rm, readFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { buildCapabilityDoc, verifyCapabilityDoc } from 'p2p-hiverelay/core/capability-doc.js'
import { RelayNode } from 'p2p-hiverelay/core/relay-node/index.js'
import { RelayAPI } from 'p2p-hiverelay/core/relay-node/api.js'

const API_KEY = 'index-room-test-key'
const ROOM = z32.encode(b4a.alloc(32, 7)) // a valid 52-char z32 key
const ROOM2 = z32.encode(b4a.alloc(32, 9))

function signKeyPair () {
  const pk = b4a.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
  const sk = b4a.alloc(sodium.crypto_sign_SECRETKEYBYTES)
  sodium.crypto_sign_keypair(pk, sk)
  return { publicKey: pk, secretKey: sk }
}

// ── capability-doc: additive + signed ──────────────────────────────────
test('capability-doc: indexRoom is signed, additive, schemaVersion stays 1', (t) => {
  const kp = signKeyPair()
  const doc = buildCapabilityDoc({
    relay: { publicKey: kp.publicKey, indexRoom: ROOM, config: {} },
    identitySecretKey: kp.secretKey,
    version: '9.9.9',
    attestedAt: 1
  })
  t.is(doc.indexRoom, ROOM, 'indexRoom present in the doc')
  t.is(doc.schemaVersion, 1, 'schemaVersion not bumped')
  t.ok(verifyCapabilityDoc(doc).valid, 'signature valid with indexRoom present')
  const tampered = { ...doc, indexRoom: ROOM2 }
  t.absent(verifyCapabilityDoc(tampered).valid, 'tampering indexRoom breaks the signature')
})

test('capability-doc: null indexRoom (no sidecar) still verifies', (t) => {
  const kp = signKeyPair()
  const doc = buildCapabilityDoc({
    relay: { publicKey: kp.publicKey, config: {} },
    identitySecretKey: kp.secretKey,
    attestedAt: 1
  })
  t.is(doc.indexRoom, null)
  t.ok(verifyCapabilityDoc(doc).valid)
})

// ── RelayNode persistence (isolated, no full node construction) ─────────
test('setIndexRoom persists + _loadIndexRoom restores; rejects bad z32', async (t) => {
  const dir = join(tmpdir(), 'idxroom-' + ROOM.slice(0, 10))
  await rm(dir, { recursive: true, force: true })
  const events = []
  const _indexRoomPath = RelayNode.prototype._indexRoomPath
  // setIndexRoom now best-effort republishes the DHT relay record; provide the
  // sibling method (no swarm on the stub → it no-ops and returns false).
  const publishRelayRecord = RelayNode.prototype.publishRelayRecord
  const stub = { config: { storage: dir }, indexRoom: null, _indexRoomPath, publishRelayRecord, emit (e) { events.push(e) } }

  await RelayNode.prototype.setIndexRoom.call(stub, ROOM)
  t.is(stub.indexRoom, ROOM)
  t.ok(events.includes('index-room'), 'emits index-room')
  const file = JSON.parse(await readFile(join(dir, 'index-room.json'), 'utf8'))
  t.is(file.room, ROOM, 'persisted to index-room.json')

  const fresh = { config: { storage: dir }, indexRoom: null, _indexRoomPath, emit () {} }
  await RelayNode.prototype._loadIndexRoom.call(fresh)
  t.is(fresh.indexRoom, ROOM, 'restored across restart')

  await t.exception(() => RelayNode.prototype.setIndexRoom.call(stub, 'not-a-z32-key'))
  await rm(dir, { recursive: true, force: true })
})

// ── API: envelope + proxy + publish ─────────────────────────────────────
function mockNode (opts = {}) {
  return {
    running: true,
    config: { storage: null },
    metrics: { getSummary () { return { uptime: 1 } } },
    seededApps: new Map(),
    appRegistry: { apps: new Map(), catalog () { return [] }, catalogForBroadcast () { return [] } },
    getStats () { return { running: true } },
    getHealthStatus () { return { healthy: true } },
    async stop () {},
    async start () {},
    seeder: null,
    swarm: null,
    federation: null,
    catalogBeeKey: null,
    indexRoom: opts.indexRoom || null,
    indexSidecarUrl: opts.indexSidecarUrl || null,
    _setRooms: [],
    async setIndexRoom (r) { this._setRooms.push(r); this.indexRoom = r; return r },
    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 server (t, node, events = null) {
  const api = new RelayAPI(node, { apiPort: 0, apiHost: '127.0.0.1', apiKey: API_KEY })
  if (events) api.on('index-proxy-error', event => events.push(event))
  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 (_) {} }
    await new Promise(resolve => api.server.close(resolve))
  })
  return port
}

test('catalog.json surfaces indexRoom (and null when no sidecar)', async (t) => {
  const p1 = await server(t, mockNode({ indexRoom: ROOM }))
  const r1 = await request(p1, 'GET', '/catalog.json')
  t.is(r1.body.indexRoom, ROOM)
  const p2 = await server(t, mockNode())
  const r2 = await request(p2, 'GET', '/catalog.json')
  t.is(r2.body.indexRoom, null)
})

test('GET /index/* returns 501 when no sidecar configured', async (t) => {
  const port = await server(t, mockNode())
  const res = await request(port, 'GET', '/index/relays')
  t.is(res.statusCode, 501)
  t.is(res.body.errorCode, 'index-disabled')
})

test('GET /index/* reverse-proxies to the configured sidecar (path+query only)', async (t) => {
  let seenUrl = null
  const upstream = http.createServer((req, res) => {
    seenUrl = req.url
    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify({ rows: [{ pubkey: 'x' }], via: 'sidecar' }))
  })
  await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve))
  const upPort = upstream.address().port
  t.teardown(() => new Promise(resolve => upstream.close(resolve)))

  const port = await server(t, mockNode({ indexSidecarUrl: `http://127.0.0.1:${upPort}` }))
  const res = await request(port, 'GET', '/index/relays?region=eu')
  t.is(res.statusCode, 200)
  t.is(res.body.via, 'sidecar')
  t.is(seenUrl, '/index/relays?region=eu', 'forwarded path + query verbatim')
})

test('GET /index/* redacts sidecar connection failures and emits internals', async (t) => {
  const events = []
  const port = await server(t, mockNode({ indexSidecarUrl: 'http://127.0.0.1:9' }), events)
  const res = await request(port, 'GET', '/index/relays')

  t.is(res.statusCode, 502)
  t.is(res.body.error, 'unsupported: index sidecar unreachable')
  t.is(res.body.errorCode, 'index-unreachable')
  t.absent(Object.prototype.hasOwnProperty.call(res.body, 'detail'))
  t.is(events.length, 1)
  t.is(events[0].status, 502)
  t.ok(events[0].error, 'raw error emitted internally')
})

test('POST /api/manage/index-room: auth + z32 validation + publish', async (t) => {
  const node = mockNode()
  const port = await server(t, node)
  // no auth
  const unauth = await request(port, 'POST', '/api/manage/index-room', { room: ROOM })
  t.is(unauth.statusCode, 401)
  // bad z32
  const bad = await request(port, 'POST', '/api/manage/index-room', { room: 'nope' }, { Authorization: 'Bearer ' + API_KEY })
  t.is(bad.statusCode, 400)
  t.ok(bad.body.error.startsWith('bad-request: '))
  // valid
  const ok = await request(port, 'POST', '/api/manage/index-room', { room: ROOM }, { Authorization: 'Bearer ' + API_KEY })
  t.is(ok.statusCode, 200)
  t.is(ok.body.indexRoom, ROOM)
  t.alike(node._setRooms, [ROOM], 'setIndexRoom called once')
})

test('POST /api/manage/index-room redacts setter failures', async (t) => {
  const node = mockNode()
  node.setIndexRoom = async () => {
    throw new Error('internal path /data/private/index-room.json leaked')
  }
  const port = await server(t, node)

  const res = await request(port, 'POST', '/api/manage/index-room', { room: ROOM }, { Authorization: 'Bearer ' + API_KEY })

  t.is(res.statusCode, 503)
  t.is(res.body.error, 'persist-failed: failed to set index room')
  t.absent(res.body.error.includes('/data/private'))
  t.alike(node._setRooms, [], 'failed setter did not publish a room')
})