OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / integration / network.test.js

555 lines · javascript ref:
import test from 'brittle'
import createTestnet from '@hyperswarm/testnet'
import Hyperdrive from 'hyperdrive'
import { RelayNode } from 'p2p-hiverelay/core/relay-node/index.js'
import b4a from 'b4a'
import path from 'path'
import { tmpdir } from 'os'
import { randomBytes } from 'crypto'
import { mkdirSync } from 'fs'

const TEST_MAX_STORAGE_BYTES = 64 * 1024 * 1024

function tmpStorage () {
  const storage = path.join(tmpdir(), 'hiverelay-integ-' + randomBytes(8).toString('hex'))
  mkdirSync(storage, { recursive: true })
  return storage
}

function createNode (testnet, overrides = {}) {
  return new RelayNode({
    storage: tmpStorage(),
    bootstrapNodes: testnet.bootstrap,
    enableAPI: false,
    enableMetrics: false,
    ...overrides
  })
}

async function waitFor (fn, timeoutMs = 15000, intervalMs = 200) {
  const start = Date.now()
  while ((Date.now() - start) < timeoutMs) {
    if (await fn()) return true
    await new Promise(resolve => setTimeout(resolve, intervalMs))
  }
  return false
}

async function authorLocalDrive (node, files) {
  const writer = new Hyperdrive(node.store.session())
  let keyHex = null
  try {
    await writer.ready()
    for (const [filePath, content] of Object.entries(files)) {
      await writer.put(filePath, content)
    }
    keyHex = b4a.toString(writer.key, 'hex')
  } finally {
    await writer.close()
  }

  const probe = new Hyperdrive(node.store.session(), b4a.from(keyHex, 'hex'))
  try {
    await probe.ready()
    if (probe.version <= 0) throw new Error('authored drive did not reopen by key')
  } finally {
    await probe.close()
  }
  return keyHex
}

// ─── Two-node discovery ────────────────────────────────────────────

test('integration: two nodes discover each other via DHT', async (t) => {
  const testnet = await createTestnet(3)
  const nodeA = createNode(testnet)
  const nodeB = createNode(testnet)

  t.teardown(async () => {
    await nodeA.stop()
    await nodeB.stop()
    await testnet.destroy()
  })

  await nodeA.start()
  await nodeB.start()

  t.ok(nodeA.running, 'node A running')
  t.ok(nodeB.running, 'node B running')

  const keyA = b4a.toString(nodeA.swarm.keyPair.publicKey, 'hex')
  const keyB = b4a.toString(nodeB.swarm.keyPair.publicKey, 'hex')
  t.not(keyA, keyB, 'nodes have different public keys')
})

// ─── Hypercore replication between nodes ───────────────────────────

test('integration: node A writes Hypercore, node B replicates it', async (t) => {
  const testnet = await createTestnet(3)
  const nodeA = createNode(testnet)
  const nodeB = createNode(testnet)

  t.teardown(async () => {
    await nodeA.stop()
    await nodeB.stop()
    await testnet.destroy()
  })

  await nodeA.start()
  await nodeB.start()

  // Node A creates a Hypercore and writes data
  const coreA = nodeA.store.get({ name: 'test-core' })
  await coreA.ready()
  await coreA.append(b4a.from('hello'))
  await coreA.append(b4a.from('world'))

  // Node A joins the topic so others can find it
  const topic = coreA.discoveryKey
  nodeA.swarm.join(topic, { server: true, client: true })
  await nodeA.swarm.flush()

  // Node B gets the same core by key and joins the topic
  const coreB = nodeB.store.get({ key: coreA.key })
  nodeB.swarm.join(topic, { server: true, client: true })
  await nodeB.swarm.flush()

  // Wait for replication
  await coreB.ready()
  await coreB.update({ wait: true })

  t.is(coreB.length, 2, 'node B has both blocks')

  const block0 = await coreB.get(0)
  const block1 = await coreB.get(1)
  t.ok(b4a.equals(block0, b4a.from('hello')), 'block 0 matches')
  t.ok(b4a.equals(block1, b4a.from('world')), 'block 1 matches')
})

