Технический долг Среза 1: dev-seed под DEBUG, /docs и CORS в проде, / и 405, аудит входа, дата по МСК, seed не возвращает снятые права #10

Merged
aep merged 13 commits from fix/9-tech-debt-slice-1 into main 2026-10-05 19:18:45 +03:00
3 changed files with 122 additions and 6 deletions
Showing only changes of commit 4b873468fb - Show all commits

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