OverviewFilesCommitsIssuesDocsReleasesPeers

favela-gateway / test / gateway.test.js

197 lines · javascript ref:
// The gateway: the membrane and the fediverse bridge as roles of ONE process (hyper-social #80, G1).
//
// What G1 promises, each pinned here:
//   - both roles serve from one process, each on its own port and its own data dir;
//   - their control verbs answer from ONE handler map (the gateway's socket serves it);
//   - a role that fails to start is reported down and the other keeps serving;
//   - the stop steps free everything, so the gateway can start again in the same process.

import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import http from 'bare-http1'
import { startGateway } from '../lib/gateway.js'
import { TEST_PRIVATE_KEY, TEST_PUBLIC_KEY } from 'ap-bridge/test/fixtures/keys.js'

// ⚠ SHORT paths: admin.sock lives in the ap data dir, and a unix socket path past ~107 bytes does not bind.
const tmp = (p) => fs.mkdtempSync(path.join('/tmp', p))

function membraneConfig () {
  return { serverDid: 'did:web:pds.test', userDomain: '.test', frontedDids: [], feedRegistries: [], favelaWebUpstream: null, crawlRelay: null, crawlHostname: null, port: 0, bind: '127.0.0.1', storage: tmp('gwm-') }
}

function apConfig ({ keys = true } = {}) {
  const dir = tmp('gwa-')
  if (keys) {
    fs.writeFileSync(path.join(dir, 'actor.key'), TEST_PRIVATE_KEY)
    fs.writeFileSync(path.join(dir, 'actor.pub'), TEST_PUBLIC_KEY)
  }
  return { host: 'ap.test', username: 'p2p', bind: '127.0.0.1', port: 0, adminPort: 0, adminBind: '127.0.0.1', dataDir: dir, privateKeyPath: path.join(dir, 'actor.key'), publicKeyPath: path.join(dir, 'actor.pub'), registration: 'open', backfill: 0, did: null, favelaSocket: null }
}

function get (port, p) {
  return new Promise((resolve, reject) => {
    const req = http.request({ host: '127.0.0.1', port, path: p, method: 'GET' }, (res) => {
      let body = ''
      res.on('data', (c) => { body += c })
      res.on('end', () => resolve({ status: res.statusCode, body }))
    })
    req.on('error', reject)
    req.end()
  })
}

const quiet = { log: () => {}, bootstrap: [] }
async function stop (g) { for (const [, fn] of g.steps) await fn() }

test('★ both roles serve from one process', async (t) => {
  const g = await startGateway({ roles: ['membrane', 'fediverse'], membrane: membraneConfig(), fediverse: apConfig(), ...quiet })
  t.teardown(() => stop(g))
  t.is((await get(g.roles.membrane.port, '/xrpc/com.atproto.server.describeServer')).status, 200, 'the membrane answers')
  t.is((await get(g.roles.fediverse.port, '/ap/actor')).status, 200, 'the fediverse actor answers')
  const s = await g.handlers.status()
  t.ok(s.ok, 'status ok')
  t.alike(Object.keys(s.roles).sort(), ['fediverse', 'membrane'], 'and names both roles')
})

test('★ one handler map carries both roles\' verbs, and nothing collides', async (t) => {
  const g = await startGateway({ roles: ['membrane', 'fediverse'], membrane: membraneConfig(), fediverse: apConfig(), ...quiet })
  t.teardown(() => stop(g))
  for (const verb of ['list-fronted', 'diagnose', 'probe-front', 'drop-front', 'allow-front', 'forget-did', 'set-seq-base', 'emit-sync', 'resolve-handle', 'list-repos']) {
    t.is(typeof g.handlers[verb], 'function', 'membrane: ' + verb)
  }
  for (const verb of ['invite', 'notifications', 'reproject']) t.is(typeof g.handlers[verb], 'function', 'fediverse: ' + verb)
  t.absent('release-ready' in g.handlers, 'release-ready belongs to the process owner, not to a role')
  t.alike((await g.handlers['list-fronted']({})).fronted, [], 'a membrane verb answers')
  t.is(typeof (await g.handlers.invite()), 'string', 'a fediverse verb answers')
})

