"""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 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} @app.get("/health") async def health() -> dict: return {"status": "ok"} 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): assert auth_mod.get_role(f"user{n}") == "pilot" 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_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.""" for user in ("praktika",): # role=expired (trial отозван; user2 восстановлен 2026-07-13) 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-исключение.""" resp_me = client.get("/api/v1/me", headers={"X-Authenticated-User": "praktika"}) 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": "praktika"}) 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()