All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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
CI Trade-In / backend-tests (pull_request) Successful in 4m52s
Admin viewing the audit/analytics dashboards hits /api/v1/admin/* which the request-audit middleware logged as api_request — polluting top_paths and activity counts with the dashboards' own traffic. Skip api_request for admin paths; keep login (admin sign-in from an IP is still valid audit).
140 lines
5.1 KiB
Python
140 lines
5.1 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
|
||
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"]
|