OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / integration / gateway-streaming.test.js

399 lines · javascript ref:
/**
 * Integration tests for HyperGateway streaming + Range support (Fix 2.2).
 *
 * Boots a single relay node, seeds a Hyperdrive containing files of varying
 * sizes, mounts a HyperGateway, and exercises the HTTP surface end-to-end:
 *
 *   - small text file streams with the right Content-Type
 *   - 5 MiB binary file streams without buffering and arrives byte-identical
 *   - Range: bytes=0-99 → 206 + Content-Range + correct slice
 *   - Range: bytes=100-199 → 206 + correct slice from offset 100
 *   - invalid range → 416
 *   - Accept-Ranges: bytes is on every response
 *   - path traversal attempts are rejected with 403
 *
 * The gateway is shared with the relay's Corestore (the production wiring),
 * so the same drive instance held by `seededApps` is what the gateway serves.
 */

import test from 'brittle'
import { createServer } from 'http'
import createTestnet from '@hyperswarm/testnet'
import b4a from 'b4a'
import path from 'path'
import { tmpdir } from 'os'
import { randomBytes } from 'crypto'
import { mkdirSync } from 'fs'
import { RelayNode } from 'p2p-hiverelay/core/relay-node/index.js'
import { HyperGateway } from 'p2p-hiverelay/gateway'

const TEST_MAX_STORAGE_BYTES = 64 * 1024 * 1024

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

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

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

/**
 * Boot a relay, seed an in-process Hyperdrive against it, mount a gateway,
 * and start a tiny HTTP server in front. Returns helpers + teardown.
 */
async function bootGateway (t, files) {
  const testnet = await createTestnet(3)
  const node = createNode(testnet)
  await node.start()

  // Author through a disposable session, then close the writer before the
  // relay adopts the drive through the production seed path. The gateway may
  // serve only the exact metadata/blob snapshots committed by AppLifecycle.
  const Hyperdrive = (await import('hyperdrive')).default
  const writerDrive = new Hyperdrive(node.store.session())
  await writerDrive.ready()

  for (const [filePath, content] of Object.entries(files)) {
    await writerDrive.put(filePath, content)
  }

  const keyHex = b4a.toString(writerDrive.key, 'hex')
  // Each awaited put is this Hyperdrive version's durable append barrier;
  // closing settles the writer session before production adopts the key.
  await writerDrive.close()

  const reopenProbe = new Hyperdrive(node.store.session(), b4a.from(keyHex, 'hex'))
  await reopenProbe.ready()
  t.ok(reopenProbe.version > 0, 'authored drive reopens by key after writer close')
  await reopenProbe.close()

  await node.seedApp(keyHex, {
    privacyTier: 'public',
    maxStorage: TEST_MAX_STORAGE_BYTES
  })

  const anchored = await waitFor(() => node.appRegistry.get(keyHex)?.anchored === true)
  t.ok(anchored, 'production seed path anchors the authored drive')
  const seededEntry = node.appRegistry.get(keyHex)
  t.ok(
    seededEntry &&
    seededEntry.anchoredLength === seededEntry.storageProvedDriveVersion &&
    Number.isSafeInteger(seededEntry.storageProvedDriveVersion) && seededEntry.storageProvedDriveVersion > 0 &&
    Number.isSafeInteger(seededEntry.storageProvedMetaLength) && seededEntry.storageProvedMetaLength >= 0 &&
    Number.isSafeInteger(seededEntry.storageProvedBlobLength) && seededEntry.storageProvedBlobLength >= 0 &&
    Number.isSafeInteger(seededEntry.storageProvedTotalBytes) && seededEntry.storageProvedTotalBytes >= 0 &&
    Number.isSafeInteger(seededEntry.storageProvedMetaFork) && seededEntry.storageProvedMetaFork >= 0 &&
    Number.isSafeInteger(seededEntry.storageProvedBlobFork) && seededEntry.storageProvedBlobFork >= 0 &&
    Array.isArray(seededEntry.downloadSnapshotCores) && seededEntry.downloadSnapshotCores.length === 2,
    'anchored entry retains the complete persisted metadata/blob proof tuple'
  )
  t.ok(node.storageAdmission.canAcknowledge(`drive:${keyHex}`), 'shared storage authority ACKs the committed drive')

  const gateway = new HyperGateway(node, { store: node.store })

  const server = createServer((req, res) => {
    if (req.url.startsWith('/v1/hyper/')) return gateway.handle(req, res)
    res.writeHead(404)
    res.end()
  })

  await new Promise((resolve, reject) => {
    server.on('error', reject)
    server.listen(0, '127.0.0.1', resolve)
  })
  const port = server.address().port

  t.teardown(async () => {
    await new Promise(resolve => server.close(resolve))
    try { await gateway.close() } catch {}
    await node.stop()
    await testnet.destroy()
  })

  return {
    keyHex,
    url: (filePath) => `http://127.0.0.1:${port}/v1/hyper/${keyHex}${filePath}`,
    rawUrl: (suffix) => `http://127.0.0.1:${port}${suffix}`
  }
}

