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