"""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}