"""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 со слэшем и подпути через '/', но НЕ соседей по префиксу (дефис не матчится). Именно поэтому глоб-форма безопасна как замена точного пути: '/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}"