OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / seed-durability.test.js

193 lines · javascript ref:
/**
 * Tests for the accept-vs-replicate distinction added after PearBrowser
 * reported "relays accept seed but drive.core.peers stays at 0."
 *
 *   - Issue #1 fix: client.seed now uses the proper hypercore discoveryKey
 *     (keyed BLAKE2b, not plain BLAKE2b), so the discoveryKey in the
 *     signed request advertises the same DHT topic as the actual drive.
 *   - New helpers: getDurableStatus / waitForDurable surface the
 *     acceptance-vs-active-peers distinction that was previously hidden.
 */

import test from 'brittle'
import { EventEmitter } from 'events'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import hypercoreCrypto from 'hypercore-crypto'
import { HiveRelayClient } from 'p2p-hiverelay-client'

function mockSwarm () {
  const swarm = new EventEmitter()
  swarm.keyPair = { publicKey: Buffer.alloc(32, 0xaa), secretKey: null }
  swarm.connections = new Set()
  swarm.join = () => ({ destroy: () => {} })
  swarm.leave = async () => {}
  swarm.flush = async () => {}
  swarm.destroy = async () => {}
  return swarm
}

function mockDrive (core) {
  return { core, async close () {} }
}

// ─── Issue #1 — discoveryKey is now correct ──────────────────────────

test('seed() advertises the hypercore-derived discoveryKey, not a plain hash', async (t) => {
  // Track what topic the swarm joins with. This is the topic the publisher
  // announces on; if it doesn't match the drive's discoveryKey, relays
  // looking for the drive never find us.
  const joins = []
  const swarm = mockSwarm()
  swarm.join = (topic, opts) => { joins.push({ topic: b4a.toString(topic, 'hex'), opts }); return { destroy: () => {} } }

  // Keypair so signing path runs cleanly
  const pk = b4a.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
  const sk = b4a.alloc(sodium.crypto_sign_SECRETKEYBYTES)
  sodium.crypto_sign_keypair(pk, sk)
  swarm.keyPair = { publicKey: pk, secretKey: sk }

  const client = new HiveRelayClient({ swarm, keyPair: swarm.keyPair })
  await client.start()

  // Pick an arbitrary 32-byte app key; the correct discoveryKey is the
  // hypercore-crypto keyed BLAKE2b, NOT a plain BLAKE2b.
  const appKey = b4a.alloc(32, 0x42)
  const expectedHex = b4a.toString(hypercoreCrypto.discoveryKey(appKey), 'hex')
  const wrongPlainHash = b4a.alloc(32)
  sodium.crypto_generichash(wrongPlainHash, appKey)
  const wrongHex = b4a.toString(wrongPlainHash, 'hex')

  // Patch out the actual broadcast path — we're testing the topic, not the wire.
  client.seedRequests = new Map()
  await client.seed(appKey, { timeout: 50, retryPersistent: false })

  const topics = joins.map(j => j.topic)
  t.ok(topics.includes(expectedHex), 'announces on hypercore-derived discoveryKey')
  t.absent(topics.includes(wrongHex), 'does NOT announce on the plain-hash (old) discoveryKey')

  await client.destroy()
})

test('seed() accepts an explicit opts.discoveryKey override', async (t) => {
  const joins = []
  const swarm = mockSwarm()
  swarm.join = (topic, opts) => { joins.push(b4a.toString(topic, 'hex')); return { destroy: () => {} } }

  const pk = b4a.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
  const sk = b4a.alloc(sodium.crypto_sign_SECRETKEYBYTES)
  sodium.crypto_sign_keypair(pk, sk)
  swarm.keyPair = { publicKey: pk, secretKey: sk }

  const client = new HiveRelayClient({ swarm, keyPair: swarm.keyPair })
  await client.start()

  const appKey = b4a.alloc(32, 0x42)
  // Pretend the caller already has the drive and knows its discoveryKey.
  const explicit = b4a.alloc(32, 0x11)
  client.seedRequests = new Map()
  await client.seed(appKey, { discoveryKey: explicit, timeout: 50, retryPersistent: false })

  t.ok(joins.includes(b4a.toString(explicit, 'hex')), 'explicit discoveryKey honoured')

  await client.destroy()
})

