From d046eaada24067b1597304be123dacfdbc13b57e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 22:32:53 +0300 Subject: [PATCH] =?UTF-8?q?feat(mera/b2c):=20=D0=BF=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=87=D0=BD=D1=8B=D0=B9=20API=20=D0=9C=D0=95=D0=A0=D0=AB?= =?UTF-8?q?=20=E2=80=94=20/api/public/mera/{suggest,coverage}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Первый шаг к отдельному B2C-интерфейсу оценки на meraocenka.ru: домен получает собственную поверхность бэкенда вместо того, чтобы тянуть куски закрытого контура. ## Отдельный префикс, а не проброс кусков /api/v1/* На meraocenka.ru действует allowlist-by-default. Открыть там API можно было двумя способами: перечислить нужные v1-пути поимённо — или завести префикс, под которым по определению не лежит ничего закрытого. Выбран второй: при первом одна опечатка в матчере (`/trade-in/api/*` вместо точного пути) открывает наружу весь v1 — ~20 ручек, включая PDF расчётов, фотографии и админку. Цена ошибки, а не удобство. Добавить сюда приватную ручку теперь нужно СПЕЦИАЛЬНО — положив файл в app/api/public/. Случайно нельзя. ## Ноль записей в БД Обе ручки только читают: /coverage — один SELECT, /suggest — прокси автокомплита. Это условие, при котором публичная форма работает ДО контура согласия 152-ФЗ (#2895: сегодня адрес физлица попадает в trade_in_estimates раньше согласия, а пути удаления в бэкенде нет). Платный расчёт, который писать будет, открывается только вместе с ним. ## Делегирование, а не копии Обе ручки вызывают те же функции, что обслуживают закрытый контур (v1.geocode.suggest_addresses, v1.trade_in.coverage_probe). Разбор #2894 показал, чем кончается вторая копия когорты: проба отвечает «данные есть» там, где платный расчёт видит ноль. Публичный ответ переиспользует CoverageProbeResponse — на нём уже стоит гейт «ни одного price-подобного поля». ## Бюджеты Общего 300/60с мало: /suggest через DaData-тир — платный внешний вызов, абуз стоит денег. Свои per-IP окна: 40/мин на подсказки (человек с debounce'ом тратит единицы на адрес), 15/мин на пробу. ## Проверено 11 тестов, из них структурные: набор ручек под /api/public проверяется на РАВЕНСТВО (третья, добавленная без правки теста, роняет сборку) и сверяется с rbac._PUBLIC_PATHS в обе стороны — чтобы не осталось открытого пути-призрака. Рядом висит закрытый маршрут-двойник: без него «аноним получает 200» одинаково зелёный и когда исключение точечное, и когда auth-гейт снят целиком. Мутации: убрать пути из rbac._PUBLIC_PATHS → 7 failed / 4 passed снять бюджет с /coverage → 2 failed / 9 passed откат → 11 passed Плюс 72 passed на связке rbac + coverage + version, `caddy validate` = Valid configuration, ruff чист. Смоук периметра дополнен парой, которую нельзя разделять: публичные ручки отвечают 200 анонимно И /trade-in/api/v1/* на этом домене по-прежнему 404. Зелёная только первая проверка = API открыт целиком, а тест этого не заметил. Refs #2894, #2895 --- Caddyfile | 32 ++- scripts/smoke-mera-perimeter.sh | 33 +++ .../backend/app/api/public/__init__.py | 7 + tradein-mvp/backend/app/api/public/mera.py | 149 +++++++++++ tradein-mvp/backend/app/core/rbac.py | 14 ++ tradein-mvp/backend/app/main.py | 7 + .../backend/tests/test_public_mera_api.py | 235 ++++++++++++++++++ 7 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/app/api/public/__init__.py create mode 100644 tradein-mvp/backend/app/api/public/mera.py create mode 100644 tradein-mvp/backend/tests/test_public_mera_api.py diff --git a/Caddyfile b/Caddyfile index 8624f14f..1de54f2e 100644 --- a/Caddyfile +++ b/Caddyfile @@ -317,8 +317,38 @@ meraocenka.ru { } } + # Публичный API МЕРЫ — ЕДИНСТВЕННЫЙ путь этого домена, доходящий до + # бэкенда. Под /api/public/ по определению не лежит ничего закрытого: + # гарантию даёт структура пакета app/api/public/, а не аккуратность этого + # матчера (разбор — в app/api/public/mera.py). Матчер тем не менее узкий: + # /trade-in/api/v1/* по-прежнему падает в catch-all 404 ниже. + # + # ПОЧЕМУ ПУТЬ С ПРЕФИКСОМ /trade-in, А НЕ КОРОТКИЙ /api/public/*. + # Тот же URL обязан работать и на gendsgn.ru/trade-in/mera-public — ту же + # страницу оттуда открывают для QA (там она за basic_auth). На gendsgn.ru + # корневой /api/* уже занят бэкендом Site Finder, то есть короткий путь + # потребовал бы там ВТОРОГО handle, выигрывающего у существующего по + # специфичности — то есть работоспособность публичной формы зависела бы от + # порядка сортировки матчеров в чужом site-блоке. С префиксом /trade-in + # запрос ловит уже существующий `handle /trade-in/api/*` (:123), и здесь + # нужен ровно один новый handle. Цена — префикс /trade-in виден в devtools + # публичного домена; он там и так виден на всех чанках Next (basePath). + # + # strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не + # часть маршрута FastAPI. + # + # X-Internal-Auth-Secret здесь НЕ подставляется (в отличие от :130): + # публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с + # анонимного домена — расширять доверие без нужды. + handle /trade-in/api/public/* { + uri strip_prefix /trade-in + reverse_proxy tradein-backend:8000 { + header_up -X-Authenticated-User + } + } + # Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin, - # /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется. + # /scrapers/*, /trade-in/api/v1/*, /history, ...) — 404, НЕ проксируется. handle { respond 404 } diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh index 99e53ef5..c2b091ea 100644 --- a/scripts/smoke-mera-perimeter.sh +++ b/scripts/smoke-mera-perimeter.sh @@ -43,6 +43,19 @@ check() { fi } +check_post() { + local desc="$1" url="$2" body="$3" expected="$4" + local code + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \ + -X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null) + if [ "$code" = "$expected" ]; then + echo "PASS: $desc ($url -> $code)" + else + echo "FAIL: $desc ($url -> got '${code:-}', expected $expected)" + fail=1 + fi +} + echo "== МЕРА B2C perimeter smoke (ЭТАП 1) ==" # 1. Публичный домен отдаёт 200 анонимно. @@ -79,6 +92,26 @@ check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API) # Ловит расширение матчера обратно до `/trade-in/_next/*`. check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404 +# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/* +# доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене +# по-прежнему 404. +# +# Пара проверок ниже неразделима: первая доказывает, что форма вообще +# работает, вторая — что новый handle не расширил периметр до +# `/trade-in/api/*`. Зелёная только первая = API открыт целиком и тест это +# пропустил (ровно та ошибка, ради которой в Caddyfile выбран отдельный +# префикс, а не поимённый проброс v1-путей). +check "meraocenka.ru public suggest — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 200 +check_post "meraocenka.ru public coverage — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/coverage" '{"lat":56.838,"lon":60.597,"rooms":2,"area_m2":54}' 200 +check "meraocenka.ru v1 geocode — must stay 404" "$BASE_MERA/trade-in/api/v1/geocode/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 404 +check "meraocenka.ru v1 coverage — must stay 404" "$BASE_MERA/trade-in/api/v1/trade-in/coverage" 404 + +# Тот же публичный путь на gendsgn.ru: страницу лэндинга открывают и оттуда +# (QA за basic_auth), поэтому URL у формы один на оба домена. Здесь он +# проходит через уже существующий `handle /trade-in/api/*` — проверка ловит +# регресс в rbac._PUBLIC_PATHS (стало бы 401), а не в Caddyfile. +check "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/api/public/mera/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 200 + # 3. B2B-данные trade-in по-прежнему закрыты анониму. # # ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200. diff --git a/tradein-mvp/backend/app/api/public/__init__.py b/tradein-mvp/backend/app/api/public/__init__.py new file mode 100644 index 00000000..6a5ab986 --- /dev/null +++ b/tradein-mvp/backend/app/api/public/__init__.py @@ -0,0 +1,7 @@ +"""Публичный (анонимный) API — поверхность, открытая на meraocenka.ru. + +Отдельный пакет, а не ещё один модуль в `app/api/v1/`, — намеренно: всё, что +лежит здесь, доступно БЕЗ идентичности, поэтому периметр должен читаться по +дереву каталогов, а не по списку исключений в middleware. Разбор — в +`app/api/public/mera.py`. +""" diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py new file mode 100644 index 00000000..818459f4 --- /dev/null +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -0,0 +1,149 @@ +"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный, ровно две ручки. + +ЗАЧЕМ ОТДЕЛЬНЫЙ ПРЕФИКС, А НЕ ОТКРЫТИЕ КУСКА /api/v1/* +------------------------------------------------------- +На `meraocenka.ru` действует allowlist-by-default: Caddy проксирует поимённо +перечисленные пути, всё остальное — 404 (см. корневой Caddyfile, site-блок +meraocenka.ru; регресс — scripts/smoke-mera-perimeter.sh). Чтобы открыть там +API, нужно было выбрать одно из двух: + + (а) пробросить `/trade-in/api/v1/trade-in/coverage` и `.../geocode/suggest` + поимённо — периметр остаётся узким, но одна опечатка в matcher'е + (`/trade-in/api/*` вместо точного пути) открывает наружу ВЕСЬ v1: ~20 + ручек, включая PDF расчётов, фотографии объектов, историю и админку; + (б) завести отдельный префикс, под которым по определению не может лежать + ничего закрытого, и пробрасывать его целиком. + +Выбрано (б). Разница не в удобстве, а в цене ошибки: при (а) безопасность +периметра держится на аккуратности матчера, при (б) — на структуре кода. +Добавить сюда ручку с приватными данными нужно СПЕЦИАЛЬНО (положить файл в +`app/api/public/`), случайно — нельзя. + +Тот же принцип, что уже применён на фронте: публичный лэндинг вынесен в +`app/mera-public/` с guard-скриптом на граф импортов, а не помечен флагом +внутри общего дерева. + +АНОНИМНОСТЬ +----------- +`rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого +non-public пути. Обе ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками — +не префиксом: множество там — frozenset с проверкой `path in ...`, и +добавление префиксной ветки ради двух путей расширило бы механизм, которым +пользуется весь бэкенд, ради одной фичи. + +ЧТО ЭТИ РУЧКИ НЕ ДЕЛАЮТ +----------------------- +Ни одна из них не пишет в БД строк с адресом пользователя: `/coverage` — +чистое чтение (один SELECT), `/suggest` — прокси автокомплита. Это не +случайность, а условие, при котором публичная форма может работать ДО того, +как появится контур согласия 152-ФЗ (issue #2895: сегодня адрес физлица +попадает в `trade_in_estimates` раньше любого согласия, а пути удаления +данных в бэкенде нет). Платный расчёт, который писать будет, открывается +отдельно и только вместе с этим контуром. + +БЮДЖЕТЫ +------- +Общий `RateLimitMiddleware` (300/60с на IP) здесь недостаточен: `/suggest` +через DaData-тир геокодера — платный внешний вызов, то есть абуз стоит денег, +а не только CPU. Поэтому у каждой ручки свой, заведомо более узкий per-IP +бюджет поверх общего — тот же приём, что у анонимного чата поддержки +(app/api/v1/support.py, `_anon_ip_limiter`). + +Лимитеры in-process: при нескольких репликах бэкенда бюджет умножится на их +число. Сейчас реплика одна (docker-compose, tradein-backend), что и делает +допущение верным; при масштабировании — выносить в Redis (issue заводить +тогда же, не раньше: преждевременный вынос добавит зависимость без выигрыша). +""" + +from __future__ import annotations + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy.orm import Session + +from app.api.v1.geocode import SuggestResponse, suggest_addresses +from app.api.v1.trade_in import coverage_probe +from app.core.db import get_db +from app.core.ratelimit import SlidingWindowLimiter, _client_ip +from app.schemas.trade_in import CoverageProbeInput, CoverageProbeResponse + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Бюджеты подобраны от живого сценария, а не «на глаз»: человек набирает адрес +# с debounce'ом — это единицы запросов на один адрес, поэтому 40/мин хватает +# на несколько попыток подряд и режет перебор словарём. Проба покрытия — шаг +# осознанный (нажатие кнопки), 15/мин с запасом покрывает «поправил площадь, +# нажал ещё раз». +_SUGGEST_LIMIT = 40 +_COVERAGE_LIMIT = 15 +_WINDOW_S = 60.0 + +_suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S) +_coverage_limiter = SlidingWindowLimiter(limit=_COVERAGE_LIMIT, window_s=_WINDOW_S) + + +def _enforce(limiter: SlidingWindowLimiter, request: Request, what: str) -> None: + """429 при превышении per-IP бюджета. Попытку регистрируем ДО работы ручки. + + В отличие от отправки сообщения в поддержку (там `record()` только на + успех, чтобы неудача не съедала бюджет), здесь считаем каждую попытку: + внешний вызов геокодера тратится и на запросе, который вернёт пусто, — + иначе перебор мусорными строками не стоил бы атакующему ничего. + """ + ip = _client_ip(request) + retry_after = limiter.retry_after(ip) + if retry_after is not None: + logger.info("public mera %s rate-limited for %s", what, ip) + raise HTTPException( + status_code=429, + detail="Слишком много запросов. Попробуйте через минуту.", + headers={"Retry-After": str(int(retry_after) + 1)}, + ) + limiter.record(ip) + + +@router.get("/suggest", response_model=SuggestResponse) +async def public_suggest( + request: Request, + q: Annotated[str, Query(min_length=2, max_length=200)], + db: Annotated[Session, Depends(get_db)], + limit: Annotated[int, Query(ge=1, le=10)] = 8, + city_hint: Annotated[str | None, Query(max_length=100)] = None, +) -> SuggestResponse: + """Автокомплит адреса для публичной формы (Свердловская область). + + Делегирует В ТУ ЖЕ функцию, что обслуживает B2B-экран + (`app.api.v1.geocode.suggest_addresses`), а не повторяет её логику: + публичная форма обязана резолвить адрес ровно так же, как платный расчёт, + иначе аноним выберет дом, которого потом «не окажется». + + Отличие от v1 ровно одно — потолок `limit` 10 вместо 15: выдача сверх + десятка в публичном UI не показывается, а каждый лишний кандидат может + стоить внешнего вызова. + """ + _enforce(_suggest_limiter, request, "suggest") + return await suggest_addresses(q=q, limit=limit, db=db, city_hint=city_hint) + + +@router.post("/coverage", response_model=CoverageProbeResponse) +def public_coverage( + request: Request, + payload: CoverageProbeInput, + db: Annotated[Session, Depends(get_db)], +) -> CoverageProbeResponse: + """Бесплатная проба покрытия (issue #2894) для публичной формы. + + Делегирует в `app.api.v1.trade_in.coverage_probe` — ту же функцию, что + вызывает закрытый контур. Копии SQL здесь нет намеренно: разбор #2894 + показал, что стоит когорте пробы разойтись с когортой платного расчёта — + проба честно отвечает «есть данные» там, где расчёт увидит ноль. + + Ответ не содержит ни одной цены (см. `CoverageProbeResponse`) — бесплатный + шаг доказывает наличие данных, цену продаёт платный. + """ + _enforce(_coverage_limiter, request, "coverage") + return coverage_probe(payload=payload, db=db) diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index 0dd654ff..bf7e7ead 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -86,6 +86,20 @@ _PUBLIC_PATHS = frozenset( # не секрет, читает только process env — быстрая справка для клиента/ # поддержки/смоук-теста, не должна требовать сессию. "/api/v1/trade-in/version", + # Публичный B2C-периметр МЕРЫ (meraocenka.ru): у посетителя лендинга + # идентичности нет и не будет — Caddy на этом домене вообще без + # basic_auth. Обе ручки только читают (SELECT/прокси автокомплита) и не + # пишут в БД адрес физлица, поэтому открываются ДО контура согласия + # 152-ФЗ (#2895), а платный расчёт — только вместе с ним. Разбор + # периметра и бюджетов — app/api/public/mera.py. + # + # Строки ТОЧНЫЕ, префиксной ветки здесь нет: множество проверяется как + # `path in _PUBLIC_PATHS`, и превращать его в префиксный матчер ради + # двух путей значит расширить механизм, от которого зависит auth-гейт + # всего бэкенда. Гарантия «под /api/public/ не лежит ничего закрытого» + # держится на структуре пакета app/api/public/, а не на матчере. + "/api/public/mera/suggest", + "/api/public/mera/coverage", } ) # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 8b76910e..8b5dbab2 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -20,6 +20,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.starlette import StarletteIntegration +from app.api.public import mera as public_mera from app.api.v1 import ( admin, audit, @@ -255,3 +256,9 @@ app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["building app.include_router(search.router, prefix="/api/v1", tags=["search"]) app.include_router(me.router, prefix="/api/v1", tags=["me"]) app.include_router(team.router, prefix="/api/v1/team", tags=["team"]) + +# Публичный B2C-периметр МЕРЫ (meraocenka.ru). Префикс НЕ /api/v1/* намеренно — +# на публичном домене он проксируется целиком, поэтому под ним по определению +# не должно лежать ничего закрытого; разбор — в app/api/public/mera.py. +# Обе ручки перечислены в rbac._PUBLIC_PATHS точными строками. +app.include_router(public_mera.router, prefix="/api/public/mera", tags=["public-mera"]) diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py new file mode 100644 index 00000000..71d3e966 --- /dev/null +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -0,0 +1,235 @@ +"""Публичный B2C-API МЕРЫ — /api/public/mera/* (issue #2911). + +Что здесь запинено и почему именно это: + + 1. ПЕРИМЕТР. Набор маршрутов под /api/public/ проверяется на РАВЕНСТВО, а не + на вхождение. Третья ручка, добавленная сюда без правки этого теста, + уронит его — а решение «эта ручка доступна анониму» не должно приниматься + мимоходом, добавлением файла в каталог. + + 2. СВЯЗКА С rbac. Публичность даёт не расположение файла, а строка в + `rbac._PUBLIC_PATHS`; тест сверяет, что для каждого маршрута она есть, и + наоборот — что `_PUBLIC_PATHS` не содержит /api/public/ путей, которых + больше нет в роутере (иначе освободившийся путь остался бы открытым для + любого, кто его потом займёт). + + 3. УЗОСТЬ ИСКЛЮЧЕНИЯ. Соседний non-public путь в том же приложении обязан + по-прежнему отдавать 401 анониму. Без этой проверки тест «аноним получает + 200» одинаково зелёный и когда исключение точечное, и когда auth-гейт + снят целиком. + + 4. БЮДЖЕТ. 429 после исчерпания per-IP окна — иначе внешний (платный) + геокодер открыт наружу без ограничений. +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +# Settings требует DATABASE_URL на конструирование — stub до любого app-импорта +# (тот же паттерн, что в остальных tests/*.py). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +# WeasyPrint (GTK) в CI/Windows нет, а app.api.v1.trade_in тянет его на импорте — +# public/mera.py делегирует туда, значит стаб нужен и здесь. +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) +sys.modules.setdefault("weasyprint.CSS", _wp_mock) +sys.modules.setdefault("weasyprint.HTML", _wp_mock) + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from app.api.public import mera as public_mera # noqa: E402 +from app.api.v1.geocode import SuggestResponse # noqa: E402 +from app.core.db import get_db # noqa: E402 +from app.core.rbac import _PUBLIC_PATHS, rbac_guard # noqa: E402 +from app.schemas.trade_in import CoverageProbeResponse # noqa: E402 + +# Внешний префикс монтирования — ровно тот, что в app/main.py. +PREFIX = "/api/public/mera" + +_COVERAGE_BODY = {"lat": 56.838, "lon": 60.597, "rooms": 2, "area_m2": 54.0} + +_FAKE_COVERAGE = CoverageProbeResponse( + status="ok", + n_listings=34, + median_listing_age_days=44, + n_with_age=6, + radius_m=1000, + city="Екатеринбург", + threshold=10, +) + + +@pytest.fixture(autouse=True) +def _reset_limiters(): + """Лимитеры — состояние МОДУЛЯ, переживающее тест. + + Без сброса порядок тестов начинает влиять на результат: тест бюджета + выбирает окно, а следующий за ним «аноним получает 200» ловит 429 и падает + не по своей вине. + """ + public_mera._suggest_limiter._hits.clear() + public_mera._coverage_limiter._hits.clear() + yield + public_mera._suggest_limiter._hits.clear() + public_mera._coverage_limiter._hits.clear() + + +@pytest.fixture() +def client() -> TestClient: + """Приложение с РЕАЛЬНЫМ rbac_guard — тем же, что регистрирует app/main.py. + + Рядом с публичным роутером намеренно висит закрытый маршрут-двойник: он и + делает проверку 3 (узость исключения) возможной внутри одного приложения. + """ + app = FastAPI() + app.middleware("http")(rbac_guard) + app.include_router(public_mera.router, prefix=PREFIX) + + @app.get("/api/v1/trade-in/coverage") + def _closed_twin() -> dict[str, str]: # pragma: no cover — до хендлера не доходит + return {"ok": "should never be reachable anonymously"} + + def _override_db(): + yield MagicMock() + + app.dependency_overrides[get_db] = _override_db + return TestClient(app) + + +# ── 1-2. Периметр и его связка с rbac ──────────────────────────────────────── + + +def test_public_router_exposes_exactly_two_routes() -> None: + paths = {r.path for r in public_mera.router.routes} + assert paths == {"/suggest", "/coverage"}, ( + "изменился набор публичных (анонимных) ручек МЕРЫ. Это не рефакторинг: " + "всё под /api/public/ проксируется на meraocenka.ru целиком и доступно " + "без идентичности. Обнови тест ОСОЗНАННО вместе с rbac._PUBLIC_PATHS." + ) + + +def test_every_public_route_is_exempt_in_rbac() -> None: + for route in public_mera.router.routes: + full = f"{PREFIX}{route.path}" + assert full in _PUBLIC_PATHS, ( + f"{full} смонтирован в публичном пакете, но rbac его не пропускает — " + "анониму прилетит 401, форма на лэндинге молча сломается" + ) + + +def test_rbac_has_no_stale_public_mera_exemptions() -> None: + """Обратное направление: в `_PUBLIC_PATHS` нет /api/public/ путей-призраков. + + Удалённая ручка, чья строка осталась в исключениях, открывает этот путь + заранее — для любого маршрута, который потом займёт то же место. + """ + live = {f"{PREFIX}{r.path}" for r in public_mera.router.routes} + stale = {p for p in _PUBLIC_PATHS if p.startswith("/api/public/")} - live + assert not stale, f"в rbac._PUBLIC_PATHS остались несуществующие пути: {stale}" + + +# ── 3. Аноним проходит, но исключение узкое ────────────────────────────────── + + +def test_anonymous_gets_coverage(client: TestClient) -> None: + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY) + assert resp.status_code == 200, resp.text + assert resp.json()["city"] == "Екатеринбург" + + +def test_anonymous_gets_suggest(client: TestClient) -> None: + with patch.object( + public_mera, + "suggest_addresses", + AsyncMock(return_value=SuggestResponse(items=[])), + ): + resp = client.get(f"{PREFIX}/suggest", params={"q": "Малышева"}) + assert resp.status_code == 200, resp.text + assert resp.json() == {"items": []} + + +def test_closed_twin_still_401_for_anonymous(client: TestClient) -> None: + """Исключение точечное, а не «auth-гейт снят». + + Без этой проверки тесты выше остаются зелёными и в случае, когда + rbac_guard перестал требовать идентичность вообще. + """ + resp = client.get("/api/v1/trade-in/coverage") + assert resp.status_code == 401, resp.text + + +# ── 4. Бюджеты ─────────────────────────────────────────────────────────────── + + +def test_coverage_rate_limited_per_ip(client: TestClient) -> None: + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + codes = [ + client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY).status_code + for _ in range(public_mera._COVERAGE_LIMIT + 2) + ] + assert codes[: public_mera._COVERAGE_LIMIT] == [200] * public_mera._COVERAGE_LIMIT + assert codes[-1] == 429, f"бюджет не сработал: {codes}" + + +def test_rate_limited_response_carries_retry_after(client: TestClient) -> None: + """429 без Retry-After — это «попробуйте когда-нибудь»: клиент начнёт + долбить сразу же, а лимит существует ровно чтобы этого не было.""" + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + for _ in range(public_mera._COVERAGE_LIMIT): + client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY) + resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY) + assert resp.status_code == 429 + assert int(resp.headers["Retry-After"]) > 0 + + +def test_suggest_and_coverage_budgets_are_independent(client: TestClient) -> None: + """Общий лимитер на две ручки означал бы, что набор адреса (частые + подсказки) выедает бюджет пробы — человек не смог бы нажать кнопку.""" + with patch.object( + public_mera, + "suggest_addresses", + AsyncMock(return_value=SuggestResponse(items=[])), + ): + for _ in range(public_mera._SUGGEST_LIMIT): + client.get(f"{PREFIX}/suggest", params={"q": "Малышева"}) + + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY) + assert resp.status_code == 200, resp.text + + +# ── Контракт ответа ────────────────────────────────────────────────────────── + + +def test_public_coverage_reuses_v1_response_model(client: TestClient) -> None: + """Публичная ручка отдаёт ТОТ ЖЕ тип, что и v1. + + Это не косметика: на `CoverageProbeResponse` уже стоит гейт «ни одного + price-подобного поля» (tests/test_coverage_probe_endpoint.py). Заведи здесь + отдельную модель — и публичный ответ окажется вне того гейта, а именно он + держит продуктовое правило «бесплатный шаг не отдаёт цену». + """ + route = next(r for r in public_mera.router.routes if r.path == "/coverage") + assert route.response_model is CoverageProbeResponse + + +def test_suggest_limit_ceiling_is_lower_than_v1(client: TestClient) -> None: + """Потолок limit публичной ручки — 10 (у v1 15). Каждый лишний кандидат + может стоить внешнего (платного) вызова, а публичный UI больше и не + показывает.""" + with patch.object( + public_mera, + "suggest_addresses", + AsyncMock(return_value=SuggestResponse(items=[])), + ): + at_ceiling = client.get(f"{PREFIX}/suggest", params={"q": "Малышева", "limit": 10}) + above = client.get(f"{PREFIX}/suggest", params={"q": "Малышева", "limit": 15}) + assert at_ceiling.status_code == 200, at_ceiling.text + assert above.status_code == 422, above.text