Refactor relay transport socket tests (#882)

This commit is contained in:
Mohamed Boudra
2026-05-10 07:53:54 +00:00
committed by GitHub
parent 3f6b84899a
commit f881f9ae32
2 changed files with 157 additions and 133 deletions
@@ -1,35 +1,44 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type pino from "pino";
import { startRelayTransport } from "./relay-transport";
const wsMock = vi.hoisted(() => { function createMockLogger() {
class MockWebSocket { const messages: { level: "debug" | "info" | "warn" | "error"; args: unknown[] }[] = [];
const logger = {
messages,
child: () => logger,
debug: (...args: unknown[]) => messages.push({ level: "debug", args }),
info: (...args: unknown[]) => messages.push({ level: "info", args }),
warn: (...args: unknown[]) => messages.push({ level: "warn", args }),
error: (...args: unknown[]) => messages.push({ level: "error", args }),
};
return logger;
}
type TestLogger = ReturnType<typeof createMockLogger>;
function hasLogMessage(logger: TestLogger, level: "info" | "warn", message: string): boolean {
return logger.messages.some((entry) => {
return entry.level === level && entry.args.some((arg) => arg === message);
});
}
class FakeRelayWebSocket {
static readonly CONNECTING = 0; static readonly CONNECTING = 0;
static readonly OPEN = 1; static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3; static readonly CLOSED = 3;
static instances: MockWebSocket[] = [];
readonly url: string; readyState = FakeRelayWebSocket.CONNECTING;
readonly options: unknown; sent: Array<string | Uint8Array | ArrayBuffer> = [];
readyState = MockWebSocket.CONNECTING;
sent: string[] = [];
terminateCalls = 0; terminateCalls = 0;
private listeners = new Map<string, Array<(...args: unknown[]) => void>>(); private readonly listeners = new Map<string, Array<(...args: unknown[]) => void>>();
constructor(url: string, options?: unknown) { constructor(readonly url: string) {}
this.url = url;
this.options = options;
MockWebSocket.instances.push(this);
}
static reset() {
MockWebSocket.instances = [];
}
on(event: string, listener: (...args: unknown[]) => void) { on(event: string, listener: (...args: unknown[]) => void) {
const handlers = this.listeners.get(event) ?? []; const handlers = this.listeners.get(event) ?? [];
handlers.push(listener); handlers.push(listener);
this.listeners.set(event, handlers); this.listeners.set(event, handlers);
return this;
} }
once(event: string, listener: (...args: unknown[]) => void) { once(event: string, listener: (...args: unknown[]) => void) {
@@ -37,29 +46,29 @@ const wsMock = vi.hoisted(() => {
this.off(event, wrapped); this.off(event, wrapped);
listener(...args); listener(...args);
}; };
return this.on(event, wrapped); this.on(event, wrapped);
} }
close(code?: number, reason?: string) { close(code?: number, reason?: string) {
this.readyState = MockWebSocket.CLOSED; this.readyState = FakeRelayWebSocket.CLOSED;
this.emit("close", code ?? 1000, reason ?? ""); this.emit("close", code ?? 1000, reason ?? "");
} }
terminate() { terminate() {
this.terminateCalls += 1; this.terminateCalls += 1;
this.readyState = MockWebSocket.CLOSED; this.readyState = FakeRelayWebSocket.CLOSED;
this.emit("close", 1006, ""); this.emit("close", 1006, "");
} }
send(data: string) { send(data: string | Uint8Array | ArrayBuffer) {
if (this.readyState !== MockWebSocket.OPEN) { if (this.readyState !== FakeRelayWebSocket.OPEN) {
throw new Error(`WebSocket not open (readyState=${this.readyState})`); throw new Error(`WebSocket not open (readyState=${this.readyState})`);
} }
this.sent.push(data); this.sent.push(data);
} }
open() { open() {
this.readyState = MockWebSocket.OPEN; this.readyState = FakeRelayWebSocket.OPEN;
this.emit("open"); this.emit("open");
} }
@@ -67,10 +76,6 @@ const wsMock = vi.hoisted(() => {
this.emit("message", data); this.emit("message", data);
} }
error(err: unknown) {
this.emit("error", err);
}
private off(event: string, listener: (...args: unknown[]) => void) { private off(event: string, listener: (...args: unknown[]) => void) {
const handlers = this.listeners.get(event) ?? []; const handlers = this.listeners.get(event) ?? [];
this.listeners.set( this.listeners.set(
@@ -87,38 +92,24 @@ const wsMock = vi.hoisted(() => {
} }
} }
return { MockWebSocket }; function createFakeWebSockets() {
}); const sockets: FakeRelayWebSocket[] = [];
return {
vi.mock("ws", () => ({ sockets,
default: wsMock.MockWebSocket, createWebSocket(url: string) {
WebSocket: wsMock.MockWebSocket, const socket = new FakeRelayWebSocket(url);
})); sockets.push(socket);
return socket;
import type pino from "pino"; },
import { startRelayTransport } from "./relay-transport";
function createMockLogger() {
const logger = {
child: vi.fn(() => logger),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}; };
return logger;
}
function hasLogMessage(mockFn: ReturnType<typeof vi.fn>, message: string): boolean {
return mockFn.mock.calls.some((call) => call.some((arg) => arg === message));
} }
describe("relay-transport control lifecycle", () => { describe("relay-transport control lifecycle", () => {
const controllers: Array<{ stop: () => Promise<void> }> = []; const controllers: Array<{ stop: () => Promise<void> }> = [];
const MockWebSocket = wsMock.MockWebSocket; let relay: ReturnType<typeof createFakeWebSockets>;
beforeEach(() => { beforeEach(() => {
MockWebSocket.reset(); relay = createFakeWebSockets();
}); });
afterEach(async () => { afterEach(async () => {
@@ -135,18 +126,19 @@ describe("relay-transport control lifecycle", () => {
relayEndpoint: "relay.paseo.sh:443", relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true, relayUseTls: true,
serverId: "srv_test", serverId: "srv_test",
createWebSocket: relay.createWebSocket,
}); });
controllers.push(controller); controllers.push(controller);
const control = MockWebSocket.instances[0]; const control = relay.sockets[0];
expect(control).toBeDefined(); expect(control).toBeDefined();
control.open(); control.open();
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(false); expect(hasLogMessage(logger, "info", "relay_control_connected")).toBe(false);
expect(control.sent.length).toBeGreaterThan(0); expect(control.sent.length).toBeGreaterThan(0);
control.message(JSON.stringify({ type: "pong", ts: Date.now() })); control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(true); expect(hasLogMessage(logger, "info", "relay_control_connected")).toBe(true);
}); });
test("terminates and reconnects when control socket opens but never becomes ready", () => { test("terminates and reconnects when control socket opens but never becomes ready", () => {
@@ -158,18 +150,19 @@ describe("relay-transport control lifecycle", () => {
relayEndpoint: "relay.paseo.sh:443", relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true, relayUseTls: true,
serverId: "srv_test", serverId: "srv_test",
createWebSocket: relay.createWebSocket,
}); });
controllers.push(controller); controllers.push(controller);
const firstControl = MockWebSocket.instances[0]; const firstControl = relay.sockets[0];
firstControl.open(); firstControl.open();
vi.advanceTimersByTime(8_000); vi.advanceTimersByTime(8_000);
expect(hasLogMessage(logger.warn, "relay_control_ready_timeout_terminating")).toBe(true); expect(hasLogMessage(logger, "warn", "relay_control_ready_timeout_terminating")).toBe(true);
expect(firstControl.terminateCalls).toBe(1); expect(firstControl.terminateCalls).toBe(1);
vi.advanceTimersByTime(1_000); vi.advanceTimersByTime(1_000);
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2); expect(relay.sockets.length).toBeGreaterThanOrEqual(2);
}); });
test("terminates stale control sockets in under one minute", () => { test("terminates stale control sockets in under one minute", () => {
@@ -181,47 +174,56 @@ describe("relay-transport control lifecycle", () => {
relayEndpoint: "relay.paseo.sh:443", relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true, relayUseTls: true,
serverId: "srv_test", serverId: "srv_test",
createWebSocket: relay.createWebSocket,
}); });
controllers.push(controller); controllers.push(controller);
const control = MockWebSocket.instances[0]; const control = relay.sockets[0];
control.open(); control.open();
control.message(JSON.stringify({ type: "pong", ts: Date.now() })); control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
logger.warn.mockClear(); logger.messages.length = 0;
vi.advanceTimersByTime(40_000); vi.advanceTimersByTime(40_000);
expect(hasLogMessage(logger.warn, "relay_control_stale_terminating")).toBe(true); expect(hasLogMessage(logger, "warn", "relay_control_stale_terminating")).toBe(true);
expect(control.terminateCalls).toBe(1); expect(control.terminateCalls).toBe(1);
}); });
test("passes stable relay external session metadata when attaching data socket", async () => { test("passes stable relay external session metadata when attaching data socket", async () => {
const logger = createMockLogger(); const logger = createMockLogger();
const attachSocket = vi.fn(async () => {}); const attachedSockets: unknown[] = [];
const attachedMetadata: unknown[] = [];
const attachSocket = async (socket: unknown, metadata: unknown) => {
attachedSockets.push(socket);
attachedMetadata.push(metadata);
};
const controller = startRelayTransport({ const controller = startRelayTransport({
logger: logger as unknown as pino.Logger, logger: logger as unknown as pino.Logger,
attachSocket, attachSocket,
relayEndpoint: "relay.paseo.sh:443", relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true, relayUseTls: true,
serverId: "srv_test", serverId: "srv_test",
createWebSocket: relay.createWebSocket,
}); });
controllers.push(controller); controllers.push(controller);
const control = MockWebSocket.instances[0]; const control = relay.sockets[0];
control.open(); control.open();
control.message(JSON.stringify({ type: "pong", ts: Date.now() })); control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" })); control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }));
const dataSocket = MockWebSocket.instances[1]; const dataSocket = relay.sockets[1];
expect(dataSocket).toBeDefined(); expect(dataSocket).toBeDefined();
dataSocket.open(); dataSocket.open();
await Promise.resolve(); await Promise.resolve();
expect(attachSocket).toHaveBeenCalledTimes(1); expect(attachedSockets).toEqual([dataSocket]);
expect(attachSocket).toHaveBeenCalledWith(dataSocket, { expect(attachedMetadata).toEqual([
{
transport: "relay", transport: "relay",
externalSessionKey: "session:clt_test", externalSessionKey: "session:clt_test",
}); },
]);
}); });
test("uses relayUseTls for control and data socket URLs", () => { test("uses relayUseTls for control and data socket URLs", () => {
@@ -232,15 +234,16 @@ describe("relay-transport control lifecycle", () => {
relayEndpoint: "[::1]:443", relayEndpoint: "[::1]:443",
relayUseTls: true, relayUseTls: true,
serverId: "srv_test", serverId: "srv_test",
createWebSocket: relay.createWebSocket,
}); });
controllers.push(controller); controllers.push(controller);
const control = MockWebSocket.instances[0]; const control = relay.sockets[0];
control.open(); control.open();
control.message(JSON.stringify({ type: "pong", ts: Date.now() })); control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" })); control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }));
expect(MockWebSocket.instances[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/); expect(relay.sockets[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
expect(MockWebSocket.instances[1]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/); expect(relay.sockets[1]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
}); });
}); });
+29 -8
View File
@@ -18,6 +18,7 @@ interface RelayTransportOptions {
relayUseTls: boolean; relayUseTls: boolean;
serverId: string; serverId: string;
daemonKeyPair?: KeyPair; daemonKeyPair?: KeyPair;
createWebSocket?: RelayWebSocketFactory;
} }
export interface RelayTransportController { export interface RelayTransportController {
@@ -32,6 +33,16 @@ interface RelaySocketLike {
once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void; once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void;
} }
interface RelayWebSocketLike extends RelaySocketLike {
terminate: () => void;
on: (
event: "open" | "message" | "close" | "error",
listener: (...args: unknown[]) => void,
) => void;
}
type RelayWebSocketFactory = (url: string) => RelayWebSocketLike;
type ControlMessage = type ControlMessage =
| { type: "sync"; connectionIds: string[] } | { type: "sync"; connectionIds: string[] }
| { type: "connected"; connectionId: string } | { type: "connected"; connectionId: string }
@@ -42,6 +53,11 @@ type ControlMessage =
const CONTROL_PING_INTERVAL_MS = 10_000; const CONTROL_PING_INTERVAL_MS = 10_000;
const CONTROL_STALE_TIMEOUT_MS = 30_000; const CONTROL_STALE_TIMEOUT_MS = 30_000;
const CONTROL_READY_TIMEOUT_MS = 8_000; const CONTROL_READY_TIMEOUT_MS = 8_000;
const RELAY_WEBSOCKET_OPTIONS = { handshakeTimeout: 10_000, perMessageDeflate: false } as const;
function createDefaultRelayWebSocket(url: string): RelayWebSocketLike {
return new WebSocket(url, RELAY_WEBSOCKET_OPTIONS);
}
function normalizeRelaySendPayload(data: string | Uint8Array | ArrayBuffer): string | ArrayBuffer { function normalizeRelaySendPayload(data: string | Uint8Array | ArrayBuffer): string | ArrayBuffer {
if (typeof data === "string") return data; if (typeof data === "string") return data;
@@ -106,14 +122,15 @@ export function startRelayTransport({
relayUseTls, relayUseTls,
serverId, serverId,
daemonKeyPair, daemonKeyPair,
createWebSocket = createDefaultRelayWebSocket,
}: RelayTransportOptions): RelayTransportController { }: RelayTransportOptions): RelayTransportController {
const relayLogger = logger.child({ module: "relay-transport" }); const relayLogger = logger.child({ module: "relay-transport" });
let stopped = false; let stopped = false;
let controlWs: WebSocket | null = null; let controlWs: RelayWebSocketLike | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null; let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempt = 0; let reconnectAttempt = 0;
const dataSockets = new Map<string, WebSocket>(); // connectionId -> ws const dataSockets = new Map<string, RelayWebSocketLike>(); // connectionId -> ws
let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null; let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null;
let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null; let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null;
let controlLastSeenAt = 0; let controlLastSeenAt = 0;
@@ -161,7 +178,7 @@ export function startRelayTransport({
serverId, serverId,
role: "server", role: "server",
}); });
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false }); const socket = createWebSocket(url);
controlWs = socket; controlWs = socket;
let controlConnected = false; let controlConnected = false;
@@ -338,7 +355,7 @@ export function startRelayTransport({
role: "server", role: "server",
connectionId, connectionId,
}); });
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false }); const socket = createWebSocket(url);
dataSockets.set(connectionId, socket); dataSockets.set(connectionId, socket);
let attached = false; let attached = false;
@@ -397,7 +414,7 @@ export function startRelayTransport({
} }
async function attachEncryptedSocket( async function attachEncryptedSocket(
socket: WebSocket, socket: RelayWebSocketLike,
daemonKeyPair: KeyPair, daemonKeyPair: KeyPair,
logger: pino.Logger, logger: pino.Logger,
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>, attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>,
@@ -426,7 +443,10 @@ async function attachEncryptedSocket(
} }
} }
function createRelayTransportAdapter(socket: WebSocket, logger: pino.Logger): RelayTransport { function createRelayTransportAdapter(
socket: RelayWebSocketLike,
logger: pino.Logger,
): RelayTransport {
const relayTransport: RelayTransport = { const relayTransport: RelayTransport = {
send: (data) => { send: (data) => {
try { try {
@@ -445,10 +465,11 @@ function createRelayTransportAdapter(socket: WebSocket, logger: pino.Logger): Re
}; };
socket.on("message", (data, isBinary) => { socket.on("message", (data, isBinary) => {
relayTransport.onmessage?.(normalizeMessageData(data, isBinary)); relayTransport.onmessage?.(normalizeMessageData(data, isBinary === true));
}); });
socket.on("close", (code, reason) => { socket.on("close", (code, reason) => {
relayTransport.onclose?.(code, reason.toString()); const closeCode = typeof code === "number" ? code : 1006;
relayTransport.onclose?.(closeCode, String(reason ?? ""));
}); });
socket.on("error", (err) => { socket.on("error", (err) => {
relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err))); relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err)));