📚 Documentation Updates
All checks were successful
Deploy on push / deploy (push) Successful in 5m47s

- **🔍 Comprehensive authentication documentation refactoring**: Полная переработка документации аутентификации
  - Обновлена таблица содержания в README.md
  - Исправлены архитектурные диаграммы - токены хранятся только в Redis
  - Добавлены практические примеры кода для микросервисов
  - Консолидирована OAuth документация
This commit is contained in:
2025-09-22 00:56:36 +03:00
parent 4dccb84b18
commit a4411e3c86
22 changed files with 4401 additions and 2454 deletions

845
docs/auth/testing.md Normal file
View File

@@ -0,0 +1,845 @@
# 🧪 Тестирование системы аутентификации
## 🎯 Обзор
Комплексная стратегия тестирования системы аутентификации с 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: 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