feature/e2e #4

Merged
to merged 22 commits from feature/e2e into dev 2025-08-20 17:21:31 +00:00
132 changed files with 7048 additions and 3725 deletions
Showing only changes of commit 1b48675b92 - Show all commits

View File

@@ -136,7 +136,7 @@ jobs:
from orm.reaction import Reaction
from orm.shout import Shout
from orm.topic import Topic
from auth.orm import Author, AuthorBookmark, AuthorRating, AuthorFollower
from orm.author import Author, AuthorBookmark, AuthorRating, AuthorFollower
from storage.db import engine
from sqlalchemy import inspect

View File

@@ -1,22 +1,40 @@
# Changelog
Все значимые изменения в проекте документируются в этом файле.
## [0.9.7] - 2025-08-17
## [0.9.7] - 2025-08-18
### 🔧 Исправления архитектуры
- **Устранены циклические импорты в ORM**: Исправлена проблема с циклическими импортами между `orm/community.py` и `orm/shout.py`
### 🔄 Изменения
- **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`, заменен на строковые ссылки
- **Исправлены предупреждения ruff**: Добавлены `# noqa: PLW0603` комментарии для подавления предупреждений о `global` в `rbac/interface.py`
- **Улучшена совместимость SQLAlchemy**: Использование `text()` для сложных SQL выражений в `CommunityStats`
### 🏷️ Типизация
- **Исправлены mypy ошибки**: Все ORM модели теперь корректно проходят проверку типов
- **Улучшена совместимость**: Использование `BaseModel` вместо алиаса `Base` для избежания путаницы
### 🔧 Авторизация с 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`
- **Логирование операций**: Добавлены подробные логи для отслеживания процесса авторизации
### 🧹 Код-качество
- **Упрощена архитектура импортов**: Убраны сложные конструкции для избежания `global`
- **Сохранена функциональность**: Все методы `CommunityStats` работают корректно с новой архитектурой
### 📝 Документация
- **Обновлена схема GraphQL**: `SessionInfo` тип теперь соответствует новому формату ответа
- Обновлена документация RBAC
- Обновлена документация авторизации с cookies
## [0.9.6] - 2025-08-12
@@ -2086,4 +2104,4 @@ Radical architecture simplification with separation into service layer and thin
- `gittask`, `inbox` and `auth` logics removed
- `settings` moved to base and now smaller
- new outside auth schema
- removed `gittask`, `auth`, `inbox`, `migration`
- removed `gittask`, `auth`, `inbox`, `migration`

View File

