OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / gateway-standalone-server.test.js

252 lines · javascript ref:
import test from 'brittle'
import http from 'http'
import { Readable } from 'stream'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import {
  buildGatewaySeedErrorResponse,
  isStandaloneSeedRequestAuthorized,
  readGatewaySeedBody,
  startGateway,
  validateStandaloneGatewayOptions,
  validateGatewaySeedKey
} from 'p2p-hiverelay/gateway/server.js'

function reqFor (body, headers = {}) {
  const req = Readable.from(body === null || body === undefined ? [] : [body])
  req.method = 'POST'
  req.headers = {
    'content-type': 'application/json',
    ...(body === null || body === undefined ? {} : { 'content-length': String(Buffer.byteLength(body)) }),
    ...headers
  }
  return req
}

function request (port, method, path, body = null, headers = {}) {
  return new Promise((resolve, reject) => {
    const req = http.request({
      hostname: '127.0.0.1',
      port,
      method,
      path,
      agent: false,
      headers
    }, res => {
      const chunks = []
      res.on('data', chunk => chunks.push(chunk))
      res.on('end', () => {
        const raw = Buffer.concat(chunks)
        let payload = raw.toString()
        try { payload = JSON.parse(payload) } catch {}
        resolve({ statusCode: res.statusCode, headers: res.headers, payload })
      })
    })
    req.on('error', reject)
    req.end(body)
  })
}

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

test('standalone gateway seed body validates 64-hex keys', async (t) => {
  const key = 'A'.repeat(64)
  const body = await readGatewaySeedBody(reqFor(JSON.stringify({ key })))

  t.alike(body, { key: key.toLowerCase() })
  t.is(validateGatewaySeedKey(key), key.toLowerCase())
  t.exception(() => validateGatewaySeedKey('a'.repeat(63)), /64 hex/)
  t.exception(() => validateGatewaySeedKey('z'.repeat(64)), /64 hex/)
})

test('standalone gateway seed body rejects non-json media types before parsing', async (t) => {
  const err = await rejects(t, readGatewaySeedBody(reqFor('key=' + 'a'.repeat(64), {
    'content-type': 'application/x-www-form-urlencoded'
  })), /Content-Type must be application\/json/, 400)
  t.is(err.close, true, 'body-bearing media-type rejection should close')
})

test('standalone gateway seed body rejects oversized and non-object JSON bodies', async (t) => {
  await rejects(t, readGatewaySeedBody(reqFor(JSON.stringify({
    key: 'a'.repeat(64),
    pad: 'x'.repeat(4096)
  }))), /Request body too large/, 413)

  await rejects(t, readGatewaySeedBody(reqFor(JSON.stringify(['a'.repeat(64)]))), /JSON body must be an object/, 400)
  await rejects(t, readGatewaySeedBody(reqFor(JSON.stringify({ key: 'not-a-key' }))), /64 hex/, 400)
})

test('standalone gateway seed errors redact unexpected internals', async (t) => {
  const validation = await rejects(t, readGatewaySeedBody(reqFor('key=' + 'a'.repeat(64), {
    'content-type': 'application/x-www-form-urlencoded'
  })), /Content-Type must be application\/json/, 400)

  const exposed = buildGatewaySeedErrorResponse(validation)
  t.is(exposed.status, 400, 'validation status is preserved')
  t.alike(exposed.payload, { error: 'Content-Type must be application/json' })
  t.is(exposed.close, true, 'body-bearing media-type rejection keeps close signal')

  const internal = new Error('failed opening /private/data with HIVERELAY_API_KEY=secret')
  internal.statusCode = 503
  const redacted = buildGatewaySeedErrorResponse(internal)
  t.is(redacted.status, 500, 'unexpected internal status is collapsed')
  t.alike(redacted.payload, { error: 'Gateway seed failed' }, 'public error is generic')
  t.is(redacted.close, false, 'unexpected internal errors do not force close by default')
})

test('standalone gateway defaults to loopback with dynamic mutation disabled', (t) => {
  const config = validateStandaloneGatewayOptions({ port: 0 })
  t.is(config.host, '127.0.0.1')
  t.is(config.port, 0, 'ephemeral test port remains valid')
  t.is(config.allowDynamicSeed, false)
  t.is(config.seedToken, null)
  t.is(config.maxSeededDrives, 64, 'total drive count has a finite default')
  t.is(config.maxSeedRequestsPerMinute, 30, 'mutating request rate has a finite default')
  t.is(config.seedOperationTimeoutMs, 30_000, 'drive open has a finite default')
})

test('standalone gateway non-loopback dynamic seed requires complete bounded auth config', (t) => {
  t.exception(() => validateStandaloneGatewayOptions({
    host: '0.0.0.0',
    allowDynamicSeed: true
  }), /requires a configured seedToken/)
  t.exception(() => validateStandaloneGatewayOptions({
    host: '0.0.0.0',
    allowDynamicSeed: true,
    seedToken: 'short'
  }), /32 to 4096/)
  t.exception(() => validateStandaloneGatewayOptions({
    host: '0.0.0.0',
    allowDynamicSeed: true,
    seedToken: 's'.repeat(32),
    maxSeededDrives: 0
  }), /maxSeededDrives/)
  t.exception(() => validateStandaloneGatewayOptions({
    host: '0.0.0.0',
    allowDynamicSeed: true,
    seedToken: 's'.repeat(32),
    maxSeedRequestsPerMinute: 601
  }), /maxSeedRequestsPerMinute/)

  const config = validateStandaloneGatewayOptions({
    host: '0.0.0.0',
    allowDynamicSeed: true,
    seedToken: 's'.repeat(32),
    maxSeededDrives: 8,
    maxSeedRequestsPerMinute: 4
  })
  t.is(config.host, '0.0.0.0')
  t.is(config.maxSeededDrives, 8)
  t.is(config.maxSeedRequestsPerMinute, 4)
})

