gendesign/tradein-mvp/backend/tests/test_rbac.py
bot-backend 762d1f68c6
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
chore(ci+rbac): auth/** в paths-фильтры + «Поиск домов» только у админа
Две связанные вещи, обе — по решению владельца продукта.

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 провалидирован.
2026-07-31 20:24:24 +03:00

532 lines
25 KiB
Python
Raw Permalink 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 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}"