harden implementations

This commit is contained in:
seavor 2026-04-12 15:21:29 -05:00
parent c3ae4cffd6
commit 559a3ff1f4
25 changed files with 240 additions and 37 deletions

View file

@ -30,6 +30,8 @@ import { setSideboardLock } from './setSideboardLock';
import { setSideboardPlan } from './setSideboardPlan';
import { shuffle } from './shuffle';
import { undoDraw } from './undoDraw';
import { unconcede } from './unconcede';
import { judge } from './judge';
jest.mock('../../services/BackendService', () => ({
BackendService: { sendGameCommand: jest.fn() },
@ -197,4 +199,19 @@ describe('Game commands — delegate to BackendService.sendGameCommand', () => {
undoDraw(gameId);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_UndoDraw', {});
});
it('unconcede sends Command_Unconcede with empty object', () => {
unconcede(gameId);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_Unconcede', {});
});
it('judge sends Command_Judge with targetId and wrapped gameCommand array', () => {
const targetId = 3;
const innerGameCommand = { '.Command_DrawCards.ext': { numberOfCards: 2 } };
judge(gameId, targetId, innerGameCommand);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_Judge', {
targetId,
gameCommand: [innerGameCommand],
});
});
});

View file

@ -3,6 +3,8 @@ export { kickFromGame } from './kickFromGame';
export { gameSay } from './gameSay';
export { readyStart } from './readyStart';
export { concede } from './concede';
export { unconcede } from './unconcede';
export { judge } from './judge';
export { nextTurn } from './nextTurn';
export { setActivePhase } from './setActivePhase';
export { reverseTurn } from './reverseTurn';

View file

@ -0,0 +1,8 @@
import { BackendService } from '../../services/BackendService';
export function judge(gameId: number, targetId: number, innerGameCommand: any): void {
BackendService.sendGameCommand(gameId, 'Command_Judge', {
targetId,
gameCommand: [innerGameCommand],
});
}

View file

@ -0,0 +1,5 @@
import { BackendService } from '../../services/BackendService';
export function unconcede(gameId: number): void {
BackendService.sendGameCommand(gameId, 'Command_Unconcede', {});
}

View file

@ -24,7 +24,9 @@ export * from './ping';
export * from './register';
export * from './removeFromList';
export * from './replayDeleteMatch';
export * from './replayGetCode';
export * from './replayList';
export * from './replayModifyMatch';
export * from './replaySubmitCode';
export * from './requestPasswordSalt';
export * from './updateStatus';

View file

@ -44,7 +44,8 @@ export function login(options: WebSocketConnectOptions, passwordSalt?: string):
SessionPersistence.updateBuddyList(buddyList);
SessionPersistence.updateIgnoreList(ignoreList);
SessionPersistence.updateUser(userInfo);
SessionPersistence.loginSuccessful(loginConfig);
const { password: _password, ...safeConfig } = loginConfig;
SessionPersistence.loginSuccessful(safeConfig);
listUsers();
listRooms();

View file

@ -0,0 +1,10 @@
import { BackendService } from '../../services/BackendService';
export function replayGetCode(gameId: number, onCodeReceived: (code: string) => void): void {
BackendService.sendSessionCommand('Command_ReplayGetCode', { gameId }, {
responseName: 'Response_ReplayGetCode',
onSuccess: (response) => {
onCodeReceived(response.replayCode);
},
});
}

View file

@ -0,0 +1,12 @@
import { BackendService } from '../../services/BackendService';
export function replaySubmitCode(
replayCode: string,
onSuccess?: () => void,
onError?: (responseCode: number) => void,
): void {
BackendService.sendSessionCommand('Command_ReplaySubmitCode', { replayCode }, {
onSuccess,
onError,
});
}

View file

