OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / capability-endpoints.test.js

590 lines · javascript ref:
/**
 * HTTP-level tests for the three new v0.5.1 endpoints:
 *   GET  /.well-known/hiverelay.json
 *   GET  /api/capabilities
 *   GET  /api/authors/<pubkey>/seeding.json
 *   POST /api/authors/seeding.json
 *
 * Uses the same RelayAPI-with-mock-node pattern as api-auth.test.js. Does
 * NOT spin up a real Hyperswarm — just a minimal stub that satisfies the
 * endpoint's reads. This keeps the test fast (<500ms) and deterministic.
 */

import test from 'brittle'
import http from 'http'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import { ManifestStore } from 'p2p-hiverelay/core/manifest-store.js'
import { createSeedingManifest } from 'p2p-hiverelay/core/seeding-manifest.js'
import {
  buildCapabilityRoutePayload,
  CAPABILITY_DOC_CACHE_CONTROL,
  resolveCapabilityRoute
} from 'p2p-hiverelay/core/relay-node/api-capabilities.js'

// Build a GENUINE hypercore fork proof: two different blocks each signed
// by the hypercore's OWN key. Post-HR-SVC-004 the fork endpoints require
// this — a signed envelope over junk evidence no longer quarantines.
function genuineForkProof (sodium, b4a) {
  const publicKey = b4a.alloc(32)
  const secretKey = b4a.alloc(64)
  sodium.crypto_sign_keypair(publicKey, secretKey)
  const hypercoreKey = b4a.toString(publicKey, 'hex')
  const blockA = b4a.from('head-A@7', 'utf8')
  const blockB = b4a.from('head-B@7-conflict', 'utf8')
  const sigA = b4a.alloc(64); sodium.crypto_sign_detached(sigA, blockA, secretKey)
  const sigB = b4a.alloc(64); sodium.crypto_sign_detached(sigB, blockB, secretKey)
  return {
    hypercoreKey,
    evidenceA: { fromRelay: 'r1', block: b4a.toString(blockA, 'hex'), signature: b4a.toString(sigA, 'hex') },
    evidenceB: { fromRelay: 'r2', block: b4a.toString(blockB, 'hex'), signature: b4a.toString(sigB, 'hex') }
  }
}

function mockRelayNode ({ manifestStore, forkDetector } = {}) {
  return {
    running: true,
    config: {
      storage: null,
      acceptMode: 'review',
      maxPendingRequests: 5000,
      maxConnections: 256,
      regions: ['test-region']
    },
    metrics: { getSummary () { return { uptime: 100 } }, startedAt: Date.now() - 60000 },
    appRegistry: {
      apps: new Map(),
      catalog () {
        return [
          { type: 'app' },
          { type: 'drive' },
          { type: 'dataset' }
        ]
      },
      catalogForBroadcast () { return [] },
      has () { return false },
      get () { return null }
    },
    seededApps: new Map(),
    federation: {
      snapshot () {
        return { followed: [{ url: 'http://a' }], mirrored: [], republished: [] }
      }
    },
    manifestStore,
    forkDetector,
    _checkDelegation: () => {},
    _revokedCertSignatures: new Map(),
    getStats () { return { publicKey: 'deadbeef', connections: 0, seededApps: 0 } },
    getHealthStatus () { return { healthy: true } },
    on () {},
    removeListener () {},
    emit () {},
    async seedApp () { return { ok: true } },
    async unseedApp () {},
    swarm: null,
    relay: null,
    seeder: null,
    router: null,
    serviceRegistry: null,
    seedingRegistry: null,
    reputation: null,
    networkDiscovery: null,
    alertManager: null,
    selfHeal: null,
    torTransport: null,
    dhtRelayWs: null,
    holesailTransport: null,
    _bandwidthReceipt: null,
    paymentManager: null,
    // HyperGateway wants a corestore-shaped thing. We never hit gateway
    // routes in these tests, but RelayAPI.start() constructs the gateway
    // eagerly, so provide a minimal stub that satisfies close().
    store: { close: async () => {}, replicate: () => {} }
  }
}

