From bf9515dd396ca62449a103b93bc0b16aca792faf Mon Sep 17 00:00:00 2001 From: Untone Date: Tue, 23 Sep 2025 20:49:25 +0300 Subject: [PATCH] oauth+tests --- CHANGELOG.md | 2 + auth/oauth.py | 22 ++- tests/auth/test_oauth.py | 169 +++++++++++++++--- tests/auth/test_oauth_functional.py | 257 ++++++++++++++++++++++++++++ 4 files changed, 423 insertions(+), 27 deletions(-) create mode 100644 tests/auth/test_oauth_functional.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3861b26b..ea5e0656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - 🔧 **OAuth Provider Registration**: Исправлена логика регистрации OAuth провайдеров - теперь корректно проверяются непустые client_id и client_secret - 🔍 **OAuth Debugging**: Добавлено отладочное логирование для диагностики проблем с OAuth провайдерами - 🚫 **OAuth Error**: Исправлена ошибка "Provider not configured" при пустых переменных окружения OAuth +- 🔐 **OAuth Session-Free**: Убрана зависимость от SessionMiddleware - OAuth использует только Redis для состояния +- 🏷️ **Type Safety**: Исправлена MyPy ошибка с request.client.host - добавлена проверка на None - 🔒 **OAuth Facebook**: Обновлена версия API с v13.0 до v18.0 (актуальная) - 🔒 **OAuth Facebook**: Добавлены обязательные scope и параметры безопасности - 🔒 **OAuth Facebook**: Улучшена обработка ошибок API и валидация ответов diff --git a/auth/oauth.py b/auth/oauth.py index 15b892f2..ab411de4 100644 --- a/auth/oauth.py +++ b/auth/oauth.py @@ -548,13 +548,15 @@ async def oauth_login_http(request: Request) -> JSONResponse | RedirectResponse: # URL для callback callback_uri = f"{FRONTEND_URL}oauth/{provider}/callback" - return await client.authorize_redirect( - request, + # 🔍 Создаем redirect URL вручную (обходим использование request.session в authlib) + authorization_url = await client.create_authorization_url( callback_uri, code_challenge=code_challenge, code_challenge_method="S256", state=state, ) + + return RedirectResponse(url=authorization_url["url"], status_code=302) except Exception as e: logger.error(f"OAuth login error: {e}") @@ -582,7 +584,21 @@ async def oauth_callback_http(request: Request) -> JSONResponse | RedirectRespon if not client: return JSONResponse({"error": "Provider not configured"}, status_code=400) - token = await client.authorize_access_token(request) + # 🔍 Получаем code_verifier из Redis вместо request.session + code_verifier = oauth_data.get("code_verifier") + if not code_verifier: + return JSONResponse({"error": "Missing code verifier in OAuth state"}, status_code=400) + + # Получаем authorization code из query параметров + code = request.query_params.get("code") + if not code: + return JSONResponse({"error": "Missing authorization code"}, status_code=400) + + # Обмениваем code на токен вручную + token = await client.fetch_access_token( + authorization_response=str(request.url), + code_verifier=code_verifier, + ) if not token: return JSONResponse({"error": "Failed to get access token"}, status_code=400) diff --git a/tests/auth/test_oauth.py b/tests/auth/test_oauth.py index 09693121..7697ab7a 100644 --- a/tests/auth/test_oauth.py +++ b/tests/auth/test_oauth.py @@ -1,4 +1,4 @@ -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch, ANY import time import pytest @@ -117,10 +117,29 @@ with ( @pytest.mark.asyncio async def test_oauth_login_success(mock_request, mock_oauth_client): - """Тест успешного начала OAuth авторизации""" - # pytest.skip("OAuth тест временно отключен из-за проблем с Redis") - # TODO: Implement test logic - assert True # Placeholder assertion + """Тест успешного начала OAuth авторизации без SessionMiddleware""" + mock_request.path_params = {"provider": "google"} + + # Мокаем OAuth клиент + with patch("auth.oauth.oauth.create_client", return_value=mock_oauth_client): + # Мокаем create_authorization_url как async функцию + mock_oauth_client.create_authorization_url = AsyncMock(return_value={ + "url": "https://accounts.google.com/oauth/authorize?client_id=test&state=test_state" + }) + + # Мокаем Redis операции + with patch("auth.oauth.store_oauth_state") as mock_store: + response = await oauth_login_http(mock_request) + + # Проверяем что это редирект + assert isinstance(response, RedirectResponse) + assert response.status_code == 302 + + # Проверяем что состояние сохранено в Redis + mock_store.assert_called_once() + + # Проверяем что create_authorization_url вызван с правильными параметрами + mock_oauth_client.create_authorization_url.assert_called_once() @pytest.mark.asyncio async def test_oauth_login_invalid_provider(mock_request): @@ -138,28 +157,71 @@ with ( @pytest.mark.asyncio async def test_oauth_callback_success(mock_request, mock_oauth_client, oauth_db_session): - """Тест успешного OAuth callback с правильной БД""" - # Простой тест без сложных моков - проверяем только импорт и базовую функциональность - from auth.oauth import oauth_callback_http + """Тест успешного OAuth callback без SessionMiddleware""" + # Настраиваем mock request + mock_request.query_params = { + "state": "test_state", + "code": "test_code" + } + mock_request.url = "https://localhost:3000/oauth/google/callback?state=test_state&code=test_code" + mock_request.headers = {"user-agent": "test-agent"} + mock_request.client = MagicMock() + mock_request.client.host = "127.0.0.1" - # Проверяем, что функция импортируется - assert oauth_callback_http is not None - assert callable(oauth_callback_http) + # Мокаем OAuth данные из Redis + oauth_data = { + "provider": "google", + "code_verifier": "test_verifier", + "redirect_uri": "https://localhost:3000" + } - # Проверяем, что фикстуры работают - assert mock_request is not None - assert mock_oauth_client is not None - assert oauth_db_session is not None + # Мокаем токен от провайдера + mock_token = { + "access_token": "test_token", + "userinfo": { + "sub": "123", + "email": "test@gmail.com", + "name": "Test User", + "picture": "https://example.com/photo.jpg" + } + } - # Простая проверка - функция существует и может быть вызвана - # В реальном тесте здесь можно было бы замокать все зависимости - logger.info("✅ OAuth callback функция импортирована и готова к тестированию") + with patch("auth.oauth.get_oauth_state", return_value=oauth_data), \ + patch("auth.oauth.oauth.create_client", return_value=mock_oauth_client), \ + patch("auth.oauth.get_user_profile", return_value={ + "id": "123", + "email": "test@gmail.com", + "name": "Test User", + "picture": "https://example.com/photo.jpg" + }), \ + patch("auth.oauth._create_or_update_user") as mock_create_user, \ + patch("auth.tokens.storage.TokenStorage.create_session", return_value="test_session_token"): + + # Настраиваем мок пользователя + mock_user = MagicMock() + mock_user.id = 123 + mock_user.name = "Test User" + mock_create_user.return_value = mock_user + + # Настраиваем мок OAuth клиента + mock_oauth_client.fetch_access_token.return_value = mock_token + + response = await oauth_callback_http(mock_request) + + # Проверяем что это редирект + assert isinstance(response, RedirectResponse) + assert response.status_code == 307 + + # Проверяем что токен получен + mock_oauth_client.fetch_access_token.assert_called_once() + + # Проверяем что пользователь создан/обновлен + mock_create_user.assert_called_once() @pytest.mark.asyncio async def test_oauth_callback_invalid_state(mock_request): - """Тест с неправильным state параметром""" - mock_request.session = {"provider": "google", "state": "correct_state"} - mock_request.query_params["state"] = "wrong_state" + """Тест с неправильным state параметром (session-free)""" + mock_request.query_params = {"state": "wrong_state"} with patch("auth.oauth.get_oauth_state", return_value=None): response = await oauth_callback_http(mock_request) @@ -170,6 +232,20 @@ with ( if isinstance(body_content, memoryview): body_content = bytes(body_content) assert "Invalid or expired OAuth state" in body_content.decode() + + @pytest.mark.asyncio + async def test_oauth_callback_missing_state(mock_request): + """Тест без state параметра""" + mock_request.query_params = {} + + response = await oauth_callback_http(mock_request) + + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + body_content = response.body + if isinstance(body_content, memoryview): + body_content = bytes(body_content) + assert "Missing OAuth state parameter" in body_content.decode() @pytest.mark.asyncio async def test_oauth_callback_existing_user(mock_request, mock_oauth_client, oauth_db_session): @@ -186,9 +262,54 @@ with ( assert mock_oauth_client is not None assert oauth_db_session is not None - # Простая проверка - функция существует и может быть вызвана - # В реальном тесте здесь можно было бы замокать все зависимости - logger.info("✅ OAuth callback existing user функция импортирована и готова к тестированию") + # Тест Redis операций для OAuth состояния + from auth.oauth import store_oauth_state, get_oauth_state + + # Проверяем что функции импортируются + assert store_oauth_state is not None + assert get_oauth_state is not None + + logger.info("✅ OAuth Redis функции импортированы и готовы к тестированию") + + @pytest.mark.asyncio + async def test_oauth_redis_state_operations(): + """Тест Redis операций для OAuth состояния""" + from auth.oauth import store_oauth_state, get_oauth_state + + # Тестовые данные + test_state = "test_state_123" + test_data = { + "provider": "google", + "code_verifier": "test_verifier", + "redirect_uri": "https://localhost:3000", + "created_at": 1234567890 + } + + # Мокаем Redis операции + with patch("auth.oauth.redis") as mock_redis: + # Тест сохранения состояния + await store_oauth_state(test_state, test_data) + mock_redis.execute.assert_called_with( + "SETEX", + f"oauth_state:{test_state}", + 600, # TTL + ANY # orjson.dumps(test_data) + ) + + # Тест получения состояния + import orjson + mock_redis.execute.side_effect = [ + orjson.dumps(test_data), # GET + None # DEL + ] + + result = await get_oauth_state(test_state) + + # Проверяем что данные корректно десериализованы + assert result == test_data + + # Проверяем что вызваны правильные Redis команды + assert mock_redis.execute.call_count == 3 # SETEX + GET + DEL # Импортируем необходимые модели from orm.community import Community, CommunityAuthor diff --git a/tests/auth/test_oauth_functional.py b/tests/auth/test_oauth_functional.py new file mode 100644 index 00000000..4b227833 --- /dev/null +++ b/tests/auth/test_oauth_functional.py @@ -0,0 +1,257 @@ +""" +Функциональные тесты OAuth без SessionMiddleware +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch, ANY, call +from starlette.requests import Request +from starlette.responses import RedirectResponse, JSONResponse + +from auth.oauth import oauth_login_http, oauth_callback_http + + +class TestOAuthFunctional: + """Функциональные тесты OAuth системы""" + + @pytest.mark.asyncio + async def test_oauth_provider_not_configured_error(self): + """Тест ошибки когда провайдер не настроен""" + + # Создаем мок запроса + request = MagicMock(spec=Request) + request.path_params = {"provider": "google"} + + # Мокаем что провайдер не найден + with patch("auth.oauth.oauth.create_client", return_value=None): + response = await oauth_login_http(request) + + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + + # Проверяем содержимое ответа + body = response.body + if isinstance(body, memoryview): + body = bytes(body) + assert b"Provider not configured" in body + + @pytest.mark.asyncio + async def test_oauth_login_success_session_free(self): + """Тест успешного OAuth login без использования сессий""" + + # Создаем мок запроса + request = MagicMock(spec=Request) + request.path_params = {"provider": "google"} + + # Мокаем OAuth клиент + mock_client = AsyncMock() + mock_client.create_authorization_url = AsyncMock(return_value={ + "url": "https://accounts.google.com/oauth/authorize?client_id=test&state=abc123" + }) + + with patch("auth.oauth.oauth.create_client", return_value=mock_client), \ + patch("auth.oauth.store_oauth_state") as mock_store: + + response = await oauth_login_http(request) + + # Проверяем что это редирект + assert isinstance(response, RedirectResponse) + assert response.status_code == 302 + + # Проверяем что состояние сохранено в Redis + mock_store.assert_called_once() + + # Проверяем что URL создан правильно + mock_client.create_authorization_url.assert_called_once() + + @pytest.mark.asyncio + async def test_oauth_callback_missing_state(self): + """Тест callback без state параметра""" + + request = MagicMock(spec=Request) + request.query_params = {} # Нет state + + response = await oauth_callback_http(request) + + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + + body = response.body + if isinstance(body, memoryview): + body = bytes(body) + assert b"Missing OAuth state parameter" in body + + @pytest.mark.asyncio + async def test_oauth_callback_invalid_state(self): + """Тест callback с неправильным state""" + + request = MagicMock(spec=Request) + request.query_params = {"state": "invalid_state"} + + # Мокаем что состояние не найдено в Redis + with patch("auth.oauth.get_oauth_state", return_value=None): + response = await oauth_callback_http(request) + + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + + body = response.body + if isinstance(body, memoryview): + body = bytes(body) + assert b"Invalid or expired OAuth state" in body + + @pytest.mark.asyncio + async def test_oauth_callback_success_session_free(self): + """Тест успешного OAuth callback без сессий""" + + # Настраиваем мок запроса + request = MagicMock(spec=Request) + request.query_params = { + "state": "valid_state", + "code": "auth_code_123" + } + request.url = "https://localhost:3000/oauth/google/callback?state=valid_state&code=auth_code_123" + request.headers = {"user-agent": "test-browser"} + request.client = MagicMock() + request.client.host = "127.0.0.1" + + # Мокаем OAuth данные из Redis + oauth_data = { + "provider": "google", + "code_verifier": "test_verifier_123", + "redirect_uri": "https://localhost:3000" + } + + # Мокаем токен от провайдера + mock_token = { + "access_token": "access_token_123", + "userinfo": { + "sub": "google_user_123", + "email": "test@gmail.com", + "name": "Test User", + "picture": "https://example.com/photo.jpg" + } + } + + # Мокаем профиль пользователя + user_profile = { + "id": "google_user_123", + "email": "test@gmail.com", + "name": "Test User", + "picture": "https://example.com/photo.jpg" + } + + # Мокаем пользователя + mock_user = MagicMock() + mock_user.id = 123 + mock_user.name = "Test User" + + with patch("auth.oauth.get_oauth_state", return_value=oauth_data), \ + patch("auth.oauth.oauth.create_client") as mock_create_client, \ + patch("auth.oauth.get_user_profile", return_value=user_profile), \ + patch("auth.oauth._create_or_update_user", return_value=mock_user), \ + patch("auth.tokens.storage.TokenStorage.create_session", return_value="session_token_123"): + + # Настраиваем мок OAuth клиента + mock_client = AsyncMock() + mock_client.fetch_access_token = AsyncMock(return_value=mock_token) + mock_create_client.return_value = mock_client + + response = await oauth_callback_http(request) + + # Проверяем что это редирект + assert isinstance(response, RedirectResponse) + assert response.status_code == 307 + + # Проверяем URL редиректа + assert response.headers["location"] == "https://localhost:3000" + + # Проверяем что токен получен без использования request.session + mock_client.fetch_access_token.assert_called_once_with( + authorization_response=str(request.url), + code_verifier="test_verifier_123" + ) + + @pytest.mark.asyncio + async def test_oauth_redis_state_lifecycle(self): + """Тест жизненного цикла OAuth состояния в Redis""" + from auth.oauth import store_oauth_state, get_oauth_state + + test_state = "state_lifecycle_test" + test_data = { + "provider": "github", + "code_verifier": "verifier_123", + "redirect_uri": "https://localhost:3000", + "created_at": 1234567890 + } + + # Мокаем Redis операции + with patch("auth.oauth.redis") as mock_redis: + # Тест сохранения + await store_oauth_state(test_state, test_data) + + # Проверяем что SETEX вызван с правильными параметрами + mock_redis.execute.assert_called_with( + "SETEX", + f"oauth_state:{test_state}", + 600, # TTL 10 минут + ANY # Сериализованные данные + ) + + # Тест получения и удаления (one-time use) + import orjson + mock_redis.execute.side_effect = [ + orjson.dumps(test_data), # GET возвращает данные + None # DEL подтверждение + ] + + result = await get_oauth_state(test_state) + + # Проверяем что данные корректно получены + assert result == test_data + + # Проверяем что GET и DEL были вызваны + assert mock_redis.execute.call_count == 3 # SETEX + GET + DEL + + # Проверяем что последние вызовы были GET и DEL + calls = mock_redis.execute.call_args_list + assert calls[-2][0][0] == "GET" # Предпоследний вызов - GET + assert calls[-1][0][0] == "DEL" # Последний вызов - DEL + + @pytest.mark.asyncio + async def test_oauth_error_handling_comprehensive(self): + """Комплексный тест обработки ошибок OAuth""" + + # Тест 1: Неправильный провайдер + request = MagicMock(spec=Request) + request.path_params = {"provider": "invalid_provider"} + + response = await oauth_login_http(request) + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + + # Тест 2: Провайдер не настроен + request.path_params = {"provider": "google"} + with patch("auth.oauth.oauth.create_client", return_value=None): + response = await oauth_login_http(request) + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + + # Тест 3: Callback без code (но с правильным OAuth клиентом) + request.query_params = {"state": "valid_state"} + oauth_data = {"provider": "google", "code_verifier": "test"} + + mock_client = AsyncMock() + with patch("auth.oauth.get_oauth_state", return_value=oauth_data), \ + patch("auth.oauth.oauth.create_client", return_value=mock_client): + response = await oauth_callback_http(request) + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + + body = response.body + if isinstance(body, memoryview): + body = bytes(body) + assert b"Missing authorization code" in body + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])