OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / client-striping.test.js

149 lines · javascript ref:
import test from 'brittle'
import b4a from 'b4a'
import { HiveRelayClient } from 'p2p-hiverelay-client'

// Build a fake HTTP gateway over a fixed byte payload that honours Range
// requests (RFC 9110), so we can exercise getStriped() without a live relay.
// Records which relay served which range so we can assert the work was spread.
function fakeGateway (payload, served) {
  return async (url, opts = {}) => {
    const range = opts.headers && opts.headers.Range
    let start = 0
    let end = payload.length - 1
    if (range) {
      const m = /bytes=(\d+)-(\d+)/.exec(range)
      start = Number(m[1]); end = Number(m[2])
    }
    const slice = payload.subarray(start, end + 1)
    served.push({ url, start, end })
    return {
      ok: true,
      status: range ? 206 : 200,
      headers: { get: (h) => (h && h.toLowerCase() === 'content-range') ? `bytes ${start}-${end}/${payload.length}` : null },
      arrayBuffer: async () => slice.buffer.slice(slice.byteOffset, slice.byteOffset + slice.byteLength)
    }
  }
}

function makeClient () {
  // Constructor is pure (no I/O); we never call start().
  return new HiveRelayClient({ autoDiscover: false })
}

const DRIVE = 'a'.repeat(64)

test('getStriped: reassembles a drive from ranges across multiple relays', async (t) => {
  const payload = b4a.alloc(1000)
  for (let i = 0; i < payload.length; i++) payload[i] = i % 256
  const served = []
  const orig = globalThis.fetch
  globalThis.fetch = fakeGateway(payload, served)
  t.teardown(() => { globalThis.fetch = orig })

  const client = makeClient()
  const quorum = [
    { url: 'http://r1', pubkey: 'r1' },
    { url: 'http://r2', pubkey: 'r2' },
    { url: 'http://r3', pubkey: 'r3' }
  ]
  const out = await client.getStriped(DRIVE, '/file.bin', { quorum, stripes: 3 })

  t.ok(out.ok, 'reassembled length matches total')
  t.is(out.bytes, 1000)
  t.is(out.striped, true)
  t.ok(b4a.equals(out.buffer, payload), 'bytes are byte-for-byte correct after concat')
  t.is(out.relaysUsed.length, 3, 'all three relays each served a stripe')
  // Beyond the 1-byte probe, every data stripe carried a Range header.
  t.ok(served.filter(s => s.end - s.start > 0).length >= 2, 'work was split into ranges')
})

test('getStriped: falls back to a single full GET when Range is unsupported', async (t) => {
  const payload = b4a.from('hello world')
  const orig = globalThis.fetch
  // Gateway that ignores Range and returns the whole body with NO content-range.
  globalThis.fetch = async () => ({
    ok: true,
    status: 200,
    headers: { get: () => null },
    arrayBuffer: async () => payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength)
  })
  t.teardown(() => { globalThis.fetch = orig })

  const client = makeClient()
  const out = await client.getStriped(DRIVE, '/x', { quorum: [{ url: 'http://r1', pubkey: 'r1' }] })
  t.is(out.striped, false, 'flagged as not striped')
  t.ok(b4a.equals(out.buffer, payload), 'still returns the correct bytes')
})

test('getStriped: a relay that ignores Range is rejected and the range is refetched elsewhere', async (t) => {
  const payload = b4a.alloc(600)
  for (let i = 0; i < payload.length; i++) payload[i] = (i * 7) % 256
  const orig = globalThis.fetch
  // r1 ignores Range entirely (always returns the FULL body, status 200, no
  // content-range). r2 honours Range. Reassembly must still be byte-correct.
  globalThis.fetch = async (url, opts = {}) => {
    const range = opts.headers && opts.headers.Range
    const ignoresRange = url.includes('://r1')
    if (ignoresRange || !range) {
      return {
        ok: true,
        status: 200,
        headers: { get: () => null },
        arrayBuffer: async () => payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength)
      }
    }
    const m = /bytes=(\d+)-(\d+)/.exec(range)
    const s = Number(m[1]); const e = Number(m[2])
    const slice = payload.subarray(s, e + 1)
    return {
      ok: true,
      status: 206,
      headers: { get: (h) => (h && h.toLowerCase() === 'content-range') ? `bytes ${s}-${e}/${payload.length}` : null },
      arrayBuffer: async () => slice.buffer.slice(slice.byteOffset, slice.byteOffset + slice.byteLength)
    }
  }
  t.teardown(() => { globalThis.fetch = orig })

  const client = makeClient()
  const out = await client.getStriped(DRIVE, '/f', {
    quorum: [{ url: 'http://r1', pubkey: 'r1' }, { url: 'http://r2', pubkey: 'r2' }],
    stripes: 2
  })
  t.is(out.striped, true)
  t.ok(b4a.equals(out.buffer, payload), 'bytes are correct despite a Range-ignoring relay')
  t.absent(out.relaysUsed.includes('r1'), 'the misbehaving relay served no accepted stripe')
})

test('getStriped: a throwing relay is failed over to a healthy one', async (t) => {
  const payload = b4a.from('the quick brown fox jumps over the lazy dog')
  const orig = globalThis.fetch
  globalThis.fetch = async (url, opts = {}) => {
    if (url.includes('://bad')) throw new Error('connection refused')
    const range = opts.headers && opts.headers.Range
    const m = range && /bytes=(\d+)-(\d+)/.exec(range)
    const s = m ? Number(m[1]) : 0
    const e = m ? Number(m[2]) : payload.length - 1
    const slice = payload.subarray(s, e + 1)
    return {
      ok: true,
      status: range ? 206 : 200,
      headers: { get: (h) => (h && h.toLowerCase() === 'content-range') ? `bytes ${s}-${e}/${payload.length}` : null },
      arrayBuffer: async () => slice.buffer.slice(slice.byteOffset, slice.byteOffset + slice.byteLength)
    }
  }
  t.teardown(() => { globalThis.fetch = orig })

  const client = makeClient()
  const out = await client.getStriped(DRIVE, '/f', {
    quorum: [{ url: 'http://bad', pubkey: 'bad' }, { url: 'http://good', pubkey: 'good' }]
  })
  t.ok(out.ok)
  t.ok(b4a.equals(out.buffer, payload), 'reassembled correctly after failover')
})

test('getStriped: validates inputs', async (t) => {
  const client = makeClient()
  await t.exception(() => client.getStriped('nothex', '/x', { quorum: [{ url: 'http://r1' }] }), /64 hex/)
  await t.exception(() => client.getStriped(DRIVE, 'no-leading-slash', { quorum: [{ url: 'http://r1' }] }), /must start with/)
})