diff --git a/server/src/middleware/arena-proxy.test.ts b/server/src/middleware/arena-proxy.test.ts index c7a8b56..00a3821 100644 --- a/server/src/middleware/arena-proxy.test.ts +++ b/server/src/middleware/arena-proxy.test.ts @@ -103,6 +103,24 @@ function buildProxyingApp() { 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(fn: (baseUrl: string) => Promise): Promise { + const app = buildProxyingApp() + let server: ServerType + const baseUrl = await new Promise((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((resolve) => server.close(() => resolve())) + } +} + describe('arenaProxy', () => { const originalEnv = process.env.ARENA_UPSTREAM_URL @@ -229,22 +247,23 @@ describe('arenaProxy', () => { it('forwards the client address in x-forwarded-for', async () => { process.env.ARENA_UPSTREAM_URL = upstreamUrl - const app = buildProxyingApp() - const res = await app.request('/api/echo', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), + // 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(res.status).toBe(200) - const body = await res.json() as { xff: string | null } - // app.request() drives the Hono app directly (no real Node socket), so the - // remoteAddress lookup this proxy relies on may legitimately be - // undetermined here — the behavioral contract is that the header is - // either forwarded (non-empty) or cleanly absent, never invented/garbage. - if (body.xff !== null) { - expect(body.xff.length).toBeGreaterThan(0) - } + + 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 () => {