OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / bare-relay-surface.test.js

210 lines · javascript ref:
/**
 * BareRelay surface smoke test.
 *
 * We cannot exercise BareRelay end-to-end under Node because the file
 * declares imports for `bare-events`, `bare-fs/promises`, `bare-path` which
 * the package.json `imports` map redirects to Node built-ins under the
 * `default` condition. This test verifies that:
 *
 *   1. The BareRelay module loads at all under Node (no missing deps,
 *      no broken exports map).
 *   2. The class has the API surface the Pear runtime expects.
 *   3. Shared accept-mode helpers behave identically in BareRelay and
 *      RelayNode — this matters because both runtimes apply the same
 *      policy module and a divergence would silently break parity.
 *
 * End-to-end testing of the actual Bare runtime path requires running under
 * `bare` or `pear run` and is covered by manual operator verification, not
 * this suite.
 */

import test from 'brittle'
import { readFile, rm, stat } from 'fs/promises'
import { randomBytes } from 'crypto'
import { join } from 'path'
import { tmpdir } from 'os'
import { BareRelay } from 'p2p-hiverelay/core/relay-node/bare-relay.js'

function tmpStorage () {
  return join(tmpdir(), 'hiverelay-bare-identity-' + randomBytes(8).toString('hex'))
}

function deferred () {
  let resolvePromise
  const promise = new Promise(resolve => { resolvePromise = resolve })
  return { promise, resolve: resolvePromise }
}

test('bare lifecycle: concurrent start callers share one exact completion authority', async (t) => {
  const gate = deferred()
  const relay = Object.create(BareRelay.prototype)
  Object.assign(relay, {
    running: false,
    _starting: false,
    _stopping: null,
    _startCompletion: null,
    async _startLifecycle () {
      await gate.promise
      this.running = true
      return this
    }
  })

  const first = relay.start()
  const concurrent = relay.start()
  t.is(concurrent, first, 'concurrent Bare caller receives the exact first promise')
  gate.resolve()
  t.is(await first, relay, 'shared completion resolves to the exact Bare relay')
  t.is(relay.start(), first, 'already-running Bare start returns the completed authority')

  const failureGate = deferred()
  const failure = new Error('injected bare startup failure')
  const failing = Object.create(BareRelay.prototype)
  Object.assign(failing, {
    running: false,
    _starting: false,
    _stopping: null,
    _startCompletion: null,
    async _startLifecycle () {
      await failureGate.promise
      throw failure
    }
  })
  const rejectedFirst = failing.start()
  const rejectedConcurrent = failing.start()
  t.is(rejectedConcurrent, rejectedFirst, 'rejected Bare callers share exact promise')
  failureGate.resolve()
  let observed = null
  try { await rejectedConcurrent } catch (err) { observed = err }
  t.is(observed, failure, 'shared Bare rejection preserves exact startup error')
})

test('bare smoke: BareRelay module imports cleanly under Node via the imports map', (t) => {
  t.ok(BareRelay, 'BareRelay class is exported')
  t.is(typeof BareRelay, 'function', 'is a constructor')
})

test('bare smoke: BareRelay constructs with minimal config', (t) => {
  const relay = new BareRelay({ storage: '/tmp/bare-smoke-' + Date.now() })
  t.ok(relay, 'instance constructed')
  t.is(relay.running, false, 'not running before start()')
  t.is(typeof relay.start, 'function', 'has start')
  t.is(typeof relay.stop, 'function', 'has stop')
  t.is(typeof relay._resolveAcceptMode, 'function', 'shares accept-mode resolver with RelayNode')
  t.is(typeof relay._decideAcceptance, 'function', 'shares accept-mode decision with RelayNode')
})

test('bare smoke: default config matches Bare-appropriate defaults', (t) => {
  const relay = new BareRelay({ storage: '/tmp/bare-smoke-' + Date.now() })
  // Bare has no operator TUI to drain a review queue, so the default mode
  // must NOT be 'review' or every inbound seed would block forever.
  t.is(relay.config.acceptMode, 'open', 'Bare default is open (Pear operator opts into tighter modes explicitly)')
  t.is(relay.config.enableSeeding, true)
  t.is(relay.config.enableRelay, true)
  t.is(relay.config.catalogSync, true)
})

