CI / check (push) Failing after 6m14s
The prior test drove the proxying app via Hono's in-process app.request() harness, which has no real Node socket — so it could only assert the header was non-empty-or-absent, not that the real client IP round-trips. Spin the proxying app up with @hono/node-server (real loopback socket) and assert the upstream actually receives 127.0.0.1/::1, exercising the same remoteAddress lookup arenaProxy uses in production. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
279 lines
9.6 KiB
TypeScript
279 lines
9.6 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
|
import { Hono } from 'hono'
|
|
import { serve, type ServerType } from '@hono/node-server'
|
|
import type { AddressInfo } from 'node:net'
|
|
import { gzipSync } from 'node:zlib'
|
|
import { arenaProxy } from './arena-proxy.js'
|
|
|
|
// --- Real upstream "arena" server: a second, independent Hono app --------
|
|
const registeredBots: { id: string; secret: string; name: string }[] = []
|
|
|
|
const upstream = new Hono()
|
|
|
|
upstream.post('/api/bots', async (c) => {
|
|
const body = await c.req.json().catch(() => ({}))
|
|
const bot = { id: `bot-${registeredBots.length + 1}`, secret: 'shh', name: body.name ?? 'unnamed' }
|
|
registeredBots.push(bot)
|
|
return c.json(bot)
|
|
})
|
|
|
|
upstream.get('/api/bots', (c) => c.json(registeredBots))
|
|
|
|
upstream.get('/api/echo', (c) => {
|
|
return c.json({
|
|
method: c.req.method,
|
|
path: new URL(c.req.url).pathname,
|
|
query: new URL(c.req.url).search,
|
|
host: c.req.header('host') ?? null,
|
|
})
|
|
})
|
|
|
|
upstream.post('/api/echo', async (c) => {
|
|
const body = await c.req.json().catch(() => null)
|
|
return c.json({
|
|
method: c.req.method,
|
|
path: new URL(c.req.url).pathname,
|
|
query: new URL(c.req.url).search,
|
|
body,
|
|
host: c.req.header('host') ?? null,
|
|
xff: c.req.header('x-forwarded-for') ?? null,
|
|
})
|
|
})
|
|
|
|
upstream.get('/api/sse', (c) => {
|
|
const encoder = new TextEncoder()
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
controller.enqueue(encoder.encode('event: frame\ndata: {"n":1}\n\n'))
|
|
await new Promise((r) => setTimeout(r, 60))
|
|
controller.enqueue(encoder.encode('event: frame\ndata: {"n":2}\n\n'))
|
|
await new Promise((r) => setTimeout(r, 60))
|
|
controller.enqueue(encoder.encode('event: frame\ndata: {"n":3}\n\n'))
|
|
controller.close()
|
|
},
|
|
})
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: { 'content-type': 'text/event-stream' },
|
|
})
|
|
})
|
|
|
|
upstream.get('/api/gzip-lie', (c) => {
|
|
// Upstream actually gzip-compresses the body and declares content-encoding
|
|
// for the COMPRESSED bytes. undici transparently decompresses on the
|
|
// proxy's fetch() before this code ever sees the response, so by the time
|
|
// the proxy builds its own Response, the stale content-encoding/
|
|
// content-length (describing the compressed representation) would corrupt
|
|
// what the caller receives if copied through verbatim — the proxy must
|
|
// strip them, not forward them.
|
|
const compressed = gzipSync(Buffer.from(JSON.stringify({ ok: true })))
|
|
return new Response(compressed, {
|
|
status: 200,
|
|
// content-length deliberately omitted — the underlying Node HTTP server
|
|
// computes and sends the real one automatically.
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'content-encoding': 'gzip',
|
|
},
|
|
})
|
|
})
|
|
|
|
let upstreamServer: ServerType
|
|
let upstreamUrl: string
|
|
|
|
beforeAll(async () => {
|
|
await new Promise<void>((resolve) => {
|
|
upstreamServer = serve({ fetch: upstream.fetch, port: 0 }, (info) => {
|
|
upstreamUrl = `http://127.0.0.1:${(info as AddressInfo).port}`
|
|
resolve()
|
|
})
|
|
})
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await new Promise<void>((resolve) => upstreamServer.close(() => resolve()))
|
|
})
|
|
|
|
// --- Proxying app under test ----------------------------------------------
|
|
function buildProxyingApp() {
|
|
const app = new Hono()
|
|
app.use('/api/*', arenaProxy)
|
|
app.get('/api/health', (c) => c.json({ status: 'ok', name: 'botfights' }))
|
|
app.get('/api/sentinel', (c) => c.json({ sentinel: true }))
|
|
return app
|
|
}
|
|
|
|
// A real Node HTTP server for the proxying app itself, needed to exercise
|
|
// the actual socket.remoteAddress lookup arenaProxy uses for x-forwarded-for
|
|
// — Hono's in-process app.request() harness has no real socket to read.
|
|
async function withRealProxyingServer<T>(fn: (baseUrl: string) => Promise<T>): Promise<T> {
|
|
const app = buildProxyingApp()
|
|
let server: ServerType
|
|
const baseUrl = await new Promise<string>((resolve) => {
|
|
server = serve({ fetch: app.fetch, port: 0 }, (info) => {
|
|
resolve(`http://127.0.0.1:${(info as AddressInfo).port}`)
|
|
})
|
|
})
|
|
try {
|
|
return await fn(baseUrl)
|
|
} finally {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
}
|
|
}
|
|
|
|
describe('arenaProxy', () => {
|
|
const originalEnv = process.env.ARENA_UPSTREAM_URL
|
|
|
|
afterEach(() => {
|
|
if (originalEnv === undefined) delete process.env.ARENA_UPSTREAM_URL
|
|
else process.env.ARENA_UPSTREAM_URL = originalEnv
|
|
})
|
|
|
|
it('registers a bot upstream and reads it back through the proxy', async () => {
|
|
process.env.ARENA_UPSTREAM_URL = upstreamUrl
|
|
const app = buildProxyingApp()
|
|
|
|
const postRes = await app.request('/api/bots', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: 'cross-node-bot' }),
|
|
})
|
|
expect(postRes.status).toBe(200)
|
|
const posted = await postRes.json() as { name: string }
|
|
expect(posted.name).toBe('cross-node-bot')
|
|
|
|
const getRes = await app.request('/api/bots')
|
|
expect(getRes.status).toBe(200)
|
|
const list = await getRes.json() as { name: string }[]
|
|
expect(list.some((b) => b.name === 'cross-node-bot')).toBe(true)
|
|
})
|
|
|
|
it('falls through to local routers when ARENA_UPSTREAM_URL is unset', async () => {
|
|
delete process.env.ARENA_UPSTREAM_URL
|
|
const app = buildProxyingApp()
|
|
|
|
const res = await app.request('/api/sentinel')
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as { sentinel: boolean }
|
|
expect(body.sentinel).toBe(true)
|
|
})
|
|
|
|
it('answers /api/health locally even in proxy mode', async () => {
|
|
// Point at a port with nothing listening.
|
|
process.env.ARENA_UPSTREAM_URL = 'http://127.0.0.1:1'
|
|
const app = buildProxyingApp()
|
|
|
|
const res = await app.request('/api/health')
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as { status: string }
|
|
expect(body.status).toBe('ok')
|
|
})
|
|
|
|
it('forwards method, query string and JSON body unchanged', async () => {
|
|
process.env.ARENA_UPSTREAM_URL = upstreamUrl
|
|
const app = buildProxyingApp()
|
|
|
|
const res = await app.request('/api/echo?a=1&b=2', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ hello: 'world' }),
|
|
})
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as { method: string; path: string; query: string; body: unknown }
|
|
expect(body.method).toBe('POST')
|
|
expect(body.path).toBe('/api/echo')
|
|
expect(body.query).toBe('?a=1&b=2')
|
|
expect(body.body).toEqual({ hello: 'world' })
|
|
})
|
|
|
|
it('does not forward the inbound Host header', async () => {
|
|
process.env.ARENA_UPSTREAM_URL = upstreamUrl
|
|
const app = buildProxyingApp()
|
|
|
|
const res = await app.request('/api/echo', {
|
|
headers: { Host: 'caller-node.example.com' },
|
|
})
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as { host: string | null }
|
|
expect(body.host).not.toBe('caller-node.example.com')
|
|
expect(body.host).toBe(new URL(upstreamUrl).host)
|
|
})
|
|
|
|
it('strips response content-encoding and content-length', async () => {
|
|
process.env.ARENA_UPSTREAM_URL = upstreamUrl
|
|
const app = buildProxyingApp()
|
|
|
|
const res = await app.request('/api/gzip-lie')
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('content-encoding')).toBeNull()
|
|
expect(res.headers.get('content-length')).toBeNull()
|
|
const body = await res.json() as { ok: boolean }
|
|
expect(body.ok).toBe(true)
|
|
})
|
|
|
|
it('streams SSE incrementally through the proxy', async () => {
|
|
process.env.ARENA_UPSTREAM_URL = upstreamUrl
|
|
const app = buildProxyingApp()
|
|
|
|
const start = Date.now()
|
|
const res = await app.request('/api/sse')
|
|
expect(res.status).toBe(200)
|
|
expect(res.body).not.toBeNull()
|
|
|
|
const reader = res.body!.getReader()
|
|
const decoder = new TextDecoder()
|
|
let firstFrameAt: number | null = null
|
|
let buffer = ''
|
|
let frameCount = 0
|
|
|
|
while (true) {
|
|
const { value, done } = await reader.read()
|
|
if (done) break
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const frames = buffer.split('\n\n').filter((f) => f.includes('event: frame'))
|
|
if (frames.length > 0 && firstFrameAt === null) {
|
|
firstFrameAt = Date.now()
|
|
}
|
|
frameCount = frames.length
|
|
}
|
|
|
|
expect(frameCount).toBe(3)
|
|
// The first frame must have arrived well before the full ~120ms stream
|
|
// finished — proves the proxy piped the stream through instead of
|
|
// buffering the whole thing before responding.
|
|
expect(firstFrameAt).not.toBeNull()
|
|
expect(firstFrameAt! - start).toBeLessThan(100)
|
|
})
|
|
|
|
it('forwards the client address in x-forwarded-for', async () => {
|
|
process.env.ARENA_UPSTREAM_URL = upstreamUrl
|
|
|
|
// Drive the proxying app over a REAL socket (loopback) so
|
|
// c.env.incoming.socket.remoteAddress is actually populated, exercising
|
|
// the real code path instead of Hono's in-process app.request() harness.
|
|
const body = await withRealProxyingServer(async (baseUrl) => {
|
|
const res = await fetch(`${baseUrl}/api/echo`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
})
|
|
expect(res.status).toBe(200)
|
|
return await res.json() as { xff: string | null }
|
|
})
|
|
|
|
expect(body.xff).toBeTruthy()
|
|
// Loopback connection — either IPv4 or IPv6-mapped loopback form.
|
|
expect(body.xff).toMatch(/127\.0\.0\.1|::1|::ffff:127\.0\.0\.1/)
|
|
})
|
|
|
|
it('answers 502 when the arena is unreachable', async () => {
|
|
process.env.ARENA_UPSTREAM_URL = 'http://127.0.0.1:1'
|
|
const app = buildProxyingApp()
|
|
|
|
const res = await app.request('/api/bots')
|
|
expect(res.status).toBe(502)
|
|
const body = await res.json() as { error: string }
|
|
expect(body.error).toBeTruthy()
|
|
})
|
|
})
|