""" Tests for rate limiting in WebSocket handlers. Verifies that rate limiting is properly applied at both connection and game levels for all handlers that implement rate limiting. This is critical for DOS protection. Author: Claude Date: 2025-11-27 """ import pytest from uuid import uuid4 from unittest.mock import AsyncMock, MagicMock, patch from contextlib import asynccontextmanager # ============================================================================ # CONNECTION-LEVEL RATE LIMITING TESTS # ============================================================================ class TestConnectionRateLimiting: """ Tests for connection-level rate limiting across handlers. Connection-level rate limiting prevents any single connection from overwhelming the server with requests, regardless of the request type. """ @pytest.mark.asyncio async def test_join_game_rate_limited_returns_error(self, mock_manager): """ Verify join_game returns error when connection is rate limited. When a connection exceeds its rate limit, the handler should emit an error with code RATE_LIMITED and not process the join request. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter: mock_limiter.check_websocket_limit = AsyncMock(return_value=False) register_handlers(sio, mock_manager) handler = sio.handlers["/"]["join_game"] await handler("test_sid", {"game_id": str(uuid4())}) # Verify error emitted mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "RATE_LIMITED" in call_args[0][2]["code"] # Verify join not attempted mock_manager.join_game.assert_not_called() @pytest.mark.asyncio async def test_request_game_state_rate_limited(self, mock_manager): """ Verify request_game_state returns error when rate limited. Game state requests can be expensive (database recovery), so rate limiting is essential to prevent abuse. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter: mock_limiter.check_websocket_limit = AsyncMock(return_value=False) register_handlers(sio, mock_manager) handler = sio.handlers["/"]["request_game_state"] await handler("test_sid", {"game_id": str(uuid4())}) # Verify rate limit error mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "RATE_LIMITED" in call_args[0][2]["code"] @pytest.mark.asyncio async def test_roll_dice_rate_limited_at_connection_level(self, mock_manager): """ Verify roll_dice checks connection-level rate limit first. Roll dice has both connection-level and game-level limits. The connection limit is checked first. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter: mock_limiter.check_websocket_limit = AsyncMock(return_value=False) register_handlers(sio, mock_manager) handler = sio.handlers["/"]["roll_dice"] await handler("test_sid", {"game_id": str(uuid4())}) mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "RATE_LIMITED" in call_args[0][2]["code"] @pytest.mark.asyncio async def test_submit_manual_outcome_rate_limited(self, mock_manager): """ Verify submit_manual_outcome returns error when rate limited. Manual outcome submissions must be rate limited to prevent flooding. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter: mock_limiter.check_websocket_limit = AsyncMock(return_value=False) register_handlers(sio, mock_manager) handler = sio.handlers["/"]["submit_manual_outcome"] await handler("test_sid", {"game_id": str(uuid4()), "outcome": "single"}) mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "RATE_LIMITED" in call_args[0][2]["code"] @pytest.mark.asyncio async def test_get_lineup_rate_limited(self, mock_manager): """ Verify get_lineup returns error when rate limited. Lineup queries can hit the database, so rate limiting prevents abuse. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter: mock_limiter.check_websocket_limit = AsyncMock(return_value=False) register_handlers(sio, mock_manager) handler = sio.handlers["/"]["get_lineup"] await handler("test_sid", {"game_id": str(uuid4()), "team_id": 1}) mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "RATE_LIMITED" in call_args[0][2]["code"] @pytest.mark.asyncio async def test_get_box_score_rate_limited(self, mock_manager): """ Verify get_box_score returns error when rate limited. Box score queries aggregate data, so they're expensive and need limiting. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter: mock_limiter.check_websocket_limit = AsyncMock(return_value=False) register_handlers(sio, mock_manager) handler = sio.handlers["/"]["get_box_score"] await handler("test_sid", {"game_id": str(uuid4())}) mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "RATE_LIMITED" in call_args[0][2]["code"] # ============================================================================ # GAME-LEVEL RATE LIMITING TESTS # ============================================================================ class TestGameRateLimiting: """ Tests for game-level rate limiting. Game-level rate limiting prevents any single game from being flooded with actions, protecting the game state integrity and server resources. """ @pytest.mark.asyncio async def test_roll_dice_game_rate_limited(self, mock_manager, mock_game_state): """ Verify roll_dice returns error when game roll limit exceeded. Dice rolls have a per-game limit to prevent spamming. When the limit is exceeded, the handler should emit a GAME_RATE_LIMITED error. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() @asynccontextmanager async def mock_lock(game_id, timeout=30.0): yield with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \ patch("app.websocket.handlers.state_manager") as mock_state_mgr: # Connection limit passes, game limit fails mock_limiter.check_websocket_limit = AsyncMock(return_value=True) mock_limiter.check_game_limit = AsyncMock(return_value=False) mock_state_mgr.get_state.return_value = mock_game_state mock_state_mgr.game_lock = mock_lock register_handlers(sio, mock_manager) handler = sio.handlers["/"]["roll_dice"] await handler("test_sid", {"game_id": str(mock_game_state.game_id)}) # Verify game rate limit error mock_manager.emit_to_user.assert_called() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "GAME_RATE_LIMITED" in call_args[0][2]["code"] @pytest.mark.asyncio async def test_submit_manual_outcome_game_rate_limited( self, mock_manager, mock_game_state ): """ Verify submit_manual_outcome returns error when game decision limit exceeded. Decision submissions have per-game limits to prevent rapid-fire outcomes. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \ patch("app.websocket.handlers.state_manager") as mock_state_mgr: mock_limiter.check_websocket_limit = AsyncMock(return_value=True) mock_limiter.check_game_limit = AsyncMock(return_value=False) mock_state_mgr.get_state.return_value = mock_game_state register_handlers(sio, mock_manager) handler = sio.handlers["/"]["submit_manual_outcome"] await handler( "test_sid", {"game_id": str(mock_game_state.game_id), "outcome": "single"}, ) mock_manager.emit_to_user.assert_called() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "GAME_RATE_LIMITED" in call_args[0][2]["code"] @pytest.mark.asyncio async def test_submit_defensive_decision_game_rate_limited( self, mock_manager, mock_game_state ): """ Verify submit_defensive_decision returns error when game decision limit exceeded. Defensive decisions use the 'decision' action type for rate limiting. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \ patch("app.websocket.handlers.state_manager") as mock_state_mgr: mock_limiter.check_websocket_limit = AsyncMock(return_value=True) mock_limiter.check_game_limit = AsyncMock(return_value=False) mock_state_mgr.get_state.return_value = mock_game_state register_handlers(sio, mock_manager) handler = sio.handlers["/"]["submit_defensive_decision"] await handler( "test_sid", {"game_id": str(mock_game_state.game_id), "alignment": "normal"}, ) mock_manager.emit_to_user.assert_called() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "GAME_RATE_LIMITED" in call_args[0][2]["code"] @pytest.mark.asyncio async def test_submit_offensive_decision_game_rate_limited( self, mock_manager, mock_game_state ): """ Verify submit_offensive_decision returns error when game decision limit exceeded. Offensive decisions use the 'decision' action type for rate limiting. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \ patch("app.websocket.handlers.state_manager") as mock_state_mgr: mock_limiter.check_websocket_limit = AsyncMock(return_value=True) mock_limiter.check_game_limit = AsyncMock(return_value=False) mock_state_mgr.get_state.return_value = mock_game_state register_handlers(sio, mock_manager) handler = sio.handlers["/"]["submit_offensive_decision"] await handler( "test_sid", {"game_id": str(mock_game_state.game_id), "action": "swing_away"}, ) mock_manager.emit_to_user.assert_called() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "GAME_RATE_LIMITED" in call_args[0][2]["code"] # ============================================================================ # SUBSTITUTION RATE LIMITING TESTS # ============================================================================ class TestSubstitutionRateLimiting: """ Tests for rate limiting on substitution handlers. Substitution actions have their own rate limit category to prevent rapid lineup changes which could cause state inconsistencies. """ @pytest.mark.asyncio async def test_pinch_hitter_connection_rate_limited(self, mock_manager): """ Verify pinch_hitter returns error when connection rate limited. Connection-level limits are checked before game-level limits. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter: mock_limiter.check_websocket_limit = AsyncMock(return_value=False) register_handlers(sio, mock_manager) handler = sio.handlers["/"]["request_pinch_hitter"] await handler( "test_sid", { "game_id": str(uuid4()), "player_out_lineup_id": 1, "player_in_card_id": 999, "team_id": 1, }, ) mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "RATE_LIMITED" in call_args[0][2]["code"] @pytest.mark.asyncio async def test_pinch_hitter_game_rate_limited(self, mock_manager, mock_game_state): """ Verify pinch_hitter returns error when substitution limit exceeded. The substitution rate limit is per-game to prevent lineup abuse. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \ patch("app.websocket.handlers.state_manager") as mock_state_mgr: mock_limiter.check_websocket_limit = AsyncMock(return_value=True) mock_limiter.check_game_limit = AsyncMock(return_value=False) mock_state_mgr.get_state.return_value = mock_game_state register_handlers(sio, mock_manager) handler = sio.handlers["/"]["request_pinch_hitter"] await handler( "test_sid", { "game_id": str(mock_game_state.game_id), "player_out_lineup_id": 1, "player_in_card_id": 999, "team_id": 1, }, ) mock_manager.emit_to_user.assert_called() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "GAME_RATE_LIMITED" in call_args[0][2]["code"] @pytest.mark.asyncio async def test_defensive_replacement_connection_rate_limited(self, mock_manager): """ Verify defensive_replacement returns error when connection rate limited. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter: mock_limiter.check_websocket_limit = AsyncMock(return_value=False) register_handlers(sio, mock_manager) handler = sio.handlers["/"]["request_defensive_replacement"] await handler( "test_sid", { "game_id": str(uuid4()), "player_out_lineup_id": 1, "player_in_card_id": 999, "new_position": "CF", "team_id": 1, }, ) mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "RATE_LIMITED" in call_args[0][2]["code"] @pytest.mark.asyncio async def test_defensive_replacement_game_rate_limited( self, mock_manager, mock_game_state ): """ Verify defensive_replacement returns error when substitution limit exceeded. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \ patch("app.websocket.handlers.state_manager") as mock_state_mgr: mock_limiter.check_websocket_limit = AsyncMock(return_value=True) mock_limiter.check_game_limit = AsyncMock(return_value=False) mock_state_mgr.get_state.return_value = mock_game_state register_handlers(sio, mock_manager) handler = sio.handlers["/"]["request_defensive_replacement"] await handler( "test_sid", { "game_id": str(mock_game_state.game_id), "player_out_lineup_id": 1, "player_in_card_id": 999, "new_position": "CF", "team_id": 1, }, ) mock_manager.emit_to_user.assert_called() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "GAME_RATE_LIMITED" in call_args[0][2]["code"] @pytest.mark.asyncio async def test_pitching_change_connection_rate_limited(self, mock_manager): """ Verify pitching_change returns error when connection rate limited. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter: mock_limiter.check_websocket_limit = AsyncMock(return_value=False) register_handlers(sio, mock_manager) handler = sio.handlers["/"]["request_pitching_change"] await handler( "test_sid", { "game_id": str(uuid4()), "player_out_lineup_id": 1, "player_in_card_id": 999, "team_id": 1, }, ) mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "RATE_LIMITED" in call_args[0][2]["code"] @pytest.mark.asyncio async def test_pitching_change_game_rate_limited( self, mock_manager, mock_game_state ): """ Verify pitching_change returns error when substitution limit exceeded. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \ patch("app.websocket.handlers.state_manager") as mock_state_mgr: mock_limiter.check_websocket_limit = AsyncMock(return_value=True) mock_limiter.check_game_limit = AsyncMock(return_value=False) mock_state_mgr.get_state.return_value = mock_game_state register_handlers(sio, mock_manager) handler = sio.handlers["/"]["request_pitching_change"] await handler( "test_sid", { "game_id": str(mock_game_state.game_id), "player_out_lineup_id": 1, "player_in_card_id": 999, "team_id": 1, }, ) mock_manager.emit_to_user.assert_called() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "GAME_RATE_LIMITED" in call_args[0][2]["code"] # ============================================================================ # RATE LIMITER CLEANUP TESTS # ============================================================================ class TestRateLimiterCleanup: """ Tests for rate limiter cleanup during disconnect. When a connection closes, its rate limiter buckets should be cleaned up to prevent memory leaks. """ @pytest.mark.asyncio async def test_disconnect_removes_rate_limiter_bucket(self, mock_manager): """ Verify disconnect removes connection's rate limiter bucket. When a client disconnects, the rate limiter should clean up the bucket for that connection to prevent memory leaks. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter: mock_limiter.remove_connection = MagicMock() register_handlers(sio, mock_manager) handler = sio.handlers["/"]["disconnect"] await handler("test_sid") # Verify rate limiter cleanup called mock_limiter.remove_connection.assert_called_once_with("test_sid") # Verify manager disconnect also called mock_manager.disconnect.assert_called_once_with("test_sid") @pytest.mark.asyncio async def test_disconnect_cleanup_happens_before_manager(self, mock_manager): """ Verify rate limiter cleanup happens during disconnect. The rate limiter bucket removal should happen regardless of other disconnect processing. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() cleanup_order = [] def track_limiter_cleanup(sid): cleanup_order.append(("limiter", sid)) async def track_manager_disconnect(sid): cleanup_order.append(("manager", sid)) with patch("app.websocket.handlers.rate_limiter") as mock_limiter: mock_limiter.remove_connection = track_limiter_cleanup mock_manager.disconnect = track_manager_disconnect register_handlers(sio, mock_manager) handler = sio.handlers["/"]["disconnect"] await handler("test_sid") # Both cleanup steps should have happened assert ("limiter", "test_sid") in cleanup_order assert ("manager", "test_sid") in cleanup_order # ============================================================================ # RATE LIMITING ALLOWS WHEN UNDER LIMIT # ============================================================================ class TestRateLimitingAllows: """ Tests verifying that rate limiting allows requests when under limit. These tests ensure the rate limiter doesn't accidentally block legitimate requests. """ @pytest.mark.asyncio async def test_join_game_allowed_when_under_limit(self, mock_manager): """ Verify join_game proceeds normally when rate limit not exceeded. The handler should process the request and emit game_joined when the connection is under its rate limit. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter: mock_limiter.check_websocket_limit = AsyncMock(return_value=True) register_handlers(sio, mock_manager) handler = sio.handlers["/"]["join_game"] game_id = str(uuid4()) await handler("test_sid", {"game_id": game_id}) # Should proceed with join mock_manager.join_game.assert_called_once() mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "game_joined" @pytest.mark.asyncio async def test_roll_dice_allowed_when_under_limits( self, mock_manager, mock_game_state, mock_ab_roll ): """ Verify roll_dice proceeds when both connection and game limits allow. Both rate limit checks must pass for the handler to process the roll. """ from socketio import AsyncServer from app.websocket.handlers import register_handlers sio = AsyncServer() @asynccontextmanager async def mock_lock(game_id, timeout=30.0): yield with patch("app.websocket.handlers.rate_limiter") as mock_limiter, \ patch("app.websocket.handlers.state_manager") as mock_state_mgr, \ patch("app.websocket.handlers.dice_system") as mock_dice: mock_limiter.check_websocket_limit = AsyncMock(return_value=True) mock_limiter.check_game_limit = AsyncMock(return_value=True) mock_state_mgr.get_state.return_value = mock_game_state mock_state_mgr.game_lock = mock_lock mock_state_mgr.update_state = MagicMock() mock_dice.roll_ab.return_value = mock_ab_roll register_handlers(sio, mock_manager) handler = sio.handlers["/"]["roll_dice"] await handler("test_sid", {"game_id": str(mock_game_state.game_id)}) # Should process the roll and broadcast mock_dice.roll_ab.assert_called_once() mock_manager.broadcast_to_game.assert_called_once() call_args = mock_manager.broadcast_to_game.call_args assert call_args[0][1] == "dice_rolled"