Files

156 lines
6.3 KiB
JavaScript

import { describe, test, expect } from 'bun:test';
import http from 'node:http';
import { createTunnelHost, isAllowedRelayWebSocketPath } from './tunnel-host.js';
import { decodeTunnelFrame, encodeTunnelFrame, encodeJsonPayload, TunnelFrameType } from './tunnel-codec.js';
const startLoopback = () =>
new Promise((resolve) => {
const requests = [];
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
requests.push({
method: req.method,
url: req.url,
body: Buffer.concat(chunks).toString('utf8'),
});
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
server.listen(0, '127.0.0.1', () => resolve({
server,
port: server.address().port,
requests,
stop: () => new Promise((r) => server.close(() => r())),
}));
});
const createHarness = async (hostOverrides = {}) => {
const loopback = await startLoopback();
const sentFrames = [];
const host = createTunnelHost({
connectionId: 'conn-test',
getLocalPort: () => loopback.port,
sendFrame: async (frame) => {
sentFrames.push(decodeTunnelFrame(frame));
},
getBufferedAmount: () => 0,
...hostOverrides,
});
return { host, loopback, sentFrames };
};
const httpHead = (overrides = {}) => encodeTunnelFrame(TunnelFrameType.HttpRequest, 1, encodeJsonPayload({
method: 'POST',
path: '/api/submit',
query: '',
headers: { 'content-type': 'application/json' },
...overrides,
}));
const waitFor = async (predicate, timeoutMs = 2000) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return true;
await new Promise((r) => setTimeout(r, 20));
}
return predicate();
};
describe('tunnel-host HTTP body forwarding', () => {
test('buffers tunneled body frames and forwards the complete body', async () => {
const { host, loopback, sentFrames } = await createHarness();
await host.handleFrame(httpHead());
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, 1, new TextEncoder().encode('alpha')));
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, 1, new TextEncoder().encode('beta')));
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, 1, new Uint8Array(0)));
const received = await waitFor(() => loopback.requests.length === 1);
expect(received).toBe(true);
expect(loopback.requests[0].method).toBe('POST');
expect(loopback.requests[0].body).toBe('alphabeta');
await waitFor(() => sentFrames.some((f) => f.frameType === TunnelFrameType.StreamEnd));
await loopback.stop();
});
test('body-expected request with zero delivered frames is aborted as ambiguous, not forwarded', async () => {
const { host, loopback, sentFrames } = await createHarness();
await host.handleFrame(httpHead({ hasBody: true }));
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, 1, new Uint8Array(0)));
const aborted = await waitFor(() => sentFrames.some((f) => f.frameType === TunnelFrameType.StreamAbort));
expect(aborted).toBe(true);
// Loopback must never have seen a request with a lost body.
expect(loopback.requests.length).toBe(0);
await loopback.stop();
});
test('bodyless request (hasBody absent, legacy client) still forwards empty', async () => {
const { host, loopback } = await createHarness();
await host.handleFrame(httpHead());
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, 1, new Uint8Array(0)));
const received = await waitFor(() => loopback.requests.length === 1);
expect(received).toBe(true);
expect(loopback.requests[0].body).toBe('');
await loopback.stop();
});
test('aborts a buffered body that never completes within the delivery deadline', async () => {
const { host, loopback, sentFrames } = await createHarness({ bodyDeliveryTimeoutMs: 50 });
await host.handleFrame(httpHead({ hasBody: true }));
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, 1, new TextEncoder().encode('partial')));
// No StreamEnd — the tunnel stalled mid-body.
const aborted = await waitFor(() => sentFrames.some((f) => f.frameType === TunnelFrameType.StreamAbort));
expect(aborted).toBe(true);
expect(loopback.requests.length).toBe(0);
// A late StreamEnd for the dropped stream must not trigger a second abort
// or forward the stale body.
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, 1, new Uint8Array(0)));
await new Promise((r) => setTimeout(r, 50));
expect(sentFrames.filter((f) => f.frameType === TunnelFrameType.StreamAbort).length).toBe(1);
expect(loopback.requests.length).toBe(0);
await loopback.stop();
});
test('forwards an empty body when the client delivered an explicit empty frame', async () => {
const { host, loopback } = await createHarness();
await host.handleFrame(httpHead({ hasBody: true }));
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, 1, new Uint8Array(0)));
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, 1, new Uint8Array(0)));
const received = await waitFor(() => loopback.requests.length === 1);
expect(received).toBe(true);
expect(loopback.requests[0].body).toBe('');
await loopback.stop();
});
test('GET forwards immediately with no body wait', async () => {
const { host, loopback } = await createHarness();
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.HttpRequest, 1, encodeJsonPayload({
method: 'GET',
path: '/api/health',
query: '',
headers: {},
})));
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, 1, new Uint8Array(0)));
const received = await waitFor(() => loopback.requests.length === 1);
expect(received).toBe(true);
expect(loopback.requests[0].method).toBe('GET');
await loopback.stop();
});
});
describe('relay host WebSocket allowlist', () => {
test('allows only the exact dev-server tunnel path', () => {
expect(isAllowedRelayWebSocketPath('/api/dev-tunnel')).toBe(true);
expect(isAllowedRelayWebSocketPath('/api/dev-tunnel/')).toBe(false);
expect(isAllowedRelayWebSocketPath('/api/database/ws')).toBe(false);
});
});