From 4b873468fb33afb60f1429eccc54d1513bc21652 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:17:48 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20GET=20/=20=D0=B1=D0=B5=D0=B7=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BD=D0=B0?= =?UTF-8?q?=20//,=20405=20=D0=BD=D0=B0=20=D0=BD=D0=B5=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=20/api/v1=20(OQ-61,=20OQ-62,=20#9=20=D0=BF.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Причина редиректа: `FastAPI(root_path="/")` — путь маршрута для `GET /` после вычитания root_path пуст, монтирование SPA его не находит, и роутер Starlette отвечал 307 на `//`. Теперь `root_path` нормализуется (`/` → "", хвостовой `/` снимается) — `_normalize_root_path` существовал, но не применялся. `SPAStaticFiles` для путей `api/...` сверяется с маршрутами приложения: путь совпал, метод нет (`Match.PARTIAL`) — 405 с заголовком `Allow` и русским `detail`; иначе — прежний 404 (dev-маршруты при выключенных флагах — 404). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 5 ++- api/src/app.py | 43 ++++++++++++++++++++--- api/tests/test_app.py | 80 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 016a5d3..7e3ee9f 100644 --- a/README.md +++ b/README.md @@ -426,7 +426,10 @@ email без учёта регистра проверяет только сер | `LAST_ADMIN` | 409 | операция оставила бы систему без активного `USERS_ADMIN` | Файл импорта больше `IMPORT_MAX_BYTES` — 413 со строкой в `detail`. Недостаточно прав — 403 строкой -(`недостаточно прав: требуется <ПРАВО>`), нет токена или он неверен — 401. +(`недостаточно прав: требуется <ПРАВО>`), нет токена или он неверен — 401. Под `/api/v1` SPA не отвечает: неверный +метод на существующий маршрут — 405 `{"detail": "метод не поддерживается этим маршрутом"}` с заголовком `Allow` +(например, `POST /api/v1/me`), несуществующий путь (и dev-маршрут при выключенных флагах) — 404 `{"detail": "Not Found"}`. +Все остальные пути — SPA: `GET /` и клиентские маршруты (`/admin`) отдают `index.html` без редиректов. ## Конфигурация diff --git a/api/src/app.py b/api/src/app.py index 7651a0d..fc1020b 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -32,6 +32,8 @@ from fastapi.staticfiles import StaticFiles from loguru import logger from miniopy_async import Minio from starlette.middleware.gzip import GZipMiddleware +from starlette.routing import Match, Mount, Route +from starlette.types import Scope from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from src.api.router import API_PREFIX, build_api_router @@ -212,7 +214,9 @@ def create_app(app_settings: Settings, *, web_dir: str = "web") -> FastAPI: application = FastAPI( title="АРМ КА", version=VERSION, - root_path=app_settings.ROOT_PATH, + # `/` → "" (OQ-62): при root_path "/" путь маршрута для `GET /` вычисляется пустым, монтирование SPA его не + # находит, и роутер отвечал редиректом на `//` + root_path=_normalize_root_path(app_settings.ROOT_PATH), lifespan=lifespan, docs_url="/docs" if docs_enabled else None, redoc_url="/redoc" if docs_enabled else None, @@ -254,11 +258,40 @@ API_STATIC_PATH = API_PREFIX.strip("/") _DOC_STATIC_PATHS = frozenset(path.strip("/") for path in DOC_PATHS) +METHOD_NOT_ALLOWED_DETAIL = "метод не поддерживается этим маршрутом" + + +def _allowed_methods(scope: Scope) -> set[str]: + """Методы маршрутов приложения, чей путь совпал с запросом, а метод — нет (`Match.PARTIAL`). + + SPA смонтировано на `/` и совпадает с любым запросом полностью, поэтому роутер Starlette до своего 405 не доходит: + неверный метод на существующий маршрут API попадает сюда. Монтирования (в том числе само SPA) не учитываются. + """ + allowed: set[str] = set() + for route in scope["app"].router.routes: + if isinstance(route, Mount) or not isinstance(route, Route): + continue + match, _ = route.matches(scope) + if match is Match.PARTIAL and route.methods: + allowed |= route.methods + return allowed + + class SPAStaticFiles(StaticFiles): - async def get_response(self, path: str, scope): - # Несуществующий маршрут API — честный 404 (в т.ч. для POST), а не index.html SPA и не 405 от StaticFiles: - # иначе dev-маршруты, которых при выключенных флагах нет, выглядели бы как «существующие» - if path == API_STATIC_PATH or path.startswith(API_STATIC_PATH + "/") or path in _DOC_STATIC_PATHS: + async def get_response(self, path: str, scope: Scope): + # Под /api/v1 SPA не отвечает (OQ-61): путь совпал с маршрутом API, но метод другой — 405 с `Allow`; + # маршрута нет — честный 404 (в т.ч. для POST), а не index.html SPA и не 405 от StaticFiles: иначе dev-маршруты, + # которых при выключенных флагах нет, выглядели бы как «существующие» + if path == API_STATIC_PATH or path.startswith(API_STATIC_PATH + "/"): + allowed = _allowed_methods(scope) + if allowed: + raise HTTPException( + status_code=HTTPStatus.METHOD_NOT_ALLOWED, + detail=METHOD_NOT_ALLOWED_DETAIL, + headers={"Allow": ", ".join(sorted(allowed))}, + ) + raise HTTPException(status_code=HTTPStatus.NOT_FOUND) + if path in _DOC_STATIC_PATHS: raise HTTPException(status_code=HTTPStatus.NOT_FOUND) try: return await super().get_response(path, scope) diff --git a/api/tests/test_app.py b/api/tests/test_app.py index da628d0..549fdf1 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -533,3 +533,83 @@ def test_cors_options_rules() -> None: "allow_origins": ["*", ORIGIN], "allow_credentials": False, } + + +# --- GET / без редиректа на //, 405 и 404 под /api/v1 (OQ-61, OQ-62, issue #9 п.3) --------------------------------- + +SPA_HTML = "АРМ КА SPA" + + +def _spa_app(tmp_path: Any, **update: Any) -> Any: + (tmp_path / "index.html").write_text(SPA_HTML, encoding="utf-8") + return _app_with(tmp_path, **update) + + +@pytest.mark.parametrize("root_path", ["/", ""]) +async def test_root_serves_spa_index_without_redirect(tmp_path: Any, root_path: str) -> None: + response = await _request(_spa_app(tmp_path, ROOT_PATH=root_path), "GET", "/") + + assert response.status_code == HTTPStatus.OK + assert response.text == SPA_HTML + assert "location" not in response.headers + + +async def test_root_path_prefix_serves_spa(tmp_path: Any) -> None: + """ROOT_PATH за прокси (`/armka`, с хвостовым `/` или без): корень приложения — index.html.""" + for root_path in ("/armka", "/armka/"): + response = await _request(_spa_app(tmp_path, ROOT_PATH=root_path), "GET", "/armka/") + assert response.status_code == HTTPStatus.OK, root_path + assert response.text == SPA_HTML + + +async def test_spa_client_route_serves_index(tmp_path: Any) -> None: + response = await _request(_spa_app(tmp_path), "GET", "/admin") + + assert response.status_code == HTTPStatus.OK + assert response.text == SPA_HTML + + +@pytest.mark.parametrize( + ("method", "path", "allowed"), + [ + ("POST", "/api/v1/me", {"GET"}), + ("DELETE", "/api/v1/me", {"GET"}), + ("PATCH", "/api/v1/directories/company-groups", {"GET", "POST"}), + ("GET", "/api/v1/directories/import", {"POST"}), + ("PUT", "/api/v1/directories/company-groups/00000000-0000-0000-0000-000000000001", {"GET", "PATCH"}), + ("GET", "/api/v1/dev/token", {"POST"}), # dev-эмитент включён в .env.test + ], +) +async def test_wrong_method_on_existing_api_route_is_405( + tmp_path: Any, method: str, path: str, allowed: set[str] +) -> None: + response = await _request(_spa_app(tmp_path), method, path) + + assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED + assert {m.strip() for m in response.headers["allow"].split(",")} == allowed + assert response.json() == {"detail": "метод не поддерживается этим маршрутом"} + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("GET", "/api/v1/no-such-route"), + ("POST", "/api/v1/no-such-route"), + ("GET", "/api/v1"), + ("GET", "/api/v1/"), + ("GET", "/api/v1/me/extra"), + ], +) +async def test_unknown_api_path_is_404(tmp_path: Any, method: str, path: str) -> None: + response = await _request(_spa_app(tmp_path), method, path) + + assert response.status_code == HTTPStatus.NOT_FOUND + assert SPA_HTML not in response.text + + +async def test_dev_route_absent_without_debug_is_404_not_405(tmp_path: Any) -> None: + """Поведение Среза 0: при выключенных флагах dev-маршрута нет в таблице — 404 на любой метод.""" + application = _spa_app(tmp_path, DEBUG=False) + for method in ("GET", "POST"): + response = await _request(application, method, "/api/v1/dev/token") + assert response.status_code == HTTPStatus.NOT_FOUND, method