async function bootTraversalGateway (t) {
  const gateway = new HyperGateway({
    seededApps: new Map(),
    config: { gatewayPublicOnlyPrivacyTier: true }
  })
  const server = createServer((req, res) => gateway.handle(req, res))
  await new Promise((resolve, reject) => {
    server.on('error', reject)
    server.listen(0, '127.0.0.1', resolve)
  })
  const port = server.address().port
  t.teardown(async () => {
    await new Promise(resolve => server.close(resolve))
    await gateway.close()
  })
  return {
    keyHex: 'aa'.repeat(32),
    rawUrl: (suffix) => `http://127.0.0.1:${port}${suffix}`
  }
}

// ─── Small file streams correctly with right Content-Type ──────────

test('integration: small file streams with correct Content-Type', async (t) => {
  const ctx = await bootGateway(t, {
    '/hello.txt': b4a.from('hello world')
  })

  const res = await fetch(ctx.url('/hello.txt'))
  t.is(res.status, 200, 'returns 200')
  t.is(res.headers.get('content-type'), 'text/plain; charset=utf-8', 'text/plain content-type')
  t.is(res.headers.get('content-length'), '11', 'content-length is set')
  t.is(res.headers.get('accept-ranges'), 'bytes', 'accept-ranges header present')

  const body = await res.text()
  t.is(body, 'hello world', 'body matches')
})

// ─── PNG content-type from extension map ───────────────────────────

test('integration: PNG file gets image/png Content-Type', async (t) => {
  const fakePng = b4a.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
  const ctx = await bootGateway(t, {
    '/icon.png': fakePng
  })

  const res = await fetch(ctx.url('/icon.png'))
  t.is(res.status, 200, '200 OK')
  t.is(res.headers.get('content-type'), 'image/png', 'image/png content-type')
  t.is(res.headers.get('accept-ranges'), 'bytes', 'accept-ranges present on binaries')

  const body = b4a.from(await res.arrayBuffer())
  t.ok(b4a.equals(body, fakePng), 'PNG bytes match')
})

// ─── Unknown extension → octet-stream ──────────────────────────────

test('integration: unknown extension maps to application/octet-stream', async (t) => {
  const ctx = await bootGateway(t, {
    '/blob.weird': b4a.from('xyz')
  })

  const res = await fetch(ctx.url('/blob.weird'))
  t.is(res.status, 200, '200 OK')
  t.is(res.headers.get('content-type'), 'application/octet-stream', 'octet-stream fallback')
  t.is(res.headers.get('accept-ranges'), 'bytes', 'accept-ranges present')
})

// ─── Large-ish file streams without buffering issues ───────────────

