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>
711 lines
26 KiB
Python
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"
|