feature/e2e #4
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -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
|
||||
|
||||
|
||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -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`
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(",")
|
||||
|
||||
@@ -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(",")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
118
auth/utils.py
118
auth/utils.py
@@ -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
6
cache/cache.py
vendored
@@ -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
7
cache/precache.py
vendored
@@ -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
5
cache/triggers.py
vendored
@@ -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")
|
||||
|
||||
@@ -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 серверов"""
|
||||
|
||||
@@ -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+
|
||||
|
||||
1436
docs/auth.md
1436
docs/auth.md
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
- **Автоматизация браузера**: Полноценное тестирование пользовательского интерфейса админ-панели
|
||||
|
||||
@@ -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` работают без изменений
|
||||
- Существующие тесты проходят без модификации
|
||||
|
||||
6
main.py
6
main.py
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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})>"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
20
rbac/api.py
20
rbac/api.py
@@ -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)
|
||||
|
||||
|
||||
# --- Декораторы ---
|
||||
|
||||
@@ -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:
|
||||
"""Проверяет, есть ли у набора ролей конкретное разрешение в сообществе"""
|
||||
...
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -309,8 +309,10 @@ type Permission {
|
||||
}
|
||||
|
||||
type SessionInfo {
|
||||
token: String!
|
||||
author: Author!
|
||||
success: Boolean!
|
||||
token: String
|
||||
author: Author
|
||||
error: String
|
||||
}
|
||||
|
||||
type AuthSuccess {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
# Создаем основные типы
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import pytest
|
||||
from auth.password import Password
|
||||
from utils.password import Password
|
||||
|
||||
def test_password_verify():
|
||||
# Создаем пароль
|
||||
|
||||
@@ -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
|
||||
|
||||
# Создаем тестового пользователя
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
276
tests/test_getSession_cookies.py
Normal file
276
tests/test_getSession_cookies.py
Normal 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"])
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user