fix(mera/b2c): выключенная ручка расчёта подтверждала своё существование и печатала схему
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m10s

Докстринг обещал: «Отвечаем 404, а не 403: выключенная ручка не должна
подтверждать, что она существует». Замер на проде 29.08.2026 (флаг выключен):

  POST /api/public/mera/estimate  {}          → 422 + address/area_m2/rooms/consent
  POST /api/public/mera/estimate  {валидное}  → 404
  POST /api/public/mera/nosuchthing           → 401 (rbac)

422 отличается и от 404, и от 401 — то есть подтверждает, что ручка есть, и
заодно выдаёт её схему.

Причина не в логике гейта, а в его МЕСТЕ: проверка стояла первой строкой тела
хендлера, а FastAPI валидирует тело раньше, чем доходит до кода. Гейт перенесён
в dependencies=[Depends(...)] обеих ручек — зависимости решаются до разбора тела,
и выключенная ручка неотличима от отсутствующей при любом входе.

Тест двусторонний и фальсифицирован: возврат вызова в тело красит три теста
(включая уже существовавший про 404), обратная правка — снова зелено.
This commit is contained in:
bot-backend 2026-08-29 20:19:15 +05:00
parent b241e0145a
commit 6ab13649f6
2 changed files with 62 additions and 5 deletions

View file

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

View file

@ -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, "схема чтения утекла при выключенном флаге"