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
#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 снаружи.
673 lines
35 KiB
Python
673 lines
35 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 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}"
|