@@ -1,19 +1,18 @@
from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse, Response
# Импорт базовых функций из реструктурированных модулей
from auth.core import verify_internal_auth
from auth.orm import Author
from auth.tokens.storage import TokenStorage
from storage.db import local_session
from auth.utils import extract_token_from_request
from orm.author import Author
from settings import (
SESSION_COOKIE_HTTPONLY,
SESSION_COOKIE_MAX_AGE,
SESSION_COOKIE_NAME,
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_SECURE,
SESSION_TOKEN_HEADER,
)
from storage.db import local_session
from utils.logger import root_logger as logger
@@ -25,30 +24,7 @@ async def logout(request: Request) -> Response:
1. HTTP-only cookie
2. Заголовка Authorization
"""
token = None
# Получаем токен из cookie
if SESSION_COOKIE_NAME in request.cookies:
token = request.cookies.get(SESSION_COOKIE_NAME)
logger.debug(f"[auth] logout: Получен токен из cookie {SESSION_COOKIE_NAME}")
# Если токен не найден в cookie, проверяем заголовок
if not token:
# Сначала проверяем основной заголовок авторизации
auth_header = request.headers.get(SESSION_TOKEN_HEADER)
if auth_header:
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
logger.debug(f"[auth] logout: Получен Bearer токен из заголовка {SESSION_TOKEN_HEADER}")
else:
token = auth_header.strip()
logger.debug(f"[auth] logout: Получен прямой токен из заголовка {SESSION_TOKEN_HEADER}")
# Если токен не найден в основном заголовке, проверяем стандартный Authorization
if not token and "Authorization" in request.headers:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
logger.debug("[auth] logout: Получен Bearer токен из заголовка Authorization")
token = await extract_token_from_request(request)
# Если токен найден, отзываем его
if token:
@@ -91,36 +67,7 @@ async def refresh_token(request: Request) -> JSONResponse:
Возвращает новый токен как в HTTP-only cookie, так и в теле ответа.
"""
token = None
source = None
# Получаем текущий токен из cookie
if SESSION_COOKIE_NAME in request.cookies:
token = request.cookies.get(SESSION_COOKIE_NAME)
source = "cookie"
logger.debug(f"[auth] refresh_token: Токен получен из cookie {SESSION_COOKIE_NAME}")
# Если токен не найден в cookie, проверяем заголовок авторизации
if not token:
# Проверяем основной заголовок авторизации
auth_header = request.headers.get(SESSION_TOKEN_HEADER)
if auth_header:
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
source = "header"
logger.debug(f"[auth] refresh_token: Токен получен из заголовка {SESSION_TOKEN_HEADER} (Bearer)")
else:
token = auth_header.strip()
source = "header"
logger.debug(f"[auth] refresh_token: Токен получен из заголовка {SESSION_TOKEN_HEADER} (прямой)")
# Если токен не найден в основном заголовке, проверяем стандартный Authorization
if not token and "Authorization" in request.headers:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
source = "header"
logger.debug("[auth] refresh_token: Токен получен из заголовка Authorization")
token = await extract_token_from_request(request)
if not token:
logger.warning("[auth] refresh_token: Токен не найден в запросе")
@@ -152,6 +99,8 @@ async def refresh_token(request: Request) -> JSONResponse:
logger.error(f"[auth] refresh_token: Не удалось обновить токен для пользователя {user_id}")
return JSONResponse({"success": False, "error": "Не удалось обновить токен"}, status_code=500)
source = "cookie" if token.startswith("Bearer ") else "header"
# Создаем ответ
response = JSONResponse(
{

View File

@@ -7,12 +7,12 @@ import time
from sqlalchemy.orm.exc import NoResultFound
from auth.orm import Author
from auth.state import AuthState
from auth.tokens.storage import TokenStorage as TokenManager
from orm.author import Author
from orm.community import CommunityAuthor
from storage.db import local_session
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from storage.db import local_session
from utils.logger import root_logger as logger
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")

View File

@@ -9,11 +9,11 @@ from sqlalchemy import exc
from auth.core import authenticate
from auth.credentials import AuthCredentials
from auth.exceptions import OperationNotAllowedError
from auth.orm import Author
from auth.utils import get_auth_token, get_safe_headers
from orm.author import Author
from orm.community import CommunityAuthor
from storage.db import local_session
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from storage.db import local_session
from utils.logger import root_logger as logger
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")

View File

@@ -2,11 +2,11 @@ from typing import Any, TypeVar
from auth.exceptions import ExpiredTokenError, InvalidPasswordError, InvalidTokenError
from auth.jwtcodec import JWTCodec
from auth.orm import Author
from auth.password import Password
from orm.author import Author
from storage.db import local_session
from storage.redis import redis
from utils.logger import root_logger as logger
from utils.password import Password
AuthorType = TypeVar("AuthorType", bound=Author)

View File

@@ -15,10 +15,8 @@ from starlette.responses import JSONResponse, Response
from starlette.types import ASGIApp
from auth.credentials import AuthCredentials
from auth.orm import Author
from auth.tokens.storage import TokenStorage as TokenManager
from storage.db import local_session
from storage.redis import redis as redis_adapter
from orm.author import Author
from settings import (
ADMIN_EMAILS as ADMIN_EMAILS_LIST,
)
@@ -30,6 +28,8 @@ from settings import (
SESSION_COOKIE_SECURE,
SESSION_TOKEN_HEADER,
)
from storage.db import local_session
from storage.redis import redis as redis_adapter
from utils.logger import root_logger as logger
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
@@ -498,6 +498,31 @@ class AuthMiddleware:
f"[graphql_handler] Установлена cookie {SESSION_COOKIE_NAME} для операции {op_name}"
)
# Если это операция getSession и в ответе есть токен, устанавливаем cookie
elif op_name == "getsession":
token = None
# Пытаемся извлечь токен из данных ответа
if result_data and isinstance(result_data, dict):
data_obj = result_data.get("data", {})
if isinstance(data_obj, dict) and "getSession" in data_obj:
op_result = data_obj.get("getSession", {})
if isinstance(op_result, dict) and "token" in op_result and op_result.get("success"):
token = op_result.get("token")
if token:
# Устанавливаем cookie с токеном для поддержания сессии
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=SESSION_COOKIE_HTTPONLY,
secure=SESSION_COOKIE_SECURE,
samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE,
)
logger.debug(
f"[graphql_handler] Установлена cookie {SESSION_COOKIE_NAME} для операции {op_name}"
)
# Если это операция logout, удаляем cookie
elif op_name == "logout":
response.delete_cookie(

View File

@@ -10,11 +10,9 @@ from sqlalchemy.orm import Session
from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse
from auth.orm import Author
from auth.tokens.storage import TokenStorage
from orm.author import Author
from orm.community import Community, CommunityAuthor, CommunityFollower
from storage.db import local_session
from storage.redis import redis
from settings import (
FRONTEND_URL,
OAUTH_CLIENTS,
@@ -24,6 +22,8 @@ from settings import (
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_SECURE,
)
from storage.db import local_session
from storage.redis import redis
from utils.generate_slug import generate_unique_slug
from utils.logger import root_logger as logger

View File

@@ -3,7 +3,7 @@
Содержит функции для работы с токенами, заголовками и запросами
"""
from typing import Any
from typing import Any, Tuple
from settings import SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER
from utils.logger import root_logger as logger
@@ -56,6 +56,122 @@ def get_safe_headers(request: Any) -> dict[str, str]:
return headers
async def extract_token_from_request(request) -> str | None:
"""
DRY функция для извлечения токена из request.
Проверяет cookies и заголовок Authorization.
Args:
request: Request объект
Returns:
Optional[str]: Токен или None
"""
if not request:
return None
# 1. Проверяем cookies
if hasattr(request, "cookies") and request.cookies:
token = request.cookies.get(SESSION_COOKIE_NAME)
if token:
logger.debug(f"[utils] Токен получен из cookie {SESSION_COOKIE_NAME}")
return token
# 2. Проверяем заголовок Authorization
headers = get_safe_headers(request)
auth_header = headers.get("authorization", "")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
logger.debug("[utils] Токен получен из заголовка Authorization")
return token
logger.debug("[utils] Токен не найден ни в cookies, ни в заголовке")
return None
async def get_user_data_by_token(token: str) -> Tuple[bool, dict | None, str | None]:
"""
Получает данные пользователя по токену.
Args:
token: Токен авторизации
Returns:
Tuple[bool, Optional[dict], Optional[str]]: (success, user_data, error_message)
"""
try:
from auth.tokens.storage import TokenStorage as TokenManager
from orm.author import Author
from storage.db import local_session
# Проверяем сессию через TokenManager
payload = await TokenManager.verify_session(token)
if not payload:
return False, None, "Сессия не найдена"
# Получаем user_id из payload
user_id = payload.user_id if hasattr(payload, "user_id") else payload.get("user_id")
if not user_id:
return False, None, "Токен не содержит user_id"
# Получаем данные пользователя
with local_session() as session:
author_obj = session.query(Author).where(Author.id == int(user_id)).first()
if not author_obj:
return False, None, f"Пользователь с ID {user_id} не найден в БД"
try:
user_data = author_obj.dict()
except Exception:
user_data = {
"id": author_obj.id,
"email": author_obj.email,
"name": getattr(author_obj, "name", ""),
"slug": getattr(author_obj, "slug", ""),
"username": getattr(author_obj, "username", ""),
}
logger.debug(f"[utils] Данные пользователя получены для ID {user_id}")
return True, user_data, None
except Exception as e:
logger.error(f"[utils] Ошибка при получении данных пользователя: {e}")
return False, None, f"Ошибка получения данных: {e!s}"
async def get_auth_token_from_context(info: Any) -> str | None:
"""
Извлекает токен авторизации из GraphQL контекста.
Порядок проверки:
1. Проверяет заголовок Authorization
2. Проверяет cookie session_token
3. Переиспользует логику get_auth_token для request
Args:
info: GraphQLResolveInfo объект
Returns:
Optional[str]: Токен авторизации или None
"""
try:
context = getattr(info, "context", {})
request = context.get("request")
if request:
# Переиспользуем существующую логику для request
return await get_auth_token(request)
# Если request отсутствует, возвращаем None
logger.debug("[utils] Request отсутствует в GraphQL контексте")
return None
except Exception as e:
logger.error(f"[utils] Ошибка при извлечении токена из GraphQL контекста: {e}")
return None
async def get_auth_token(request: Any) -> str | None:
"""
Извлекает токен авторизации из запроса.

6
cache/cache.py vendored
View File

@@ -34,7 +34,7 @@ from typing import Any, Callable, Dict, List, Type
import orjson
from sqlalchemy import and_, join, select
from auth.orm import Author, AuthorFollower
from orm.author import Author, AuthorFollower
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic, TopicFollower
from storage.db import local_session
@@ -278,7 +278,7 @@ async def get_cached_author_followers(author_id: int):
f[0]
for f in session.query(Author.id)
.join(AuthorFollower, AuthorFollower.follower == Author.id)
.where(AuthorFollower.author == author_id, Author.id != author_id)
.where(AuthorFollower.following == author_id, Author.id != author_id)
.all()
]
await redis.execute("SET", f"author:followers:{author_id}", fast_json_dumps(followers_ids))
@@ -298,7 +298,7 @@ async def get_cached_follower_authors(author_id: int):
a[0]
for a in session.execute(
select(Author.id)
.select_from(join(Author, AuthorFollower, Author.id == AuthorFollower.author))
.select_from(join(Author, AuthorFollower, Author.id == AuthorFollower.following))
.where(AuthorFollower.follower == author_id)
).all()
]