// ─── getDurableStatus / waitForDurable ───────────────────────────────

test('getDurableStatus: drive not open → driveOpen:false, durable:false', async (t) => {
  const client = new HiveRelayClient({ swarm: mockSwarm() })
  await client.start()
  const status = client.getDurableStatus('a'.repeat(64))
  t.is(status.driveOpen, false)
  t.is(status.durable, false)
  t.is(status.acceptances, 0)
  t.is(status.activePeers, 0)
  await client.destroy()
})

test('getDurableStatus: drive open but no peers → not durable', async (t) => {
  const client = new HiveRelayClient({ swarm: mockSwarm() })
  await client.start()
  const keyHex = 'a'.repeat(64)
  client.drives.set(keyHex, mockDrive({ length: 10, peers: [] }))
  client.seedRequests.set(keyHex, { acceptances: [{ relayPubkey: Buffer.alloc(32) }] })

  const status = client.getDurableStatus(keyHex)
  t.is(status.driveOpen, true)
  t.is(status.acceptances, 1, 'one relay accepted')
  t.is(status.activePeers, 0, 'but nobody is actually replicating')
  t.is(status.durable, false, 'acceptance ≠ durable — this is the PearBrowser bug signature')
  await client.destroy()
})

test('getDurableStatus: at least one peer has caught up → durable', async (t) => {
  const client = new HiveRelayClient({ swarm: mockSwarm() })
  await client.start()
  const keyHex = 'b'.repeat(64)
  client.drives.set(keyHex, mockDrive({
    length: 10,
    peers: [
      { remoteLength: 10 },
      { remoteLength: 8 }
    ]
  }))

  const status = client.getDurableStatus(keyHex)
  t.is(status.activePeers, 2)
  t.is(status.byteLengthLocal, 10)
  t.is(status.byteLengthRemoteMax, 10)
  t.is(status.durable, true)
  await client.destroy()
})

test('getDurableStatus: peers present but behind local → not durable yet', async (t) => {
  const client = new HiveRelayClient({ swarm: mockSwarm() })
  await client.start()
  const keyHex = 'c'.repeat(64)
  client.drives.set(keyHex, mockDrive({
    length: 100,
    peers: [
      { remoteLength: 50 } // peer has only half our data
    ]
  }))
  const status = client.getDurableStatus(keyHex)
  t.is(status.activePeers, 1)
  t.is(status.byteLengthRemoteMax, 50)
  t.is(status.durable, false, 'peer lagging behind local length — not durable yet')
  await client.destroy()
})

test('waitForDurable: resolves as soon as durability transitions to true', async (t) => {
  const client = new HiveRelayClient({ swarm: mockSwarm() })
  await client.start()
  const keyHex = 'd'.repeat(64)
  const drive = mockDrive({ length: 5, peers: [] })
  client.drives.set(keyHex, drive)

  // After 50ms, simulate a peer catching up
  setTimeout(() => {
    drive.core.peers = [{ remoteLength: 5 }]
  }, 50)

  const status = await client.waitForDurable(keyHex, { timeoutMs: 2000, pollIntervalMs: 20 })
  t.is(status.durable, true, 'waited until durable')
  t.is(status.activePeers, 1)
  await client.destroy()
})

test('waitForDurable: returns last snapshot on timeout even if never durable', async (t) => {
  const client = new HiveRelayClient({ swarm: mockSwarm() })
  await client.start()
  const keyHex = 'e'.repeat(64)
  client.drives.set(keyHex, mockDrive({ length: 10, peers: [] })) // no peers ever

  const started = Date.now()
  const status = await client.waitForDurable(keyHex, { timeoutMs: 200, pollIntervalMs: 30 })
  const elapsed = Date.now() - started
  t.ok(elapsed >= 200, 'waited at least the timeout window')
  t.is(status.durable, false)
  t.is(status.activePeers, 0)
  await client.destroy()
})