Files
ccs/tests/unit/web-server/cliproxy-local-proxy.test.ts
Tam Nhu Tran 27409b789b fix(docker): harden proxy with timeout, Bun compat, and test coverage
- Add 30s timeout on proxy requests to prevent indefinite hangs
- Wrap resolveLocalCliproxyPort in try/catch with default port fallback
- Simplify buildProxyBody: remove fragile content-length check that
  caused Bun to fall through to req.pipe() on consumed streams
- Replace proxyRes.pipe(res) with manual streaming for Bun compatibility
  (pipe hangs after writeHead in Bun runtime)
- Replace deprecated req.on('aborted') with res.on('close') cleanup
  (req.on('close') fires with req.destroyed=true in Bun after body
  consumption, prematurely destroying the proxy connection)
- Explicitly end proxy request for bodyless methods (GET/HEAD/OPTIONS)
  instead of piping an already-consumed express stream
- Add server.closeAllConnections() in test cleanup to prevent hangs
- Add GET passthrough and 502 unreachable test cases
2026-04-01 11:39:34 -04:00

153 lines
4.6 KiB
TypeScript

import { afterEach, describe, expect, it } from 'bun:test';
import express from 'express';
import http from 'http';
import type { AddressInfo } from 'net';
import {
createCliproxyLocalProxyRouter,
type CliproxyLocalProxyDeps,
} from '../../../src/web-server/routes/cliproxy-local-proxy';
const servers: http.Server[] = [];
async function listen(server: http.Server): Promise<number> {
servers.push(server);
return await new Promise<number>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
resolve((server.address() as AddressInfo).port);
});
});
}
async function createBackendServer(
handler: http.RequestListener
): Promise<{ port: number; server: http.Server }> {
const server = http.createServer(handler);
const port = await listen(server);
return { port, server };
}
async function createProxyServer(options: {
enforceAccess?: CliproxyLocalProxyDeps['enforceAccess'];
resolveTargetPort: () => number;
}): Promise<{ baseUrl: string; server: http.Server }> {
const app = express();
app.use(express.json());
app.use(
'/api/cliproxy-local',
createCliproxyLocalProxyRouter({
enforceAccess: options.enforceAccess,
resolveTargetPort: options.resolveTargetPort,
})
);
const server = http.createServer(app);
const port = await listen(server);
return { baseUrl: `http://127.0.0.1:${port}`, server };
}
afterEach(async () => {
while (servers.length > 0) {
const server = servers.pop();
if (!server) {
continue;
}
// Force-close keep-alive connections so server.close() doesn't hang
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
describe('cliproxy local proxy route', () => {
it('blocks requests when local-access enforcement fails', async () => {
let backendHit = false;
const backend = await createBackendServer((_req, res) => {
backendHit = true;
res.writeHead(200).end('ok');
});
const proxy = await createProxyServer({
resolveTargetPort: () => backend.port,
enforceAccess: (_req, res) => {
res.status(403).json({ error: 'blocked' });
return false;
},
});
const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/management.html`);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ error: 'blocked' });
expect(backendHit).toBe(false);
});
it('forwards JSON request bodies after express.json has parsed them', async () => {
const backend = await createBackendServer((req, res) => {
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
body: JSON.parse(body),
method: req.method,
path: req.url,
})
);
});
});
const proxy = await createProxyServer({
resolveTargetPort: () => backend.port,
enforceAccess: () => true,
});
const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/v0/management/test`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, ids: ['a', 'b'] }),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
body: { enabled: true, ids: ['a', 'b'] },
method: 'PATCH',
path: '/v0/management/test',
});
});
it('forwards GET requests and returns backend response', async () => {
const backend = await createBackendServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html>management panel</html>');
});
const proxy = await createProxyServer({
resolveTargetPort: () => backend.port,
enforceAccess: () => true,
});
const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/management.html`);
expect(response.status).toBe(200);
expect(await response.text()).toBe('<html>management panel</html>');
});
it('returns 502 when CLIProxy is not reachable', async () => {
// Use a port with nothing listening
const proxy = await createProxyServer({
resolveTargetPort: () => 19999,
enforceAccess: () => true,
});
const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/`);
expect(response.status).toBe(502);
expect(await response.json()).toEqual({ error: 'CLIProxy is not reachable' });
});
});