// ─── seedApp replication ───────────────────────────────────────────

test('integration: seedApp makes Hyperdrive available to other nodes', async (t) => {
  const testnet = await createTestnet(3)
  const publisher = createNode(testnet)
  const seeder = createNode(testnet)

  t.teardown(async () => {
    await publisher.stop()
    await seeder.stop()
    await testnet.destroy()
  })

  await publisher.start()

  // Publisher creates a Hyperdrive and writes a file
  const drive = new Hyperdrive(publisher.store)
  await drive.ready()
  await drive.put('/readme.txt', b4a.from('HiveRelay test file'))

  const appKeyHex = b4a.toString(drive.key, 'hex')

  // Announce the drive
  publisher.swarm.join(drive.discoveryKey, { server: true, client: true })
  await publisher.swarm.flush()

  // Seeder starts and seeds the app by key
  await seeder.start()
  const result = await seeder.seedApp(appKeyHex, { maxStorage: TEST_MAX_STORAGE_BYTES })
  t.ok(result.discoveryKey, 'seedApp returns discoveryKey')
  t.is(seeder.seededApps.size, 1, 'seeder tracks seeded app')

  // Wait for drive replication — the seeder should eventually have the data
  const seederDrive = seeder.seededApps.get(appKeyHex).drive
  await seederDrive.update({ wait: true })

  // Wait for eager download to pull file content
  let content = null
  for (let i = 0; i < 20 && !content; i++) {
    content = await seederDrive.get('/readme.txt')
    if (!content) await new Promise(resolve => setTimeout(resolve, 250))
  }
  t.ok(content, 'seeder downloaded the file')
  t.ok(b4a.equals(content, b4a.from('HiveRelay test file')), 'file content matches')
})

test('integration: seeding registry requests replicate across relays', async (t) => {
  const testnet = await createTestnet(3)
  const nodeA = createNode(testnet, { enableAPI: false, enableServices: false })
  const nodeB = createNode(testnet, { enableAPI: false, enableServices: false })

  t.teardown(async () => {
    await nodeA.stop()
    await nodeB.stop()
    await testnet.destroy()
  })

  await nodeA.start()
  await nodeB.start()

  // Force at least one direct swarm connection by sharing a temporary topic.
  const topicCore = nodeA.store.get({ name: 'registry-sync-topic' })
  await topicCore.ready()
  await topicCore.append(b4a.from('sync'))
  nodeA.swarm.join(topicCore.discoveryKey, { server: true, client: true })
  nodeB.swarm.join(topicCore.discoveryKey, { server: true, client: true })
  await nodeA.swarm.flush()
  await nodeB.swarm.flush()

  const appKey = randomBytes(32)
  const appKeyHex = appKey.toString('hex')
  await nodeA.seedingRegistry.publishRequest({
    appKey,
    discoveryKeys: [randomBytes(32)],
    replicationFactor: 1,
    geoPreference: [],
    maxStorageBytes: TEST_MAX_STORAGE_BYTES,
    bountyRate: 0,
    ttlSeconds: 3600,
    privacyTier: 'public',
    publisherPubkey: nodeA.swarm.keyPair.publicKey
  })

  let replicatedEntry = null
  const replicated = await waitFor(async () => {
    const requests = await nodeB.seedingRegistry.getActiveRequests()
    replicatedEntry = requests.find(r => r.appKey === appKeyHex) || null
    return replicatedEntry !== null
  }, 20000, 250)

  t.is(replicated, true, 'node B indexed node A registry request')
  t.is(replicatedEntry?.maxStorageBytes, TEST_MAX_STORAGE_BYTES, 'finite storage bound replicates exactly')
})

