All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 2m19s
CI Trade-In / backend-tests (pull_request) Successful in 3m13s
CI / openapi-codegen-check (pull_request) Successful in 3m34s
CI / backend-tests (pull_request) Successful in 16m7s
Аккаунт praktika (DB-роль manager) видел оба пункта в топбаре на /trade-in/team.
Это внутренние инструменты — аналитика рынка и состояние кэшей/скраперов, —
клиентские аккаунты их видеть не должны (решение владельца продукта).
Гейт один — deny-список роли, потому что все три места сверяются с ним через
общий матчер: пункт меню (Topbar по scopePath из /me), страница (RouteGuard) и
серверные ручки (rbac_guard). Правка только фронта спрятала бы пункт, оставив
прямой URL и API открытыми.
Закрыто для employee/manager (DB_ROLE_PATHS) и для legacy pilot (roles.yaml):
/trade-in/sale-share/**
/trade-in/cache/**
/trade-in/api/v1/buildings/**
/trade-in/api/v1/trade-in/cache-stats/**
У cache-stats ГЛОБ, а не точный путь: точный паттерн — строгое равенство, его
обходит трейлинг-слэш ('…/cache-stats/' → allowed=True), и защита держалась бы
на Starlette redirect_slashes, а не на RBAC. Замерено после правки: все варианты
(слэш, %2f, ./, ../) дают 403, утечек нет.
Основной продукт не задет: buildings.py обслуживает ТОЛЬКО sale-share, секция
«Продажи в доме» на экране оценки питается estimate-хендлерами. admin и analyst
сознательно вне deny — запиннено тестом, иначе «синхронизация» списков закрыла
бы их молча.
Заодно починен КРАСНЫЙ pre-existing тест главного бэкенда:
backend/tests/test_rbac.py::test_get_role_known_users ждал pilot у всех
user1..user10, но user2 («Брусника») стал expired 2026-07-30. CI это пропустил —
auth/roles.yaml не входит в paths-filter backend/**, из-за чего сьют не бежал.
Тесты: 153 passed (tradein) + 24 passed (site-finder, было 23+1 failed).
Новые — e2e через реальный rbac_guard по session-ветке (именно ею ходит
praktika), пин deny_paths в выдаче /me, границы глоба и regression-guard'ы.
Проверены снятием deny: 7 тестов краснеют, т.е. не тавтологии.
496 lines
23 KiB
Python
496 lines
23 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/",
|
||
)
|
||
|
||
# Основной продукт — не должен быть задет 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-управление);
|
||
решение не распространять на неё клиентский deny осознанное, а не забытое.
|
||
"""
|
||
for path in _INTERNAL_TOOL_PATHS:
|
||
assert auth_mod.is_path_allowed("admin", path), f"admin lost access to {path}"
|
||
assert auth_mod.is_path_allowed("analyst", path), (
|
||
f"analyst lost access to {path} — если это намеренно, обнови этот тест "
|
||
f"и комментарий у роли analyst в auth/roles.yaml"
|
||
)
|
||
|
||
|
||
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}"
|