mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-04-27 07:48:01 -07:00
harden implementations
This commit is contained in:
parent
c3ae4cffd6
commit
559a3ff1f4
25 changed files with 240 additions and 37 deletions
|
|
@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
8
webclient/src/websocket/commands/game/judge.ts
Normal file
8
webclient/src/websocket/commands/game/judge.ts
Normal 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],
|
||||
});
|
||||
}
|
||||
5
webclient/src/websocket/commands/game/unconcede.ts
Normal file
5
webclient/src/websocket/commands/game/unconcede.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { BackendService } from '../../services/BackendService';
|
||||
|
||||
export function unconcede(gameId: number): void {
|
||||
BackendService.sendGameCommand(gameId, 'Command_Unconcede', {});
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
10
webclient/src/websocket/commands/session/replayGetCode.ts
Normal file
10
webclient/src/websocket/commands/session/replayGetCode.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
12
webclient/src/websocket/commands/session/replaySubmitCode.ts
Normal file
12
webclient/src/websocket/commands/session/replaySubmitCode.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ export class RoomPersistence {
|
|||
}
|
||||
|
||||
static updateGames(roomId: number, gameList: Game[]) {
|
||||
if (!gameList?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const game = gameList[0];
|
||||
|
||||
if (!game.gameType) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue