156 lines
6.3 KiB
JavaScript
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);
|
|
});
|
|
});
|