OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / start-bridge.test.js

111 lines · javascript ref:
// ap-bridge as a module: started and stopped inside one process, with no exit.
//
// ⛔ WHY. The favela gateway (hyper-social #80) runs ap-bridge beside the atproto membrane in ONE
// process. index.js's main() bound two ports, an admin socket, a FeedHolder (Corestore + swarm) and an
// admin-identity Corestore, then shut down by closing only the public server and calling
// process.exit — process exit was what released everything else. A role in a shared process cannot
// rely on that: stopping it has to free every port, socket and store lock it took.

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

function dataDir () {
  // ⚠ SHORT: admin.sock lives in here, and a unix socket path past ~107 bytes does not bind.
  const d = fs.mkdtempSync(path.join('/tmp', 'apb-'))
  fs.writeFileSync(path.join(d, 'actor.key'), TEST_PRIVATE_KEY)
  fs.writeFileSync(path.join(d, 'actor.pub'), TEST_PUBLIC_KEY)
  return d
}

function configFor (dir, { port = 0, adminPort = 0 } = {}) {
  return {
    host: 'ap.test',
    username: 'p2p',
    bind: '127.0.0.1',
    port,
    adminPort,
    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()
  })
}

async function stop (b) { for (const [, fn] of b.steps) await fn() }

test('★ start → serve → stop → start again on the same ports and data dir, in one process', async (t) => {
  const dir = dataDir()
  const one = await startApBridge(configFor(dir), { bootstrap: [], log: () => {} })
  t.ok(one.port > 0 && one.adminPort > 0, 'bound the public and the admin port')
  t.is((await get(one.port, '/ap/actor')).status, 200, 'the actor resolves')
  t.ok(fs.existsSync(path.join(dir, 'admin.sock')), 'the admin recovery socket is up')
  const key = one.bridgeKey
  t.ok(key, 'the registration door is open over the swarm')

  await stop(one)
  t.absent(fs.existsSync(path.join(dir, 'admin.sock')) && await socketAnswers(path.join(dir, 'admin.sock')), 'the admin socket no longer answers')

  // Same ports, same data dir: the listeners are released and the stores' locks are let go.
  const two = await startApBridge(configFor(dir, { port: one.port, adminPort: one.adminPort }), { bootstrap: [], log: () => {} })
  t.teardown(() => stop(two))
  t.is(two.port, one.port, 'the public port binds again')
  t.is(two.adminPort, one.adminPort, 'and the admin port')
  t.is((await get(two.port, '/ap/actor')).status, 200, 'and serves')
  t.is(two.bridgeKey, key, '⛔ the published bridgeKey is the same — registrants find this bridge by it')
})

test('the stop steps are named, and the control verbs are plain handlers', async (t) => {
  const b = await startApBridge(configFor(dataDir()), { bootstrap: [], log: () => {} })
  t.teardown(() => stop(b))
  const names = b.steps.map(([n]) => n)
  for (const n of ['server', 'admin-server', 'admin-control', 'holder']) t.ok(names.includes(n), 'stops ' + n)
  t.is(names[0], 'outbound', 'the delivery drain stops first; what is owed is already on disk')
  for (const verb of ['status', 'invite', 'notifications', 'reproject']) t.is(typeof b.handlers[verb], 'function', verb)
  t.is(typeof (await b.handlers.invite()), 'string', 'invite mints a token (returned once, stored as a hash)')
})

function socketAnswers (p) {
  return new Promise((resolve) => {
    import('bare-pipe').then(({ default: Pipe }) => {
      const s = new Pipe()
      s.on('error', () => resolve(false))
      s.connect(p, () => { s.destroy(); resolve(true) })
    }).catch(() => resolve(false))
  })
}

// ── metrics: what the gateway's panel and CLI show for the fediverse role ─────────────────────────────
test('★ metrics: users, queue, inbound and registrations, read from what the bridge already keeps', async (t) => {
  const b = await startApBridge(configFor(dataDir()), { bootstrap: [], log: () => {} })
  t.teardown(() => stop(b))
  const m = await b.metrics()
  t.alike(m.users.map((u) => u.username), ['p2p'], 'the primary, by name')
  t.is(m.users[0].primary, true)
  t.is(m.users[0].followers, 0, 'followers counted')
  t.is(m.users[0].delivered, 0, 'delivered counted from the publisher state')
  t.alike(m.queue, { pending: 0, dead: 0, oldestPendingAt: null, failing: [] }, 'an empty delivery queue')
  t.alike(m.inbound, { count: 0, lastReceivedAt: null })
  t.is(m.registrations, 0)
})