gendesign/tradein-mvp/backend/tests/test_ratelimit.py
bot-backend 1f85ef7d4e
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Successful in 3m50s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
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
fix(tradein/payments): тело нотификации не течёт в мониторинг и аудит, повторы банка не отбиваются лимитом
PR-D2 платёжного контура МЕРЫ — закрывает утечки до открытия публичных путей
(PR-D3/D4), сам ничего не открывает: _PUBLIC_PATHS (rbac.py), Caddyfile,
roles.yaml, auth_session.py не тронуты.

- sentry_scrub.py: новая scrub_payment_request_body — вырезает
  event.request.data целиком для /api/v1/trade-in/payments/* (sentry_sdk 2.64
  кладёт полное тело запроса в request.data, send_default_pii=False это НЕ
  гейтит — тот флаг управляет только куками). Плюс расширен _PII_KEYS:
  customer_email/customer_phone/pan/expdate/cardid/rebillid/token/terminalkey.
- main.py, scheduler_main.py, tgbot_main.py (все 3 точки инициализации
  sentry_sdk.init в проекте) — тот же обработчик проведён в ОБА канала,
  before_send и before_send_transaction. Мотивирующий инцидент: на соседнем
  продукте вчера закрыли только error-канал, transaction остался без
  обработчика вообще.
- ratelimit.py: точный путь notify — свой щедрый SlidingWindowLimiter
  (3000/60с per-IP, идиома support.py) вместо общего лимитера, но НЕ полное
  отключение — backstop против шторма запросов остаётся, подпись проверяется
  уже после разбора тела (PR-D3). Только notify, не checkout (тот с сессией).
- request_audit.py: notify — в audit skip-набор (defense-in-depth: middleware
  внешний относительно rbac_guard и читает сырой X-Authenticated-User —
  спуфнутый заголовок иначе писал бы фальшивые события с атрибуцией admin).
- smoke-mera-perimeter.sh: негативные проверки-канарейки — notify/checkout
  сейчас закрыты 404 (meraocenka.ru, Caddy не проксирует) и 401
  (gendsgn.ru, rbac ещё не открыл) с обеих сторон периметра.

Тесты: scrub на произвольной глубине + payment-path body-wipe, AST-разбор
(не substring — комментарии в этих же файлах сами упоминают
before_send_transaction) на проводку обоих каналов во всех точках
инициализации, 400 запросов notify без единого 429 + контроль что общий
лимитер по-прежнему активен на других путях, notify вне user_events даже со
спуфнутым X-Authenticated-User: admin.
2026-08-07 16:17:59 +03:00

261 lines
13 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 RateLimitMiddleware — per-user / per-IP sliding window (#655, #2213).
Тестируем middleware в изоляции на минимальном FastAPI-приложении, чтобы не
тянуть тяжёлый app.main (DB / scheduler / sentry). Лимит/окно/множитель читаются
из settings на каждый dispatch → monkeypatch'им их в маленькие значения.
#2213: аутентифицированный трафик больше НЕ освобождается целиком — он лимитируется
per-user с щедрым порогом (rate_limit × multiplier). Анонимный ключ = client IP,
взятый из RIGHTMOST элемента X-Forwarded-For (ровно один доверенный прокси Caddy),
поэтому подделка leftmost XFF не меняет ключ.
"""
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core import config
from app.core.ratelimit import RateLimitMiddleware, SlidingWindowLimiter
@pytest.fixture
def client(monkeypatch):
"""Минимальное приложение: анонимный лимит 3 / 60 с, auth-множитель ×2 (→6)."""
monkeypatch.setattr(config.settings, "rate_limit", 3)
monkeypatch.setattr(config.settings, "rate_limit_window_s", 60.0)
monkeypatch.setattr(config.settings, "rate_limit_authenticated_multiplier", 2)
app = FastAPI()
app.add_middleware(RateLimitMiddleware)
@app.get("/api/v1/ping")
def ping() -> dict[str, bool]:
return {"ok": True}
return TestClient(app)
def test_anonymous_throttled_past_limit(client):
# Первые rate_limit запросов проходят, следующий — 429.
for _ in range(3):
assert client.get("/api/v1/ping").status_code == 200
resp = client.get("/api/v1/ping")
assert resp.status_code == 429
assert resp.json() == {"detail": "Слишком много запросов. Попробуйте позже."}
assert "Retry-After" in resp.headers
def test_authenticated_user_is_limited_per_user(client):
# #2213: authenticated НЕ освобождается — лимит = rate_limit × multiplier = 6.
headers = {"X-Authenticated-User": "alice"}
for _ in range(6):
assert client.get("/api/v1/ping", headers=headers).status_code == 200
# 7-й запрос того же юзера — 429.
assert client.get("/api/v1/ping", headers=headers).status_code == 429
def test_authenticated_limit_higher_than_anonymous(client):
# Auth-порог (6) строго выше анонимного (3): 4-й запрос юзера ещё проходит,
# тогда как анонимный на 4-м уже режется (см. test_anonymous_throttled_past_limit).
headers = {"X-Authenticated-User": "alice"}
for _ in range(4):
assert client.get("/api/v1/ping", headers=headers).status_code == 200
def test_per_user_key_isolation(client):
# Разные юзеры — независимые корзины: alice упирается, bob не затронут.
alice = {"X-Authenticated-User": "alice"}
bob = {"X-Authenticated-User": "bob"}
for _ in range(6):
assert client.get("/api/v1/ping", headers=alice).status_code == 200
assert client.get("/api/v1/ping", headers=alice).status_code == 429
# bob со своим ключом проходит свободно.
assert client.get("/api/v1/ping", headers=bob).status_code == 200
def test_empty_auth_header_does_not_bypass(client):
# Пустой заголовок не должен no-op'ить лимит (header present но falsy) → per-IP.
headers = {"X-Authenticated-User": ""}
for _ in range(3):
assert client.get("/api/v1/ping", headers=headers).status_code == 200
assert client.get("/api/v1/ping", headers=headers).status_code == 429
def test_leftmost_xff_does_not_affect_anon_key(client):
# Клиент подделывает leftmost XFF, но rightmost (добавленный Caddy) одинаков →
# один ключ ip:9.9.9.9 → лимит общий, подделкой его не обойти.
spoofed = [
{"X-Forwarded-For": "1.1.1.1, 9.9.9.9"},
{"X-Forwarded-For": "2.2.2.2, 9.9.9.9"},
{"X-Forwarded-For": "3.3.3.3, 9.9.9.9"},
]
for h in spoofed:
assert client.get("/api/v1/ping", headers=h).status_code == 200
# 4-й (снова другой leftmost, тот же rightmost) — уже за лимитом.
resp = client.get("/api/v1/ping", headers={"X-Forwarded-For": "4.4.4.4, 9.9.9.9"})
assert resp.status_code == 429
def test_anonymous_key_from_rightmost_xff(client):
# Разный rightmost = разные реальные клиенты = независимые корзины.
for _ in range(3):
assert (
client.get("/api/v1/ping", headers={"X-Forwarded-For": "8.8.8.8, 1.1.1.1"}).status_code
== 200
)
# Другой rightmost — своя корзина, не затронут лимитом первого.
assert (
client.get("/api/v1/ping", headers={"X-Forwarded-For": "8.8.8.8, 2.2.2.2"}).status_code
== 200
)
# ── SlidingWindowLimiter (#tgsupport-web) — reusable narrower per-feature limit ──
def test_sliding_window_limiter_retry_after_does_not_record():
"""`.retry_after()` — non-destructive peek: не расходует бюджет сам по себе
(review L3 — вызывающая сторона решает, когда `.record()`)."""
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
assert limiter.retry_after("alice") is None
# Повторный peek БЕЗ record() — бюджет не тронут, всё ещё None.
assert limiter.retry_after("alice") is None
def test_sliding_window_limiter_record_then_retry_after_blocks():
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
assert limiter.retry_after("alice") is None
limiter.record("alice")
retry_after = limiter.retry_after("alice")
assert retry_after is not None
assert retry_after > 0
def test_sliding_window_limiter_check_combines_peek_and_record():
"""`.check()` — комбинированный (обратной совместимости ради) peek+record."""
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
assert limiter.check("alice") is None # первый — проходит и сразу учитывается
assert limiter.check("alice") is not None # второй — уже за лимитом
def test_sliding_window_limiter_per_key_isolation():
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
limiter.record("alice")
assert limiter.retry_after("alice") is not None
assert limiter.retry_after("bob") is None # свой ключ — не задет alice
# ── Платёжная нотификация — мимо ОБЩЕГО лимитера (PR-D2, критерий приёмки #3) ──
@pytest.fixture
def notify_client(monkeypatch):
"""То же минимальное приложение, что `client`, но лимит анонима искусственно
крошечный (1/60с) — если бы notify-путь шёл через общий лимитер, 2-й запрос
уже получил бы 429. Плюс контрольный `/api/v1/ping` — доказывает, что
лимитер в принципе активен (не выключен целиком), просто notify мимо него."""
monkeypatch.setattr(config.settings, "rate_limit", 1)
monkeypatch.setattr(config.settings, "rate_limit_window_s", 60.0)
monkeypatch.setattr(config.settings, "rate_limit_authenticated_multiplier", 2)
app = FastAPI()
app.add_middleware(RateLimitMiddleware)
@app.get("/api/v1/ping")
def ping() -> dict[str, bool]:
return {"ok": True}
@app.post("/api/v1/trade-in/payments/notify")
def notify() -> dict[str, bool]:
return {"ok": True}
return TestClient(app)
def test_notify_path_bypasses_general_limiter_400_requests_zero_429(notify_client):
"""PR-D2 acceptance criteria: 400 запросов к notify с одного адреса за минуту
не дают ни одного отказа по частоте — даже с общим лимитом искусственно
зажатым до 1/60с (см. фикстуру)."""
statuses = [
notify_client.post("/api/v1/trade-in/payments/notify").status_code for _ in range(400)
]
assert all(
code == 200 for code in statuses
), f"notify получил 429 хотя бы раз: {[c for c in statuses if c != 200]}"
def test_general_limiter_still_active_for_other_paths(notify_client):
"""Контроль: общий лимитер НЕ выключен целиком — обычный /api/v1/ping с тем
же крошечным лимитом (1/60с) отбивается на 2-м запросе как обычно. Доказывает,
что notify-bypass узкий (точный путь), а не побочный эффект общей поломки."""
assert notify_client.get("/api/v1/ping").status_code == 200
assert notify_client.get("/api/v1/ping").status_code == 429
def test_notify_bypass_has_own_dedicated_limiter_not_unlimited():
"""notify НЕ отключён от лимитера вовсе — своя щедрая, но конечная корзина
(`_notify_limiter`, идиома `SlidingWindowLimiter` из `support.py`). Проверяем
напрямую: исчерпать маленький искусственный лимит и убедиться, что backstop
таки срабатывает (защита от полного disable вместо узкого бюджета)."""
from app.core import ratelimit as ratelimit_module
limiter = ratelimit_module.SlidingWindowLimiter(limit=2, window_s=60.0)
assert limiter.check("1.2.3.4") is None
assert limiter.check("1.2.3.4") is None
# 3-й запрос того же ключа — уже за лимитом (backstop жив).
assert limiter.check("1.2.3.4") is not None
def test_notify_limiter_key_is_per_ip_not_global(monkeypatch):
"""Бюджет notify — per-IP (не общий на все входящие сразу), тот же принцип
ключа, что общий лимитер использует для анонимного трафика."""
monkeypatch.setattr(config.settings, "rate_limit", 300)
monkeypatch.setattr(config.settings, "rate_limit_window_s", 60.0)
from app.core import ratelimit as ratelimit_module
monkeypatch.setattr(
ratelimit_module,
"_notify_limiter",
ratelimit_module.SlidingWindowLimiter(limit=1, window_s=60.0),
)
app = FastAPI()
app.add_middleware(RateLimitMiddleware)
@app.post("/api/v1/trade-in/payments/notify")
def notify() -> dict[str, bool]:
return {"ok": True}
client = TestClient(app)
# Первый запрос с IP #1 — проходит, второй с тем же IP — 429 (лимит=1).
headers_ip1 = {"X-Forwarded-For": "1.1.1.1"}
headers_ip2 = {"X-Forwarded-For": "2.2.2.2"}
assert client.post("/api/v1/trade-in/payments/notify", headers=headers_ip1).status_code == 200
assert client.post("/api/v1/trade-in/payments/notify", headers=headers_ip1).status_code == 429
# Другой IP — своя, независимая корзина.
assert client.post("/api/v1/trade-in/payments/notify", headers=headers_ip2).status_code == 200
def test_sliding_window_limiter_prunes_empty_buckets_past_threshold():
"""review L2: пустые корзины чистятся при накоплении >10000 ключей (тот же
паттерн, что `RateLimitMiddleware.dispatch`) — не бесконечная утечка памяти.
`retry_after()`-peek на новом ключе создаёт ПУСТУЮ корзину как побочный эффект
`defaultdict` (даже если вызывающая сторона так и не позвала `.record()` —
напр. запрос отклонён по другой причине выше по стеку). Это главный источник
"мусорных" пустых корзин, которые cleanup обязан подбирать.
"""
limiter = SlidingWindowLimiter(limit=1000, window_s=60.0)
for i in range(10001):
limiter.retry_after(f"user-{i}")
assert len(limiter._hits) == 10001
# Следующий record() создаёт СВОЮ (непустую) корзину и заодно подчищает
# все чужие пустые — тот же порог (>10000), что и RateLimitMiddleware.
limiter.record("trigger-cleanup")
assert len(limiter._hits) < 10001