test('integration: 5MB file streams correctly end-to-end', async (t) => {
  const big = randomBytes(5 * 1024 * 1024) // 5 MiB
  const ctx = await bootGateway(t, {
    '/big.bin': big
  })

  const res = await fetch(ctx.url('/big.bin'))
  t.is(res.status, 200, '200 OK on large file')
  t.is(res.headers.get('content-length'), String(big.length), 'content-length matches 5MB')
  t.is(res.headers.get('accept-ranges'), 'bytes', 'accept-ranges present')

  const body = b4a.from(await res.arrayBuffer())
  t.is(body.length, big.length, 'received exactly 5MB')
  t.ok(b4a.equals(body, big), '5MB body bytes are byte-identical')
})

// ─── Range: bytes=0-99 returns first 100 bytes with 206 ────────────

test('integration: Range bytes=0-99 returns 206 with correct slice', async (t) => {
  const data = randomBytes(2048)
  const ctx = await bootGateway(t, {
    '/data.bin': data
  })

  const res = await fetch(ctx.url('/data.bin'), {
    headers: { Range: 'bytes=0-99' }
  })
  t.is(res.status, 206, 'returns 206 Partial Content')
  t.is(res.headers.get('content-range'), `bytes 0-99/${data.length}`, 'content-range correct')
  t.is(res.headers.get('content-length'), '100', 'content-length is 100')
  t.is(res.headers.get('accept-ranges'), 'bytes', 'accept-ranges present')

  const body = b4a.from(await res.arrayBuffer())
  t.is(body.length, 100, 'received exactly 100 bytes')
  t.ok(b4a.equals(body, data.slice(0, 100)), 'first 100 bytes match')
})

// ─── Range: bytes=100-199 returns slice from offset 100 ────────────

test('integration: Range bytes=100-199 returns slice from offset 100', async (t) => {
  const data = randomBytes(2048)
  const ctx = await bootGateway(t, {
    '/data.bin': data
  })

  const res = await fetch(ctx.url('/data.bin'), {
    headers: { Range: 'bytes=100-199' }
  })
  t.is(res.status, 206, '206 Partial Content')
  t.is(res.headers.get('content-range'), `bytes 100-199/${data.length}`, 'content-range correct')
  t.is(res.headers.get('content-length'), '100', 'content-length is 100')

  const body = b4a.from(await res.arrayBuffer())
  t.is(body.length, 100, 'received 100 bytes')
  t.ok(b4a.equals(body, data.slice(100, 200)), 'bytes [100, 200) match')
})

// ─── Open-ended range: bytes=N- returns from N to EOF ──────────────

test('integration: Range bytes=N- returns from offset N to EOF', async (t) => {
  const data = randomBytes(512)
  const ctx = await bootGateway(t, {
    '/data.bin': data
  })

  const res = await fetch(ctx.url('/data.bin'), {
    headers: { Range: 'bytes=400-' }
  })
  t.is(res.status, 206, '206 Partial Content')
  t.is(res.headers.get('content-range'), `bytes 400-511/${data.length}`, 'content-range covers tail')
  t.is(res.headers.get('content-length'), '112', 'content-length is 112')

  const body = b4a.from(await res.arrayBuffer())
  t.ok(b4a.equals(body, data.slice(400)), 'tail slice matches')
})

// ─── Suffix range: bytes=-N returns last N bytes ───────────────────

test('integration: suffix range bytes=-N returns last N bytes', async (t) => {
  const data = randomBytes(1024)
  const ctx = await bootGateway(t, {
    '/data.bin': data
  })

  const res = await fetch(ctx.url('/data.bin'), {
    headers: { Range: 'bytes=-50' }
  })
  t.is(res.status, 206, '206 Partial Content')
  const expectedStart = data.length - 50
  t.is(res.headers.get('content-range'), `bytes ${expectedStart}-${data.length - 1}/${data.length}`, 'content-range matches suffix')

  const body = b4a.from(await res.arrayBuffer())
  t.ok(b4a.equals(body, data.slice(expectedStart)), 'last 50 bytes match')
})

// ─── Invalid range (start > end) returns 416 ──────────────────────

