d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
211 lines
6.4 KiB
Python
211 lines
6.4 KiB
Python
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
|