PR review changes

This commit is contained in:
seavor 2026-04-19 16:36:33 -05:00
parent ef6cea6f6c
commit b103db681b
35 changed files with 640 additions and 125 deletions

View file

@ -189,6 +189,18 @@ describe('WebClient', () => {
vi.advanceTimersByTime(5000);
expect(wsMockInstance.close).toHaveBeenCalled();
});
it('closes the prior in-flight socket on rapid re-click', () => {
const { instances } = installMockWebSocket();
// The fresh installMockWebSocket replaces the stub from beforeEach so
// we observe the next two constructions in isolation.
client.testConnect(target);
const first = instances[instances.length - 1];
expect(first.close).not.toHaveBeenCalled();
client.testConnect(target);
expect(first.close).toHaveBeenCalled();
});
});
describe('disconnect', () => {

View file

@ -1,5 +1,8 @@
import { ping } from './commands/session';
import { CLIENT_OPTIONS } from './config';
import { GameEvents } from './events/game';
import { RoomEvents } from './events/room';
import { SessionEvents } from './events/session';
import type { ConnectTarget } from './types/WebClientConfig';
import type { IWebClientRequest } from './types/WebClientRequest';
import type { IWebClientResponse } from './types/WebClientResponse';
@ -22,6 +25,7 @@ export class WebClient {
protobuf: ProtobufService;
socket: WebSocketService;
status: StatusEnum;
private testSocket: WebSocket | null = null;
constructor(
public request: IWebClientRequest,
@ -46,7 +50,8 @@ export class WebClient {
{
send: (data) => this.socket.send(data),
isOpen: () => this.socket.checkReadyState(WebSocket.OPEN),
}
},
{ game: GameEvents, room: RoomEvents, session: SessionEvents },
);
this.socket.message$.subscribe((message: MessageEvent) => {
@ -64,24 +69,42 @@ export class WebClient {
}
public testConnect(target: ConnectTarget) {
// A prior test connection still in flight when the user re-clicks would
// otherwise leak the socket until its keepalive timeout. Close eagerly.
if (this.testSocket) {
this.testSocket.close();
this.testSocket = null;
}
const protocol = window.location.hostname === 'localhost' ? 'ws' : 'wss';
const { host, port } = target;
const socket = new WebSocket(`${protocol}://${host}:${port}`);
socket.binaryType = 'arraybuffer';
this.testSocket = socket;
const timeout = setTimeout(() => socket.close(), CLIENT_OPTIONS.keepalive);
const clearIfActive = () => {
if (this.testSocket === socket) {
this.testSocket = null;
}
};
socket.onopen = () => {
clearTimeout(timeout);
this.response.session.testConnectionSuccessful();
socket.close();
clearIfActive();
};
socket.onerror = () => {
this.response.session.testConnectionFailed();
clearIfActive();
};
socket.onclose = () => {};
socket.onclose = () => {
clearIfActive();
};
}
public disconnect() {

View file

@ -20,13 +20,21 @@ export function makeMockWebSocketInstance() {
export function installMockWebSocket() {
const originalWebSocket = (globalThis as any).WebSocket;
const mockInstance = makeMockWebSocketInstance();
const instances: ReturnType<typeof makeMockWebSocketInstance>[] = [mockInstance];
let firstCall = true;
const MockWS = vi.fn(function MockWebSocket() {
return mockInstance;
if (firstCall) {
firstCall = false;
return mockInstance;
}
const next = makeMockWebSocketInstance();
instances.push(next);
return next;
}) as any;
MockWS.OPEN = 1;
MockWS.CLOSED = 3;
(globalThis as any).WebSocket = MockWS;
return { MockWS, mockInstance, restore: () => {
return { MockWS, mockInstance, instances, restore: () => {
(globalThis as any).WebSocket = originalWebSocket;
} };
}

View file

@ -47,7 +47,7 @@ export function login(options: ConnectTarget & LoginParams, password?: string, p
WebClient.instance.response.session.updateBuddyList(buddyList);
WebClient.instance.response.session.updateIgnoreList(ignoreList);
WebClient.instance.response.session.updateUser(userInfo);
WebClient.instance.response.session.loginSuccessful({ ...resp, hashedPassword: loginConfig.hashedPassword });
WebClient.instance.response.session.loginSuccessful({ hashedPassword: loginConfig.hashedPassword });
listUsers();
listRooms();
@ -71,11 +71,10 @@ export function login(options: ConnectTarget & LoginParams, password?: string, p
onLoginError('Login failed: missing client ID'),
[Response_ResponseCode.RespContextError]: () =>
onLoginError('Login failed: server error'),
[Response_ResponseCode.RespAccountNotActivated]: (raw) =>
[Response_ResponseCode.RespAccountNotActivated]: () =>
onLoginError('Login failed: account not activated',
() => {
WebClient.instance.response.session.accountAwaitingActivation({
...raw,
host: options.host,
port: options.port,
userName: options.userName,

View file

@ -45,10 +45,9 @@ export function register(options: ConnectTarget & RegisterParams, password?: str
}, password, passwordSalt);
WebClient.instance.response.session.registrationSuccess();
},
[Response_ResponseCode.RespRegistrationAcceptedNeedsActivation]: (raw) => {
[Response_ResponseCode.RespRegistrationAcceptedNeedsActivation]: () => {
updateStatus(StatusEnum.DISCONNECTED, 'Registration accepted, awaiting activation');
WebClient.instance.response.session.accountAwaitingActivation({
...raw,
host: options.host,
port: options.port,
userName: options.userName,

View file

@ -7,25 +7,13 @@ vi.mock('@bufbuild/protobuf', async (importOriginal) => ({
setExtension: vi.fn(),
}));
vi.mock('../events/game', () => ({
GameEvents: [],
}));
vi.mock('../events/room', () => ({
RoomEvents: [],
}));
vi.mock('../events/session', () => ({
SessionEvents: [],
}));
import { create, fromBinary, hasExtension, getExtension } from '@bufbuild/protobuf';
import type { GenExtension } from '@bufbuild/protobuf/codegenv2';
import { ProtobufService } from './ProtobufService';
import { GameEvents } from '../events/game';
import { RoomEvents } from '../events/room';
import { SessionEvents } from '../events/session';
import { ProtobufService, type EventRegistries } from './ProtobufService';
import type { GameExtensionRegistry } from '../events/game';
import type { RoomExtensionRegistry } from '../events/room';
import type { SessionExtensionRegistry } from '../events/session';
import type {
AdminCommand,
@ -55,6 +43,12 @@ type ProtobufInternal = ProtobufService & {
};
let mockSocket: { isOpen: ReturnType<typeof vi.fn>; send: ReturnType<typeof vi.fn> };
let gameEvents: GameExtensionRegistry;
let roomEvents: RoomExtensionRegistry;
let sessionEvents: SessionExtensionRegistry;
let registries: EventRegistries;
const makeService = () => new ProtobufService(mockSocket, registries);
beforeEach(() => {
mockSocket = {
@ -62,10 +56,13 @@ beforeEach(() => {
send: vi.fn(),
};
// Reset event registries
(GameEvents as any).length = 0;
(RoomEvents as any).length = 0;
(SessionEvents as any).length = 0;
// Per-test registries inject empty handlers; tests that exercise dispatch
// push their own mock entries. This is what the old `(GameEvents as any).length = 0`
// hack approximated, now expressed as constructor injection.
gameEvents = [];
roomEvents = [];
sessionEvents = [];
registries = { game: gameEvents, room: roomEvents, session: sessionEvents };
});
describe('ProtobufService', () => {
@ -78,7 +75,7 @@ describe('ProtobufService', () => {
describe('resetCommands', () => {
it('resets cmdId and pendingCommands', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
service.sendSessionCommand(sessionExt, vi.fn());
expect((service as ProtobufInternal).cmdId).toBe(1);
service.resetCommands();
@ -89,7 +86,7 @@ describe('ProtobufService', () => {
describe('sendCommand', () => {
it('increments cmdId and stores callback', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
const cb = vi.fn();
service.sendCommand(create(CommandContainerSchema), cb);
expect((service as ProtobufInternal).cmdId).toBe(1);
@ -97,14 +94,14 @@ describe('ProtobufService', () => {
});
it('sends encoded data when socket is OPEN', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
mockSocket.isOpen.mockReturnValue(true);
service.sendCommand(create(CommandContainerSchema), vi.fn());
expect(mockSocket.send).toHaveBeenCalled();
});
it('does not register callback or increment cmdId when transport is closed', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
mockSocket.isOpen.mockReturnValue(false);
const cb = vi.fn();
service.sendCommand(create(CommandContainerSchema), cb);
@ -114,13 +111,13 @@ describe('ProtobufService', () => {
});
it('returns true when command is sent', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
const result = service.sendCommand(create(CommandContainerSchema), vi.fn());
expect(result).toBe(true);
});
it('returns false when transport is closed', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
mockSocket.isOpen.mockReturnValue(false);
const result = service.sendCommand(create(CommandContainerSchema), vi.fn());
expect(result).toBe(false);
@ -129,7 +126,7 @@ describe('ProtobufService', () => {
describe('send*Command when transport is closed', () => {
it('calls onError when sendSessionCommand is dropped', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
mockSocket.isOpen.mockReturnValue(false);
const onError = vi.fn();
service.sendSessionCommand(sessionExt, {}, { onError });
@ -137,7 +134,7 @@ describe('ProtobufService', () => {
});
it('calls onError when sendRoomCommand is dropped', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
mockSocket.isOpen.mockReturnValue(false);
const onError = vi.fn();
service.sendRoomCommand(42, roomExt, {}, { onError });
@ -145,7 +142,7 @@ describe('ProtobufService', () => {
});
it('calls onError when sendGameCommand is dropped', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
mockSocket.isOpen.mockReturnValue(false);
const onError = vi.fn();
service.sendGameCommand(7, gameExt, {}, { onError });
@ -153,7 +150,7 @@ describe('ProtobufService', () => {
});
it('calls onError when sendModeratorCommand is dropped', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
mockSocket.isOpen.mockReturnValue(false);
const onError = vi.fn();
service.sendModeratorCommand(moderatorExt, {}, { onError });
@ -161,7 +158,7 @@ describe('ProtobufService', () => {
});
it('calls onError when sendAdminCommand is dropped', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
mockSocket.isOpen.mockReturnValue(false);
const onError = vi.fn();
service.sendAdminCommand(adminExt, {}, { onError });
@ -169,7 +166,7 @@ describe('ProtobufService', () => {
});
it('does not throw when command is dropped with no options', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
mockSocket.isOpen.mockReturnValue(false);
expect(() => service.sendSessionCommand(sessionExt, {})).not.toThrow();
});
@ -177,14 +174,14 @@ describe('ProtobufService', () => {
describe('sendSessionCommand', () => {
it('stores callback and increments cmdId', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
service.sendSessionCommand(sessionExt, {});
expect((service as ProtobufInternal).cmdId).toBe(1);
expect((service as ProtobufInternal).pendingCommands.get(1)).toBeTypeOf('function');
});
it('invokes onResponse with raw response when the pending command is triggered', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
const cb = vi.fn();
service.sendSessionCommand(sessionExt, {}, { onResponse: cb });
@ -195,7 +192,7 @@ describe('ProtobufService', () => {
});
it('does not throw when no callback is provided and pending command is triggered', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
service.sendSessionCommand(sessionExt, {});
const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!;
@ -205,13 +202,13 @@ describe('ProtobufService', () => {
describe('sendRoomCommand', () => {
it('stores callback and increments cmdId', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
service.sendRoomCommand(42, roomExt, {});
expect((service as ProtobufInternal).cmdId).toBe(1);
});
it('invokes onResponse with raw response when the pending command is triggered', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
const cb = vi.fn();
service.sendRoomCommand(42, roomExt, {}, { onResponse: cb });
@ -222,7 +219,7 @@ describe('ProtobufService', () => {
});
it('does not throw when no callback is provided and pending command is triggered', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
service.sendRoomCommand(42, roomExt, {});
const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!;
@ -232,13 +229,13 @@ describe('ProtobufService', () => {
describe('sendGameCommand', () => {
it('stores callback and increments cmdId', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
service.sendGameCommand(7, gameExt, {});
expect((service as ProtobufInternal).cmdId).toBe(1);
});
it('invokes onResponse with raw response when the pending command is triggered', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
const cb = vi.fn();
service.sendGameCommand(7, gameExt, {}, { onResponse: cb });
@ -249,7 +246,7 @@ describe('ProtobufService', () => {
});
it('does not throw when no callback is provided and pending command is triggered', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
service.sendGameCommand(7, gameExt, {});
const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!;
@ -259,13 +256,13 @@ describe('ProtobufService', () => {
describe('sendModeratorCommand', () => {
it('stores callback and increments cmdId', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
service.sendModeratorCommand(moderatorExt, {});
expect((service as ProtobufInternal).cmdId).toBe(1);
});
it('invokes onResponse with raw response when the pending command is triggered', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
const cb = vi.fn();
service.sendModeratorCommand(moderatorExt, {}, { onResponse: cb });
@ -276,7 +273,7 @@ describe('ProtobufService', () => {
});
it('does not throw when no callback is provided and pending command is triggered', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
service.sendModeratorCommand(moderatorExt, {});
const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!;
@ -286,13 +283,13 @@ describe('ProtobufService', () => {
describe('sendAdminCommand', () => {
it('stores callback and increments cmdId', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
service.sendAdminCommand(adminExt, {});
expect((service as ProtobufInternal).cmdId).toBe(1);
});
it('invokes onResponse with raw response when the pending command is triggered', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
const cb = vi.fn();
service.sendAdminCommand(adminExt, {}, { onResponse: cb });
@ -303,7 +300,7 @@ describe('ProtobufService', () => {
});
it('does not throw when no callback is provided and pending command is triggered', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
service.sendAdminCommand(adminExt, {});
const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!;
@ -313,7 +310,7 @@ describe('ProtobufService', () => {
describe('handleMessageEvent', () => {
it('routes RESPONSE message to processServerResponse', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
const cb = vi.fn();
(service as ProtobufInternal).cmdId = 1;
(service as ProtobufInternal).pendingCommands.set(1, cb);
@ -331,7 +328,7 @@ describe('ProtobufService', () => {
});
it('routes ROOM_EVENT message', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
const processRoomEvent = vi.spyOn(service as ProtobufInternal, 'processRoomEvent');
vi.mocked(fromBinary).mockReturnValue(
@ -345,7 +342,7 @@ describe('ProtobufService', () => {
});
it('routes SESSION_EVENT message', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
const processSessionEvent = vi.spyOn(service as ProtobufInternal, 'processSessionEvent');
vi.mocked(fromBinary).mockReturnValue(
@ -359,7 +356,7 @@ describe('ProtobufService', () => {
});
it('routes GAME_EVENT_CONTAINER message', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
const processGameEvent = vi.spyOn(service as ProtobufInternal, 'processGameEvent');
vi.mocked(fromBinary).mockReturnValue(
@ -373,7 +370,7 @@ describe('ProtobufService', () => {
});
it('warns on unknown message types (default case)', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.mocked(fromBinary).mockReturnValue(
@ -388,13 +385,13 @@ describe('ProtobufService', () => {
});
it('does nothing when decoded message is null', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
vi.mocked(fromBinary).mockReturnValue(null!);
expect(() => service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent)).not.toThrow();
});
it('catches and logs decode errors', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(fromBinary).mockImplementation(() => {
throw new Error('decode error');
@ -407,7 +404,7 @@ describe('ProtobufService', () => {
describe('processGameEvent', () => {
it('returns early when container has no eventList', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
vi.mocked(hasExtension).mockReturnValue(false);
(service as ProtobufInternal).processGameEvent(null, {});
expect(hasExtension).not.toHaveBeenCalled();
@ -418,8 +415,8 @@ describe('ProtobufService', () => {
const mockExt = {} as GenExtension<GameEvent, unknown>;
const payload = { someData: 1 };
(GameEvents as any).push([mockExt, handler]);
const service = new ProtobufService(mockSocket);
(gameEvents as any).push([mockExt, handler]);
const service = makeService();
vi.mocked(hasExtension).mockReturnValue(true);
vi.mocked(getExtension).mockReturnValue(payload);
@ -436,8 +433,8 @@ describe('ProtobufService', () => {
const mockExt = {} as GenExtension<GameEvent, unknown>;
const payload = { someData: 1 };
(GameEvents as any).push([mockExt, handler]);
const service = new ProtobufService(mockSocket);
(gameEvents as any).push([mockExt, handler]);
const service = makeService();
vi.mocked(hasExtension).mockReturnValue(true);
vi.mocked(getExtension).mockReturnValue(payload);
@ -452,7 +449,7 @@ describe('ProtobufService', () => {
describe('processServerResponse', () => {
it('returns early when response is undefined', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
(service as ProtobufInternal).pendingCommands.set(1, vi.fn());
(service as ProtobufInternal).processServerResponse(undefined);
expect((service as ProtobufInternal).pendingCommands.size).toBe(1);
@ -461,7 +458,7 @@ describe('ProtobufService', () => {
describe('processRoomEvent', () => {
it('returns early when event is undefined', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
vi.mocked(hasExtension).mockReturnValue(false);
(service as ProtobufInternal).processRoomEvent(undefined);
expect(hasExtension).not.toHaveBeenCalled();
@ -472,8 +469,8 @@ describe('ProtobufService', () => {
const mockExt = {} as GenExtension<RoomEvent, unknown>;
const payload = { roomData: 1 };
(RoomEvents as any).push([mockExt, handler]);
const service = new ProtobufService(mockSocket);
(roomEvents as any).push([mockExt, handler]);
const service = makeService();
vi.mocked(hasExtension).mockReturnValue(true);
vi.mocked(getExtension).mockReturnValue(payload);
@ -486,7 +483,7 @@ describe('ProtobufService', () => {
describe('processSessionEvent', () => {
it('returns early when event is undefined', () => {
const service = new ProtobufService(mockSocket);
const service = makeService();
vi.mocked(hasExtension).mockReturnValue(false);
(service as ProtobufInternal).processSessionEvent(undefined);
expect(hasExtension).not.toHaveBeenCalled();
@ -497,8 +494,8 @@ describe('ProtobufService', () => {
const mockExt = {} as GenExtension<SessionEvent, unknown>;
const payload = { sessionData: 1 };
(SessionEvents as any).push([mockExt, handler]);
const service = new ProtobufService(mockSocket);
(sessionEvents as any).push([mockExt, handler]);
const service = makeService();
vi.mocked(hasExtension).mockReturnValue(true);
vi.mocked(getExtension).mockReturnValue(payload);

View file

@ -23,9 +23,9 @@ import {
type RoomEvent,
} from '@app/generated';
import { GameEvents } from '../events/game';
import { RoomEvents } from '../events/room';
import { SessionEvents } from '../events/session';
import type { GameExtensionRegistry } from '../events/game';
import type { RoomExtensionRegistry } from '../events/room';
import type { SessionExtensionRegistry } from '../events/session';
import type { GameEventMeta } from '../types/WebSocketConfig';
import { type CommandOptions, handleResponse } from './command-options';
@ -34,11 +34,20 @@ export interface SocketTransport {
isOpen(): boolean;
}
export interface EventRegistries {
game: GameExtensionRegistry;
room: RoomExtensionRegistry;
session: SessionExtensionRegistry;
}
export class ProtobufService {
private cmdId = 0;
private pendingCommands = new Map<number, (response: Response) => void>();
constructor(private transport: SocketTransport) {}
constructor(
private transport: SocketTransport,
private events: EventRegistries,
) {}
public resetCommands() {
this.cmdId = 0;
@ -171,7 +180,7 @@ export class ProtobufService {
if (!event) {
return;
}
for (const [ext, handler] of RoomEvents) {
for (const [ext, handler] of this.events.room) {
if (hasExtension(event, ext)) {
handler(getExtension(event, ext), event);
return;
@ -183,7 +192,7 @@ export class ProtobufService {
if (!event) {
return;
}
for (const [ext, handler] of SessionEvents) {
for (const [ext, handler] of this.events.session) {
if (hasExtension(event, ext)) {
handler(getExtension(event, ext), undefined);
return;
@ -207,7 +216,7 @@ export class ProtobufService {
forcedByJudge: forcedByJudge ?? 0,
};
for (const [ext, handler] of GameEvents) {
for (const [ext, handler] of this.events.game) {
if (hasExtension(event, ext)) {
handler(getExtension(event, ext), meta);
break;

View file

@ -1,12 +1,9 @@
import type {
Response_Login,
Response,
Response_GetGamesOfUser,
Response_DeckList,
Response_DeckDownload,
Response_ReplayDownload,
Response_WarnList,
ResponseMap,
Event_RoomSay,
Event_GameJoined,
Event_GameStateChanged,
@ -45,17 +42,17 @@ import type {
} from '@app/generated';
import type { StatusEnum } from './StatusEnum';
import type { LoginSuccessContext, PendingActivationContext } from './SignalContexts';
import type {
KeyOf,
WebSocketSessionResponseOverrides,
WebSocketRoomResponseOverrides,
} from './WebSocketConfig';
export interface ISessionResponse<T extends ResponseMap = WebSocketSessionResponseOverrides> {
export interface ISessionResponse {
initialized(): void;
connectionAttempted(): void;
clearStore(): void;
loginSuccessful(result: T[KeyOf<ResponseMap, Response_Login>]): void;
loginSuccessful(options: LoginSuccessContext): void;
loginFailed(): void;
connectionFailed(): void;
testConnectionSuccessful(): void;
@ -73,7 +70,7 @@ export interface ISessionResponse<T extends ResponseMap = WebSocketSessionRespon
userJoined(user: ServerInfo_User): void;
userLeft(userName: string): void;
serverMessage(message: string): void;
accountAwaitingActivation(result: T[KeyOf<ResponseMap, Response>]): void;
accountAwaitingActivation(options: PendingActivationContext): void;
accountActivationSuccess(): void;
accountActivationFailed(): void;
registrationRequiresEmail(): void;
@ -179,10 +176,9 @@ export interface IModeratorResponse {
}
export interface IWebClientResponse<
S extends ResponseMap = WebSocketSessionResponseOverrides,
R extends RoomEventMap = WebSocketRoomResponseOverrides,
> {
session: ISessionResponse<S>;
session: ISessionResponse;
room: IRoomResponse<R>;
game: IGameResponse;
admin: IAdminResponse;

View file

@ -1,9 +1,6 @@
import type {
GameEventContext,
Response_Login,
Response,
Event_RoomSay,
ResponseMap,
RoomEventMap,
} from '@app/generated';
@ -18,11 +15,6 @@ export interface GameEventMeta {
forcedByJudge: number;
}
export interface WebSocketSessionResponseOverrides extends ResponseMap {
Response_Login: Response_Login & { hashedPassword?: string };
Response: Response & { host: string; port: string; userName: string };
}
export interface WebSocketRoomResponseOverrides extends RoomEventMap {
Event_RoomSay: Event_RoomSay & { timeReceived: number };
}

View file

@ -0,0 +1,42 @@
import { setPendingOptions, consumePendingOptions } from './connectionState';
import type { WebSocketConnectOptions } from '../types/ConnectOptions';
const opts = (over: Partial<WebSocketConnectOptions> = {}): WebSocketConnectOptions => ({
type: 'login',
host: 'h',
port: '1',
userName: 'u',
...over,
} as unknown as WebSocketConnectOptions);
describe('connectionState', () => {
beforeEach(() => {
// Drain any value lingering from prior tests.
consumePendingOptions();
});
it('returns null when nothing has been set', () => {
expect(consumePendingOptions()).toBeNull();
});
it('round-trips a value through set → consume', () => {
const value = opts({ host: 'a' });
setPendingOptions(value);
expect(consumePendingOptions()).toBe(value);
});
it('consume is one-shot — second call returns null', () => {
setPendingOptions(opts());
expect(consumePendingOptions()).not.toBeNull();
expect(consumePendingOptions()).toBeNull();
});
it('a second set replaces the prior value (no queue semantics)', () => {
const first = opts({ host: 'a' });
const second = opts({ host: 'b' });
setPendingOptions(first);
setPendingOptions(second);
expect(consumePendingOptions()).toBe(second);
expect(consumePendingOptions()).toBeNull();
});
});

View file

@ -10,8 +10,13 @@ describe('guid', () => {
expect(guid()).toMatch(uuidPattern);
});
it('returns deterministic value when Math.random is mocked', () => {
const spy = vi.spyOn(Math, 'random').mockReturnValue(0.5);
it('returns deterministic value when crypto.getRandomValues is mocked', () => {
const spy = vi.spyOn(crypto, 'getRandomValues').mockImplementation((buf: any) => {
for (let i = 0; i < buf.length; i++) {
buf[i] = 0x1234;
}
return buf;
});
const result = guid();
expect(result).toBe(guid());
spy.mockRestore();

View file

@ -1,8 +1,15 @@
function s4(): string {
const s4 = Math.floor((1 + Math.random()) * 0x10000);
return s4.toString(16).substring(1);
function s4(buf: Uint16Array, idx: number): string {
// Mask to 16 bits then OR 0x10000 so the leading nibble is always present
// (guarantees 4 hex digits without padding logic).
const v = (buf[idx] & 0xffff) | 0x10000;
return v.toString(16).substring(1);
}
export function guid(): string {
return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
const buf = new Uint16Array(8);
crypto.getRandomValues(buf);
return (
s4(buf, 0) + s4(buf, 1) + '-' + s4(buf, 2) + '-' + s4(buf, 3) + '-' +
s4(buf, 4) + '-' + s4(buf, 5) + s4(buf, 6) + s4(buf, 7)
);
}

View file

@ -17,9 +17,12 @@ export const hashPassword = (salt: string, password: string): string => {
export const generateSalt = (): string => {
const characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
const bytes = new Uint8Array(SALT_LENGTH);
crypto.getRandomValues(bytes);
let salt = '';
for (let i = 0; i < SALT_LENGTH; i++) {
salt += characters.charAt(Math.floor(Math.random() * characters.length));
salt += characters.charAt(bytes[i] % characters.length);
}
return salt;

View file

@ -64,6 +64,16 @@ describe('sanitizeHtml', () => {
expect(result).not.toContain('src="javascript:');
});
it('strips ftp: scheme from img src (scheme-hardening vs desktop)', () => {
const result = sanitizeHtml('<img src="ftp://evil.example/tracker.png" />');
expect(result).not.toContain('ftp://');
});
it('preserves https: scheme on img src', () => {
const result = sanitizeHtml('<img src="https://example.com/img.png" />');
expect(result).toContain('src="https://example.com/img.png"');
});
it('strips onerror from img while keeping safe src', () => {
const result = sanitizeHtml('<img src="http://example.com/img.png" onerror="alert(1)" />');
expect(result).not.toContain('onerror');

View file

@ -8,10 +8,14 @@ DOMPurify.addHook('afterSanitizeAttributes', (node) => {
});
export function sanitizeHtml(msg: string): string {
// Desktop Cockatrice renders MOTD via Qt QTextBrowser with no sanitization;
// web client hardens via a DOMPurify tag/attr allowlist and restricts URIs
// to https/http (ftp is effectively dead in modern browsers and would only
// broaden the attack surface for a hostile server).
return DOMPurify.sanitize(msg, {
ALLOWED_TAGS: ['br', 'a', 'img', 'center', 'b', 'font'],
ALLOWED_ATTR: ['href', 'color', 'rel', 'target', 'src', 'alt'],
ADD_URI_SAFE_ATTR: ['color'],
ALLOWED_URI_REGEXP: /^(?:(?:https?|ftp):)/i,
ALLOWED_URI_REGEXP: /^https?:/i,
});
}