function request (port, method, path, body, headers = {}) {
  return new Promise((resolve, reject) => {
    const opts = {
      hostname: '127.0.0.1',
      port,
      method,
      path,
      headers: { 'Content-Type': 'application/json', ...headers }
    }
    const req = http.request(opts, (res) => {
      let data = ''
      res.on('data', (chunk) => { data += chunk })
      res.on('end', () => {
        let parsed
        try { parsed = JSON.parse(data) } catch (_) { parsed = data }
        resolve({ statusCode: res.statusCode, body: parsed, headers: res.headers })
      })
    })
    req.on('error', reject)
    if (body) req.write(typeof body === 'string' ? body : JSON.stringify(body))
    req.end()
  })
}

function requestRaw (port, method, path, body, headers = {}) {
  return new Promise((resolve, reject) => {
    const opts = {
      hostname: '127.0.0.1',
      port,
      method,
      path,
      agent: false,
      headers: { Connection: 'close', ...headers }
    }
    const req = http.request(opts, (res) => {
      let data = ''
      res.on('data', (chunk) => { data += chunk })
      res.on('end', () => {
        let parsed
        try { parsed = JSON.parse(data) } catch (_) { parsed = data }
        resolve({ statusCode: res.statusCode, body: parsed, headers: res.headers })
        req.destroy()
      })
    })
    req.on('error', reject)
    if (body) req.write(body)
    req.end()
  })
}

function makeKeyPair () {
  const publicKey = b4a.alloc(32)
  const secretKey = b4a.alloc(64)
  sodium.crypto_sign_keypair(publicKey, secretKey)
  return { publicKey, secretKey }
}

function validHex () {
  return Array.from({ length: 64 }).map((_, i) => (i % 16).toString(16)).join('')
}

async function setupApi (t, nodeExtras = {}) {
  const { RelayAPI } = await import('p2p-hiverelay/core/relay-node/api.js')
  const node = mockRelayNode(nodeExtras)
  // apiKey must be set for _requireAuth to actually enforce — without it
  // the implicit-localhost fallback lets every local request through.
  const api = new RelayAPI(node, {
    apiPort: 0,
    apiHost: '127.0.0.1',
    apiKey: 'test-key-' + Math.random().toString(36).slice(2)
  })
  await api.start()
  const port = api.server.address().port
  t.teardown(async () => {
    // Full async stop — shuts down DashboardFeed broadcast interval,
    // gateway, rate-limit sweep, and http server. Without this the
    // broadcast interval keeps firing past the test end and the
    // unref'd socket errors surface as ECONNRESET in the next test.
    try { await api.stop() } catch (_) {}
  })
  return { api, node, port }
}

test('capability route helper builds node runtime doc with public cache headers', (t) => {
  const node = mockRelayNode()
  const result = buildCapabilityRoutePayload({
    node,
    version: '1.2.3-test',
    runtime: 'node'
  })

  t.is(result.status, 200)
  t.alike(result.headers, { 'Cache-Control': CAPABILITY_DOC_CACHE_CONTROL })
  t.is(result.payload.version, '1.2.3-test')
  t.is(result.payload.runtime, 'node')
  t.is(result.payload.limitation.accept_mode, 'review')
  t.ok(result.payload.features.includes('capability-doc'))
})

test('capability route helper maps exact public capability routes', (t) => {
  t.alike(resolveCapabilityRoute('GET', '/.well-known/hiverelay.json'), {
    kind: 'capability-doc'
  })
  t.alike(resolveCapabilityRoute('GET', '/api/capabilities'), {
    kind: 'capability-doc'
  })

  t.is(resolveCapabilityRoute('POST', '/.well-known/hiverelay.json'), null)
  t.is(resolveCapabilityRoute('GET', '/.well-known/hiverelay.json/extra'), null)
  t.is(resolveCapabilityRoute('GET', '/api/capabilities/extra'), null)
})

