gendesign/tradein-mvp/backend/tests/test_rbac.py
bot-backend 990a59e469
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m6s
fix(rbac): анониму на admin-префиксе отвечать 404, а не 401
#3360. Периметр /trade-in/api/v1/admin/* снаружи не срезан (caddy/sites/apps.caddy:
блок `handle /trade-in/api/*` стоит выше `import caddy/users.caddy.snippet`), и
срезать его нельзя: admin-UI кабинета зовёт эти пути ИЗ БРАУЗЕРА (12 файлов
tradein-mvp/frontend/src — scrapers/**, components/scrapers/**, admin-audit-api.ts,
GuardedRoute). Значит внешний аноним доходит до rbac_guard, а после #3324
(несуществующий путь → 404 роутера) 401 на существующей ручке стал оракулом:
перебором имён восстанавливался список admin-API.

Все три ветки «личность не установлена» (нет X-Authenticated-User, auth_mode=db_only,
подделанный заголовок без #2213-секрета) на admin-префиксе теперь отдают ровно то же,
что роутер даёт на несуществующий путь. Аутентифицированные не тронуты: admin — 200,
не-admin — 403 «admin only» (прятать наличие ручки от опознанного человека незачем).

Смоук периметра: пара admin-путей — существующий /admin/proxies и несуществующий
/admin/users — оба обязаны быть 404 снаружи.
2026-09-06 00:51:09 +05:00

673 lines
35 KiB
Python
Raw 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 import config
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()
@pytest.fixture(autouse=True)
def _legacy_yaml_only(monkeypatch: pytest.MonkeyPatch) -> None:
"""Реестр в ЭТОМ файле молчит — здесь проверяется legacy-путь roles.yaml.
#3316 сделал `get_role` DB-first (реестр → YAML-fallback), и без этой
изоляции результат файла зависел бы от ОКРУЖЕНИЯ: локально без БД шла
YAML-ветка и всё было зелено, а в CI, где реестр засеян миграцией 193
(kopylov=manager, user*=employee), те же ассерты краснели. Тест, который
отвечает по-разному в двух окружениях, не проверяет ничего.
Здесь закреплена ровно YAML-семантика (разбор файла, globs, поведение
guard'а и /me на trusted-header пути); DB-first, приоритет реестра и
эквивалентность scope employee↔pilot покрыты tests/test_role_single_source.py.
"""
monkeypatch.setattr(auth_mod, "_registry_role", lambda username: None)
# ---------------------------------------------------------------------------
# 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_404(client: TestClient) -> None:
"""#3360: было 401 («ручка есть, но не для тебя»), стало 404 — как у
несуществующего пути. Доступа это не меняет (запрос по-прежнему отбит ДО
хендлера), меняется только то, что аноним узнаёт из ответа. Неразличимость
с несуществующей ручкой проверяется ниже,
test_anon_cannot_tell_existing_admin_route_from_missing_one."""
resp = client.get("/api/v1/admin/dummy")
assert resp.status_code == 404
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()
def test_rbac_guard_unrouted_path_answers_the_same_everywhere(client: TestClient) -> None:
"""#3324: аноним не должен по ответу отличать несуществующий путь от закрытого.
Сравниваются два ОТВЕТА между собой, а не с константой: важно именно
неразличимость. До фикса мусор под «интересным» префиксом получал 401 с
rbac-текстом, а такой же мусор рядом — 404 роутера, и по этой разнице
периметр разведывался бесплатно.
"""
for interesting, boring in (
# Пара, на которой оракул был виден в чистом виде: под публичным
# префиксом guard молчал (404 роутера), а рядом отвечал 401.
("/api/v1/trade-in/zzz", "/api/v1/trade-in/r/zzz"),
("/api/public/zzz", "/api/zzz"),
("/trade-in/api/public/zzz", "/trade-in/api/zzz"),
("/api/v1/admin/zzz", "/zzz"),
):
first = client.get(interesting)
second = client.get(boring)
assert (first.status_code, first.json()) == (second.status_code, second.json()), (
f"{interesting}{first.status_code} {first.json()}, "
f"{boring}{second.status_code} {second.json()}"
)
assert first.status_code == 404
# Защита не ослаблена: РЕАЛЬНЫЙ закрытый маршрут анониму по-прежнему 401.
assert client.get("/api/v1/trade-in/support/unread").status_code == 401
assert client.get("/api/v1/me").status_code == 401
# Match.PARTIAL — путь есть, метод чужой (/api/v1/me зарегистрирован как GET).
# Такой запрос обязан идти в guard (401), а НЕ проваливаться в роутер за 405:
# иначе «метод не тот» стало бы ещё одним способом обойти проверку личности.
assert client.post("/api/v1/me").status_code == 401
# Реальный публичный маршрут по-прежнему работает без идентичности.
assert client.get("/api/v1/trade-in/support/anon/unread").status_code == 200
def test_anon_cannot_tell_existing_admin_route_from_missing_one(client: TestClient) -> None:
"""#3360: анониму СУЩЕСТВУЮЩАЯ admin-ручка отвечает ровно как несуществующая.
Периметр admin-путей снаружи не срезан (`caddy/sites/apps.caddy`: блок
`handle /trade-in/api/*` выше `import caddy/users.caddy.snippet`) и срезать
его нельзя — admin-UI кабинета зовёт `/api/v1/admin/*` ИЗ БРАУЗЕРА
(tradein-mvp/frontend/src/app/scrapers/**, lib/admin-audit-api.ts). Значит
внешний аноним доходит до guard'а, а после #3324 (несуществующий путь → 404
роутера) 401 на существующей ручке работал оракулом имён admin-API.
Сравниваются два ЖИВЫХ ответа между собой, а не с константой: важна именно
неразличимость, а не конкретная формулировка тела от FastAPI.
"""
existing = client.get("/api/v1/admin/dummy") # роут есть — см. _build_test_app
missing = client.get("/api/v1/admin/zzz-no-such-handle")
assert (existing.status_code, existing.json()) == (missing.status_code, missing.json()), (
f"существующая → {existing.status_code} {existing.json()}, "
f"несуществующая → {missing.status_code} {missing.json()}"
)
assert existing.status_code == 404
# Маскировка НЕ трогает аутентифицированных: admin работает как раньше,
# а не-admin получает свой 403 «admin only» — прятать наличие ручки от
# опознанного человека незачем (и это ломало бы отладку прав).
ok = client.get("/api/v1/admin/dummy", headers={"X-Authenticated-User": "admintest"})
assert ok.status_code == 200, ok.text
denied = client.get("/api/v1/admin/dummy", headers={"X-Authenticated-User": "analysttest"})
assert denied.status_code == 403, denied.text
assert denied.json()["detail"] == "admin only"
# non-admin путь анониму — по-прежнему 401 (#3324 оставил его как есть).
assert client.get("/api/v1/me").status_code == 401
def test_anon_admin_cloak_covers_all_unauthenticated_branches(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Все три ветки «личность не установлена» на admin-префиксе дают тот же 404.
Одной ветки мало: guard отказывает анониму в трёх местах (нет заголовка,
auth_mode=db_only, подделанный X-Authenticated-User без #2213-секрета), и
любая пропущенная снова становится оракулом существования ручки.
"""
baseline = client.get("/api/v1/admin/zzz-no-such-handle")
monkeypatch.setattr(config.settings, "tradein_internal_auth_secret", "s3cr3t-value")
forged = client.get("/api/v1/admin/dummy", headers={"X-Authenticated-User": "admintest"})
assert (forged.status_code, forged.json()) == (baseline.status_code, baseline.json()), (
f"подделка мимо Caddy → {forged.status_code} {forged.json()}"
)
# Тот же запрос на non-admin пути секрет-гейт по-прежнему отбивает 401 —
# маскировка живёт ровно на admin-префиксе, а не поверх всего guard'а.
non_admin = client.get("/api/v1/me", headers={"X-Authenticated-User": "admintest"})
assert non_admin.status_code == 401
assert "internal auth secret" in non_admin.json()["detail"].lower()
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
db_only = client.get("/api/v1/admin/dummy")
assert (db_only.status_code, db_only.json()) == (baseline.status_code, baseline.json()), (
f"db_only без сессии → {db_only.status_code} {db_only.json()}"
)
assert client.get("/api/v1/me").status_code == 401
def test_real_app_does_not_redirect_trailing_slash() -> None:
"""#3324: `/api/v1/me/` анониму → 404, а не 307 на существующий путь.
Трейлинг-слэш был вторым каналом оракула существования: путь со слэшем не
матчит ни один маршрут (Match.NONE) → guard пропускает → Starlette отвечал
307 на `/api/v1/me`, т.е. сообщал «маршрут есть» в обход 401.
Проверяется РЕАЛЬНОЕ приложение (app.main), а не локальный _build_test_app:
флаг `redirect_slashes=False` живёт именно там, и тест на своей копии app
был бы тавтологией — он подтверждал бы настройку фикстуры, а не прода.
Импорт локальный: модуль этого файла намеренно не тянет app.main (см. шапку).
"""
from app.main import app as real_app
# TestClient без `with` не запускает lifespan — в БД никто не ходит.
resp = TestClient(real_app).get("/api/v1/me/", follow_redirects=False)
assert resp.status_code == 404, f"{resp.status_code} {resp.headers.get('location')}"
# ---------------------------------------------------------------------------
# 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}"