ReviewOS

also looking at this

stacks/rpx

fix(test): stop a leaked spawn mock hanging the gateway CLI tests (#2267), pin forwarded headers (#2265)

#2268
Merged chris wants to merge fix/2265-2267-proxy-header-and-cli-coverage into main
4 files +57 -4
packages/rpx/test/gateway.test.tsmodified+1-3
Changes to packages/rpx/test/gateway.test.ts
@@ -353,9 +353,7 @@ async function freePort(): Promise<number> {
353353 return port
354354}
355355
356const CLI_STARTS = process.platform !== 'linux'
357
358describe.skipIf(!CLI_STARTS)('rpx gateway (CLI, end to end)', () => {
356describe('rpx gateway (CLI, end to end)', () => {
359357 it('starts and prints its help', async () => {
360358 const cli = path.join(import.meta.dir, '..', 'bin', 'cli.ts')
361359 const child = spawn(process.execPath, [cli, '--help'], { stdio: ['ignore', 'pipe', 'pipe'] })
packages/rpx/test/process-manager.test.tsmodified+9-1
Changes to packages/rpx/test/process-manager.test.ts
@@ -45,7 +45,15 @@ describe('ProcessManager', () => {
4545 })
4646
4747 afterEach(() => {
48 // No cleanup needed - Bun's spyOn handles cleanup automatically
48 // Bun's spyOn does NOT restore on its own, and the `childProcess.spawn` spy
49 // above is installed on the shared module object — so without this it stays
50 // installed for every test file that runs after this one in the same `bun
51 // test` process. Anything spawning a subprocess then gets the stub back
52 // (pid 12345, no-op `on`/`stdout.on`), so its child never starts, never
53 // prints and never exits: #2267, where `rpx --help` appeared to hang on
54 // Linux CI purely because bun's file order happened to put this file first
55 // there. Restore, and keep it restored.
56 mock.restore()
4957 })
5058
5159 describe('isRunning', () => {
packages/rpx/test/proxy-handler.test.tsmodified+44-0
Changes to packages/rpx/test/proxy-handler.test.ts
@@ -246,6 +246,50 @@ describe('client address forwarding', () => {
246246 }
247247 })
248248
249 it('gives the upstream the whole forwarded set on the ordinary HTTP path', async () => {
250 // #2265: the report was that an upstream saw only `x-real-ip` plus a rewritten
251 // `Host`, with no `x-forwarded-host`/`-for`/`-proto`, leaving an app behind rpx
252 // unable to learn the hostname or scheme the client actually used — a CSRF
253 // origin guard comparing `Origin` against the host then rejected every real
254 // browser request. The pooled and fallback transports each write the set
255 // separately, so both are pinned here rather than only the one in use.
256 const upstream = Bun.serve({
257 port: 0,
258 hostname: '127.0.0.1',
259 fetch: r => new Response(JSON.stringify({
260 host: r.headers.get('host'),
261 for: r.headers.get('x-forwarded-for'),
262 realIp: r.headers.get('x-real-ip'),
263 forwardedHost: r.headers.get('x-forwarded-host'),
264 proto: r.headers.get('x-forwarded-proto'),
265 }), { headers: { 'content-type': 'application/json' } }),
266 })
267 try {
268 const handler = createProxyFetchHandler(() => ({ sourceHost: `127.0.0.1:${upstream.port}` }))
269 const expected = {
270 host: `127.0.0.1:${upstream.port}`,
271 for: '198.51.100.42',
272 realIp: '198.51.100.42',
273 forwardedHost: 'bughq.org',
274 proto: 'https',
275 }
276
277 // The pooled transport, which carries ordinary requests.
278 const pooled = await handler(req('https://bughq.org/api/thing'), serverWithPeer('198.51.100.42'))
279 expect(await pooled?.json()).toEqual(expected)
280
281 // ...and the fetch fallback, which the pool declines an SSE request to.
282 const fallback = await handler(
283 req('https://bughq.org/api/thing', { accept: 'text/event-stream' }),
284 serverWithPeer('198.51.100.42'),
285 )
286 expect(await fallback?.json()).toEqual(expected)
287 }
288 finally {
289 upstream.stop(true)
290 }
291 })
292
249293 it('carries the peer onto the websocket upgrade too', async () => {
250294 const handler = createProxyFetchHandler(() => ({ sourceHost: 'localhost:3002' }))
251295 let data: any
packages/rpx/test/proxy-pool.test.tsmodified+3-0
Changes to packages/rpx/test/proxy-pool.test.ts
@@ -183,6 +183,9 @@ describe('proxyViaPool', () => {
183183 expect(got['x-forwarded-for']).toBe('203.0.113.7')
184184 expect(got['x-real-ip']).toBe('203.0.113.7')
185185 expect(got['x-forwarded-host']).toBe('site.test')
186 // rpx terminates TLS, so the upstream cannot otherwise tell the client arrived
187 // over it — anything building absolute URLs or forcing HTTPS reads this.
188 expect(got['x-forwarded-proto']).toBe('https')
186189 expect(got['x-custom']).toBe('keep-me') // unrelated client headers pass through
187190 })
188191