feat(监控): 完善系统监控、登录状态与访问审计能力
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.models.permission_access_log import PermissionAccessLog
|
||||
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
|
||||
from app.models.security_access_log import SecurityAccessLog
|
||||
from app.models.study import Study
|
||||
from app.models.user import User, UserStatus
|
||||
from app.services import monitoring_retention
|
||||
from app.services import permission_log_writer, security_access_log_writer
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.added: list[object] = []
|
||||
self.committed = False
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def add(self, value: object) -> None:
|
||||
self.added.append(value)
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_writer_gracefully_flushes_and_reports_stats(monkeypatch):
|
||||
session = _FakeSession()
|
||||
monkeypatch.setattr(permission_log_writer, "SessionLocal", lambda: session)
|
||||
writer = permission_log_writer.PermissionLogWriter()
|
||||
await writer.start()
|
||||
writer.enqueue(
|
||||
{
|
||||
"study_id": uuid.uuid4(),
|
||||
"user_id": uuid.uuid4(),
|
||||
"endpoint_key": "subjects.read",
|
||||
"role": "PM",
|
||||
"allowed": True,
|
||||
"elapsed_ms": 2.5,
|
||||
}
|
||||
)
|
||||
|
||||
await writer.stop()
|
||||
|
||||
stats = writer.stats()
|
||||
assert session.committed is True
|
||||
assert len(session.added) == 1
|
||||
assert stats["running"] is False
|
||||
assert stats["accepted_entries"] == 1
|
||||
assert stats["written_entries"] == 1
|
||||
assert stats["dropped_entries"] == 0
|
||||
assert stats["queue_size"] == 0
|
||||
assert stats["last_success_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_writer_retries_and_exposes_permanent_failure(monkeypatch):
|
||||
class _FailingSession:
|
||||
async def __aenter__(self):
|
||||
raise RuntimeError("database unavailable")
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(permission_log_writer, "SessionLocal", _FailingSession)
|
||||
writer = permission_log_writer.PermissionLogWriter()
|
||||
batch = [
|
||||
{
|
||||
"study_id": uuid.uuid4(),
|
||||
"user_id": uuid.uuid4(),
|
||||
"endpoint_key": "subjects.read",
|
||||
"role": "PM",
|
||||
"allowed": True,
|
||||
"elapsed_ms": 2.5,
|
||||
}
|
||||
]
|
||||
|
||||
assert await writer._write_batch(batch) is False
|
||||
|
||||
stats = writer.stats()
|
||||
assert stats["retry_count"] == 1
|
||||
assert stats["failed_batches"] == 1
|
||||
assert stats["failed_entries"] == 1
|
||||
assert stats["last_error_type"] == "RuntimeError"
|
||||
|
||||
|
||||
def test_writer_queue_overflow_is_counted():
|
||||
writer = security_access_log_writer.SecurityAccessLogWriter()
|
||||
writer._queue = asyncio.Queue(maxsize=1)
|
||||
|
||||
writer.enqueue({"path": "/first"})
|
||||
writer.enqueue({"path": "/dropped"})
|
||||
|
||||
stats = writer.stats()
|
||||
assert stats["accepted_entries"] == 1
|
||||
assert stats["dropped_entries"] == 1
|
||||
assert stats["queue_size"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retention_deletes_expired_rows_and_preserves_recent_rows(
|
||||
db_session, test_engine, monkeypatch
|
||||
):
|
||||
now = datetime(2026, 7, 10, tzinfo=timezone.utc)
|
||||
study_id = (await db_session.execute(select(Study.id).limit(1))).scalar_one()
|
||||
user = User(
|
||||
email=f"retention-{uuid.uuid4()}@example.com",
|
||||
password_hash="hash",
|
||||
full_name="Retention Test",
|
||||
clinical_department="QA",
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
|
||||
old_permission = PermissionAccessLog(
|
||||
study_id=study_id,
|
||||
user_id=user.id,
|
||||
endpoint_key="old",
|
||||
role="PM",
|
||||
allowed=True,
|
||||
elapsed_ms=1,
|
||||
created_at=now - timedelta(days=91),
|
||||
)
|
||||
recent_permission = PermissionAccessLog(
|
||||
study_id=study_id,
|
||||
user_id=user.id,
|
||||
endpoint_key="recent",
|
||||
role="PM",
|
||||
allowed=True,
|
||||
elapsed_ms=1,
|
||||
created_at=now - timedelta(days=89),
|
||||
)
|
||||
old_security = SecurityAccessLog(
|
||||
method="GET",
|
||||
path="/old",
|
||||
status_code=404,
|
||||
elapsed_ms=1,
|
||||
auth_status="ANONYMOUS",
|
||||
created_at=now - timedelta(days=91),
|
||||
)
|
||||
recent_security = SecurityAccessLog(
|
||||
method="GET",
|
||||
path="/recent",
|
||||
status_code=200,
|
||||
elapsed_ms=1,
|
||||
auth_status="AUTHENTICATED",
|
||||
created_at=now - timedelta(days=89),
|
||||
)
|
||||
old_metric = PermissionMetricSnapshot(
|
||||
bucket_time=now - timedelta(days=401)
|
||||
)
|
||||
recent_metric = PermissionMetricSnapshot(
|
||||
bucket_time=now - timedelta(days=399)
|
||||
)
|
||||
db_session.add_all(
|
||||
[
|
||||
old_permission,
|
||||
recent_permission,
|
||||
old_security,
|
||||
recent_security,
|
||||
old_metric,
|
||||
recent_metric,
|
||||
]
|
||||
)
|
||||
await db_session.commit()
|
||||
old_permission_id = old_permission.id
|
||||
recent_permission_id = recent_permission.id
|
||||
old_security_id = old_security.id
|
||||
recent_security_id = recent_security.id
|
||||
old_metric_id = old_metric.id
|
||||
recent_metric_id = recent_metric.id
|
||||
|
||||
session_factory = async_sessionmaker(
|
||||
bind=test_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
monkeypatch.setattr(monitoring_retention, "SessionLocal", session_factory)
|
||||
monkeypatch.setattr(
|
||||
monitoring_retention.settings, "MONITORING_ACCESS_LOG_RETENTION_DAYS", 90
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
monitoring_retention.settings, "MONITORING_METRIC_RETENTION_DAYS", 400
|
||||
)
|
||||
|
||||
deleted = await monitoring_retention.purge_expired_monitoring_data(now=now)
|
||||
|
||||
db_session.expire_all()
|
||||
assert deleted["permission_access_logs"] >= 1
|
||||
assert deleted["security_access_logs"] >= 1
|
||||
assert deleted["permission_metric_snapshots"] >= 1
|
||||
assert await db_session.get(PermissionAccessLog, old_permission_id) is None
|
||||
assert await db_session.get(SecurityAccessLog, old_security_id) is None
|
||||
assert await db_session.get(PermissionMetricSnapshot, old_metric_id) is None
|
||||
assert await db_session.get(PermissionAccessLog, recent_permission_id) is not None
|
||||
assert await db_session.get(SecurityAccessLog, recent_security_id) is not None
|
||||
assert await db_session.get(PermissionMetricSnapshot, recent_metric_id) is not None
|
||||
Reference in New Issue
Block a user