7
cache/precache.py vendored
View File

@@ -3,10 +3,9 @@ import traceback
from sqlalchemy import and_, join, select
from auth.orm import Author, AuthorFollower
# Импорт Author, AuthorFollower отложен для избежания циклических импортов
from cache.cache import cache_author, cache_topic
from orm.author import Author, AuthorFollower
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
from orm.topic import Topic, TopicFollower
from resolvers.stat import get_with_stat
@@ -19,7 +18,7 @@ from utils.logger import root_logger as logger
# Предварительное кеширование подписчиков автора
async def precache_authors_followers(author_id, session) -> None:
authors_followers: set[int] = set()
followers_query = select(AuthorFollower.follower).where(AuthorFollower.author == author_id)
followers_query = select(AuthorFollower.follower).where(AuthorFollower.following == author_id)
result = session.execute(followers_query)
authors_followers.update(row[0] for row in result if row[0])
@@ -30,7 +29,7 @@ async def precache_authors_followers(author_id, session) -> None:
# Предварительное кеширование подписок автора
async def precache_authors_follows(author_id, session) -> None:
follows_topics_query = select(TopicFollower.topic).where(TopicFollower.follower == author_id)
follows_authors_query = select(AuthorFollower.author).where(AuthorFollower.follower == author_id)
follows_authors_query = select(AuthorFollower.following).where(AuthorFollower.follower == author_id)
follows_shouts_query = select(ShoutReactionsFollower.shout).where(ShoutReactionsFollower.follower == author_id)
follows_topics = {row[0] for row in session.execute(follows_topics_query) if row[0]}

5
cache/triggers.py vendored
View File

@@ -1,9 +1,8 @@
from sqlalchemy import event
from auth.orm import Author, AuthorFollower
# Импорт Author, AuthorFollower отложен для избежания циклических импортов
from cache.revalidator import revalidation_manager
from orm.author import Author, AuthorFollower
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower
from orm.topic import Topic, TopicFollower
@@ -40,7 +39,7 @@ def after_follower_handler(mapper, connection, target, is_delete=False) -> None:
if entity_type:
revalidation_manager.mark_for_revalidation(
target.author if entity_type == "authors" else target.topic, entity_type
target.following if entity_type == "authors" else target.topic, entity_type
)
if not is_delete:
revalidation_manager.mark_for_revalidation(target.follower, "authors")

View File

@@ -23,6 +23,7 @@ from orm.base import Base
from storage.db import engine
from utils.logger import root_logger as logger
class CIServerManager:
"""Менеджер CI серверов"""

View File

@@ -1,4 +1,4 @@
# Документация Discours Core v0.9.6
# Документация Discours Core v0.9.8
## 📚 Быстрый старт
@@ -22,7 +22,7 @@ python -m granian main:app --interface asgi
### 📊 Статус проекта
- **Версия**: 0.9.6
- **Версия**: 0.9.8
- **Тесты**: 344/344 проходят (включая E2E Playwright тесты) ✅
- **Покрытие**: 90%
- **Python**: 3.12+

File diff suppressed because it is too large Load Diff

View File

@@ -99,6 +99,22 @@
- `VerificationTokenManager`: Токены для подтверждения email, телефона, смены пароля
- `OAuthTokenManager`: Управление OAuth токенами для внешних провайдеров
## Авторизация с cookies
- **getSession без токена**: Мутация `getSession` теперь работает с httpOnly cookies даже без заголовка Authorization
- **Dual-авторизация**: Поддержка как токенов в заголовках, так и cookies для максимальной совместимости
- **Автоматические cookies**: Middleware автоматически устанавливает httpOnly cookies при успешной авторизации
- **Безопасность**: Использование httpOnly, secure и samesite cookies для защиты от XSS и CSRF атак
- **Сессии без перелогина**: Пользователи остаются авторизованными между сессиями браузера
## DRY архитектура авторизации
- **Централизованные функции**: Все функции для работы с токенами и авторизацией находятся в `auth/utils.py`
- **Устранение дублирования**: Единая логика проверки авторизации используется во всех модулях
- **Единообразная обработка**: Стандартизированный подход к извлечению токенов из cookies и заголовков
- **Улучшенная тестируемость**: Мокирование централизованных функций упрощает тестирование
- **Легкость поддержки**: Изменения в логике авторизации требуют правки только в одном месте
## E2E тестирование с Playwright
- **Автоматизация браузера**: Полноценное тестирование пользовательского интерфейса админ-панели

View File

@@ -2,16 +2,17 @@
## Общее описание
Система управления доступом на основе ролей (Role-Based Access Control, RBAC) обеспечивает гибкое управление правами пользователей в рамках сообществ платформы.
Система управления доступом на основе ролей (Role-Based Access Control, RBAC) обеспечивает гибкое управление правами пользователей в рамках сообществ платформы. Система поддерживает иерархическое наследование разрешений и автоматическое кеширование для оптимальной производительности.
## Архитектура системы
### Принципы работы
1. **Иерархия ролей**: Роли наследуют права друг от друга
1. **Иерархия ролей**: Роли наследуют права друг от друга с рекурсивным вычислением
2. **Контекстная проверка**: Права проверяются в контексте конкретного сообщества
3. **Системные администраторы**: Пользователи из `ADMIN_EMAILS` автоматически получают роль `admin` в любом сообществе
4. **Динамическое определение community_id**: Система автоматически определяет `community_id` из аргументов GraphQL мутаций
5. **Рекурсивное наследование**: Разрешения автоматически включают все унаследованные права от родительских ролей
### Получение community_id
@@ -27,7 +28,7 @@
2. **CommunityAuthor** - связь пользователя с сообществом и его ролями
3. **Role** - роль пользователя (reader, author, editor, admin)
4. **Permission** - разрешение на выполнение действия
5. **RBAC Service** - сервис управления ролями и разрешениями
5. **RBAC Service** - сервис управления ролями и разрешениями с рекурсивным наследованием
### Модель данных
@@ -103,7 +104,7 @@ CREATE INDEX idx_community_author_author ON community_author(author_id);
admin > editor > expert > artist/author > reader
```
Каждая роль автоматически включает права всех ролей ниже по иерархии.
Каждая роль автоматически включает права всех ролей ниже по иерархии. Система рекурсивно вычисляет все унаследованные разрешения при инициализации сообщества.
## Разрешения (Permissions)
@@ -124,10 +125,6 @@ admin > editor > expert > artist/author > reader
- `@require_all_permissions(["permission1", "permission2"])` - проверка наличия всех разрешений
**Важно**: В resolvers не должна быть дублирующая логика проверки прав - вся проверка осуществляется через систему RBAC.
- `comment:create` - создание комментариев
- `comment:moderate` - модерация комментариев
- `user:manage` - управление пользователями
- `community:settings` - настройки сообщества
### Категории разрешений
@@ -480,3 +477,78 @@ role_checks_total = Counter('rbac_role_checks_total')
permission_checks_total = Counter('rbac_permission_checks_total')
role_assignments_total = Counter('rbac_role_assignments_total')
```
## Новые возможности системы
### Рекурсивное наследование разрешений
Система теперь поддерживает автоматическое вычисление всех унаследованных разрешений:
```python
# Получить разрешения для конкретной роли с учетом наследования
role_permissions = await rbac_ops.get_role_permissions_for_community(
community_id=1,
role="editor"
)
# Возвращает: {"editor": ["shout:edit_any", "comment:moderate", "draft:create", "shout:read", ...]}
# Получить все разрешения для сообщества
all_permissions = await rbac_ops.get_all_permissions_for_community(community_id=1)
# Возвращает полный словарь всех ролей с их разрешениями
```
### Автоматическая инициализация
При создании нового сообщества система автоматически инициализирует права с учетом иерархии:
```python
# Автоматически создает расширенные разрешения для всех ролей
await rbac_ops.initialize_community_permissions(community_id=123)
# Система рекурсивно вычисляет все наследованные разрешения
# и сохраняет их в Redis для быстрого доступа
```
### Улучшенная производительность
- **Кеширование в Redis**: Все разрешения кешируются с ключом `community:roles:{community_id}`
- **Рекурсивное вычисление**: Разрешения вычисляются один раз при инициализации
- **Быстрая проверка**: Проверка разрешений происходит за O(1) из кеша
### Обновленный API
```python
class RBACOperations(Protocol):
# Получить разрешения для конкретной роли с наследованием
async def get_role_permissions_for_community(self, community_id: int, role: str) -> dict
# Получить все разрешения для сообщества
async def get_all_permissions_for_community(self, community_id: int) -> dict
# Проверить разрешения для набора ролей
async def roles_have_permission(self, role_slugs: list[str], permission: str, community_id: int) -> bool
```
## Миграция на новую систему
### Обновление существующего кода
Если в вашем коде используются старые методы, обновите их:
```python
# Старый код
permissions = await rbac_ops._get_role_permissions_for_community(community_id)
# Новый код
permissions = await rbac_ops.get_all_permissions_for_community(community_id)
# Или для конкретной роли
role_permissions = await rbac_ops.get_role_permissions_for_community(community_id, "editor")
```
### Обратная совместимость
Новая система полностью совместима с существующим кодом:
- Все публичные API методы сохранили свои сигнатуры
- Декораторы `@require_permission` работают без изменений
- Существующие тесты проходят без модификации

