"""Read-only, bounded audit timeline query contract regressions.""" from __future__ import annotations import hashlib import json from collections.abc import Iterator from datetime import UTC, datetime, timedelta import pytest from fastapi.testclient import TestClient from pydantic import SecretStr from sqlalchemy import create_engine, func, select from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from modelforge_api.db import get_session from modelforge_api.main import app from modelforge_api.persistence.models import AuditEvent, Base from modelforge_api.services.audit import AuditContext, AuditWriter from modelforge_api.settings import Settings, get_settings TIMELINE_PATH = "/api/v1/admin/audit/events" ADMIN_CREDENTIAL = "audit-timeline-test-admin" @pytest.fixture def session() -> Iterator[Session]: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(engine) with Session(engine) as value: yield value engine.dispose() @pytest.fixture(autouse=True) def clean_overrides() -> Iterator[None]: yield app.dependency_overrides.clear() def _client(session: Session, role: str = "admin") -> tuple[TestClient, dict[str, str]]: app.dependency_overrides[get_session] = lambda: session if role == "admin": settings = Settings(_env_file=None, operator_api_key=SecretStr(ADMIN_CREDENTIAL)) # type: ignore[call-arg] token = ADMIN_CREDENTIAL else: token = "mfop_" + "A" * 43 identities = [ { "credential_id": f"audit-{role}", "subject": f"audit-{role}", "role": role, "token_sha256": hashlib.sha256(token.encode()).hexdigest(), "expires_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), } ] settings = Settings( _env_file=None, operator_identities=SecretStr(json.dumps(identities)), ) # type: ignore[call-arg] app.dependency_overrides[get_settings] = lambda: settings return TestClient(app), {"X-ModelForge-Admin-Token": token} def _append( session: Session, *, principal: str = "alice", action: str = "MODEL_UPDATED", resource_type: str = "model", resource_id: str = "model-1", outcome: str = "success", correlation_id: str = "correlation-1", details: dict[str, object] | None = None, ) -> AuditEvent: event = AuditWriter( session, context=AuditContext("operator", principal, correlation_id), ).write( action, resource_type, resource_id, details or {"changed_fields": ["display_name"]}, outcome=outcome, ) session.commit() return event @pytest.mark.parametrize("role", ["viewer", "operator"]) def test_timeline_requires_admin_human_authentication(session: Session, role: str) -> None: client, headers = _client(session, role) assert client.get(TIMELINE_PATH).status_code == 401 assert client.get(TIMELINE_PATH, headers=headers).status_code == 403 assert ( client.get( TIMELINE_PATH, headers={"Authorization": "Bearer mfsvc_project-scoped"}, ).status_code == 401 ) assert ( client.get( TIMELINE_PATH, headers={"Authorization": "Bearer mfnode_node-scoped"}, ).status_code == 401 ) def test_timeline_applies_typed_filters_and_omits_untrusted_details(session: Session) -> None: _append( session, principal="alice-operator", action="NODE_ENROLLMENT_COMPLETED", resource_type="compute_node", resource_id="gpu_node-node-42", outcome="failure", correlation_id="corr-audit-42", details={ "access_token": "mfpw_NEVER_RETURN_THIS", "nested": {"api-key": "NEVER_RETURN_THIS_EITHER", "changed": True}, "comment": "authorization: Bearer mfpw_NEVER_RETURN_THIS", "operator_note": "opaque-credential-value-do-not-return-7f93", }, ) _append( session, principal="bob-operator", action="NODE_ENROLLMENT_COMPLETED", resource_type="compute_node", resource_id="gpu_node-node-43", outcome="success", correlation_id="corr-audit-43", ) client, headers = _client(session) moment = datetime.now(UTC) response = client.get( TIMELINE_PATH, headers=headers, params={ "principal": "alice", "action": "enrollment", "resource": "node-42", "outcome": "failure", "correlation_id": "corr-audit-42", "occurred_after": (moment - timedelta(minutes=1)).isoformat(), "occurred_before": (moment + timedelta(minutes=1)).isoformat(), "limit": 10, }, ) assert response.status_code == 200 payload = response.json() assert len(payload["events"]) == 1 assert payload["events"][0]["actor_id"] == "alice-operator" assert payload["events"][0]["action"] == "NODE_ENROLLMENT_COMPLETED" assert "details" not in payload["events"][0] for secret_like in ( "NEVER_RETURN_THIS", "NEVER_RETURN_THIS_EITHER", "opaque-credential-value-do-not-return-7f93", ): assert secret_like not in response.text def test_timeline_cursor_pages_stably_when_new_events_are_appended(session: Session) -> None: seeded = [_append(session, principal=f"operator-{index}") for index in range(3)] client, headers = _client(session) first = client.get(TIMELINE_PATH, headers=headers, params={"limit": 2}) assert first.status_code == 200 first_payload = first.json() assert [item["sequence"] for item in first_payload["events"]] == [ seeded[2].sequence, seeded[1].sequence, ] cursor = first_payload["next_cursor"] assert isinstance(cursor, str) and cursor _append(session, principal="operator-new") second = client.get( TIMELINE_PATH, headers=headers, params={"limit": 2, "cursor": cursor}, ) assert second.status_code == 200 second_payload = second.json() assert [item["sequence"] for item in second_payload["events"]] == [seeded[0].sequence] assert second_payload["next_cursor"] is None assert not ( {item["id"] for item in first_payload["events"]} & {item["id"] for item in second_payload["events"]} ) mismatched_filters = client.get( TIMELINE_PATH, headers=headers, params={"limit": 2, "cursor": cursor, "principal": "another-principal"}, ) assert mismatched_filters.status_code == 422 @pytest.mark.parametrize( "params", [ {"limit": 101}, {"cursor": "not-a-valid-cursor"}, {"principal": "p" * 256}, {"action": "a" * 129}, {"resource": "r" * 129}, {"outcome": "o" * 33}, {"correlation_id": "c" * 65}, {"occurred_after": "2026-01-01T00:00:00Z"}, {"occurred_after": "not-a-time", "occurred_before": "2026-01-02T00:00:00Z"}, { "occurred_after": "2024-01-01T00:00:00Z", "occurred_before": "2026-01-01T00:00:00Z", }, { "occurred_after": "2026-02-01T00:00:00Z", "occurred_before": "2026-01-01T00:00:00Z", }, {"unexpected_filter": "not-accepted"}, ], ) def test_timeline_rejects_malformed_or_unbounded_query_input( session: Session, params: dict[str, str | int] ) -> None: client, headers = _client(session) response = client.get(TIMELINE_PATH, headers=headers, params=params) assert response.status_code == 422 def test_timeline_returns_a_typed_empty_page_and_does_not_append_audit_events( session: Session, ) -> None: client, headers = _client(session) response = client.get(TIMELINE_PATH, headers=headers) assert response.status_code == 200 assert response.json() == {"events": [], "next_cursor": None, "limit": 50} assert session.scalar(select(func.count()).select_from(AuditEvent)) == 0