test('bare smoke: identity seed file is created owner-only and reloads', async (t) => {
  const storage = tmpStorage()
  const relay = new BareRelay({ storage })
  t.teardown(async () => {
    try { await relay.store.close() } catch (_) {}
    await rm(storage, { recursive: true, force: true })
  })

  const first = await relay._deriveKeypair()
  const mode = (await stat(join(storage, 'identity.key'))).mode & 0o777
  const second = await relay._deriveKeypair()

  t.is(mode, 0o600, 'Bare identity seed is owner-read/write only')
  t.alike(second.publicKey, first.publicKey, 'public key reloads from disk')
  t.alike(second.secretKey, first.secretKey, 'secret key reloads from disk')
})

test('bare smoke: identity temp path avoids Node-only process globals', async (t) => {
  const source = await readFile(new URL('../../packages/core/core/relay-node/bare-relay.js', import.meta.url), 'utf8')
  t.ok(source.includes('function identityTempPath (keyPath)'), 'uses a portable temp-path helper')
  t.absent(source.includes('process.pid'), 'Bare identity path does not depend on Node process.pid')
})

test('bare smoke: accept-mode resolver gives same answer as RelayNode for same config', async (t) => {
  // Symmetry check — if these ever diverge, the two runtimes silently apply
  // different policies for identical config. That would be a security bug.
  const bare = new BareRelay({ storage: '/tmp/bare-symm-' + Date.now() })
  const { RelayNode } = await import('p2p-hiverelay/core/relay-node/index.js')
  const node = new RelayNode({ storage: '/tmp/node-symm-' + Date.now() })

  const cases = [
    { acceptMode: 'open', expect: 'open' },
    { acceptMode: 'review', expect: 'review' },
    { acceptMode: 'allowlist', expect: 'allowlist' },
    { acceptMode: 'closed', expect: 'closed' },
    { registryAutoAccept: true, expect: 'open' },
    { registryAutoAccept: false, expect: 'review' }
  ]

  for (const { expect, ...cfg } of cases) {
    Object.assign(bare.config, cfg)
    Object.assign(node.config, cfg)
    delete bare.config.acceptMode
    delete node.config.acceptMode
    Object.assign(bare.config, cfg)
    Object.assign(node.config, cfg)

    const bareMode = bare._resolveAcceptMode()
    const nodeMode = node._resolveAcceptMode()
    t.is(bareMode, nodeMode, `runtimes agree for config ${JSON.stringify(cfg)}`)
    t.is(bareMode, expect, `result is ${expect} as expected`)
  }
})

test('bare smoke: federation module is wired (instance exists pre-start)', (t) => {
  const relay = new BareRelay({ storage: '/tmp/bare-fed-' + Date.now() })
  // Federation is constructed inside start() in BareRelay, not in the
  // constructor — verify the property exists and starts null.
  t.is(relay.federation, null, 'federation slot exists, lazily constructed in start()')
})

test('bare lifecycle destroys every protocol owner and retains rejected teardown for retry', async (t) => {
  const relay = new BareRelay({ storage: '/tmp/bare-protocol-' + Date.now() })
  const events = []
  let rejectProof = true
  relay._serviceProtocol = { destroy () { events.push('service') } }
  relay._seedProtocol = { destroy () { events.push('seed') } }
  relay._circuitRelay = { destroy () { events.push('circuit') } }
  const proof = {
    destroy () {
      events.push('proof')
      if (rejectProof) throw new Error('injected proof destroy failure')
    }
  }
  relay._proofOfRelay = proof

  await t.exception(relay._destroyProtocolHandlers(100), /injected proof destroy failure/)
  t.alike(events, ['service', 'seed', 'circuit', 'proof'])
  t.is(relay._serviceProtocol, null)
  t.is(relay._seedProtocol, null)
  t.is(relay._circuitRelay, null)
  t.is(relay._proofOfRelay, proof, 'rejected owner remains reachable')

  rejectProof = false
  await relay._destroyProtocolHandlers(100)
  t.is(relay._proofOfRelay, null)
})

test('bare lifecycle drains scope then tears down every seeded drive before store ownership', async (t) => {
  const relay = new BareRelay({ storage: '/tmp/bare-drive-' + Date.now() })
  const events = []
  relay._scope = { async drain () { events.push('scope-drained') } }
  relay.appRegistry = { apps: new Map([['a'.repeat(64), {}], ['b'.repeat(64), {}]]) }
  relay.appLifecycle = {
    async unseedApp (key, opts) {
      events.push('unseed-' + key[0] + '-' + String(opts.forget))
    }
  }

  await relay._drainLifecycleAndSeededDrives(100)
  t.alike(events, ['scope-drained', 'unseed-a-false', 'unseed-b-false'])
  t.is(relay._scope, null)
})