test('integration: invalid Range (start > end) returns 416', async (t) => {
  const data = b4a.from('abcdefghij')
  const ctx = await bootGateway(t, {
    '/data.bin': data
  })

  const res = await fetch(ctx.url('/data.bin'), {
    headers: { Range: 'bytes=5-2' }
  })
  t.is(res.status, 416, '416 Range Not Satisfiable')
  t.is(res.headers.get('content-range'), `bytes */${data.length}`, 'unsatisfied content-range')
  // drain
  await res.arrayBuffer()
})

// ─── Range starting past EOF returns 416 ───────────────────────────

test('integration: Range past EOF returns 416', async (t) => {
  const data = b4a.from('short')
  const ctx = await bootGateway(t, {
    '/data.bin': data
  })

  const res = await fetch(ctx.url('/data.bin'), {
    headers: { Range: 'bytes=100-200' }
  })
  t.is(res.status, 416, '416 Range Not Satisfiable')
  t.is(res.headers.get('content-range'), `bytes */${data.length}`, 'unsatisfied content-range')
  await res.arrayBuffer()
})

// ─── Accept-Ranges: bytes header on every response ─────────────────

test('integration: Accept-Ranges header is present on all 200/206 responses', async (t) => {
  const ctx = await bootGateway(t, {
    '/a.txt': b4a.from('a'),
    '/b.png': b4a.from([0x89, 0x50]),
    '/c.bin': randomBytes(100)
  })

  for (const p of ['/a.txt', '/b.png', '/c.bin']) {
    const res = await fetch(ctx.url(p))
    t.is(res.headers.get('accept-ranges'), 'bytes', `${p} has accept-ranges`)
    await res.arrayBuffer()
  }

  // And on a 206 too
  const ranged = await fetch(ctx.url('/c.bin'), { headers: { Range: 'bytes=0-9' } })
  t.is(ranged.status, 206, '206 returned')
  t.is(ranged.headers.get('accept-ranges'), 'bytes', '206 also carries accept-ranges')
  await ranged.arrayBuffer()
})

// ─── Path traversal rejected with 403 ──────────────────────────────

test('integration: path traversal attempts are rejected with 403', async (t) => {
  const ctx = await bootTraversalGateway(t)

  // Encoded ../ traversal
  const res1 = await fetch(ctx.rawUrl(`/v1/hyper/${ctx.keyHex}/..%2Fetc%2Fpasswd`))
  t.is(res1.status, 403, 'encoded ../ rejected with 403')
  await res1.text()

  // Direct ../ traversal — fetch normalizes the URL client-side, so the
  // server may see a path that no longer matches /v1/hyper/<key>/... and
  // return 400 ("Invalid path" / "Invalid drive key"). Anything in the
  // 4xx range that isn't 200 means the traversal was not honored.
  const res2 = await fetch(ctx.rawUrl(`/v1/hyper/${ctx.keyHex}/../etc/passwd`))
  t.ok(res2.status >= 400 && res2.status < 500, `traversal blocked (got ${res2.status})`)
  await res2.text()

  // Double-encoded ..
  const res3 = await fetch(ctx.rawUrl(`/v1/hyper/${ctx.keyHex}/%252e%252e/etc/passwd`))
  t.is(res3.status, 403, 'double-encoded .. rejected with 403')
  await res3.text()
})

// ─── HEAD request returns headers without body ─────────────────────

test('integration: HEAD request returns headers without body', async (t) => {
  const data = randomBytes(1024)
  const ctx = await bootGateway(t, {
    '/data.bin': data
  })

  const res = await fetch(ctx.url('/data.bin'), { method: 'HEAD' })
  t.is(res.status, 200, '200 OK on HEAD')
  t.is(res.headers.get('content-length'), String(data.length), 'content-length present on HEAD')
  t.is(res.headers.get('accept-ranges'), 'bytes', 'accept-ranges present on HEAD')

  const body = b4a.from(await res.arrayBuffer())
  t.is(body.length, 0, 'HEAD body is empty')
})