feat(tradein/admin): audit + analytics read API over user_events (#2520)
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 57s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 30s
Deploy Trade-In / test (push) Successful in 4m49s
Deploy Trade-In / deploy (push) Successful in 50s
Deploy Trade-In / changes (push) Successful in 10s
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 57s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 30s
Deploy Trade-In / test (push) Successful in 4m49s
Deploy Trade-In / deploy (push) Successful in 50s
Deploy Trade-In / changes (push) Successful in 10s
Admin-only read API: GET /admin/audit/accounts, /admin/audit/accounts/{username}, /admin/analytics over user_events. Read-only, empty-safe, RBAC via central gate.
This commit is contained in:
parent
8b7af92ee3
commit
9fd6396fbc
4 changed files with 772 additions and 1 deletions
280
tradein-mvp/backend/app/api/v1/audit.py
Normal file
280
tradein-mvp/backend/app/api/v1/audit.py
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
"""Admin read API над `user_events` — Feature 2 (login/IP audit) + Feature 3 (behavior
|
||||||
|
analytics dashboard data).
|
||||||
|
|
||||||
|
Read-only: только SELECT, никаких мутаций. Auth не нужен в этом файле — вся ветка
|
||||||
|
`/api/v1/admin/*` уже гейтится `rbac_guard` middleware в app/main.py
|
||||||
|
(`_ADMIN_API_RE`, role != admin → 403).
|
||||||
|
|
||||||
|
`user_events` (migration `184_user_events.sql`) может быть пустой (feature только
|
||||||
|
что задеплоена) — все запросы ниже написаны так, чтобы на пустой таблице отдавать
|
||||||
|
пустые списки/нулевые счётчики, а не падать.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.schemas.audit import (
|
||||||
|
AccountActivityEntry,
|
||||||
|
AccountDeviceEntry,
|
||||||
|
AccountDrilldown,
|
||||||
|
AccountIpEntry,
|
||||||
|
AccountSearchEntry,
|
||||||
|
AccountSummary,
|
||||||
|
AnalyticsByAccount,
|
||||||
|
AnalyticsDailyPoint,
|
||||||
|
AnalyticsDashboard,
|
||||||
|
AnalyticsSummary,
|
||||||
|
AnalyticsTopPath,
|
||||||
|
AnalyticsTopSearch,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit/accounts", response_model=list[AccountSummary])
|
||||||
|
async def list_accounts(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> list[AccountSummary]:
|
||||||
|
"""Список аккаунтов, по одной строке на username, с базовой сводкой активности."""
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT username,
|
||||||
|
min(created_at) AS first_seen_at,
|
||||||
|
max(created_at) AS last_seen_at,
|
||||||
|
count(DISTINCT ip_address) AS distinct_ips,
|
||||||
|
count(DISTINCT user_agent) AS distinct_devices,
|
||||||
|
count(*) FILTER (WHERE event_type = 'login') AS login_count,
|
||||||
|
count(*) FILTER (WHERE event_type = 'api_request') AS request_count,
|
||||||
|
count(*) FILTER (WHERE event_type = 'estimate_request') AS search_count
|
||||||
|
FROM user_events
|
||||||
|
GROUP BY username
|
||||||
|
ORDER BY last_seen_at DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [AccountSummary.model_validate(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit/accounts/{username}", response_model=AccountDrilldown)
|
||||||
|
async def account_drilldown(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
username: Annotated[str, Path(min_length=1, max_length=200)],
|
||||||
|
) -> AccountDrilldown:
|
||||||
|
"""Drilldown по одному username: IP-адреса, устройства, поиски, недавняя активность.
|
||||||
|
|
||||||
|
Неизвестный username — НЕ 404, а пустой отчёт (все 4 списка == []): аудит не
|
||||||
|
подтверждает/опровергает существование аккаунта, просто нет событий.
|
||||||
|
"""
|
||||||
|
params = {"username": username}
|
||||||
|
|
||||||
|
ips = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT CAST(ip_address AS text) AS ip_address,
|
||||||
|
count(*) AS event_count,
|
||||||
|
min(created_at) AS first_seen,
|
||||||
|
max(created_at) AS last_seen
|
||||||
|
FROM user_events
|
||||||
|
WHERE username = :username AND ip_address IS NOT NULL
|
||||||
|
GROUP BY ip_address
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
devices = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT user_agent,
|
||||||
|
count(*) AS event_count,
|
||||||
|
min(created_at) AS first_seen,
|
||||||
|
max(created_at) AS last_seen
|
||||||
|
FROM user_events
|
||||||
|
WHERE username = :username AND user_agent IS NOT NULL
|
||||||
|
GROUP BY user_agent
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
searches = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT payload ->> 'address' AS address,
|
||||||
|
payload ->> 'area_m2' AS area_m2,
|
||||||
|
payload ->> 'rooms' AS rooms,
|
||||||
|
CAST(estimate_id AS text) AS estimate_id,
|
||||||
|
CAST(ip_address AS text) AS ip_address,
|
||||||
|
created_at
|
||||||
|
FROM user_events
|
||||||
|
WHERE username = :username AND event_type = 'estimate_request'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
recent_activity = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT event_type,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
CAST(ip_address AS text) AS ip_address,
|
||||||
|
created_at
|
||||||
|
FROM user_events
|
||||||
|
WHERE username = :username
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return AccountDrilldown(
|
||||||
|
ips=[AccountIpEntry.model_validate(r) for r in ips],
|
||||||
|
devices=[AccountDeviceEntry.model_validate(r) for r in devices],
|
||||||
|
searches=[AccountSearchEntry.model_validate(r) for r in searches],
|
||||||
|
recent_activity=[AccountActivityEntry.model_validate(r) for r in recent_activity],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/analytics", response_model=AnalyticsDashboard)
|
||||||
|
async def analytics_dashboard(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
days: Annotated[int, Query(ge=1, le=365)] = 30,
|
||||||
|
) -> AnalyticsDashboard:
|
||||||
|
"""Feature 3 dashboard bundle: сводка, дневной time-series, топ-поиски/пути/аккаунты."""
|
||||||
|
summary_row = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT count(*) AS total_events,
|
||||||
|
count(DISTINCT username) AS distinct_users,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE created_at >= now() - INTERVAL '24 hours'
|
||||||
|
) AS events_last_24h,
|
||||||
|
count(DISTINCT username) FILTER (
|
||||||
|
WHERE created_at >= now() - INTERVAL '24 hours'
|
||||||
|
) AS active_users_last_24h
|
||||||
|
FROM user_events
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
daily_rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT date_trunc('day', created_at)::date AS day,
|
||||||
|
count(*) AS events,
|
||||||
|
count(DISTINCT username) AS users
|
||||||
|
FROM user_events
|
||||||
|
WHERE created_at >= now() - make_interval(days => CAST(:days AS int))
|
||||||
|
GROUP BY date_trunc('day', created_at)::date
|
||||||
|
ORDER BY day
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"days": days},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
top_searches_rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT payload ->> 'address' AS address, count(*) AS n
|
||||||
|
FROM user_events
|
||||||
|
WHERE event_type = 'estimate_request'
|
||||||
|
AND payload ->> 'address' IS NOT NULL
|
||||||
|
GROUP BY payload ->> 'address'
|
||||||
|
ORDER BY n DESC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
top_paths_rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT path, count(*) AS n
|
||||||
|
FROM user_events
|
||||||
|
WHERE event_type = 'api_request'
|
||||||
|
GROUP BY path
|
||||||
|
ORDER BY n DESC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
by_account_rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT username,
|
||||||
|
count(*) AS events,
|
||||||
|
count(*) FILTER (WHERE event_type = 'estimate_request') AS searches,
|
||||||
|
max(created_at) AS last_seen
|
||||||
|
FROM user_events
|
||||||
|
GROUP BY username
|
||||||
|
ORDER BY events DESC
|
||||||
|
LIMIT 50
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return AnalyticsDashboard(
|
||||||
|
summary=AnalyticsSummary.model_validate(summary_row),
|
||||||
|
daily=[AnalyticsDailyPoint.model_validate(r) for r in daily_rows],
|
||||||
|
top_searches=[AnalyticsTopSearch.model_validate(r) for r in top_searches_rows],
|
||||||
|
top_paths=[AnalyticsTopPath.model_validate(r) for r in top_paths_rows],
|
||||||
|
by_account=[AnalyticsByAccount.model_validate(r) for r in by_account_rows],
|
||||||
|
)
|
||||||
|
|
@ -23,7 +23,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||||
|
|
||||||
from app.api.v1 import admin, brand, buildings, geocode, lead, me, search, trade_in
|
from app.api.v1 import admin, audit, brand, buildings, geocode, lead, me, search, trade_in
|
||||||
from app.core.auth import get_role, is_path_allowed
|
from app.core.auth import get_role, is_path_allowed
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
|
|
@ -225,6 +225,7 @@ def health() -> dict[str, str]:
|
||||||
|
|
||||||
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
||||||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||||
|
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
|
||||||
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
||||||
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||||
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||||
|
|
|
||||||
146
tradein-mvp/backend/app/schemas/audit.py
Normal file
146
tradein-mvp/backend/app/schemas/audit.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""Pydantic-схемы admin read API над `user_events` (Feature 2 audit + Feature 3 analytics).
|
||||||
|
|
||||||
|
`user_events` (migration `184_user_events.sql`) — unified append-only событийный лог
|
||||||
|
(login/IP audit + behavior analytics), admin-read-only. Эти схемы описывают ответы
|
||||||
|
`GET /api/v1/admin/audit/*` и `GET /api/v1/admin/analytics` (см. app/api/v1/audit.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSummary(BaseModel):
|
||||||
|
"""Одна строка списка `GET /audit/accounts` — сводка по одному username."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
username: str
|
||||||
|
first_seen_at: datetime
|
||||||
|
last_seen_at: datetime
|
||||||
|
distinct_ips: int
|
||||||
|
distinct_devices: int
|
||||||
|
login_count: int
|
||||||
|
request_count: int
|
||||||
|
search_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class AccountIpEntry(BaseModel):
|
||||||
|
"""Одна строка `ips` в drilldown `GET /audit/accounts/{username}`."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
ip_address: str | None = None
|
||||||
|
event_count: int
|
||||||
|
first_seen: datetime
|
||||||
|
last_seen: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AccountDeviceEntry(BaseModel):
|
||||||
|
"""Одна строка `devices` в drilldown `GET /audit/accounts/{username}`."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
user_agent: str | None = None
|
||||||
|
event_count: int
|
||||||
|
first_seen: datetime
|
||||||
|
last_seen: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSearchEntry(BaseModel):
|
||||||
|
"""Одна строка `searches` в drilldown — недавний estimate_request."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
address: str | None = None
|
||||||
|
area_m2: str | None = None
|
||||||
|
rooms: str | None = None
|
||||||
|
estimate_id: str | None = None
|
||||||
|
ip_address: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AccountActivityEntry(BaseModel):
|
||||||
|
"""Одна строка `recent_activity` в drilldown — сырое событие."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
event_type: str
|
||||||
|
path: str | None = None
|
||||||
|
method: str | None = None
|
||||||
|
ip_address: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AccountDrilldown(BaseModel):
|
||||||
|
"""Полный ответ `GET /audit/accounts/{username}` — 4 списка.
|
||||||
|
|
||||||
|
Неизвестный username НЕ 404 — это просто пустой отчёт (все списки == []).
|
||||||
|
"""
|
||||||
|
|
||||||
|
ips: list[AccountIpEntry]
|
||||||
|
devices: list[AccountDeviceEntry]
|
||||||
|
searches: list[AccountSearchEntry]
|
||||||
|
recent_activity: list[AccountActivityEntry]
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsSummary(BaseModel):
|
||||||
|
"""Верхнеуровневые счётчики дашборда `GET /analytics`."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
total_events: int
|
||||||
|
distinct_users: int
|
||||||
|
events_last_24h: int
|
||||||
|
active_users_last_24h: int
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsDailyPoint(BaseModel):
|
||||||
|
"""Одна точка time-series `daily` — события/пользователи за день."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
day: date
|
||||||
|
events: int
|
||||||
|
users: int
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsTopSearch(BaseModel):
|
||||||
|
"""Одна строка `top_searches` — самый частый искомый адрес."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
address: str | None = None
|
||||||
|
n: int
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsTopPath(BaseModel):
|
||||||
|
"""Одна строка `top_paths` — самый частый API-путь."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
path: str | None = None
|
||||||
|
n: int
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsByAccount(BaseModel):
|
||||||
|
"""Одна строка `by_account` — сводка активности по username."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
username: str
|
||||||
|
events: int
|
||||||
|
searches: int
|
||||||
|
last_seen: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsDashboard(BaseModel):
|
||||||
|
"""Полный ответ `GET /analytics` — бандл для дашборда Feature 3."""
|
||||||
|
|
||||||
|
summary: AnalyticsSummary
|
||||||
|
daily: list[AnalyticsDailyPoint]
|
||||||
|
top_searches: list[AnalyticsTopSearch]
|
||||||
|
top_paths: list[AnalyticsTopPath]
|
||||||
|
by_account: list[AnalyticsByAccount]
|
||||||
344
tradein-mvp/backend/tests/test_audit_api.py
Normal file
344
tradein-mvp/backend/tests/test_audit_api.py
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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()
|
||||||
Loading…
Add table
Reference in a new issue