Files
core/docs/auth/testing.md
Untone 14ff155789
All checks were successful
Deploy on push / deploy (push) Successful in 3m19s
config-fix
2025-09-30 21:48:29 +03:00

846 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🧪 Тестирование системы аутентификации
## 🎯 Обзор
Комплексная стратегия тестирования системы аутентификации с unit, integration и E2E тестами.
## 🏗️ Структура тестов
```
tests/auth/
├── unit/
│ ├── test_session_manager.py
│ ├── test_oauth_manager.py
│ ├── test_batch_operations.py
│ ├── test_monitoring.py
│ └── test_utils.py
├── integration/
│ ├── test_redis_integration.py
│ ├── test_oauth_flow.py
│ ├── test_middleware.py
│ └── test_decorators.py
├── e2e/
│ ├── test_login_flow.py
│ ├── test_oauth_flow.py
│ └── test_session_management.py
└── fixtures/
├── auth_fixtures.py
├── redis_fixtures.py
└── oauth_fixtures.py
```
## 🔧 Unit Tests
### SessionTokenManager Tests
```python
import pytest
from unittest.mock import AsyncMock, patch
from auth.tokens.sessions import SessionTokenManager
class TestSessionTokenManager:
@pytest.fixture
def session_manager(self):
return SessionTokenManager()
@pytest.mark.asyncio
async def test_create_session(self, session_manager):
"""Тест создания сессии"""
with patch('auth.tokens.sessions.redis') as mock_redis:
mock_redis.hset = AsyncMock()
mock_redis.sadd = AsyncMock()
mock_redis.expire = AsyncMock()
token = await session_manager.create_session(
user_id="123",
username="testuser"
)
assert token is not None
assert len(token) > 20
mock_redis.hset.assert_called()
mock_redis.sadd.assert_called()
@pytest.mark.asyncio
async def test_verify_session_valid(self, session_manager):
"""Тест проверки валидной сессии"""
with patch('auth.jwtcodec.decode_jwt') as mock_decode:
mock_decode.return_value = {
"user_id": "123",
"username": "testuser",
"exp": int(time.time()) + 3600
}
with patch('auth.tokens.sessions.redis') as mock_redis:
mock_redis.exists.return_value = True
payload = await session_manager.verify_session("valid_token")
assert payload is not None
assert payload["user_id"] == "123"
assert payload["username"] == "testuser"
@pytest.mark.asyncio
async def test_verify_session_invalid(self, session_manager):
"""Тест проверки невалидной сессии"""
with patch('auth.jwtcodec.decode_jwt') as mock_decode:
mock_decode.return_value = None
payload = await session_manager.verify_session("invalid_token")
assert payload is None
@pytest.mark.asyncio
async def test_revoke_session_token(self, session_manager):
"""Тест отзыва токена сессии"""
with patch('auth.tokens.sessions.redis') as mock_redis:
mock_redis.delete = AsyncMock(return_value=1)
mock_redis.srem = AsyncMock()
result = await session_manager.revoke_session_token("test_token")
assert result is True
mock_redis.delete.assert_called()
mock_redis.srem.assert_called()
@pytest.mark.asyncio
async def test_get_user_sessions(self, session_manager):
"""Тест получения сессий пользователя"""
with patch('auth.tokens.sessions.redis') as mock_redis:
mock_redis.smembers.return_value = {b"token1", b"token2"}
mock_redis.hgetall.return_value = {
b"user_id": b"123",
b"username": b"testuser",
b"last_activity": b"1640995200"
}
sessions = await session_manager.get_user_sessions("123")
assert len(sessions) == 2
assert sessions[0]["token"] == "token1"
assert sessions[0]["user_id"] == "123"
```
### OAuthTokenManager Tests
```python
import pytest
from unittest.mock import AsyncMock, patch
from auth.tokens.oauth import OAuthTokenManager
class TestOAuthTokenManager:
@pytest.fixture
def oauth_manager(self):
return OAuthTokenManager()
@pytest.mark.asyncio
async def test_store_oauth_tokens(self, oauth_manager):
"""Тест сохранения OAuth токенов"""
with patch('auth.tokens.oauth.redis') as mock_redis:
mock_redis.setex = AsyncMock()
await oauth_manager.store_oauth_tokens(
user_id="123",
provider="google",
access_token="access_token_123",
refresh_token="refresh_token_123",
expires_in=3600
)
# Проверяем, что токены сохранены
assert mock_redis.setex.call_count == 2 # access + refresh
@pytest.mark.asyncio
async def test_get_token(self, oauth_manager):
"""Тест получения OAuth токена"""
with patch('auth.tokens.oauth.redis') as mock_redis:
mock_redis.get.return_value = b'{"token": "access_token_123", "expires_in": 3600}'
token_data = await oauth_manager.get_token("123", "google", "oauth_access")
assert token_data is not None
assert token_data["token"] == "access_token_123"
assert token_data["expires_in"] == 3600
@pytest.mark.asyncio
async def test_revoke_oauth_tokens(self, oauth_manager):
"""Тест отзыва OAuth токенов"""
with patch('auth.tokens.oauth.redis') as mock_redis:
mock_redis.delete = AsyncMock(return_value=2)
result = await oauth_manager.revoke_oauth_tokens("123", "google")
assert result is True
mock_redis.delete.assert_called()
```
### BatchTokenOperations Tests
```python
import pytest
from unittest.mock import AsyncMock, patch
from auth.tokens.batch import BatchTokenOperations
class TestBatchTokenOperations:
@pytest.fixture
def batch_operations(self):
return BatchTokenOperations()
@pytest.mark.asyncio
async def test_batch_validate_tokens(self, batch_operations):
"""Тест массовой валидации токенов"""
tokens = ["token1", "token2", "token3"]
with patch.object(batch_operations, '_validate_token_batch') as mock_validate:
mock_validate.return_value = {
"token1": True,
"token2": False,
"token3": True
}
results = await batch_operations.batch_validate_tokens(tokens)
assert results["token1"] is True
assert results["token2"] is False
assert results["token3"] is True
@pytest.mark.asyncio
async def test_batch_revoke_tokens(self, batch_operations):
"""Тест массового отзыва токенов"""
tokens = ["token1", "token2", "token3"]
with patch.object(batch_operations, '_revoke_token_batch') as mock_revoke:
mock_revoke.return_value = 2 # 2 токена отозваны
revoked_count = await batch_operations.batch_revoke_tokens(tokens)
assert revoked_count == 2
@pytest.mark.asyncio
async def test_cleanup_expired_tokens(self, batch_operations):
"""Тест очистки истекших токенов"""
with patch('auth.tokens.batch.redis') as mock_redis:
# Мокаем поиск истекших токенов
mock_redis.scan_iter.return_value = [
"session:123:expired_token1",
"session:456:expired_token2"
]
mock_redis.ttl.return_value = -1 # Истекший токен
mock_redis.delete = AsyncMock(return_value=1)
cleaned_count = await batch_operations.cleanup_expired_tokens()
assert cleaned_count >= 0
```
## 🔗 Integration Tests
### Redis Integration Tests
```python
import pytest
import asyncio
from storage.redis import redis
from auth.tokens.sessions import SessionTokenManager
class TestRedisIntegration:
@pytest.mark.asyncio
async def test_redis_connection(self):
"""Тест подключения к Redis"""
result = await redis.ping()
assert result is True
@pytest.mark.asyncio
async def test_session_lifecycle(self):
"""Тест полного жизненного цикла сессии"""
sessions = SessionTokenManager()
# Создаем сессию
token = await sessions.create_session(
user_id="test_user",
username="testuser"
)
assert token is not None
# Проверяем сессию
payload = await sessions.verify_session(token)
assert payload is not None
assert payload["user_id"] == "test_user"
# Получаем сессии пользователя
user_sessions = await sessions.get_user_sessions("test_user")
assert len(user_sessions) >= 1
# Отзываем сессию
revoked = await sessions.revoke_session_token(token)
assert revoked is True
# Проверяем, что сессия отозвана
payload = await sessions.verify_session(token)
assert payload is None
@pytest.mark.asyncio
async def test_concurrent_sessions(self):
"""Тест множественных сессий"""
sessions = SessionTokenManager()
# Создаем несколько сессий одновременно
tasks = []
for i in range(5):
task = sessions.create_session(
user_id="concurrent_user",
username=f"user_{i}"
)
tasks.append(task)
tokens = await asyncio.gather(*tasks)
# Проверяем, что все токены созданы
assert len(tokens) == 5
assert all(token is not None for token in tokens)
# Проверяем, что все сессии валидны
for token in tokens:
payload = await sessions.verify_session(token)
assert payload is not None
# Очищаем тестовые данные
for token in tokens:
await sessions.revoke_session_token(token)
```
### OAuth Flow Integration Tests
```python
import pytest
from unittest.mock import AsyncMock, patch
from auth.oauth import oauth_login_http, oauth_callback_http
class TestOAuthIntegration:
@pytest.mark.asyncio
async def test_oauth_state_flow(self):
"""Тест OAuth state flow"""
from auth.oauth import store_oauth_state, get_oauth_state
# Сохраняем state
state = "test_state_123"
redirect_uri = "http://localhost:3000"
await store_oauth_state(state, redirect_uri)
# Получаем state
stored_data = await get_oauth_state(state)
assert stored_data is not None
assert stored_data["redirect_uri"] == redirect_uri
# Проверяем, что state удален после использования
stored_data_again = await get_oauth_state(state)
assert stored_data_again is None
@pytest.mark.asyncio
async def test_oauth_login_redirect(self):
"""Тест OAuth login redirect"""
mock_request = AsyncMock()
mock_request.query_params = {
"provider": "google",
"state": "test_state",
"redirect_uri": "http://localhost:3000"
}
with patch('auth.oauth.store_oauth_state') as mock_store:
with patch('auth.oauth.generate_provider_url') as mock_generate:
mock_generate.return_value = "https://accounts.google.com/oauth/authorize?..."
response = await oauth_login_http(mock_request)
assert response.status_code == 307 # Redirect
mock_store.assert_called_once()
@pytest.mark.asyncio
async def test_oauth_callback_success(self):
"""Тест успешного OAuth callback"""
mock_request = AsyncMock()
mock_request.query_params = {
"code": "auth_code_123",
"state": "test_state"
}
with patch('auth.oauth.get_oauth_state') as mock_get_state:
mock_get_state.return_value = {
"redirect_uri": "http://localhost:3000"
}
with patch('auth.oauth.exchange_code_for_user_data') as mock_exchange:
mock_exchange.return_value = {
"id": "123",
"email": "test@example.com",
"name": "Test User"
}
with patch('auth.oauth._create_or_update_user') as mock_create_user:
mock_create_user.return_value = AsyncMock(id=123)
response = await oauth_callback_http(mock_request)
assert response.status_code == 307 # Redirect
assert "access_token=" in response.headers["location"]
```
## 🌐 E2E Tests
### Login Flow E2E Tests
```python
import pytest
from httpx import AsyncClient
from main import app
class TestLoginFlowE2E:
@pytest.mark.asyncio
async def test_complete_login_flow(self):
"""Тест полного flow входа в систему"""
async with AsyncClient(app=app, base_url="http://test") as client:
# 1. Регистрация пользователя
register_response = await client.post("/auth/register", json={
"email": "test@example.com",
"password": "TestPassword123!",
"name": "Test User"
})
assert register_response.status_code == 200
# 2. Вход в систему
login_response = await client.post("/auth/login", json={
"email": "test@example.com",
"password": "TestPassword123!"
})
assert login_response.status_code == 200
data = login_response.json()
assert data["success"] is True
assert "token" in data
# Проверяем установку cookie
cookies = login_response.cookies
assert "session_token" in cookies
# 3. Проверка защищенного endpoint с cookie
session_response = await client.get("/auth/session", cookies={
"session_token": cookies["session_token"]
})
assert session_response.status_code == 200
session_data = session_response.json()
assert session_data["user"]["email"] == "test@example.com"
# 4. Выход из системы
logout_response = await client.post("/auth/logout", cookies={
"session_token": cookies["session_token"]
})
assert logout_response.status_code == 200
# 5. Проверка, что сессия недоступна после выхода
invalid_session_response = await client.get("/auth/session", cookies={
"session_token": cookies["session_token"]
})
assert invalid_session_response.status_code == 401
@pytest.mark.asyncio
async def test_bearer_token_auth(self):
"""Тест аутентификации через Bearer token"""
async with AsyncClient(app=app, base_url="http://test") as client:
# Вход в систему
login_response = await client.post("/auth/login", json={
"email": "test@example.com",
"password": "TestPassword123!"
})
token = login_response.json()["token"]
# Использование Bearer token
protected_response = await client.get("/auth/session", headers={
"Authorization": f"Bearer {token}"
})
assert protected_response.status_code == 200
data = protected_response.json()
assert data["user"]["email"] == "test@example.com"
@pytest.mark.asyncio
async def test_invalid_credentials(self):
"""Тест входа с неверными данными"""
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.post("/auth/login", json={
"email": "test@example.com",
"password": "WrongPassword"
})
assert response.status_code == 401
data = response.json()
assert data["success"] is False
assert "error" in data
```
### OAuth E2E Tests
```python
import pytest
from unittest.mock import patch
from httpx import AsyncClient
from main import app
class TestOAuthFlowE2E:
@pytest.mark.asyncio
async def test_oauth_google_flow(self):
"""Тест OAuth flow с Google"""
async with AsyncClient(app=app, base_url="http://test") as client:
# 1. Инициация OAuth
oauth_response = await client.get(
"/auth/oauth/google",
params={
"state": "test_state_123",
"redirect_uri": "http://localhost:3000"
},
follow_redirects=False
)
assert oauth_response.status_code == 307
assert "accounts.google.com" in oauth_response.headers["location"]
# 2. Мокаем OAuth callback
with patch('auth.oauth.exchange_code_for_user_data') as mock_exchange:
mock_exchange.return_value = {
"id": "google_user_123",
"email": "user@gmail.com",
"name": "Google User"
}
callback_response = await client.get(
"/auth/oauth/google/callback",
params={
"code": "auth_code_123",
"state": "test_state_123"
},
follow_redirects=False
)
assert callback_response.status_code == 307
location = callback_response.headers["location"]
assert "access_token=" in location
# Извлекаем токен из redirect URL
import urllib.parse
parsed = urllib.parse.urlparse(location)
query_params = urllib.parse.parse_qs(parsed.query)
access_token = query_params["access_token"][0]
# 3. Проверяем, что токен работает
session_response = await client.get("/auth/session", headers={
"Authorization": f"Bearer {access_token}"
})
assert session_response.status_code == 200
data = session_response.json()
assert data["user"]["email"] == "user@gmail.com"
```
## 🧰 Test Fixtures
### Auth Fixtures
```python
import pytest
import asyncio
from auth.tokens.sessions import SessionTokenManager
from auth.tokens.oauth import OAuthTokenManager
@pytest.fixture
async def session_manager():
"""Фикстура SessionTokenManager"""
return SessionTokenManager()
@pytest.fixture
async def oauth_manager():
"""Фикстура OAuthTokenManager"""
return OAuthTokenManager()
@pytest.fixture
async def test_user_token(session_manager):
"""Фикстура для создания тестового токена"""
token = await session_manager.create_session(
user_id="test_user_123",
username="testuser"
)
yield token
# Cleanup
await session_manager.revoke_session_token(token)
@pytest.fixture
async def authenticated_client():
"""Фикстура для аутентифицированного клиента"""
from httpx import AsyncClient
from main import app
async with AsyncClient(app=app, base_url="http://test") as client:
# Создаем пользователя и получаем токен
login_response = await client.post("/auth/login", json={
"email": "test@example.com",
"password": "TestPassword123!"
})
token = login_response.json()["token"]
# Настраиваем клиент с токеном
client.headers.update({"Authorization": f"Bearer {token}"})
yield client
@pytest.fixture
async def oauth_tokens(oauth_manager):
"""Фикстура для OAuth токенов"""
await oauth_manager.store_oauth_tokens(
user_id="test_user_123",
provider="google",
access_token="test_access_token",
refresh_token="test_refresh_token",
expires_in=3600
)
yield {
"user_id": "test_user_123",
"provider": "google",
"access_token": "test_access_token",
"refresh_token": "test_refresh_token"
}
# Cleanup
await oauth_manager.revoke_oauth_tokens("test_user_123", "google")
```
### Redis Fixtures
```python
import pytest
from storage.redis import redis
@pytest.fixture(scope="session")
async def redis_client():
"""Фикстура Redis клиента"""
yield redis
# Cleanup после всех тестов
await redis.flushdb()
@pytest.fixture
async def clean_redis():
"""Фикстура для очистки Redis перед тестом"""
# Очищаем тестовые ключи
test_keys = await redis.keys("test:*")
if test_keys:
await redis.delete(*test_keys)
yield
# Очищаем после теста
test_keys = await redis.keys("test:*")
if test_keys:
await redis.delete(*test_keys)
```
## 📊 Test Configuration
### pytest.ini
```ini
[tool:pytest]
asyncio_mode = auto
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--tb=short
--strict-markers
--disable-warnings
--cov=auth
--cov-report=html
--cov-report=term-missing
--cov-fail-under=80
markers =
unit: Unit tests
integration: Integration tests
e2e: End-to-end tests
slow: Slow tests
redis: Tests requiring Redis
oauth: OAuth related tests
```
### conftest.py
```python
import pytest
import asyncio
from unittest.mock import AsyncMock
from httpx import AsyncClient
from main import app
# Настройка asyncio для тестов
@pytest.fixture(scope="session")
def event_loop():
"""Создает event loop для всей сессии тестов"""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
# Мок Redis для unit тестов
@pytest.fixture
def mock_redis():
"""Мок Redis клиента"""
mock = AsyncMock()
mock.ping.return_value = True
mock.get.return_value = None
mock.set.return_value = True
mock.delete.return_value = 1
mock.exists.return_value = False
mock.ttl.return_value = -1
mock.hset.return_value = 1
mock.hgetall.return_value = {}
mock.sadd.return_value = 1
mock.smembers.return_value = set()
mock.srem.return_value = 1
mock.expire.return_value = True
mock.setex.return_value = True
return mock
# Test client
@pytest.fixture
async def test_client():
"""Тестовый HTTP клиент"""
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
```
## 🚀 Running Tests
### Команды запуска
```bash
# Все тесты
pytest
# Unit тесты
pytest tests/auth/unit/ -m unit
# Integration тесты
pytest tests/auth/integration/ -m integration
# E2E тесты
pytest tests/auth/e2e/ -m e2e
# Тесты с покрытием
pytest --cov=auth --cov-report=html
# Параллельный запуск
pytest -n auto
# Только быстрые тесты
pytest -m "not slow"
# Конкретный тест
pytest tests/auth/unit/test_session_manager.py::TestSessionTokenManager::test_create_session
```
### CI/CD Integration
```yaml
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
redis:
image: redis:6.2
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Install dependencies
run: |
pip install -r requirements.dev.txt
- name: Run unit tests
run: |
pytest tests/auth/unit/ -m unit --cov=auth
- name: Run integration tests
run: |
pytest tests/auth/integration/ -m integration
env:
REDIS_URL: redis://localhost:6379/0
- name: Run E2E tests
run: |
pytest tests/auth/e2e/ -m e2e
env:
REDIS_URL: redis://localhost:6379/0
JWT_SECRET_KEY: test_secret_key_for_ci
- name: Upload coverage
uses: codecov/codecov-action@v3
```
## 📈 Test Metrics
### Coverage Goals
- **Unit Tests**: ≥ 90% coverage
- **Integration Tests**: ≥ 80% coverage
- **E2E Tests**: Critical paths covered
- **Overall**: ≥ 85% coverage
### Performance Benchmarks
- **Unit Tests**: < 100ms per test
- **Integration Tests**: < 1s per test
- **E2E Tests**: < 10s per test
- **Total Test Suite**: < 5 minutes
### Quality Metrics
- **Test Reliability**: 99% pass rate
- **Flaky Tests**: < 1% of total tests
- **Test Maintenance**: Regular updates with code changes