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/*
|
ЗАЧЕМ ОТДЕЛЬНЫЙ ПРЕФИКС, А НЕ ОТКРЫТИЕ КУСКА /api/v1/*
|
||||||
-------------------------------------------------------
|
-------------------------------------------------------
|
||||||
|
|
@ -28,18 +28,27 @@ API, нужно было выбрать одно из двух:
|
||||||
`rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого
|
`rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого
|
||||||
non-public пути. Все ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками —
|
non-public пути. Все ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками —
|
||||||
не префиксом: множество там — frozenset с проверкой `path in ...`, и
|
не префиксом: множество там — frozenset с проверкой `path in ...`, и
|
||||||
добавление префиксной ветки ради двух путей расширило бы механизм, которым
|
добавление префиксной ветки расширило бы механизм, которым пользуется весь
|
||||||
пользуется весь бэкенд, ради одной фичи.
|
бэкенд, ради одной фичи. Поэтому и чтение по токену — POST с постоянным
|
||||||
|
путём `/estimate/read`, а не `GET /estimate/{token}`: переменный сегмент
|
||||||
|
пути потребовал бы ровно такой префиксной ветки (плюс сам токен уехал бы в
|
||||||
|
access-лог Caddy, см. `PublicEstimateTokenInput`).
|
||||||
|
|
||||||
ЧТО ЭТИ РУЧКИ НЕ ДЕЛАЮТ
|
ЧТО ЭТИ РУЧКИ ДЕЛАЮТ С ДАННЫМИ
|
||||||
-----------------------
|
------------------------------
|
||||||
Ни одна из них не пишет в БД строк с адресом пользователя: `/coverage` —
|
`/suggest` и `/coverage` не пишут в БД ничего: первый — прокси автокомплита,
|
||||||
чистое чтение (один SELECT), `/suggest` — прокси автокомплита. Это не
|
второй — один SELECT. Это по-прежнему так и меняться не должно.
|
||||||
случайность, а условие, при котором публичная форма может работать ДО того,
|
|
||||||
как появится контур согласия 152-ФЗ (issue #2895: сегодня адрес физлица
|
`/estimate` — единственная, которая ПИШЕТ строку с адресом физлица (issue
|
||||||
попадает в `trade_in_estimates` раньше любого согласия, а пути удаления
|
#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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
from datetime import datetime
|
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 pydantic import BaseModel, Field
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.v1.geocode import SuggestResponse, suggest_addresses
|
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.db import get_db
|
||||||
from app.core.public_request import install_address_log_redaction, public_request_scope
|
from app.core.public_request import install_address_log_redaction, public_request_scope
|
||||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -97,11 +113,22 @@ _COVERAGE_LIMIT = 15
|
||||||
# поэтому бюджет шире соседних: он здесь против перебора-в-цикле, а не против
|
# поэтому бюджет шире соседних: он здесь против перебора-в-цикле, а не против
|
||||||
# денежных трат. Лэндинг дёргает ручку один раз на загрузку страницы.
|
# денежных трат. Лэндинг дёргает ручку один раз на загрузку страницы.
|
||||||
_SHOWCASE_LIMIT = 60
|
_SHOWCASE_LIMIT = 60
|
||||||
|
# Расчёт — самый дорогой шаг публичного контура (геокодер + десяток SQL по
|
||||||
|
# листингам + запись строки). Бюджет намеренно ниже пробы покрытия: живой
|
||||||
|
# человек нажимает «рассчитать» единицы раз, а анонимная месячная квота
|
||||||
|
# (settings.anon_estimate_quota_limit) обходится сменой IP — минутное окно
|
||||||
|
# делает такой обход дорогим по времени.
|
||||||
|
_ESTIMATE_LIMIT = 5
|
||||||
|
# Чтение по токену дешевле расчёта (один SELECT по уникальному индексу), но
|
||||||
|
# ослаблять его до бесконечности нельзя: перебор токенов — это перебор.
|
||||||
|
_ESTIMATE_READ_LIMIT = 30
|
||||||
_WINDOW_S = 60.0
|
_WINDOW_S = 60.0
|
||||||
|
|
||||||
_suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S)
|
_suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S)
|
||||||
_coverage_limiter = SlidingWindowLimiter(limit=_COVERAGE_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)
|
_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
|
for row in rows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ShowcaseDeal(BaseModel):
|
class ShowcaseDeal(BaseModel):
|
||||||
"""Одна строка витрины «МЕРА сказала X — продали за Y».
|
"""Одна строка витрины «МЕРА сказала X — продали за Y».
|
||||||
|
|
||||||
|
|
@ -490,3 +519,233 @@ def public_showcase(
|
||||||
for r in rows
|
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.
|
# обязаны отказывать сразу, ничего не вызывая у T-Bank.
|
||||||
payments_enabled: bool = Field(default=False, validation_alias="PAYMENTS_ENABLED")
|
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()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,13 @@ _PUBLIC_PATHS = frozenset(
|
||||||
# СВОЮ таблицу-витрину, где по построению нет ни адреса, ни владельца —
|
# СВОЮ таблицу-витрину, где по построению нет ни адреса, ни владельца —
|
||||||
# район + характеристики квартиры + пара «прогноз/факт».
|
# район + характеристики квартиры + пара «прогноз/факт».
|
||||||
"/api/public/mera/showcase",
|
"/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) перед
|
# #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 ────────────────────────────────────────
|
# ── 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}
|
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 целиком и доступно "
|
"всё под /api/public/ проксируется на meraocenka.ru целиком и доступно "
|
||||||
"без идентичности. Обнови тест ОСОЗНАННО вместе с rbac._PUBLIC_PATHS."
|
"без идентичности. Обнови тест ОСОЗНАННО вместе с 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