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
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:
parent
7537b54dbd
commit
d046eaada2
7 changed files with 476 additions and 1 deletions
32
Caddyfile
32
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,
|
# Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin,
|
||||||
# /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется.
|
# /scrapers/*, /trade-in/api/v1/*, /history, ...) — 404, НЕ проксируется.
|
||||||
handle {
|
handle {
|
||||||
respond 404
|
respond 404
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,19 @@ check() {
|
||||||
fi
|
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) =="
|
echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
|
||||||
|
|
||||||
# 1. Публичный домен отдаёт 200 анонимно.
|
# 1. Публичный домен отдаёт 200 анонимно.
|
||||||
|
|
@ -79,6 +92,26 @@ check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)
|
||||||
# Ловит расширение матчера обратно до `/trade-in/_next/*`.
|
# Ловит расширение матчера обратно до `/trade-in/_next/*`.
|
||||||
check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404
|
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 по-прежнему закрыты анониму.
|
# 3. B2B-данные trade-in по-прежнему закрыты анониму.
|
||||||
#
|
#
|
||||||
# ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200.
|
# ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200.
|
||||||
|
|
|
||||||
7
tradein-mvp/backend/app/api/public/__init__.py
Normal file
7
tradein-mvp/backend/app/api/public/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Публичный (анонимный) API — поверхность, открытая на meraocenka.ru.
|
||||||
|
|
||||||
|
Отдельный пакет, а не ещё один модуль в `app/api/v1/`, — намеренно: всё, что
|
||||||
|
лежит здесь, доступно БЕЗ идентичности, поэтому периметр должен читаться по
|
||||||
|
дереву каталогов, а не по списку исключений в middleware. Разбор — в
|
||||||
|
`app/api/public/mera.py`.
|
||||||
|
"""
|
||||||
149
tradein-mvp/backend/app/api/public/mera.py
Normal file
149
tradein-mvp/backend/app/api/public/mera.py
Normal 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)
|
||||||
|
|
@ -86,6 +86,20 @@ _PUBLIC_PATHS = frozenset(
|
||||||
# не секрет, читает только process env — быстрая справка для клиента/
|
# не секрет, читает только process env — быстрая справка для клиента/
|
||||||
# поддержки/смоук-теста, не должна требовать сессию.
|
# поддержки/смоук-теста, не должна требовать сессию.
|
||||||
"/api/v1/trade-in/version",
|
"/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) перед
|
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||||
|
|
||||||
|
from app.api.public import mera as public_mera
|
||||||
from app.api.v1 import (
|
from app.api.v1 import (
|
||||||
admin,
|
admin,
|
||||||
audit,
|
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(search.router, prefix="/api/v1", tags=["search"])
|
||||||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
||||||
app.include_router(team.router, prefix="/api/v1/team", tags=["team"])
|
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"])
|
||||||
|
|
|
||||||
235
tradein-mvp/backend/tests/test_public_mera_api.py
Normal file
235
tradein-mvp/backend/tests/test_public_mera_api.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue