All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Successful in 1m56s
CI / frontend-tests (pull_request) Successful in 2m14s
CI Trade-In / backend-tests (pull_request) Successful in 3m1s
CI / openapi-codegen-check (pull_request) Successful in 2m15s
CI / backend-tests (pull_request) Successful in 15m48s
Две связанные вещи, обе — по решению владельца продукта. 1. auth/** в paths-фильтры обоих CI (ci.yml, ci-tradein.yml). auth/roles.yaml — общий RBAC-конфиг двух стеков, но лежит в корне репы и не попадал НИ В ОДИН фильтр: правка ролей/пользователей не запускала ни backend-, ни tradein-сьют. Так 2026-07-30 в main уехал красный test_get_role_known_users (user2 переведён в expired, тест ждал pilot) — обнаружен только вручную и починен в PR #2587. Теперь правка roles.yaml гоняет оба гейта. 2. «Поиск домов» (/trade-in/sale-share) — ТЕСТОВЫЙ продукт, доступ только у админа. Раньше он был закрыт от клиентских ролей (employee/manager/pilot), но оставался открыт внутренней роли analyst. «Только у админа» включает и внутренние роли → analyst добавлен в deny по sale-share. Асимметрия с «Кэшем» намеренная и запиннена тестом: Кэш — не продукт, а диагностика кэшей/скраперов, т.е. ровно тот инструмент, ради которого роль analyst заведена; ему он оставлен. Замеры после правки (реальный is_path_allowed поверх roles.yaml): роль | Поиск домов | Кэш | ядро продукта admin | True | True | True analyst | False | True | True pilot | False | False| True Тест test_yaml_roles_deliberately_outside_client_deny переписан: пиннит ОБЕ стороны асимметрии, а не только «analyst видит всё». Набор внутренних путей разрезан на _SALE_SHARE_PATHS / _CACHE_TOOL_PATHS с assert'ом, что разрез покрывает исходный набор целиком — иначе новый путь добавят и забудут отнести к продукту, оставив analyst непроверенным. Заодно поправлены устаревшие комментарии «Доступ: pilot + admin» в Caddyfile (vanity-редирект gendsgn.ru/sale-share) и в докстринге самой страницы. Тесты: 77 passed (tradein rbac/auth_session/auth_api) + 24 passed (site-finder). tsc --noEmit + next build — зелёные. YAML обоих workflow провалидирован.
532 lines
25 KiB
Python
532 lines
25 KiB
Python
"""RBAC unit + integration tests for tradein backend.
|
||
|
||
Coverage:
|
||
- get_role / get_user_scope happy + error paths
|
||
- is_path_allowed glob semantics (admin everywhere, pilot blocked from /admin/**)
|
||
- 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 → 403)
|
||
|
||
Тесты используют изолированный FastAPI app (см. _build_test_app), чтобы не
|
||
тянуть тяжёлые модули из app.main (lifespan task + DB session + scheduler).
|
||
Guard больше НЕ мокается/копируется вручную — импортируется тот же
|
||
``app.core.rbac.rbac_guard``, что регистрирует app/main.py в проде. Раньше
|
||
здесь была hand-maintained "MIRROR of app.main" копия, которая незаметно
|
||
отстала (не имела #2213 X-Internal-Auth-Secret check) — правки реального
|
||
guard'а тесты бы не заметили. См. app/core/rbac.py.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
# Settings (app.core.config, импортируемый через app.core.rbac) требует
|
||
# DATABASE_URL на конструирование — stub перед любым app-импортом (тот же
|
||
# паттерн что и в остальных tests/*.py).
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.api.v1 import me as me_router
|
||
from app.core import auth as auth_mod
|
||
from app.core.rbac import _db_role_path_allowed, rbac_guard
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_auth_cache() -> None:
|
||
auth_mod.reset_cache_for_tests()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Test app — использует РЕАЛЬНЫЙ rbac_guard (app/core/rbac.py), а не копию.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _build_test_app() -> FastAPI:
|
||
app = FastAPI()
|
||
app.middleware("http")(rbac_guard)
|
||
|
||
app.include_router(me_router.router, prefix="/api/v1", tags=["me"])
|
||
|
||
@app.get("/api/v1/admin/dummy")
|
||
async def admin_dummy() -> dict:
|
||
return {"ok": True}
|
||
|
||
@app.get("/api/v1/search")
|
||
async def search_dummy() -> dict:
|
||
return {"ok": True}
|
||
|
||
@app.get("/api/v1/trade-in/dummy")
|
||
async def tradein_dummy() -> dict:
|
||
return {"ok": True}
|
||
|
||
@app.get("/api/v1/brand/dummy")
|
||
async def brand_dummy() -> dict:
|
||
return {"ok": True}
|
||
|
||
# Внутренние инструменты, закрытые от клиентских ролей 2026-07-31
|
||
# (см. _INTERNAL_TOOL_PATHS ниже): API «Доли в продаже» и «Кэша».
|
||
@app.get("/api/v1/buildings/sale-share")
|
||
async def buildings_sale_share() -> dict:
|
||
return {"ok": True}
|
||
|
||
@app.get("/api/v1/trade-in/cache-stats")
|
||
async def tradein_cache_stats() -> dict:
|
||
return {"ok": True}
|
||
|
||
@app.get("/health")
|
||
async def health() -> dict:
|
||
return {"status": "ok"}
|
||
|
||
# Анонимная поддержка (инцидент 2026-07-31) — публичная ветка рядом с
|
||
# авторизованной, чтобы тесты ниже проверяли ИМЕННО границу между ними.
|
||
@app.get("/api/v1/trade-in/support/anon/unread")
|
||
async def anon_support_unread() -> dict:
|
||
return {"unread": 0}
|
||
|
||
@app.get("/api/v1/trade-in/support/unread")
|
||
async def support_unread() -> dict:
|
||
return {"unread": 0}
|
||
|
||
return app
|
||
|
||
|
||
@pytest.fixture
|
||
def client() -> TestClient:
|
||
return TestClient(_build_test_app())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# get_role
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_get_role_known_users() -> None:
|
||
assert auth_mod.get_role("admin") == "admin"
|
||
assert auth_mod.get_role("kopylov") == "pilot"
|
||
for n in range(1, 11):
|
||
# user2 («Брусника») — доступ закрыт 2026-07-30 (#2548)
|
||
expected = "expired" if n == 2 else "pilot"
|
||
assert auth_mod.get_role(f"user{n}") == expected
|
||
|
||
|
||
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)."""
|
||
# 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")
|
||
|
||
# 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")
|
||
|
||
# Pilot DENIED — admin paths
|
||
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_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_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:
|
||
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:
|
||
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_no_header_on_admin_path_returns_401(client: TestClient) -> None:
|
||
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:
|
||
resp = client.get("/health")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["status"] == "ok"
|
||
|
||
|
||
def test_rbac_guard_lets_anon_support_through_without_identity(client: TestClient) -> None:
|
||
"""Инцидент 2026-07-31: поддержка должна работать БЕЗ входа — иначе тот, кто
|
||
не может залогиниться, не может и пожаловаться на это."""
|
||
resp = client.get("/api/v1/trade-in/support/anon/unread")
|
||
assert resp.status_code == 200
|
||
assert resp.json() == {"unread": 0}
|
||
|
||
|
||
def test_rbac_guard_still_gates_authenticated_support(client: TestClient) -> None:
|
||
"""Обратная сторона той же границы: анонимная ветка НЕ распахнула соседний
|
||
авторизованный support (тред залогиненного юзера по-прежнему за identity)."""
|
||
assert client.get("/api/v1/trade-in/support/unread").status_code == 401
|
||
resp = client.get(
|
||
"/api/v1/trade-in/support/unread", headers={"X-Authenticated-User": "nosuchuser"}
|
||
)
|
||
assert resp.status_code == 403
|
||
|
||
|
||
def test_rbac_guard_pilot_can_hit_non_admin_api(client: TestClient) -> None:
|
||
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:
|
||
"""Неизвестный юзер → 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()
|
||
|
||
|
||
# --- #R2-H3: roles.yaml scope enforced on ALL non-admin paths (not just /admin/*) ---
|
||
|
||
|
||
def test_rbac_expired_denied_on_non_admin_api(client: TestClient) -> None:
|
||
"""#R2-H3: revoked (role=expired, paths:[] deny:/**) НЕ достаёт non-admin API.
|
||
Раньше rbac_guard гейтил только /admin/* → expired имел полный non-admin доступ
|
||
(POST /search экспорт листингов и т.д.). Теперь scope-blocked → 403.
|
||
|
||
Fixture-юзер: expiredtest (temp QA, roles.yaml) — praktika перестал быть
|
||
expired-фикстурой 2026-07-27 (доступ восстановлен, роль теперь pilot)."""
|
||
for user in ("expiredtest",):
|
||
for path in ("/api/v1/search", "/api/v1/trade-in/dummy"):
|
||
resp = client.get(path, headers={"X-Authenticated-User": user})
|
||
assert resp.status_code == 403, f"{user} {path}: {resp.status_code}"
|
||
assert "forbidden for role" in resp.json()["detail"].lower()
|
||
|
||
|
||
def test_rbac_expired_allowed_on_bootstrap(client: TestClient) -> None:
|
||
"""expired ДОЛЖЕН достучаться до /me (получить role=expired → trial-экран) и
|
||
/brand/* (брендинг trial-экрана) — иначе UX сломан. Bootstrap-исключение.
|
||
|
||
Fixture-юзер: expiredtest (см. test_rbac_expired_denied_on_non_admin_api)."""
|
||
resp_me = client.get("/api/v1/me", headers={"X-Authenticated-User": "expiredtest"})
|
||
assert resp_me.status_code == 200, resp_me.text
|
||
assert resp_me.json()["role"] == "expired"
|
||
resp_brand = client.get("/api/v1/brand/dummy", headers={"X-Authenticated-User": "expiredtest"})
|
||
assert resp_brand.status_code == 200, resp_brand.text
|
||
|
||
|
||
def test_rbac_pilot_allowed_on_tradein_api(client: TestClient) -> None:
|
||
"""pilot по roles.yaml имеет /trade-in/api/v1/** → доступ к trade-in/search сохраняется."""
|
||
for path in ("/api/v1/search", "/api/v1/trade-in/dummy"):
|
||
resp = client.get(path, headers={"X-Authenticated-User": "kopylov"})
|
||
assert resp.status_code == 200, f"pilot {path}: {resp.text}"
|
||
|
||
|
||
def test_rbac_admin_allowed_everywhere(client: TestClient) -> None:
|
||
for path in ("/api/v1/search", "/api/v1/trade-in/dummy", "/api/v1/admin/dummy"):
|
||
resp = client.get(path, headers={"X-Authenticated-User": "admin"})
|
||
assert resp.status_code == 200, f"admin {path}: {resp.text}"
|
||
|
||
|
||
def test_rbac_analyst_allowed_non_admin_denied_admin(client: TestClient) -> None:
|
||
ok = client.get("/api/v1/search", headers={"X-Authenticated-User": "analysttest"})
|
||
assert ok.status_code == 200, ok.text
|
||
denied = client.get("/api/v1/admin/dummy", headers={"X-Authenticated-User": "analysttest"})
|
||
assert denied.status_code == 403
|
||
|
||
|
||
def test_rbac_guard_no_header_on_non_admin_path_returns_401(client: TestClient) -> None:
|
||
"""Любой non-public path без X-Authenticated-User → 401."""
|
||
resp = client.get("/api/v1/me")
|
||
assert resp.status_code == 401
|
||
assert "no authenticated user" in resp.json()["detail"].lower()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2026-07-31: внутренние разделы («Доля в продаже» / «Кэш») закрыты от клиентов
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Решение владельца продукта: оба раздела — внутренние инструменты (аналитика
|
||
# рынка / состояние кэшей и скраперов), клиентские аккаунты их видеть не должны
|
||
# (триггер — praktika, DB-роль manager, у которого оба пункта висели в топбаре).
|
||
# Deny заведён в DB_ROLE_PATHS (employee/manager) и зеркально в pilot.deny
|
||
# (auth/roles.yaml) — страницы И их API, чтобы гейт сработал сразу в трёх местах:
|
||
# пункт меню (Topbar через /me), страница (RouteGuard), ручки (rbac_guard).
|
||
|
||
# Внешние пути (как их видит RBAC-конфиг): 2 страницы + все API раздела.
|
||
# Проверяются матчерами напрямую — регистрировать их в тестовом app не нужно.
|
||
_INTERNAL_TOOL_PATHS = (
|
||
"/trade-in/sale-share",
|
||
"/trade-in/cache",
|
||
"/trade-in/api/v1/buildings/sale-share",
|
||
# Остальные ручки роутера buildings.py — глоб '/…/buildings/**' обязан
|
||
# покрывать и их, включая параметризованную (самый вероятный кандидат на
|
||
# переезд под другой префикс — тогда этот тест упадёт, а не промолчит).
|
||
"/trade-in/api/v1/buildings/sale-share/summary",
|
||
"/trade-in/api/v1/buildings/123/listings",
|
||
"/trade-in/api/v1/trade-in/cache-stats",
|
||
# Трейлинг-слэш: точный паттерн его НЕ ловил (allowed=True), защита висела
|
||
# на Starlette redirect_slashes — поэтому deny переведён на глоб-форму.
|
||
"/trade-in/api/v1/trade-in/cache-stats/",
|
||
)
|
||
|
||
# Разрез тех же путей по ПРОДУКТАМ — нужен для ролей, у которых доступ
|
||
# асимметричен (см. test_yaml_roles_deliberately_outside_client_deny).
|
||
# «Поиск домов» — тестовый продукт, admin-only (решение владельца 2026-07-31).
|
||
_SALE_SHARE_PATHS = (
|
||
"/trade-in/sale-share",
|
||
"/trade-in/api/v1/buildings/sale-share",
|
||
"/trade-in/api/v1/buildings/sale-share/summary",
|
||
"/trade-in/api/v1/buildings/123/listings",
|
||
)
|
||
# «Кэш» — не продукт, а диагностика состояния кэшей/скраперов.
|
||
_CACHE_TOOL_PATHS = (
|
||
"/trade-in/cache",
|
||
"/trade-in/api/v1/trade-in/cache-stats",
|
||
"/trade-in/api/v1/trade-in/cache-stats/",
|
||
)
|
||
|
||
# Разрез обязан покрывать исходный набор целиком — иначе новый внутренний путь
|
||
# добавят в _INTERNAL_TOOL_PATHS, забудут отнести к продукту, и роль analyst
|
||
# останется непроверенной на нём.
|
||
assert set(_SALE_SHARE_PATHS) | set(_CACHE_TOOL_PATHS) == set(_INTERNAL_TOOL_PATHS)
|
||
|
||
# Основной продукт — не должен быть задет deny выше.
|
||
_CORE_PRODUCT_PATHS = ("/trade-in/", "/trade-in/api/v1/trade-in/estimate")
|
||
|
||
|
||
def test_db_roles_denied_on_internal_tool_paths() -> None:
|
||
"""manager/employee (DB-роли, session-auth ветка rbac_guard) → deny."""
|
||
for role in ("manager", "employee"):
|
||
for path in _INTERNAL_TOOL_PATHS:
|
||
assert not _db_role_path_allowed(role, path), f"{role} must not reach {path}"
|
||
|
||
|
||
def test_db_admin_still_allowed_on_internal_tool_paths() -> None:
|
||
for path in _INTERNAL_TOOL_PATHS:
|
||
assert _db_role_path_allowed("admin", path), f"admin lost access to {path}"
|
||
|
||
|
||
def test_yaml_roles_deliberately_outside_client_deny() -> None:
|
||
"""Пиннит ОБРАТНУЮ сторону правки 2026-07-31: роли, которые сознательно НЕ
|
||
попали под клиентский deny.
|
||
|
||
Без этого теста «синхронизация» deny-списков между ролями в auth/roles.yaml
|
||
(соблазн скопировать pilot.deny в соседей) молча отрезала бы админа от его
|
||
же инструментов, и ни один тест бы не упал: roles.yaml лежит ВНЕ paths-фильтров
|
||
`backend/**` и `tradein-mvp/**`, т.е. CI такую правку не проверяет вовсе —
|
||
ровно тот класс рассинхрона, что уже случился с user2 (см.
|
||
backend/tests/test_rbac.py::test_get_role_known_users).
|
||
|
||
`analyst` (внутренняя роль, paths "/**") попадает под клиентский deny
|
||
ЧАСТИЧНО, и обе стороны асимметрии здесь запиннены намеренно:
|
||
- «Поиск домов» ЗАКРЫТ — тестовый продукт, доступ только у admin
|
||
(решение владельца 2026-07-31; «только у админа» включает и внутренние
|
||
роли, поэтому analyst тоже в deny);
|
||
- «Кэш» ОТКРЫТ — это не продукт, а диагностика кэшей/скраперов, ровно тот
|
||
инструмент, ради которого роль analyst и заведена.
|
||
Если решение поменяется — упадёт этот тест, а не пользователь на проде.
|
||
"""
|
||
for path in _INTERNAL_TOOL_PATHS:
|
||
assert auth_mod.is_path_allowed("admin", path), f"admin lost access to {path}"
|
||
|
||
for path in _SALE_SHARE_PATHS:
|
||
assert not auth_mod.is_path_allowed("analyst", path), (
|
||
f"analyst не должен видеть «Поиск домов» ({path}) — тестовый продукт, "
|
||
f"admin-only; если решение изменилось, обнови тест И комментарий у роли "
|
||
f"analyst в auth/roles.yaml"
|
||
)
|
||
|
||
for path in _CACHE_TOOL_PATHS:
|
||
assert auth_mod.is_path_allowed("analyst", path), (
|
||
f"analyst потерял «Кэш» ({path}) — это его рабочий инструмент; если "
|
||
f"закрыли намеренно, обнови тест И комментарий у роли analyst"
|
||
)
|
||
|
||
|
||
def test_db_roles_still_allowed_on_core_product() -> None:
|
||
"""Регресс: оценка (основной продукт) для клиентских ролей не задета."""
|
||
for role in ("manager", "employee"):
|
||
for path in _CORE_PRODUCT_PATHS:
|
||
assert _db_role_path_allowed(role, path), f"{role} lost access to {path}"
|
||
|
||
|
||
def test_legacy_pilot_denied_on_internal_tool_paths() -> None:
|
||
"""Зеркало в auth/roles.yaml: пока auth_mode=dual, legacy-pilot не должен
|
||
видеть то, что DB-employee уже не видит."""
|
||
for path in _INTERNAL_TOOL_PATHS:
|
||
assert not auth_mod.is_path_allowed("pilot", path), f"pilot must not reach {path}"
|
||
for path in _CORE_PRODUCT_PATHS:
|
||
assert auth_mod.is_path_allowed("pilot", path), f"pilot lost access to {path}"
|
||
|
||
|
||
def test_rbac_guard_blocks_pilot_on_internal_tool_api(client: TestClient) -> None:
|
||
"""Тот же deny через РЕАЛЬНЫЙ guard (legacy trusted-header ветка): ручки
|
||
sale-share/кэша отдают 403, а не только прячутся из меню."""
|
||
for path in ("/api/v1/buildings/sale-share", "/api/v1/trade-in/cache-stats"):
|
||
resp = client.get(path, headers={"X-Authenticated-User": "kopylov"})
|
||
assert resp.status_code == 403, f"pilot {path}: {resp.status_code}"
|
||
assert "forbidden for role" in resp.json()["detail"].lower()
|
||
|
||
|
||
def test_rbac_guard_admin_keeps_internal_tool_api(client: TestClient) -> None:
|
||
for path in ("/api/v1/buildings/sale-share", "/api/v1/trade-in/cache-stats"):
|
||
resp = client.get(path, headers={"X-Authenticated-User": "admin"})
|
||
assert resp.status_code == 200, f"admin {path}: {resp.text}"
|
||
|
||
|
||
def test_internal_deny_globs_do_not_leak_to_sibling_prefixes() -> None:
|
||
"""Граничный случай: '<prefix>/**' компилируется в '^<prefix>(?:/.*)?$' —
|
||
матчит сам prefix, prefix со слэшем и подпути через '/', но НЕ соседей по
|
||
префиксу (дефис не матчится). Именно поэтому глоб-форма безопасна как
|
||
замена точного пути: '/trade-in/cache/**' не задевает страницу
|
||
'/trade-in/cache-stats', а '/…/trade-in/cache-stats/**' — не гипотетическую
|
||
'/…/trade-in/cache-statistics'. Фиксируем семантику тестом: если её однажды
|
||
поменяют (напр. на префиксный startswith), соседние пути начнут молча
|
||
падать в 403."""
|
||
siblings_allowed = (
|
||
"/trade-in/cache-stats",
|
||
"/trade-in/sale-share-report",
|
||
"/trade-in/api/v1/trade-in/cache-statistics",
|
||
)
|
||
section_denied = (
|
||
"/trade-in/cache/detail",
|
||
"/trade-in/sale-share/123",
|
||
"/trade-in/api/v1/trade-in/cache-stats/reset",
|
||
)
|
||
for role in ("manager", "employee"):
|
||
for path in siblings_allowed:
|
||
assert _db_role_path_allowed(role, path), f"{role} lost sibling {path}"
|
||
# ...при том что сам раздел и его подпути закрыты.
|
||
for path in section_denied:
|
||
assert not _db_role_path_allowed(role, path), f"{role} must not reach {path}"
|
||
|
||
for path in siblings_allowed:
|
||
assert auth_mod.is_path_allowed("pilot", path), f"pilot lost sibling {path}"
|
||
for path in section_denied:
|
||
assert not auth_mod.is_path_allowed("pilot", path), f"pilot must not reach {path}"
|