253 lines
8.0 KiB
Python
253 lines
8.0 KiB
Python
"""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
|