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
|
|
@ -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<WebSocketConnectOptions | null>(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);
|
||||
|
|
|
|||
|
|
@ -136,7 +136,6 @@ export function makeServerState(overrides: Partial<ServerState> = {}): ServerSta
|
|||
field: UserSortField.NAME,
|
||||
order: SortDirection.ASC,
|
||||
},
|
||||
connectOptions: {},
|
||||
messages: {},
|
||||
userInfo: {},
|
||||
notifications: [],
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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!');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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