feat(mera/b2c): анонимный расчёт и повторное чтение результата по токену (за флагом) #3230

Merged
bot-backend merged 3 commits from feat/b2c-anon-estimate into main 2026-08-29 14:37:21 +00:00
6 changed files with 728 additions and 18 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View 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