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
Блокер номер один перед открытием эндпоинта оценки наружу: анонимный запрос означал БЕЗЛИМИТ. В сервисе квот отсутствие имени пользователя трактовалось как 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 — это ожидаемое изменение, не регрессия.
154 lines
6.9 KiB
Python
154 lines
6.9 KiB
Python
"""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
|