gendesign/backend/tests/test_rbac.py
bot-backend 7be07efe70
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 15m13s
feat(sitefinder): «Птица» принимает сессию общего реестра [PR-2c/6]
Дефолт AUTH_MODE=legacy — сегодняшнее поведение байт-в-байт: соединение с БД
auth не открывается, кука не читается, отсутствие настроек не роняет старт.
Popup Caddy стоит и снимается последним PR эпика — инвариант «гейт уходит
последним» не нарушен.

У Site Finder не было авторизации вообще: rbac_guard доверял заголовку
X-Authenticated-User от Caddy. Теперь он умеет резолвить сессионную куку
общего реестра. ВЫДАВАТЬ сессии «Птица» не будет — логин один, у «Меры», а
кука host-only на gendsgn.ru с path=/ и так долетает до обоих продуктов.
Меньше кода и меньше мест, где можно ошибиться.

Режим трёхзначный, а не булев: legacy | dual | db_only. Это прямое следствие
ревью. При булевом флаге фолбэк «сессия не нашлась → верим заголовку» после
снятия popup превращался бы в полный обход аутентификации, и ничто в коде не
заставило бы про него вспомнить. В db_only легаси-ветка недостижима: 401.

Резолв уехал в threadpool. Три независимых ревьюера нашли одно и то же:
sync-запрос к БД в async-guard блокирует event loop на каждом non-public
запросе — ровно инцидент #1202, который в этом же файле уже лечили. Кука
разбирается на loop'е, в поток уезжает только строка токена; запрос без куки
не платит ни за поток, ни за коннект.

Срок годности сессии считают часы БД, а не приложения. Раньше проверка шла в
Python, а sliding-refresh переписывал строку через now() базы — при
расхождении часов истёкшая сессия не просто проходила, а продлевалась заново,
то есть воскресала навсегда. Теперь `expires_at > now()` в самом SELECT;
питоновская проверка оставлена вторым поясом.

Fail-fast на старте проверяет не синтаксис DSN, а живое соединение: SELECT 1.
Иначе неверный пароль или хост выглядели бы как «ни у кого нет сессии» —
сутками, потому что ошибку ловил бы except в guard'е.

Ещё из ревью: connect_timeout и statement_timeout по 3с (недоступный хост
вешал коннект на минуты); throttling логов сбоя реестра (иначе шторм в
GlitchTip на каждый запрос); тела SQL закреплены ассертами формы — мутация
любого фрагмента теперь красит тесты, до этого не красила ничего.

Дефолт хоста БД — postgres, и это зеркально «Мере». У неё gendesign-postgres,
потому что внутри её стека `postgres` — чужой контейнер; здесь стек главный, и
`postgres` из корневого compose и есть нужный сервер. Алиас gendesign-postgres
дефолтом был бы багом: контейнер beat состоит только в сети default и это имя
из него не разрезолвится.

Сверка реестра с ролевой картой сделана на живом проде: все 13 юзеров
auth.users присутствуют в auth/roles.yaml, ни один не получит 403 на всё.
Четыре QA-фикстуры (admintest, pilottest, analysttest, expiredtest) есть в
yaml, но не в реестре — после снятия popup войти ими через браузер будет
нельзя, только внутрисетевым заголовком.

Осознанный долг, вписан ⚠️-блоком перед guard'ом: paths/deny из roles.yaml
бэкендом не применяются (их энфорсит фронтовый RouteGuard), guard проверяет
только известность username и admin-пути. Это предсуществующее поведение;
менять его здесь значило бы изменить и легаси-ветку, то есть нарушить
«дефолт = сегодня».