// ─── unseedApp cleanup ─────────────────────────────────────────────

test('integration: unseedApp cleans up drive and topic', async (t) => {
  const testnet = await createTestnet(3)
  const node = createNode(testnet)

  t.teardown(async () => {
    await node.stop()
    await testnet.destroy()
  })

  await node.start()

  const appKey = await authorLocalDrive(node, {
    '/unseed.txt': b4a.from('production unseed fixture')
  })
  await node.seedApp(appKey, { maxStorage: TEST_MAX_STORAGE_BYTES })
  t.is(node.seededApps.size, 1, 'app seeded')
  t.ok(node.storageAdmission.canAcknowledge(`drive:${appKey}`), 'seeded app has committed storage authority')

  await node.unseedApp(appKey)
  t.is(node.seededApps.size, 0, 'app removed after unseed')
  t.is(node.storageAdmission.canAcknowledge(`drive:${appKey}`), false, 'unseed retires storage authority ACK')
})

test('integration: peerless drive without a blob proof fails before durable admission', async (t) => {
  const testnet = await createTestnet(3)
  const node = createNode(testnet)

  t.teardown(async () => {
    await node.stop()
    await testnet.destroy()
  })

  await node.start()
  const fakeKey = randomBytes(32).toString('hex')
  const error = await node.seedApp(fakeKey, { maxStorage: TEST_MAX_STORAGE_BYTES }).then(
    () => null,
    err => err
  )

  t.ok(error, 'peerless drive is rejected')
  t.ok(
    /DRIVE_SIZE_UNRESOLVED|timeout/i.test(error?.message || '') ||
      error?.code === 'STORAGE_SIZE_PROOF_UNAVAILABLE',
    'rejection identifies unresolved authoritative drive size'
  )
  t.is(node.appRegistry.has(fakeKey), false, 'failed proof creates no durable registry entry')
  t.is(node.storageAdmission.canAcknowledge(`drive:${fakeKey}`), false, 'failed proof creates no authority ACK')
})

// ─── Connection events ─────────────────────────────────────────────

test('integration: connection events fire when peers meet', async (t) => {
  const testnet = await createTestnet(3)
  const nodeA = createNode(testnet)
  const nodeB = createNode(testnet)

  t.teardown(async () => {
    await nodeA.stop()
    await nodeB.stop()
    await testnet.destroy()
  })

  // Listen on the RelayNode event emitter
  let gotConnectionEvent = false
  nodeA.on('connection', () => { gotConnectionEvent = true })

  await nodeA.start()
  await nodeB.start()

  // Use a Hypercore to force a real connection (more reliable than raw topic join)
  const core = nodeA.store.get({ name: 'conn-test' })
  await core.ready()
  await core.append(b4a.from('test'))

  nodeA.swarm.join(core.discoveryKey, { server: true, client: true })
  await nodeA.swarm.flush()

  const coreB = nodeB.store.get({ key: core.key })
  nodeB.swarm.join(core.discoveryKey, { server: true, client: true })
  await nodeB.swarm.flush()

  await coreB.ready()
  await coreB.update({ wait: true })

  await waitFor(() => gotConnectionEvent, 1000, 25)
  t.ok(gotConnectionEvent, 'node A emitted connection event')
  t.ok(nodeA.getStats().connections >= 1, 'node A has at least 1 connection')
})

// ─── Circuit relay between streams ─────────────────────────────────