test('GET /.well-known/hiverelay.json returns a valid capability doc', async (t) => {
  const { port } = await setupApi(t)
  const res = await request(port, 'GET', '/.well-known/hiverelay.json')
  t.is(res.statusCode, 200)
  t.is(res.body.schemaVersion, 1)
  t.is(res.body.runtime, 'node')
  t.is(res.body.limitation.accept_mode, 'review')
  t.is(res.body.limitation.max_pending_requests, 5000)
  t.is(res.body.region, 'test-region')
  t.ok(res.body.features.includes('capability-doc'))
  t.ok(res.body.features.includes('federation'))
  t.alike(res.body.federation, { followed: 1, mirrored: 0, republished: 0 })
  t.ok(res.body.catalog)
  t.is(res.body.catalog.total, 3)
  // Cache-Control header hint for CDNs / browsers.
  t.ok(res.headers['cache-control']?.includes('max-age=60'))
  t.ok(res.headers['content-type']?.includes('application/json; charset=utf-8'), 'capability docs are typed as JSON')
  t.is(res.headers['x-content-type-options'], 'nosniff', 'JSON responses disable content sniffing')
})

test('GET /api/capabilities mirrors /.well-known/hiverelay.json', async (t) => {
  const { port } = await setupApi(t)
  const a = await request(port, 'GET', '/.well-known/hiverelay.json')
  const b = await request(port, 'GET', '/api/capabilities')
  t.is(a.statusCode, 200)
  t.is(b.statusCode, 200)
  // Compare semantic content. attestedAt differs by ms between calls
  // (each is a fresh build) and so does signature (signs the
  // attestedAt). That's correct behavior — clients should always see
  // fresh attestation. Strip both for the equivalence check.
  const stripVolatile = (doc) => {
    const { attestedAt, signature, ...rest } = doc
    return rest
  }
  t.alike(stripVolatile(a.body), stripVolatile(b.body),
    'both endpoints return semantically-identical payloads')
})

test('GET /api/authors/<unknown>/seeding.json returns 404 with machine code', async (t) => {
  const store = new ManifestStore({})
  const { port } = await setupApi(t, { manifestStore: store })
  const pubkey = validHex()
  const res = await request(port, 'GET', '/api/authors/' + pubkey + '/seeding.json')
  t.is(res.statusCode, 404)
  t.ok(res.body.error?.startsWith('not-found: '),
    'error string carries machine-readable prefix')
  t.ok(res.headers['content-type']?.includes('application/json; charset=utf-8'), 'error response is typed as JSON')
  t.is(res.headers['x-content-type-options'], 'nosniff', 'error response disables content sniffing')
  t.is(res.headers['cache-control'], 'no-store, max-age=0', 'error response is not cached by default')
})

test('POST /api/authors/seeding.json stores a signed manifest, GET returns it', async (t) => {
  const store = new ManifestStore({}) // no storagePath → runtime-only
  const { port } = await setupApi(t, { manifestStore: store })

  const kp = makeKeyPair()
  const manifest = createSeedingManifest({
    keyPair: kp,
    relays: [{ url: 'hyperswarm://test', role: 'primary' }],
    drives: [{ driveKey: validHex() }]
  })

  const put = await request(port, 'POST', '/api/authors/seeding.json', manifest)
  t.is(put.statusCode, 200)
  t.ok(put.body.ok)
  t.is(put.body.replaced, false)

  const pubkeyHex = b4a.toString(kp.publicKey, 'hex')
  const got = await request(port, 'GET', '/api/authors/' + pubkeyHex + '/seeding.json')
  t.is(got.statusCode, 200)
  t.is(got.body.signature, manifest.signature)
  t.is(got.body.pubkey, pubkeyHex)
})

test('POST /api/authors/seeding.json rolls back memory when manifest save fails', async (t) => {
  const store = new ManifestStore({})
  store.save = async () => { throw new Error('readonly manifest store') }
  const { port } = await setupApi(t, { manifestStore: store })

  const kp = makeKeyPair()
  const manifest = createSeedingManifest({
    keyPair: kp,
    relays: [{ url: 'hyperswarm://test', role: 'primary' }],
    drives: [{ driveKey: validHex() }]
  })

  const put = await request(port, 'POST', '/api/authors/seeding.json', manifest)
  t.is(put.statusCode, 500)
  t.is(put.body.errorCode, 'persist-failed')
  t.ok(put.body.error?.startsWith('persist-failed: '))

  const pubkeyHex = b4a.toString(kp.publicKey, 'hex')
  t.absent(store.get(pubkeyHex), 'failed save did not leave runtime manifest')
  const got = await request(port, 'GET', '/api/authors/' + pubkeyHex + '/seeding.json')
  t.is(got.statusCode, 404)
})

