From 5e39bfcb23b9e403167ec7419d513c8c4b8fc759 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 18:44:18 +0500 Subject: [PATCH] =?UTF-8?q?feat(mera):=20=D0=B0=D0=BD=D0=BE=D0=BD=D0=B8?= =?UTF-8?q?=D0=BC=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82?= =?UTF-8?q?=20=D0=B8=20=D0=BA=D0=B0=D0=BF=D0=B0=D0=B1=D0=B8=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D0=B8-=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B5=D0=B3=D0=BE=20=D0=B1=D0=B5=D1=81=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D0=BD=D1=83=D1=8E=20=D1=87=D0=B0=D1=81=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Публичный контур умел только подсказки и пробу покрытия: полный расчёт закрыт RBAC, а результат анонима нельзя было прочитать повторно — _assert_estimate_access отдаёт 404 на строку с created_by IS NULL всем, кроме админа, то есть расчёт жил ровно в теле POST-ответа и не переживал перезагрузку страницы. POST /api/public/mera/estimate делегирует в app.api.v1.trade_in.estimate (копии логики нет — иначе публичная когорта разъедется с платной) и отдаёт наружу только бесплатную часть: число аналогов и вердикт покрытия из той же coverage_probe. Цены, прогнозы и списки аналогов остаются в БД для платного контура. Согласие 152-ФЗ обязательно и строго True на уровне схемы, поэтому отказ происходит до входа в хендлер — раньше, чем адрес физлица дошёл бы до БД. POST /api/public/mera/estimate/read читает бесплатную часть по токену (secrets.token_urlsafe(32), в БД только sha256, срок жизни 7 дней, миграция 278). Токен едет телом: access-лог Caddy пишет URI целиком, и капабилити-ссылка в пути легла бы в файл рядом с IP посетителя — тот же довод, по которому POST'ом сделан /suggest. Постоянный путь заодно не требует префиксной ветки в rbac._PUBLIC_PATHS. Всё закрыто флагом public_estimate_enabled (дефолт false → 404): включение открывает запись ПДн и требует решения владельца вместе с правкой политики. --- tradein-mvp/backend/app/api/public/mera.py | 293 ++++++++++++++++- tradein-mvp/backend/app/core/config.py | 8 + tradein-mvp/backend/app/core/rbac.py | 7 + .../278_trade_in_estimates_public_token.sql | 37 +++ .../backend/tests/test_public_mera_api.py | 4 +- .../tests/test_public_mera_estimate.py | 304 ++++++++++++++++++ 6 files changed, 634 insertions(+), 19 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/278_trade_in_estimates_public_token.sql create mode 100644 tradein-mvp/backend/tests/test_public_mera_estimate.py diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index 3c2bc5f2..6becf189 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -1,4 +1,4 @@ -"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный, ровно две ручки. +"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный. ЗАЧЕМ ОТДЕЛЬНЫЙ ПРЕФИКС, А НЕ ОТКРЫТИЕ КУСКА /api/v1/* ------------------------------------------------------- @@ -26,20 +26,29 @@ API, нужно было выбрать одно из двух: АНОНИМНОСТЬ ----------- `rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого -non-public пути. Обе ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками — +non-public пути. Все ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками — не префиксом: множество там — frozenset с проверкой `path in ...`, и -добавление префиксной ветки ради двух путей расширило бы механизм, которым -пользуется весь бэкенд, ради одной фичи. +добавление префиксной ветки расширило бы механизм, которым пользуется весь +бэкенд, ради одной фичи. Поэтому и чтение по токену — POST с постоянным +путём `/estimate/read`, а не `GET /estimate/{token}`: переменный сегмент +пути потребовал бы ровно такой префиксной ветки (плюс сам токен уехал бы в +access-лог Caddy, см. `PublicEstimateTokenInput`). -ЧТО ЭТИ РУЧКИ НЕ ДЕЛАЮТ ------------------------ -Ни одна из них не пишет в БД строк с адресом пользователя: `/coverage` — -чистое чтение (один SELECT), `/suggest` — прокси автокомплита. Это не -случайность, а условие, при котором публичная форма может работать ДО того, -как появится контур согласия 152-ФЗ (issue #2895: сегодня адрес физлица -попадает в `trade_in_estimates` раньше любого согласия, а пути удаления -данных в бэкенде нет). Платный расчёт, который писать будет, открывается -отдельно и только вместе с этим контуром. +ЧТО ЭТИ РУЧКИ ДЕЛАЮТ С ДАННЫМИ +------------------------------ +`/suggest` и `/coverage` не пишут в БД ничего: первый — прокси автокомплита, +второй — один SELECT. Это по-прежнему так и меняться не должно. + +`/estimate` — единственная, которая ПИШЕТ строку с адресом физлица (issue +#2895), и поэтому устроена иначе: она закрыта флагом +`settings.public_estimate_enabled` (дефолт false → 404) и требует явного +согласия 152-ФЗ в payload'е строго `True` — на уровне схемы, то есть 422 +прилетает до входа в хендлер и до любого обращения к БД. Пути удаления +данных в бэкенде всё ещё нет; включать флаг на проде — вместе с ним и с +правкой п.5.5 политики обработки ПДн. + +`/estimate/read` возвращает по капабилити-токену ТОЛЬКО бесплатную часть +(`PublicEstimateResult`) — цену и прогнозы продаёт платный контур. БЮДЖЕТЫ ------- @@ -58,19 +67,28 @@ non-public пути. Обе ручки перечислены в `_PUBLIC_PATHS` from __future__ import annotations import asyncio +import hashlib import logging -from typing import Annotated +import secrets +from datetime import datetime +from typing import Annotated, Literal -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel, Field +from sqlalchemy import text 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.api.v1.trade_in import coverage_probe, estimate +from app.core.config import settings from app.core.db import get_db from app.core.public_request import install_address_log_redaction, public_request_scope from app.core.ratelimit import SlidingWindowLimiter, _client_ip -from app.schemas.trade_in import CoverageProbeInput, CoverageProbeResponse +from app.schemas.trade_in import ( + CoverageProbeInput, + CoverageProbeResponse, + TradeInEstimateInput, +) logger = logging.getLogger(__name__) @@ -91,10 +109,21 @@ router = APIRouter() # нажал ещё раз». _SUGGEST_LIMIT = 20 _COVERAGE_LIMIT = 15 +# Расчёт — самый дорогой шаг публичного контура (геокодер + десяток SQL по +# листингам + запись строки). Бюджет намеренно ниже пробы покрытия: живой +# человек нажимает «рассчитать» единицы раз, а анонимная месячная квота +# (settings.anon_estimate_quota_limit) обходится сменой IP — минутное окно +# делает такой обход дорогим по времени. +_ESTIMATE_LIMIT = 5 +# Чтение по токену дешевле расчёта (один SELECT по уникальному индексу), но +# ослаблять его до бесконечности нельзя: перебор токенов — это перебор. +_ESTIMATE_READ_LIMIT = 30 _WINDOW_S = 60.0 _suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S) _coverage_limiter = SlidingWindowLimiter(limit=_COVERAGE_LIMIT, window_s=_WINDOW_S) +_estimate_limiter = SlidingWindowLimiter(limit=_ESTIMATE_LIMIT, window_s=_WINDOW_S) +_estimate_read_limiter = SlidingWindowLimiter(limit=_ESTIMATE_READ_LIMIT, window_s=_WINDOW_S) # ── Общий суточный потолок публичных подсказок ────────────────────────────── # @@ -286,3 +315,233 @@ def public_coverage( """ _enforce(_coverage_limiter, request, "coverage") return coverage_probe(payload=payload, db=db) + + +# ── Анонимный расчёт и повторное чтение его бесплатной части ──────────────── +# +# ФЛАГ. Обе ручки ниже мертвы, пока `settings.public_estimate_enabled` не +# включён в .env.runtime: это первая пара публичных ручек, которая ПИШЕТ в +# `trade_in_estimates` адрес физлица, а такое включение — продуктовое решение +# владельца (нужна правка п.5.5 политики обработки ПДн, она сегодня анонимный +# расчёт не описывает), а не следствие мержа кода. Тот же приём, что у +# `payments_enabled`. Отвечаем 404, а не 403: выключенная ручка не должна +# подтверждать, что она существует. +# +# ОБЩИЙ СУТОЧНЫЙ ПОТОЛОК. Per-IP окно ограничивает одного клиента, но не сумму: +# 5/мин с адреса — это 7200 расчётов в сутки, каждый из которых дёргает +# геокодер и пишет строку. Потолок ниже — про защиту закрытого контура и +# квоты геокодера (тот же довод, что у `_DAILY_SUGGEST_BUDGET`), исчерпание — +# сигнал абуза, не штатный режим. +_DAILY_ESTIMATE_BUDGET = 300 +_daily_estimate_limiter = SlidingWindowLimiter(limit=_DAILY_ESTIMATE_BUDGET, window_s=86_400.0) +_ESTIMATE_GLOBAL_KEY = "public-estimate" + +# Срок жизни капабилити-ссылки. Ссылка — единственный способ анонима вернуться +# к своему расчёту (аккаунта у него нет), поэтому недели мало не будет для +# «оплатил, закрыл вкладку, вернулся вечером», а бесконечной она быть не может: +# это ссылка на данные о конкретной квартире конкретного человека. +_TOKEN_TTL = "7 days" + + +def _require_public_estimate_enabled() -> None: + """404, пока публичный расчёт не включён владельцем явно.""" + if not settings.public_estimate_enabled: + raise HTTPException(status_code=404, detail="Not Found") + + +def _token_hash(token: str) -> str: + """sha256(hex) — в БД лежит только это, сам токен не хранится нигде. + + Без соли намеренно: вход — `secrets.token_urlsafe(32)` (256 бит), перебор + по словарю невозможен, а соль сделала бы невозможным поиск по равенству. + """ + return hashlib.sha256(token.encode()).hexdigest() + + +class PublicEstimateInput(TradeInEstimateInput): + """Вход анонимного расчёта: тот же payload, что у закрытого контура, но + согласие 152-ФЗ — ОБЯЗАТЕЛЬНОЕ и строго True. + + В базовой схеме `consent: bool | None = None`: сделать его обязательным там + нельзя — B2B-пилоты согласия в UI не дают, у них договор, и их фронт поля + не шлёт. Здесь же анонимный посетитель — единственный источник согласия, + поэтому `Literal[True]`: без него Pydantic отвечает 422 ДО входа в + хендлер, то есть до первого обращения к БД. Это не дубль гейта в + `estimate_quality` (тот ловит любых вызывающих), а его сдвиг на самую + раннюю возможную границу — «согласие фиксируется до первого INSERT» + перестаёт зависеть от порядка строк внутри эстиматора. + """ + + consent: Literal[True] + + +class PublicEstimateTokenInput(BaseModel): + """Токен едет ТЕЛОМ, а ручка чтения — POST, а не GET /{token}. + + Причина ровно та же, по которой POST'ом сделан `/suggest`: access-лог Caddy + на публичном домене пишет URI целиком, поэтому капабилити-ссылка в пути + легла бы в файл рядом с IP посетителя — и любой, у кого есть доступ к + логам (или их бэкапу), открыл бы чужой расчёт. Токен — это пароль; пароли + в URL не кладут. + """ + + token: str = Field(min_length=16, max_length=128) + + +class PublicEstimateResult(BaseModel): + """БЕСПЛАТНАЯ часть расчёта — ровно то, что можно показать до оплаты. + + Здесь СОЗНАТЕЛЬНО нет ни одного поля из `AggregatedEstimate` с ценой, + прогнозом или списком аналогов (`median_price_rub`, `range_*`, + `expected_sold_*`, `analogs`, `actual_deals`, `market_percentile`, + `est_days_on_market`, `cian_valuation`, `avito_imv`, `dkp_corridor`…). + Модель отдельная, а не `AggregatedEstimate` с `exclude`: список исключений + надо помнить и дополнять при каждом новом поле эстиматора, а отдельная + модель молчит по умолчанию — новое платное поле не утечёт само. + + `coverage` — тот же ответ, что у бесплатной пробы `/coverage` (в нём цен + нет по построению, см. `CoverageProbeResponse`): число похожих квартир, + возраст объявлений, вердикт покрытия. null — координаты дома не + разрезолвились, честнее отдать «неизвестно», чем правдоподобное число. + """ + + token: str + token_expires_at: datetime + n_analogs: int + coverage: CoverageProbeResponse | None = None + + +def _coverage_for( + db: Session, lat: float | None, lon: float | None, rooms: int, area_m2: float +) -> CoverageProbeResponse | None: + """Вердикт покрытия для уже посчитанной оценки — через ту же `coverage_probe`. + + Своей копии порогов здесь нет намеренно: разъедься она с бесплатной пробой, + один и тот же адрес получил бы «есть данные» на одном экране и «мало» на + соседнем. + """ + if lat is None or lon is None: + return None + return coverage_probe( + payload=CoverageProbeInput(lat=lat, lon=lon, rooms=rooms, area_m2=area_m2), + db=db, + ) + + +@router.post("/estimate", response_model=PublicEstimateResult) +async def public_estimate( + request: Request, + response: Response, + payload: PublicEstimateInput, + db: Annotated[Session, Depends(get_db)], +) -> PublicEstimateResult: + """Анонимный расчёт: считает полную оценку, отдаёт только бесплатную часть. + + Делегирует в `app.api.v1.trade_in.estimate` — ту же функцию, что обслуживает + закрытый контур, а не копию её тела. Оттуда же бесплатно достаётся всё + анти-абузное хозяйство: анонимная квота на связку (подписанная cookie + IP) + с лимитом `settings.anon_estimate_quota_limit`, семафор одновременности, + 503 вместо непрозрачного 502 при сбое и consent-гейт (`require_consent` + включается ровно потому, что мы зовём её без `X-Authenticated-User`). + + Полный расчёт при этом СОХРАНЯЕТСЯ в `trade_in_estimates` целиком — платный + контур (сосед, `/api/v1/trade-in/r/{token}`) открывает его после оплаты по + своему токену. Наружу здесь уезжает только `PublicEstimateResult`. + """ + _require_public_estimate_enabled() + _enforce(_estimate_limiter, request, "estimate") + + daily_retry = _daily_estimate_limiter.retry_after(_ESTIMATE_GLOBAL_KEY) + if daily_retry is not None: + logger.error( + "публичные расчёты исчерпали суточный бюджет (%d) — закрытый контур " + "защищён, но форма на лэндинге сейчас не считает", + _DAILY_ESTIMATE_BUDGET, + ) + raise HTTPException( + status_code=429, + detail="Расчёт временно недоступен. Попробуйте позже.", + headers={"Retry-After": str(int(daily_retry) + 1)}, + ) + _daily_estimate_limiter.record(_ESTIMATE_GLOBAL_KEY) + + # Пометка публичного запроса — как у `/suggest`: внутри цепочки геокодер + # печатает введённый адрес, а публичная форма обещает обратное. + with public_request_scope(): + result = await estimate( + payload=payload, + request=request, + response=response, + db=db, + x_authenticated_user=None, + ) + + # Токен выдаём ПОСЛЕ успешного расчёта: ссылка на несуществующий результат + # не нужна никому, а строка в БД уже есть — estimate() её закоммитил. + token = secrets.token_urlsafe(32) + row = db.execute( + text( + """ + UPDATE trade_in_estimates + SET public_token_hash = :token_hash, + public_token_expires_at = NOW() + CAST(:ttl AS interval) + WHERE id = CAST(:id AS uuid) + RETURNING public_token_expires_at + """ + ), + {"token_hash": _token_hash(token), "ttl": _TOKEN_TTL, "id": str(result.estimate_id)}, + ).fetchone() + db.commit() + if row is None: # pragma: no cover — оценка только что записана этой же транзакцией + raise HTTPException(status_code=503, detail="estimate temporarily unavailable") + + return PublicEstimateResult( + token=token, + token_expires_at=row.public_token_expires_at, + n_analogs=result.n_analogs, + coverage=_coverage_for( + db, result.target_lat, result.target_lon, payload.rooms, payload.area_m2 + ), + ) + + +@router.post("/estimate/read", response_model=PublicEstimateResult) +def public_estimate_read( + request: Request, + payload: PublicEstimateTokenInput, + db: Annotated[Session, Depends(get_db)], +) -> PublicEstimateResult: + """Повторное чтение бесплатной части по капабилити-токену. + + Существует потому, что у анонима нет аккаунта: без этой ручки результат + жил бы ровно в теле POST-ответа и не переживал бы перезагрузку страницы + (`_assert_estimate_access` в закрытом контуре отдаёт 404 на оценку с + `created_by IS NULL` всем, кроме админа — и это правильно, менять его + ради анонима значило бы ослабить IDOR-гейт для всех). + + Просрочка и «нет такого токена» отвечают ОДИНАКОВО (404): различать их + значит подтверждать существование расчёта тому, кто угадал токен. + """ + _require_public_estimate_enabled() + _enforce(_estimate_read_limiter, request, "estimate-read") + + row = db.execute( + text( + """ + SELECT n_analogs, lat, lon, rooms, area_m2, public_token_expires_at + FROM trade_in_estimates + WHERE public_token_hash = :token_hash + AND public_token_expires_at > NOW() + """ + ), + {"token_hash": _token_hash(payload.token)}, + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Расчёт не найден или ссылка устарела.") + + return PublicEstimateResult( + token=payload.token, + token_expires_at=row.public_token_expires_at, + n_analogs=row.n_analogs, + coverage=_coverage_for(db, row.lat, row.lon, row.rooms, row.area_m2), + ) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index af4081b8..959ad820 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -1226,5 +1226,13 @@ class Settings(BaseSettings): # обязаны отказывать сразу, ничего не вызывая у T-Bank. payments_enabled: bool = Field(default=False, validation_alias="PAYMENTS_ENABLED") + # Kill-switch анонимного расчёта на публичном домене (POST/GET + # /api/public/mera/estimate*). false — обе ручки отвечают 404, как будто их + # нет: включение публичного расчёта открывает запись адреса физлица в + # trade_in_estimates, и решение это продуктовое (нужна правка п.5.5 политики + # обработки ПДн), а не «смержили код». Тот же приём и та же причина, что у + # payments_enabled выше. ENV: PUBLIC_ESTIMATE_ENABLED. + public_estimate_enabled: bool = Field(default=False, validation_alias="PUBLIC_ESTIMATE_ENABLED") + settings = Settings() diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index 7966280d..66c421bc 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -114,6 +114,13 @@ _PUBLIC_PATHS = frozenset( # держится на структуре пакета app/api/public/, а не на матчере. "/api/public/mera/suggest", "/api/public/mera/coverage", + # Анонимный расчёт и повторное чтение его бесплатной части. Оба — + # POST с точным путём: у чтения токен едет ТЕЛОМ, а не в URI, иначе + # капабилити-ссылка легла бы в access-лог Caddy рядом с IP посетителя + # (тот же довод, что у /suggest — см. app/api/public/mera.py). + # Обе ручки дополнительно закрыты флагом settings.public_estimate_enabled. + "/api/public/mera/estimate", + "/api/public/mera/estimate/read", } ) # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед diff --git a/tradein-mvp/backend/data/sql/278_trade_in_estimates_public_token.sql b/tradein-mvp/backend/data/sql/278_trade_in_estimates_public_token.sql new file mode 100644 index 00000000..0b4445a6 --- /dev/null +++ b/tradein-mvp/backend/data/sql/278_trade_in_estimates_public_token.sql @@ -0,0 +1,37 @@ +-- 278_trade_in_estimates_public_token.sql +-- Purpose: capability-ссылка на БЕСПЛАТНУЮ часть анонимного расчёта +-- (GET /api/public/mera/estimate/{token}). +-- +-- ЗАЧЕМ КОЛОНКА, А НЕ ОТДЕЛЬНАЯ ТАБЛИЦА. Токен — атрибут ровно одной оценки и +-- живёт/умирает вместе с ней; таблица 1:1 добавила бы join и вторую точку, +-- где строку можно забыть удалить. +-- +-- ХРАНИМ ХЭШ, А НЕ ТОКЕН. Дамп/бэкап/случайный SELECT в поддержке не должны +-- давать доступ к чужому расчёту. sha256 без соли осознанно: вход — +-- secrets.token_urlsafe(32), 256 бит энтропии, словарь по нему невозможен, +-- а соль сломала бы поиск по равенству (пришлось бы сканировать таблицу). +-- +-- ПОЧЕМУ ОТДЕЛЬНЫЙ СРОК, А НЕ expires_at/retain_until. expires_at — про то, +-- сколько живёт сам расчёт, retain_until двигает контур оплаты. Ссылка на +-- бесплатную часть — третье, независимое обещание («ссылка работает N дней»), +-- и склеивать его с чужими сроками значит менять его молча при каждой правке +-- соседей. +-- +-- Dependencies: trade_in_estimates (миграция 001+). +-- Deploy order: применять после 277. + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS public_token_hash text, + ADD COLUMN IF NOT EXISTS public_token_expires_at timestamptz; + +-- UNIQUE, а не просто индекс: коллизия хэшей означала бы, что по одной ссылке +-- отдаются два разных расчёта. Partial — токен есть у меньшинства строк +-- (B2B-пилоты его не получают вовсе), индексировать NULL'ы незачем. +CREATE UNIQUE INDEX IF NOT EXISTS idx_trade_in_estimates_public_token_hash + ON trade_in_estimates (public_token_hash) + WHERE public_token_hash IS NOT NULL; + +COMMENT ON COLUMN trade_in_estimates.public_token_hash IS + 'sha256(hex) капабилити-токена бесплатной части (app/api/public/mera.py). Сам токен не хранится.'; +COMMENT ON COLUMN trade_in_estimates.public_token_expires_at IS + 'До какого момента работает GET /api/public/mera/estimate/{token}. Не связан с expires_at/retain_until.'; diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index 5b834927..84ee893b 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -105,9 +105,9 @@ def client() -> TestClient: # ── 1-2. Периметр и его связка с rbac ──────────────────────────────────────── -def test_public_router_exposes_exactly_two_routes() -> None: +def test_public_router_exposes_exactly_the_declared_routes() -> None: paths = {r.path for r in public_mera.router.routes} - assert paths == {"/suggest", "/coverage"}, ( + assert paths == {"/suggest", "/coverage", "/estimate", "/estimate/read"}, ( "изменился набор публичных (анонимных) ручек МЕРЫ. Это не рефакторинг: " "всё под /api/public/ проксируется на meraocenka.ru целиком и доступно " "без идентичности. Обнови тест ОСОЗНАННО вместе с rbac._PUBLIC_PATHS." diff --git a/tradein-mvp/backend/tests/test_public_mera_estimate.py b/tradein-mvp/backend/tests/test_public_mera_estimate.py new file mode 100644 index 00000000..26df13b2 --- /dev/null +++ b/tradein-mvp/backend/tests/test_public_mera_estimate.py @@ -0,0 +1,304 @@ +"""Анонимный расчёт /api/public/mera/estimate* — что здесь запинено и почему. + +1. СОГЛАСИЕ ДО ЗАПИСИ. Отказ без согласия обязан случиться ДО того, как + адрес физлица дойдёт до БД. Поэтому проверяется не только код 422, но и + то, что расчёт вообще не запускался и в сессию не ушло ни одного запроса: + «422, но строка записана» выглядит в логах как успех приватности и им не + является. + +2. ФЛАГ. Выключенный `public_estimate_enabled` обязан давать 404 — иначе + мерж этого кода сам по себе открывает наружу запись ПДн. + +3. ТОКЕН. В БД уходит ХЭШ, а не токен; выборка отфильтрована по сроку + жизни; «нет такого» и «протух» неотличимы снаружи. + +4. БЕСПЛАТНАЯ ЧАСТЬ. Публичный ответ не содержит ни одного платного поля. + Проверяется набором ключей на равенство: любое добавленное поле роняет + тест, в том числе случайно добавленное платное. +""" + +from __future__ import annotations + +import hashlib +import os +import sys +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_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.core.db import get_db # noqa: E402 +from app.schemas.trade_in import AggregatedEstimate, CoverageProbeResponse # noqa: E402 + +PREFIX = "/api/public/mera" + +_BODY = { + "address": "Малышева 51", + "area_m2": 54.0, + "rooms": 2, + "city_hint": "Екатеринбург", + "consent": True, +} + +_FAKE_COVERAGE = CoverageProbeResponse( + status="ok", + n_listings=34, + median_listing_age_days=44, + n_with_age=6, + radius_m=1000, + city="Екатеринбург", + threshold=10, +) + +_TOKEN_EXPIRES = datetime(2026, 9, 5, 12, 0, tzinfo=UTC) + + +def _fake_estimate_result() -> MagicMock: + """Результат закрытого контура. MagicMock, а не собранный AggregatedEstimate: + хендлеру нужны четыре атрибута, а перечисление всех полей платной модели + здесь означало бы, что тест придётся править при каждой правке эстиматора.""" + result = MagicMock() + result.estimate_id = "11111111-1111-1111-1111-111111111111" + result.n_analogs = 27 + result.target_lat = 56.838 + result.target_lon = 60.597 + return result + + +@pytest.fixture(autouse=True) +def _reset_limiters(): + public_mera._estimate_limiter._hits.clear() + public_mera._estimate_read_limiter._hits.clear() + public_mera._daily_estimate_limiter._hits.clear() + yield + public_mera._estimate_limiter._hits.clear() + public_mera._estimate_read_limiter._hits.clear() + public_mera._daily_estimate_limiter._hits.clear() + + +@pytest.fixture() +def db() -> MagicMock: + session = MagicMock() + session.execute.return_value.fetchone.return_value = MagicMock( + public_token_expires_at=_TOKEN_EXPIRES, + n_analogs=27, + lat=56.838, + lon=60.597, + rooms=2, + area_m2=54.0, + ) + return session + + +@pytest.fixture() +def client(db: MagicMock) -> TestClient: + """Без rbac-мидлвари: узость auth-исключения проверяет соседний + tests/test_public_mera_api.py, здесь предмет — сами ручки.""" + app = FastAPI() + app.include_router(public_mera.router, prefix=PREFIX) + app.dependency_overrides[get_db] = lambda: db + return TestClient(app) + + +@pytest.fixture() +def flag_on(): + with patch.object(public_mera.settings, "public_estimate_enabled", True): + yield + + +# ── 1. Согласие фиксируется до первого обращения к БД ──────────────────────── + + +def test_estimate_without_consent_is_422_and_writes_nothing( + client: TestClient, db: MagicMock, flag_on: None +) -> None: + body = {k: v for k, v in _BODY.items() if k != "consent"} + with patch.object(public_mera, "estimate", AsyncMock()) as estimate_mock: + resp = client.post(f"{PREFIX}/estimate", json=body) + + assert resp.status_code == 422, resp.text + assert not estimate_mock.called, ( + "расчёт запустился без согласия — адрес физлица дошёл бы до " + "trade_in_estimates раньше, чем человек что-либо разрешил" + ) + assert db.execute.call_args_list == [], ( + "в БД ушёл запрос при отсутствии согласия: проверка согласия сдвинулась " + "ПОСЛЕ работы с данными" + ) + + +def test_estimate_with_consent_false_is_also_422(client: TestClient, flag_on: None) -> None: + """`consent: false` — это осознанный отказ, а не «поле не прислали».""" + with patch.object(public_mera, "estimate", AsyncMock()) as estimate_mock: + resp = client.post(f"{PREFIX}/estimate", json={**_BODY, "consent": False}) + assert resp.status_code == 422, resp.text + assert not estimate_mock.called + + +# ── 2. Флаг ────────────────────────────────────────────────────────────────── + + +def test_estimate_is_404_while_flag_is_off(client: TestClient) -> None: + with patch.object(public_mera, "estimate", AsyncMock()) as estimate_mock: + resp = client.post(f"{PREFIX}/estimate", json=_BODY) + assert resp.status_code == 404, resp.text + assert not estimate_mock.called + + +def test_estimate_read_is_404_while_flag_is_off(client: TestClient, db: MagicMock) -> None: + resp = client.post(f"{PREFIX}/estimate/read", json={"token": "x" * 32}) + assert resp.status_code == 404, resp.text + assert db.execute.call_args_list == [] + + +def test_flag_default_is_off() -> None: + """Дефолт — выключено: мерж кода не должен открывать запись ПДн наружу.""" + from app.core.config import Settings + + assert Settings.model_fields["public_estimate_enabled"].default is False + + +# ── 3. Токен ───────────────────────────────────────────────────────────────── + + +def test_estimate_stores_token_hash_not_token( + client: TestClient, db: MagicMock, flag_on: None +) -> None: + with ( + patch.object(public_mera, "estimate", AsyncMock(return_value=_fake_estimate_result())), + patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE), + ): + resp = client.post(f"{PREFIX}/estimate", json=_BODY) + + assert resp.status_code == 200, resp.text + token = resp.json()["token"] + assert len(token) >= 32, "короткий токен перебираем" + + params = db.execute.call_args_list[0].args[1] + assert token not in str(params), "сам токен уехал в БД — дамп базы открывает чужие расчёты" + assert params["token_hash"] == hashlib.sha256(token.encode()).hexdigest() + + +def test_read_filters_by_expiry_and_hash(client: TestClient, db: MagicMock, flag_on: None) -> None: + token = "t" * 40 + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + resp = client.post(f"{PREFIX}/estimate/read", json={"token": token}) + assert resp.status_code == 200, resp.text + + sql = str(db.execute.call_args_list[0].args[0]) + assert "public_token_expires_at > NOW()" in sql, ( + "из выборки пропал срок жизни ссылки — протухший токен снова читает расчёт" + ) + params = db.execute.call_args_list[0].args[1] + assert params["token_hash"] == hashlib.sha256(token.encode()).hexdigest() + + +def test_unknown_or_expired_token_is_404(client: TestClient, db: MagicMock, flag_on: None) -> None: + """Чужой и протухший токен неотличимы: 404 в обоих случаях. + + Мок сессии отдаёт None ровно так же, как реальный SELECT с предикатом + `public_token_expires_at > NOW()` — то есть и для несуществующего токена, + и для просроченного. + """ + db.execute.return_value.fetchone.return_value = None + resp = client.post(f"{PREFIX}/estimate/read", json={"token": "z" * 40}) + assert resp.status_code == 404, resp.text + assert "не найден" in resp.json()["detail"].lower() + + +# ── 4. В публичном ответе нет платного ─────────────────────────────────────── + +# Поля AggregatedEstimate, которые продукт продаёт. Список не для красоты: он +# сверяется с реальной моделью ниже, поэтому переименование поля в эстиматоре +# не оставит здесь мёртвую строку-обманку. +_PAID_FIELDS = { + "median_price_rub", + "range_low_rub", + "range_high_rub", + "median_price_per_m2", + "market_percentile", + "analogs", + "actual_deals", + "expected_sold_price_rub", + "est_days_on_market", + "price_trend", +} + +_FREE_FIELDS = {"token", "token_expires_at", "n_analogs", "coverage"} + + +def test_paid_field_names_still_exist_in_estimator_model() -> None: + """Контроль самого контроля: если поле переименовали, тест ниже сравнивал бы + публичный ответ с несуществующими именами и был бы зелёным по построению.""" + missing = _PAID_FIELDS - set(AggregatedEstimate.model_fields) + assert not missing, f"эти поля исчезли из AggregatedEstimate, обнови список: {missing}" + + +def test_public_result_model_exposes_only_free_fields() -> None: + assert set(public_mera.PublicEstimateResult.model_fields) == _FREE_FIELDS, ( + "изменился состав публичного ответа. Любое поле отсюда видит любой аноним " + "без оплаты — правь ОСОЗНАННО вместе с этим тестом" + ) + + +def test_public_estimate_response_leaks_no_paid_field(client: TestClient, flag_on: None) -> None: + with ( + patch.object(public_mera, "estimate", AsyncMock(return_value=_fake_estimate_result())), + patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE), + ): + resp = client.post(f"{PREFIX}/estimate", json=_BODY) + body = resp.json() + assert set(body) == _FREE_FIELDS, f"публичный ответ отдаёт лишнее: {set(body) - _FREE_FIELDS}" + assert not _PAID_FIELDS & set(body) + # Вложенный coverage тоже без цен — по построению CoverageProbeResponse, + # но проверяем, а не верим: он мог обрасти полем «медиана ₽/м²». + assert not _PAID_FIELDS & set(body["coverage"]) + assert body["n_analogs"] == 27 + assert body["coverage"]["median_listing_age_days"] == 44 + + +def test_read_returns_same_free_shape(client: TestClient, flag_on: None) -> None: + """POST и повторное чтение обязаны отдавать ОДНУ форму: разойдись они — + фронт после перезагрузки страницы показал бы не то же самое.""" + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + resp = client.post(f"{PREFIX}/estimate/read", json={"token": "t" * 40}) + assert resp.status_code == 200, resp.text + assert set(resp.json()) == _FREE_FIELDS + assert resp.json()["n_analogs"] == 27 + + +# ── 5. Бюджеты ─────────────────────────────────────────────────────────────── + + +def test_estimate_rate_limited_per_ip(client: TestClient, flag_on: None) -> None: + with ( + patch.object(public_mera, "estimate", AsyncMock(return_value=_fake_estimate_result())), + patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE), + ): + codes = [ + client.post(f"{PREFIX}/estimate", json=_BODY).status_code + for _ in range(public_mera._ESTIMATE_LIMIT + 1) + ] + assert codes[:-1] == [200] * public_mera._ESTIMATE_LIMIT + assert codes[-1] == 429, f"бюджет не сработал: {codes}" + + +def test_daily_budget_stops_estimates_for_everyone(client: TestClient, flag_on: None) -> None: + """Общий потолок — про сумму по всем IP, а не про одного клиента.""" + for _ in range(public_mera._DAILY_ESTIMATE_BUDGET): + public_mera._daily_estimate_limiter.record(public_mera._ESTIMATE_GLOBAL_KEY) + with patch.object(public_mera, "estimate", AsyncMock()) as estimate_mock: + resp = client.post(f"{PREFIX}/estimate", json=_BODY) + assert resp.status_code == 429, resp.text + assert int(resp.headers["Retry-After"]) > 0 + assert not estimate_mock.called