Files
ModelForge-Public/backend/tests/test_model_updates.py
T

346 lines
16 KiB
Python

from datetime import UTC, datetime, timedelta
from unittest.mock import Mock
from uuid import uuid4
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from modelforge_api.api.authorization import (
Principal,
PrincipalRole,
authenticate_operator_principal,
)
from modelforge_api.api.routes.model_updates import get_model_update_service, router
from modelforge_api.domain.model_updates import ModelWatchCreate, ModelWatchUpdate
from modelforge_api.persistence.models import (
ArtifactJob,
AuditEvent,
Base,
CapabilityDeployment,
Model,
ModelRevision,
ModelUpdateWatch,
)
from modelforge_api.services.acquisition import AcquisitionService, ProviderBoundaryError
from modelforge_api.services.audit import AuditContext
from modelforge_api.services.model_updates import ModelUpdateService
from modelforge_api.services.registry import RegistryConflict, RegistryService
@pytest.fixture
def setup():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as session:
model = Model(key="selected", display_name="Selected", upstream_provider="huggingface", upstream_source="org/selected")
session.add(model)
session.flush()
revision = ModelRevision(model_id=model.id, upstream_revision="main", resolved_commit_sha="a" * 40)
session.add(revision)
session.commit()
acquisition = Mock(spec=AcquisitionService)
def refresh(model_id, ref):
candidate = session.scalar(select(ModelRevision).where(ModelRevision.resolved_commit_sha == "b" * 40))
if candidate is None:
session.add(ModelRevision(model_id=model_id, upstream_revision=ref, resolved_commit_sha="b" * 40))
session.commit()
return Mock(resolved_commit_sha="b" * 40)
acquisition.refresh_model.side_effect = refresh
service = ModelUpdateService(session, acquisition, AuditContext("operator", "test"))
request = ModelWatchCreate(model_id=model.id, baseline_revision_id=revision.id)
yield session, service, acquisition, request
engine.dispose()
def test_tracking_and_notice_history_explain_registry_delete_refusal(setup):
session, service, _, request = setup
watch = service.create(request)
service.check(watch.id)
notice, = service.notices()
service.dismiss(notice.id)
service.change(watch.id, ModelWatchUpdate(
expected_version=watch.version,
expected_baseline_revision_id=watch.baseline_revision_id,
baseline_revision_id=watch.baseline_revision_id, enabled=False,
reason="Keep historical tracking while paused",
))
registry = RegistryService(session)
for revision_id, relation in (
(watch.baseline_revision_id, "update_tracking_baseline"),
(notice.candidate_revision_id, "update_candidate_history"),
):
with pytest.raises(RegistryConflict) as blocked:
registry.delete("model_revision", revision_id)
assert any(item["relation"] == relation for item in blocked.value.details["dependencies"])
assert session.get(ModelRevision, revision_id) is not None
with pytest.raises(RegistryConflict) as blocked_model:
registry.delete("model", request.model_id)
assert {"update_tracking_baseline", "update_candidate_history"}.issubset(
{item["relation"] for item in blocked_model.value.details["dependencies"]})
def test_check_deduplicates_dismissed_notice_and_never_deploys(setup):
session, service, acquisition, request = setup
watch = service.create(request)
assert service.create(request).id == watch.id
for _ in range(2):
assert service.check(watch.id).last_error_code is None
notice, = service.notices()
assert notice.baseline_commit_sha == "a" * 40
assert notice.candidate_commit_sha == "b" * 40
service.dismiss(notice.id)
service.check(watch.id)
assert len(service.notices()) == 1
assert service.notices()[0].status == "dismissed"
assert session.scalar(select(func.count()).select_from(ArtifactJob)) == 0
assert session.scalar(select(func.count()).select_from(CapabilityDeployment)) == 0
acquisition.create_plan.assert_not_called()
acquisition.execute_plan.assert_not_called()
def test_unchanged_and_provider_failure_preserve_baseline(setup):
session, service, acquisition, request = setup
watch = service.create(request)
acquisition.refresh_model.side_effect = None
acquisition.refresh_model.return_value = Mock(resolved_commit_sha="a" * 40)
service.check(watch.id)
assert service.notices() == []
acquisition.refresh_model.side_effect = ProviderBoundaryError("private provider details")
failed = service.check(watch.id)
assert failed.last_error_code == "upstream_unavailable"
assert failed.baseline_commit_sha == watch.baseline_commit_sha
assert failed.last_checked_at is not None
assert service.notices() == []
assert "private provider details" not in failed.model_dump_json()
assert session.get(ModelUpdateWatch, watch.id).check_token is None
def test_concurrent_check_refused_and_expired_lease_recoverable(setup):
session, service, acquisition, request = setup
watch = service.create(request)
item = session.get(ModelUpdateWatch, watch.id)
item.check_until = datetime.now(UTC) + timedelta(minutes=1)
item.check_token = uuid4()
session.commit()
with pytest.raises(RegistryConflict):
service.check(watch.id)
acquisition.refresh_model.assert_not_called()
item.check_until = datetime.now(UTC) - timedelta(seconds=1)
session.commit()
service.check(watch.id)
assert len(service.notices()) == 1
def test_unexpected_failure_records_failure_and_releases_lease(setup):
session, service, acquisition, request = setup
watch = service.create(request)
acquisition.refresh_model.side_effect = RuntimeError("test crash")
with pytest.raises(RuntimeError):
service.check(watch.id)
item = session.get(ModelUpdateWatch, watch.id)
assert item.last_error_code == "check_failed"
assert item.check_token is None
assert item.baseline_commit_sha == watch.baseline_commit_sha
def test_baseline_mismatch_and_stale_baseline_refused(setup):
session, service, _acquisition, request = setup
watch = service.create(request)
service.check(watch.id)
revision = session.scalar(select(ModelRevision).where(ModelRevision.resolved_commit_sha == "b" * 40))
with pytest.raises(RegistryConflict, match="explicit tracking update"):
service.create(request.model_copy(update={"baseline_revision_id": revision.id}))
other = Model(key="other", display_name="Other", upstream_provider="huggingface", upstream_source="org/other")
session.add(other)
session.commit()
with pytest.raises(RegistryConflict, match="own exact"):
service.create(request.model_copy(update={"model_id": other.id}))
def test_watch_bound_and_pagination(setup):
_, service, _, request = setup
for index in range(100):
service.create(request.model_copy(update={"upstream_ref": f"branch-{index}"}))
with pytest.raises(RegistryConflict, match="100"):
service.create(request)
assert len(service.watches(limit=100)) == 100
assert len(service.watches(limit=10, offset=95)) == 5
@pytest.mark.parametrize("failure", [False, True])
def test_superseded_check_cannot_clear_successor_or_publish_notice(setup, failure):
session, service, acquisition, request = setup
watch = service.create(request)
successor = uuid4()
def supersede(model_id, ref):
item = session.get(ModelUpdateWatch, watch.id)
item.check_token = successor
item.check_until = datetime.now(UTC) + timedelta(minutes=5)
session.commit()
if failure:
raise RuntimeError("superseded failure")
return Mock(resolved_commit_sha="a" * 40)
acquisition.refresh_model.side_effect = supersede
with pytest.raises(RuntimeError if failure else RegistryConflict):
service.check(watch.id)
item = session.get(ModelUpdateWatch, watch.id)
assert item.check_token == successor
assert item.last_checked_at is None
assert service.notices() == []
def test_metadata_audit_uses_authenticated_context(setup):
_, service, acquisition, _ = setup
assert acquisition.audit is service.audit
assert acquisition.audit.context.actor_id == "test"
@pytest.mark.parametrize("role,status", [(None,401),(PrincipalRole.VIEWER,403),(PrincipalRole.OPERATOR,200),(PrincipalRole.ADMIN,200)])
@pytest.mark.parametrize("action", ["create", "check", "dismiss", "change"])
def test_mutation_roles(role, status, action, setup):
_, service, _, request = setup
watch = service.create(request)
service.check(watch.id)
notice = service.notices()[0]
app = FastAPI()
app.include_router(router)
def principal():
if role is None:
raise HTTPException(401)
return Principal("test", role, "test")
mocked = Mock()
mocked.create.return_value = watch
mocked.check.return_value = watch
mocked.dismiss.return_value = notice
mocked.change.return_value = watch
app.dependency_overrides[authenticate_operator_principal] = principal
app.dependency_overrides[get_model_update_service] = lambda: mocked
with TestClient(app) as client:
if action == "create":
response = client.post("/api/v1/model-updates/watches", json=request.model_dump(mode="json"))
elif action == "change":
response = client.patch(f"/api/v1/model-updates/watches/{watch.id}", json={
"expected_version": watch.version, "expected_baseline_revision_id": str(watch.baseline_revision_id),
"baseline_revision_id": str(watch.baseline_revision_id), "enabled": False, "reason": "Pause tracking",
})
else:
path = f"watches/{watch.id}/check" if action == "check" else f"notices/{notice.id}/dismiss"
response = client.post(f"/api/v1/model-updates/{path}")
assert response.status_code == status
assert getattr(mocked, action).call_count == int(status == 200)
def test_list_bounds_require_viewer():
app = FastAPI()
app.include_router(router)
mocked = Mock()
mocked.watches.return_value = []
app.dependency_overrides[get_model_update_service] = lambda: mocked
app.dependency_overrides[authenticate_operator_principal] = lambda: Principal("viewer", PrincipalRole.VIEWER, "test")
with TestClient(app) as client:
assert client.get("/api/v1/model-updates/watches").status_code == 200
assert client.get("/api/v1/model-updates/watches?limit=101").status_code == 422
assert client.get("/api/v1/model-updates/watches?offset=-1").status_code == 422
def tracking_change(watch, **changes):
return ModelWatchUpdate(expected_version=watch.version,
expected_baseline_revision_id=watch.baseline_revision_id,
baseline_revision_id=changes.get("baseline_revision_id", watch.baseline_revision_id),
enabled=changes.get("enabled", watch.enabled), reason="Reviewed tracking change")
def test_pause_resume_fences_in_flight_check_and_stale_changes(setup):
session, service, acquisition, request = setup
watch = service.create(request)
original = acquisition.refresh_model.side_effect
def interrupted(model_id, ref):
candidate = original(model_id, ref)
paused = service.change(watch.id, tracking_change(watch, enabled=False))
assert not paused.enabled and paused.version == 2
assert session.get(ModelUpdateWatch, watch.id).check_token is None
with pytest.raises(RegistryConflict, match="changed"):
service.change(watch.id, tracking_change(watch, enabled=True))
resumed = service.change(watch.id, tracking_change(paused, enabled=True))
assert resumed.enabled and resumed.version == 3
return candidate
acquisition.refresh_model.side_effect = interrupted
with pytest.raises(RegistryConflict, match="superseded"):
service.check(watch.id)
assert service.notices() == []
assert service.watches()[0].last_checked_at is None
def test_paused_watch_and_not_due_check_do_not_call_provider(setup):
_, service, acquisition, request = setup
watch = service.create(request)
paused = service.change(watch.id, tracking_change(watch, enabled=False))
with pytest.raises(RegistryConflict, match="paused"):
service.check(watch.id)
acquisition.refresh_model.assert_not_called()
service.change(watch.id, tracking_change(paused, enabled=True))
service.check(watch.id)
acquisition.refresh_model.reset_mock()
with pytest.raises(RegistryConflict, match="not due"):
service.check(watch.id, if_checked_before=datetime.now(UTC) - timedelta(hours=1))
acquisition.refresh_model.assert_not_called()
def test_baseline_advance_is_audited_cas_and_preserves_notice_provenance(setup):
session, service, _, request = setup
watch = service.create(request)
service.check(watch.id)
notice, = service.notices()
advanced = service.change(watch.id, tracking_change(watch, baseline_revision_id=notice.candidate_revision_id))
assert advanced.baseline_commit_sha == notice.candidate_commit_sha
assert advanced.version == watch.version + 1
assert advanced.last_checked_at is None
assert service.notices()[0] == notice
audit = session.scalar(select(AuditEvent).where(AuditEvent.action == "MODEL_WATCH_TRACKING_CHANGED"))
assert audit.details["previous"]["baseline_revision_id"] == str(watch.baseline_revision_id)
assert audit.details["baseline_revision_id"] == str(notice.candidate_revision_id)
assert audit.details["reason"] == "Reviewed tracking change"
with pytest.raises(RegistryConflict, match="changed"):
service.change(watch.id, tracking_change(watch, enabled=False))
assert session.scalar(select(func.count()).select_from(CapabilityDeployment)) == 0
def test_baseline_advance_rejects_other_models_and_active_check(setup):
session, service, _, request = setup
watch = service.create(request)
service.check(watch.id)
notice = service.notices()[0]
item = session.get(ModelUpdateWatch, watch.id)
item.check_token = uuid4()
item.check_until = datetime.now(UTC) + timedelta(minutes=1)
session.commit()
with pytest.raises(RegistryConflict, match="active check"):
service.change(watch.id, tracking_change(watch, baseline_revision_id=notice.candidate_revision_id))
other = Model(key="other", display_name="Other", upstream_provider="huggingface", upstream_source="org/other")
session.add(other)
session.flush()
other_revision = ModelRevision(model_id=other.id, upstream_revision="main", resolved_commit_sha="c" * 40)
session.add(other_revision)
session.commit()
with pytest.raises(RegistryConflict, match="same model"):
service.change(watch.id, tracking_change(watch, baseline_revision_id=other_revision.id))
paused = service.change(watch.id, tracking_change(watch, enabled=False))
updated = service.change(watch.id, tracking_change(paused, baseline_revision_id=notice.candidate_revision_id))
assert not updated.enabled
assert session.get(ModelUpdateWatch, watch.id).check_token is None
@pytest.mark.parametrize("env,enabled,active", [("production",True,True),("production",False,False),("development",True,False)])
def test_monitor_status_reports_actual_environment_gate(env, enabled, active):
from modelforge_api.settings import Settings, get_settings
app = FastAPI()
app.include_router(router)
app.dependency_overrides[authenticate_operator_principal] = lambda: Principal("viewer", PrincipalRole.VIEWER, "test")
app.dependency_overrides[get_settings] = lambda: Settings(env=env, model_update_monitor_enabled=enabled,
model_update_check_interval_seconds=7200)
with TestClient(app) as client:
assert client.get("/api/v1/model-updates/monitor").json() == {"enabled": active, "check_interval_seconds": 7200}