@ -163,6 +163,22 @@ describe('login', () => {
expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(StatusEnum.LOGGED_IN, 'Logged in.');
});
it('onSuccess does NOT pass plaintext password to loginSuccessful', () => {
login({ userName: 'alice', password: 'secret' } as any);
const loginResp = { buddyList: [], ignoreList: [], userInfo: { name: 'alice' } };
invokeOnSuccess(loginResp, { responseCode: 0, '.Response_Login.ext': loginResp });
const calledWith = (SessionPersistence.loginSuccessful as jest.Mock).mock.calls[0][0];
expect(calledWith).not.toHaveProperty('password');
});
it('onSuccess passes hashedPassword to loginSuccessful when salt is used', () => {
login({ userName: 'alice', password: 'pw' } as any, 'salt');
const loginResp = { buddyList: [], ignoreList: [], userInfo: { name: 'alice' } };
invokeOnSuccess(loginResp, { responseCode: 0, '.Response_Login.ext': loginResp });
const calledWith = (SessionPersistence.loginSuccessful as jest.Mock).mock.calls[0][0];
expect(calledWith).toHaveProperty('hashedPassword', 'hashed_pw');
});
it('onResponseCode RespClientUpdateRequired calls onLoginError', () => {
login({ userName: 'alice', password: 'pw' } as any);
invokeResponseCode(1);

View file

@ -423,3 +423,52 @@ describe('removeFromList / removeFromBuddyList / removeFromIgnoreList', () => {
expect(SessionPersistence.removeFromList).toHaveBeenCalledWith('buddy', 'alice');
});
});
describe('replayGetCode', () => {
const { replayGetCode } = jest.requireActual('./replayGetCode');
beforeEach(() => jest.clearAllMocks());
it('sends Command_ReplayGetCode with gameId and responseName', () => {
replayGetCode(42, jest.fn());
expect(BackendService.sendSessionCommand).toHaveBeenCalledWith(
'Command_ReplayGetCode',
{ gameId: 42 },
expect.objectContaining({ responseName: 'Response_ReplayGetCode' })
);
});
it('calls onCodeReceived with replayCode on success', () => {
const onCodeReceived = jest.fn();
replayGetCode(42, onCodeReceived);
invokeOnSuccess({ replayCode: 'abc123-xyz' });
expect(onCodeReceived).toHaveBeenCalledWith('abc123-xyz');
});
});
describe('replaySubmitCode', () => {
const { replaySubmitCode } = jest.requireActual('./replaySubmitCode');
beforeEach(() => jest.clearAllMocks());
it('sends Command_ReplaySubmitCode with replayCode', () => {
replaySubmitCode('42-abc123');
expect(BackendService.sendSessionCommand).toHaveBeenCalledWith(
'Command_ReplaySubmitCode',
{ replayCode: '42-abc123' },
expect.any(Object)
);
});
it('forwards onSuccess callback', () => {
const onSuccess = jest.fn();
replaySubmitCode('42-abc123', onSuccess);
invokeOnSuccess();
expect(onSuccess).toHaveBeenCalled();
});
it('forwards onError callback', () => {
const onError = jest.fn();
replaySubmitCode('42-abc123', undefined, onError);
invokeCallback('onError', 404);
expect(onError).toHaveBeenCalledWith(404);
});
});

View file

