fix: GET / без редиректа на //, 405 на неверный метод под /api/v1 (OQ-61, OQ-62, #9 п.3)

Причина редиректа: `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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 13:17:48 +04:00
parent 0eedb822e2
commit 4b873468fb
3 changed files with 122 additions and 6 deletions

View File

@ -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` без редиректов.
## Конфигурация

View File

@ -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)

View File

@ -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 = "<html>АРМ КА SPA</html>"
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