test('POST with invalid signature is rejected with BAD_REQUEST prefix', async (t) => {
  const store = new ManifestStore({})
  const { port } = await setupApi(t, { manifestStore: store })

  const kp = makeKeyPair()
  const manifest = createSeedingManifest({
    keyPair: kp,
    relays: [{ url: 'hyperswarm://test', role: 'primary' }],
    drives: [{ driveKey: validHex() }]
  })
  // Tamper after signing.
  manifest.drives[0].channel = 'tampered'

  const res = await request(port, 'POST', '/api/authors/seeding.json', manifest)
  t.is(res.statusCode, 400)
  t.ok(res.body.error?.startsWith('bad-request: '))
})

test('POST with stale (older) manifest returns 409 Conflict', async (t) => {
  const store = new ManifestStore({})
  const { port } = await setupApi(t, { manifestStore: store })

  const kp = makeKeyPair()
  const newer = createSeedingManifest({
    keyPair: kp,
    relays: [{ url: 'hyperswarm://a' }],
    drives: [{ driveKey: validHex() }],
    timestamp: 2000
  })
  const older = createSeedingManifest({
    keyPair: kp,
    relays: [{ url: 'hyperswarm://a' }],
    drives: [{ driveKey: validHex() }],
    timestamp: 1000
  })

  const r1 = await request(port, 'POST', '/api/authors/seeding.json', newer)
  t.is(r1.statusCode, 200)
  const r2 = await request(port, 'POST', '/api/authors/seeding.json', older)
  t.is(r2.statusCode, 409, 'stale manifest → 409')
  t.ok(r2.body.error?.startsWith('bad-request: '))
})

test('POST with no manifestStore configured returns 503 unsupported', async (t) => {
  const { port } = await setupApi(t, { manifestStore: null })
  const kp = makeKeyPair()
  const manifest = createSeedingManifest({
    keyPair: kp,
    relays: [{ url: 'hyperswarm://a' }],
    drives: [{ driveKey: validHex() }]
  })
  const res = await request(port, 'POST', '/api/authors/seeding.json', manifest)
  t.is(res.statusCode, 503)
  t.ok(res.body.error?.startsWith('unsupported: '))
})

test('unauthenticated management endpoint returns errorCode auth-required', async (t) => {
  const { port } = await setupApi(t)
  // /api/manage/config requires auth — hit without it.
  const res = await request(port, 'POST', '/api/manage/config', { maxConnections: 100 })
  t.is(res.statusCode, 401)
  t.is(res.body.errorCode, 'auth-required',
    'errorCode field is machine-readable')
  t.ok(res.body.error, 'human-readable error string still present for back-compat')
})

// ─── Signed fork-proof server-side requirement ──────────────────

test('POST /api/forks/proof rejects unsigned bare proofs', async (t) => {
  // Set up node with ForkDetector
  const { ForkDetector } = await import('p2p-hiverelay/core/fork-detector.js')
  const fd = new ForkDetector({})
  const { port } = await setupApi(t, { forkDetector: fd })

  // Bare unsigned proof — pre-v0.6.0 shape
  const res = await request(port, 'POST', '/api/forks/proof', {
    hypercoreKey: 'a'.repeat(64),
    blockIndex: 0,
    evidenceA: { fromRelay: 'r1', block: 'b1', signature: 's1' },
    evidenceB: { fromRelay: 'r2', block: 'b2', signature: 's2' }
  })
  t.is(res.statusCode, 400)
  t.ok(res.body.error?.includes('invalid signed proof'))
})

