OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / client-quorum-fork-integration.test.js

912 lines · javascript ref:
/**
 * Integration tests for HiveRelayClient's v0.6.0 security additions:
 * QuorumSelector, ForkDetector, and the new public methods that wire
 * them into the client (refreshCapabilityCache, selectQuorum,
 * queryQuorum, queryQuorumWithComparison, isDriveQuarantined,
 * quarantine-aware open()).
 *
 * Test approach:
 *   - Construct HiveRelayClient WITHOUT calling start() — none of the
 *     new methods require the swarm to be running, and skipping start()
 *     keeps the tests fast and deterministic.
 *   - Inject a mock fetch via globalThis.fetch swap so we can simulate
 *     any relay topology without hitting real HTTP.
 *   - For the open() quarantine test, set up the ForkDetector
 *     manually (since we're not calling start()) and assert open()
 *     refuses without force:true.
 */

import test from 'brittle'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import { HiveRelayClient } from 'p2p-hiverelay-client'
import { buildCapabilityDoc } from 'p2p-hiverelay/core/capability-doc.js'
import { ForkDetector } from 'p2p-hiverelay/core/fork-detector.js'

// Generate a default keypair for tests that need signed operations
// (e.g., publishForkProof). Tests can override by passing keyPair: ...
// in opts.
function defaultKeyPair () {
  const publicKey = b4a.alloc(32)
  const secretKey = b4a.alloc(64)
  sodium.crypto_sign_keypair(publicKey, secretKey)
  return { publicKey, secretKey }
}

// ─── Mock fetch helpers ──────────────────────────────────────────

const REAL_FETCH = globalThis.fetch

function installMockFetch (responses) {
  globalThis.fetch = async (url) => {
    const handler = responses[url]
    if (!handler) {
      return mockResponse({ ok: false, status: 404, body: { error: 'not found in mock' } })
    }
    if (typeof handler === 'function') return handler(url)
    return mockResponse(handler)
  }
}

function restoreFetch () {
  globalThis.fetch = REAL_FETCH
}

function mockResponse ({ ok = true, status = 200, body = {}, error = null }) {
  return {
    ok,
    status,
    text: async () => {
      if (error) throw new Error(error)
      return body === null ? '' : JSON.stringify(body)
    }
  }
}

function makeClient (opts = {}) {
  return new HiveRelayClient({
    storage: null,
    autoDiscover: false,
    swarm: {}, // dummy — we never call start()
    store: {}, // dummy
    keyPair: defaultKeyPair(), // signed ops need a real key
    ...opts
  })
}

// Sample capability docs for a 5-region relay set
const CAP_US_EAST = {
  schemaVersion: 1,
  pubkey: 'aa'.repeat(32),
  region: 'us-east-1',
  features: ['capability-doc', 'federation', 'delegation-certs']
}
const CAP_US_EAST_OP_A_TWO = {
  schemaVersion: 1,
  pubkey: 'bb'.repeat(32),
  region: 'us-east-1',
  operator: 'aa'.repeat(32), // same operator as the previous, different relay
  features: ['capability-doc', 'federation']
}
const CAP_EU_WEST = {
  schemaVersion: 1,
  pubkey: 'cc'.repeat(32),
  region: 'eu-west',
  features: ['capability-doc', 'federation', 'delegation-certs', 'delegation-revocation']
}
const CAP_ASIA_TOKYO = {
  schemaVersion: 1,
  pubkey: 'dd'.repeat(32),
  region: 'asia-tokyo',
  features: ['capability-doc']
}
const CAP_SA_EAST = {
  schemaVersion: 1,
  pubkey: 'ee'.repeat(32),
  region: 'sa-east',
  features: ['capability-doc', 'federation']
}

// ─── refreshCapabilityCache ──────────────────────────────────────

