diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cd93b28..e5924ec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,15 @@ - Исправлена загрузка данных из Redis в `load_views_from_redis` - Добавлен fallback механизм с созданием тестовых данных о просмотрах - Исправлена проблема когда всегда возвращался 0 для счетчика просмотров +- **Исправлена проблема с логином пользователей**: Устранена ошибка RBAC при аутентификации + - Добавлена обработка ошибок RBAC в `services/auth.py` при проверке ролей пользователя + - Исправлена логика входа для системных администраторов из `ADMIN_EMAILS` + - Добавлен fallback механизм входа для админов при недоступности системы ролей + - Использован современный синтаксис `list | tuple` вместо устаревшего `(list, tuple)` в `isinstance()` +- **Улучшено логирование авторизации**: Убраны избыточные трейсбеки для обычных случаев + - Заменены `logger.error` на `logger.warning` для стандартных проверок авторизации + - Убраны трейсбеки из логов при обычных ошибках входа и обновления токенов + - Исправлены дублирующие slug в тестовых фикстурах, вызывавшие UNIQUE constraint ошибки ### 🔧 Техническое diff --git a/auth/decorators.py b/auth/decorators.py index d123e232..023f69a0 100644 --- a/auth/decorators.py +++ b/auth/decorators.py @@ -34,7 +34,7 @@ async def validate_graphql_context(info: GraphQLResolveInfo) -> None: # Проверка базовой структуры контекста if info is None or not hasattr(info, "context"): - logger.error("[validate_graphql_context] Missing GraphQL context information") + logger.warning("[validate_graphql_context] Missing GraphQL context information") msg = "Internal server error: missing context" raise GraphQLError(msg) @@ -127,11 +127,11 @@ async def validate_graphql_context(info: GraphQLResolveInfo) -> None: f"[validate_graphql_context] Токен успешно проверен и установлен для пользователя {auth_state.author_id}" ) else: - logger.error("[validate_graphql_context] Не удалось установить auth: отсутствует request.scope") + logger.warning("[validate_graphql_context] Не удалось установить auth: отсутствует request.scope") msg = "Internal server error: unable to set authentication context" raise GraphQLError(msg) except exc.NoResultFound: - logger.error(f"[validate_graphql_context] Пользователь с ID {auth_state.author_id} не найден в базе данных") + logger.warning(f"[validate_graphql_context] Пользователь с ID {auth_state.author_id} не найден в базе данных") msg = "UnauthorizedError - user not found" raise GraphQLError(msg) from None @@ -165,7 +165,7 @@ def admin_auth_required(resolver: Callable) -> Callable: # Проверяем авторизацию пользователя if info is None: - logger.error("[admin_auth_required] GraphQL info is None") + logger.warning("[admin_auth_required] GraphQL info is None") msg = "Invalid GraphQL context" raise GraphQLError(msg) @@ -199,10 +199,10 @@ def admin_auth_required(resolver: Callable) -> Callable: auth = info.context["request"].auth logger.debug(f"[admin_auth_required] Auth из request: {auth.author_id if auth else None}") else: - logger.error("[admin_auth_required] Auth не найден ни в scope, ни в request") + logger.warning("[admin_auth_required] Auth не найден ни в scope, ни в request") if not auth or not getattr(auth, "logged_in", False): - logger.error("[admin_auth_required] Пользователь не авторизован после validate_graphql_context") + logger.warning("[admin_auth_required] Пользователь не авторизован после validate_graphql_context") msg = "UnauthorizedError - please login" raise GraphQLError(msg) @@ -212,7 +212,7 @@ def admin_auth_required(resolver: Callable) -> Callable: # Преобразуем author_id в int для совместимости с базой данных author_id = int(auth.author_id) if auth and auth.author_id else None if not author_id: - logger.error(f"[admin_auth_required] ID автора не определен: {auth}") + logger.warning(f"[admin_auth_required] ID автора не определен: {auth}") msg = "UnauthorizedError - invalid user ID" raise GraphQLError(msg) @@ -230,7 +230,7 @@ def admin_auth_required(resolver: Callable) -> Callable: raise GraphQLError(msg) except exc.NoResultFound: - logger.error(f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных") + logger.warning(f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных") msg = "UnauthorizedError - user not found" raise GraphQLError(msg) from None except GraphQLError: @@ -317,7 +317,7 @@ def permission_required(resource: str, operation: str, func: Callable) -> Callab ) return await func(parent, info, *args, **kwargs) except exc.NoResultFound: - logger.error(f"[permission_required] Пользователь с ID {auth.author_id} не найден в базе данных") + logger.warning(f"[permission_required] Пользователь с ID {auth.author_id} не найден в базе данных") msg = "User not found" raise OperationNotAllowedError(msg) from None diff --git a/auth/middleware.py b/auth/middleware.py index 2cbacf04..b23c7939 100644 --- a/auth/middleware.py +++ b/auth/middleware.py @@ -174,12 +174,12 @@ class AuthMiddleware: token=None, ), UnauthenticatedUser() except Exception as e: - logger.error(f"[auth.authenticate] Ошибка при работе с базой данных: {e}") + logger.warning(f"[auth.authenticate] Ошибка при работе с базой данных: {e}") return AuthCredentials( author_id=None, scopes={}, logged_in=False, error_message=str(e), email=None, token=None ), UnauthenticatedUser() except Exception as e: - logger.error(f"[auth.authenticate] Ошибка при проверке сессии: {e}") + logger.warning(f"[auth.authenticate] Ошибка при проверке сессии: {e}") return AuthCredentials( author_id=None, scopes={}, logged_in=False, error_message=str(e), email=None, token=None ), UnauthenticatedUser() diff --git a/resolvers/auth.py b/resolvers/auth.py index f3b8948a..cc6bd1b6 100644 --- a/resolvers/auth.py +++ b/resolvers/auth.py @@ -103,7 +103,7 @@ async def login(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, A return result except Exception as e: - logger.error(f"Ошибка входа: {e}") + logger.warning(f"Ошибка входа: {e}") return {"success": False, "token": None, "author": None, "error": str(e)} @@ -135,7 +135,7 @@ async def logout(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, return result except Exception as e: - logger.error(f"Ошибка выхода: {e}") + logger.warning(f"Ошибка выхода: {e}") return {"success": False} @@ -184,7 +184,7 @@ async def refresh_token(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dic return result except Exception as e: - logger.error(f"Ошибка обновления токена: {e}") + logger.warning(f"Ошибка обновления токена: {e}") return {"success": False, "token": None, "author": None, "error": str(e)} @@ -275,7 +275,7 @@ async def get_session(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[ return {"success": False, "token": None, "author": None, "error": error_message} except Exception as e: - logger.error(f"Ошибка получения сессии: {e}") + logger.warning(f"Ошибка получения сессии: {e}") return {"success": False, "token": None, "author": None, "error": str(e)} diff --git a/services/auth.py b/services/auth.py index 56196eb3..abef5b0a 100644 --- a/services/auth.py +++ b/services/auth.py @@ -362,13 +362,31 @@ class AuthService: if not author: logger.warning(f"Пользователь {email} не найден") return {"success": False, "token": None, "author": None, "error": "Пользователь не найден"} - user_roles = get_user_roles_in_community(int(author.id), community_id=1) - has_reader_role = "reader" in user_roles + + # 🩵 Проверяем права с обработкой ошибок RBAC + is_admin_email = author.email in ADMIN_EMAILS.split(",") + has_reader_role = False + + try: + user_roles = get_user_roles_in_community(int(author.id), community_id=1) + has_reader_role = "reader" in user_roles + logger.debug(f"Роли пользователя {email}: {user_roles}") + except Exception as rbac_error: + logger.warning(f"🧿 RBAC ошибка для {email}: {rbac_error}") + # Если RBAC не работает, разрешаем вход только админам + if not is_admin_email: + logger.warning(f"RBAC недоступен и {email} не админ - запрещаем вход") + return { + "success": False, + "token": None, + "author": None, + "error": "Система ролей временно недоступна. Попробуйте позже.", + } + logger.info(f"🔒 RBAC недоступен, но {email} - админ, разрешаем вход") - logger.debug(f"Роли пользователя {email}: {user_roles}") - - if not has_reader_role and author.email not in ADMIN_EMAILS.split(","): - logger.warning(f"У пользователя {email} нет роли 'reader'. Текущие роли: {user_roles}") + # Проверяем права: админы или пользователи с ролью reader + if not has_reader_role and not is_admin_email: + logger.warning(f"У пользователя {email} нет роли 'reader' и он не админ") return { "success": False, "token": None, diff --git a/tests/auth/test_auth_service.py b/tests/auth/test_auth_service.py index 1db11649..c5989f61 100644 --- a/tests/auth/test_auth_service.py +++ b/tests/auth/test_auth_service.py @@ -15,8 +15,8 @@ async def test_ensure_user_has_reader_role(db_session): if not community: community = Community( id=1, - name="Test Community", - slug="test-community", + name="Auth Service Test Community", + slug="auth-service-test-community", desc="Test community for auth tests", created_at=int(asyncio.get_event_loop().time()) ) diff --git a/tests/conftest.py b/tests/conftest.py index d6a80bed..455fd426 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -832,6 +832,89 @@ def backend_server(): backend_process.wait() +@pytest.fixture(scope="session") +def frontend_server(): + """ + 🚀 Фикстура для автоматического запуска/остановки фронтенд сервера. + Запускает фронтенд только если он не запущен. + """ + frontend_process: Optional[subprocess.Popen] = None + frontend_running = False + + # Проверяем, не запущен ли уже фронтенд + try: + response = requests.get("http://localhost:3000/", timeout=2) + if response.status_code == 200: + print("✅ Фронтенд сервер уже запущен") + frontend_running = True + else: + frontend_running = False + except: + frontend_running = False + + if not frontend_running: + print("🔄 Запускаем фронтенд сервер для тестов...") + try: + # Проверяем наличие node_modules + node_modules_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "node_modules") + if not os.path.exists(node_modules_path): + print("📦 Устанавливаем зависимости фронтенда...") + subprocess.run(["npm", "install"], check=True, + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + # Запускаем фронтенд сервер + env = os.environ.copy() + env["NODE_ENV"] = "development" + + frontend_process = subprocess.Popen( + ["npm", "run", "dev"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=env, + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + + # Ждем запуска фронтенда + print("⏳ Ждем запуска фронтенда...") + for i in range(60): # Ждем максимум 60 секунд + try: + response = requests.get("http://localhost:3000/", timeout=2) + if response.status_code == 200: + print("✅ Фронтенд сервер запущен") + frontend_running = True + break + except: + pass + time.sleep(1) + else: + print("❌ Фронтенд сервер не запустился за 60 секунд") + if frontend_process: + frontend_process.terminate() + frontend_process.wait() + # Не падаем жестко, а возвращаем False + frontend_running = False + + except Exception as e: + print(f"❌ Ошибка запуска фронтенда: {e}") + if frontend_process: + frontend_process.terminate() + frontend_process.wait() + # Не падаем жестко, а возвращаем False + frontend_running = False + + yield frontend_running + + # Cleanup: останавливаем фронтенд только если мы его запускали + if frontend_process: + print("🛑 Останавливаем фронтенд сервер...") + try: + frontend_process.terminate() + frontend_process.wait(timeout=10) + except subprocess.TimeoutExpired: + frontend_process.kill() + frontend_process.wait() + + @pytest.fixture def test_client(backend_server): """ @@ -1124,8 +1207,8 @@ def test_community(db_session, test_users): # Создаем сообщество с ID 2, так как ID 1 уже занят основным сообществом community = Community( id=2, # Используем ID 2, чтобы не конфликтовать с основным сообществом - name="Test Community", - slug="test-community", + name="Test Community Fixture", + slug="test-community-fixture", # Уникальный slug для этой фикстуры desc="A test community for testing purposes", created_by=test_users[0].id, # Администратор создает сообщество settings={ diff --git a/tests/test_auth_fixes.py b/tests/test_auth_fixes.py index 8533f1a1..4a2b7e32 100644 --- a/tests/test_auth_fixes.py +++ b/tests/test_auth_fixes.py @@ -26,8 +26,8 @@ def test_community(db_session, test_users): """Создает тестовое сообщество""" community = Community( id=100, - name="Test Community", - slug="test-community", + name="Auth Test Community", + slug="auth-test-community", # Уникальный slug для auth тестов desc="Test community for auth tests", created_by=test_users[0].id, created_at=int(time.time()) diff --git a/tests/test_rbac_system.py b/tests/test_rbac_system.py index c2180786..e86d792e 100644 --- a/tests/test_rbac_system.py +++ b/tests/test_rbac_system.py @@ -35,8 +35,8 @@ def test_community(db_session, test_users): if not community: community = Community( id=1, - name="Test Community", - slug="test-community", + name="RBAC Test Community", + slug="rbac-test-community", desc="Test community for RBAC tests", created_by=test_users[0].id, created_at=int(time.time()) diff --git a/tests/test_server_health.py b/tests/test_server_health.py index 8acf61c1..728a4b94 100644 --- a/tests/test_server_health.py +++ b/tests/test_server_health.py @@ -8,75 +8,47 @@ import requests import pytest -@pytest.mark.skip_ci -def test_backend_health(): +def test_backend_health(backend_server): """Проверяем здоровье бэкенда""" - max_retries = 10 - for attempt in range(1, max_retries + 1): - try: - response = requests.get("http://localhost:8000/", timeout=10) - if response.status_code == 200: - print(f"✅ Бэкенд готов (попытка {attempt})") - return - except requests.exceptions.RequestException as e: - print(f"⚠️ Попытка {attempt}/{max_retries}: Бэкенд не готов - {e}") - if attempt < max_retries: - time.sleep(3) - else: - pytest.fail(f"Бэкенд не готов после {max_retries} попыток") + assert backend_server, "Бэкенд сервер должен быть запущен" + + response = requests.get("http://localhost:8000/", timeout=10) + assert response.status_code == 200, f"Бэкенд вернул статус {response.status_code}" + print("✅ Бэкенд здоров") -@pytest.mark.skip_ci -def test_frontend_health(): +def test_frontend_health(frontend_server): """Проверяем здоровье фронтенда""" - max_retries = 10 - for attempt in range(1, max_retries + 1): - try: - response = requests.get("http://localhost:3000/", timeout=10) - if response.status_code == 200: - print(f"✅ Фронтенд готов (попытка {attempt})") - return - except requests.exceptions.RequestException as e: - print(f"⚠️ Попытка {attempt}/{max_retries}: Фронтенд не готов - {e}") - if attempt < max_retries: - time.sleep(3) - else: - # В CI фронтенд может быть не запущен, поэтому не падаем - pytest.skip("Фронтенд не запущен (ожидаемо в некоторых CI средах)") + if not frontend_server: + pytest.skip("Фронтенд сервер не удалось запустить (возможно отсутствует npm или зависимости)") + + response = requests.get("http://localhost:3000/", timeout=10) + assert response.status_code == 200, f"Фронтенд вернул статус {response.status_code}" + print("✅ Фронтенд здоров") -@pytest.mark.skip_ci -def test_graphql_endpoint(): +def test_graphql_endpoint(backend_server): """Проверяем доступность GraphQL endpoint""" - try: - response = requests.post( - "http://localhost:8000/graphql", - headers={"Content-Type": "application/json"}, - json={"query": "{ __schema { types { name } } }"}, - timeout=15 - ) - if response.status_code == 200: - print("✅ GraphQL endpoint доступен") - return - else: - pytest.fail(f"GraphQL endpoint вернул статус {response.status_code}") - except requests.exceptions.RequestException as e: - pytest.fail(f"GraphQL endpoint недоступен: {e}") + assert backend_server, "Бэкенд сервер должен быть запущен" + + response = requests.post( + "http://localhost:8000/graphql", + headers={"Content-Type": "application/json"}, + json={"query": "{ __schema { types { name } } }"}, + timeout=15 + ) + assert response.status_code == 200, f"GraphQL endpoint вернул статус {response.status_code}" + print("✅ GraphQL endpoint доступен") -@pytest.mark.skip_ci -def test_admin_panel_access(): +def test_admin_panel_access(frontend_server): """Проверяем доступность админ-панели""" - try: - response = requests.get("http://localhost:3000/admin", timeout=15) - if response.status_code == 200: - print("✅ Админ-панель доступна") - return - else: - pytest.fail(f"Админ-панель вернула статус {response.status_code}") - except requests.exceptions.RequestException as e: - # В CI фронтенд может быть не запущен, поэтому не падаем - pytest.skip("Админ-панель недоступна (фронтенд не запущен)") + if not frontend_server: + pytest.skip("Фронтенд сервер не запущен - админ-панель недоступна") + + response = requests.get("http://localhost:3000/admin", timeout=15) + assert response.status_code == 200, f"Админ-панель вернула статус {response.status_code}" + print("✅ Админ-панель доступна") if __name__ == "__main__":