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