test('refreshCapabilityCache fetches all relays and populates cache', async (t) => {
  installMockFetch({
    'https://relay-a.test/.well-known/hiverelay.json': { body: CAP_US_EAST },
    'https://relay-b.test/.well-known/hiverelay.json': { body: CAP_EU_WEST }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  const results = await client.refreshCapabilityCache([
    'https://relay-a.test',
    'https://relay-b.test'
  ])
  t.is(results.length, 2)
  t.is(results[0].pubkey, CAP_US_EAST.pubkey)
  t.is(results[0].region, 'us-east-1')
  t.is(results[1].pubkey, CAP_EU_WEST.pubkey)
})

test('refreshCapabilityCache honors TTL on subsequent calls', async (t) => {
  let fetchCount = 0
  installMockFetch({
    'https://relay-a.test/.well-known/hiverelay.json': () => {
      fetchCount++
      return mockResponse({ body: CAP_US_EAST })
    }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  await client.refreshCapabilityCache(['https://relay-a.test'])
  await client.refreshCapabilityCache(['https://relay-a.test'])
  t.is(fetchCount, 1, 'second call hit the cache')

  await client.refreshCapabilityCache(['https://relay-a.test'], { force: true })
  t.is(fetchCount, 2, 'force:true bypasses cache')
})

test('refreshCapabilityCache emits capability-fetch-error on unreachable relay', async (t) => {
  installMockFetch({
    'https://relay-a.test/.well-known/hiverelay.json': { body: CAP_US_EAST },
    'https://relay-bad.test/.well-known/hiverelay.json': { ok: false, status: 503 }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  let errored = null
  client.on('capability-fetch-error', (e) => { errored = e })

  // The fallback /api/capabilities will also 404 in our mock, so
  // fetchCapabilities throws — and refreshCapabilityCache catches.
  const results = await client.refreshCapabilityCache([
    'https://relay-a.test',
    'https://relay-bad.test'
  ])
  // The good relay is in the result; the bad one isn't (it threw)
  t.is(results.length, 1)
  t.is(results[0].pubkey, CAP_US_EAST.pubkey)
  t.ok(errored)
  t.is(errored.url, 'https://relay-bad.test')
})

test('refreshCapabilityCache rejects non-array input', async (t) => {
  const client = makeClient()
  try {
    await client.refreshCapabilityCache('not-an-array')
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('must be an array'))
  }
})

// ─── selectQuorum ─────────────────────────────────────────────────

test('selectQuorum picks diverse relays from cached capability docs', async (t) => {
  installMockFetch({
    'https://relay-a.test/.well-known/hiverelay.json': { body: CAP_US_EAST },
    'https://relay-b.test/.well-known/hiverelay.json': { body: CAP_US_EAST_OP_A_TWO },
    'https://relay-c.test/.well-known/hiverelay.json': { body: CAP_EU_WEST },
    'https://relay-d.test/.well-known/hiverelay.json': { body: CAP_ASIA_TOKYO },
    'https://relay-e.test/.well-known/hiverelay.json': { body: CAP_SA_EAST }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  await client.refreshCapabilityCache([
    'https://relay-a.test',
    'https://relay-b.test',
    'https://relay-c.test',
    'https://relay-d.test',
    'https://relay-e.test'
  ])
  const quorum = client.selectQuorum({ size: 4 })
  t.is(quorum.length, 4)
  const regions = new Set(quorum.map(r => r.region))
  t.ok(regions.size >= 3, 'covers >=3 regions')
})

test('selectQuorum surfaces quorum-warning on insufficient diversity', async (t) => {
  installMockFetch({
    'https://relay-a.test/.well-known/hiverelay.json': { body: CAP_US_EAST },
    'https://relay-b.test/.well-known/hiverelay.json': { body: CAP_US_EAST_OP_A_TWO }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  let warning = null
  client.on('quorum-warning', (w) => { warning = w })

  await client.refreshCapabilityCache([
    'https://relay-a.test',
    'https://relay-b.test'
  ])
  client.selectQuorum({ size: 2, minRegions: 3 })
  t.ok(warning)
  t.is(warning.reason, 'insufficient-quorum-diversity')
})

test('selectQuorum honors foundation strategy + foundationPubkeys constructor option', async (t) => {
  installMockFetch({
    'https://relay-a.test/.well-known/hiverelay.json': { body: CAP_US_EAST },
    'https://relay-b.test/.well-known/hiverelay.json': { body: CAP_EU_WEST },
    'https://relay-c.test/.well-known/hiverelay.json': { body: CAP_ASIA_TOKYO }
  })
  t.teardown(restoreFetch)

  const client = makeClient({
    foundationPubkeys: [CAP_US_EAST.pubkey, CAP_ASIA_TOKYO.pubkey]
  })
  await client.refreshCapabilityCache([
    'https://relay-a.test',
    'https://relay-b.test',
    'https://relay-c.test'
  ])
  const quorum = client.selectQuorum({ strategy: 'foundation', size: 5 })
  t.is(quorum.length, 2)
  const pubkeys = quorum.map(r => r.pubkey).sort()
  t.alike(pubkeys, [CAP_US_EAST.pubkey, CAP_ASIA_TOKYO.pubkey].sort())
})

test('selectQuorum filters by requireFeatures', async (t) => {
  installMockFetch({
    'https://relay-a.test/.well-known/hiverelay.json': { body: CAP_US_EAST }, // has delegation-certs
    'https://relay-b.test/.well-known/hiverelay.json': { body: CAP_ASIA_TOKYO }, // doesn't
    'https://relay-c.test/.well-known/hiverelay.json': { body: CAP_EU_WEST } // does
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  await client.refreshCapabilityCache([
    'https://relay-a.test',
    'https://relay-b.test',
    'https://relay-c.test'
  ])
  const quorum = client.selectQuorum({ size: 5, requireFeatures: ['delegation-certs'] })
  t.is(quorum.length, 2)
  t.ok(quorum.every(r => r.features.includes('delegation-certs')))
})

test('describeQuorum summarizes selection', async (t) => {
  installMockFetch({
    'https://relay-a.test/.well-known/hiverelay.json': { body: CAP_US_EAST },
    'https://relay-b.test/.well-known/hiverelay.json': { body: CAP_EU_WEST }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  await client.refreshCapabilityCache(['https://relay-a.test', 'https://relay-b.test'])
  const quorum = client.selectQuorum({ size: 5, minRegions: 1 })
  const desc = client.describeQuorum(quorum)
  t.is(desc.size, 2)
  t.is(desc.regions.length, 2)
})

// ─── queryQuorum + queryQuorumWithComparison ─────────────────────

test('queryQuorum hits all quorum relays in parallel', async (t) => {
  installMockFetch({
    'https://relay-a.test/.well-known/hiverelay.json': { body: CAP_US_EAST },
    'https://relay-b.test/.well-known/hiverelay.json': { body: CAP_EU_WEST },
    'https://relay-a.test/api/info': { body: { count: 5 } },
    'https://relay-b.test/api/info': { body: { count: 5 } }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  await client.refreshCapabilityCache(['https://relay-a.test', 'https://relay-b.test'])
  const quorum = client.selectQuorum({ size: 5, minRegions: 1 })
  const responses = await client.queryQuorum('/api/info', quorum)
  t.is(responses.length, 2)
  t.ok(responses.every(r => r.ok))
  t.ok(responses.every(r => r.body.count === 5))
})

test('queryQuorum validates inputs', async (t) => {
  const client = makeClient()
  try {
    await client.queryQuorum('no-leading-slash', [{ url: 'https://x' }])
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('must start with /'))
  }
  try {
    await client.queryQuorum('/x', [])
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('non-empty array'))
  }
})

test('queryQuorumWithComparison detects divergent values across relays', async (t) => {
  installMockFetch({
    'https://relay-a.test/.well-known/hiverelay.json': { body: CAP_US_EAST },
    'https://relay-b.test/.well-known/hiverelay.json': { body: CAP_EU_WEST },
    'https://relay-a.test/api/drive': { body: { length: 100, version: 5 } },
    'https://relay-b.test/api/drive': { body: { length: 100, version: 7 } }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  let divergenceEvent = null
  client.on('quorum-divergence', (info) => { divergenceEvent = info })

  await client.refreshCapabilityCache(['https://relay-a.test', 'https://relay-b.test'])
  const quorum = client.selectQuorum({ size: 5, minRegions: 1 })
  const result = await client.queryQuorumWithComparison('/api/drive', quorum, {
    compareFields: ['length', 'version']
  })
  t.is(result.divergent.length, 1)
  t.alike(result.divergent[0].fields, ['version'])
  t.ok(divergenceEvent)
})

test('queryQuorumWithComparison + driveKey records fork evidence', async (t) => {
  installMockFetch({
    'https://relay-a.test/.well-known/hiverelay.json': { body: CAP_US_EAST },
    'https://relay-b.test/.well-known/hiverelay.json': { body: CAP_EU_WEST },
    'https://relay-a.test/api/drive': { body: { length: 100, version: 5 } },
    'https://relay-b.test/api/drive': { body: { length: 100, version: 7 } }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  // Manually wire ForkDetector since we don't call start()
  client.forkDetector = new ForkDetector({})

  await client.refreshCapabilityCache(['https://relay-a.test', 'https://relay-b.test'])
  const quorum = client.selectQuorum({ size: 5, minRegions: 1 })
  const driveKey = 'a'.repeat(64)
  await client.queryQuorumWithComparison('/api/drive', quorum, {
    compareFields: ['version'],
    driveKey
  })
  t.ok(client.isDriveQuarantined(driveKey), 'fork detector recorded the divergence')
})

test('queryQuorumWithComparison no-op when too few responses or no compare fields', async (t) => {
  installMockFetch({
    'https://relay-a.test/.well-known/hiverelay.json': { body: CAP_US_EAST },
    'https://relay-b.test/.well-known/hiverelay.json': { body: CAP_EU_WEST },
    'https://relay-a.test/api/x': { body: { v: 1 } },
    'https://relay-b.test/api/x': { body: { v: 2 } }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  await client.refreshCapabilityCache(['https://relay-a.test', 'https://relay-b.test'])
  const quorum = client.selectQuorum({ size: 5, minRegions: 1 })
  // No compareFields → no divergence reported even though responses differ
  const r = await client.queryQuorumWithComparison('/api/x', quorum, { compareFields: [] })
  t.is(r.divergent.length, 0)
})

// ─── isDriveQuarantined + open() quarantine ──────────────────────

test('isDriveQuarantined returns false when no ForkDetector exists', async (t) => {
  const client = makeClient()
  // forkDetector is null by default until start()
  t.absent(client.isDriveQuarantined('a'.repeat(64)))
})

test('isDriveQuarantined returns true after fork is reported', async (t) => {
  const client = makeClient()
  client.forkDetector = new ForkDetector({})
  const driveKey = 'a'.repeat(64)
  client.forkDetector.report({
    hypercoreKey: driveKey,
    blockIndex: 0,
    evidenceA: { fromRelay: 'r1', block: 'b1', signature: 's1' },
    evidenceB: { fromRelay: 'r2', block: 'b2', signature: 's2' }
  })
  t.ok(client.isDriveQuarantined(driveKey))
  // Resolved drive is no longer quarantined
  client.forkDetector.resolve(driveKey, { resolution: 'rotated' })
  t.absent(client.isDriveQuarantined(driveKey))
})

test('isDriveQuarantined accepts both hex string and Buffer keys', async (t) => {
  const client = makeClient()
  client.forkDetector = new ForkDetector({})
  const driveKey = 'a'.repeat(64)
  client.forkDetector.report({
    hypercoreKey: driveKey,
    blockIndex: 0,
    evidenceA: { fromRelay: 'r1', block: 'b1', signature: 's1' },
    evidenceB: { fromRelay: 'r2', block: 'b2', signature: 's2' }
  })
  t.ok(client.isDriveQuarantined(driveKey))
  t.ok(client.isDriveQuarantined(Buffer.from(driveKey, 'hex')))
})

// open() quarantine — verifies the check WITHOUT actually opening a
// Hyperdrive (which needs a real corestore). We rely on the fact
// that the quarantine check throws BEFORE any drive logic runs.

test('open() refuses to open a quarantined drive', async (t) => {
  const client = makeClient()
  // Force _started=true so _ensureStarted doesn't throw
  client._started = true
  client.forkDetector = new ForkDetector({})
  const driveKey = 'a'.repeat(64)
  client.forkDetector.report({
    hypercoreKey: driveKey,
    blockIndex: 0,
    evidenceA: { fromRelay: 'r1', block: 'b1', signature: 's1' },
    evidenceB: { fromRelay: 'r2', block: 'b2', signature: 's2' }
  })
  try {
    await client.open(driveKey)
    t.fail('should have refused')
  } catch (err) {
    t.is(err.code, 'DRIVE_QUARANTINED')
    t.is(err.driveKey, driveKey)
  }
})

test('open() with force:true bypasses quarantine — but Hyperdrive load fails on null store', async (t) => {
  const client = makeClient()
  client._started = true
  client.forkDetector = new ForkDetector({})
  const driveKey = 'a'.repeat(64)
  client.forkDetector.report({
    hypercoreKey: driveKey,
    blockIndex: 0,
    evidenceA: { fromRelay: 'r1', block: 'b1', signature: 's1' },
    evidenceB: { fromRelay: 'r2', block: 'b2', signature: 's2' }
  })
  // With force:true, the quarantine check passes, and the next thing
  // open() does is construct a Hyperdrive — which fails because our
  // mock store {} isn't a real corestore. The error MUST NOT be
  // DRIVE_QUARANTINED — that's the test.
  try {
    await client.open(driveKey, { force: true })
    t.fail('expected the Hyperdrive construction to fail in this mocked harness')
  } catch (err) {
    t.absent(err.code === 'DRIVE_QUARANTINED', 'force:true bypassed quarantine check')
  }
})

// ─── Resolved fork no longer blocks open() ───────────────────────

test('open() permits drives whose forks have been resolved', async (t) => {
  const client = makeClient()
  client._started = true
  client.forkDetector = new ForkDetector({})
  const driveKey = 'a'.repeat(64)
  client.forkDetector.report({
    hypercoreKey: driveKey,
    blockIndex: 0,
    evidenceA: { fromRelay: 'r1', block: 'b1', signature: 's1' },
    evidenceB: { fromRelay: 'r2', block: 'b2', signature: 's2' }
  })
  client.forkDetector.resolve(driveKey, { resolution: 'rotated' })
  // After resolution, open() doesn't throw DRIVE_QUARANTINED — it
  // proceeds and only fails when Hyperdrive construction fails on
  // our mock store. Same pattern as the previous test.
  try {
    await client.open(driveKey)
    t.fail('expected harness-level failure')
  } catch (err) {
    t.absent(err.code === 'DRIVE_QUARANTINED')
  }
})

// ─── Concern 3 (force:true audit trail) ──────────────────────────

test('open(force:true) records a bypass entry in the audit log', async (t) => {
  const client = makeClient()
  client._started = true
  client.forkDetector = new ForkDetector({})
  const driveKey = 'a'.repeat(64)
  client.forkDetector.report({
    hypercoreKey: driveKey,
    blockIndex: 0,
    evidenceA: { fromRelay: 'r1', block: 'b1', signature: 's1' },
    evidenceB: { fromRelay: 'r2', block: 'b2', signature: 's2' }
  })
  let bypassEvent = null
  client.forkDetector.on('quarantine-bypassed', (e) => { bypassEvent = e })

  // Use force:true; expect a non-quarantine error from the Hyperdrive
  // construction that follows, but the bypass should already be logged
  // by then.
  try { await client.open(driveKey, { force: true, bypassReason: 'recovering content from key-rotation event' }) } catch (_) {}

  t.ok(bypassEvent, 'quarantine-bypassed event fired')
  t.is(bypassEvent.hypercoreKey, driveKey)
  t.is(bypassEvent.note, 'recovering content from key-rotation event')

  const log = client.forkDetector.bypassLog()
  t.is(log.length, 1)
  t.is(log[0].caller, 'client.open')
})

test('forkDetector.recordBypass enforces hex-key validation', async (t) => {
  const fd = new ForkDetector({})
  const r = fd.recordBypass({ hypercoreKey: 'not-hex' })
  t.absent(r.ok)
})

test('forkDetector bypassLog persists across save/load cycles', async (t) => {
  const { tmpdir } = await import('os')
  const { join } = await import('path')
  const { mkdtemp, rm } = await import('fs/promises')
  const dir = await mkdtemp(join(tmpdir(), 'fd-bypass-'))
  t.teardown(async () => { try { await rm(dir, { recursive: true, force: true }) } catch (_) {} })
  const path = join(dir, 'forks.json')

  const fd1 = new ForkDetector({ storagePath: path })
  fd1.recordBypass({ hypercoreKey: 'a'.repeat(64), caller: 'test', note: 'n1' })
  fd1.recordBypass({ hypercoreKey: 'b'.repeat(64), caller: 'test', note: 'n2' })
  await fd1.save()

  const fd2 = new ForkDetector({ storagePath: path })
  await fd2.load()
  const log = fd2.bypassLog()
  t.is(log.length, 2)
  t.is(log[0].note, 'n1')
  t.is(log[1].note, 'n2')
})

// ─── Defect 2 (auto-detect forks via Hypercore events) ───────────
//
// We can't easily exercise a real Hypercore truncate event in unit tests
// (would require a multi-peer setup). What we CAN test is that open()
// correctly attaches listeners to the underlying core when ForkDetector
// is present, and that those listeners report to ForkDetector.
//
// Strategy: stub Hyperdrive by having client.open() reach a synthetic
// drive whose core fires the truncate event when we manually emit it.

test('open() attaches truncate + verification-error listeners on drive.core', async (t) => {
  // We're testing the listener-attachment behavior. Build a fake "drive"
  // and test the listener-wiring logic in isolation by reaching into
  // _driveForkListeners.
  const client = makeClient()
  client._started = true
  client.forkDetector = new ForkDetector({})

  // Bypass the real Hyperdrive construction by pre-populating
  // this.drives with a fake drive that exposes a core EventEmitter.
  // Then call the listener-attach code path directly via a small
  // helper (we extract it conceptually — for the integration test
  // we just verify the contract: when fork events fire, they
  // become ForkDetector reports).
  //
  // Since wiring is inside open(), and open() requires a real swarm,
  // this test just verifies the ForkDetector wiring exists and works
  // when invoked. Full E2E coverage would require an integration test
  // with two real peers — out of scope for unit tests.

  // Direct test: simulate the listener body firing
  const driveKey = 'c'.repeat(64)
  let detectedFork = null
  client.on('drive-fork-detected', (e) => { detectedFork = e })

  // Manually invoke what the listener would do
  client.forkDetector.report({
    hypercoreKey: driveKey,
    blockIndex: 5,
    evidenceA: { fromRelay: 'local', block: 'pre', signature: 'sigA' },
    evidenceB: { fromRelay: 'replication', block: 'post-fork-2', signature: 'sigB' }
  })
  client.emit('drive-fork-detected', { driveKey, newLength: 5, fork: 2 })

  t.ok(client.isDriveQuarantined(driveKey))
  t.ok(detectedFork)
  t.is(detectedFork.driveKey, driveKey)
})

// ─── Pubkey pinning via knownRelays registry ─────────────────────

test('pinRelay + fetchCapabilities auto-injects expectedPubkey', async (t) => {
  const PUBKEY_GOOD = 'aa'.repeat(32)
  const PUBKEY_BAD = 'bb'.repeat(32)
  installMockFetch({
    'https://pinned.test/.well-known/hiverelay.json': {
      body: { schemaVersion: 1, pubkey: PUBKEY_GOOD, features: [] }
    },
    'https://imposter.test/.well-known/hiverelay.json': {
      body: { schemaVersion: 1, pubkey: PUBKEY_BAD, features: [] }
    }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  client.pinRelay('https://pinned.test', PUBKEY_GOOD)
  client.pinRelay('https://imposter.test', PUBKEY_GOOD) // we EXPECT good but they serve bad

  // Pinned relay matches → succeeds
  const goodDoc = await client.fetchCapabilities('https://pinned.test')
  t.is(goodDoc.pubkey, PUBKEY_GOOD)

  // Pinned relay serves WRONG pubkey → throws
  let mismatchEvent = null
  client.on('capability-pubkey-mismatch', (e) => { mismatchEvent = e })
  try {
    await client.fetchCapabilities('https://imposter.test')
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('pubkey mismatch'))
    t.ok(mismatchEvent)
  }
})

test('pinRelay validates inputs', async (t) => {
  const client = makeClient()
  try { client.pinRelay('', 'a'.repeat(64)); t.fail() } catch (err) { t.ok(err.message.includes('url')) }
  try { client.pinRelay('https://x', 'not-hex'); t.fail() } catch (err) { t.ok(err.message.includes('64 hex')) }
})

test('unpinRelay removes pin and reverts to TOFU', async (t) => {
  const client = makeClient()
  client.pinRelay('https://x.test', 'a'.repeat(64))
  t.is(client.pinnedRelays().length, 1)
  t.ok(client.unpinRelay('https://x.test'))
  t.is(client.pinnedRelays().length, 0)
})

test('constructor accepts initial knownRelays config', async (t) => {
  const client = makeClient({
    knownRelays: {
      'https://a.test': 'a'.repeat(64),
      'https://b.test/': 'b'.repeat(64) // trailing slash should be normalized
    }
  })
  const pinned = client.pinnedRelays()
  t.is(pinned.length, 2)
  // URLs should be normalized (no trailing slash)
  const urls = pinned.map(p => p.url).sort()
  t.alike(urls, ['https://a.test', 'https://b.test'])
})

test('caller-supplied expectedPubkey wins over registry pin', async (t) => {
  const PUBKEY_REGISTRY = 'aa'.repeat(32)
  const PUBKEY_OVERRIDE = 'cc'.repeat(32)
  installMockFetch({
    'https://x.test/.well-known/hiverelay.json': {
      body: { schemaVersion: 1, pubkey: PUBKEY_OVERRIDE, features: [] }
    }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  client.pinRelay('https://x.test', PUBKEY_REGISTRY)
  // Caller passes a DIFFERENT expectedPubkey — this should be respected
  // (registry is just the auto-default).
  const doc = await client.fetchCapabilities('https://x.test', { expectedPubkey: PUBKEY_OVERRIDE })
  t.is(doc.pubkey, PUBKEY_OVERRIDE)
})

// ─── publishForkProof ─────────────────────────────────────────────

test('publishForkProof POSTs to all relays in parallel', async (t) => {
  const calls = []
  installMockFetch({
    'https://a.test/api/forks/proof': (url) => {
      calls.push(url)
      return mockResponse({ body: { ok: true } })
    },
    'https://b.test/api/forks/proof': (url) => {
      calls.push(url)
      return mockResponse({ body: { ok: true } })
    },
    'https://c.test/api/forks/proof': (url) => {
      calls.push(url)
      return mockResponse({ ok: false, status: 503, body: { error: 'down' } })
    }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  const proof = {
    hypercoreKey: 'a'.repeat(64),
    blockIndex: 5,
    evidence: [
      { fromRelay: 'r1', block: 'b1', signature: 's1' },
      { fromRelay: 'r2', block: 'b2', signature: 's2' }
    ]
  }
  const results = await client.publishForkProof(proof, [
    'https://a.test',
    'https://b.test',
    'https://c.test'
  ])
  t.is(results.length, 3)
  t.is(calls.length, 3, 'all three relays were POSTed to')
  t.ok(results.find(r => r.relay === 'https://c.test' && !r.ok), '503 reported as not-ok')
  t.ok(results.filter(r => r.ok).length === 2)
})

test('publishForkProof validates inputs', async (t) => {
  const client = makeClient()
  try { await client.publishForkProof(null, ['x']); t.fail() } catch (err) { t.ok(err.message.includes('proof required')) }
  try { await client.publishForkProof({}, []); t.fail() } catch (err) { t.ok(err.message.includes('non-empty array')) }
})

// ─── Signed fork proof publishing (closes attack 8.2) ────────────

test('publishForkProof requires client to have an identity key', async (t) => {
  installMockFetch({})
  t.teardown(restoreFetch)
  const client = makeClient()
  client.keyPair = null // explicitly clear
  try {
    await client.publishForkProof({
      hypercoreKey: 'a'.repeat(64),
      blockIndex: 0,
      evidence: [{ fromRelay: 'r1', block: 'b1', signature: 's1' }, { fromRelay: 'r2', block: 'b2', signature: 's2' }]
    }, ['https://x.test'])
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('no identity key'))
  }
})

test('publishForkProof wraps proof in signed envelope before POST', async (t) => {
  const sodium = (await import('sodium-universal')).default
  const b4a = (await import('b4a')).default

  // Build a real keypair so signing works
  const publicKey = b4a.alloc(32)
  const secretKey = b4a.alloc(64)
  sodium.crypto_sign_keypair(publicKey, secretKey)

  let receivedBody = null
  installMockFetch({
    'https://relay.test/api/forks/proof': async (url, opts) => {
      // Store the POST body so we can inspect what was sent
      receivedBody = opts && opts.body ? JSON.parse(opts.body) : null
      return mockResponse({ body: { ok: true } })
    }
  })
  // Override our mock fetch so it captures opts
  const origFetch = globalThis.fetch
  globalThis.fetch = async (url, opts) => {
    if (url === 'https://relay.test/api/forks/proof') {
      receivedBody = opts && opts.body ? JSON.parse(opts.body) : null
      return mockResponse({ body: { ok: true } })
    }
    return mockResponse({ ok: false, status: 404, body: {} })
  }
  t.teardown(() => { globalThis.fetch = origFetch })

  const client = makeClient()
  client.keyPair = { publicKey, secretKey }
  await client.publishForkProof({
    hypercoreKey: 'a'.repeat(64),
    blockIndex: 5,
    evidence: [{ fromRelay: 'r1', block: 'b1', signature: 's1' }, { fromRelay: 'r2', block: 'b2', signature: 's2' }]
  }, ['https://relay.test'])

  t.ok(receivedBody, 'a body was POSTed')
  t.is(receivedBody.version, 1, 'envelope version 1')
  t.ok(receivedBody.proof, 'has proof inside envelope')
  t.ok(receivedBody.observer, 'has observer inside envelope')
  t.is(receivedBody.observer.pubkey, b4a.toString(publicKey, 'hex'))
  t.is(receivedBody.observer.signature.length, 128)
  t.ok(typeof receivedBody.observer.attestedAt === 'number')
})

// ─── Capability doc staleness emit ───────────────────────────────

test('fetchCapabilities emits capability-doc-stale when attestedAt is old', async (t) => {
  const oldTime = Date.now() - 48 * 60 * 60 * 1000 // 48h old
  installMockFetch({
    'https://stale.test/.well-known/hiverelay.json': {
      body: { schemaVersion: 1, pubkey: 'a'.repeat(64), features: [], attestedAt: oldTime }
    }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  let staleEvent = null
  client.on('capability-doc-stale', (e) => { staleEvent = e })

  // No threshold override → defaults to 24h, our doc is 48h old → fires
  await client.fetchCapabilities('https://stale.test')
  t.ok(staleEvent, 'capability-doc-stale fired')
  t.is(staleEvent.url, 'https://stale.test')
  t.ok(staleEvent.ageMs > 24 * 60 * 60 * 1000)
})

test('fetchCapabilities does NOT emit stale for fresh attestation', async (t) => {
  const recentTime = Date.now() - 60_000 // 1m old
  installMockFetch({
    'https://fresh.test/.well-known/hiverelay.json': {
      body: { schemaVersion: 1, pubkey: 'a'.repeat(64), features: [], attestedAt: recentTime }
    }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  let staleEvent = null
  client.on('capability-doc-stale', (e) => { staleEvent = e })
  await client.fetchCapabilities('https://fresh.test')
  t.absent(staleEvent, 'no stale event for fresh doc')
})

test('fetchCapabilities maxAgeMs override is honored', async (t) => {
  const tenMinutesOld = Date.now() - 10 * 60 * 1000
  installMockFetch({
    'https://x.test/.well-known/hiverelay.json': {
      body: { schemaVersion: 1, pubkey: 'a'.repeat(64), features: [], attestedAt: tenMinutesOld }
    }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  let staleEvent = null
  client.on('capability-doc-stale', (e) => { staleEvent = e })
  // 5-minute threshold with a 10-minute-old doc → should fire
  await client.fetchCapabilities('https://x.test', { maxAgeMs: 5 * 60 * 1000 })
  t.ok(staleEvent, 'stale fires under tighter threshold')
})

test('fetchCapabilities can reject stale signed capability docs when strict freshness is requested', async (t) => {
  const now = 1782753600000
  const doc = buildCapabilityDoc({
    relay: {
      config: {},
      swarm: { keyPair: defaultKeyPair() }
    },
    attestedAt: now - 10 * 60 * 1000
  })
  installMockFetch({
    'https://strict-stale.test/.well-known/hiverelay.json': { body: doc }
  })
  t.teardown(restoreFetch)

  const client = makeClient()
  let verifyEvent = null
  client.on('capability-verify-error', (event) => { verifyEvent = event })

  try {
    await client.fetchCapabilities('https://strict-stale.test', {
      requireFreshCapabilityDoc: true,
      maxAgeMs: 5 * 60 * 1000,
      now
    })
    t.fail('should reject stale signed capability doc')
  } catch (err) {
    t.ok(err.message.includes('capability doc attestation expired'))
    t.ok(verifyEvent, 'verification event emitted')
    t.is(verifyEvent.reason, 'capability doc attestation expired')
  }
})

test('closeDrive removes fork listeners (no leak)', async (t) => {
  const client = makeClient()
  client._started = true

  // Simulate a drive entry with attached listeners
  let truncateRemoved = false
  let verifyRemoved = false
  const fakeCore = {
    removeListener: (event, handler) => {
      if (event === 'truncate') truncateRemoved = true
      if (event === 'verification-error') verifyRemoved = true
    }
  }
  const fakeDrive = {
    discoveryKey: Buffer.alloc(32),
    close: async () => {}
  }
  const driveKey = 'd'.repeat(64)
  client.drives.set(driveKey, fakeDrive)
  client._driveForkListeners = new Map()
  client._driveForkListeners.set(driveKey, {
    core: fakeCore,
    onTruncate: () => {},
    onVerifyError: () => {}
  })
  // swarm.leave is a no-op stub
  client.swarm = { leave: async () => {} }

  await client.closeDrive(driveKey)
  t.ok(truncateRemoved, 'truncate listener removed')
  t.ok(verifyRemoved, 'verification-error listener removed')
  t.absent(client._driveForkListeners.has(driveKey))
})