test('⛔ a role that fails to start is reported down, and the other keeps serving', async (t) => {
  const g = await startGateway({ roles: ['membrane', 'fediverse'], membrane: membraneConfig(), fediverse: apConfig({ keys: false }), ...quiet })
  t.teardown(() => stop(g))
  t.is((await get(g.roles.membrane.port, '/xrpc/com.atproto.server.describeServer')).status, 200, 'the membrane still serves')
  t.absent(g.roles.fediverse, 'the fediverse role is not running')
  const s = await g.handlers.status()
  t.ok(s.roles.fediverse.down, 'status says it is down')
  t.ok(/actor\.key|ENOENT|no such file/i.test(s.roles.fediverse.error), 'and why')
  t.absent('invite' in g.handlers, 'and a down role offers no verbs')
})

test('a config the entry could not load arrives as an Error and marks only that role down', async (t) => {
  const g = await startGateway({ roles: ['membrane', 'fediverse'], membrane: membraneConfig(), fediverse: new Error('fediverse config: AP_HOST is required'), ...quiet })
  t.teardown(() => stop(g))
  t.is((await g.handlers.status()).roles.fediverse.error, 'fediverse config: AP_HOST is required', 'the reason is the config error, verbatim')
  t.ok(g.roles.membrane, 'the membrane is up')
})

test('⛔ if every requested role fails, the gateway refuses to start', async (t) => {
  await t.exception(
    startGateway({ roles: ['fediverse'], fediverse: apConfig({ keys: false }), ...quiet }),
    /no role started/
  )
})

test('⛔ an unknown role is refused rather than ignored', async (t) => {
  await t.exception(startGateway({ roles: ['membrane', 'seeder'], membrane: membraneConfig(), ...quiet }), /unknown role: seeder/)
})

test('the stop steps free everything: the same ports start again in one process', async (t) => {
  const m = membraneConfig()
  const a = apConfig()
  const one = await startGateway({ roles: ['membrane', 'fediverse'], membrane: m, fediverse: a, ...quiet })
  const ports = { m: one.roles.membrane.port, a: one.roles.fediverse.port, admin: one.roles.fediverse.adminPort }
  const names = one.steps.map(([n]) => n)
  t.ok(names.indexOf('fediverse:server') < names.indexOf('membrane:server'), 'roles stop in reverse start order')
  await stop(one)
  const two = await startGateway({ roles: ['membrane', 'fediverse'], membrane: { ...m, port: ports.m }, fediverse: { ...a, port: ports.a, adminPort: ports.admin }, ...quiet })
  t.teardown(() => stop(two))
  t.is(two.roles.membrane.port, ports.m)
  t.is(two.roles.fediverse.port, ports.a)
  t.is(two.roles.fediverse.bridgeKey, one.roles.fediverse.bridgeKey, 'the published bridgeKey survives a restart')
  t.is(two.roles.membrane.swarmKey, one.roles.membrane.swarmKey, 'and so does the membrane\'s swarm identity')
})

// ── metrics: one verb, every role, plus bsky's own view (hyper-social #80) ─────────────────────────────
const offlineFetch = async (url) => ({ ok: true, status: 200, json: async () => (url.includes('getHostStatus') ? { status: 'active', seq: 0 } : {}) })

test('★ metrics: process, roles, membrane, fediverse and bsky in one answer', async (t) => {
  const g = await startGateway({ roles: ['membrane', 'fediverse'], membrane: membraneConfig(), fediverse: apConfig(), fetch: offlineFetch, ...quiet })
  t.teardown(() => stop(g))
  const m = await g.handlers.metrics({})
  t.ok(m.process.uptimeS >= 0 && m.process.rss > 0, 'process uptime and RSS')
  t.is(m.roles.membrane.up, true)
  t.is(m.roles.fediverse.up, true)
  t.alike(m.membrane.relay, { emitted: 0, seqBase: 0 }, 'the membrane section is the role\'s own metrics')
  t.is(m.fediverse.users[0].username, 'p2p', 'and so is the fediverse section')
  t.is(m.bsky.relay.status, 'active', 'bsky\'s view, through the probes')
  t.is(m.bsky.hostname, 'pds.test', 'probed for the membrane\'s own host (from serverDid)')
})

test('metrics still answers with a role down, and names it', async (t) => {
  const g = await startGateway({ roles: ['membrane', 'fediverse'], membrane: membraneConfig(), fediverse: new Error('fediverse config: AP_HOST is required'), fetch: offlineFetch, ...quiet })
  t.teardown(() => stop(g))
  const m = await g.handlers.metrics({})
  t.is(m.roles.fediverse.up, false)
  t.is(m.roles.fediverse.error, 'fediverse config: AP_HOST is required')
  t.is(m.fediverse, null, 'no section for a role that is not running')
  t.ok(m.membrane, 'the running role still reports')
})

