strat-gameplay-webapp/backend/tests/unit/websocket/test_rate_limiting.py
Cal Corum 4253b71db9 CLAUDE: Enhance WebSocket handlers with comprehensive test coverage
WebSocket Infrastructure:
- Connection manager: Improved connection/disconnection handling
- Handlers: Enhanced event handlers for game operations

Test Coverage (148 new tests):
- test_connection_handlers.py: Connection lifecycle tests
- test_connection_manager.py: Manager operations tests
- test_handler_locking.py: Concurrency/locking tests
- test_query_handlers.py: Game query handler tests
- test_rate_limiting.py: Rate limit enforcement tests
- test_substitution_handlers.py: Player substitution tests
- test_manual_outcome_handlers.py: Manual outcome workflow tests
- conftest.py: Shared WebSocket test fixtures

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 12:08:43 -06:00

711 lines
26 KiB
Python

"""
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"