OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / network-discovery.test.js

401 lines · javascript ref:
import test from 'brittle'
import http from 'http'
import { EventEmitter } from 'events'
import { NetworkDiscovery } from 'p2p-hiverelay/core/network-discovery.js'

const MAX_API_OVERVIEW_BYTES = 256 * 1024
const MAX_META_BYTES = 2048
const VALID_HOLESAIL_KEY = 'y'.repeat(52)

function listen (server) {
  return new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
}

function closeServer (server) {
  return new Promise(resolve => server.close(resolve))
}

async function rejects (t, promise, pattern) {
  let err = null
  try {
    await promise
  } catch (e) {
    err = e
  }
  t.ok(err, 'expected rejection')
  t.ok(pattern.test(err.message), `message matches ${pattern}`)
  return err
}

test('network discovery fetches bounded API overview JSON', async (t) => {
  const payload = {
    publicKey: 'a'.repeat(64),
    connections: 2,
    seededApps: 3
  }
  const server = http.createServer((req, res) => {
    t.is(req.url, '/api/overview')
    res.statusCode = 200
    res.setHeader('Content-Type', 'application/json')
    res.end(JSON.stringify(payload))
  })
  await listen(server)
  t.teardown(() => closeServer(server))

  const discovery = new NetworkDiscovery()
  const data = await discovery._fetchApi('127.0.0.1', server.address().port)
  t.alike(data, payload)
})

test('network discovery rejects oversized API overview content-length', async (t) => {
  const server = http.createServer((_req, res) => {
    res.statusCode = 200
    res.setHeader('Content-Type', 'application/json')
    res.setHeader('Content-Length', String(MAX_API_OVERVIEW_BYTES + 1))
    res.end('{}')
  })
  await listen(server)
  t.teardown(() => closeServer(server))

  const discovery = new NetworkDiscovery()
  await rejects(t, discovery._fetchApi('127.0.0.1', server.address().port), /Response too large/)
})

test('network discovery rejects oversized streamed API overview bodies', async (t) => {
  const server = http.createServer((_req, res) => {
    res.statusCode = 200
    res.setHeader('Content-Type', 'application/json')
    res.end(JSON.stringify({ pad: 'x'.repeat(MAX_API_OVERVIEW_BYTES) }))
  })
  await listen(server)
  t.teardown(() => closeServer(server))

  const discovery = new NetworkDiscovery()
  await rejects(t, discovery._fetchApi('127.0.0.1', server.address().port), /Response too large/)
})

test('network discovery rejects non-object API overview JSON', async (t) => {
  const server = http.createServer((_req, res) => {
    res.statusCode = 200
    res.setHeader('Content-Type', 'application/json')
    res.end('[]')
  })
  await listen(server)
  t.teardown(() => closeServer(server))

  const discovery = new NetworkDiscovery()
  await rejects(t, discovery._fetchApi('127.0.0.1', server.address().port), /Invalid JSON/)
})

test('network discovery accepts bounded z32 holesail metadata frames', (t) => {
  const pubkey = 'a'.repeat(64)
  const discovery = new NetworkDiscovery()
  const relay = { publicKey: pubkey }
  let event = null

  discovery._relays.set(pubkey, relay)
  discovery.on('relay-holesail-key', data => { event = data })

  discovery._handleMetadataFrame(
    pubkey,
    Buffer.from(JSON.stringify({ holesailKey: `hs://0000${VALID_HOLESAIL_KEY}` }))
  )

  t.is(relay.holesailKey, VALID_HOLESAIL_KEY)
  t.alike(event, { publicKey: pubkey, holesailKey: VALID_HOLESAIL_KEY })
})

test('network discovery ignores oversized holesail metadata frames', (t) => {
  const pubkey = 'b'.repeat(64)
  const discovery = new NetworkDiscovery()
  const relay = { publicKey: pubkey }
  let events = 0

  discovery._relays.set(pubkey, relay)
  discovery.on('relay-holesail-key', () => { events++ })

  discovery._handleMetadataFrame(pubkey, Buffer.alloc(MAX_META_BYTES + 1, 123))

  t.absent(relay.holesailKey)
  t.is(events, 0)
})

test('network discovery rejects malformed holesail metadata frames', (t) => {
  const pubkey = 'c'.repeat(64)
  const discovery = new NetworkDiscovery()
  const relay = { publicKey: pubkey }
  let events = 0

  discovery._relays.set(pubkey, relay)
  discovery.on('relay-holesail-key', () => { events++ })

  discovery._handleMetadataFrame(pubkey, Buffer.from('[]'))
  discovery._handleMetadataFrame(pubkey, Buffer.from('{'))
  discovery._handleMetadataFrame(pubkey, Buffer.from(JSON.stringify({ holesailKey: 'not-valid' })))

  t.absent(relay.holesailKey)
  t.is(events, 0)
})

test('network discovery rejects invalid holesail tunnel keys before connecting', async (t) => {
  const discovery = new NetworkDiscovery()

  await rejects(t, discovery._fetchViaHolesail('not-valid'), /Invalid holesail key/)
})

