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,
|
||||
# /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется.
|
||||
# /scrapers/*, /trade-in/api/v1/*, /history, ...) — 404, НЕ проксируется.
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
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 — быстрая справка для клиента/
|
||||
# поддержки/смоук-теста, не должна требовать сессию.
|
||||
"/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) перед
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
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