test('⛔ the lens without a running membrane is down, with the reason — it serves THROUGH the membrane', async (t) => {
  const g = await startGateway({ roles: ['fediverse', 'lens'], fediverse: apConfig(), lens: { feeds: [{ id: 'x', roomKey: 'a'.repeat(64) }] }, ...quiet })
  t.teardown(() => stop(g))
  t.absent(g.roles.lens)
  t.ok(/needs the membrane/.test(g.down.lens), g.down.lens)
})

test('★ alerts: with the watcher on, `alerts refresh=true` checks now and `metrics` carries the result', async (t) => {
  const offlineRelay = async (url) => ({ ok: true, status: 200, json: async () => (url.includes('getHostStatus') ? { status: 'offline', seq: 5 } : {}) })
  const g = await startGateway({ roles: ['membrane'], membrane: membraneConfig(), fetch: offlineRelay, watch: true, stateDir: tmp('gwa-st-'), ...quiet })
  t.teardown(() => stop(g))
  const a = await g.handlers.alerts({ refresh: true })
  t.ok(a.open.some((x) => x.id === 'relay-offline'), 'the relay-offline alert is open')
  t.ok(a.lastCheckAt > 0)
  const m = await g.handlers.metrics({})
  t.ok(m.alerts && m.alerts.open.length, 'metrics carries the alerts')
  const names = g.steps.map(([n]) => n)
  t.ok(names.indexOf('alerts') < names.findIndex((n) => n.startsWith('membrane:')), 'the watcher stops before any role')
})

test('★ the status page is registered on the fediverse role\'s admin surface and renders every role', async (t) => {
  const g = await startGateway({ roles: ['membrane', 'fediverse'], membrane: membraneConfig(), fediverse: apConfig(), fetch: offlineFetch, ...quiet })
  t.teardown(() => stop(g))
  t.ok(g.adminPages.has('/admin/status'))
  const p = await g.adminPages.get('/admin/status')()
  t.is(p.title, 'Gateway status')
  for (const s of ['Roles', 'Served DIDs', 'Fediverse', 'membrane', 'fediverse']) t.ok(p.body.includes(s), s)
})

test('★ the public page is served on the membrane\'s own host only', async (t) => {
  const g = await startGateway({ roles: ['membrane'], membrane: membraneConfig(), fetch: offlineFetch, ...quiet })
  t.teardown(() => stop(g))
  const page = g.roles.membrane.extensions.http.get('/')
  t.ok(page, 'registered at /')
  t.ok(g.roles.membrane.extensions.http.get('/status'), 'and /status')
  t.is(await page({ host: 'p2p.test' }), null, 'a handle host: declined, falls through as before')
  const r = await page({ host: 'pds.test' })
  t.is(r.status, 200)
  t.ok(r.body.startsWith('<!doctype html>') && r.body.includes('pds.test'))
})

test('★ the lens role gets the labels module (with a state dir) and the member projector (with a labeler socket)', async (t) => {
  const g = await startGateway({ roles: ['membrane', 'lens'], membrane: membraneConfig(), lens: { feeds: [{ id: 'x', roomKey: 'a'.repeat(64) }], labelerSock: '/tmp/gw-no-such-labeler.sock' }, fetch: offlineFetch, stateDir: tmp('gwl-st-'), ...quiet })
  t.teardown(() => stop(g))
  t.ok(g.roles.lens, g.down.lens || 'lens up')
  const m = await g.handlers.metrics({})
  t.ok(m.lens.labels, 'labels served from the gateway\'s own log')
  t.ok(m.lens.memberLabels, 'the member projector runs')
})

test('without a state dir or a labeler socket, the lens runs as before', async (t) => {
  const g = await startGateway({ roles: ['membrane', 'lens'], membrane: membraneConfig(), lens: { feeds: [{ id: 'x', roomKey: 'a'.repeat(64) }] }, fetch: offlineFetch, ...quiet })
  t.teardown(() => stop(g))
  const m = await g.handlers.metrics({})
  t.is(m.lens.labels, null)
  t.is(m.lens.memberLabels, null)
})