feat: зависимости current_user и require(code), репозиторий пользователей, схема me
Пользователь, филиал, роли и права читаются одним запросом; роль is_active=false прав не даёт; email без учёта регистра, неоднозначность — 403 (R-17). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
5ed52a7239
commit
11941caa20
109
api/src/api/dependencies/auth.py
Normal file
109
api/src/api/dependencies/auth.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""Зависимости FastAPI: личность из JWT, пользователь из БД, право `require(code)` (spec authn-authz, D-5, D-10).
|
||||
|
||||
Цепочка: заголовок `Authorization: Bearer` → проверяющий токены (`TokenVerifier`) → ОДИН запрос в БД
|
||||
(`load_principal_data`) → :class:`Principal`. Зависимости кэшируются FastAPI в пределах запроса, поэтому любое число
|
||||
`require(...)` и `current_user` на одном маршруте даёт один запрос к БД.
|
||||
|
||||
Ответы: 401 — нет/невалидный токен (с `WWW-Authenticate: Bearer`); 403 — пользователь не найден, неоднозначен,
|
||||
отключён либо нет права. Тело — `{"detail": "<причина по-русски>"}` (форматирует обработчик `HTTPException`).
|
||||
Права проверяются только по кодам прав: код роли в логике не используется.
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.core.auth import AuthError, Claims, TokenVerifier, build_verifier
|
||||
from src.core.config import get_settings
|
||||
from src.db.db import get_async_session
|
||||
from src.repositories.users import AmbiguousUserError, UserWithAccess, load_principal_data
|
||||
|
||||
# Текущий пользователь вместе с филиалом, ролями и эффективными правами (то, что нужно `GET /me` и `require`)
|
||||
Principal = UserWithAccess
|
||||
|
||||
_BEARER = "Bearer"
|
||||
_UNAUTHORIZED_HEADERS = {"WWW-Authenticate": _BEARER}
|
||||
_MISSING_HEADER = "требуется заголовок Authorization: Bearer <токен>"
|
||||
_BEARER_PARTS = 2
|
||||
|
||||
|
||||
def _unauthorized(detail: str) -> HTTPException:
|
||||
return HTTPException(status.HTTP_401_UNAUTHORIZED, detail=detail, headers=dict(_UNAUTHORIZED_HEADERS))
|
||||
|
||||
|
||||
def _forbidden(detail: str) -> HTTPException:
|
||||
return HTTPException(status.HTTP_403_FORBIDDEN, detail=detail)
|
||||
|
||||
|
||||
def parse_bearer(authorization: str | None) -> str:
|
||||
"""Токен из значения заголовка `Authorization: Bearer <jwt>`; иначе 401 (схема без учёта регистра)."""
|
||||
if authorization is not None:
|
||||
parts = authorization.split()
|
||||
if len(parts) == _BEARER_PARTS and parts[0].lower() == _BEARER.lower():
|
||||
return parts[1]
|
||||
raise _unauthorized(_MISSING_HEADER)
|
||||
|
||||
|
||||
def get_token_verifier(request: Request) -> TokenVerifier | None:
|
||||
"""Проверяющий токены приложения: строится лениво по настройкам и кэшируется в `app.state.token_verifier`.
|
||||
|
||||
`None` — проверять нечем (ничего не настроено). В тестах переопределяется через `app.dependency_overrides`.
|
||||
"""
|
||||
state = request.app.state
|
||||
if not hasattr(state, "token_verifier"):
|
||||
state.token_verifier = build_verifier(get_settings())
|
||||
verifier: TokenVerifier | None = state.token_verifier
|
||||
return verifier
|
||||
|
||||
|
||||
async def verified_claims(
|
||||
verifier: Annotated[TokenVerifier | None, Depends(get_token_verifier)],
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> Claims:
|
||||
"""Claims проверенного токена. Не обращается к БД: все 401 решаются до неё."""
|
||||
token = parse_bearer(authorization)
|
||||
if verifier is None:
|
||||
raise _unauthorized("аутентификация не настроена")
|
||||
try:
|
||||
return await verifier.verify(token)
|
||||
except AuthError as error:
|
||||
raise _unauthorized(str(error)) from error
|
||||
|
||||
|
||||
async def current_user(
|
||||
claims: Annotated[Claims, Depends(verified_claims)],
|
||||
session: Annotated[AsyncSession, Depends(get_async_session)],
|
||||
) -> Principal:
|
||||
"""Пользователь по email из токена с филиалом, ролями и эффективными правами (один запрос к БД).
|
||||
|
||||
`Claims.email` уже нормализован (`strip().lower()`), сравнение в БД — по `lower(app_user.email)` (R-17).
|
||||
403: пользователя нет в АРМ; учётная запись неоднозначна (несколько email, различающихся регистром) или отключена.
|
||||
"""
|
||||
try:
|
||||
principal = await load_principal_data(session, claims.email)
|
||||
except AmbiguousUserError as error:
|
||||
raise _forbidden("неоднозначная учётная запись") from error
|
||||
if principal is None:
|
||||
raise _forbidden("пользователь не зарегистрирован в АРМ")
|
||||
if not principal.user.is_active:
|
||||
raise _forbidden("учётная запись отключена")
|
||||
return principal
|
||||
|
||||
|
||||
def require(code: str) -> Callable[..., Awaitable[Principal]]:
|
||||
"""Фабрика зависимости: пропускает, только если среди эффективных прав есть `code`; иначе 403 с названием права.
|
||||
|
||||
Код права не сверяется с enum на этапе создания (проверяется лишь непустота): права — данные. Роли не
|
||||
используются. Возвращает :class:`Principal`, так что зависимость можно использовать и как `current_user`.
|
||||
"""
|
||||
if not code or not code.strip():
|
||||
raise ValueError("код права для require() не задан")
|
||||
|
||||
async def check(principal: Annotated[Principal, Depends(current_user)]) -> Principal:
|
||||
if code not in principal.permissions:
|
||||
raise _forbidden(f"недостаточно прав: требуется {code}")
|
||||
return principal
|
||||
|
||||
return check
|
||||
73
api/src/repositories/users.py
Normal file
73
api/src/repositories/users.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""Репозиторий пользователей: личность и эффективные права одним запросом (spec authn-authz, D-5, R-17).
|
||||
|
||||
`effective_permissions(user) = ∪ permission.code по всем активным ролям пользователя` (CLAUDE.md: права — данные).
|
||||
Код роли в проверках не участвует: роли возвращаются только для показа (`GET /me`).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from src.models.access import AppUser, Permission, Role, RolePermission, UserRole
|
||||
from src.models.directories import RegionalOffice
|
||||
|
||||
# Достаточно двух строк, чтобы заметить неоднозначность; больше пользователей читать незачем
|
||||
_AMBIGUITY_PROBE_LIMIT = 2
|
||||
|
||||
|
||||
class AmbiguousUserError(Exception):
|
||||
"""По email найдено больше одного пользователя (email в БД уникален с учётом регистра, сравнение — без него)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserWithAccess:
|
||||
"""Пользователь, его филиал, коды активных ролей и эффективные права (коды).
|
||||
|
||||
`roles` — отсортированный кортеж кодов активных ролей; `permissions` — объединение прав этих ролей без дубликатов.
|
||||
"""
|
||||
|
||||
user: AppUser
|
||||
regional_office: RegionalOffice
|
||||
roles: tuple[str, ...]
|
||||
permissions: frozenset[str]
|
||||
|
||||
|
||||
async def load_principal_data(session: AsyncSession, email: str) -> UserWithAccess | None:
|
||||
"""Пользователь по email (без учёта регистра) вместе с филиалом, ролями и правами — ОДИН SQL-запрос.
|
||||
|
||||
Возвращает `None`, если пользователя нет; бросает :class:`AmbiguousUserError`, если подходит больше одного
|
||||
(fail-closed, R-17). Неактивный пользователь возвращается как есть (решение — за вызывающим). Роль с
|
||||
`is_active=false` не даёт прав и в `roles` не попадает.
|
||||
"""
|
||||
matched = (
|
||||
select(AppUser)
|
||||
.where(func.lower(AppUser.email) == email.lower())
|
||||
.order_by(AppUser.id)
|
||||
.limit(_AMBIGUITY_PROBE_LIMIT)
|
||||
.subquery()
|
||||
)
|
||||
user = aliased(AppUser, matched)
|
||||
statement = (
|
||||
select(user, RegionalOffice, Role.code, Permission.code)
|
||||
.join(RegionalOffice, RegionalOffice.id == user.regional_office_id)
|
||||
.outerjoin(UserRole, UserRole.user_id == user.id)
|
||||
.outerjoin(Role, and_(Role.id == UserRole.role_id, Role.is_active.is_(True)))
|
||||
.outerjoin(RolePermission, RolePermission.role_id == Role.id)
|
||||
.outerjoin(Permission, Permission.id == RolePermission.permission_id)
|
||||
)
|
||||
rows = (await session.execute(statement)).all()
|
||||
if not rows:
|
||||
return None
|
||||
users = {row[0].id for row in rows}
|
||||
if len(users) > 1:
|
||||
raise AmbiguousUserError(email)
|
||||
roles = {row[2] for row in rows if row[2] is not None}
|
||||
permissions = frozenset(row[3] for row in rows if row[3] is not None)
|
||||
return UserWithAccess(
|
||||
user=rows[0][0],
|
||||
regional_office=rows[0][1],
|
||||
roles=tuple(sorted(roles)),
|
||||
permissions=permissions,
|
||||
)
|
||||
29
api/src/schemas/me.py
Normal file
29
api/src/schemas/me.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""Схема ответа `GET /api/v1/me` (spec authn-authz): профиль, роли и эффективные права текущего пользователя."""
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from src.models.enums import BusinessSegment
|
||||
|
||||
|
||||
class RegionalOfficeOut(BaseModel):
|
||||
"""Филиал пользователя."""
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
is_head_office: bool
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
"""Текущий пользователь. `roles` и `permissions` — коды, отсортированные по алфавиту."""
|
||||
|
||||
model_config = ConfigDict(use_enum_values=True)
|
||||
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
full_name: str
|
||||
regional_office: RegionalOfficeOut
|
||||
business_segment: BusinessSegment | None
|
||||
roles: list[str]
|
||||
permissions: list[str]
|
||||
479
api/tests/test_auth_deps.py
Normal file
479
api/tests/test_auth_deps.py
Normal file
@ -0,0 +1,479 @@
|
||||
"""Зависимости аутентификации и прав (spec authn-authz, задача 17): `current_user`, `require(code)`, 401/403.
|
||||
|
||||
БД-тесты (`integration`) работают на `db_session` с seed dev-данных; мини-приложение собрано в тесте без lifespan,
|
||||
верификатор токенов подменён на `DevHs256Verifier`. Быстрые тесты (без БД) проверяют разбор заголовка и то, что
|
||||
401-ветки не обращаются к сессии.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Callable, Iterator
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from sqlalchemy import event, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
|
||||
from scripts.seed import run_seed
|
||||
from src.api.dependencies.auth import (
|
||||
Principal,
|
||||
current_user,
|
||||
get_token_verifier,
|
||||
parse_bearer,
|
||||
require,
|
||||
)
|
||||
from src.core.auth import DevHs256Verifier
|
||||
from src.core.exception_handlers import register_exception_handlers
|
||||
from src.db.db import get_async_session
|
||||
from src.models.access import AppUser, Role, UserRole
|
||||
from src.models.directories import RegionalOffice
|
||||
from src.models.enums import BusinessSegment
|
||||
from src.repositories.users import AmbiguousUserError, load_principal_data
|
||||
from src.schemas.me import MeResponse, RegionalOfficeOut
|
||||
|
||||
SECRET = "test-secret-for-auth-deps-0123456789abcdef"
|
||||
OTHER_SECRET = "foreign-secret-for-auth-deps-0123456789ab"
|
||||
SRC = Path(__file__).resolve().parent.parent / "src"
|
||||
SEED_CORE = Path(__file__).resolve().parent.parent / "seed" / "core"
|
||||
|
||||
NOT_REGISTERED = "пользователь не зарегистрирован в АРМ"
|
||||
DISABLED = "учётная запись отключена"
|
||||
AMBIGUOUS = "неоднозначная учётная запись"
|
||||
|
||||
|
||||
class _ForbiddenSession:
|
||||
"""Заглушка сессии для 401-веток: любое обращение — ошибка теста."""
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
raise AssertionError(f"обращение к БД при 401: session.{name}")
|
||||
|
||||
|
||||
def _build_app(session_factory: Callable[[], AsyncIterator[Any]], verifier: DevHs256Verifier | None) -> FastAPI:
|
||||
app = FastAPI()
|
||||
register_exception_handlers(app)
|
||||
|
||||
@app.get("/whoami")
|
||||
async def whoami(principal: Annotated[Principal, Depends(current_user)]) -> dict[str, Any]:
|
||||
return {
|
||||
"email": principal.user.email,
|
||||
"roles": list(principal.roles),
|
||||
"permissions": sorted(principal.permissions),
|
||||
"office": principal.regional_office.name,
|
||||
}
|
||||
|
||||
@app.get("/kfo-verify", dependencies=[Depends(require("KFO_VERIFY"))])
|
||||
async def kfo_verify() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/doc-edit", dependencies=[Depends(require("DOCUMENT_EDIT"))])
|
||||
async def doc_edit() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/both", dependencies=[Depends(require("KFO_VERIFY")), Depends(require("DOCUMENT_EDIT"))])
|
||||
async def both(principal: Annotated[Principal, Depends(current_user)]) -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
app.dependency_overrides[get_async_session] = session_factory
|
||||
app.dependency_overrides[get_token_verifier] = lambda: verifier
|
||||
return app
|
||||
|
||||
|
||||
def _client(app: FastAPI) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test")
|
||||
|
||||
|
||||
def _bearer(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# --- быстрые тесты без БД ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_bearer_ok() -> None:
|
||||
assert parse_bearer("Bearer abc.def.ghi") == "abc.def.ghi"
|
||||
assert parse_bearer("bearer abc") == "abc"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("header", [None, "", "Bearer", "Bearer ", "Bearer ", "Basic abc", "abc.def", "Bearer a b"])
|
||||
def test_parse_bearer_rejects(header: str | None) -> None:
|
||||
with pytest.raises(HTTPException) as info:
|
||||
parse_bearer(header)
|
||||
assert info.value.status_code == 401 # noqa: PLR2004
|
||||
assert info.value.headers == {"WWW-Authenticate": "Bearer"}
|
||||
assert "Authorization: Bearer" in info.value.detail
|
||||
|
||||
|
||||
def test_require_rejects_empty_code() -> None:
|
||||
with pytest.raises(ValueError, match="код права"):
|
||||
require("")
|
||||
with pytest.raises(ValueError, match="код права"):
|
||||
require(" ")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def untouchable_app() -> tuple[FastAPI, list[str]]:
|
||||
"""Приложение с заглушкой сессии; список `touched` пополняется, если зависимость сессии вообще вызвана."""
|
||||
touched: list[str] = []
|
||||
|
||||
async def session() -> AsyncIterator[_ForbiddenSession]:
|
||||
touched.append("session")
|
||||
yield _ForbiddenSession()
|
||||
|
||||
verifier = DevHs256Verifier(SECRET)
|
||||
return _build_app(session, verifier), touched
|
||||
|
||||
|
||||
async def _expect_401(app: FastAPI, headers: dict[str, str]) -> httpx.Response:
|
||||
async with _client(app) as client:
|
||||
response = await client.get("/whoami", headers=headers)
|
||||
assert response.status_code == 401 # noqa: PLR2004
|
||||
assert response.headers["www-authenticate"] == "Bearer"
|
||||
assert isinstance(response.json()["detail"], str)
|
||||
return response
|
||||
|
||||
|
||||
async def test_401_without_header_does_not_touch_db(untouchable_app: tuple[FastAPI, list[str]]) -> None:
|
||||
app, touched = untouchable_app
|
||||
response = await _expect_401(app, {})
|
||||
assert "Authorization: Bearer" in response.json()["detail"]
|
||||
assert touched == []
|
||||
|
||||
|
||||
async def test_401_not_bearer_does_not_touch_db(untouchable_app: tuple[FastAPI, list[str]]) -> None:
|
||||
app, touched = untouchable_app
|
||||
await _expect_401(app, {"Authorization": "Basic dXNlcjpwYXNz"})
|
||||
assert touched == []
|
||||
|
||||
|
||||
async def test_401_garbage_token_does_not_touch_db(untouchable_app: tuple[FastAPI, list[str]]) -> None:
|
||||
app, touched = untouchable_app
|
||||
response = await _expect_401(app, _bearer("not-a-jwt"))
|
||||
assert response.json() == {"detail": "некорректный токен"}
|
||||
assert touched == []
|
||||
|
||||
|
||||
async def test_401_expired_token_does_not_touch_db(untouchable_app: tuple[FastAPI, list[str]]) -> None:
|
||||
app, touched = untouchable_app
|
||||
token = DevHs256Verifier(SECRET).issue_token("ka_go@armka.local", ttl=timedelta(minutes=-5))
|
||||
response = await _expect_401(app, _bearer(token))
|
||||
assert response.json() == {"detail": "срок действия токена истёк"}
|
||||
assert touched == []
|
||||
|
||||
|
||||
async def test_401_foreign_signature_does_not_touch_db(untouchable_app: tuple[FastAPI, list[str]]) -> None:
|
||||
app, touched = untouchable_app
|
||||
token = DevHs256Verifier(OTHER_SECRET).issue_token("ka_go@armka.local")
|
||||
response = await _expect_401(app, _bearer(token))
|
||||
assert response.json() == {"detail": "недействительная подпись токена"}
|
||||
assert touched == []
|
||||
|
||||
|
||||
async def test_401_when_verifier_not_configured() -> None:
|
||||
touched: list[str] = []
|
||||
|
||||
async def session() -> AsyncIterator[_ForbiddenSession]:
|
||||
touched.append("session")
|
||||
yield _ForbiddenSession()
|
||||
|
||||
app = _build_app(session, None)
|
||||
token = DevHs256Verifier(SECRET).issue_token("ka_go@armka.local")
|
||||
response = await _expect_401(app, _bearer(token))
|
||||
assert response.json() == {"detail": "аутентификация не настроена"}
|
||||
assert touched == []
|
||||
|
||||
|
||||
def test_get_token_verifier_builds_lazily_and_caches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Проверяющий строится один раз на приложение (в том числе `None`) и кладётся в `app.state.token_verifier`."""
|
||||
from types import SimpleNamespace # noqa: PLC0415
|
||||
|
||||
calls: list[object] = []
|
||||
sentinel = DevHs256Verifier(SECRET)
|
||||
|
||||
def fake_build(settings: object) -> DevHs256Verifier:
|
||||
calls.append(settings)
|
||||
return sentinel
|
||||
|
||||
monkeypatch.setattr("src.api.dependencies.auth.build_verifier", fake_build)
|
||||
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()))
|
||||
assert get_token_verifier(request) is sentinel # type: ignore[arg-type]
|
||||
assert get_token_verifier(request) is sentinel # type: ignore[arg-type]
|
||||
assert len(calls) == 1
|
||||
assert request.app.state.token_verifier is sentinel
|
||||
|
||||
monkeypatch.setattr("src.api.dependencies.auth.build_verifier", lambda settings: None)
|
||||
request_none = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()))
|
||||
assert get_token_verifier(request_none) is None # type: ignore[arg-type]
|
||||
assert request_none.app.state.token_verifier is None
|
||||
|
||||
|
||||
# --- статическая проверка: код роли в логике не используется ------------------------------------------------------
|
||||
|
||||
|
||||
def test_role_codes_are_not_used_in_auth_logic() -> None:
|
||||
"""В `dependencies/auth.py` и `repositories/users.py` нет строковых констант — кодов ролей (права — данные)."""
|
||||
roles = yaml.safe_load((SEED_CORE / "roles.yaml").read_text(encoding="utf-8"))
|
||||
role_codes = {item["code"] for item in roles}
|
||||
assert {"KA_RF", "KA_GO", "EXPERT"} <= role_codes
|
||||
for relative in ("api/dependencies/auth.py", "repositories/users.py"):
|
||||
tree = ast.parse((SRC / relative).read_text(encoding="utf-8"))
|
||||
literals = {
|
||||
node.value for node in ast.walk(tree) if isinstance(node, ast.Constant) and isinstance(node.value, str)
|
||||
}
|
||||
assert not literals & role_codes, f"{relative}: код роли в логике: {sorted(literals & role_codes)}"
|
||||
|
||||
|
||||
# --- интеграционные тесты на seed -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded(db_session: AsyncSession) -> AsyncSession:
|
||||
await run_seed(db_session, dev=True)
|
||||
await db_session.flush()
|
||||
return db_session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app(seeded: AsyncSession) -> FastAPI:
|
||||
async def session() -> AsyncIterator[AsyncSession]:
|
||||
yield seeded
|
||||
|
||||
return _build_app(session, DevHs256Verifier(SECRET))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token_for() -> Callable[[str], dict[str, str]]:
|
||||
verifier = DevHs256Verifier(SECRET)
|
||||
|
||||
def make(email: str) -> dict[str, str]:
|
||||
return _bearer(verifier.issue_token(email))
|
||||
|
||||
return make
|
||||
|
||||
|
||||
async def _add_user(session: AsyncSession, email: str, *, active: bool = True, roles: tuple[str, ...] = ()) -> AppUser:
|
||||
office_id = (await session.execute(select(RegionalOffice.id).limit(1))).scalar_one()
|
||||
user = AppUser(email=email, full_name="Тест Тестов", regional_office_id=office_id, is_active=active)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
for code in roles:
|
||||
role_id = (await session.execute(select(Role.id).where(Role.code == code))).scalar_one()
|
||||
session.add(UserRole(user_id=user.id, role_id=role_id))
|
||||
await session.flush()
|
||||
return user
|
||||
|
||||
|
||||
def _seed_permissions(*role_codes: str) -> set[str]:
|
||||
matrix = yaml.safe_load((SEED_CORE / "role_permissions.yaml").read_text(encoding="utf-8"))
|
||||
return {code for role in role_codes for code in matrix[role]}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_permission_present(app: FastAPI, token_for: Callable[[str], dict[str, str]]) -> None:
|
||||
"""Сценарий «Право есть»: KA_GO, KFO_VERIFY."""
|
||||
async with _client(app) as client:
|
||||
response = await client.get("/kfo-verify", headers=token_for("ka_go@armka.local"))
|
||||
assert response.status_code == 200 # noqa: PLR2004
|
||||
assert response.json() == {"ok": True}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_permission_absent(app: FastAPI, token_for: Callable[[str], dict[str, str]]) -> None:
|
||||
"""Сценарий «Права нет»: EXPERT, DOCUMENT_EDIT → 403 с названием права."""
|
||||
async with _client(app) as client:
|
||||
response = await client.get("/doc-edit", headers=token_for("expert@armka.local"))
|
||||
assert response.status_code == 403 # noqa: PLR2004
|
||||
assert response.json() == {"detail": "недостаточно прав: требуется DOCUMENT_EDIT"}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_whoami_returns_user_office_roles_and_permissions(
|
||||
app: FastAPI, token_for: Callable[[str], dict[str, str]]
|
||||
) -> None:
|
||||
async with _client(app) as client:
|
||||
response = await client.get("/whoami", headers=token_for("ka_go@armka.local"))
|
||||
assert response.status_code == 200 # noqa: PLR2004
|
||||
body = response.json()
|
||||
assert body["email"] == "ka_go@armka.local"
|
||||
assert body["roles"] == ["KA_GO"]
|
||||
assert body["permissions"] == sorted(_seed_permissions("KA_GO"))
|
||||
assert body["office"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_two_roles_union_without_duplicates(
|
||||
seeded: AsyncSession, app: FastAPI, token_for: Callable[[str], dict[str, str]]
|
||||
) -> None:
|
||||
"""Сценарий «Две роли»: KA_RF + CURATOR_GO → объединение прав без дубликатов."""
|
||||
await _add_user(seeded, "two.roles@armka.local", roles=("KA_RF", "CURATOR_GO"))
|
||||
ka_rf, curator = _seed_permissions("KA_RF"), _seed_permissions("CURATOR_GO")
|
||||
assert ka_rf & curator, "тест осмыслен, только если у ролей есть общие права"
|
||||
async with _client(app) as client:
|
||||
response = await client.get("/whoami", headers=token_for("two.roles@armka.local"))
|
||||
assert response.status_code == 200 # noqa: PLR2004
|
||||
body = response.json()
|
||||
assert body["roles"] == ["CURATOR_GO", "KA_RF"]
|
||||
assert body["permissions"] == sorted(ka_rf | curator)
|
||||
assert len(body["permissions"]) == len(set(body["permissions"]))
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_unknown_user_is_403(app: FastAPI, token_for: Callable[[str], dict[str, str]]) -> None:
|
||||
async with _client(app) as client:
|
||||
response = await client.get("/whoami", headers=token_for("nobody@armka.local"))
|
||||
assert response.status_code == 403 # noqa: PLR2004
|
||||
assert response.json() == {"detail": NOT_REGISTERED}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_inactive_user_is_403(
|
||||
seeded: AsyncSession, app: FastAPI, token_for: Callable[[str], dict[str, str]]
|
||||
) -> None:
|
||||
await _add_user(seeded, "off@armka.local", active=False, roles=("KA_GO",))
|
||||
async with _client(app) as client:
|
||||
response = await client.get("/kfo-verify", headers=token_for("off@armka.local"))
|
||||
assert response.status_code == 403 # noqa: PLR2004
|
||||
assert response.json() == {"detail": DISABLED}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_email_case_variants_are_ambiguous_403(
|
||||
seeded: AsyncSession, app: FastAPI, token_for: Callable[[str], dict[str, str]]
|
||||
) -> None:
|
||||
"""R-17: два пользователя с email, различающимися регистром → 403, а не «первый попавшийся»."""
|
||||
await _add_user(seeded, "Ivan@x.local", roles=("KA_GO",))
|
||||
await _add_user(seeded, "ivan@x.local", roles=("EXPERT",))
|
||||
async with _client(app) as client:
|
||||
response = await client.get("/whoami", headers=token_for("IVAN@x.local"))
|
||||
assert response.status_code == 403 # noqa: PLR2004
|
||||
assert response.json() == {"detail": AMBIGUOUS}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_token_email_case_insensitive(app: FastAPI, token_for: Callable[[str], dict[str, str]]) -> None:
|
||||
async with _client(app) as client:
|
||||
response = await client.get("/kfo-verify", headers=token_for("KA_GO@Armka.Local"))
|
||||
assert response.status_code == 200 # noqa: PLR2004
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_user_email_stored_in_mixed_case_is_found(
|
||||
seeded: AsyncSession, app: FastAPI, token_for: Callable[[str], dict[str, str]]
|
||||
) -> None:
|
||||
await _add_user(seeded, "Mixed.Case@Armka.Local", roles=("KA_GO",))
|
||||
async with _client(app) as client:
|
||||
response = await client.get("/kfo-verify", headers=token_for("mixed.case@armka.local"))
|
||||
assert response.status_code == 200 # noqa: PLR2004
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_inactive_role_grants_nothing(
|
||||
seeded: AsyncSession, app: FastAPI, token_for: Callable[[str], dict[str, str]]
|
||||
) -> None:
|
||||
await _add_user(seeded, "mix@armka.local", roles=("KA_GO", "EXPERT"))
|
||||
await seeded.execute(update(Role).where(Role.code == "KA_GO").values(is_active=False))
|
||||
async with _client(app) as client:
|
||||
verify = await client.get("/kfo-verify", headers=token_for("mix@armka.local"))
|
||||
who = await client.get("/whoami", headers=token_for("mix@armka.local"))
|
||||
assert verify.status_code == 403 # noqa: PLR2004
|
||||
assert who.json()["roles"] == ["EXPERT"]
|
||||
assert who.json()["permissions"] == sorted(_seed_permissions("EXPERT"))
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_user_without_roles_has_no_permissions(
|
||||
seeded: AsyncSession, app: FastAPI, token_for: Callable[[str], dict[str, str]]
|
||||
) -> None:
|
||||
await _add_user(seeded, "norole@armka.local")
|
||||
async with _client(app) as client:
|
||||
who = await client.get("/whoami", headers=token_for("norole@armka.local"))
|
||||
verify = await client.get("/kfo-verify", headers=token_for("norole@armka.local"))
|
||||
assert who.status_code == 200 # noqa: PLR2004
|
||||
assert who.json()["roles"] == []
|
||||
assert who.json()["permissions"] == []
|
||||
assert verify.status_code == 403 # noqa: PLR2004
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sql_log(db_engine: AsyncEngine) -> Iterator[list[str]]:
|
||||
"""Все SQL-выражения, выполненные через движок тестовой БД с момента запроса фикстуры."""
|
||||
statements: list[str] = []
|
||||
|
||||
def record(_conn: Any, _cursor: Any, statement: str, *_rest: Any) -> None:
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(db_engine.sync_engine, "before_cursor_execute", record)
|
||||
try:
|
||||
yield statements
|
||||
finally:
|
||||
event.remove(db_engine.sync_engine, "before_cursor_execute", record)
|
||||
|
||||
|
||||
def _principal_queries(statements: list[str]) -> list[str]:
|
||||
return [s for s in statements if "app_user" in s]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_db_queried_once_per_request(
|
||||
app: FastAPI, token_for: Callable[[str], dict[str, str]], sql_log: list[str]
|
||||
) -> None:
|
||||
"""Два `require` и `current_user` на одном маршруте — один запрос к пользователю и правам."""
|
||||
async with _client(app) as client:
|
||||
single = await client.get("/whoami", headers=token_for("ka_go@armka.local"))
|
||||
assert single.status_code == 200 # noqa: PLR2004
|
||||
queries_single = len(_principal_queries(sql_log))
|
||||
sql_log.clear()
|
||||
combined = await client.get("/both", headers=token_for("ka_go@armka.local"))
|
||||
assert combined.status_code == 200 # noqa: PLR2004
|
||||
assert queries_single == 1
|
||||
assert len(_principal_queries(sql_log)) == 1
|
||||
assert len(sql_log) == 1, sql_log
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_both_requires_checked_on_one_route(app: FastAPI, token_for: Callable[[str], dict[str, str]]) -> None:
|
||||
async with _client(app) as client:
|
||||
response = await client.get("/both", headers=token_for("expert@armka.local"))
|
||||
assert response.status_code == 403 # noqa: PLR2004
|
||||
assert response.json() == {"detail": "недостаточно прав: требуется KFO_VERIFY"}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_repository_load_principal_data(seeded: AsyncSession) -> None:
|
||||
assert await load_principal_data(seeded, "nobody@armka.local") is None
|
||||
data = await load_principal_data(seeded, "ka_go@armka.local")
|
||||
assert data is not None
|
||||
assert data.user.email == "ka_go@armka.local"
|
||||
assert data.roles == ("KA_GO",)
|
||||
assert data.permissions == frozenset(_seed_permissions("KA_GO"))
|
||||
assert data.regional_office.id == data.user.regional_office_id
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_repository_raises_on_ambiguity(seeded: AsyncSession) -> None:
|
||||
await _add_user(seeded, "Dup@armka.local", roles=("EXPERT",))
|
||||
await _add_user(seeded, "dup@armka.local", roles=("KA_GO",))
|
||||
with pytest.raises(AmbiguousUserError):
|
||||
await load_principal_data(seeded, "dup@armka.local")
|
||||
|
||||
|
||||
def test_me_response_schema_serializes_enum_and_null_segment() -> None:
|
||||
office = RegionalOfficeOut(id=uuid.uuid4(), name="ГО", is_head_office=True)
|
||||
fields: dict[str, Any] = {
|
||||
"id": uuid.uuid4(),
|
||||
"email": "a@b.c",
|
||||
"full_name": "А",
|
||||
"regional_office": office,
|
||||
"roles": ["KA_GO"],
|
||||
"permissions": ["KFO_VIEW"],
|
||||
}
|
||||
empty = MeResponse(business_segment=None, **fields)
|
||||
assert empty.model_dump(mode="json")["business_segment"] is None
|
||||
segment = next(iter(BusinessSegment))
|
||||
filled = MeResponse(business_segment=segment, **fields)
|
||||
assert filled.model_dump()["business_segment"] == str(segment)
|
||||
assert type(filled.model_dump()["business_segment"]) is str
|
||||
Loading…
x
Reference in New Issue
Block a user