gendesign/tradein-mvp/backend/tests/test_audit_api.py
bot-backend 90e328df66
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 3m0s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 1m23s
fix(tradein/auth): отказ по насыщению — до выборки из БД и с агрегированным следом (#2715) (#2734)
2026-08-06 14:27:26 +00:00

377 lines
15 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 app.api.v1.audit — admin read API over `user_events` (Feature 2/3).
Coverage:
(a) static SQL guard — CAST(:x AS type), never `:x::type` (psycopg v3 trap).
(b) GET /audit/accounts — empty table → [] (never errors); populated → shape-valid
AccountSummary rows pass through response_model unchanged.
(c) GET /audit/accounts/{username} — empty table (incl. unknown username, NOT 404) →
all 4 lists == []; populated → shape-valid drilldown rows.
(d) GET /analytics — empty table → zeroed summary + empty lists (never errors);
populated → shape-valid dashboard bundle, `days` query param clamped [1, 365].
(e) optional real-Postgres round trip — inserts a couple of user_events rows and
asserts aggregation through the real DB; self-SKIPS without a reachable,
non-placeholder Postgres (mirrors tests/test_house_dedup_merge.py's live-DB
pattern).
Router is tested in isolation on a minimal FastAPI app (mirrors tests/test_ratelimit.py
and the quota_app fixture in tests/test_user_events.py) — no need to pull in the full
app.main (sentry/scheduler/CORS/rate-limit wiring).
"""
from __future__ import annotations
import inspect
import os
import re
import uuid
from datetime import UTC, date, datetime
from typing import Any
# psycopg v3 driver required; stub DATABASE_URL before any app import (mirrors other tests).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.v1 import audit as audit_module
from app.core.db import get_db
# ---------------------------------------------------------------------------
# (a) Static SQL guard
# ---------------------------------------------------------------------------
_AUDIT_SRC = inspect.getsource(audit_module)
def test_no_psycopg_v3_doublecolon_cast() -> None:
"""CAST(:x AS type) — НИКОГДА `:x::type` (psycopg v3 trap, .claude/rules/backend.md)."""
assert not re.search(r":\w+::\w", _AUDIT_SRC)
def test_days_param_uses_cast_as_int() -> None:
assert "CAST(:days AS int)" in _AUDIT_SRC
def test_every_group_by_username_filters_out_the_nameless() -> None:
"""Каждая выборка «по аккаунтам» отбрасывает строки с пустым именем (#2715).
Пустое имя пишет `login_verify_saturated`: отказ по насыщению случается ДО
того, как мы посмотрели на присланное имя, и записать его нельзя — иначе
атакующий набивал бы аудит строками с любым именем на выбор. Но аккаунтом
такая строка от этого не становится: без фильтра она встаёт ПЕРВОЙ в списке
(её `last_seen_at` — момент атаки), даёт фантома в `count(DISTINCT
username)`, а раскрытие уходит в `/audit/accounts/{username}` с
`min_length=1` — то есть в ошибку.
То же и со счётчиками уникальных: `count(DISTINCT username)` считал бы
безымянного за человека, и первая же атака НАВСЕГДА добавила бы +1 к числу
пользователей (строка остаётся в таблице). `NULLIF(username, '')` роняет её
в NULL, который `count(DISTINCT)` не считает. Сами события при этом из
`total_events` не исчезают — они события, просто не люди.
Сравнение ЧИСЛОМ, а не поиском подстроки: так сторож ловит и НОВУЮ выборку,
добавленную без фильтра, а не только сегодняшние. На проде пустых имён
сейчас 0 из 3365 строк — то есть это ново.
"""
grouped = _AUDIT_SRC.count("GROUP BY username")
filtered = _AUDIT_SRC.count("WHERE username <> ''")
assert grouped == filtered, (
f"{grouped} выборок GROUP BY username, из них с фильтром {filtered}"
"безымянная строка попадёт в список аккаунтов"
)
assert "count(DISTINCT username)" not in _AUDIT_SRC, (
"count(DISTINCT username) считает безымянные события за людей — "
"нужен count(DISTINCT NULLIF(username, ''))"
)
# ---------------------------------------------------------------------------
# Fakes — mirror the mocked-DB convention used across tests/test_user_events.py etc.
# ---------------------------------------------------------------------------
class _FakeMappingResult:
def __init__(self, rows: list[dict[str, Any]]) -> None:
self._rows = rows
def all(self) -> list[dict[str, Any]]:
return list(self._rows)
def one(self) -> dict[str, Any]:
return self._rows[0]
class _FakeExecResult:
def __init__(self, rows: list[dict[str, Any]]) -> None:
self._rows = rows
def mappings(self) -> _FakeMappingResult:
return _FakeMappingResult(self._rows)
class _FakeSession:
"""Returns queued row-lists in the exact order audit.py issues db.execute() calls."""
def __init__(self, responses: list[list[dict[str, Any]]]) -> None:
self._responses = list(responses)
self._i = 0
def execute(self, _stmt: object, _params: dict[str, Any] | None = None) -> _FakeExecResult:
rows = self._responses[self._i]
self._i += 1
return _FakeExecResult(rows)
def _make_app(responses: list[list[dict[str, Any]]]) -> FastAPI:
application = FastAPI()
application.include_router(audit_module.router, prefix="/api/v1/admin")
def _override_db() -> Any:
yield _FakeSession(responses)
application.dependency_overrides[get_db] = _override_db
return application
_NOW = datetime(2026, 7, 13, 12, 0, 0, tzinfo=UTC)
# ---------------------------------------------------------------------------
# (b) GET /audit/accounts
# ---------------------------------------------------------------------------
def test_list_accounts_empty_table() -> None:
app = _make_app(responses=[[]])
client = TestClient(app)
resp = client.get("/api/v1/admin/audit/accounts")
assert resp.status_code == 200
assert resp.json() == []
def test_list_accounts_populated_shape() -> None:
row = {
"username": "user1",
"first_seen_at": _NOW,
"last_seen_at": _NOW,
"distinct_ips": 3,
"distinct_devices": 2,
"login_count": 5,
"request_count": 40,
"search_count": 7,
}
app = _make_app(responses=[[row]])
client = TestClient(app)
resp = client.get("/api/v1/admin/audit/accounts")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["username"] == "user1"
assert data[0]["distinct_ips"] == 3
assert data[0]["login_count"] == 5
assert data[0]["request_count"] == 40
assert data[0]["search_count"] == 7
# ---------------------------------------------------------------------------
# (c) GET /audit/accounts/{username}
# ---------------------------------------------------------------------------
def test_account_drilldown_empty_table_unknown_user_not_404() -> None:
"""Unknown username → 200 with all 4 lists empty, NOT a 404."""
app = _make_app(responses=[[], [], [], []])
client = TestClient(app)
resp = client.get("/api/v1/admin/audit/accounts/ghost_user_xyz")
assert resp.status_code == 200
data = resp.json()
assert data == {"ips": [], "devices": [], "searches": [], "recent_activity": []}
def test_account_drilldown_populated_shape() -> None:
ips = [{"ip_address": "1.2.3.4", "event_count": 10, "first_seen": _NOW, "last_seen": _NOW}]
devices = [
{"user_agent": "pytest-agent", "event_count": 10, "first_seen": _NOW, "last_seen": _NOW}
]
searches = [
{
"address": "ул. Малышева, 1",
"area_m2": "50.0",
"rooms": "2",
"estimate_id": str(uuid.uuid4()),
"ip_address": "1.2.3.4",
"created_at": _NOW,
}
]
recent_activity = [
{
"event_type": "api_request",
"path": "/api/v1/trade-in/estimate",
"method": "POST",
"ip_address": "1.2.3.4",
"created_at": _NOW,
}
]
app = _make_app(responses=[ips, devices, searches, recent_activity])
client = TestClient(app)
resp = client.get("/api/v1/admin/audit/accounts/user1")
assert resp.status_code == 200
data = resp.json()
assert data["ips"][0]["ip_address"] == "1.2.3.4"
assert data["ips"][0]["event_count"] == 10
assert data["devices"][0]["user_agent"] == "pytest-agent"
assert data["searches"][0]["address"] == "ул. Малышева, 1"
assert data["searches"][0]["area_m2"] == "50.0"
assert data["recent_activity"][0]["event_type"] == "api_request"
assert data["recent_activity"][0]["path"] == "/api/v1/trade-in/estimate"
# ---------------------------------------------------------------------------
# (d) GET /analytics
# ---------------------------------------------------------------------------
_ZERO_SUMMARY = {
"total_events": 0,
"distinct_users": 0,
"events_last_24h": 0,
"active_users_last_24h": 0,
}
def test_analytics_empty_table_never_errors() -> None:
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
client = TestClient(app)
resp = client.get("/api/v1/admin/analytics")
assert resp.status_code == 200
data = resp.json()
assert data["summary"] == _ZERO_SUMMARY
assert data["daily"] == []
assert data["top_searches"] == []
assert data["top_paths"] == []
assert data["by_account"] == []
def test_analytics_populated_shape() -> None:
summary = {
"total_events": 120,
"distinct_users": 4,
"events_last_24h": 15,
"active_users_last_24h": 2,
}
daily = [{"day": date(2026, 7, 13), "events": 15, "users": 2}]
top_searches = [{"address": "ул. Малышева, 1", "n": 3}]
top_paths = [{"path": "/api/v1/trade-in/estimate", "n": 40}]
by_account = [{"username": "user1", "events": 60, "searches": 7, "last_seen": _NOW}]
app = _make_app(responses=[[summary], daily, top_searches, top_paths, by_account])
client = TestClient(app)
resp = client.get("/api/v1/admin/analytics")
assert resp.status_code == 200
data = resp.json()
assert data["summary"]["total_events"] == 120
assert data["daily"][0]["events"] == 15
assert data["daily"][0]["day"] == "2026-07-13"
assert data["top_searches"][0]["address"] == "ул. Малышева, 1"
assert data["top_paths"][0]["n"] == 40
assert data["by_account"][0]["username"] == "user1"
def test_analytics_days_query_param_clamped() -> None:
"""days=0 (below ge=1) → 422; days=9999 (above le=365) → 422."""
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
client = TestClient(app)
assert client.get("/api/v1/admin/analytics?days=0").status_code == 422
assert client.get("/api/v1/admin/analytics?days=9999").status_code == 422
def test_analytics_days_query_param_default_30() -> None:
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
client = TestClient(app)
resp = client.get("/api/v1/admin/analytics")
assert resp.status_code == 200
def test_analytics_days_query_param_accepted_in_range() -> None:
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
client = TestClient(app)
resp = client.get("/api/v1/admin/analytics?days=7")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# (e) Optional real-Postgres round trip (self-skips without a reachable DB)
# ---------------------------------------------------------------------------
def _live_session() -> Any | None:
"""Return a SQLAlchemy Session if a non-placeholder Postgres is reachable, else None."""
try:
from sqlalchemy import create_engine
from sqlalchemy import text as _t
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
conn = engine.connect()
conn.execute(_t("SELECT 1"))
conn.close()
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_accounts_and_analytics_aggregate_inserted_rows() -> None:
"""End-to-end on a real DB: insert 2 user_events rows for a throwaway test username,
assert /audit/accounts, /audit/accounts/{username} and /analytics reflect them."""
from sqlalchemy import text as _t
from app.core.db import SessionLocal
db = _live_session()
assert db is not None
username = f"audit_test_{uuid.uuid4().hex[:8]}"
try:
db.execute(
_t(
"""
INSERT INTO user_events
(event_type, username, ip_address, user_agent, path, method, payload)
VALUES
('login', :u, CAST('9.9.9.9' AS inet), 'pytest-ua', NULL, NULL,
CAST('{}' AS jsonb)),
('estimate_request', :u, CAST('9.9.9.9' AS inet), 'pytest-ua',
'/api/v1/trade-in/estimate', 'POST',
CAST(:payload AS jsonb))
"""
),
{"u": username, "payload": '{"address": "ул. Тестовая, 1", "area_m2": "42.0"}'},
)
db.commit()
application = FastAPI()
application.include_router(audit_module.router, prefix="/api/v1/admin")
def _override_db() -> Any:
yield SessionLocal()
application.dependency_overrides[get_db] = _override_db
client = TestClient(application)
accounts = client.get("/api/v1/admin/audit/accounts").json()
row = next(r for r in accounts if r["username"] == username)
assert row["login_count"] == 1
assert row["search_count"] == 1
drilldown = client.get(f"/api/v1/admin/audit/accounts/{username}").json()
assert len(drilldown["searches"]) == 1
assert drilldown["searches"][0]["address"] == "ул. Тестовая, 1"
assert len(drilldown["recent_activity"]) == 2
dashboard = client.get("/api/v1/admin/analytics?days=1").json()
assert dashboard["summary"]["total_events"] >= 2
finally:
db.rollback()
db.execute(_t("DELETE FROM user_events WHERE username = :u"), {"u": username})
db.commit()