Files
TREK/server/tests/unit/nest/trek-ws-adapter.test.ts
T
Maurice 3a7c8e1d26 Make a bind failure fatal again, and undo four contract tightenings
An adversarial audit over the three merged migration PRs. Sixteen findings
survived triple refutation; these are the ones with a defect behind them.

The serious one is mine, from the gateway move. ws registers
`error: this.emit.bind(this, 'error')` on the http server it attaches to, and
the adapter now runs inside buildApp, before listen(). So from that moment the
http server HAS an error listener and Node stops throwing on one. The adapter's
handler was `() => {}`, which meant EADDRINUSE was forwarded there and dropped:
the process survived, the listen callback never ran, so the banner never
printed and the scheduler never started. A container that looks healthy and
serves nothing. Reproduced, then fixed at both ends: index.ts owns the fatal
decision on the http server, and the adapter logs instead of swallowing.

Four DTOs were tightening contracts rather than describing them, which is the
thing the pipe-before-handler ordering makes easy to do by accident:

- OauthClientCreateDto replaced 'Name is required' with a Zod field report, and
  pre-empted the handler's 403 on an instance with the MCP addon off.
- BookingImportConfirmDto demanded per-item fields the service never reads, so
  a caller assembling items itself got a 400 about `source`.
- TagCreateDto/TagUpdateDto typed name and color as strings; the legacy route
  bound whatever arrived, so a numeric name used to create a tag.
- PluginUserSettingsUpdateDto typed config as a record, where the handler
  answers 200 for anything it cannot use.

Two of my own tests were false-green, which the audit demonstrated by mutation
rather than by reading. WSGW-008 asserted on frames that never carry the user
object at all, so it passed with the password_version strip deleted; it reads
the retained identity now. GEO-009 made the same assertion before and after the
write; it distinguishes a cached negative from a miss now, through a hasCached
seam that says what getCached cannot. Both were re-run against the mutation
they exist to catch, and both fail on it.

booking-import geocodes every venue and endpoint in one request loop, up to
thirty sequential calls. Folding maps onto the shared Nominatim cursor put that
loop on the interactive lane, so an import starved every concurrent keystroke
for half a minute. It takes the background lane now, which is what the two-lane
split was built for.

Also: the README's in-container bridge list had outlived one entry and was
missing another, and the journey e2e still mocked a module this branch deleted.
2026-08-11 09:58:37 +02:00

277 lines
12 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, logError } = vi.hoisted(() => ({
wsOrigins: { value: null as string[] | null },
logError: vi.fn(),
}));
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ logError, logInfo: vi.fn(), logDebug: vi.fn(), logWarn: vi.fn() }));
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 LOGGED, never swallowed', () => {
// ws forwards the http server's errors here, so an empty handler eats the
// bind failure too and the process survives EADDRINUSE serving nothing.
// index.ts owns the fatal decision; this handler owes a log line.
wsOrigins.value = null;
const ad = new TrekWsAdapter(httpServer);
const server = ad.create(0, { path: '/ws' }) as { emit: (e: string, err: unknown) => boolean };
try {
expect(() => server.emit('error', new Error('upstream reset'))).not.toThrow();
expect(logError).toHaveBeenCalledWith(expect.stringContaining('upstream reset'));
// A non-Error rejection reads as itself rather than as [object Object].
logError.mockClear();
server.emit('error', 'ECONNRESET');
expect(logError).toHaveBeenCalledWith(expect.stringContaining('ECONNRESET'));
} 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();
});
});