feat(chat): Socket.IO 双端通道、消息持久化与幂等
访客与客服分别鉴权接入,消息带客户端 ID 去重,重发只产生一条业务记录。
This commit is contained in:
1 parent
0cc6299d1c
commit
ef9fee1585
23 files changed
+7057
No files matched your search
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import redis
|
||||
import asyncio
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
from app.services.socket_rate_limit import socket_rate_limit
|
||||
from app.core.config import settings
|
||||
from app.core.socketio import sio
|
||||
|
||||
# Test constants
|
||||
TEST_SID = "test_session_id"
|
||||
TEST_NAMESPACE = "/widget"
|
||||
TEST_IP = "192.168.1.1"
|
||||
TEST_AGENT_ID = "test_agent_123"
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
with patch('app.services.socket_rate_limit.redis_client') as mock_client:
|
||||
# Setup Redis mock to work with asyncio.wait_for
|
||||
mock_client.get = MagicMock()
|
||||
mock_client.setex = MagicMock()
|
||||
mock_client.incr = MagicMock()
|
||||
mock_client.ttl = MagicMock()
|
||||
yield mock_client
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sio():
|
||||
with patch('app.services.socket_rate_limit.sio') as mock_sio:
|
||||
# Setup session data
|
||||
mock_sio.get_session = AsyncMock(return_value={
|
||||
'enable_rate_limiting': True,
|
||||
'overall_limit_per_ip': 100,
|
||||
'requests_per_sec': 1.0,
|
||||
'agent_id': TEST_AGENT_ID
|
||||
})
|
||||
# Setup environment data with load balancer headers
|
||||
mock_sio.get_environ.return_value = {
|
||||
'REMOTE_ADDR': '10.0.0.1', # Load balancer IP
|
||||
'HTTP_X_FORWARDED_FOR': f'{TEST_IP}, 10.0.0.1' # Original client IP, load balancer IP
|
||||
}
|
||||
# Make emit async
|
||||
mock_sio.emit = AsyncMock()
|
||||
yield mock_sio
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_socket_rate_limit_disabled():
|
||||
"""Test when rate limiting is disabled"""
|
||||
# Create a mock handler function
|
||||
mock_handler = AsyncMock()
|
||||
|
||||
# Create a mock session without rate limiting
|
||||
mock_session = {
|
||||
'enable_rate_limiting': False
|
||||
}
|
||||
|
||||
# Mock sio.get_session
|
||||
with patch('app.services.socket_rate_limit.sio.get_session', AsyncMock(return_value=mock_session)):
|
||||
# Apply the decorator
|
||||
decorated_handler = socket_rate_limit()(mock_handler)
|
||||
|
||||
# Call the decorated handler
|
||||
await decorated_handler('test_sid', 'test_arg')
|
||||
|
||||
# Verify the handler was called
|
||||
mock_handler.assert_called_once_with('test_sid', 'test_arg')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_socket_rate_limit_redis_disabled():
|
||||
"""Test when Redis is disabled"""
|
||||
# Create a mock handler function
|
||||
mock_handler = AsyncMock()
|
||||
|
||||
# Create a mock session with rate limiting
|
||||
mock_session = {
|
||||
'enable_rate_limiting': True,
|
||||
'overall_limit_per_ip': 100,
|
||||
'requests_per_sec': 1.0,
|
||||
'agent_id': TEST_AGENT_ID
|
||||
}
|
||||
|
||||
with patch('app.services.socket_rate_limit.settings.REDIS_ENABLED', False), \
|
||||
patch('app.services.socket_rate_limit.sio.get_session', AsyncMock(return_value=mock_session)):
|
||||
# Apply the decorator
|
||||
decorated_handler = socket_rate_limit()(mock_handler)
|
||||
|
||||
# Call the decorated handler
|
||||
await decorated_handler('test_sid', 'test_arg')
|
||||
|
||||
# Verify the handler was called
|
||||
mock_handler.assert_called_once_with('test_sid', 'test_arg')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_socket_rate_limit_with_forwarded_ip(mock_redis):
|
||||
"""Test rate limiting with X-Forwarded-For header"""
|
||||
# Create a mock handler function
|
||||
mock_handler = AsyncMock()
|
||||
|
||||
# Create a mock session with rate limiting
|
||||
mock_session = {
|
||||
'enable_rate_limiting': True,
|
||||
'overall_limit_per_ip': 100,
|
||||
'requests_per_sec': 1.0,
|
||||
'agent_id': TEST_AGENT_ID
|
||||
}
|
||||
|
||||
# Mock environment with X-Forwarded-For
|
||||
mock_environ = {
|
||||
'HTTP_X_FORWARDED_FOR': '192.168.1.1, 10.0.0.1'
|
||||
}
|
||||
|
||||
# Configure Redis mock
|
||||
mock_redis.get.return_value = None # No previous requests
|
||||
mock_redis.setex.return_value = True
|
||||
mock_redis.incr.return_value = 1 # First request
|
||||
mock_redis.expire.return_value = True
|
||||
|
||||
# Mock asyncio.wait_for to return Redis results directly
|
||||
async def mock_wait_for(coro, timeout):
|
||||
if isinstance(coro, asyncio.Future):
|
||||
return await coro
|
||||
return coro
|
||||
|
||||
with patch('app.services.socket_rate_limit.settings.REDIS_ENABLED', True), \
|
||||
patch('app.services.socket_rate_limit.sio.get_session', AsyncMock(return_value=mock_session)), \
|
||||
patch('app.services.socket_rate_limit.sio.get_environ', return_value=mock_environ), \
|
||||
patch('asyncio.wait_for', side_effect=mock_wait_for):
|
||||
# Apply the decorator
|
||||
decorated_handler = socket_rate_limit()(mock_handler)
|
||||
|
||||
# Call the decorated handler
|
||||
await decorated_handler('test_sid', 'test_arg')
|
||||
|
||||
# Verify the handler was called
|
||||
mock_handler.assert_called_once_with('test_sid', 'test_arg')
|
||||
|
||||
# Verify Redis interactions
|
||||
assert mock_redis.get.call_count >= 2 # Called for both overall and rate limits
|
||||
assert mock_redis.setex.call_count >= 2 # Called for both overall and rate limits
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_socket_rate_limit_exceeded(mock_redis):
|
||||
"""Test when rate limit is exceeded"""
|
||||
# Create a mock handler function
|
||||
mock_handler = AsyncMock()
|
||||
|
||||
# Create a mock session with rate limiting
|
||||
mock_session = {
|
||||
'enable_rate_limiting': True,
|
||||
'overall_limit_per_ip': 100,
|
||||
'requests_per_sec': 1.0,
|
||||
'agent_id': TEST_AGENT_ID
|
||||
}
|
||||
|
||||
# Mock environment
|
||||
mock_environ = {
|
||||
'REMOTE_ADDR': '192.168.1.1'
|
||||
}
|
||||
|
||||
# Configure Redis mock to simulate exceeded limit
|
||||
mock_redis.get.return_value = b'101' # Over the limit
|
||||
mock_redis.ttl.return_value = 3600 # 1 hour remaining
|
||||
|
||||
# Mock asyncio.wait_for to return Redis results directly
|
||||
async def mock_wait_for(coro, timeout):
|
||||
if isinstance(coro, asyncio.Future):
|
||||
return await coro
|
||||
return coro
|
||||
|
||||
# Mock sio.emit to capture error messages
|
||||
mock_emit = AsyncMock()
|
||||
|
||||
with patch('app.services.socket_rate_limit.settings.REDIS_ENABLED', True), \
|
||||
patch('app.services.socket_rate_limit.sio.get_session', AsyncMock(return_value=mock_session)), \
|
||||
patch('app.services.socket_rate_limit.sio.get_environ', return_value=mock_environ), \
|
||||
patch('app.services.socket_rate_limit.sio.emit', mock_emit), \
|
||||
patch('asyncio.wait_for', side_effect=mock_wait_for):
|
||||
# Apply the decorator
|
||||
decorated_handler = socket_rate_limit()(mock_handler)
|
||||
|
||||
# Call the decorated handler
|
||||
result = await decorated_handler('test_sid', 'test_arg')
|
||||
|
||||
# Verify the handler was not called
|
||||
mock_handler.assert_not_called()
|
||||
|
||||
# Verify error was emitted
|
||||
mock_emit.assert_called_once()
|
||||
args = mock_emit.call_args[0]
|
||||
assert args[0] == 'error'
|
||||
assert 'Daily request limit reached' in args[1]['error']
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_socket_rate_limit_with_real_ip(mock_redis):
|
||||
"""Test rate limiting with X-Real-IP header"""
|
||||
# Create a mock handler function
|
||||
mock_handler = AsyncMock()
|
||||
|
||||
# Create a mock session with rate limiting
|
||||
mock_session = {
|
||||
'enable_rate_limiting': True,
|
||||
'overall_limit_per_ip': 100,
|
||||
'requests_per_sec': 1.0,
|
||||
'agent_id': TEST_AGENT_ID
|
||||
}
|
||||
|
||||
# Mock environment with X-Real-IP
|
||||
mock_environ = {
|
||||
'HTTP_X_REAL_IP': '192.168.1.1'
|
||||
}
|
||||
|
||||
# Configure Redis mock
|
||||
mock_redis.get.return_value = None # No previous requests
|
||||
mock_redis.setex.return_value = True
|
||||
mock_redis.incr.return_value = 1 # First request
|
||||
mock_redis.expire.return_value = True
|
||||
|
||||
# Mock asyncio.wait_for to return Redis results directly
|
||||
async def mock_wait_for(coro, timeout):
|
||||
if isinstance(coro, asyncio.Future):
|
||||
return await coro
|
||||
return coro
|
||||
|
||||
with patch('app.services.socket_rate_limit.settings.REDIS_ENABLED', True), \
|
||||
patch('app.services.socket_rate_limit.sio.get_session', AsyncMock(return_value=mock_session)), \
|
||||
patch('app.services.socket_rate_limit.sio.get_environ', return_value=mock_environ), \
|
||||
patch('asyncio.wait_for', side_effect=mock_wait_for):
|
||||
# Apply the decorator
|
||||
decorated_handler = socket_rate_limit()(mock_handler)
|
||||
|
||||
# Call the decorated handler
|
||||
await decorated_handler('test_sid', 'test_arg')
|
||||
|
||||
# Verify the handler was called
|
||||
mock_handler.assert_called_once_with('test_sid', 'test_arg')
|
||||
|
||||
# Verify Redis interactions
|
||||
assert mock_redis.get.call_count >= 2 # Called for both overall and rate limits
|
||||
assert mock_redis.setex.call_count >= 2 # Called for both overall and rate limits
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_socket_rate_limit_redis_error(mock_redis):
|
||||
"""Test handling of Redis errors"""
|
||||
# Create a mock handler function
|
||||
mock_handler = AsyncMock()
|
||||
|
||||
# Create a mock session with rate limiting
|
||||
mock_session = {
|
||||
'enable_rate_limiting': True,
|
||||
'overall_limit_per_ip': 100,
|
||||
'requests_per_sec': 1.0,
|
||||
'agent_id': TEST_AGENT_ID
|
||||
}
|
||||
|
||||
# Mock environment
|
||||
mock_environ = {
|
||||
'REMOTE_ADDR': '192.168.1.1'
|
||||
}
|
||||
|
||||
# Configure Redis mock to raise an error
|
||||
mock_redis.get.side_effect = redis.RedisError("Connection error")
|
||||
|
||||
with patch('app.services.socket_rate_limit.settings.REDIS_ENABLED', True), \
|
||||
patch('app.services.socket_rate_limit.sio.get_session', AsyncMock(return_value=mock_session)), \
|
||||
patch('app.services.socket_rate_limit.sio.get_environ', return_value=mock_environ):
|
||||
# Apply the decorator
|
||||
decorated_handler = socket_rate_limit()(mock_handler)
|
||||
|
||||
# Call the decorated handler
|
||||
await decorated_handler('test_sid', 'test_arg')
|
||||
|
||||
# Verify the handler was called (should proceed on Redis error)
|
||||
mock_handler.assert_called_once_with('test_sid', 'test_arg')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_localhost_bypass(mock_redis):
|
||||
"""Test that localhost requests bypass rate limiting"""
|
||||
# Test different localhost variations and headers
|
||||
test_cases = [
|
||||
# (header_name, is_list, localhost_ip)
|
||||
('HTTP_X_FORWARDED_FOR', True, '127.0.0.1'),
|
||||
('HTTP_X_FORWARDED_FOR', True, 'localhost'),
|
||||
('HTTP_X_FORWARDED_FOR', True, '::1'),
|
||||
('HTTP_X_REAL_IP', False, '127.0.0.1'),
|
||||
('HTTP_X_REAL_IP', False, 'localhost'),
|
||||
('HTTP_CF_CONNECTING_IP', False, '127.0.0.1'),
|
||||
('REMOTE_ADDR', False, '127.0.0.1')
|
||||
]
|
||||
|
||||
mock_session = {
|
||||
'enable_rate_limiting': True,
|
||||
'overall_limit_per_ip': 100,
|
||||
'requests_per_sec': 1.0,
|
||||
'agent_id': TEST_AGENT_ID
|
||||
}
|
||||
|
||||
for header, is_list, ip in test_cases:
|
||||
# Setup environment data
|
||||
environ = {'REMOTE_ADDR': '10.0.0.1'} # Default remote addr
|
||||
if is_list:
|
||||
environ[header] = f"{ip}, 10.0.0.1" # Add load balancer IP
|
||||
else:
|
||||
environ[header] = ip
|
||||
|
||||
# Create and decorate handler
|
||||
mock_handler = AsyncMock()
|
||||
decorated_handler = socket_rate_limit()(mock_handler)
|
||||
|
||||
with patch('app.services.socket_rate_limit.settings.REDIS_ENABLED', True), \
|
||||
patch('app.services.socket_rate_limit.sio.get_session', AsyncMock(return_value=mock_session)), \
|
||||
patch('app.services.socket_rate_limit.sio.get_environ', return_value=environ):
|
||||
# Call handler
|
||||
await decorated_handler(TEST_SID)
|
||||
|
||||
# Verify handler was called without checking Redis
|
||||
mock_handler.assert_called_once_with(TEST_SID)
|
||||
mock_redis.get.assert_not_called()
|
||||
mock_redis.setex.assert_not_called()
|
||||
|
||||
# Reset mocks for next iteration
|
||||
mock_handler.reset_mock()
|
||||
mock_redis.get.reset_mock()
|
||||
mock_redis.setex.reset_mock()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_timeout(mock_redis):
|
||||
"""Test behavior when Redis operations timeout"""
|
||||
# Create a mock handler function
|
||||
mock_handler = AsyncMock()
|
||||
|
||||
# Create a mock session with rate limiting
|
||||
mock_session = {
|
||||
'enable_rate_limiting': True,
|
||||
'overall_limit_per_ip': 100,
|
||||
'requests_per_sec': 1.0,
|
||||
'agent_id': TEST_AGENT_ID
|
||||
}
|
||||
|
||||
# Mock environment
|
||||
mock_environ = {
|
||||
'REMOTE_ADDR': '192.168.1.1'
|
||||
}
|
||||
|
||||
# Configure Redis mock to timeout
|
||||
mock_redis.get.side_effect = asyncio.TimeoutError()
|
||||
|
||||
# Mock asyncio.wait_for to raise TimeoutError
|
||||
async def mock_wait_for(coro, timeout):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
with patch('app.services.socket_rate_limit.settings.REDIS_ENABLED', True), \
|
||||
patch('app.services.socket_rate_limit.sio.get_session', AsyncMock(return_value=mock_session)), \
|
||||
patch('app.services.socket_rate_limit.sio.get_environ', return_value=mock_environ), \
|
||||
patch('asyncio.wait_for', side_effect=mock_wait_for):
|
||||
# Apply the decorator
|
||||
decorated_handler = socket_rate_limit()(mock_handler)
|
||||
|
||||
# Call the decorated handler
|
||||
await decorated_handler('test_sid', 'test_arg')
|
||||
|
||||
# Verify handler was called despite timeout
|
||||
mock_handler.assert_called_once_with('test_sid', 'test_arg')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_per_second_exceeded(mock_redis):
|
||||
"""Test when rate limit per second is exceeded"""
|
||||
# Create a mock handler function
|
||||
mock_handler = AsyncMock()
|
||||
|
||||
# Create a mock session with rate limiting
|
||||
mock_session = {
|
||||
'enable_rate_limiting': True,
|
||||
'overall_limit_per_ip': 100,
|
||||
'requests_per_sec': 1.0,
|
||||
'agent_id': TEST_AGENT_ID
|
||||
}
|
||||
|
||||
# Mock environment
|
||||
mock_environ = {
|
||||
'REMOTE_ADDR': '192.168.1.1'
|
||||
}
|
||||
|
||||
# Configure Redis mock for rate limit check
|
||||
mock_redis.get.side_effect = [b'50', b'1'] # Under overall limit but over rate limit
|
||||
mock_redis.ttl.return_value = 1 # 1 second remaining
|
||||
|
||||
# Mock sio.emit to capture error messages
|
||||
mock_emit = AsyncMock()
|
||||
|
||||
# Mock asyncio.wait_for to return Redis results directly
|
||||
async def mock_wait_for(coro, timeout):
|
||||
if isinstance(coro, asyncio.Future):
|
||||
return await coro
|
||||
return coro
|
||||
|
||||
with patch('app.services.socket_rate_limit.settings.REDIS_ENABLED', True), \
|
||||
patch('app.services.socket_rate_limit.sio.get_session', AsyncMock(return_value=mock_session)), \
|
||||
patch('app.services.socket_rate_limit.sio.get_environ', return_value=mock_environ), \
|
||||
patch('app.services.socket_rate_limit.sio.emit', mock_emit), \
|
||||
patch('asyncio.wait_for', side_effect=mock_wait_for):
|
||||
# Apply the decorator
|
||||
decorated_handler = socket_rate_limit()(mock_handler)
|
||||
|
||||
# Call the decorated handler
|
||||
result = await decorated_handler('test_sid', 'test_arg')
|
||||
|
||||
# Verify the handler was not called
|
||||
mock_handler.assert_not_called()
|
||||
|
||||
# Verify error was emitted
|
||||
mock_emit.assert_called_once()
|
||||
args = mock_emit.call_args[0]
|
||||
assert args[0] == 'error'
|
||||
assert 'You\'re sending messages too quickly' in args[1]['error']
|
||||
assert result is None
|
||||
Reference in new issue
Block a user