test('integration: circuit relay forwards data between two peers', async (t) => {
  const testnet = await createTestnet(3)
  const relayNode = createNode(testnet)

  t.teardown(async () => {
    await relayNode.stop()
    await testnet.destroy()
  })

  await relayNode.start()

  // Use mock stream objects that the relay's forward() function can attach to.
  // Each mock captures the data callback registered by forward() so we can
  // simulate sending data without creating feedback loops.
  let sourceDataCb = null
  const source = {
    on (event, cb) { if (event === 'data') sourceDataCb = cb },
    write () { return true },
    pause () {},
    resume () {},
    destroy () {}
  }

  const destReceived = []
  const dest = {
    on (event, cb) {},
    write (chunk) { destReceived.push(Buffer.from(chunk)); return true },
    pause () {},
    resume () {},
    destroy () {}
  }

  const circuit = relayNode.relay.createCircuit('test-circuit-1', source, dest)
  t.ok(circuit, 'circuit created')

  // Simulate source sending data — invoke the data callback the relay registered
  sourceDataCb(b4a.from('ping from source'))

  t.is(destReceived.length, 1, 'dest received one chunk')
  t.ok(b4a.equals(destReceived[0], b4a.from('ping from source')), 'data forwarded correctly')

  const stats = relayNode.relay.getStats()
  t.is(stats.activeCircuits, 1, 'one active circuit')
  t.ok(stats.totalBytesRelayed > 0, 'bytes relayed tracked')

  relayNode.relay._closeCircuit('test-circuit-1', 'TEST_DONE')
  t.is(relayNode.relay.getStats().activeCircuits, 0, 'circuit closed')
})

// ─── API integration ───────────────────────────────────────────────

test('integration: HTTP API returns health and status', async (t) => {
  const testnet = await createTestnet(3)
  const port = 9200 + Math.floor(Math.random() * 800)
  const node = createNode(testnet, { enableAPI: true, apiPort: port })

  t.teardown(async () => {
    await node.stop()
    await testnet.destroy()
  })

  await node.start()

  // Health endpoint
  const healthRes = await fetch(`http://127.0.0.1:${port}/health`)
  t.is(healthRes.status, 200, 'health returns 200')
  const health = await healthRes.json()
  t.is(health.ok, true, 'health.ok is true')
  t.is(health.running, true, 'health.running is true')

  // Status endpoint
  const statusRes = await fetch(`http://127.0.0.1:${port}/status`)
  t.is(statusRes.status, 200, 'status returns 200')
  const status = await statusRes.json()
  t.ok(status.publicKey, 'status has publicKey')
  t.is(status.seededApps, 0, 'no seeded apps')
  t.is(typeof status.connections, 'number', 'connections is number')

  // Peers endpoint
  const peersRes = await fetch(`http://127.0.0.1:${port}/peers`)
  const peers = await peersRes.json()
  t.is(typeof peers.count, 'number', 'peers.count is number')
  t.ok(Array.isArray(peers.peers), 'peers.peers is array')
})

// ─── API seed/unseed via HTTP ──────────────────────────────────────

test('integration: HTTP API seed and unseed', async (t) => {
  const testnet = await createTestnet(3)
  const port = 9200 + Math.floor(Math.random() * 800)
  const apiKey = 'test-api-key-' + randomBytes(8).toString('hex')
  const node = createNode(testnet, { enableAPI: true, apiPort: port, apiKey })

  t.teardown(async () => {
    await node.stop()
    await testnet.destroy()
  })

  await node.start()

  const appKey = await authorLocalDrive(node, {
    '/api-seed.txt': b4a.from('production API seed fixture')
  })
  const authHeaders = { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }

  // Seed via API
  const seedRes = await fetch(`http://127.0.0.1:${port}/seed`, {
    method: 'POST',
    headers: authHeaders,
    body: JSON.stringify({ appKey, maxStorageBytes: TEST_MAX_STORAGE_BYTES })
  })
  t.is(seedRes.status, 200, 'seed returns 200')
  const seedData = await seedRes.json()
  t.is(seedData.ok, true, 'seed response ok')
  t.ok(seedData.discoveryKey, 'seed returns discoveryKey')

  // Check status reflects the seeded app
  const statusRes = await fetch(`http://127.0.0.1:${port}/status`)
  const status = await statusRes.json()
  t.is(status.seededApps, 1, 'status shows 1 seeded app')

  // Unseed via API
  const unseedRes = await fetch(`http://127.0.0.1:${port}/unseed`, {
    method: 'POST',
    headers: authHeaders,
    body: JSON.stringify({ appKey })
  })
  t.is(unseedRes.status, 200, 'unseed returns 200')

  const status2Res = await fetch(`http://127.0.0.1:${port}/status`)
  const status2 = await status2Res.json()
  t.is(status2.seededApps, 0, 'status shows 0 seeded apps after unseed')
})