test('POST /api/forks/proof accepts properly signed envelope', async (t) => {
  const { ForkDetector } = await import('p2p-hiverelay/core/fork-detector.js')
  const { signForkProof } = await import('p2p-hiverelay/core/fork-proof-signing.js')
  const sodium = (await import('sodium-universal')).default
  const b4a = (await import('b4a')).default

  const fd = new ForkDetector({})
  const { port } = await setupApi(t, { forkDetector: fd })

  const publicKey = b4a.alloc(32)
  const secretKey = b4a.alloc(64)
  sodium.crypto_sign_keypair(publicKey, secretKey)

  // A genuine fork proof: two DIFFERENT blocks each signed by the
  // hypercore's own key. Post-HR-SVC-004 the endpoint requires this — a
  // signed envelope over junk evidence no longer quarantines.
  const fork = genuineForkProof(sodium, b4a)
  const signed = signForkProof({
    hypercoreKey: fork.hypercoreKey,
    blockIndex: 7,
    evidence: [fork.evidenceA, fork.evidenceB]
  }, { publicKey, secretKey })

  const res = await request(port, 'POST', '/api/forks/proof', signed)
  t.is(res.statusCode, 200)
  t.ok(res.body.ok)
  t.is(res.body.observer, b4a.toString(publicKey, 'hex'))
  t.ok(fd.isQuarantined(fork.hypercoreKey), 'genuine proof quarantined the drive')
})

test('POST /api/forks/proof rolls back memory when fork save fails', async (t) => {
  const { ForkDetector } = await import('p2p-hiverelay/core/fork-detector.js')
  const { signForkProof } = await import('p2p-hiverelay/core/fork-proof-signing.js')
  const sodium = (await import('sodium-universal')).default
  const b4a = (await import('b4a')).default

  const fd = new ForkDetector({})
  fd.save = async () => { throw new Error('readonly fork store') }
  const { port } = await setupApi(t, { forkDetector: fd })

  const publicKey = b4a.alloc(32)
  const secretKey = b4a.alloc(64)
  sodium.crypto_sign_keypair(publicKey, secretKey)

  const fork = genuineForkProof(sodium, b4a)
  const signed = signForkProof({
    hypercoreKey: fork.hypercoreKey,
    blockIndex: 7,
    evidence: [fork.evidenceA, fork.evidenceB]
  }, { publicKey, secretKey })

  const res = await request(port, 'POST', '/api/forks/proof', signed)
  t.is(res.statusCode, 500)
  t.is(res.body.errorCode, 'persist-failed')
  t.ok(res.body.error?.startsWith('persist-failed: '))
  t.is(fd.list().length, 0, 'failed save did not leave runtime fork proof')
  t.absent(fd.isQuarantined(fork.hypercoreKey), 'failed save did not quarantine the key')
})

test('POST /api/forks/proof rejects tampered signed proof', async (t) => {
  const { ForkDetector } = await import('p2p-hiverelay/core/fork-detector.js')
  const { signForkProof } = await import('p2p-hiverelay/core/fork-proof-signing.js')
  const sodium = (await import('sodium-universal')).default
  const b4a = (await import('b4a')).default

  const fd = new ForkDetector({})
  const { port } = await setupApi(t, { forkDetector: fd })

  const publicKey = b4a.alloc(32)
  const secretKey = b4a.alloc(64)
  sodium.crypto_sign_keypair(publicKey, secretKey)

  const signed = signForkProof({
    hypercoreKey: 'c'.repeat(64),
    blockIndex: 7,
    evidence: [{ fromRelay: 'r1', block: 'b1', signature: 's1' }, { fromRelay: 'r2', block: 'b2', signature: 's2' }]
  }, { publicKey, secretKey })
  // Tamper after signing
  signed.proof.blockIndex = 999

  const res = await request(port, 'POST', '/api/forks/proof', signed)
  t.is(res.statusCode, 400)
  t.ok(res.body.error?.includes('invalid signed proof'))
})

// ─── Per-endpoint rate limit ────────────────────────────────────

test('per-endpoint rate limit triggers on /api/wizard/payout brute force', async (t) => {
  const { port } = await setupApi(t)

  // Send 14 POSTs to /api/wizard/payout — limit is 10/min/IP
  let blockedCount = 0
  for (let i = 0; i < 14; i++) {
    const res = await request(port, 'POST', '/api/wizard/payout', { address: 'attempt-' + i })
    if (res.statusCode === 429) blockedCount++
  }
  t.ok(blockedCount >= 2, 'at least 2 of 14 requests were rate-limited (10/min cap)')
})