test('network discovery owns and awaits its shared-swarm discovery handle', async (t) => {
  const events = []
  let settleDiscovery
  const settled = new Promise(resolve => { settleDiscovery = resolve })
  const handle = {
    async destroy () {
      events.push('discovery-destroy-start')
      await settled
      events.push('discovery-destroy-done')
    }
  }
  const swarm = {
    join () { events.push('join'); return handle },
    on (event) { events.push(`on-${event}`) },
    removeListener (event) { events.push(`remove-${event}`) },
    async flush () { events.push('flush') }
  }
  const discovery = new NetworkDiscovery({ swarm })
  await discovery.start()
  t.is(discovery._discoveryHandle, handle)

  const stopping = discovery.stop()
  await new Promise(resolve => setImmediate(resolve))
  t.ok(events.includes('remove-connection'))
  t.ok(events.includes('discovery-destroy-start'))
  t.absent(events.includes('discovery-destroy-done'))

  settleDiscovery()
  await stopping
  t.is(discovery._discoveryHandle, null)
  t.ok(events.includes('discovery-destroy-done'))
})

test('network discovery retains a rejected discovery handle for retry', async (t) => {
  let rejectDestroy = true
  const handle = {
    async destroy () {
      if (rejectDestroy) throw new Error('injected network discovery failure')
    }
  }
  const swarm = {
    join () { return handle },
    on () {},
    removeListener () {},
    async flush () {}
  }
  const discovery = new NetworkDiscovery({ swarm })
  await discovery.start()

  await t.exception(discovery.stop(), /injected network discovery failure/)
  t.is(discovery._discoveryHandle, handle)
  t.is(discovery.swarm, swarm)

  rejectDestroy = false
  await discovery.stop()
  t.is(discovery._discoveryHandle, null)
  t.is(discovery.swarm, swarm, 'shared swarm remains owned by the relay')
})

test('network discovery stop aborts a hung startup flush before awaiting it', async (t) => {
  let destroys = 0
  let removes = 0
  const handle = { async destroy () { destroys++ } }
  const swarm = {
    join () { return handle },
    on () {},
    removeListener () { removes++ },
    flush () { return new Promise(() => {}) }
  }
  const discovery = new NetworkDiscovery({ swarm, startFlushTimeout: 60_000 })
  const starting = discovery.start()
  await new Promise(resolve => setImmediate(resolve))

  await discovery.stop()
  await t.exception(starting, /aborted/)
  t.is(destroys, 1, 'exact startup discovery handle destroyed once')
  t.is(removes, 1, 'startup connection listener detached')
  t.is(discovery.running, false)
})

test('network discovery bounds startup flush and rolls resources back', async (t) => {
  let destroys = 0
  const handle = { async destroy () { destroys++ } }
  const swarm = {
    join () { return handle },
    on () {},
    removeListener () {},
    flush () { return new Promise(() => {}) }
  }
  const discovery = new NetworkDiscovery({ swarm, startFlushTimeout: 10 })

  await t.exception(discovery.start(), /startup flush timed out/)
  t.is(destroys, 1, 'failed startup destroys exact discovery handle')
  t.is(discovery._discoveryHandle, null)
  t.is(discovery.running, false)
})

test('network discovery bounds hung handle teardown and retains exact owner', async (t) => {
  let settleDestroy
  let destroys = 0
  const pending = new Promise(resolve => { settleDestroy = resolve })
  const handle = {
    destroy () {
      destroys++
      return pending
    }
  }
  const swarm = {
    join () { return handle },
    on () {},
    removeListener () {},
    async flush () {}
  }
  const discovery = new NetworkDiscovery({ swarm, stopTimeout: 10 })
  await discovery.start()

  await t.exception(discovery.stop(), /handle destroy timed out/)
  t.is(discovery._discoveryHandle, handle, 'timed-out owner retained')
  t.is(destroys, 1, 'single exact destroy remains in flight')
  settleDestroy()
  await pending
  await discovery.stop()
  t.is(discovery._discoveryHandle, null)
  t.is(destroys, 1, 'retry awaits original destroy instead of duplicating it')
})

test('network discovery preserves startup and teardown causes together', async (t) => {
  const handle = { async destroy () { throw new Error('injected handle teardown failure') } }
  const swarm = {
    join () { return handle },
    on () {},
    removeListener () {},
    async flush () { throw new Error('injected startup flush failure') }
  }
  const discovery = new NetworkDiscovery({ swarm })
  let failure = null
  try { await discovery.start() } catch (err) { failure = err }
  t.is(failure?.code, 'NETWORK_DISCOVERY_START_TEARDOWN_FAILED')
  t.is(failure?.startCause?.message, 'injected startup flush failure')
  t.is(failure?.teardownCause?.message, 'injected handle teardown failure')
  t.is(discovery._discoveryHandle, handle)
})

