gendesign/tradein-mvp/backend/tests/test_request_audit.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

320 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 RequestAuditMiddleware (app/core/request_audit.py).
Тестируем middleware в изоляции на минимальном FastAPI-приложении (как
tests/test_ratelimit.py) — не тянем тяжёлый app.main. schedule_event
и should_log_login мокируются, реальная запись в user_events не требуется
(она уже покрыта tests/test_user_events.py).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from unittest.mock import patch
import pytest
from fastapi import FastAPI, Response
from fastapi.testclient import TestClient
from app.core.request_audit import RequestAuditMiddleware
@pytest.fixture
def client() -> TestClient:
app = FastAPI()
app.add_middleware(RequestAuditMiddleware)
@app.get("/api/v1/ping")
def ping() -> dict[str, bool]:
return {"ok": True}
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
return TestClient(app)
def test_authenticated_api_request_schedules_api_request_event(client: TestClient) -> None:
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=False),
):
resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
assert resp.status_code == 200
mock_schedule.assert_called_once()
kwargs = mock_schedule.call_args.kwargs
assert kwargs["event_type"] == "api_request"
assert kwargs["username"] == "alice"
assert kwargs["path"] == "/api/v1/ping"
assert kwargs["method"] == "GET"
def test_login_event_scheduled_when_should_log_login_true(client: TestClient) -> None:
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=True),
):
client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
assert event_types == ["api_request", "login"]
def test_login_event_not_scheduled_when_should_log_login_false(client: TestClient) -> None:
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=False),
):
client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
assert event_types == ["api_request"]
def test_no_username_header_skips_audit(client: TestClient) -> None:
with patch("app.core.request_audit.schedule_event") as mock_schedule:
resp = client.get("/api/v1/ping")
assert resp.status_code == 200
mock_schedule.assert_not_called()
def test_public_path_skips_audit_even_with_username(client: TestClient) -> None:
with patch("app.core.request_audit.schedule_event") as mock_schedule:
resp = client.get("/health", headers={"X-Authenticated-User": "alice"})
assert resp.status_code == 200
mock_schedule.assert_not_called()
def test_non_api_path_skips_audit(client: TestClient) -> None:
"""Path not under /api/ (and not one of the FastAPI-generated docs paths) is skipped."""
app = FastAPI()
app.add_middleware(RequestAuditMiddleware)
@app.get("/other")
def other() -> dict[str, bool]:
return {"ok": True}
with patch("app.core.request_audit.schedule_event") as mock_schedule:
resp = TestClient(app).get("/other", headers={"X-Authenticated-User": "alice"})
assert resp.status_code == 200
mock_schedule.assert_not_called()
def test_audit_failure_does_not_break_response(client: TestClient) -> None:
"""A raising schedule_event must never turn a 200 into a 500 (dispatch swallows it)."""
with patch("app.core.request_audit.schedule_event", side_effect=RuntimeError("boom")):
resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_admin_path_excluded_from_api_request_but_login_kept() -> None:
"""/api/v1/admin/* — ops-действия (просмотр дашбордов аудита/аналитики), не
поведение пилота: api_request НЕ пишем (иначе дашборд зашумляет активность),
а login (вход админа с IP) остаётся валидным аудитом."""
app = FastAPI()
app.add_middleware(RequestAuditMiddleware)
@app.get("/api/v1/admin/analytics")
def analytics() -> dict[str, bool]:
return {"ok": True}
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=True),
):
resp = TestClient(app).get(
"/api/v1/admin/analytics", headers={"X-Authenticated-User": "admin"}
)
assert resp.status_code == 200
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
assert event_types == ["login"]
# ── Admin audit (security-audit fix): mutating /admin/* -> admin_action ────────
def test_admin_mutating_post_schedules_admin_action_with_attribution() -> None:
"""POST на /api/v1/admin/* (напр. правка прокси / настройки скрапера) должен
писать `admin_action` с атрибуцией (кто), а НЕ игнорироваться целиком, как
раньше (security-audit: не было возможности установить, кто это сделал)."""
app = FastAPI()
app.add_middleware(RequestAuditMiddleware)
@app.post("/api/v1/admin/scraper/avito/rotate-ip")
def rotate_ip() -> dict[str, bool]:
return {"ok": True}
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=False),
):
resp = TestClient(app).post(
"/api/v1/admin/scraper/avito/rotate-ip",
headers={"X-Authenticated-User": "admin"},
)
assert resp.status_code == 200
assert mock_schedule.call_count == 1
kwargs = mock_schedule.call_args.kwargs
assert kwargs["event_type"] == "admin_action"
assert kwargs["username"] == "admin"
assert kwargs["path"] == "/api/v1/admin/scraper/avito/rotate-ip"
assert kwargs["method"] == "POST"
assert kwargs["payload"] == {"status_code": 200, "success": True}
def test_admin_get_does_not_schedule_admin_action() -> None:
"""GET на /admin/* (просмотр дашборда) НЕ должен писать admin_action — только
мутирующие методы считаются "действием"."""
app = FastAPI()
app.add_middleware(RequestAuditMiddleware)
@app.get("/api/v1/admin/scraper/health")
def health() -> dict[str, bool]:
return {"ok": True}
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=False),
):
TestClient(app).get(
"/api/v1/admin/scraper/health", headers={"X-Authenticated-User": "admin"}
)
mock_schedule.assert_not_called()
def test_admin_action_payload_excludes_request_body() -> None:
"""security-audit: тело запроса (куки/пароли/секреты правки прокси) НЕ должно
попадать в audit-payload — только факт действия + атрибуция."""
app = FastAPI()
app.add_middleware(RequestAuditMiddleware)
@app.post("/api/v1/admin/scraper/cookies")
def upload_cookies() -> dict[str, bool]:
# Хендлер намеренно НЕ объявляет body-параметр — middleware проверяет
# только headers/path/method/status, JSON-тело запроса ниже (куки) в
# audit-payload попасть не может структурно, не только "по договорённости".
return {"ok": True}
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=False),
):
TestClient(app).post(
"/api/v1/admin/scraper/cookies",
headers={"X-Authenticated-User": "admin"},
json={"cookies": "super-secret-session-cookie"},
)
kwargs = mock_schedule.call_args.kwargs
assert kwargs["event_type"] == "admin_action"
assert "super-secret-session-cookie" not in repr(kwargs)
def test_admin_mutating_failure_status_recorded_in_payload() -> None:
"""admin_action на неуспешный ответ (напр. 500 от нижестоящего сервиса) должен
нести success=False + реальный status_code — не маскироваться под успех."""
app = FastAPI()
app.add_middleware(RequestAuditMiddleware)
@app.post("/api/v1/admin/scraper/pacing")
def pacing() -> Response:
return Response(status_code=502)
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=False),
):
TestClient(app).post(
"/api/v1/admin/scraper/pacing", headers={"X-Authenticated-User": "admin"}
)
kwargs = mock_schedule.call_args.kwargs
assert kwargs["event_type"] == "admin_action"
assert kwargs["payload"] == {"status_code": 502, "success": False}
# ── login vs login_failed (security-audit fix) ──────────────────────────────────
def test_login_event_type_when_request_succeeds(client: TestClient) -> None:
"""Ответ < 400 -> event_type='login' (успешный вход/активность), payload несёт
status_code."""
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=True),
):
client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
login_calls = [c for c in mock_schedule.call_args_list if c.kwargs["event_type"] == "login"]
assert len(login_calls) == 1
assert login_calls[0].kwargs["payload"] == {"status_code": 200}
# ── Платёжная нотификация — вне аудита (PR-D2, критерий приёмки #4) ─────────
def test_payments_notify_path_skips_audit_even_with_spoofed_admin_header() -> None:
"""PR-D2 acceptance criteria: запрос к нотификации не создаёт записей в
журнале аудита — даже с заголовком `X-Authenticated-User: admin`. Middleware
— ВНЕШНИЙ относительно rbac_guard и читает сырой заголовок напрямую (см.
docstring `request_audit.py`), так что анонимный POST со спуфнутым
заголовком иначе писал бы фальшивое `login`/`api_request` событие с
атрибуцией admin, хотя rbac этот путь пока (до PR-D3) закрывает 401'ом
отдельно и независимо от этого middleware."""
app = FastAPI()
app.add_middleware(RequestAuditMiddleware)
@app.post("/api/v1/trade-in/payments/notify")
def notify() -> dict[str, bool]:
return {"ok": True}
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=True),
):
resp = TestClient(app).post(
"/api/v1/trade-in/payments/notify",
headers={"X-Authenticated-User": "admin"},
)
assert resp.status_code == 200
mock_schedule.assert_not_called()
def test_login_failed_event_type_when_rbac_rejects_request() -> None:
"""Ответ >= 400 (напр. RBAC-отказ downstream: неизвестная роль / протухший
внутренний секрет) -> event_type='login_failed', а НЕ 'login' — раньше эти
два случая были неразличимы в журнале (security-audit)."""
app = FastAPI()
app.add_middleware(RequestAuditMiddleware)
@app.get("/api/v1/ping")
def ping() -> Response:
return Response(status_code=403, content="forbidden")
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=True),
):
TestClient(app).get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
login_calls = [
c
for c in mock_schedule.call_args_list
if c.kwargs["event_type"] in ("login", "login_failed")
]
assert len(login_calls) == 1
assert login_calls[0].kwargs["event_type"] == "login_failed"
assert login_calls[0].kwargs["payload"] == {"status_code": 403}