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
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.
261 lines
13 KiB
Python
261 lines
13 KiB
Python
"""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
|