mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
d981719941
The transport was a hand-rolled setupWebSocket(server) that index.ts kicked
off with a dynamic import after listen(). It is a @WebSocketGateway now, with
DatabaseService and EphemeralTokenService injected instead of imported, and
the heartbeat owned by a lifecycle hook rather than an interval nobody stops.
The wire protocol does not move. Nest's WsAdapter dispatches on
{ event, data } and every deployed client sends { type, tripId }, so
TrekWsAdapter translates rather than asking clients to change. It also builds
the ws server itself instead of delegating: the base's port-0 branch uses
noServer and performs the upgrade from its own registry, which never consults
verifyClient, so delegating would have dropped the origin check and answered
101 to anyone. The per-socket error listener stays attached first, because an
unhandled one kills the process (#1576), and the flood guard counts every
inbound frame before parsing, because malformed frames are exactly the ones
worth limiting.
Four things had to stay where they were, and each is a silent failure if it
does not:
The socket registry stays module state. Seventeen bridges and
notifications.instance build `new RealtimeService()` outside the container;
rooms on a provider instance would leave their broadcasts going to an empty
map, with no error and no log.
The socket id stays a monotonic integer. The client echoes it as X-Socket-Id
and broadcast excludes the originator with Number(excludeSid). A uuid is NaN,
NaN === NaN is false, and every client would receive its own writes back,
which shows up as drag-and-drop that jumps rather than as an error.
buildApp creates the http server and binds the adapter before app.init(), and
callers take it from getHttpServer(). Nest binds gateways during init, so a
later adapter binds nothing; an adapter given the app binds to Nest's own
internal server, which this process never listens on. Both boot green with no
client able to connect.
RealtimeGatewayModule is imported by AppModule alone. RealtimeModule is
@Global, so a gateway there follows into every hand-assembled TestingModule,
and Nest's SocketModule then loads an adapter none of them set: it reaches for
@nestjs/platform-socket.io and calls process.exit(1). Eighteen e2e harnesses
died that way before the split.
src/websocket.ts survives as a re-export of the three fan-out primitives. 115
test files vi.mock that path to assert on broadcasts.
The two transport suites pass unchanged, and 30 new cases pin the pieces
directly: the pv and MFA gates, the numeric socket id, the plugin announcement
firing before the room check, both sides of the origin allowlist, and the
flood guard counting frames no handler wants.
264 lines
11 KiB
TypeScript
264 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
/**
|
|
* TrekWsAdapter: the wire protocol and the per-socket flood guard.
|
|
*
|
|
* Both are things the stock WsAdapter does differently, and both fail quietly
|
|
* if this class regresses. A protocol slip means `join` is never handled, the
|
|
* room stays empty, `broadcast` returns at its size check and every client
|
|
* simply stops updating. A missing flood guard is only visible under a hostile
|
|
* client.
|
|
*/
|
|
// The origin allowlist is env-driven and empty in the test environment, so the
|
|
// branch that builds verifyClient would never run. Driving readEnv lets both
|
|
// sides of it be asserted rather than assumed.
|
|
const { wsOrigins } = vi.hoisted(() => ({ wsOrigins: { value: null as string[] | null } }));
|
|
vi.mock('../../../src/app-config', async (importOriginal) => {
|
|
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
return {
|
|
...actual,
|
|
readEnv: () => ({
|
|
...(actual.readEnv as () => { http: Record<string, unknown> })(),
|
|
http: {
|
|
...((actual.readEnv as () => { http: Record<string, unknown> })().http),
|
|
wsOrigins: wsOrigins.value,
|
|
},
|
|
}),
|
|
};
|
|
});
|
|
|
|
import { TrekWsAdapter } from '../../../src/nest/realtime/trek-ws.adapter';
|
|
import { getServer } from '../../../src/nest/realtime/ws-state';
|
|
import type { Server as HttpServer } from 'node:http';
|
|
|
|
type MessageListener = (buffer: Buffer) => void;
|
|
|
|
function fakeSocket() {
|
|
const sent: string[] = [];
|
|
const listeners: Record<string, MessageListener[]> = {};
|
|
return {
|
|
readyState: 1,
|
|
sent,
|
|
terminate: vi.fn(),
|
|
send: (raw: string) => { sent.push(raw); },
|
|
on: (event: string, fn: MessageListener) => {
|
|
(listeners[event] ??= []).push(fn);
|
|
},
|
|
emit: (event: string, payload: Buffer) => {
|
|
for (const fn of listeners[event] ?? []) fn(payload);
|
|
},
|
|
listenerCount: (event: string) => (listeners[event] ?? []).length,
|
|
};
|
|
}
|
|
|
|
const adapter = new TrekWsAdapter({} as HttpServer);
|
|
const frame = (o: unknown) => Buffer.from(JSON.stringify(o));
|
|
/** The adapter hands results to Nest's transform; here it is identity. */
|
|
const transform = (v: unknown) => ({ subscribe: (o: { next: (x: unknown) => void }) => o.next(v) }) as never;
|
|
|
|
let handled: unknown[];
|
|
let handlers: { message: string; callback: (data: unknown, socket: unknown) => unknown }[];
|
|
|
|
beforeEach(() => {
|
|
handled = [];
|
|
handlers = [
|
|
{ message: 'join', callback: (data) => { handled.push(data); return { type: 'joined' }; } },
|
|
{ message: 'leave', callback: (data) => { handled.push(data); return undefined; } },
|
|
];
|
|
});
|
|
|
|
describe('TrekWsAdapter wire protocol', () => {
|
|
it('WSAD-001: dispatches on `type`, and hands the WHOLE frame to the handler', () => {
|
|
// TREK's frames are flat: tripId sits beside type, not under a `data` key.
|
|
// The stock adapter reads `message.event` and passes `message.data`, which
|
|
// would leave join unhandled and the payload undefined.
|
|
const socket = fakeSocket();
|
|
adapter.bindMessageHandlers(socket as never, handlers as never, transform);
|
|
socket.emit('message', frame({ type: 'join', tripId: 7 }));
|
|
expect(handled).toEqual([{ type: 'join', tripId: 7 }]);
|
|
expect(JSON.parse(socket.sent[0])).toEqual({ type: 'joined' });
|
|
});
|
|
|
|
it('WSAD-001b: a socket that closed mid-dispatch is not written to', () => {
|
|
const socket = fakeSocket();
|
|
adapter.bindMessageHandlers(socket as never, handlers as never, transform);
|
|
(socket as { readyState: number }).readyState = 3; // CLOSED
|
|
socket.emit('message', frame({ type: 'join', tripId: 7 }));
|
|
expect(handled).toHaveLength(1); // the handler still ran
|
|
expect(socket.sent).toHaveLength(0); // the answer went nowhere
|
|
});
|
|
|
|
it('WSAD-002: a handler that answers undefined sends nothing', () => {
|
|
const socket = fakeSocket();
|
|
adapter.bindMessageHandlers(socket as never, handlers as never, transform);
|
|
socket.emit('message', frame({ type: 'leave', tripId: 7 }));
|
|
expect(socket.sent).toHaveLength(0);
|
|
});
|
|
|
|
it('WSAD-003: malformed JSON and unknown types are ignored, not answered', () => {
|
|
const socket = fakeSocket();
|
|
adapter.bindMessageHandlers(socket as never, handlers as never, transform);
|
|
socket.emit('message', Buffer.from('{not json'));
|
|
socket.emit('message', frame({ type: 'nope' }));
|
|
socket.emit('message', frame({ tripId: 7 }));
|
|
socket.emit('message', frame('a string'));
|
|
expect(handled).toHaveLength(0);
|
|
expect(socket.sent).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('TrekWsAdapter flood guard', () => {
|
|
it('WSAD-010: counts EVERY frame, including the ones no handler wants', () => {
|
|
// The guard sits before parsing on purpose. Counting only the frames that
|
|
// reach a handler would let a client flood the process with garbage for
|
|
// free, which is what the limit exists to stop.
|
|
const socket = fakeSocket();
|
|
adapter.bindMessageHandlers(socket as never, handlers as never, transform);
|
|
|
|
for (let i = 0; i < 30; i++) socket.emit('message', Buffer.from('{not json'));
|
|
expect(socket.sent).toHaveLength(0);
|
|
|
|
socket.emit('message', frame({ type: 'join', tripId: 7 }));
|
|
expect(JSON.parse(socket.sent[0])).toEqual({ type: 'error', message: 'Rate limit exceeded' });
|
|
expect(handled).toHaveLength(0);
|
|
});
|
|
|
|
it('WSAD-010b: a socket that closed while flooding is not written to either', () => {
|
|
// The refusal is still a write. A client that hangs up mid-flood would
|
|
// otherwise take an ERR_HTTP_HEADERS-shaped error on the way out.
|
|
const socket = fakeSocket();
|
|
adapter.bindMessageHandlers(socket as never, handlers as never, transform);
|
|
for (let i = 0; i < 30; i++) socket.emit('message', Buffer.from('{not json'));
|
|
(socket as { readyState: number }).readyState = 3; // CLOSED
|
|
socket.emit('message', frame({ type: 'join', tripId: 7 }));
|
|
expect(socket.sent).toHaveLength(0);
|
|
});
|
|
|
|
it('WSAD-011: the window reopens, so a well-behaved client recovers', () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const socket = fakeSocket();
|
|
adapter.bindMessageHandlers(socket as never, handlers as never, transform);
|
|
for (let i = 0; i < 31; i++) socket.emit('message', frame({ type: 'join', tripId: 7 }));
|
|
expect(socket.sent.some((s) => s.includes('Rate limit'))).toBe(true);
|
|
|
|
vi.advanceTimersByTime(10_001);
|
|
socket.sent.length = 0;
|
|
socket.emit('message', frame({ type: 'join', tripId: 7 }));
|
|
expect(JSON.parse(socket.sent[0])).toEqual({ type: 'joined' });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('TrekWsAdapter connection binding', () => {
|
|
it('WSAD-020: attaches the error listener BEFORE handing the socket on', () => {
|
|
// A protocol violation (a malformed frame, a reserved close code such as
|
|
// 1006) arrives as an `error` event; unhandled, Node rethrows it and the
|
|
// process dies. That is #1576, and it only reproduces against a hostile
|
|
// client, so nothing in CI would notice this listener going missing.
|
|
const socket = fakeSocket();
|
|
const server = {
|
|
on: (_event: string, cb: (s: unknown, r: unknown) => void) => cb(socket, { url: '/ws' }),
|
|
};
|
|
let listenersWhenCalled = -1;
|
|
adapter.bindClientConnect(server as never, () => {
|
|
listenersWhenCalled = socket.listenerCount('error');
|
|
});
|
|
expect(listenersWhenCalled).toBe(1);
|
|
});
|
|
|
|
it('WSAD-021: forwards the upgrade request, which carries the ws token', () => {
|
|
const socket = fakeSocket();
|
|
const request = { url: '/ws?token=abc' };
|
|
const server = {
|
|
on: (_event: string, cb: (s: unknown, r: unknown) => void) => cb(socket, request),
|
|
};
|
|
const seen: unknown[] = [];
|
|
adapter.bindClientConnect(server as never, (...args: unknown[]) => seen.push(...args));
|
|
expect(seen[1]).toBe(request);
|
|
});
|
|
});
|
|
|
|
describe('TrekWsAdapter server creation', () => {
|
|
/** A real http.Server is never listened on; ws only needs it to hang an upgrade handler. */
|
|
const httpServer = { on: vi.fn(), once: vi.fn(), removeListener: vi.fn(), emit: vi.fn() } as unknown as HttpServer;
|
|
|
|
it('WSAD-030: attaches to the http server it was given, on the gateway path', () => {
|
|
const ad = new TrekWsAdapter(httpServer);
|
|
const server = ad.create(0, { path: '/ws' }) as { options: Record<string, unknown> };
|
|
try {
|
|
expect(server.options.path).toBe('/ws');
|
|
// The registry has to see the live server, or every bridge broadcast
|
|
// silently goes nowhere.
|
|
expect(getServer()).toBe(server);
|
|
} finally {
|
|
void ad.close(server as never);
|
|
}
|
|
});
|
|
|
|
it('WSAD-031: with no allowlist configured, ws gets no verifyClient at all', () => {
|
|
// Rather than one that always says yes: an always-true hook is a hook that
|
|
// can be broken into a false later without anyone noticing.
|
|
wsOrigins.value = null;
|
|
const ad = new TrekWsAdapter(httpServer);
|
|
const server = ad.create(0, { path: '/ws' }) as { options: { verifyClient?: unknown } };
|
|
try {
|
|
expect(typeof server.options.verifyClient).not.toBe('function');
|
|
} finally {
|
|
void ad.close(server as never);
|
|
}
|
|
});
|
|
|
|
it('WSAD-031b: with an allowlist, a foreign origin is refused 403 before a socket exists', () => {
|
|
wsOrigins.value = ['https://trip.example'];
|
|
const ad = new TrekWsAdapter(httpServer);
|
|
const server = ad.create(0, { path: '/ws' }) as { options: { verifyClient?: unknown } };
|
|
try {
|
|
const verify = server.options.verifyClient as (
|
|
info: { origin: string },
|
|
cb: (ok: boolean, code?: number, msg?: string) => void,
|
|
) => void;
|
|
const seen: unknown[][] = [];
|
|
verify({ origin: 'https://evil.example' }, (...args) => seen.push(args));
|
|
verify({ origin: 'https://trip.example' }, (...args) => seen.push(args));
|
|
// A same-origin upgrade sends no Origin header at all; refusing that would
|
|
// lock out every non-browser client.
|
|
verify({ origin: '' }, (...args) => seen.push(args));
|
|
expect(seen).toEqual([[false, 403, 'Origin not allowed'], [true], [true]]);
|
|
} finally {
|
|
void ad.close(server as never);
|
|
}
|
|
});
|
|
|
|
it('WSAD-030b: falls back to /ws when the gateway declares no path', () => {
|
|
wsOrigins.value = null;
|
|
const ad = new TrekWsAdapter(httpServer);
|
|
const server = ad.create(0, {}) as { options: Record<string, unknown> };
|
|
try {
|
|
expect(server.options.path).toBe('/ws');
|
|
} finally {
|
|
void ad.close(server as never);
|
|
}
|
|
});
|
|
|
|
it('WSAD-031c: a server-level error is absorbed, not rethrown at the process', () => {
|
|
wsOrigins.value = null;
|
|
const ad = new TrekWsAdapter(httpServer);
|
|
const server = ad.create(0, { path: '/ws' }) as { emit: (e: string, err: Error) => boolean };
|
|
try {
|
|
expect(() => server.emit('error', new Error('upstream reset'))).not.toThrow();
|
|
} finally {
|
|
void ad.close(server as never);
|
|
}
|
|
});
|
|
|
|
it('WSAD-032: closing clears the registry, so a stale server is never broadcast to', async () => {
|
|
const ad = new TrekWsAdapter(httpServer);
|
|
const server = ad.create(0, { path: '/ws' });
|
|
await ad.close(server as never);
|
|
expect(getServer()).toBeNull();
|
|
});
|
|
});
|