OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / api-index-proxy.test.js

196 lines · javascript ref:
import test from 'brittle'
import {
  INDEX_PROXY_RESPONSE_LIMIT,
  INDEX_PROXY_TIMEOUT_MS,
  buildIndexProxyDisabledResponse,
  buildIndexProxyRouteResponse,
  buildIndexProxyTarget
} from '../../packages/core/core/relay-node/api-index-proxy.js'

function headers (values = {}) {
  return {
    get (name) {
      return values[String(name).toLowerCase()] || null
    }
  }
}

function response (opts = {}) {
  return {
    status: opts.status || 200,
    headers: headers(opts.headers),
    body: opts.body || null,
    text: opts.text || (async () => opts.textValue || '')
  }
}

function streamBody (chunks) {
  let index = 0
  return {
    getReader () {
      return {
        async read () {
          if (index >= chunks.length) return { done: true }
          return { done: false, value: chunks[index++] }
        }
      }
    }
  }
}

function abortFactory (calls) {
  return () => ({
    signal: { aborted: false },
    abort () {
      calls.push('abort')
      this.signal.aborted = true
    }
  })
}

test('api index proxy: disabled response is stable and public-safe', (t) => {
  t.alike(buildIndexProxyDisabledResponse(), {
    kind: 'json',
    status: 501,
    payload: {
      error: 'index sidecar not configured',
      errorCode: 'index-disabled'
    }
  })
})

test('api index proxy: target preserves path and query without forwarding client headers', async (t) => {
  const url = new URL('http://relay.local/index/rooms/demo?limit=2')
  t.is(buildIndexProxyTarget('http://127.0.0.1:9000', url), 'http://127.0.0.1:9000/index/rooms/demo?limit=2')

  const calls = []
  const result = await buildIndexProxyRouteResponse({
    base: 'http://127.0.0.1:9000',
    url,
    fetchImpl: async (target, opts) => {
      calls.push({ target, opts })
      return response({
        status: 202,
        textValue: '{"ok":true}'
      })
    },
    createAbortController: abortFactory([]),
    setTimeoutFn: () => 1,
    clearTimeoutFn: () => {}
  })

  t.is(calls.length, 1)
  t.is(calls[0].target, 'http://127.0.0.1:9000/index/rooms/demo?limit=2')
  t.alike(calls[0].opts.headers, { Accept: 'application/json' })
  t.is(calls[0].opts.method, 'GET')
  t.is(result.kind, 'text')
  t.is(result.status, 202)
  t.is(result.text, '{"ok":true}')
  t.alike(result.headers, { 'Content-Type': 'application/json' })
})

test('api index proxy: streams upstream JSON while enforcing a byte cap', async (t) => {
  const result = await buildIndexProxyRouteResponse({
    base: 'http://sidecar',
    url: new URL('http://relay.local/api/index/room'),
    fetchImpl: async () => response({
      body: streamBody([Buffer.from('{"room":'), Buffer.from('"abc"}')])
    }),
    createAbortController: abortFactory([]),
    setTimeoutFn: () => 1,
    clearTimeoutFn: () => {}
  })

  t.is(result.kind, 'buffer')
  t.is(result.status, 200)
  t.is(result.body.toString(), '{"room":"abc"}')
})

test('api index proxy: declared oversized upstream body aborts before buffering', async (t) => {
  const aborts = []
  const result = await buildIndexProxyRouteResponse({
    base: 'http://sidecar',
    url: new URL('http://relay.local/index/search'),
    fetchImpl: async () => response({
      headers: { 'content-length': String(INDEX_PROXY_RESPONSE_LIMIT + 1) },
      textValue: 'must not be read'
    }),
    createAbortController: abortFactory(aborts),
    setTimeoutFn: () => 1,
    clearTimeoutFn: () => {}
  })

  t.alike(aborts, ['abort'])
  t.alike(result, {
    kind: 'json',
    status: 502,
    payload: { error: 'index response too large' }
  })
})

test('api index proxy: streamed oversized upstream body aborts incrementally', async (t) => {
  const aborts = []
  const result = await buildIndexProxyRouteResponse({
    base: 'http://sidecar',
    url: new URL('http://relay.local/index/search'),
    limitBytes: 4,
    fetchImpl: async () => response({
      body: streamBody([Buffer.from('abcd'), Buffer.from('e')])
    }),
    createAbortController: abortFactory(aborts),
    setTimeoutFn: () => 1,
    clearTimeoutFn: () => {}
  })

  t.alike(aborts, ['abort'])
  t.is(result.kind, 'json')
  t.is(result.status, 502)
  t.is(result.payload.error, 'index response too large')
})

test('api index proxy: text fallback uses byte length for cap enforcement', async (t) => {
  const result = await buildIndexProxyRouteResponse({
    base: 'http://sidecar',
    url: new URL('http://relay.local/index/search'),
    limitBytes: 3,
    fetchImpl: async () => response({
      textValue: 'éé'
    }),
    createAbortController: abortFactory([]),
    setTimeoutFn: () => 1,
    clearTimeoutFn: () => {}
  })

  t.is(result.kind, 'json')
  t.is(result.status, 502)
  t.is(result.payload.error, 'index response too large')
})

test('api index proxy: timeout and fetch failures produce stable public errors with internal event detail', async (t) => {
  const timeout = new Error('private sidecar path /tmp/index.sock stalled')
  timeout.name = 'AbortError'
  const unreachable = new Error('connect ECONNREFUSED 127.0.0.1:9100')

  for (const [err, status] of [[timeout, 504], [unreachable, 502]]) {
    const result = await buildIndexProxyRouteResponse({
      base: 'http://sidecar',
      url: new URL('http://relay.local/index/search'),
      fetchImpl: async () => { throw err },
      createAbortController: abortFactory([]),
      setTimeoutFn: (fn, ms) => {
        t.is(ms, INDEX_PROXY_TIMEOUT_MS)
        return fn
      },
      clearTimeoutFn: () => {}
    })

    t.is(result.kind, 'json')
    t.is(result.status, status)
    t.is(result.payload.error, 'unsupported: index sidecar unreachable')
    t.is(result.payload.errorCode, 'index-unreachable')
    t.is(result.event.name, 'index-proxy-error')
    t.is(result.event.detail.error, err)
    t.is(result.event.detail.status, status)
  }
})