// ─── Three-node network ────────────────────────────────────────────

test('integration: three nodes form a mesh and replicate data', async (t) => {
  const testnet = await createTestnet(3)
  const nodeA = createNode(testnet)
  const nodeB = createNode(testnet)
  const nodeC = createNode(testnet)

  t.teardown(async () => {
    await nodeA.stop()
    await nodeB.stop()
    await nodeC.stop()
    await testnet.destroy()
  })

  await nodeA.start()
  await nodeB.start()
  await nodeC.start()

  // Node A creates a core and writes data
  const core = nodeA.store.get({ name: 'mesh-test' })
  await core.ready()
  await core.append(b4a.from('block-0'))
  await core.append(b4a.from('block-1'))
  await core.append(b4a.from('block-2'))

  const topic = core.discoveryKey
  nodeA.swarm.join(topic, { server: true, client: true })
  await nodeA.swarm.flush()

  // Both B and C join the topic and get the core
  const coreB = nodeB.store.get({ key: core.key })
  const coreC = nodeC.store.get({ key: core.key })

  nodeB.swarm.join(topic, { server: true, client: true })
  nodeC.swarm.join(topic, { server: true, client: true })
  await nodeB.swarm.flush()
  await nodeC.swarm.flush()

  await coreB.ready()
  await coreC.ready()

  await coreB.update({ wait: true })
  await coreC.update({ wait: true })

  t.is(coreB.length, 3, 'node B replicated all 3 blocks')
  t.is(coreC.length, 3, 'node C replicated all 3 blocks')

  const b2 = await coreC.get(2)
  t.ok(b4a.equals(b2, b4a.from('block-2')), 'node C has correct data')
})

// ─── getStats shape after activity ─────────────────────────────────

test('integration: getStats reflects real activity', async (t) => {
  const testnet = await createTestnet(3)
  const node = createNode(testnet)

  t.teardown(async () => {
    await node.stop()
    await testnet.destroy()
  })

  await node.start()

  const stats = node.getStats()
  t.is(stats.running, true, 'running is true')
  t.ok(stats.publicKey, 'has publicKey')
  t.is(stats.seededApps, 0, 'seededApps starts at 0')
  t.is(stats.connections, 0, 'connections starts at 0')
  t.ok(stats.relay, 'relay stats present')
  t.is(stats.relay.activeCircuits, 0, 'no active circuits')
  t.ok(stats.seeder, 'seeder stats present')
  t.is(stats.seeder.coresSeeded, 0, 'no cores seeded')
})

// ─── Start/stop lifecycle ──────────────────────────────────────────

test('integration: node start/stop/restart lifecycle', async (t) => {
  const testnet = await createTestnet(3)
  const storage = tmpStorage()
  const node = createNode(testnet, { storage })

  t.teardown(async () => {
    if (node.running) await node.stop()
    await testnet.destroy()
  })

  // Start
  await node.start()
  t.is(node.running, true, 'running after start')
  t.ok(node.getStats().publicKey, 'has key after start')

  // Stop
  await node.stop()
  t.is(node.running, false, 'stopped')

  // Restart (new node instance, same storage)
  const node2 = createNode(testnet, { storage })
  await node2.start()
  t.is(node2.running, true, 'restarted')
  t.ok(node2.getStats().publicKey, 'has key after restart')

  await node2.stop()
})