View File

@@ -22,12 +22,12 @@ from auth.oauth import oauth_callback, oauth_login
from cache.precache import precache_data
from cache.revalidator import revalidation_manager
from rbac import initialize_rbac
from utils.exception import ExceptionHandlerMiddleware
from storage.redis import redis
from storage.schema import create_all_tables, resolvers
from services.search import check_search_service, initialize_search_index_background, search_service
from services.viewed import ViewedStorage
from settings import DEV_SERVER_PID_FILE_NAME
from storage.redis import redis
from storage.schema import create_all_tables, resolvers
from utils.exception import ExceptionHandlerMiddleware
from utils.logger import root_logger as logger
DEVMODE = os.getenv("DOKKU_APP_TYPE", "false").lower() == "false"

View File

@@ -0,0 +1,63 @@
# ORM Models
# Re-export models for convenience
from orm.author import Author, AuthorBookmark, AuthorFollower, AuthorRating
from . import (
collection,
community,
draft,
invite,
notification,
rating,
reaction,
shout,
topic,
)
from .collection import Collection, ShoutCollection
from .community import Community, CommunityFollower
from .draft import Draft, DraftAuthor, DraftTopic
from .invite import Invite
from .notification import Notification, NotificationSeen
# from .rating import Rating # rating.py содержит только константы, не классы
from .reaction import REACTION_KINDS, Reaction, ReactionKind
from .shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
from .topic import Topic, TopicFollower
__all__ = [
# "Rating", # rating.py содержит только константы, не классы
"REACTION_KINDS",
# Models
"Author",
"AuthorBookmark",
"AuthorFollower",
"AuthorRating",
"Collection",
"Community",
"CommunityFollower",
"Draft",
"DraftAuthor",
"DraftTopic",
"Invite",
"Notification",
"NotificationSeen",
"Reaction",
"ReactionKind",
"Shout",
"ShoutAuthor",
"ShoutCollection",
"ShoutReactionsFollower",
"ShoutTopic",
"Topic",
"TopicFollower",
# Modules
"collection",
"community",
"draft",
"invite",
"notification",
"rating",
"reaction",
"shout",
"topic",
]

View File

@@ -12,8 +12,8 @@ from sqlalchemy import (
)
from sqlalchemy.orm import Mapped, Session, mapped_column
from auth.password import Password
from orm.base import BaseModel as Base
from utils.password import Password
# Общие table_args для всех моделей
DEFAULT_TABLE_ARGS = {"extend_existing": True}
@@ -53,7 +53,7 @@ class Author(Base):
# Поля аутентификации
email: Mapped[str | None] = mapped_column(String, unique=True, nullable=True, comment="Email")
phone: Mapped[str | None] = mapped_column(String, unique=True, nullable=True, comment="Phone")
phone: Mapped[str | None] = mapped_column(String, nullable=True, comment="Phone")
password: Mapped[str | None] = mapped_column(String, nullable=True, comment="Password hash")
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
phone_verified: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -100,7 +100,7 @@ class Author(Base):
"""Проверяет, заблокирован ли аккаунт"""
if not self.account_locked_until:
return False
return bool(self.account_locked_until > int(time.time()))
return int(time.time()) < self.account_locked_until
@property
def username(self) -> str:
@@ -211,72 +211,103 @@ class Author(Base):
if self.oauth and provider in self.oauth:
del self.oauth[provider]
def to_dict(self, include_protected: bool = False) -> Dict[str, Any]:
"""Конвертирует модель в словарь"""
result = {
"id": self.id,
"name": self.name,
"slug": self.slug,
"bio": self.bio,
"about": self.about,
"pic": self.pic,
"links": self.links,
"oauth": self.oauth,
"email_verified": self.email_verified,
"phone_verified": self.phone_verified,
"created_at": self.created_at,
"updated_at": self.updated_at,
"last_seen": self.last_seen,
"deleted_at": self.deleted_at,
"oid": self.oid,
}
if include_protected:
result.update(
{
"email": self.email,
"phone": self.phone,
"failed_login_attempts": self.failed_login_attempts,
"account_locked_until": self.account_locked_until,
}
)
return result
def __repr__(self) -> str:
return f"<Author(id={self.id}, slug='{self.slug}', email='{self.email}')>"
class AuthorFollower(Base):
"""
Связь подписки между авторами.
"""
__tablename__ = "author_follower"
__table_args__ = (
PrimaryKeyConstraint("follower", "following"),
Index("idx_author_follower_follower", "follower"),
Index("idx_author_follower_following", "following"),
{"extend_existing": True},
)
follower: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
following: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
def __repr__(self) -> str:
return f"<AuthorFollower(follower={self.follower}, following={self.following})>"
class AuthorBookmark(Base):
"""
Закладка автора на публикацию.
Attributes:
author (int): ID автора
shout (int): ID публикации
Закладки автора.
"""
__tablename__ = "author_bookmark"
author: Mapped[int] = mapped_column(ForeignKey(Author.id))
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"))
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
__table_args__ = (
PrimaryKeyConstraint(author, shout),
PrimaryKeyConstraint("author", "shout"),
Index("idx_author_bookmark_author", "author"),
Index("idx_author_bookmark_shout", "shout"),
{"extend_existing": True},
)
author: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
shout: Mapped[int] = mapped_column(Integer, ForeignKey("shout.id"), nullable=False)
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
def __repr__(self) -> str:
return f"<AuthorBookmark(author={self.author}, shout={self.shout})>"
class AuthorRating(Base):
"""
Рейтинг автора от другого автора.
Attributes:
rater (int): ID оценивающего автора
author (int): ID оцениваемого автора
plus (bool): Положительная/отрицательная оценка
Рейтинг автора.
"""
__tablename__ = "author_rating"
rater: Mapped[int] = mapped_column(ForeignKey(Author.id))
author: Mapped[int] = mapped_column(ForeignKey(Author.id))
plus: Mapped[bool] = mapped_column(Boolean)
__table_args__ = (
PrimaryKeyConstraint(rater, author),
PrimaryKeyConstraint("author", "rater"),
Index("idx_author_rating_author", "author"),
Index("idx_author_rating_rater", "rater"),
{"extend_existing": True},
)
class AuthorFollower(Base):
"""
Подписка одного автора на другого.
Attributes:
follower (int): ID подписчика
author (int): ID автора, на которого подписываются
created_at (int): Время создания подписки
auto (bool): Признак автоматической подписки
"""
__tablename__ = "author_follower"
follower: Mapped[int] = mapped_column(ForeignKey(Author.id))
author: Mapped[int] = mapped_column(ForeignKey(Author.id))
author: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
rater: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
plus: Mapped[bool] = mapped_column(Boolean, nullable=True)
rating: Mapped[int] = mapped_column(Integer, nullable=False, comment="Rating value")
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
auto: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
__table_args__ = (
PrimaryKeyConstraint(follower, author),
Index("idx_author_follower_author", "author"),
Index("idx_author_follower_follower", "follower"),
{"extend_existing": True},
)
def __repr__(self) -> str:
return f"<AuthorRating(author={self.author}, rater={self.rater}, rating={self.rating})>"

