[0.9.7] - 2025-08-18
Some checks failed
Deploy on push / deploy (push) Failing after 2m22s

### 🔄 Изменения
- **SQLAlchemy KeyError** - исправление ошибки `KeyError: Reaction` при инициализации
- **Исправлена ошибка SQLAlchemy**: Устранена проблема `InvalidRequestError: When initializing mapper Mapper[Shout(shout)], expression Reaction failed to locate a name (Reaction)`

### 🧪 Тестирование
- **Исправление тестов** - адаптация к новой структуре моделей
- **RBAC инициализация** - добавление `rbac.initialize_rbac()` в `conftest.py`
- **Создан тест для getSession**: Добавлен комплексный тест `test_getSession_cookies.py` с проверкой всех сценариев
- **Покрытие edge cases**: Тесты проверяют работу с валидными/невалидными токенами, отсутствующими пользователями
- **Мокирование зависимостей**: Использование unittest.mock для изоляции тестируемого кода

### 🔧 Рефакторинг
- **Упрощена архитектура**: Убраны сложные конструкции с отложенными импортами, заменены на чистую архитектуру
- **Перемещение моделей** - `Author` и связанные модели перенесены в `orm/author.py`: Вынесены базовые модели пользователей (`Author`, `AuthorFollower`, `AuthorBookmark`, `AuthorRating`) из `orm.author` в отдельный модуль
- **Устранены циклические импорты**: Разорван цикл между `auth.core` → `orm.community` → `orm.author` через реструктуризацию архитектуры
- **Создан модуль `utils/password.py`**: Класс `Password` вынесен в utils для избежания циклических зависимостей
- **Оптимизированы импорты моделей**: Убран прямой импорт `Shout` из `orm/community.py`, заменен на строковые ссылки

### 🔧 Авторизация с cookies
- **getSession теперь работает с cookies**: Мутация `getSession` теперь может получать токен из httpOnly cookies даже без заголовка Authorization
- **Убрано требование авторизации**: `getSession` больше не требует декоратор `@login_required`, работает автономно
- **Поддержка dual-авторизации**: Токен может быть получен как из заголовка Authorization, так и из cookie `session_token`
- **Автоматическая установка cookies**: Middleware автоматически устанавливает httpOnly cookies при успешном `getSession`
- **Обновлена GraphQL схема**: `SessionInfo` теперь содержит поля `success`, `error` и опциональные `token`, `author`
- **Единообразная обработка токенов**: Все модули теперь используют централизованные функции для работы с токенами
- **Улучшена обработка ошибок**: Добавлена детальная валидация токенов и пользователей в `getSession`
- **Логирование операций**: Добавлены подробные логи для отслеживания процесса авторизации

### 📝 Документация
- **Обновлена схема GraphQL**: `SessionInfo` тип теперь соответствует новому формату ответа
- Обновлена документация RBAC
- Обновлена документация авторизации с cookies
This commit is contained in:
2025-08-18 14:25:25 +03:00
parent 9a2b792f08
commit 1b48675b92
78 changed files with 1658 additions and 1050 deletions

View File

@@ -1,6 +1,6 @@
import pytest
from services.auth import AuthService
from auth.orm import Author
from orm.author import Author
@pytest.mark.asyncio
async def test_ensure_user_has_reader_role(db_session):

View File

@@ -1,5 +1,5 @@
import pytest
from auth.password import Password
from utils.password import Password
def test_password_verify():
# Создаем пароль

View File

@@ -6,7 +6,7 @@ import logging
from starlette.responses import JSONResponse, RedirectResponse
from auth.oauth import get_user_profile, oauth_callback_http, oauth_login_http
from auth.orm import Author
from orm.author import Author
from storage.db import local_session
# Настройка логгера
@@ -213,7 +213,7 @@ def oauth_db_session(db_session):
@pytest.fixture
def simple_user(oauth_db_session):
"""Фикстура для простого пользователя"""
from auth.orm import Author
from orm.author import Author
import time
# Создаем тестового пользователя

View File

