diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index 9e0af142..4b8288c0 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -548,7 +548,25 @@ _TOKEN_TTL = "7 days" def _require_public_estimate_enabled() -> None: - """404, пока публичный расчёт не включён владельцем явно.""" + """404, пока публичный расчёт не включён владельцем явно. + + ВЕШАЕТСЯ ЧЕРЕЗ `dependencies=[Depends(...)]` НА ДЕКОРАТОР, А НЕ ВЫЗЫВАЕТСЯ + ПЕРВОЙ СТРОКОЙ ТЕЛА. Разница не косметическая: FastAPI решает зависимости + РАНЬШЕ, чем валидирует тело запроса. Пока проверка стояла в теле, до неё + просто не доходило — невалидное тело отбивалось 422 ещё на разборе, и + выключенная ручка отвечала так: + + POST /api/public/mera/estimate {} → 422 + перечень полей + POST /api/public/mera/estimate {валидное} → 404 + POST /api/public/mera/nosuchthing → 401 (rbac) + + То есть 422 подтверждал существование ручки (несуществующий путь даёт 401) + и заодно выдавал её схему: address, area_m2, rooms, consent. Замер на проде + 29.08.2026, ровно то, что этот докстринг обещал не делать. + + Как зависимость проверка срабатывает до разбора тела, и выключенная ручка + неотличима от отсутствующей при ЛЮБОМ входе. + """ if not settings.public_estimate_enabled: raise HTTPException(status_code=404, detail="Not Found") @@ -632,7 +650,11 @@ def _coverage_for( ) -@router.post("/estimate", response_model=PublicEstimateResult) +@router.post( + "/estimate", + response_model=PublicEstimateResult, + dependencies=[Depends(_require_public_estimate_enabled)], +) async def public_estimate( request: Request, response: Response, @@ -652,7 +674,6 @@ async def public_estimate( контур (сосед, `/api/v1/trade-in/r/{token}`) открывает его после оплаты по своему токену. Наружу здесь уезжает только `PublicEstimateResult`. """ - _require_public_estimate_enabled() _enforce(_estimate_limiter, request, "estimate") daily_retry = _daily_estimate_limiter.retry_after(_ESTIMATE_GLOBAL_KEY) @@ -709,7 +730,11 @@ async def public_estimate( ) -@router.post("/estimate/read", response_model=PublicEstimateResult) +@router.post( + "/estimate/read", + response_model=PublicEstimateResult, + dependencies=[Depends(_require_public_estimate_enabled)], +) def public_estimate_read( request: Request, payload: PublicEstimateTokenInput, @@ -726,7 +751,6 @@ def public_estimate_read( Просрочка и «нет такого токена» отвечают ОДИНАКОВО (404): различать их значит подтверждать существование расчёта тому, кто угадал токен. """ - _require_public_estimate_enabled() _enforce(_estimate_read_limiter, request, "estimate-read") row = db.execute( diff --git a/tradein-mvp/backend/tests/test_public_mera_estimate.py b/tradein-mvp/backend/tests/test_public_mera_estimate.py index 5d844f49..c2915bc1 100644 --- a/tradein-mvp/backend/tests/test_public_mera_estimate.py +++ b/tradein-mvp/backend/tests/test_public_mera_estimate.py @@ -382,3 +382,36 @@ def test_daily_budget_stops_estimates_for_everyone(client: TestClient, flag_on: assert resp.status_code == 429, resp.text assert int(resp.headers["Retry-After"]) > 0 assert not estimate_mock.called + + +# ── Выключенная ручка не должна подтверждать, что она существует ───────────── +# +# Замер на проде 29.08.2026 (флаг выключен) показал, что обещание докстринга не +# выполнялось: проверка флага стояла ПЕРВОЙ СТРОКОЙ ТЕЛА, а FastAPI валидирует +# тело запроса раньше, чем доходит до кода хендлера. Итог: +# {} → 422 со списком полей address/area_m2/rooms/consent +# {валидное} → 404 +# чужой путь → 401 +# 422 отличался и от 404, и от 401 — то есть подтверждал существование ручки и +# заодно печатал её схему. Гейт переехал в `dependencies=[Depends(...)]`: +# зависимости решаются ДО разбора тела. +def test_disabled_estimate_hides_itself_from_any_input(client: TestClient) -> None: + for body in ({}, {"address": "x"}, _BODY): + got = client.post(f"{PREFIX}/estimate", json=body) + assert got.status_code == 404, ( + f"выключенная ручка ответила {got.status_code} на теле {body!r}. " + "Любой другой код отличает её от несуществующего пути, а 422 " + "вдобавок печатает схему запроса" + ) + assert "address" not in got.text, ( + "в ответе выключенной ручки видно имя поля запроса — схема утекла" + ) + + +def test_disabled_read_hides_itself_from_any_input(client: TestClient) -> None: + for body in ({}, {"token": "x"}): + got = client.post(f"{PREFIX}/estimate/read", json=body) + assert got.status_code == 404, ( + f"выключенное чтение по токену ответило {got.status_code} на {body!r}" + ) + assert "token" not in got.text, "схема чтения утекла при выключенном флаге"