@ -19,7 +19,7 @@ export function connectionClosed({ reason, reasonStr, endTime }: ConnectionClose
message = 'There are too many concurrent connections from your address';
break;
case CloseReason.BANNED:
message = endTime > 0
message = typeof endTime === 'number' && endTime > 0 && Number.isFinite(endTime)
? `You are banned until ${new Date(endTime * 1000).toLocaleString()}`
: 'You are banned';
break;

View file

@ -322,6 +322,39 @@ describe('connectionClosed', () => {
connectionClosed({ reason: 7 } as any);
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'Unknown reason');
});
it('BANNED with valid positive endTime → shows formatted date', () => {
connectionClosed({ reason: 2, endTime: 1700000000 } as any);
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('You are banned until')
);
});
it('BANNED with endTime = 0 → shows generic banned message', () => {
connectionClosed({ reason: 2, endTime: 0 } as any);
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'You are banned');
});
it('BANNED with endTime = -1 → shows generic banned message', () => {
connectionClosed({ reason: 2, endTime: -1 } as any);
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'You are banned');
});
it('BANNED with endTime = NaN → shows generic banned message', () => {
connectionClosed({ reason: 2, endTime: NaN } as any);
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'You are banned');
});
it('BANNED with endTime = Infinity → shows generic banned message', () => {
connectionClosed({ reason: 2, endTime: Infinity } as any);
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'You are banned');
});
it('BANNED with reasonStr → uses reasonStr regardless of endTime', () => {
connectionClosed({ reason: 2, endTime: 0, reasonStr: 'custom ban reason' } as any);
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'custom ban reason');
});
});
// ----------------------------------------------------------------

View file

@ -80,6 +80,16 @@ describe('RoomPersistence', () => {
RoomPersistence.updateGames(1, [game]);
expect(NormalizeService.normalizeGameObject).not.toHaveBeenCalled();
});
it('returns without error when gameList is empty', () => {
expect(() => RoomPersistence.updateGames(1, [])).not.toThrow();
expect(RoomsDispatch.updateGames).not.toHaveBeenCalled();
});
it('returns without error when gameList is null', () => {
expect(() => RoomPersistence.updateGames(1, null as any)).not.toThrow();
expect(RoomsDispatch.updateGames).not.toHaveBeenCalled();
});
});
it('addMessage normalizes message and dispatches', () => {

View file

@ -21,6 +21,10 @@ export class RoomPersistence {
}
static updateGames(roomId: number, gameList: Game[]) {
if (!gameList?.length) {
return;
}
const game = gameList[0];
if (!game.gameType) {

View file

@ -258,6 +258,25 @@ describe('ProtobufService', () => {
expect((service as any).pendingCommands[1]).toBeUndefined();
});
it('resolves pending command when response cmdId is a protobufjs Long object', () => {
const service = new ProtobufService(mockWebClient);
const cb = jest.fn();
(service as any).cmdId = 1;
(service as any).pendingCommands[1] = cb;
// Simulate protobufjs decoding cmdId as a Long object (low=1, high=0)
const longCmdId = { low: 1, high: 0, unsigned: false, toString: () => '1' };
const response = { cmdId: longCmdId };
ProtoController.root.ServerMessage.decode = jest.fn().mockReturnValue({
messageType: ProtoController.root.ServerMessage.MessageType.RESPONSE,
response,
});
service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent);
expect(cb).toHaveBeenCalledWith(response);
expect((service as any).pendingCommands[1]).toBeUndefined();
});
it('routes ROOM_EVENT message', () => {
const service = new ProtobufService(mockWebClient);
const processRoomEvent = jest.spyOn(service as any, 'processRoomEvent');

View file

@ -52,4 +52,21 @@ describe('sanitizeHtml', () => {
const result = sanitizeHtml('<a href="javascript:alert(1)">xss</a>');
expect(result).not.toContain('javascript:');
});
it('preserves src and alt on img tags', () => {
const result = sanitizeHtml('<img src="http://example.com/img.png" alt="test" />');
expect(result).toContain('src="http://example.com/img.png"');
expect(result).toContain('alt="test"');
});
it('strips javascript: scheme from img src', () => {
const result = sanitizeHtml('<img src="javascript:alert(1)" />');
expect(result).not.toContain('src="javascript:');
});
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');
expect(result).toContain('src="http://example.com/img.png"');
});
});

View file

@ -5,6 +5,7 @@ export function sanitizeHtml(msg: string): string {
allowedTags: ['br', 'a', 'img', 'center', 'b', 'font'],
allowedAttributes: {
'*': ['href', 'color', 'rel', 'target'],
'img': ['src', 'alt'],
},
allowedSchemes: ['http', 'https', 'ftp'],
transformTags: {