test('standalone gateway bearer authorization rejects missing, wrong, and duplicate headers', (t) => {
  const token = 'correct-token-'.padEnd(32, 'x')
  const req = value => ({
    headers: value == null ? {} : { authorization: value },
    headersDistinct: value == null ? {} : { authorization: [value] },
    rawHeaders: value == null ? [] : ['Authorization', value]
  })
  t.ok(isStandaloneSeedRequestAuthorized(req(`Bearer ${token}`), token))
  t.absent(isStandaloneSeedRequestAuthorized(req(null), token))
  t.absent(isStandaloneSeedRequestAuthorized(req('Basic nope'), token))
  t.absent(isStandaloneSeedRequestAuthorized(req('Bearer wrong-token-that-is-long-enough'), token))
  t.absent(isStandaloneSeedRequestAuthorized({
    headers: { authorization: `Bearer ${token}` },
    headersDistinct: { authorization: [`Bearer ${token}`, `Bearer ${token}`] },
    rawHeaders: ['Authorization', `Bearer ${token}`, 'Authorization', `Bearer ${token}`]
  }, token), 'duplicate authorization is never coalesced')
  t.ok(isStandaloneSeedRequestAuthorized(req(null), null), 'loopback opt-in may omit a token')
})

test('standalone gateway validates and bounds configured seed keys', (t) => {
  const key = 'A'.repeat(64)
  const config = validateStandaloneGatewayOptions({ seedKeys: [key, key.toLowerCase()] })
  t.alike(config.seedKeys, [key.toLowerCase()], 'duplicate keys collapse before opening sessions')
  t.exception(() => validateStandaloneGatewayOptions({
    seedKeys: [key, 'b'.repeat(64)],
    maxSeededDrives: 1
  }), /exceeds maxSeededDrives/)
  t.exception(() => validateStandaloneGatewayOptions({ host: 'example.com' }), /canonical IP address or localhost/)
  t.exception(() => validateStandaloneGatewayOptions({ port: '9100' }), /port must be an integer/)
})

test('standalone gateway live defaults bind loopback and hide dynamic seed', async (t) => {
  const dir = await mkdtemp(join(tmpdir(), 'hiverelay-standalone-gateway-'))
  let gateway = null
  t.teardown(async () => {
    if (gateway) await gateway.close()
    await rm(dir, { recursive: true, force: true })
  })

  gateway = await startGateway({
    port: 0,
    storage: dir,
    enableDiscovery: false,
    seedOperationTimeoutMs: 1000
  })
  const address = gateway.server.address()
  t.is(address.address, '127.0.0.1')
  t.is(gateway.config.allowDynamicSeed, false)

  const health = await request(address.port, 'GET', '/health')
  t.is(health.statusCode, 200)
  t.is(health.payload.dynamicSeedEnabled, false)

  const body = JSON.stringify({ key: 'a'.repeat(64) })
  const seed = await request(address.port, 'POST', '/v1/seed', body, {
    'Content-Type': 'application/json',
    'Content-Length': String(Buffer.byteLength(body))
  })
  t.is(seed.statusCode, 404, 'mutating route is absent without explicit opt-in')
  t.is(seed.headers['access-control-allow-methods'], 'GET, HEAD, OPTIONS', 'CORS never advertises mutation')
  t.is(gateway.seededDrives.size, 0)

  await Promise.all([gateway.close(), gateway.close()])
  t.absent(gateway.server.listening, 'concurrent close is idempotent')
})

test('standalone gateway live opt-in enforces a configured bearer before opening drives', async (t) => {
  const dir = await mkdtemp(join(tmpdir(), 'hiverelay-standalone-gateway-auth-'))
  const token = 'standalone-secret-'.padEnd(32, 'x')
  let gateway = null
  t.teardown(async () => {
    if (gateway) await gateway.close()
    await rm(dir, { recursive: true, force: true })
  })
  gateway = await startGateway({
    port: 0,
    storage: dir,
    enableDiscovery: false,
    allowDynamicSeed: true,
    seedToken: token,
    seedOperationTimeoutMs: 1000
  })
  const body = JSON.stringify({ key: 'a'.repeat(64) })
  const headers = {
    'Content-Type': 'application/json',
    'Content-Length': String(Buffer.byteLength(body))
  }
  const missing = await request(gateway.server.address().port, 'POST', '/v1/seed', body, headers)
  t.is(missing.statusCode, 401)
  t.ok(/^Bearer /.test(missing.headers['www-authenticate']))
  const wrong = await request(gateway.server.address().port, 'POST', '/v1/seed', body, {
    ...headers,
    Authorization: 'Bearer wrong-token-that-is-long-enough'
  })
  t.is(wrong.statusCode, 401)
  t.is(gateway.seededDrives.size, 0, 'authentication fails before drive/session creation')
})