gendesign/tradein-mvp/backend/tests/test_request_audit.py
lekss361 76016fd469
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m52s
Deploy Trade-In / build-backend (push) Successful in 1m43s
Deploy Trade-In / deploy (push) Successful in 1m8s
fix(tradein/security): утечка ключа прокси, аудит действий админа, отличимость неудачного входа, IDOR в заявке (#2536)
2026-07-26 22:42:15 +00:00

289 lines
12 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}
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}