test('429 response includes machine-readable errorCode', async (t) => {
  const { port } = await setupApi(t)
  // Burst past the /api/wizard/payout limit (10/min)
  for (let i = 0; i < 10; i++) {
    await request(port, 'POST', '/api/wizard/payout', { address: 'x' })
  }
  const res = await request(port, 'POST', '/api/wizard/payout', { address: 'x' })
  t.is(res.statusCode, 429)
  t.is(res.body.errorCode, 'rate-limited')
  t.ok(res.body.error?.startsWith('rate-limited: '))
  t.is(res.headers['retry-after'], '60')
  t.ok(res.headers['content-type']?.includes('application/json; charset=utf-8'), 'rate-limit response is typed as JSON')
  t.is(res.headers['x-content-type-options'], 'nosniff', 'rate-limit response disables content sniffing')
  t.is(res.headers['cache-control'], 'no-store, max-age=0', 'rate-limit response is not cached by default')
})

test('non-rate-limited endpoints unaffected', async (t) => {
  const { port } = await setupApi(t)
  // /health should always work, no per-endpoint cap
  for (let i = 0; i < 30; i++) {
    const res = await request(port, 'GET', '/health')
    t.is(res.statusCode, 200, 'health unaffected by per-endpoint limit')
    if (res.statusCode !== 200) break
  }
})

// ─── Content-Type validation for POST (audit item 1.6) ──────────
// A POST carrying a non-JSON Content-Type must be rejected with 400 before
// any handler runs, so a body parser is never handed a non-JSON payload.

test('POST with non-JSON Content-Type is rejected with 400', async (t) => {
  const { port } = await setupApi(t)
  // /api/v1/dispatch sits after the global Content-Type gate; the gate fires
  // before its auth check, so a text/plain body is rejected up front.
  const res = await request(port, 'POST', '/api/v1/dispatch', 'route=ai.infer', {
    'Content-Type': 'text/plain'
  })
  t.is(res.statusCode, 400, 'non-JSON Content-Type rejected')
  t.ok(/Content-Type must be application\/json/.test(res.body.error || ''),
    'error explains the JSON requirement')
})

test('POST Content-Type requires exact application/json media type', async (t) => {
  const { port } = await setupApi(t)

  const parameterSmuggle = await request(port, 'POST', '/api/v1/dispatch', { route: 'ai.infer' }, {
    'Content-Type': 'text/plain; application/json'
  })
  t.is(parameterSmuggle.statusCode, 400, 'JSON-looking parameter rejected')

  const jsonp = await request(port, 'POST', '/api/v1/dispatch', { route: 'ai.infer' }, {
    'Content-Type': 'application/jsonp'
  })
  t.is(jsonp.statusCode, 400, 'JSON-looking subtype rejected')

  const jsonWithCharset = await request(port, 'POST', '/api/v1/dispatch', { route: 'ai.infer' }, {
    'Content-Type': 'application/json; charset=utf-8'
  })
  t.not(jsonWithCharset.statusCode, 400, 'application/json parameters still accepted')
})

test('POST with chunked body and no Content-Type is rejected with 400', async (t) => {
  const { port } = await setupApi(t)
  // No Content-Length and no Content-Type makes Node send a chunked request.
  // The content-type gate must still reject it before auth or body parsing.
  const res = await requestRaw(port, 'POST', '/api/v1/dispatch', JSON.stringify({ route: 'ai.infer' }))
  t.is(res.statusCode, 400, 'chunked body without Content-Type rejected')
  t.ok(/Content-Type must be application\/json/.test(res.body.error || ''),
    'error explains the JSON requirement')
  t.is(res.headers.connection, 'close', 'early body rejection closes the connection')
})

test('POST with application/json Content-Type passes the gate', async (t) => {
  const { port } = await setupApi(t)
  // Positive control: same endpoint, correct Content-Type → the gate does NOT
  // fire, so we get past it (401 auth, not the 400 Content-Type rejection).
  const res = await request(port, 'POST', '/api/v1/dispatch', { route: 'ai.infer' }, {
    'Content-Type': 'application/json'
  })
  t.not(res.statusCode, 400, 'JSON Content-Type is not blocked by the gate')
})

// ─── Unknown-route 404 ──────────────────────────────────────────

test('GET an unknown API route returns 404 with an error body', async (t) => {
  const { port } = await setupApi(t)
  const res = await request(port, 'GET', '/api/this-route-does-not-exist')
  t.is(res.statusCode, 404, 'unknown route is 404')
  t.ok(res.body.error, 'error body present')
})