@@ -62,13 +62,17 @@ def test_engine():
# Импортируем все модели, чтобы они были зарегистрированы
from orm.base import BaseModel as Base
from orm.community import Community, CommunityAuthor
from auth.orm import Author
from orm.author import Author
from orm.draft import Draft, DraftAuthor, DraftTopic
from orm.shout import Shout, ShoutAuthor, ShoutTopic, ShoutReactionsFollower
from orm.topic import Topic
from orm.reaction import Reaction
from orm.invite import Invite
from orm.notification import Notification
# Инициализируем RBAC систему
import rbac
rbac.initialize_rbac()
engine = create_engine(
"sqlite:///:memory:", echo=False, poolclass=StaticPool, connect_args={"check_same_thread": False}
@@ -121,7 +125,7 @@ def db_session(test_session_factory, test_engine):
# Создаем дефолтное сообщество для тестов
from orm.community import Community
from auth.orm import Author
from orm.author import Author
import time
# Создаем системного автора если его нет
@@ -178,7 +182,7 @@ def db_session_commit(test_session_factory):
# Создаем дефолтное сообщество для тестов
from orm.community import Community
from auth.orm import Author
from orm.author import Author
# Создаем системного автора если его нет
system_author = session.query(Author).where(Author.slug == "system").first()
@@ -429,7 +433,7 @@ def wait_for_server():
@pytest.fixture
def test_users(db_session):
"""Создает тестовых пользователей для тестов"""
from auth.orm import Author
from orm.author import Author
# Создаем первого пользователя (администратор)
admin_user = Author(

View File

@@ -9,7 +9,7 @@ import pytest
import time
from unittest.mock import patch, MagicMock
from auth.orm import Author
from orm.author import Author
from orm.community import Community, CommunityAuthor
from storage.db import local_session
@@ -291,7 +291,7 @@ class TestPermissionSystem:
def test_admin_permissions(self, db_session, admin_user_with_roles, test_community):
"""Тест разрешений администратора"""
from auth.permissions import ContextualPermissionCheck
from rbac.permissions import ContextualPermissionCheck
# Проверяем что администратор имеет все разрешения
permissions_to_check = [
@@ -314,7 +314,7 @@ class TestPermissionSystem:
def test_regular_user_permissions(self, db_session, regular_user_with_roles, test_community):
"""Тест разрешений обычного пользователя"""
from auth.permissions import ContextualPermissionCheck
from rbac.permissions import ContextualPermissionCheck
# Проверяем что обычный пользователь имеет роли reader и author
ca = CommunityAuthor.find_author_in_community(
@@ -331,7 +331,7 @@ class TestPermissionSystem:
def test_permission_without_community_author(self, db_session, test_users, test_community):
"""Тест разрешений для пользователя без CommunityAuthor"""
from auth.permissions import ContextualPermissionCheck
from rbac.permissions import ContextualPermissionCheck
# Проверяем разрешения для пользователя без ролей в сообществе
has_permission = ContextualPermissionCheck.check_permission(

View File

@@ -11,7 +11,7 @@ async def test_admin_permissions():
"""Проверяем, что у роли admin есть все необходимые права"""
# Загружаем дефолтные права
with Path("services/default_role_permissions.json").open() as f:
with Path("rbac/default_role_permissions.json").open() as f:
default_permissions = json.load(f)
# Получаем права роли admin

View File

@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
# Импортируем модули auth для покрытия
import auth.__init__
import auth.permissions
import rbac.permissions
import auth.decorators
import auth.oauth
import auth.state
@@ -17,7 +17,7 @@ import auth.jwtcodec
import auth.email
import auth.exceptions
import auth.validations
import auth.orm
import orm.author
import auth.credentials
import auth.handler
import auth.internal
@@ -39,18 +39,18 @@ class TestAuthInit:
class TestAuthPermissions:
"""Тесты для auth.permissions"""
"""Тесты для rbac.permissions"""
def test_permissions_import(self):
"""Тест импорта permissions"""
import auth.permissions
assert auth.permissions is not None
import rbac.permissions
assert rbac.permissions is not None
def test_permissions_functions_exist(self):
"""Тест существования функций permissions"""
import auth.permissions
import rbac.permissions
# Проверяем что модуль импортируется без ошибок
assert auth.permissions is not None
assert rbac.permissions is not None
class TestAuthDecorators:
@@ -189,16 +189,16 @@ class TestAuthValidations:
class TestAuthORM:
"""Тесты для auth.orm"""
"""Тесты для orm.author"""
def test_orm_import(self):
"""Тест импорта orm"""
from auth.orm import Author
from orm.author import Author
assert Author is not None
def test_orm_functions_exist(self):
"""Тест существования функций orm"""
from auth.orm import Author
from orm.author import Author
# Проверяем что модель Author существует
assert Author is not None
assert hasattr(Author, 'id')

View File

@@ -8,11 +8,10 @@ import pytest
import time
from unittest.mock import patch, MagicMock
from auth.orm import Author, AuthorBookmark, AuthorRating, AuthorFollower
from orm.author import Author, AuthorBookmark, AuthorRating, AuthorFollower
from auth.internal import verify_internal_auth
from auth.permissions import ContextualPermissionCheck
from rbac.permissions import ContextualPermissionCheck
from orm.community import Community, CommunityAuthor
from auth.permissions import ContextualPermissionCheck
from storage.db import local_session
@@ -69,7 +68,7 @@ class TestAuthORMFixes:
rating = AuthorRating(
rater=test_users[0].id,
author=test_users[1].id,
plus=True
rating=5 # Используем поле rating вместо plus
)
db_session.add(rating)
db_session.commit()
@@ -83,15 +82,15 @@ class TestAuthORMFixes:
assert saved_rating is not None
assert saved_rating.rater == test_users[0].id
assert saved_rating.author == test_users[1].id
assert saved_rating.plus is True
assert saved_rating.rating == 5 # Проверяем поле rating
def test_author_follower_creation(self, db_session, test_users):
"""Тест создания подписки автора"""
follower = AuthorFollower(
follower=test_users[0].id,
author=test_users[1].id,
created_at=int(time.time()),
auto=False
following=test_users[1].id, # Используем поле following вместо author
created_at=int(time.time())
# Убрано поле auto, которого нет в новой модели
)
db_session.add(follower)
db_session.commit()
@@ -99,13 +98,13 @@ class TestAuthORMFixes:
# Проверяем что подписка создана
saved_follower = db_session.query(AuthorFollower).where(
AuthorFollower.follower == test_users[0].id,
AuthorFollower.author == test_users[1].id
AuthorFollower.following == test_users[1].id # Используем поле following
).first()
assert saved_follower is not None
assert saved_follower.follower == test_users[0].id
assert saved_follower.author == test_users[1].id
assert saved_follower.auto is False
assert saved_follower.following == test_users[1].id # Проверяем поле following
# Убрана проверка поля auto
def test_author_oauth_methods(self, db_session, test_users):
"""Тест методов работы с OAuth"""
@@ -145,10 +144,6 @@ class TestAuthORMFixes:
"""Тест метода dict() для сериализации"""
user = test_users[0]
# Добавляем роли
user.roles_data = {"1": ["reader", "author"]}
db_session.commit()
# Получаем словарь
user_dict = user.dict()

View File

@@ -9,7 +9,7 @@ import pytest
import time
from sqlalchemy.orm import Session
from auth.orm import Author
from orm.author import Author
from orm.community import (
Community,
CommunityAuthor,

View File

@@ -8,7 +8,7 @@ import pytest
import time
from sqlalchemy import text
from orm.community import Community, CommunityAuthor, CommunityFollower
from auth.orm import Author
from orm.author import Author
class TestCommunityFunctionality:

View File

@@ -10,7 +10,7 @@ import time
import uuid
from unittest.mock import patch, MagicMock
from auth.orm import Author
from orm.author import Author
from orm.community import Community, CommunityAuthor
from rbac.api import (
initialize_community_permissions,

View File

@@ -12,7 +12,7 @@ from starlette.routing import Route
from starlette.testclient import TestClient
# Импортируем все модели чтобы SQLAlchemy знал о них
from auth.orm import ( # noqa: F401
from orm.author import ( # noqa: F401
Author,
AuthorBookmark,
AuthorFollower,

View File

@@ -61,7 +61,7 @@ import resolvers.admin
import auth
import auth.__init__
import auth.permissions
import rbac.permissions
import auth.decorators
import auth.oauth
import auth.state
@@ -71,7 +71,7 @@ import auth.jwtcodec
import auth.email
import auth.exceptions
import auth.validations
import auth.orm
import orm.author
import auth.credentials
import auth.handler
import auth.internal
@@ -147,7 +147,7 @@ class TestCoverageImports:
"""Тест импорта модулей auth"""
assert auth is not None
assert auth.__init__ is not None
assert auth.permissions is not None
assert rbac.permissions is not None
assert auth.decorators is not None
assert auth.oauth is not None
assert auth.state is not None
@@ -157,7 +157,7 @@ class TestCoverageImports:
assert auth.email is not None
assert auth.exceptions is not None
assert auth.validations is not None
assert auth.orm is not None
assert orm.author is not None
assert auth.credentials is not None
assert auth.handler is not None
assert auth.internal is not None

View File

@@ -60,7 +60,7 @@ class TestDatabaseFunctions:
# Проверяем, что сессия работает с существующими таблицами
# Используем Author вместо TestModel
from auth.orm import Author
from orm.author import Author
authors_count = session.query(Author).count()
assert isinstance(authors_count, int)

View File

@@ -1,6 +1,6 @@
import pytest
from auth.orm import Author
from orm.author import Author
from orm.community import CommunityAuthor
from orm.shout import Shout
from resolvers.draft import create_draft, load_drafts

View File

@@ -0,0 +1,276 @@
"""
Тест для проверки работы getSession с cookies
Проверяет:
1. getSession работает без токена в заголовке, но с валидным cookie
2. getSession возвращает данные пользователя при валидном cookie
3. getSession возвращает ошибку при невалидном cookie
4. getSession работает с токеном в заголовке
"""
import pytest
from unittest.mock import patch, MagicMock
from graphql import GraphQLResolveInfo
from resolvers.auth import get_session
from auth.tokens.storage import TokenStorage as TokenManager
from orm.author import Author
class MockRequest:
"""Мок для Request объекта"""
def __init__(self, headers=None, cookies=None):
self.headers = headers or {}
self.cookies = cookies or {}
class MockContext:
"""Мок для GraphQL контекста"""
def __init__(self, request=None):
self.request = request
def get(self, key, default=None):
"""Мокаем метод get для совместимости с DRY функциями"""
if key == "request":
return self.request
return default
class MockGraphQLResolveInfo:
"""Мок для GraphQLResolveInfo"""
def __init__(self, context):
self.context = context
@pytest.fixture
def mock_author():
"""Мок для объекта Author"""
author = MagicMock(spec=Author)
author.id = 123
author.email = "test@example.com"
author.name = "Test User"
author.slug = "test-user"
author.username = "testuser"
# Мокаем метод dict()
author.dict.return_value = {
"id": 123,
"email": "test@example.com",
"name": "Test User",
"slug": "test-user",
"username": "testuser"
}
return author
@pytest.fixture
def mock_payload():
"""Мок для payload токена"""
payload = MagicMock()
payload.user_id = "123"
return payload
@pytest.mark.asyncio
async def test_getSession_with_valid_cookie(mock_author, mock_payload):
"""Тест getSession с валидным cookie"""
# Мокаем request с cookie
request = MockRequest(
headers={},
cookies={"session_token": "valid_token_123"}
)
context = MockContext(request)
info = MockGraphQLResolveInfo(context)
# Мокаем DRY функции из auth/utils.py
with patch('resolvers.auth.get_auth_token_from_context') as mock_get_token, \
patch('resolvers.auth.get_user_data_by_token') as mock_get_user_data:
mock_get_token.return_value = "valid_token_123"
mock_get_user_data.return_value = (True, mock_author.dict(), None)
result = await get_session(None, info)
# Проверяем результат
assert result["success"] is True
assert result["token"] == "valid_token_123"
assert result["author"]["id"] == 123
assert result["author"]["email"] == "test@example.com"
assert result["error"] is None
# Проверяем вызовы DRY функций
mock_get_token.assert_called_once_with(info)
mock_get_user_data.assert_called_once_with("valid_token_123")
@pytest.mark.asyncio
async def test_getSession_with_authorization_header(mock_author, mock_payload):
"""Тест getSession с заголовком Authorization"""
# Мокаем request с заголовком Authorization
request = MockRequest(
headers={"authorization": "Bearer bearer_token_456"},
cookies={}
)
context = MockContext(request)
info = MockGraphQLResolveInfo(context)
# Мокаем DRY функции из auth/utils.py
with patch('resolvers.auth.get_auth_token_from_context') as mock_get_token, \
patch('resolvers.auth.get_user_data_by_token') as mock_get_user_data:
mock_get_token.return_value = "bearer_token_456"
mock_get_user_data.return_value = (True, mock_author.dict(), None)
result = await get_session(None, info)
# Проверяем результат
assert result["success"] is True
assert result["token"] == "bearer_token_456"
assert result["author"]["id"] == 123
assert result["error"] is None
# Проверяем вызовы DRY функций
mock_get_token.assert_called_once_with(info)
mock_get_user_data.assert_called_once_with("bearer_token_456")
@pytest.mark.asyncio
async def test_getSession_with_invalid_token(mock_author):
"""Тест getSession с невалидным токеном"""
# Мокаем request с невалидным cookie
request = MockRequest(
headers={},
cookies={"session_token": "invalid_token"}
)
context = MockContext(request)
info = MockGraphQLResolveInfo(context)
# Мокаем DRY функции из auth/utils.py
with patch('resolvers.auth.get_auth_token_from_context') as mock_get_token, \
patch('resolvers.auth.get_user_data_by_token') as mock_get_user_data:
mock_get_token.return_value = "invalid_token"
mock_get_user_data.return_value = (False, None, "Сессия не найдена")
result = await get_session(None, info)
# Проверяем результат
assert result["success"] is False
assert result["token"] is None
assert result["author"] is None
assert result["error"] == "Сессия не найдена"
@pytest.mark.asyncio
async def test_getSession_without_token():
"""Тест getSession без токена"""
# Мокаем request без токена
request = MockRequest(headers={}, cookies={})
context = MockContext(request)
info = MockGraphQLResolveInfo(context)
# Мокаем DRY функции из auth/utils.py
with patch('resolvers.auth.get_auth_token_from_context') as mock_get_token:
mock_get_token.return_value = None
result = await get_session(None, info)
# Проверяем результат
assert result["success"] is False
assert result["token"] is None
assert result["author"] is None
assert result["error"] == "Сессия не найдена"
@pytest.mark.asyncio
async def test_getSession_without_request():
"""Тест getSession без request в контексте"""
# Мокаем контекст без request
context = MockContext(request=None)
info = MockGraphQLResolveInfo(context)
# Мокаем DRY функции из auth/utils.py
with patch('resolvers.auth.get_auth_token_from_context') as mock_get_token:
mock_get_token.return_value = None
result = await get_session(None, info)
# Проверяем результат
assert result["success"] is False
assert result["token"] is None
assert result["author"] is None
assert result["error"] == "Сессия не найдена"
@pytest.mark.asyncio
async def test_getSession_user_not_found(mock_payload):
"""Тест getSession когда пользователь не найден в БД"""
# Мокаем request с валидным cookie
request = MockRequest(
headers={},
cookies={"session_token": "valid_token_123"}
)
context = MockContext(request)
info = MockGraphQLResolveInfo(context)
# Мокаем DRY функции из auth/utils.py
with patch('resolvers.auth.get_auth_token_from_context') as mock_get_token, \
patch('resolvers.auth.get_user_data_by_token') as mock_get_user_data:
mock_get_token.return_value = "valid_token_123"
mock_get_user_data.return_value = (False, None, f"Пользователь с ID 123 не найден в БД")
result = await get_session(None, info)
# Проверяем результат
assert result["success"] is False
assert result["token"] is None
assert result["author"] is None
assert result["error"] == "Пользователь с ID 123 не найден в БД"
@pytest.mark.asyncio
async def test_getSession_payload_without_user_id():
"""Тест getSession когда payload не содержит user_id"""
# Мокаем request с валидным cookie
request = MockRequest(
headers={},
cookies={"session_token": "valid_token_123"}
)
context = MockContext(request)
info = MockGraphQLResolveInfo(context)
# Мокаем DRY функции из auth/utils.py
with patch('resolvers.auth.get_auth_token_from_context') as mock_get_token, \
patch('resolvers.auth.get_user_data_by_token') as mock_get_user_data:
mock_get_token.return_value = "valid_token_123"
mock_get_user_data.return_value = (False, None, "Токен не содержит user_id")
result = await get_session(None, info)
# Проверяем результат
assert result["success"] is False
assert result["token"] is None
assert result["author"] is None
assert result["error"] == "Токен не содержит user_id"
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -10,7 +10,7 @@ import time
from unittest.mock import patch, MagicMock
import json
from auth.orm import Author
from orm.author import Author
from orm.community import Community, CommunityAuthor
from rbac.api import (
initialize_community_permissions,

View File

@@ -8,7 +8,7 @@ import pytest
import time
from unittest.mock import patch, MagicMock
from auth.orm import Author
from orm.author import Author
from orm.community import Community, CommunityAuthor
from rbac.api import (
initialize_community_permissions,

View File

@@ -2,7 +2,7 @@ from datetime import datetime
import pytest
from auth.orm import Author
from orm.author import Author
from orm.community import CommunityAuthor
from orm.reaction import ReactionKind
from orm.shout import Shout

View File

@@ -2,7 +2,7 @@ from datetime import datetime
import pytest
from auth.orm import Author
from orm.author import Author
from orm.community import CommunityAuthor
from orm.shout import Shout
from resolvers.reader import get_shout

View File

@@ -18,7 +18,7 @@ import pytest
sys.path.append(str(Path(__file__).parent))
from auth.orm import Author
from orm.author import Author
from orm.community import assign_role_to_user
from orm.shout import Shout
from resolvers.editor import unpublish_shout

View File

@@ -16,7 +16,7 @@ from typing import Any
sys.path.append(str(Path(__file__).parent))
from auth.orm import Author
from orm.author import Author
from resolvers.auth import update_security
from storage.db import local_session