178 lines
7.5 KiB
JavaScript
178 lines
7.5 KiB
JavaScript
import { describe, expect, test } from 'bun:test';
|
|
import http from 'node:http';
|
|
import { WebSocketServer } from 'ws';
|
|
import { startRelayHost } from './host-client.js';
|
|
import { generateEcdhKeyPair, exportPublicKeyJwk } from './e2ee.js';
|
|
import { createRelayTunnelClient } from '../../../../ui/src/lib/relay/tunnel-client.ts';
|
|
|
|
const waitFor = async predicate => {
|
|
for (let attempt = 0; attempt < 1000; attempt++) {
|
|
if (predicate()) return;
|
|
await new Promise(resolve => setTimeout(resolve, 5));
|
|
}
|
|
throw new Error('test condition timed out');
|
|
};
|
|
|
|
async function exercise({ clientFlow = true, hostFlow = true, batch = true, cancel = false, websocket = false } = {}) {
|
|
const total = 512 * 1024;
|
|
const content = Buffer.alloc(total, 'a');
|
|
content.fill('b', total / 2);
|
|
const upstream = http.createServer((req, res) => {
|
|
// HTTP bearer ownership is unchanged by flow control.
|
|
if (req.headers.authorization !== 'Bearer fixture-token') { res.writeHead(401); res.end(); return; }
|
|
res.end(req.url === '/health' ? 'ok' : content);
|
|
});
|
|
const upstreamWs = new WebSocketServer({ noServer: true });
|
|
upstream.on('upgrade', (req, socket, head) => {
|
|
// Mirror the real URL-token and loopback-origin gates for tunneled WS.
|
|
const query = new URL(req.url, 'http://localhost');
|
|
if (query.searchParams.get('oc_url_token') !== 'fixture-url-token' || req.headers.origin !== `http://127.0.0.1:${upstream.address().port}`) {
|
|
socket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
return;
|
|
}
|
|
upstreamWs.handleUpgrade(req, socket, head, ws => {
|
|
ws.send(content.subarray(0, total / 2));
|
|
ws.send(content.subarray(total / 2));
|
|
// Let Bun finish the local write turn, then close while the throttled
|
|
// tunnel still has most of the two messages queued.
|
|
setTimeout(() => ws.close(1000, 'complete'), 20);
|
|
});
|
|
});
|
|
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
|
|
const relay = new WebSocketServer({ host: '127.0.0.1', port: 0 });
|
|
await new Promise(resolve => relay.on('listening', resolve));
|
|
let control;
|
|
let clientSocket;
|
|
let hostData;
|
|
const waiting = [];
|
|
const down = [];
|
|
// This fixture brokers an isolated room only; it does not replace production
|
|
// relay authentication. Neither WebSocket leg fails or drops a frame.
|
|
relay.on('connection', (socket, req) => {
|
|
const role = new URL(req.url, 'http://localhost').searchParams.get('role');
|
|
if (role === 'host-control') {
|
|
control = socket;
|
|
socket.send(JSON.stringify({ type: 'sync', connectionIds: clientSocket ? ['fixture'] : [] }));
|
|
} else if (role === 'client') {
|
|
clientSocket = socket;
|
|
control?.send(JSON.stringify({ type: 'connected', connectionId: 'fixture' }));
|
|
socket.on('message', (data, binary) => {
|
|
if (hostData) hostData.send(data, { binary });
|
|
else waiting.push({ data, binary });
|
|
});
|
|
} else {
|
|
hostData = socket;
|
|
for (const frame of waiting.splice(0)) socket.send(frame.data, { binary: frame.binary });
|
|
socket.on('message', (data, binary) => {
|
|
down.push({ data, binary });
|
|
});
|
|
}
|
|
});
|
|
// Drain at a byte budget, not a frame count: frame-size changes cannot make
|
|
// the test's effective bandwidth change. Preserve whole WS messages in order.
|
|
let credit = 0;
|
|
const drain = setInterval(() => {
|
|
credit = Math.min(128 * 1024, credit + 4096);
|
|
while (down.length && down[0].data.length <= credit) {
|
|
const frame = down.shift();
|
|
credit -= frame.data.length;
|
|
clientSocket.send(frame.data, { binary: frame.binary });
|
|
}
|
|
}, 5);
|
|
const keys = await generateEcdhKeyPair();
|
|
const relayUrl = `ws://127.0.0.1:${relay.address().port}/ws`;
|
|
const host = startRelayHost({
|
|
relayUrl, localPort: upstream.address().port, batch, flowControl: hostFlow,
|
|
identity: { serverId: 'fixture', hostEncPrivateKey: keys.privateKey, signRelayAuth: () => ({ ts: 0, sig: '', pk: '' }) },
|
|
});
|
|
const client = createRelayTunnelClient({ relayUrl, serverId: 'fixture', hostEncPubJwk: await exportPublicKeyJwk(keys.publicKey), batch, flowControl: clientFlow });
|
|
const states = [];
|
|
client.subscribeStatus(status => states.push(status.state));
|
|
const headers = { authorization: 'Bearer fixture-token' };
|
|
let received = 0;
|
|
let finished = false;
|
|
let wsMessages = 0;
|
|
let streamFailure;
|
|
const chunks = [];
|
|
const abort = new AbortController();
|
|
try {
|
|
await waitFor(() => client.getStatus().state === 'connected');
|
|
let stream;
|
|
if (websocket) {
|
|
const socket = client.openWebSocket('/api/terminal/ws?oc_url_token=fixture-url-token');
|
|
stream = new Promise((resolve, reject) => {
|
|
socket.onmessage = event => {
|
|
const bytes = new Uint8Array(event.data);
|
|
chunks.push(bytes);
|
|
received += bytes.length;
|
|
wsMessages++;
|
|
};
|
|
socket.onclose = event => {
|
|
finished = true;
|
|
if (event.code !== 1000) reject(new Error(event.reason));
|
|
else resolve();
|
|
};
|
|
});
|
|
} else {
|
|
const response = await client.fetch('/api/global/event', { headers, signal: abort.signal });
|
|
stream = (async () => {
|
|
try {
|
|
for await (const chunk of response.body) { chunks.push(chunk); received += chunk.length; }
|
|
} catch (error) {
|
|
if (!cancel || error.name !== 'AbortError') throw error;
|
|
} finally { finished = true; }
|
|
})();
|
|
}
|
|
stream = stream.catch(error => { streamFailure = error; });
|
|
await waitFor(() => received > 0 || streamFailure);
|
|
if (streamFailure) throw streamFailure;
|
|
if (cancel) abort.abort();
|
|
const response = await client.fetch('/health', { headers, signal: AbortSignal.timeout(5000) });
|
|
expect(await response.text()).toBe('ok');
|
|
const bytesAtProbe = received;
|
|
await stream;
|
|
expect(streamFailure).toBeUndefined();
|
|
expect(finished).toBe(true);
|
|
if (!cancel) expect(Buffer.concat(chunks).equals(content)).toBe(true);
|
|
if (websocket) expect(wsMessages).toBe(2);
|
|
expect(states).toEqual(['connected']);
|
|
return { bytesAtProbe, total };
|
|
} finally {
|
|
client.close();
|
|
host.stop();
|
|
clearInterval(drain);
|
|
for (const ws of relay.clients) ws.terminate();
|
|
relay.close();
|
|
for (const ws of upstreamWs.clients) ws.terminate();
|
|
upstreamWs.close();
|
|
upstream.closeAllConnections();
|
|
upstream.close();
|
|
}
|
|
}
|
|
|
|
describe('end-to-end downstream credit', () => {
|
|
test('small HTTP response overtakes bulk output without losing bytes or reconnecting', async () => {
|
|
const result = await exercise();
|
|
expect(result.bytesAtProbe).toBeLessThan(result.total / 2);
|
|
// Queue peaks depend on real ACK timing and include encryption overhead.
|
|
// downstream-scheduler.test.js checks credit bounds with a controlled clock.
|
|
});
|
|
test('works without batching', async () => {
|
|
const result = await exercise({ batch: false });
|
|
expect(result.bytesAtProbe).toBeLessThan(result.total / 2);
|
|
});
|
|
test('cancelling a blocked stream leaves unrelated requests usable', async () => {
|
|
const result = await exercise({ cancel: true });
|
|
expect(result.bytesAtProbe).toBeLessThan(result.total);
|
|
});
|
|
test('fragmented WS messages remain complete and precede the close', async () => {
|
|
await exercise({ websocket: true });
|
|
});
|
|
for (const legacy of [{ clientFlow: false }, { hostFlow: false }]) {
|
|
test(`legacy fallback ${JSON.stringify(legacy)} preserves delivery`, async () => {
|
|
const result = await exercise(legacy);
|
|
expect(result.bytesAtProbe).toBe(result.total);
|
|
});
|
|
}
|
|
});
|