gendesign/tradein-mvp/backend/tests/test_estimate_rate_limit.py
bot-backend eef10f406f
All checks were successful
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m12s
feat(mera/b2c): анти-абуз для анонимного трафика — этап 2 из 8
Блокер номер один перед открытием эндпоинта оценки наружу: анонимный запрос
означал БЕЗЛИМИТ. В сервисе квот отсутствие имени пользователя трактовалось
как unlimited во всех функциях, с комментарием «dev без Caddy, fail-open».
Единственной защитой был общий лимит 300 запросов в минуту на IP — это
анти-флуд для дешёвых запросов, а не бизнес-лимит для пайплайна на десятки
секунд.

1. Fail-open больше не по умолчанию. Вызывающая сторона передаёт пустую
   личность только при явно включённом флаге разработки (по умолчанию выкл).

2. Анонимная личность — подписанная кука с HMAC-SHA256, отдельным каналом
   от X-Authenticated-User. Тот заголовок ставит Caddy и валидирует внутренним
   секретом; смешивать схемы нельзя, это сломало бы модель безопасности.
   Ключ подписи из окружения; если не задан — эфемерный на процесс, с
   предупреждением в лог.

3. Анонимная квота на паре «сессия + IP», переиспользует существующую таблицу
   и тот же атомарный инкремент под WHERE used < lim (защита от гонки #747).
   Честно закомментировано: смена IP или чистка куки обходит лимит — задача
   поднять стоимость злоупотребления, а не сделать его невозможным.

4. Отдельный жёсткий лимит частоты на оценку, проверяется ДО квоты.
   Переиспользован готовый SlidingWindowLimiter. Redis намеренно не задействован:
   прод работает одним воркером, состояние теряется только при рестарте, а
   основная защита — месячная квота в Postgres. Компромисс задокументирован.

5. Потолок времени ответа. Вызов Avito IMV шёл БЕЗ бюджета, в отличие от всех
   соседних — единственный источник неограниченного времени. Обёрнут.
   Суммарный худший случай: было ~186 с (36 с ограниченных плюс IMV без
   границы ~150 с), стало 56 с.

Тесты: 2754 passed. Два существующих теста обновлены под изменившееся
поведение fail-open — это ожидаемое изменение, не регрессия.
2026-07-28 15:21:03 +03:00

154 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for the dedicated POST /estimate rate limiter (#b2c-antiabuse-2 п.4).
Отдельный, куда более строгий лимит частоты specifically на дорогой публичный
путь POST /estimate (`app.api.v1.trade_in._estimate_limiter`,
settings.estimate_rate_limit/_window_s) — поверх общего RateLimitMiddleware
(300 req/60с, app/main.py), который рассчитан на дешёвые запросы. Один вызов
/estimate запускает цепочку внешних вызовов, суммарно занимающую десятки секунд
(см. estimator._with_budget), поэтому burst нескольких параллельных вызовов от
одного ключа нужно резать раньше, гораздо строже.
Изолировано от account_quota (mock'ается no-op) и от estimate_quality (canned
result, без реальной сети/DB) — цель проверить ИМЕННО срабатывание узкого
rate-limit гейта, который стоит ПЕРВЫМ в хендлере (до квоты и до дорогой цепочки).
`tests/conftest.py::_reset_estimate_rate_limiter` сбрасывает `_estimate_limiter`
перед каждым тестом (иначе состояние утекало бы между файлами) — здесь мы поверх
этого сброса ещё и сужаем лимит через autouse-фикстуру, чтобы не тестировать
прод-значения (5/300с) напрямую (медленно/шумно).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.v1 import trade_in as trade_in_module
from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter
from app.schemas.trade_in import AggregatedEstimate
def _canned_estimate() -> AggregatedEstimate:
return AggregatedEstimate(
estimate_id=uuid4(),
median_price_rub=5_000_000,
range_low_rub=4_500_000,
range_high_rub=5_500_000,
median_price_per_m2=100_000,
confidence="medium",
n_analogs=8,
period_months=24,
analogs=[],
actual_deals=[],
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
)
@pytest.fixture()
def app() -> FastAPI:
"""Минимальное приложение вокруг trade_in-роутера; DB не используется реально —
account_quota мокается no-op в каждом тесте отдельно."""
application = FastAPI()
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
def _override_db():
yield None
application.dependency_overrides[get_db] = _override_db
return application
@pytest.fixture(autouse=True)
def _narrow_estimate_limiter(monkeypatch: pytest.MonkeyPatch) -> None:
"""Узкий лимитер (2 запроса / 60с) — тестируем СРАБАТЫВАНИЕ механизма, не
прод-пороги (settings.estimate_rate_limit=5 / estimate_rate_limit_window_s=300
было бы медленно/шумно гонять напрямую в юнит-тесте)."""
monkeypatch.setattr(
trade_in_module, "_estimate_limiter", SlidingWindowLimiter(limit=2, window_s=60.0)
)
def _post_estimate(client: TestClient, headers: dict[str, str] | None = None):
return client.post(
"/api/v1/trade-in/estimate",
json={"address": "г. Екатеринбург, ул. Малышева, 1", "area_m2": 50.0, "rooms": 2},
headers=headers or {},
)
def test_estimate_rate_limit_fires_after_narrow_threshold(app: FastAPI) -> None:
"""3-й запрос той же анонимной корзины (лимит=2) → 429 с Retry-After и текстом
ПРО ОЦЕНКУ (отличимо от общего RateLimitMiddleware "Слишком много запросов")."""
client = TestClient(app, raise_server_exceptions=False)
with (
patch("app.services.account_quota.check_and_raise"),
patch("app.services.account_quota.increment", return_value=True),
patch(
"app.services.estimator.estimate_quality",
new=AsyncMock(return_value=_canned_estimate()),
),
):
for _ in range(2):
resp = _post_estimate(client)
assert resp.status_code == 200
blocked = _post_estimate(client)
assert blocked.status_code == 429
assert "оценку" in blocked.json()["detail"]
assert "Retry-After" in blocked.headers
def test_estimate_rate_limit_per_key_isolation(app: FastAPI) -> None:
"""alice упирается в узкий лимит; bob (свой ключ) — не задет."""
client = TestClient(app, raise_server_exceptions=False)
with (
patch("app.services.account_quota.check_and_raise"),
patch("app.services.account_quota.increment", return_value=True),
patch(
"app.services.estimator.estimate_quality",
new=AsyncMock(return_value=_canned_estimate()),
),
):
alice = {"X-Authenticated-User": "alice"}
for _ in range(2):
assert _post_estimate(client, alice).status_code == 200
assert _post_estimate(client, alice).status_code == 429
bob = {"X-Authenticated-User": "bob"}
assert _post_estimate(client, bob).status_code == 200
def test_estimate_rate_limit_applies_regardless_of_quota_role(app: FastAPI) -> None:
"""Rate limit — самая дешёвая проверка, стоит ПЕРВОЙ в хендлере (до
account_quota). Даже quota-unlimited роль (admin/kopylov) упирается в него —
burst-защита capacity сервера не зависит от business-роли."""
client = TestClient(app, raise_server_exceptions=False)
admin_headers = {"X-Authenticated-User": "admin"}
with (
patch("app.services.account_quota.check_and_raise") as mock_check,
patch("app.services.account_quota.increment", return_value=True),
patch(
"app.services.estimator.estimate_quality",
new=AsyncMock(return_value=_canned_estimate()),
),
):
for _ in range(2):
assert _post_estimate(client, admin_headers).status_code == 200
blocked = _post_estimate(client, admin_headers)
assert blocked.status_code == 429
# account_quota.check_and_raise НЕ вызывается для 3-го запроса — rate limit
# короткозамкнул обработку раньше, чем дело дошло до квоты.
assert mock_check.call_count == 2