test('network discovery duplicate connection retains old teardown and never deletes replacement', async (t) => {
  const pubkey = Buffer.alloc(32, 9)
  let rejectOldDestroy = true
  let oldDestroys = 0
  let nextDestroys = 0
  class FakeConnection extends EventEmitter {
    constructor (destroy) { super(); this._destroy = destroy }
    destroy () { return this._destroy() }
  }
  const old = new FakeConnection(async () => {
    oldDestroys++
    if (rejectOldDestroy) throw new Error('injected old connection destroy failure')
  })
  const replacement = new FakeConnection(async () => { nextDestroys++ })
  const discovery = new NetworkDiscovery({ swarm: {}, stopTimeout: 10 })
  discovery._acceptingWork = true
  discovery._lifecycleEpoch = 1
  discovery._onConnection(old, { publicKey: pubkey })
  discovery._onConnection(replacement, { publicKey: pubkey })
  await new Promise(resolve => setTimeout(resolve, 0))
  old.emit('close')
  const key = pubkey.toString('hex')
  t.is(discovery._connections.get(key), replacement, 'old close cannot delete replacement')
  t.is(discovery._retiringConnections.size, 1, 'failed old owner remains retryable')

  await t.exception(discovery.stop(), /injected old connection destroy failure/)
  t.is(nextDestroys, 1, 'current replacement settles once')
  t.is(discovery._retiringConnections.size, 1)
  rejectOldDestroy = false
  await discovery.stop()
  t.is(oldDestroys, 3, 'replacement attempt plus one attempt per stop lifecycle')
  t.is(nextDestroys, 1)
  t.is(discovery._retiringConnections.size, 0)
})

test('network discovery holesail connect timeout destroys exact client and retains failed teardown', async (t) => {
  let rejectDestroy = true
  let destroys = 0
  const client = {
    state: 'connecting',
    connect () {},
    async destroy () {
      destroys++
      if (rejectDestroy) throw new Error('injected holesail destroy failure')
      this.state = 'destroyed'
    }
  }
  const discovery = new NetworkDiscovery({
    holesailConnectTimeout: 5,
    stopTimeout: 10,
    holesailClientFactory: () => client
  })
  discovery._acceptingWork = true
  discovery._lifecycleEpoch = 1
  let failure = null
  try { await discovery._fetchViaHolesail(VALID_HOLESAIL_KEY) } catch (err) { failure = err }
  t.is(failure?.code, 'HOLESAIL_START_TEARDOWN_FAILED')
  t.ok(/connect timeout/.test(failure?.startCause?.message))
  t.is(failure?.teardownCause?.message, 'injected holesail destroy failure')
  t.is(discovery._holesailClients.get(VALID_HOLESAIL_KEY)?.client, client)
  rejectDestroy = false
  await discovery._destroyHolesailEntry(VALID_HOLESAIL_KEY, discovery._holesailClients.get(VALID_HOLESAIL_KEY))
  t.is(destroys, 2)
  t.absent(discovery._holesailClients.has(VALID_HOLESAIL_KEY))
})

test('network discovery serializes concurrent holesail allocation below hard cap', async (t) => {
  let live = 0
  let maxLive = 0
  const discovery = new NetworkDiscovery({
    holesailClientFactory: () => {
      live++
      maxLive = Math.max(maxLive, live)
      return {
        state: 'connecting',
        connect (_opts, done) { this.state = 'connected'; queueMicrotask(done) },
        async destroy () { if (this.state !== 'destroyed') live--; this.state = 'destroyed' }
      }
    }
  })
  discovery._acceptingWork = true
  discovery._lifecycleEpoch = 1
  discovery._fetchApi = async () => ({ ok: true })
  const alphabet = 'ybndrfg8ejkmcpqxot1uwisza345h769'
  const keys = Array.from({ length: 25 }, (_, index) =>
    'y'.repeat(50) + alphabet[index % alphabet.length] + alphabet[(index + 1) % alphabet.length])
  await Promise.all(keys.map(key => discovery._fetchViaHolesail(key)))
  t.is(discovery._holesailClients.size, 20)
  t.ok(maxLive <= 20, `max live clients stayed bounded (${maxLive})`)
  await discovery.stop()
  t.is(live, 0)
})

test('network discovery stale probe cannot mutate a later lifecycle epoch', async (t) => {
  const pubkey = 'e'.repeat(64)
  const relay = { publicKey: pubkey, holesailKey: null }
  const connection = {}
  const discovery = new NetworkDiscovery()
  discovery._acceptingWork = true
  discovery._lifecycleEpoch = 4
  discovery._relays.set(pubkey, relay)
  discovery._connections.set(pubkey, connection)
  let resolveFetch = null
  discovery._fetchApi = () => new Promise(resolve => { resolveFetch = resolve })
  const probe = discovery._probeApiPort(pubkey, '127.0.0.1', connection, 4)
  await new Promise(resolve => setTimeout(resolve, 0))
  discovery._acceptingWork = false
  discovery._lifecycleEpoch = 5
  resolveFetch({ publicKey: pubkey })
  await probe
  t.absent(relay.apiPort, 'old probe result is discarded')
})