Тесты: 4594 passed, 0 failed. Главный — подделка: валидная кука плюс
присланный клиентом X-Authenticated-User другого пользователя, выигрывает
кука, и роут, читающий заголовок напрямую, видит владельца куки. У «Птицы»
таких прямых читателей одиннадцать, поэтому перезапись ASGI-scope обязана быть
полной, а не skip-if-present (CRITICAL #2552).
2026-08-02 17:00:54 +03:00

395 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""RBAC unit + integration tests.
Coverage:
- get_role / get_user_scope happy + error paths
- is_path_allowed glob semantics (admin everywhere, pilot blocked from /admin/**,
analyst sees /** except admin/data-management — #962)
- GET /me — header missing / unknown user / pilot / admin
- rbac_guard middleware:
* любой non-public path требует X-Authenticated-User (no-header → 401)
* неизвестный юзер → 403 на ВСЁ (включая non-admin paths) —
«человек без ролей вообще ничего не видит» (decided 2026-05-25)
* /api/v1/admin/* — только role=admin (pilot/analyst → 403; #962)
* public /health пропускаем без проверки
Тесты используют изолированный FastAPI app (см. _build_test_app), чтобы не
тянуть тяжёлые модули из app.main (weasyprint требует gobject который не
ставится на macOS dev-машинах из коробки). RBAC middleware и /me router
импортируются напрямую — это даёт нам ровно тот же поведение что в проде.
"""
from __future__ import annotations
import re
from collections.abc import Awaitable, Callable
import pytest
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, Response
from fastapi.testclient import TestClient
from app.api.v1 import me as me_router
from app.core import auth as auth_mod
@pytest.fixture(autouse=True)
def _reset_auth_cache() -> None:
"""Каждый тест получает свежий YAML-кэш."""
auth_mod.reset_cache_for_tests()
# ---------------------------------------------------------------------------
# Test app — копия rbac_guard из app/main.py, чтобы не подтягивать тяжёлые
# импорты (weasyprint, celery worker, ...). Если поведение middleware меняется
# в проде — синхронизируй здесь.
#
# NB: копия воспроизводит ЛЕГАСИ-ВЕТКУ принятия решения (trusted-header) и намеренно
# не знает про сессионную куку общего реестра, добавленную эпиком «единый вход»:
# при AUTH_MODE=legacy (дефолт) прод-guard принимает решение ровно так же, и тесты
# ниже проверяют именно тот режим. Режимы dual/db_only (кука → заголовок, приоритет
# куки над подделанным заголовком, 401/403, публичные пути) покрыты в
# tests/test_auth_session_guard.py — там вызывается НАСТОЯЩИЙ app.main.rbac_guard,
# без копии.
#
# «Ровно так же» — про ЛОГИКУ, не про списки: `_PUBLIC_PATHS` ниже держится
# синхронным с прод-версией руками (расхождение уже случалось — в копии не было
# /api/v1/ping), и никакой механики, которая бы это гарантировала, нет. Прод-список
# параметризован в test_auth_session_guard.py, поэтому его расширение хотя бы там
# проверяется автоматически.
# ---------------------------------------------------------------------------
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
# Синхронно с app.main._PUBLIC_PATHS (там же и /api/v1/ping — он был потерян здесь).
_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"})
def _build_test_app() -> FastAPI:
app = FastAPI()
@app.middleware("http")
async def rbac_guard(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
path = request.url.path
if path in _PUBLIC_PATHS:
return await call_next(request)
username = request.headers.get("X-Authenticated-User")
if not username:
return JSONResponse(
status_code=401,
content={"detail": "no authenticated user (Caddy basic_auth required)"},
)
try:
role = auth_mod.get_role(username)
except KeyError:
return JSONResponse(
status_code=403,
content={"detail": "user not in roles config"},
)
if _ADMIN_API_RE.match(path) and role != "admin":
return JSONResponse(
status_code=403,
content={"detail": "admin only"},
)
return await call_next(request)
app.include_router(me_router.router, prefix="/api/v1", tags=["me"])
# Реальный admin endpoint, который должен быть заблокирован middleware'ом.
@app.get("/api/v1/admin/dummy")
async def admin_dummy() -> dict:
return {"ok": True}
# Non-admin /api/v1/* endpoint — analyst/pilot/admin должны доходить до него.
@app.get("/api/v1/parcels/dummy")
async def parcels_dummy() -> dict:
return {"ok": True}
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
return app
@pytest.fixture
def client() -> TestClient:
return TestClient(_build_test_app())
# ---------------------------------------------------------------------------
# get_role
# ---------------------------------------------------------------------------
# Пилотные логины user1..user10 в auth/roles.yaml. user2 — «Брусника»: доступ
# закрыт владельцем продукта 2026-07-30, роль переведена pilot → expired. Это
# ЕДИНСТВЕННОЕ отклонение от «все userN = pilot», и оно ожидаемое; хардкод
# именно здесь, отдельной константой, а не магическим `if` в цикле.
_EXPIRED_PILOT_LOGINS = {"user2": "«Брусника», доступ закрыт 2026-07-30"}
def test_get_role_known_users() -> None:
"""Ловит рассинхрон auth/roles.yaml с ожиданиями теста: roles.yaml лежит вне
`backend/**`, поэтому правка ролей не попадает в paths-filter CI и такой
рассинхрон CI молча пропускает (так и случилось с user2 → expired)."""
assert auth_mod.get_role("admin") == "admin"
assert auth_mod.get_role("kopylov") == "pilot"
for n in range(1, 11):
login = f"user{n}"
expected = "expired" if login in _EXPIRED_PILOT_LOGINS else "pilot"
why = _EXPIRED_PILOT_LOGINS.get(login, "обычный пилотный логин")
assert auth_mod.get_role(login) == expected, f"{login}: ожидали {expected}{why}"
def test_get_role_unknown_user_raises() -> None:
with pytest.raises(KeyError):
auth_mod.get_role("nobody")
# ---------------------------------------------------------------------------
# is_path_allowed
# ---------------------------------------------------------------------------
def test_is_path_allowed_admin_everywhere() -> None:
assert auth_mod.is_path_allowed("admin", "/")
assert auth_mod.is_path_allowed("admin", "/admin")
assert auth_mod.is_path_allowed("admin", "/admin/scrape/runs")
assert auth_mod.is_path_allowed("admin", "/api/v1/admin/scrape/status")
assert auth_mod.is_path_allowed("admin", "/trade-in/api/v1/admin/scrape")
assert auth_mod.is_path_allowed("admin", "/concept/123")
assert auth_mod.is_path_allowed("admin", "/analytics/dashboard")
def test_is_path_allowed_pilot_only_tradein() -> None:
"""Pilot имеет доступ ТОЛЬКО к /trade-in/** (decision 2026-05-26).
Landing, analytics, site-finder, concept, остальной /api/v1/* — закрыты."""
# Pilot ALLOWED — только trade-in
assert auth_mod.is_path_allowed("pilot", "/trade-in")
assert auth_mod.is_path_allowed("pilot", "/trade-in/")
assert auth_mod.is_path_allowed("pilot", "/trade-in/123")
assert auth_mod.is_path_allowed("pilot", "/trade-in/api/v1/search")
assert auth_mod.is_path_allowed("pilot", "/trade-in/api/v1/me")
# Pilot DENIED — landing + остальные разделы (admin-only)
assert not auth_mod.is_path_allowed("pilot", "/")
assert not auth_mod.is_path_allowed("pilot", "/analytics/dashboard")
assert not auth_mod.is_path_allowed("pilot", "/site-finder/123")
assert not auth_mod.is_path_allowed("pilot", "/concept/abc")
assert not auth_mod.is_path_allowed("pilot", "/api/v1/parcels/123")
assert not auth_mod.is_path_allowed("pilot", "/api/v1/analytics/dashboard")
# Pilot DENIED — admin paths (explicit deny + not in allowed)
assert not auth_mod.is_path_allowed("pilot", "/admin")
assert not auth_mod.is_path_allowed("pilot", "/admin/jobs")
assert not auth_mod.is_path_allowed("pilot", "/admin/scrape/runs/42")
assert not auth_mod.is_path_allowed("pilot", "/api/v1/admin/scrape/status")
assert not auth_mod.is_path_allowed("pilot", "/api/v1/admin/jobs")
assert not auth_mod.is_path_allowed("pilot", "/trade-in/api/v1/admin/scrape")
def test_is_path_allowed_analyst_everything_except_admin() -> None:
"""Analyst (#962) видит ВСЁ кроме admin/data-management.
paths=/** ; deny=/admin/**, /api/v1/admin/**, /trade-in/api/v1/admin/**."""
# Analyst ALLOWED — данные, аналитика, site-finder, concept, trade-in
assert auth_mod.is_path_allowed("analyst", "/")
assert auth_mod.is_path_allowed("analyst", "/analytics/dashboard")
assert auth_mod.is_path_allowed("analyst", "/site-finder/123")
assert auth_mod.is_path_allowed("analyst", "/concept/abc")
assert auth_mod.is_path_allowed("analyst", "/trade-in/123")
assert auth_mod.is_path_allowed("analyst", "/api/v1/parcels/66:41:0204016:10/analyze")
assert auth_mod.is_path_allowed("analyst", "/api/v1/parcels/66:41:0204016:10/forecast")
assert auth_mod.is_path_allowed("analyst", "/api/v1/analytics/dashboard")
# Analyst DENIED — admin/data-management (scraper, sync, user/role mgmt)
assert not auth_mod.is_path_allowed("analyst", "/admin")
assert not auth_mod.is_path_allowed("analyst", "/admin/jobs")
assert not auth_mod.is_path_allowed("analyst", "/admin/scrape/runs/42")
assert not auth_mod.is_path_allowed("analyst", "/api/v1/admin/jobs/settings")
assert not auth_mod.is_path_allowed("analyst", "/api/v1/admin/scrape/status")
assert not auth_mod.is_path_allowed("analyst", "/trade-in/api/v1/admin/scrape")
def test_is_path_allowed_unknown_role_denied() -> None:
assert not auth_mod.is_path_allowed("ghost", "/")
assert not auth_mod.is_path_allowed("ghost", "/admin")
# ---------------------------------------------------------------------------
# get_user_scope
# ---------------------------------------------------------------------------
def test_get_user_scope_admin() -> None:
scope = auth_mod.get_user_scope("admin")
assert scope["username"] == "admin"
assert scope["role"] == "admin"
assert "/**" in scope["allowed_paths"]
assert scope["deny_paths"] == []
def test_get_user_scope_pilot() -> None:
scope = auth_mod.get_user_scope("kopylov")
assert scope["username"] == "kopylov"
assert scope["role"] == "pilot"
# Pilot allowed только /trade-in/** (decision 2026-05-26).
assert "/trade-in/**" in scope["allowed_paths"]
assert "/trade-in/api/v1/**" in scope["allowed_paths"]
assert "/" not in scope["allowed_paths"]
assert "/analytics/**" not in scope["allowed_paths"]
assert "/admin/**" in scope["deny_paths"]
assert "/api/v1/admin/**" in scope["deny_paths"]
def test_get_user_scope_analyst() -> None:
"""analysttest (#962) → analyst scope: allowed /** + admin deny_paths."""
scope = auth_mod.get_user_scope("analysttest")
assert scope["username"] == "analysttest"
assert scope["role"] == "analyst"
assert "/**" in scope["allowed_paths"]
# deny_paths драйвит фронтовый RouteGuard для /admin/** страниц.
assert "/admin/**" in scope["deny_paths"]
assert "/api/v1/admin/**" in scope["deny_paths"]
assert "/trade-in/api/v1/admin/**" in scope["deny_paths"]
def test_get_user_scope_unknown_raises() -> None:
with pytest.raises(KeyError):
auth_mod.get_user_scope("nobody")
# ---------------------------------------------------------------------------
# GET /me
# ---------------------------------------------------------------------------
def test_me_endpoint_admin(client: TestClient) -> None:
resp = client.get("/api/v1/me", headers={"X-Authenticated-User": "admin"})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["username"] == "admin"
assert body["role"] == "admin"
assert body["allowed_paths"] == ["/**"]
def test_me_endpoint_pilot(client: TestClient) -> None:
resp = client.get("/api/v1/me", headers={"X-Authenticated-User": "kopylov"})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["username"] == "kopylov"
assert body["role"] == "pilot"
assert "/admin/**" in body["deny_paths"]
def test_me_endpoint_no_header_401(client: TestClient) -> None:
resp = client.get("/api/v1/me")
assert resp.status_code == 401
assert "no authenticated user" in resp.json()["detail"].lower()
def test_me_endpoint_unknown_user_403(client: TestClient) -> None:
resp = client.get("/api/v1/me", headers={"X-Authenticated-User": "ghost"})
assert resp.status_code == 403
assert "not in roles config" in resp.json()["detail"].lower()
# ---------------------------------------------------------------------------
# rbac_guard middleware
# ---------------------------------------------------------------------------
def test_rbac_guard_pilot_blocked_on_admin_api(client: TestClient) -> None:
"""Pilot, ходящий в /api/v1/admin/* — должен получить 403, даже если
target endpoint существует."""
resp = client.get(
"/api/v1/admin/dummy",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 403
assert resp.json()["detail"] == "admin only"
def test_rbac_guard_admin_allowed(client: TestClient) -> None:
"""Admin доходит до handler'а — 200 от admin_dummy."""
resp = client.get(
"/api/v1/admin/dummy",
headers={"X-Authenticated-User": "admin"},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_rbac_guard_analyst_blocked_on_admin_api(client: TestClient) -> None:
"""#962 backend-hard: existing rbac_guard АВТО-блокирует analyst на
/api/v1/admin/* (role != admin) — без доп. кода, просто потому что роль
добавлена. Подтверждаем что 403 'admin only'."""
resp = client.get(
"/api/v1/admin/dummy",
headers={"X-Authenticated-User": "analysttest"},
)
assert resp.status_code == 403
assert resp.json()["detail"] == "admin only"
def test_rbac_guard_analyst_allowed_on_non_admin_api(client: TestClient) -> None:
"""Analyst свободно ходит в обычные /api/v1/* (не admin) — 200."""
resp = client.get(
"/api/v1/parcels/dummy",
headers={"X-Authenticated-User": "analysttest"},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_rbac_guard_no_header_on_admin_path_returns_401(client: TestClient) -> None:
"""Локальный curl без Caddy → /api/v1/admin/* отдаёт 401."""
resp = client.get("/api/v1/admin/dummy")
assert resp.status_code == 401
def test_rbac_guard_unknown_user_on_admin_path_returns_403(client: TestClient) -> None:
resp = client.get(
"/api/v1/admin/dummy",
headers={"X-Authenticated-User": "ghost"},
)
assert resp.status_code == 403
assert "not in roles config" in resp.json()["detail"].lower()
def test_rbac_guard_skips_health(client: TestClient) -> None:
"""/health публичный — middleware не должен потребовать заголовок."""
resp = client.get("/health")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
def test_rbac_guard_pilot_can_hit_non_admin_api(client: TestClient) -> None:
"""Pilot должен спокойно ходить в обычные /api/v1/* (не admin)."""
resp = client.get(
"/api/v1/me",
headers={"X-Authenticated-User": "user1"},
)
assert resp.status_code == 200
assert resp.json()["role"] == "pilot"
def test_rbac_guard_unknown_user_blocked_on_non_admin_path(client: TestClient) -> None:
"""Неизвестный юзер (не в roles.yaml) → 403 даже на non-admin path.
«Человек без ролей вообще ничего не видит» (decided 2026-05-25)."""
resp = client.get(
"/api/v1/me",
headers={"X-Authenticated-User": "ghost"},
)
assert resp.status_code == 403
assert "not in roles config" in resp.json()["detail"].lower()
def test_rbac_guard_no_header_on_non_admin_path_returns_401(client: TestClient) -> None:
"""Любой non-public path без X-Authenticated-User → 401 (не только admin)."""
resp = client.get("/api/v1/me")
assert resp.status_code == 401
assert "no authenticated user" in resp.json()["detail"].lower()