/** * Tests for the pilot-tunnel bridge resource limits introduced in the * hardening pass: * - Frame-size cap enforced at the protocol decoders. * - Per-tunnel concurrent stream cap enforced by the bridge. * - Per-stream idle timeout (verified via internal helpers; the real * timer fires at 10 minutes which is too long for a unit test, so we * drive the timer paths via reduced state). * * The bridge is exercised through its loopback HTTP server with a mock * tunnel WebSocket; this keeps the test in-process and avoids needing a * full pilot agent for the cap surfaces. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import http from 'http'; import { EventEmitter } from 'events'; import { WebSocket } from 'ws'; import { MAX_FRAME_SIZE_BYTES, MAX_STREAMS_PER_TUNNEL, decodeBinaryFrame, decodeJsonFrame, } from '../pilot/protocol'; import { PilotTunnelBridge } from '../services/PilotTunnelBridge'; describe('protocol decoders', () => { it('rejects oversized binary frames', () => { const buf = Buffer.alloc(MAX_FRAME_SIZE_BYTES + 1); expect(() => decodeBinaryFrame(buf)).toThrow(/too large/); }); it('rejects undersized binary frames', () => { expect(() => decodeBinaryFrame(Buffer.alloc(3))).toThrow(/too short/); }); it('rejects oversized JSON frames', () => { const big = '"' + 'x'.repeat(MAX_FRAME_SIZE_BYTES) + '"'; expect(() => decodeJsonFrame(big)).toThrow(/too large/); }); it('rejects malformed JSON', () => { expect(() => decodeJsonFrame('not json')).toThrow(); }); it('rejects JSON without a type discriminator', () => { expect(() => decodeJsonFrame('{"hello":"world"}')).toThrow(/discriminator/); }); }); /** * Minimal mock that satisfies PilotTunnelBridge's contract with its * tunnelWs argument. The bridge calls `send`, reads `bufferedAmount`, * checks `readyState`, and listens for `message`, `close`, `error`. */ function makeMockTunnelWs(): EventEmitter & { sent: unknown[]; readyState: number; bufferedAmount: number; send: (data: unknown) => void; ping: () => void; close: () => void; } { const ws = new EventEmitter() as EventEmitter & { sent: unknown[]; readyState: number; bufferedAmount: number; send: (data: unknown) => void; ping: () => void; close: () => void; }; ws.sent = []; ws.readyState = WebSocket.OPEN; ws.bufferedAmount = 0; ws.send = (data: unknown) => { ws.sent.push(data); }; ws.ping = () => { /* no-op */ }; ws.close = () => { ws.readyState = WebSocket.CLOSED; ws.emit('close'); }; return ws; } describe('PilotTunnelBridge concurrent stream cap', () => { let bridge: PilotTunnelBridge; let mockWs: ReturnType; let loopbackUrl: string; beforeAll(async () => { mockWs = makeMockTunnelWs(); // The bridge constructor type-checks against `WebSocket`; the runtime // only needs the surface exercised above, so the cast keeps the test // honest without dragging in a real ws pair just to fill the cap. bridge = new PilotTunnelBridge(1, mockWs as unknown as WebSocket); await bridge.start(); loopbackUrl = bridge.getLoopbackUrl(); }); afterAll(() => { bridge.close(); }); it('refuses HTTP requests with 503 once the cap is reached', async () => { // Use openTcpStream to fill the stream map without putting any load // on the loopback HTTP socket pool. Each TCP stream consumes a slot // exactly the same way an HTTP request would. const filled: Array> = []; for (let i = 0; i < MAX_STREAMS_PER_TUNNEL; i++) { const handle = bridge.openTcpStream({ stack: 's', service: 'svc', port: 80 }); // Suppress the 'error' that fires later when we close the // bridge; the handle is used purely for the slot. handle?.on('error', () => { /* expected on cleanup */ }); filled.push(handle); } // Confirm all 1024 slot allocations actually serialized a tcp_open // frame on the tunnel. If a slot were short-circuited without // sending the frame, this would catch the regression. const sentCount = mockWs.sent.length; expect(sentCount).toBe(MAX_STREAMS_PER_TUNNEL); // The (cap+1)th openTcpStream returns null per the cap check, and // does NOT consume a stream id (no tcp_open is sent). expect(bridge.openTcpStream({ stack: 's', service: 'svc', port: 80 })).toBeNull(); expect(mockWs.sent.length).toBe(sentCount); // And a loopback HTTP request lands on the 503 branch of // handleLoopbackRequest. const url = new URL(loopbackUrl); const overflow = await new Promise((resolve, reject) => { const req = http.request({ host: url.hostname, port: Number(url.port), method: 'GET', path: '/over-the-cap', }, (res) => resolve(res.statusCode || 0)); req.on('error', reject); req.end(); }); expect(overflow).toBe(503); // The 503 path also does not allocate a stream or send any frame. expect(mockWs.sent.length).toBe(sentCount); }, 15_000); });