arm-core/api/tests/test_openapi_routes.py
Andrey Epifancev 227ab67809 fix(auth): схема bearerAuth в OpenAPI — кнопка Authorize в Swagger UI
Токен читался зависимостью как Header(), поэтому в openapi.json не было securitySchemes и Swagger UI не показывал
Authorize (вопрос фронта 06.10.2026). Теперь HTTPBearer(auto_error=False) со scheme_name bearerAuth; значение
заголовка собирается обратно и идёт в parse_bearer — тексты 401/403 и правила («три части» → 401) не изменились.
Тесты: securitySchemes.bearerAuth есть, все маршруты кроме /dev/token и /healthcheck помечены security.
make check: 1126 passed; интеграция auth/me/dev-token: 53 passed.

Closes #29

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 15:43:33 +04:00

229 lines
12 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.

"""OpenAPI содержит все маршруты Срезов 1–4 (specs directories-api, directory-import, users-admin,
consolidations-api) без дублей моделей.
Схема строится из объявленных маршрутов без БД и сети. Состав маршрутов — по спекам change `slice-1-directories`;
проверка нужна, чтобы маршрут не пропал при переносе роутеров и чтобы `RegionalOfficeOut` (R-13) был одной моделью.
"""
import importlib
from typing import Any
import pytest
app_module = importlib.import_module("src.app")
D = "/api/v1/directories"
A = "/api/v1/admin"
# путь → методы (нижний регистр, как в OpenAPI)
EXPECTED_ROUTES: dict[str, set[str]] = {
"/api/v1/me": {"get"},
# directories-api: филиалы, ГК, ГСК/ГСЗ, ЮЛ/ИП
f"{D}/regional-offices": {"get"},
f"{D}/company-groups": {"get", "post"},
f"{D}/company-groups/{{group_id}}": {"get", "patch"},
f"{D}/company-groups/{{group_id}}/archive": {"post"},
f"{D}/company-groups/{{group_id}}/unarchive": {"post"},
f"{D}/company-groups/{{group_id}}/history": {"get"},
f"{D}/company-groups/{{company_group_id}}/related-groups": {"get", "post"},
f"{D}/related-groups/{{group_id}}": {"get", "patch", "delete"},
f"{D}/related-groups/{{group_id}}/members": {"post"},
f"{D}/related-groups/{{group_id}}/members/{{legal_entity_id}}": {"delete"},
f"{D}/legal-entities": {"get", "post"},
f"{D}/legal-entities/{{entity_id}}": {"get", "patch"},
f"{D}/legal-entities/{{entity_id}}/move": {"post"},
f"{D}/legal-entities/{{entity_id}}/withdraw": {"post"},
f"{D}/legal-entities/{{entity_id}}/history": {"get"},
f"{D}/legal-entities/{{entity_id}}/aliases": {"post"},
f"{D}/legal-entities/{{entity_id}}/aliases/{{alias_id}}": {"delete"},
# directory-import
f"{D}/import": {"post"},
# users-admin
f"{A}/users": {"get", "post"},
f"{A}/users/{{user_id}}": {"patch"},
f"{A}/users/{{user_id}}/roles": {"put"},
f"{A}/roles": {"get"},
f"{A}/permissions": {"get"},
f"{A}/roles/{{code}}/permissions": {"put"},
# primary-files, documents-api (Срез 2c)
"/api/v1/legal-entities/{legal_entity_id}/files": {"get", "post"},
"/api/v1/files/{file_id}": {"get", "delete"},
"/api/v1/documents/{document_id}": {"get"},
"/api/v1/documents/{document_id}/entries": {"get"},
"/api/v1/documents/{document_id}/confirm": {"post"},
# consolidations-api (Срез 4)
"/api/v1/consolidations": {"post"},
"/api/v1/company-groups/{group_id}/consolidations": {"get"},
"/api/v1/consolidations/versions/{version_id}": {"get"},
"/api/v1/consolidations/versions/{version_id}/lock": {"post", "delete"},
"/api/v1/consolidations/versions/{version_id}/status": {"post"},
"/api/v1/consolidations/versions/{version_id}/members": {"put"},
"/api/v1/consolidations/versions/{version_id}/checklist": {"get"},
"/api/v1/consolidations/versions/{version_id}/osv": {"get"},
"/api/v1/consolidations/versions/{version_id}/osv/summary": {"get"},
"/api/v1/consolidations/versions/{version_id}/osv/export": {"get"},
"/api/v1/consolidations/versions/{version_id}/counterparty-candidates": {"get"},
"/api/v1/consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty": {"post"},
}
# Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно
CHECKED_PREFIXES = (
D,
A,
"/api/v1/legal-entities",
"/api/v1/files",
"/api/v1/documents",
"/api/v1/consolidations",
"/api/v1/company-groups",
)
# Модели, которые обязаны быть в схеме ровно один раз под коротким именем
SINGLE_MODELS = [
"RegionalOfficeOut",
"CompanyGroupOut",
"LegalEntityOut",
"UserOut",
"RoleOut",
"ImportReportOut",
"FileOut",
"DocumentOut",
"EntryOut",
"VersionCard",
"ConsolidationPeriodOut",
"LockOut",
"StatusIn",
]
@pytest.fixture(scope="module")
def schema() -> dict[str, Any]:
spec: dict[str, Any] = app_module.app.openapi()
return spec
@pytest.mark.parametrize("path", sorted(EXPECTED_ROUTES))
def test_spec_route_is_in_openapi(schema: dict[str, Any], path: str) -> None:
assert path in schema["paths"], f"в OpenAPI нет маршрута {path}"
assert set(schema["paths"][path]) >= EXPECTED_ROUTES[path], (path, sorted(schema["paths"][path]))
def test_no_unexpected_routes_under_directories_and_admin(schema: dict[str, Any]) -> None:
"""Маршрутов сверх спек нет: новые маршруты сначала попадают в спеку и в этот список."""
actual = {path for path in schema["paths"] if path.startswith(CHECKED_PREFIXES)}
expected = {path for path in EXPECTED_ROUTES if path.startswith(CHECKED_PREFIXES)}
assert actual == expected
for path in actual:
assert set(schema["paths"][path]) == EXPECTED_ROUTES[path], path
def test_components_have_no_duplicated_models(schema: dict[str, Any]) -> None:
"""Одна модель — одно имя: нет автоимён вида `src__schemas__x__RegionalOfficeOut` и суффиксов `-Input`/`-Output`.
Имена — ключи словаря, поэтому дубликатов быть не может; реальный страж — проверка на `__`/`-` (FastAPI даёт такие
имена, когда две разные модели совпали по имени или одна модель разошлась на вход/выход).
"""
names = list(schema["components"]["schemas"])
for model in SINGLE_MODELS:
assert model in names, model
assert not [name for name in names if "__" in name or "-" in name], names
def test_consolidation_routes_declare_documented_responses(schema: dict[str, Any]) -> None:
"""Создание — 201 с карточкой версии и ошибки 404/409/422; чтение карточки и списка — 404 (ГК вне видимости)."""
create = schema["paths"]["/api/v1/consolidations"]["post"]["responses"]
assert {"201", "404", "409", "422"} <= set(create)
assert create["201"]["content"]["application/json"]["schema"]["$ref"].endswith("/VersionCard")
card = schema["paths"]["/api/v1/consolidations/versions/{version_id}"]["get"]["responses"]
assert {"200", "404"} <= set(card)
listing = schema["paths"]["/api/v1/company-groups/{group_id}/consolidations"]["get"]["responses"]
assert {"200", "404"} <= set(listing)
checklist = schema["paths"]["/api/v1/consolidations/versions/{version_id}/checklist"]["get"]["responses"]
assert {"200", "404"} <= set(checklist)
assert checklist["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/ChecklistOut")
osv = schema["paths"]["/api/v1/consolidations/versions/{version_id}/osv"]["get"]
assert {"200", "404", "422"} <= set(osv["responses"])
assert osv["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].endswith(
"/Page_ConsolidatedRowOut_"
)
params = {parameter["name"]: parameter for parameter in osv["parameters"]}
assert {
"legal_entity_id",
"account_root",
"counterparty_type",
"unmapped",
"match_kind",
"q",
"limit",
"offset",
} <= set(params)
assert params["match_kind"]["schema"]["anyOf"][0]["type"] == "array" # повторяемый параметр
summary = schema["paths"]["/api/v1/consolidations/versions/{version_id}/osv/summary"]["get"]["responses"]
assert {"200", "404"} <= set(summary)
assert summary["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/OsvSummaryOut")
def test_osv_export_route_declares_binary_xlsx_response(schema: dict[str, Any]) -> None:
"""Экспорт: 200 — файл xlsx (бинарный контент), 404/422; фильтры те же, что у страницы, но без пагинации."""
export = schema["paths"]["/api/v1/consolidations/versions/{version_id}/osv/export"]["get"]
assert {"200", "404", "422"} <= set(export["responses"])
xlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
assert export["responses"]["200"]["content"][xlsx]["schema"] == {"type": "string", "format": "binary"}
names = {parameter["name"] for parameter in export["parameters"]}
assert {"legal_entity_id", "account_root", "counterparty_type", "unmapped", "match_kind", "q"} <= names
assert not {"limit", "offset"} & names
def test_lock_and_status_routes_declare_documented_responses(schema: dict[str, Any]) -> None:
"""Взятие — 201/200 с `LockOut`, 404/409/422; снятие — 204/404/409; смена статуса — карточка, 404/409/422."""
lock = schema["paths"]["/api/v1/consolidations/versions/{version_id}/lock"]
assert {"200", "201", "404", "409", "422"} <= set(lock["post"]["responses"])
assert lock["post"]["responses"]["201"]["content"]["application/json"]["schema"]["$ref"].endswith("/LockOut")
assert {"204", "404", "409"} <= set(lock["delete"]["responses"])
change = schema["paths"]["/api/v1/consolidations/versions/{version_id}/status"]["post"]["responses"]
assert {"200", "404", "409", "422"} <= set(change)
assert change["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/VersionCard")
def test_counterparty_mapping_routes_declare_documented_responses(schema: dict[str, Any]) -> None:
"""Кандидаты — список `CandidateOut`, 404/422; сопоставление — `MapCounterpartyOut` (строка может быть `null`),
404/409/422."""
candidates = schema["paths"]["/api/v1/consolidations/versions/{version_id}/counterparty-candidates"]["get"]
assert {"200", "404", "422"} <= set(candidates["responses"])
assert candidates["responses"]["200"]["content"]["application/json"]["schema"]["items"]["$ref"].endswith(
"/CandidateOut"
)
assert {"q", "scope", "entry_id"} <= {parameter["name"] for parameter in candidates["parameters"]}
path = "/api/v1/consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty"
mapping = schema["paths"][path]["post"]["responses"]
assert {"200", "404", "409", "422"} <= set(mapping)
assert mapping["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/MapCounterpartyOut")
row = schema["components"]["schemas"]["MapCounterpartyOut"]["properties"]["row"]
assert {"type": "null"} in row["anyOf"]
# --- Схема безопасности (issue #29): Swagger UI показывает Authorize только при securitySchemes в OpenAPI ---
BEARER_SCHEME = "bearerAuth"
# Маршруты без аутентификации: выдача dev-токена и healthcheck
PUBLIC_PATHS = {"/api/v1/dev/token", "/healthcheck"}
def test_openapi_declares_bearer_security_scheme(schema: dict[str, Any]) -> None:
schemes = schema.get("components", {}).get("securitySchemes", {})
bearer = schemes.get(BEARER_SCHEME, {})
assert (bearer.get("type"), bearer.get("scheme"), bearer.get("bearerFormat")) == ("http", "bearer", "JWT"), schemes
assert "Authorize" in bearer.get("description", "") # подсказка для Swagger UI
def test_protected_routes_require_bearer_and_public_do_not(schema: dict[str, Any]) -> None:
missing: list[str] = []
for path, methods in schema["paths"].items():
for method, operation in methods.items():
requirements = operation.get("security", [])
has_bearer = any(BEARER_SCHEME in requirement for requirement in requirements)
if path in PUBLIC_PATHS:
assert not has_bearer, f"{method.upper()} {path} — публичный маршрут помечен security"
elif not has_bearer:
missing.append(f"{method.upper()} {path}")
assert missing == [], f"маршруты без схемы {BEARER_SCHEME} (Authorize в Swagger не подставит токен): {missing}"