Merge pull request 'feat(mera/b2c): анонимный расчёт и повторное чтение результата по токену (за флагом)' (#3230) from feat/b2c-anon-estimate into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 1m50s
Deploy Trade-In / test (push) Successful in 4m3s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 1m50s
Deploy Trade-In / test (push) Successful in 4m3s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
This commit is contained in:
commit
67d9efbac4
6 changed files with 728 additions and 18 deletions
|
|
@ -1,4 +1,4 @@
|
|||
"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный, ровно три ручки.
|
||||
"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный.
|
||||
|
||||
ЗАЧЕМ ОТДЕЛЬНЫЙ ПРЕФИКС, А НЕ ОТКРЫТИЕ КУСКА /api/v1/*
|
||||
-------------------------------------------------------
|
||||
|
|
@ -28,18 +28,27 @@ API, нужно было выбрать одно из двух:
|
|||
`rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого
|
||||
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,21 +67,28 @@ non-public пути. Все ручки перечислены в `_PUBLIC_PATHS`
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
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__)
|
||||
|
||||
|
|
@ -97,11 +113,22 @@ _COVERAGE_LIMIT = 15
|
|||
# поэтому бюджет шире соседних: он здесь против перебора-в-цикле, а не против
|
||||
# денежных трат. Лэндинг дёргает ручку один раз на загрузку страницы.
|
||||
_SHOWCASE_LIMIT = 60
|
||||
# Расчёт — самый дорогой шаг публичного контура (геокодер + десяток 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)
|
||||
_showcase_limiter = SlidingWindowLimiter(limit=_SHOWCASE_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)
|
||||
|
||||
# ── Общий суточный потолок публичных подсказок ──────────────────────────────
|
||||
#
|
||||
|
|
@ -359,6 +386,8 @@ def public_stats(
|
|||
)
|
||||
for row in rows
|
||||
}
|
||||
|
||||
|
||||
class ShowcaseDeal(BaseModel):
|
||||
"""Одна строка витрины «МЕРА сказала X — продали за Y».
|
||||
|
||||
|
|
@ -490,3 +519,233 @@ def public_showcase(
|
|||
for r in rows
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ── Анонимный расчёт и повторное чтение его бесплатной части ────────────────
|
||||
#
|
||||
# ФЛАГ. Обе ручки ниже мертвы, пока `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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -122,6 +122,13 @@ _PUBLIC_PATHS = frozenset(
|
|||
# СВОЮ таблицу-витрину, где по построению нет ни адреса, ни владельца —
|
||||
# район + характеристики квартиры + пара «прогноз/факт».
|
||||
"/api/public/mera/showcase",
|
||||
# Анонимный расчёт и повторное чтение его бесплатной части. Оба —
|
||||
# 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) перед
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
-- 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.
|
||||
BEGIN;
|
||||
-- Конвенция проекта (#2752): ALTER TABLE на ЖИВОЙ таблице берёт ACCESS
|
||||
-- EXCLUSIVE, и без lock_timeout встанет в очередь за запросами приложения,
|
||||
-- утащив их за собой. trade_in_estimates — таблица боевая (1123 строки на
|
||||
-- 29.08), поэтому это не формальность.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
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.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -110,9 +110,16 @@ def client() -> TestClient:
|
|||
# ── 1-2. Периметр и его связка с rbac ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_public_router_exposes_exactly_four_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", "/stats", "/showcase"}, (
|
||||
assert paths == {
|
||||
"/suggest",
|
||||
"/coverage",
|
||||
"/stats",
|
||||
"/showcase",
|
||||
"/estimate",
|
||||
"/estimate/read",
|
||||
}, (
|
||||
"изменился набор публичных (анонимных) ручек МЕРЫ. Это не рефакторинг: "
|
||||
"всё под /api/public/ проксируется на meraocenka.ru целиком и доступно "
|
||||
"без идентичности. Обнови тест ОСОЗНАННО вместе с rbac._PUBLIC_PATHS."
|
||||
|
|
|
|||
384
tradein-mvp/backend/tests/test_public_mera_estimate.py
Normal file
384
tradein-mvp/backend/tests/test_public_mera_estimate.py
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
"""Анонимный расчёт /api/public/mera/estimate* — что здесь запинено и почему.
|
||||
|
||||
1. СОГЛАСИЕ ДО ЗАПИСИ. Отказ без согласия обязан случиться ДО того, как
|
||||
адрес физлица дойдёт до БД. Поэтому проверяется не только код 422, но и
|
||||
то, что расчёт вообще не запускался и в сессию не ушло ни одного запроса:
|
||||
«422, но строка записана» выглядит в логах как успех приватности и им не
|
||||
является.
|
||||
|
||||
2. ФЛАГ. Выключенный `public_estimate_enabled` обязан давать 404 — иначе
|
||||
мерж этого кода сам по себе открывает наружу запись ПДн.
|
||||
|
||||
3. ТОКЕН. В БД уходит ХЭШ, а не токен; выборка отфильтрована по сроку
|
||||
жизни; «нет такого» и «протух» неотличимы снаружи.
|
||||
|
||||
4. БЕСПЛАТНАЯ ЧАСТЬ. Публичный ответ не содержит ни одного платного поля.
|
||||
Проверяется набором ключей на равенство: любое добавленное поле роняет
|
||||
тест, в том числе случайно добавленное платное.
|
||||
|
||||
5. ДЕЛЕГАЦИЯ. Весь анти-абузный рассказ ручки (анонимная квота на связку
|
||||
cookie+IP, семафор одновременности, 503 вместо 502, consent-гейт) держится
|
||||
ровно на ОДНОМ факте: в `app.api.v1.trade_in.estimate` уходит
|
||||
`x_authenticated_user=None`. `AsyncMock` съедает любую сигнатуру, поэтому
|
||||
«расчёт вызвали» тут ничего не значит — проверяются фактические kwargs
|
||||
вызова, причём запрос идёт С заголовком `X-Authenticated-User`: подмена
|
||||
`None` на чтение заголовка обязана красить тест, иначе публичная ручка
|
||||
молча начнёт считать чужим пользователем и мимо анонимной квоты.
|
||||
"""
|
||||
|
||||
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)
|
||||
_FAKE_ESTIMATE_ID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
|
||||
def _fake_estimate_result() -> MagicMock:
|
||||
"""Результат закрытого контура. MagicMock, а не собранный AggregatedEstimate:
|
||||
хендлеру нужны четыре атрибута, а перечисление всех полей платной модели
|
||||
здесь означало бы, что тест придётся править при каждой правке эстиматора."""
|
||||
result = MagicMock()
|
||||
result.estimate_id = _FAKE_ESTIMATE_ID
|
||||
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()
|
||||
assert params["id"] == _FAKE_ESTIMATE_ID, (
|
||||
"токен вешается не на ту строку: UPDATE обязан идти по id ТОЛЬКО ЧТО "
|
||||
"посчитанной оценки (result.estimate_id), иначе ссылка либо ведёт в чужой "
|
||||
"расчёт, либо не ведёт никуда"
|
||||
)
|
||||
|
||||
|
||||
def test_read_filters_by_expiry_and_hash(client: TestClient, db: MagicMock, flag_on: None) -> None:
|
||||
"""ЧТО проверено: в тексте отправленного SQL присутствует предикат срока жизни,
|
||||
а в параметры уехал ХЭШ токена, не сам токен.
|
||||
|
||||
ЧЕГО НЕ проверено: что протухший токен действительно не читается. Сессия здесь —
|
||||
MagicMock, запрос не исполняется: SQL не разбирается, `fetchone()` отдаёт строку
|
||||
из фикстуры независимо от WHERE. Поэтому зелёными останутся, например, предикат,
|
||||
перенесённый туда, где он ничего не отсекает, сравнение NOW() с полем не того
|
||||
типа и любая ошибка в самом сравнении — текст-то совпадает.
|
||||
|
||||
Поведенческой версии нет намеренно: она требует живого Postgres с миграцией 278
|
||||
(образец self-skip-теста — tests/test_purge_expired_trade_in_data.py::_live_session),
|
||||
а писать её вслепую, ни разу не прогнав, значит завести ещё один тест, зелёный
|
||||
по построению. Появится доступный Postgres — этот тест заменяется на вставку
|
||||
двух строк (свежий токен и просроченный) с проверкой 200 против 404.
|
||||
"""
|
||||
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_delegates_anonymously_despite_auth_header(
|
||||
client: TestClient, db: MagicMock, flag_on: None
|
||||
) -> None:
|
||||
"""Единственный тест, который смотрит НА АРГУМЕНТЫ делегации, а не на факт вызова.
|
||||
|
||||
Запрос идёт с `X-Authenticated-User: admin` — то есть ровно тем заголовком,
|
||||
который в закрытом контуре означает «это авторизованный пользователь». Ручка
|
||||
обязана всё равно позвать эстиматор анонимом: `x_authenticated_user=None`
|
||||
включает там consent-гейт и анонимную квоту на связку cookie+IP. Пробрось
|
||||
сюда заголовок — и публичная форма начнёт считать от чужого имени в обход
|
||||
квоты, а все остальные тесты этого файла останутся зелёными: `AsyncMock`
|
||||
принимает любую сигнатуру.
|
||||
"""
|
||||
with (
|
||||
patch.object(
|
||||
public_mera, "estimate", AsyncMock(return_value=_fake_estimate_result())
|
||||
) as estimate_mock,
|
||||
patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE),
|
||||
):
|
||||
resp = client.post(
|
||||
f"{PREFIX}/estimate", json=_BODY, headers={"X-Authenticated-User": "admin"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
call = estimate_mock.await_args
|
||||
assert call is not None, "делегации не было вовсе"
|
||||
assert call.args == (), (
|
||||
"аргументы поехали позиционно — сверка по именам ниже перестала что-либо "
|
||||
"проверять; зови эстиматор с ключевыми словами"
|
||||
)
|
||||
assert "x_authenticated_user" in call.kwargs, (
|
||||
"аргумент исчез из вызова: у эстиматора он по умолчанию None, но тогда "
|
||||
"гарантия анонимности держится на чужом дефолте, а не на этой ручке"
|
||||
)
|
||||
assert call.kwargs["x_authenticated_user"] is None, (
|
||||
"публичная ручка передала пользователя в закрытый контур: consent-гейт и "
|
||||
"анонимная квота выключаются, аноним считает от чужого имени "
|
||||
f"(пришло: {call.kwargs['x_authenticated_user']!r})"
|
||||
)
|
||||
# Остальное едет тем же вызовом: сессия — та, что отдана зависимостью (иначе
|
||||
# UPDATE токена ниже пишет в другую транзакцию), тело — то, что прислал аноним.
|
||||
assert call.kwargs["db"] is db
|
||||
assert call.kwargs["payload"].address == _BODY["address"]
|
||||
assert call.kwargs["payload"].area_m2 == _BODY["area_m2"]
|
||||
assert call.kwargs["payload"].rooms == _BODY["rooms"]
|
||||
|
||||
|
||||
# ── 6. Бюджеты ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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
|
||||
Loading…
Add table
Reference in a new issue