feat(mera/b2c): публичный API МЕРЫ — /api/public/mera/{suggest,coverage}
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
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 4m49s

Первый шаг к отдельному 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
This commit is contained in:
bot-backend 2026-08-15 22:32:53 +03:00
parent 7537b54dbd
commit d046eaada2
7 changed files with 476 additions and 1 deletions

View file

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

View file

@ -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:-<no response>}', 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.

View file

@ -0,0 +1,7 @@
"""Публичный (анонимный) API — поверхность, открытая на meraocenka.ru.
Отдельный пакет, а не ещё один модуль в `app/api/v1/`, намеренно: всё, что
лежит здесь, доступно БЕЗ идентичности, поэтому периметр должен читаться по
дереву каталогов, а не по списку исключений в middleware. Разбор в
`app/api/public/mera.py`.
"""

View file

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

View file

@ -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) перед

View file

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

View file

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