From 559a3ff1f47a7fe71f9f318c7cf00c28b2f83df7 Mon Sep 17 00:00:00 2001 From: seavor Date: Sun, 12 Apr 2026 15:21:29 -0500 Subject: [PATCH] harden implementations --- webclient/src/containers/Login/Login.tsx | 12 +++-- .../store/server/__mocks__/server-fixtures.ts | 1 - .../src/store/server/server.interfaces.ts | 3 +- .../src/store/server/server.reducer.spec.ts | 16 +++--- webclient/src/store/server/server.reducer.ts | 13 +---- .../src/store/server/server.selectors.spec.ts | 6 --- .../src/store/server/server.selectors.ts | 1 - webclient/src/types/game.ts | 15 +++++- .../commands/game/gameCommands.spec.ts | 17 +++++++ .../src/websocket/commands/game/index.ts | 2 + .../src/websocket/commands/game/judge.ts | 8 +++ .../src/websocket/commands/game/unconcede.ts | 5 ++ .../src/websocket/commands/session/index.ts | 2 + .../src/websocket/commands/session/login.ts | 3 +- .../commands/session/replayGetCode.ts | 10 ++++ .../commands/session/replaySubmitCode.ts | 12 +++++ .../session/sessionCommands-complex.spec.ts | 16 ++++++ .../session/sessionCommands-simple.spec.ts | 49 +++++++++++++++++++ .../events/session/connectionClosed.ts | 2 +- .../events/session/sessionEvents.spec.ts | 33 +++++++++++++ .../persistence/RoomPersistence.spec.ts | 10 ++++ .../websocket/persistence/RoomPersistence.ts | 4 ++ .../services/ProtobufService.spec.ts | 19 +++++++ .../websocket/utils/sanitizeHtml.util.spec.ts | 17 +++++++ .../src/websocket/utils/sanitizeHtml.util.ts | 1 + 25 files changed, 240 insertions(+), 37 deletions(-) create mode 100644 webclient/src/websocket/commands/game/judge.ts create mode 100644 webclient/src/websocket/commands/game/unconcede.ts create mode 100644 webclient/src/websocket/commands/session/replayGetCode.ts create mode 100644 webclient/src/websocket/commands/session/replaySubmitCode.ts diff --git a/webclient/src/containers/Login/Login.tsx b/webclient/src/containers/Login/Login.tsx index fe008375c..965078759 100644 --- a/webclient/src/containers/Login/Login.tsx +++ b/webclient/src/containers/Login/Login.tsx @@ -64,11 +64,13 @@ const Root = styled('div')(({ theme }) => ({ } })); -const Login = ({ state, description, connectOptions }: LoginProps) => { +const Login = ({ state, description }: LoginProps) => { const { t } = useTranslation(); const isConnected = AuthenticationService.isConnected(state); + const [pendingActivationOptions, setPendingActivationOptions] = useState(null); + const [rememberLogin, setRememberLogin] = useState(null); const [dialogState, setDialogState] = useState({ passwordResetRequestDialog: false, @@ -97,9 +99,11 @@ const Login = ({ state, description, connectOptions }: LoginProps) => { useReduxEffect(() => { accountActivatedToast.openToast() closeActivateAccountDialog(); + setPendingActivationOptions(null); }, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []); - useReduxEffect(() => { + useReduxEffect(({ options }) => { + setPendingActivationOptions(options); closeRegistrationDialog(); openActivateAccountDialog(); }, ServerTypes.ACCOUNT_AWAITING_ACTIVATION, []); @@ -161,7 +165,7 @@ const Login = ({ state, description, connectOptions }: LoginProps) => { const handleAccountActivationDialogSubmit = ({ token }) => { AuthenticationService.activateAccount({ - ...connectOptions, + ...pendingActivationOptions, token, }); }; @@ -348,13 +352,11 @@ const Login = ({ state, description, connectOptions }: LoginProps) => { interface LoginProps { state: number; description: string; - connectOptions: WebSocketConnectOptions; } const mapStateToProps = state => ({ state: ServerSelectors.getState(state), description: ServerSelectors.getDescription(state), - connectOptions: ServerSelectors.getConnectOptions(state), }); export default connect(mapStateToProps)(Login); diff --git a/webclient/src/store/server/__mocks__/server-fixtures.ts b/webclient/src/store/server/__mocks__/server-fixtures.ts index 404424f6d..9a7580365 100644 --- a/webclient/src/store/server/__mocks__/server-fixtures.ts +++ b/webclient/src/store/server/__mocks__/server-fixtures.ts @@ -136,7 +136,6 @@ export function makeServerState(overrides: Partial = {}): ServerSta field: UserSortField.NAME, order: SortDirection.ASC, }, - connectOptions: {}, messages: {}, userInfo: {}, notifications: [], diff --git a/webclient/src/store/server/server.interfaces.ts b/webclient/src/store/server/server.interfaces.ts index b0932d0ea..4a9d63509 100644 --- a/webclient/src/store/server/server.interfaces.ts +++ b/webclient/src/store/server/server.interfaces.ts @@ -1,5 +1,5 @@ import { - WarnHistoryItem, BanHistoryItem, DeckList, Game, LogItem, ReplayMatch, SortBy, User, UserSortField, WebSocketConnectOptions, WarnListItem + WarnHistoryItem, BanHistoryItem, DeckList, Game, LogItem, ReplayMatch, SortBy, User, UserSortField, WarnListItem } from 'types'; import { NotifyUserData, ServerShutdownData, UserMessageData } from 'websocket/events/session/interfaces'; @@ -51,7 +51,6 @@ export interface ServerState { user: User; users: User[]; sortUsersBy: ServerStateSortUsersBy; - connectOptions: WebSocketConnectOptions; messages: { [userName: string]: UserMessageData[]; } diff --git a/webclient/src/store/server/server.reducer.spec.ts b/webclient/src/store/server/server.reducer.spec.ts index 3645c299e..c501fbe0a 100644 --- a/webclient/src/store/server/server.reducer.spec.ts +++ b/webclient/src/store/server/server.reducer.spec.ts @@ -51,23 +51,23 @@ describe('Initialisation', () => { // ── Account & Connection ───────────────────────────────────────────────────── describe('Account & Connection', () => { - it('ACCOUNT_AWAITING_ACTIVATION → sets connectOptions from action.options', () => { + it('ACCOUNT_AWAITING_ACTIVATION → returns state unchanged', () => { const options = makeConnectOptions(); const state = makeServerState(); const result = serverReducer(state, { type: Types.ACCOUNT_AWAITING_ACTIVATION, options }); - expect(result.connectOptions).toEqual(options); + expect(result).toBe(state); }); - it('ACCOUNT_ACTIVATION_SUCCESS → clears connectOptions to {}', () => { - const state = makeServerState({ connectOptions: makeConnectOptions() }); + it('ACCOUNT_ACTIVATION_SUCCESS → returns state unchanged', () => { + const state = makeServerState(); const result = serverReducer(state, { type: Types.ACCOUNT_ACTIVATION_SUCCESS }); - expect(result.connectOptions).toEqual({}); + expect(result).toBe(state); }); - it('ACCOUNT_ACTIVATION_FAILED → clears connectOptions to {}', () => { - const state = makeServerState({ connectOptions: makeConnectOptions() }); + it('ACCOUNT_ACTIVATION_FAILED → returns state unchanged', () => { + const state = makeServerState(); const result = serverReducer(state, { type: Types.ACCOUNT_ACTIVATION_FAILED }); - expect(result.connectOptions).toEqual({}); + expect(result).toBe(state); }); }); diff --git a/webclient/src/store/server/server.reducer.ts b/webclient/src/store/server/server.reducer.ts index aaa525977..f5ad57e7c 100644 --- a/webclient/src/store/server/server.reducer.ts +++ b/webclient/src/store/server/server.reducer.ts @@ -80,7 +80,6 @@ const initialState: ServerState = { field: UserSortField.NAME, order: SortDirection.ASC }, - connectOptions: {}, messages: {}, userInfo: {}, notifications: [], @@ -105,19 +104,11 @@ export const serverReducer = (state = initialState, action: any) => { } } case Types.ACCOUNT_AWAITING_ACTIVATION: { - return { - ...state, - connectOptions: { - ...action.options - } - } + return state; } case Types.ACCOUNT_ACTIVATION_FAILED: case Types.ACCOUNT_ACTIVATION_SUCCESS: { - return { - ...state, - connectOptions: {} - } + return state; } case Types.CLEAR_STORE: { return { diff --git a/webclient/src/store/server/server.selectors.spec.ts b/webclient/src/store/server/server.selectors.spec.ts index 711513150..9adcdbaca 100644 --- a/webclient/src/store/server/server.selectors.spec.ts +++ b/webclient/src/store/server/server.selectors.spec.ts @@ -18,12 +18,6 @@ describe('Selectors', () => { expect(Selectors.getInitialized(rootState(state))).toBe(true); }); - it('getConnectOptions → returns connectOptions', () => { - const connectOptions = { host: 'localhost', port: '4747' }; - const state = makeServerState({ connectOptions }); - expect(Selectors.getConnectOptions(rootState(state))).toBe(connectOptions); - }); - it('getMessage → returns info.message', () => { const state = makeServerState({ info: { message: 'Welcome!', name: null, version: null } }); expect(Selectors.getMessage(rootState(state))).toBe('Welcome!'); diff --git a/webclient/src/store/server/server.selectors.ts b/webclient/src/store/server/server.selectors.ts index fa9f82297..4506699cf 100644 --- a/webclient/src/store/server/server.selectors.ts +++ b/webclient/src/store/server/server.selectors.ts @@ -6,7 +6,6 @@ interface State { export const Selectors = { getInitialized: ({ server }: State) => server.initialized, - getConnectOptions: ({ server }: State) => server.connectOptions, getMessage: ({ server }: State) => server.info.message, getName: ({ server }: State) => server.info.name, getVersion: ({ server }: State) => server.info.version, diff --git a/webclient/src/types/game.ts b/webclient/src/types/game.ts index 4961dc92c..74ad18381 100644 --- a/webclient/src/types/game.ts +++ b/webclient/src/types/game.ts @@ -305,11 +305,24 @@ export interface ReverseTurnData { * Contains per-container metadata from GameEventContainer. * Not stored in Redux — transient routing metadata only. */ +export interface GameEventContext { + '.Context_ReadyStart.ext'?: {}; + '.Context_Concede.ext'?: {}; + '.Context_DeckSelect.ext'?: {}; + '.Context_UndoDraw.ext'?: {}; + '.Context_MoveCard.ext'?: {}; + '.Context_Mulligan.ext'?: {}; + '.Context_PingChanged.ext'?: {}; + '.Context_ConnectionStateChanged.ext'?: {}; + '.Context_SetSideboardLock.ext'?: {}; + '.Context_Unconcede.ext'?: {}; +} + export interface GameEventMeta { gameId: number; playerId: number; /** Raw protobuf GameEventContext object. Not stored in Redux. */ - context: any; + context: GameEventContext | null; secondsElapsed: number; /** Proto type is uint32. Non-zero means the action was forced by a judge. */ forcedByJudge: number; diff --git a/webclient/src/websocket/commands/game/gameCommands.spec.ts b/webclient/src/websocket/commands/game/gameCommands.spec.ts index 6d802136d..8d9597be9 100644 --- a/webclient/src/websocket/commands/game/gameCommands.spec.ts +++ b/webclient/src/websocket/commands/game/gameCommands.spec.ts @@ -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], + }); + }); }); diff --git a/webclient/src/websocket/commands/game/index.ts b/webclient/src/websocket/commands/game/index.ts index 8978c938c..e91555f30 100644 --- a/webclient/src/websocket/commands/game/index.ts +++ b/webclient/src/websocket/commands/game/index.ts @@ -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'; diff --git a/webclient/src/websocket/commands/game/judge.ts b/webclient/src/websocket/commands/game/judge.ts new file mode 100644 index 000000000..1b9f22d14 --- /dev/null +++ b/webclient/src/websocket/commands/game/judge.ts @@ -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], + }); +} diff --git a/webclient/src/websocket/commands/game/unconcede.ts b/webclient/src/websocket/commands/game/unconcede.ts new file mode 100644 index 000000000..b724aee03 --- /dev/null +++ b/webclient/src/websocket/commands/game/unconcede.ts @@ -0,0 +1,5 @@ +import { BackendService } from '../../services/BackendService'; + +export function unconcede(gameId: number): void { + BackendService.sendGameCommand(gameId, 'Command_Unconcede', {}); +} diff --git a/webclient/src/websocket/commands/session/index.ts b/webclient/src/websocket/commands/session/index.ts index 74d0d062c..a6d3d884b 100644 --- a/webclient/src/websocket/commands/session/index.ts +++ b/webclient/src/websocket/commands/session/index.ts @@ -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'; diff --git a/webclient/src/websocket/commands/session/login.ts b/webclient/src/websocket/commands/session/login.ts index 6f3ec5ef5..c98128917 100644 --- a/webclient/src/websocket/commands/session/login.ts +++ b/webclient/src/websocket/commands/session/login.ts @@ -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(); diff --git a/webclient/src/websocket/commands/session/replayGetCode.ts b/webclient/src/websocket/commands/session/replayGetCode.ts new file mode 100644 index 000000000..1e0557d1c --- /dev/null +++ b/webclient/src/websocket/commands/session/replayGetCode.ts @@ -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); + }, + }); +} diff --git a/webclient/src/websocket/commands/session/replaySubmitCode.ts b/webclient/src/websocket/commands/session/replaySubmitCode.ts new file mode 100644 index 000000000..ad1896e57 --- /dev/null +++ b/webclient/src/websocket/commands/session/replaySubmitCode.ts @@ -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, + }); +} diff --git a/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts index edcbf6983..1d61c8a36 100644 --- a/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts +++ b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts @@ -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); diff --git a/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts b/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts index bd9a6830b..723bdbee9 100644 --- a/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts +++ b/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts @@ -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); + }); +}); diff --git a/webclient/src/websocket/events/session/connectionClosed.ts b/webclient/src/websocket/events/session/connectionClosed.ts index 40f75c013..796f3ab80 100644 --- a/webclient/src/websocket/events/session/connectionClosed.ts +++ b/webclient/src/websocket/events/session/connectionClosed.ts @@ -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; diff --git a/webclient/src/websocket/events/session/sessionEvents.spec.ts b/webclient/src/websocket/events/session/sessionEvents.spec.ts index 35ad9bcaa..f6f39957b 100644 --- a/webclient/src/websocket/events/session/sessionEvents.spec.ts +++ b/webclient/src/websocket/events/session/sessionEvents.spec.ts @@ -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'); + }); }); // ---------------------------------------------------------------- diff --git a/webclient/src/websocket/persistence/RoomPersistence.spec.ts b/webclient/src/websocket/persistence/RoomPersistence.spec.ts index 40874928b..1130ec890 100644 --- a/webclient/src/websocket/persistence/RoomPersistence.spec.ts +++ b/webclient/src/websocket/persistence/RoomPersistence.spec.ts @@ -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', () => { diff --git a/webclient/src/websocket/persistence/RoomPersistence.ts b/webclient/src/websocket/persistence/RoomPersistence.ts index a69e79338..20ed62066 100644 --- a/webclient/src/websocket/persistence/RoomPersistence.ts +++ b/webclient/src/websocket/persistence/RoomPersistence.ts @@ -21,6 +21,10 @@ export class RoomPersistence { } static updateGames(roomId: number, gameList: Game[]) { + if (!gameList?.length) { + return; + } + const game = gameList[0]; if (!game.gameType) { diff --git a/webclient/src/websocket/services/ProtobufService.spec.ts b/webclient/src/websocket/services/ProtobufService.spec.ts index c03173020..0050f645c 100644 --- a/webclient/src/websocket/services/ProtobufService.spec.ts +++ b/webclient/src/websocket/services/ProtobufService.spec.ts @@ -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'); diff --git a/webclient/src/websocket/utils/sanitizeHtml.util.spec.ts b/webclient/src/websocket/utils/sanitizeHtml.util.spec.ts index cea755d98..86c547548 100644 --- a/webclient/src/websocket/utils/sanitizeHtml.util.spec.ts +++ b/webclient/src/websocket/utils/sanitizeHtml.util.spec.ts @@ -52,4 +52,21 @@ describe('sanitizeHtml', () => { const result = sanitizeHtml('xss'); expect(result).not.toContain('javascript:'); }); + + it('preserves src and alt on img tags', () => { + const result = sanitizeHtml('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(''); + expect(result).not.toContain('src="javascript:'); + }); + + it('strips onerror from img while keeping safe src', () => { + const result = sanitizeHtml(''); + expect(result).not.toContain('onerror'); + expect(result).toContain('src="http://example.com/img.png"'); + }); }); diff --git a/webclient/src/websocket/utils/sanitizeHtml.util.ts b/webclient/src/websocket/utils/sanitizeHtml.util.ts index e5321213b..886a56e48 100644 --- a/webclient/src/websocket/utils/sanitizeHtml.util.ts +++ b/webclient/src/websocket/utils/sanitizeHtml.util.ts @@ -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: {