View File

@@ -18,7 +18,7 @@ from sqlalchemy import (
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import Mapped, mapped_column
from auth.orm import Author
from orm.author import Author
from orm.base import BaseModel
from rbac.interface import get_rbac_operations
from storage.db import local_session

View File

@@ -4,7 +4,7 @@ from typing import Any
from sqlalchemy import JSON, Boolean, ForeignKey, Index, Integer, PrimaryKeyConstraint, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from auth.orm import Author
from orm.author import Author
from orm.base import BaseModel as Base
from orm.topic import Topic

View File

@@ -6,7 +6,7 @@ from sqlalchemy import JSON, DateTime, ForeignKey, Index, Integer, PrimaryKeyCon
from sqlalchemy.orm import Mapped, mapped_column, relationship
# Импорт Author отложен для избежания циклических импортов
from auth.orm import Author
from orm.author import Author
from orm.base import BaseModel as Base
from utils.logger import root_logger as logger

View File

@@ -4,16 +4,9 @@ from enum import Enum as Enumeration
from sqlalchemy import ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from auth.orm import Author
from orm.base import BaseModel as Base
# Author уже импортирован в начале файла
def get_author_model():
"""Возвращает модель Author для использования в запросах"""
return Author
class ReactionKind(Enumeration):
# TYPE = <reaction index> # rating diff

View File

@@ -11,7 +11,7 @@ from sqlalchemy import (
)
from sqlalchemy.orm import Mapped, mapped_column
from auth.orm import Author
from orm.author import Author
from orm.base import BaseModel as Base

View File

@@ -1,6 +1,6 @@
{
"name": "publy-panel",
"version": "0.9.5",
"version": "0.9.7",
"type": "module",
"description": "Publy, a modern platform for collaborative text creation, offers a user-friendly interface for authors, editors, and readers, supporting real-time collaboration and structured feedback.",
"scripts": {

View File

@@ -1,6 +1,6 @@
[project]
name = "discours-core"
version = "0.9.5"
version = "0.9.7"
description = "Core backend for Discours.io platform"
authors = [
{name = "Tony Rewin", email = "tonyrewin@yandex.ru"}

View File

@@ -12,10 +12,10 @@ import asyncio
from functools import wraps
from typing import Any, Callable
from auth.orm import Author
from orm.author import Author
from rbac.interface import get_community_queries, get_rbac_operations
from storage.db import local_session
from settings import ADMIN_EMAILS
from storage.db import local_session
from utils.logger import root_logger as logger
@@ -46,6 +46,20 @@ async def get_permissions_for_role(role: str, community_id: int) -> list[str]:
return await rbac_ops.get_permissions_for_role(role, community_id)
async def get_role_permissions_for_community(community_id: int) -> dict:
"""
Получает все разрешения для всех ролей в сообществе.
Args:
community_id: ID сообщества
Returns:
Словарь {роль: [разрешения]} для всех ролей
"""
rbac_ops = get_rbac_operations()
return await rbac_ops.get_all_permissions_for_community(community_id)
async def update_all_communities_permissions() -> None:
"""
Обновляет права для всех существующих сообществ на основе актуальных дефолтных настроек.
@@ -121,7 +135,7 @@ async def roles_have_permission(role_slugs: list[str], permission: str, communit
True если хотя бы одна роль имеет разрешение
"""
rbac_ops = get_rbac_operations()
return await rbac_ops._roles_have_permission(role_slugs, permission, community_id)
return await rbac_ops.roles_have_permission(role_slugs, permission, community_id)
# --- Декораторы ---

View File

@@ -28,7 +28,15 @@ class RBACOperations(Protocol):
"""Проверяет разрешение пользователя в сообществе"""
...
async def _roles_have_permission(self, role_slugs: list[str], permission: str, community_id: int) -> bool:
async def get_role_permissions_for_community(self, community_id: int, role: str) -> dict:
"""Получает права для конкретной роли в сообществе"""
...
async def get_all_permissions_for_community(self, community_id: int) -> dict:
"""Получает все права ролей для конкретного сообщества"""
...
async def roles_have_permission(self, role_slugs: list[str], permission: str, community_id: int) -> bool:
"""Проверяет, есть ли у набора ролей конкретное разрешение в сообществе"""
...

View File

@@ -40,7 +40,7 @@ class RBACOperationsImpl(RBACOperations):
Returns:
Список разрешений для роли
"""
role_perms = await self._get_role_permissions_for_community(community_id)
role_perms = await self.get_role_permissions_for_community(community_id, role)
return role_perms.get(role, [])
async def initialize_community_permissions(self, community_id: int) -> None:
@@ -117,18 +117,52 @@ class RBACOperationsImpl(RBACOperations):
"""
community_queries = get_community_queries()
user_roles = community_queries.get_user_roles_in_community(author_id, community_id, session)
return await self._roles_have_permission(user_roles, permission, community_id)
return await self.roles_have_permission(user_roles, permission, community_id)
async def _get_role_permissions_for_community(self, community_id: int) -> dict:
async def get_role_permissions_for_community(self, community_id: int, role: str) -> dict:
"""
Получает права ролей для конкретного сообщества.
Получает права для конкретной роли в сообществе, включая все наследованные разрешения.
Если права не настроены, автоматически инициализирует их дефолтными.
Args:
community_id: ID сообщества
role: Название роли для получения разрешений
Returns:
Словарь {роль: [разрешения]} для указанной роли с учетом наследования
"""
key = f"community:roles:{community_id}"
data = await redis.execute("GET", key)
if data:
role_permissions = json.loads(data)
if role in role_permissions:
return {role: role_permissions[role]}
# Если роль не найдена в кеше, используем рекурсивный расчет
# Автоматически инициализируем, если не найдено
await self.initialize_community_permissions(community_id)
# Получаем инициализированные разрешения
data = await redis.execute("GET", key)
if data:
role_permissions = json.loads(data)
if role in role_permissions:
return {role: role_permissions[role]}
# Fallback: рекурсивно вычисляем разрешения для роли
return {role: list(self._get_role_permissions_recursive(role))}
async def get_all_permissions_for_community(self, community_id: int) -> dict:
"""
Получает все права ролей для конкретного сообщества.
Если права не настроены, автоматически инициализирует их дефолтными.
Args:
community_id: ID сообщества
Returns:
Словарь прав ролей для сообщества
Словарь {роль: [разрешения]} для всех ролей в сообществе
"""
key = f"community:roles:{community_id}"
data = await redis.execute("GET", key)
@@ -147,7 +181,41 @@ class RBACOperationsImpl(RBACOperations):
# Fallback на дефолтные разрешения если что-то пошло не так
return DEFAULT_ROLE_PERMISSIONS
async def _roles_have_permission(self, role_slugs: list[str], permission: str, community_id: int) -> bool:
def _get_role_permissions_recursive(self, role: str, processed_roles: set[str] | None = None) -> set[str]:
"""
Рекурсивно получает все разрешения для роли, включая наследованные.
Вспомогательный метод для вычисления разрешений без обращения к Redis.
Args:
role: Название роли
processed_roles: Множество уже обработанных ролей для предотвращения зацикливания
Returns:
Множество всех разрешений роли (прямых и наследованных)
"""
if processed_roles is None:
processed_roles = set()
if role in processed_roles:
return set()
processed_roles.add(role)
# Получаем прямые разрешения роли
direct_permissions = set(DEFAULT_ROLE_PERMISSIONS.get(role, []))
# Проверяем, есть ли наследование роли
inherited_permissions = set()
for perm in list(direct_permissions):
if perm in role_names:
# Если пермишен - это название роли, добавляем все её разрешения
direct_permissions.remove(perm)
inherited_permissions.update(self._get_role_permissions_recursive(perm, processed_roles))
# Объединяем прямые и наследованные разрешения
return direct_permissions | inherited_permissions
async def roles_have_permission(self, role_slugs: list[str], permission: str, community_id: int) -> bool:
"""
Проверяет, есть ли у набора ролей конкретное разрешение в сообществе.
@@ -159,8 +227,12 @@ class RBACOperationsImpl(RBACOperations):
Returns:
True если хотя бы одна роль имеет разрешение
"""
role_perms = await self._get_role_permissions_for_community(community_id)
return any(permission in role_perms.get(role, []) for role in role_slugs)
# Получаем разрешения для каждой роли с учетом наследования
for role in role_slugs:
role_perms = await self.get_role_permissions_for_community(community_id, role)
if permission in role_perms.get(role, []):
return True
return False
class CommunityAuthorQueriesImpl(CommunityAuthorQueries):

View File

@@ -7,7 +7,7 @@
from sqlalchemy.orm import Session
from auth.orm import Author
from orm.author import Author
from orm.community import Community, CommunityAuthor
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST

View File

@@ -11,7 +11,7 @@ from sqlalchemy import and_, case, func, or_
from sqlalchemy.orm import aliased
from auth.decorators import admin_auth_required
from auth.orm import Author
from orm.author import Author
from orm.community import Community, CommunityAuthor
from orm.draft import DraftTopic
from orm.reaction import Reaction
@@ -21,10 +21,10 @@ from rbac.api import update_all_communities_permissions
from resolvers.editor import delete_shout, update_shout
from resolvers.topic import invalidate_topic_followers_cache, invalidate_topics_cache
from services.admin import AdminService
from utils.common_result import handle_error
from storage.db import local_session
from storage.redis import redis
from storage.schema import mutation, query
from utils.common_result import handle_error
from utils.logger import root_logger as logger
admin_service = AdminService()

View File

@@ -7,9 +7,10 @@ from typing import Any
from graphql import GraphQLResolveInfo
from starlette.responses import JSONResponse
from auth.utils import extract_token_from_request, get_auth_token_from_context, get_user_data_by_token
from services.auth import auth_service
from storage.schema import mutation, query, type_author
from settings import SESSION_COOKIE_NAME
from storage.schema import mutation, query, type_author
from utils.logger import root_logger as logger
# === РЕЗОЛВЕР ДЛЯ ТИПА AUTHOR ===
@@ -121,11 +122,7 @@ async def logout(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str,
# Получаем токен
token = None
if request:
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:]
token = await extract_token_from_request(request)
result = await auth_service.logout(user_id, token)
@@ -158,11 +155,7 @@ async def refresh_token(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dic
return {"success": False, "token": None, "author": None, "error": "Запрос не найден"}
# Получаем токен
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:]
token = await extract_token_from_request(request)
if not token:
return {"success": False, "token": None, "author": None, "error": "Токен не найден"}
@@ -262,21 +255,25 @@ async def cancel_email_change(_: None, info: GraphQLResolveInfo, **kwargs: Any)
@mutation.field("getSession")
@auth_service.login_required
async def get_session(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, Any]:
"""Получает информацию о текущей сессии"""
try:
# Получаем токен из контекста (установлен декоратором login_required)
token = info.context.get("token")
author = info.context.get("author")
token = await get_auth_token_from_context(info)
if not token:
return {"success": False, "token": None, "author": None, "error": "Токен не найден"}
logger.debug("[getSession] Токен не найден")
return {"success": False, "token": None, "author": None, "error": "Сессия не найдена"}
if not author:
return {"success": False, "token": None, "author": None, "error": "Пользователь не найден"}
# Используем DRY функцию для получения данных пользователя
success, user_data, error_message = await get_user_data_by_token(token)
if success and user_data:
user_id = user_data.get("id", "NO_ID")
logger.debug(f"[getSession] Сессия валидна для пользователя {user_id}")
return {"success": True, "token": token, "author": user_data, "error": None}
logger.warning(f"[getSession] Ошибка валидации токена: {error_message}")
return {"success": False, "token": None, "author": None, "error": error_message}
return {"success": True, "token": token, "author": author, "error": None}
except Exception as e:
logger.error(f"Ошибка получения сессии: {e}")
return {"success": False, "token": None, "author": None, "error": str(e)}

View File

@@ -7,7 +7,6 @@ from graphql import GraphQLResolveInfo
from sqlalchemy import and_, asc, func, select, text
from sqlalchemy.sql import desc as sql_desc
from auth.orm import Author, AuthorFollower
from cache.cache import (
cache_author,
cached_query,
@@ -17,14 +16,15 @@ from cache.cache import (
get_cached_follower_topics,
invalidate_cache_by_prefix,
)
from orm.author import Author, AuthorFollower
from orm.community import Community, CommunityAuthor, CommunityFollower
from orm.shout import Shout, ShoutAuthor
from resolvers.stat import get_with_stat
from services.auth import login_required
from utils.common_result import CommonResult
from storage.db import local_session
from storage.redis import redis
from storage.schema import mutation, query
from utils.common_result import CommonResult
from utils.logger import root_logger as logger
DEFAULT_COMMUNITIES = [1]
@@ -199,11 +199,11 @@ async def get_authors_with_stats(
logger.debug("Building subquery for followers sorting")
subquery = (
select(
AuthorFollower.author,
AuthorFollower.following,
func.count(func.distinct(AuthorFollower.follower)).label("followers_count"),
)
.select_from(AuthorFollower)
.group_by(AuthorFollower.author)
.group_by(AuthorFollower.following)
.subquery()
)

View File

@@ -3,13 +3,13 @@ from operator import and_
from graphql import GraphQLError
from sqlalchemy import delete, insert
from auth.orm import AuthorBookmark
from orm.author import AuthorBookmark
from orm.shout import Shout
from resolvers.reader import apply_options, get_shouts_with_links, query_with_stat
from services.auth import login_required
from utils.common_result import CommonResult
from storage.db import local_session
from storage.schema import mutation, query
from utils.common_result import CommonResult
@query.field("load_shouts_bookmarked")

View File

@@ -1,6 +1,6 @@
from typing import Any
from auth.orm import Author
from orm.author import Author
from orm.invite import Invite, InviteStatus
from orm.shout import Shout
from services.auth import login_required

View File

@@ -4,7 +4,7 @@ from graphql import GraphQLResolveInfo
from sqlalchemy.orm import joinedload
from auth.decorators import editor_or_admin_required
from auth.orm import Author
from orm.author import Author
from orm.collection import Collection, ShoutCollection
from rbac.api import require_any_permission
from storage.db import local_session

View File

@@ -4,7 +4,7 @@ from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy import distinct, func
from auth.orm import Author
from orm.author import Author
from orm.community import Community, CommunityAuthor, CommunityFollower
from orm.shout import Shout, ShoutAuthor
from rbac.api import (

View File

@@ -4,18 +4,18 @@ from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy.orm import Session, joinedload
from auth.orm import Author
from cache.cache import (
invalidate_shout_related_cache,
invalidate_shouts_cache,
)
from orm.author import Author
from orm.draft import Draft, DraftAuthor, DraftTopic
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from services.auth import login_required
from storage.db import local_session
from services.notify import notify_shout
from storage.schema import mutation, query
from services.search import search_service
from storage.db import local_session
from storage.schema import mutation, query
from utils.extract_text import extract_text
from utils.logger import root_logger as logger

View File

@@ -7,23 +7,23 @@ from sqlalchemy import and_, desc, select
from sqlalchemy.orm import joinedload
from sqlalchemy.sql.functions import coalesce
from auth.orm import Author
from cache.cache import (
cache_author,
cache_topic,
invalidate_shout_related_cache,
invalidate_shouts_cache,
)
from orm.author import Author
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic
from resolvers.follower import follow
from resolvers.stat import get_with_stat
from services.auth import login_required
from utils.common_result import CommonResult
from storage.db import local_session
from services.notify import notify_shout
from storage.schema import mutation, query
from services.search import search_service
from storage.db import local_session
from storage.schema import mutation, query
from utils.common_result import CommonResult
from utils.extract_text import extract_text
from utils.logger import root_logger as logger

View File

@@ -3,7 +3,7 @@ from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy import Select, and_, select
from auth.orm import Author, AuthorFollower
from orm.author import Author, AuthorFollower
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
from orm.topic import Topic, TopicFollower
from resolvers.reader import (
@@ -70,7 +70,7 @@ def shouts_by_follower(info: GraphQLResolveInfo, follower_id: int, options: dict
:return: Список публикаций.
"""
q = query_with_stat(info)
reader_followed_authors: Select = select(AuthorFollower.author).where(AuthorFollower.follower == follower_id)
reader_followed_authors: Select = select(AuthorFollower.following).where(AuthorFollower.follower == follower_id)
reader_followed_topics: Select = select(TopicFollower.topic).where(TopicFollower.follower == follower_id)
reader_followed_shouts: Select = select(ShoutReactionsFollower.shout).where(
ShoutReactionsFollower.follower == follower_id

View File

@@ -5,19 +5,19 @@ from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy.sql import and_
from auth.orm import Author, AuthorFollower
from cache.cache import (
cache_author,
cache_topic,
get_cached_follower_authors,
get_cached_follower_topics,
)
from orm.author import Author, AuthorFollower
from orm.community import Community, CommunityFollower
from orm.shout import Shout, ShoutReactionsFollower
from orm.topic import Topic, TopicFollower
from services.auth import login_required
from storage.db import local_session
from services.notify import notify_follower
from storage.db import local_session
from storage.redis import redis
from storage.schema import mutation, query
from utils.logger import root_logger as logger

View File

@@ -8,7 +8,7 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import aliased
from sqlalchemy.sql import not_
from auth.orm import Author
from orm.author import Author
from orm.notification import (
Notification,
NotificationAction,

View File

@@ -4,7 +4,7 @@ from graphql import GraphQLResolveInfo
from sqlalchemy import and_, case, func, select, true
from sqlalchemy.orm import Session, aliased
from auth.orm import Author, AuthorRating
from orm.author import Author, AuthorRating
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor
from services.auth import login_required
@@ -116,7 +116,7 @@ async def rate_author(_: None, info: GraphQLResolveInfo, rated_slug: str, value:
.first()
)
if rating:
rating.plus = value > 0 # type: ignore[assignment]
rating.plus = value > 0
session.add(rating)
session.commit()
return {}

View File

@@ -7,7 +7,7 @@ from sqlalchemy import Select, and_, asc, case, desc, func, select
from sqlalchemy.orm import Session, aliased
from sqlalchemy.sql import ColumnElement
from auth.orm import Author
from orm.author import Author
from orm.rating import (
NEGATIVE_REACTIONS,
POSITIVE_REACTIONS,
@@ -21,8 +21,8 @@ from resolvers.follower import follow
from resolvers.proposals import handle_proposing
from resolvers.stat import update_author_stat
from services.auth import add_user_role, login_required
from storage.db import local_session
from services.notify import notify_reaction
from storage.db import local_session
from storage.schema import mutation, query
from utils.logger import root_logger as logger

View File

@@ -6,14 +6,14 @@ from sqlalchemy import Select, and_, nulls_last, text
from sqlalchemy.orm import Session, aliased
from sqlalchemy.sql.expression import asc, case, desc, func, select
from auth.orm import Author
from orm.author import Author
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic
from storage.db import json_array_builder, json_builder, local_session
from storage.schema import query
from services.search import SearchService, search_text
from services.viewed import ViewedStorage
from storage.db import json_array_builder, json_builder, local_session
from storage.schema import query
from utils.logger import root_logger as logger

View File

@@ -7,8 +7,8 @@ from sqlalchemy import and_, distinct, func, join, select
from sqlalchemy.orm import aliased
from sqlalchemy.sql.expression import Select
from auth.orm import Author, AuthorFollower
from cache.cache import cache_author
from orm.author import Author, AuthorFollower
from orm.community import Community, CommunityFollower
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic
@@ -81,7 +81,7 @@ def add_author_stat_columns(q: QueryType) -> QueryType:
# Подзапрос для подсчета подписчиков
followers_subq = (
select(func.count(distinct(AuthorFollower.follower)))
.where(AuthorFollower.author == Author.id)
.where(AuthorFollower.following == Author.id)
.scalar_subquery()
)
@@ -241,7 +241,7 @@ def get_author_followers_stat(author_id: int) -> int:
"""
Получает количество подписчиков для указанного автора
"""
q = select(func.count(AuthorFollower.follower)).filter(AuthorFollower.author == author_id)
q = select(func.count(AuthorFollower.follower)).filter(AuthorFollower.following == author_id)
with local_session() as session:
result = session.execute(q).scalar()
@@ -336,7 +336,7 @@ def author_follows_authors(author_id: int) -> list[Any]:
"""
af = aliased(AuthorFollower, name="af")
author_follows_authors_query = (
select(Author).select_from(join(Author, af, Author.id == af.author)).where(af.follower == author_id)
select(Author).select_from(join(Author, af, Author.id == af.following)).where(af.follower == author_id)
)
return get_with_stat(author_follows_authors_query)
@@ -393,7 +393,7 @@ def get_followers_count(entity_type: str, entity_id: int) -> int:
# Count followers of this author
result = (
session.query(func.count(AuthorFollower.follower))
.filter(AuthorFollower.author == entity_id)
.filter(AuthorFollower.following == entity_id)
.scalar()
)
elif entity_type == "community":

View File

@@ -4,7 +4,6 @@ from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy import desc, func, select, text
from auth.orm import Author
from cache.cache import (
cache_topic,
cached_query,
@@ -14,6 +13,7 @@ from cache.cache import (
invalidate_cache_by_prefix,
invalidate_topic_followers_cache,
)
from orm.author import Author
from orm.draft import DraftTopic
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic

View File

@@ -309,8 +309,10 @@ type Permission {
}
type SessionInfo {
token: String!
author: Author!
success: Boolean!
token: String
author: Author
error: String
}
type AuthSuccess {

View File

@@ -10,13 +10,13 @@ from sqlalchemy import String, cast, null, or_
from sqlalchemy.orm import joinedload
from sqlalchemy.sql import func, select
from auth.orm import Author
from orm.author import Author
from orm.community import Community, CommunityAuthor, role_descriptions, role_names
from orm.invite import Invite, InviteStatus
from orm.shout import Shout
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from storage.db import local_session
from storage.env import EnvVariable, env_manager
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from utils.logger import root_logger as logger

View File

@@ -17,11 +17,11 @@ from auth.exceptions import InvalidPasswordError, InvalidTokenError, ObjectNotEx
from auth.identity import Identity
from auth.internal import verify_internal_auth
from auth.jwtcodec import JWTCodec
from auth.orm import Author
from auth.password import Password
from auth.tokens.storage import TokenStorage
from auth.tokens.verification import VerificationTokenManager
from auth.utils import extract_token_from_request
from cache.cache import get_cached_author_by_id
from orm.author import Author
from orm.community import (
Community,
CommunityAuthor,
@@ -29,15 +29,16 @@ from orm.community import (
assign_role_to_user,
get_user_roles_in_community,
)
from storage.db import local_session
from storage.redis import redis
from settings import (
ADMIN_EMAILS,
SESSION_COOKIE_NAME,
SESSION_TOKEN_HEADER,
)
from storage.db import local_session
from storage.redis import redis
from utils.generate_slug import generate_unique_slug
from utils.logger import root_logger as logger
from utils.password import Password
# Список разрешенных заголовков
ALLOWED_HEADERS = ["Authorization", "Content-Type"]
@@ -62,25 +63,12 @@ class AuthService:
logger.debug("[check_auth] Запрос отсутствует (тестовое окружение)")
return 0, [], False
# Проверяем заголовок с учетом регистра
headers_dict = dict(req.headers.items())
logger.debug(f"[check_auth] Все заголовки: {headers_dict}")
# Ищем заголовок Authorization независимо от регистра
for header_name, header_value in headers_dict.items():
if header_name.lower() == SESSION_TOKEN_HEADER.lower():
token = header_value
logger.debug(f"[check_auth] Найден заголовок {header_name}: {token[:10]}...")
break
token = await extract_token_from_request(req)
if not token:
logger.debug("[check_auth] Токен не найден в заголовках")
logger.debug("[check_auth] Токен не найден")
return 0, [], False
# Очищаем токен от префикса Bearer если он есть
if token.startswith("Bearer "):
token = token.split("Bearer ")[-1].strip()
# Проверяем авторизацию внутренним механизмом
logger.debug("[check_auth] Вызов verify_internal_auth...")
user_id, user_roles, is_admin = await verify_internal_auth(token)

View File

@@ -15,7 +15,7 @@ from google.analytics.data_v1beta.types import (
)
from google.analytics.data_v1beta.types import Filter as GAFilter
from auth.orm import Author
from orm.author import Author
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic
from storage.db import local_session

View File

@@ -9,10 +9,8 @@ from ariadne import (
load_schema_from_path,
)
from auth.orm import Author, AuthorBookmark, AuthorFollower, AuthorRating
# Импорт Author, AuthorBookmark, AuthorFollower, AuthorRating отложен для избежания циклических импортов
from orm import collection, community, draft, invite, notification, reaction, shout, topic
from orm.author import Author, AuthorBookmark, AuthorFollower, AuthorRating
from storage.db import create_table_if_not_exists, local_session
# Создаем основные типы

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

View File

@@ -1,7 +1,7 @@
import re
from urllib.parse import quote_plus
from auth.orm import Author
from orm.author import Author
from storage.db import local_session

2
uv.lock generated
View File

@@ -399,7 +399,7 @@ wheels = [
[[package]]
name = "discours-core"
version = "0.9.5"
version = "0.9.7"
source = { editable = "." }
dependencies = [
{ name = "ariadne" },