from __future__ import annotations import hashlib import importlib.util import json from datetime import UTC, datetime, timedelta from pathlib import Path from types import ModuleType import pytest from alembic.migration import MigrationContext from alembic.operations import Operations from fastapi.testclient import TestClient from pydantic import SecretStr, ValidationError from sqlalchemy import create_engine, inspect, select, text from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from modelforge_api.api.authorization import AccessBoundary, access_boundary_for_request from modelforge_api.db import get_session from modelforge_api.domain.portfolio import PortfolioReconcileRequest from modelforge_api.main import app from modelforge_api.persistence.models import ( AuditEvent, Base, PortfolioReconciliationRun, PortfolioRepositoryObservation, Project, ProjectBinding, ) from modelforge_api.services.audit import AuditContext from modelforge_api.services.portfolio_registry import ( PortfolioRegistryError, PortfolioRegistryService, ) from modelforge_api.settings import Settings, get_settings ROOT = Path(__file__).resolve().parents[1] MIGRATION_PATH = ( ROOT / "alembic" / "versions" / "20260923_0036_portfolio_repository_reconciliation.py" ) def _migration() -> ModuleType: spec = importlib.util.spec_from_file_location("portfolio_reconciliation_0036", MIGRATION_PATH) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module @pytest.fixture def session() -> 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() def observation( *, repository_id: str = "all-examplerag", project_key: str = "examplerag", classification: str = "gateway", slug: str = "ITWorx/ExampleRAG", head_sha: str = "a" * 40, branch: str = "master", accept_head_update: bool = False, runtime_evidence: list[dict[str, object]] | None = None, ) -> dict[str, object]: return { "external_repository_id": repository_id, "project_key": project_key, "slug": slug, "default_branch": branch, "head_sha": head_sha, "classification": classification, "accept_head_update": accept_head_update, "code_evidence": [ { "path": "backend/src/client.py", "signal": "capability_client", "capabilities": ["rag.embedding@1"], } ], "runtime_evidence": runtime_evidence or [], } def request( *, snapshot_id: str = "gitea-001", observed_at: datetime | None = None, repositories: list[dict[str, object]] | None = None, complete: bool = False, ) -> PortfolioReconcileRequest: return PortfolioReconcileRequest.model_validate( { "source_system": "gitea", "snapshot_id": snapshot_id, "observed_at": (observed_at or datetime(2026, 9, 23, 12, tzinfo=UTC)).isoformat(), "complete": complete, "repositories": [observation()] if repositories is None else repositories, } ) def reconcile( session: Session, item: PortfolioReconcileRequest ): return PortfolioRegistryService(session).reconcile( item, AuditContext("operator", "portfolio-test", "portfolio-correlation") ) def test_reconciliation_reuses_project_and_exact_replay_is_a_noop(session: Session) -> None: project = Project( key="examplerag", name="ExampleRAG legacy", description="operator description", active=False ) session.add(project) session.commit() item = request() first = reconcile(session, item) second = reconcile(session, item) assert first.projects_created == 0 assert first.repositories_created == 1 assert first.replayed is False assert second.replayed is True assert second.snapshot_digest == first.snapshot_digest assert session.query(Project).count() == 1 assert session.query(ProjectBinding).count() == 0 assert session.query(PortfolioRepositoryObservation).count() == 1 session.refresh(project) assert (project.name, project.description, project.active) == ( "ExampleRAG legacy", "operator description", False, ) assert session.query(PortfolioReconciliationRun).count() == 1 assert session.scalar( select(AuditEvent).where(AuditEvent.action == "PORTFOLIO_RECONCILED") ) is not None assert session.query(AuditEvent).filter_by(action="PORTFOLIO_RECONCILED").count() == 1 def test_authenticated_portfolio_artifact_creates_only_source_confirmed_projects( session: Session, ) -> None: artifact = ROOT.parent / "docs/product/PORTFOLIO_RECONCILE_REQUEST_COMPLETE_2026-09-25.json" item = PortfolioReconcileRequest.model_validate_json(artifact.read_text(encoding="utf-8")) first = reconcile(session, item) second = reconcile(session, item) assert item.complete is True assert first.repositories_created == 69 assert first.projects_created == 12 assert second.replayed is True assert second.snapshot_digest == first.snapshot_digest assert session.query(PortfolioRepositoryObservation).count() == 69 assert session.query(Project).count() == 12 assert session.query(ProjectBinding).count() == 0 inventory = PortfolioRegistryService(session).inventory() assert len(inventory.repositories) == 69 assert sum(row.project_id is not None for row in inventory.repositories) == 12 def test_non_consumer_repositories_are_recorded_without_creating_projects( session: Session, ) -> None: item = request( repositories=[ observation( repository_id="all-docs-tool", project_key="docs-tool", classification="non_ai", slug="ITWorx/Docs-Tool", ) ] ) reconcile(session, item) row = session.scalar(select(PortfolioRepositoryObservation)) view = PortfolioRegistryService(session).inventory().repositories[0] assert row and row.project_id is None and row.project_key == "docs-tool" assert view.project_key == "docs-tool" and view.project_id is None and view.project_name is None assert session.query(Project).count() == 0 def test_operator_classification_links_an_existing_observation_only_when_relevant( session: Session, ) -> None: reconcile( session, request( repositories=[ observation(classification="hosted_model", project_key="examplerag") ] ), ) row = session.scalar(select(PortfolioRepositoryObservation)) assert row and row.project_id is None assert session.query(Project).count() == 0 reconcile( session, request( snapshot_id="reviewed-local-consumer", observed_at=datetime(2026, 9, 23, 12, 5, tzinfo=UTC), repositories=[observation(classification="gateway", project_key="examplerag")], ), ) session.refresh(row) assert row.project_id is not None assert session.query(Project).count() == 1 def test_nonlocal_to_local_head_change_stays_unlinked_until_exact_candidate_is_accepted( session: Session, ) -> None: start = datetime(2026, 9, 23, 12, tzinfo=UTC) reconcile( session, request( observed_at=start, repositories=[observation(classification="hosted_model")], ), ) pending = reconcile( session, request( snapshot_id="local-candidate", observed_at=start + timedelta(minutes=5), repositories=[ observation(classification="gateway", head_sha="b" * 40) ], ), ) row = session.scalar(select(PortfolioRepositoryObservation)) view = PortfolioRegistryService(session).inventory().repositories[0] assert pending.head_changes_review_required == 1 assert row and row.project_id is None and row.head_sha == "a" * 40 assert row.classification == "hosted_model" assert row.pending_head_sha == "b" * 40 assert row.pending_classification == "gateway" assert row.pending_project_key == "examplerag" assert view.project_id is None and view.pending_project_key == "examplerag" assert session.query(Project).count() == 0 with pytest.raises(PortfolioRegistryError) as error: reconcile( session, request( snapshot_id="accept-unreviewed-head", observed_at=start + timedelta(minutes=7), repositories=[ observation( classification="gateway", head_sha="c" * 40, accept_head_update=True, ) ], ), ) assert error.value.code == "PORTFOLIO_PENDING_HEAD_CONFLICT" session.refresh(row) assert row.project_id is None and row.pending_head_sha == "b" * 40 assert session.query(Project).count() == 0 accepted = reconcile( session, request( snapshot_id="accept-local-candidate", observed_at=start + timedelta(minutes=10), repositories=[ observation( classification="gateway", head_sha="b" * 40, accept_head_update=True, ) ], ), ) session.refresh(row) assert accepted.head_changes_accepted == 1 assert row.project_id is not None and row.head_sha == "b" * 40 assert row.classification == "gateway" and row.pending_head_sha is None assert row.pending_project_key is None assert session.query(Project).count() == 1 def test_duplicate_project_key_across_repositories_is_an_explicit_conflict( session: Session, ) -> None: documents = observation( repository_id="all-docs-tool", project_key="shared-key", classification="non_ai", slug="ITWorx/Docs-Tool", ) dashboard = observation( repository_id="all-dashboard", project_key="shared-key", classification="runtime_observer", slug="ITWorx/Dashboard", ) with pytest.raises(PortfolioRegistryError) as error: reconcile(session, request(repositories=[documents, dashboard])) assert error.value.status_code == 409 assert error.value.code == "PORTFOLIO_PROJECT_KEY_CONFLICT" assert session.query(PortfolioRepositoryObservation).count() == 0 assert session.query(Project).count() == 0 assert session.query(PortfolioReconciliationRun).count() == 0 def test_snapshot_identity_conflict_does_not_change_portfolio(session: Session) -> None: first = request() reconcile(session, first) changed = request( snapshot_id=first.snapshot_id, observed_at=first.observed_at + timedelta(minutes=1), repositories=[observation(head_sha="b" * 40)], ) with pytest.raises(PortfolioRegistryError) as error: reconcile(session, changed) assert error.value.status_code == 409 row = session.scalar(select(PortfolioRepositoryObservation)) assert row and row.head_sha == "a" * 40 assert session.query(PortfolioReconciliationRun).count() == 1 def test_head_change_requires_explicit_operator_snapshot_acceptance(session: Session) -> None: first_time = datetime(2026, 9, 23, 12, tzinfo=UTC) reconcile(session, request(observed_at=first_time, complete=True)) pending = reconcile( session, request( snapshot_id="gitea-002", observed_at=first_time + timedelta(minutes=5), repositories=[observation(head_sha="b" * 40)], ), ) row = session.scalar(select(PortfolioRepositoryObservation)) assert row and row.head_sha == "a" * 40 assert row.pending_head_sha == "b" * 40 assert pending.head_changes_review_required == 1 assert row.source_observed_at.replace(tzinfo=UTC) == first_time stale = reconcile( session, request( snapshot_id="gitea-old", observed_at=first_time + timedelta(minutes=3), repositories=[observation(head_sha="c" * 40)], ), ) assert stale.stale_snapshot is False assert stale.stale_observations == 1 session.refresh(row) assert row.head_sha == "a" * 40 assert row.pending_head_sha == "b" * 40 accepted = reconcile( session, request( snapshot_id="gitea-003", observed_at=first_time + timedelta(minutes=7), repositories=[observation(head_sha="b" * 40, accept_head_update=True)], ), ) session.refresh(row) assert accepted.head_changes_accepted == 1 assert row.head_sha == "b" * 40 assert row.pending_head_sha is None def test_incomplete_snapshot_never_marks_absent_repository_stale(session: Session) -> None: start = datetime(2026, 9, 23, 12, tzinfo=UTC) second = observation( repository_id="all-examplevision", project_key="examplevision", slug="ITWorx/ExampleVision" ) reconcile(session, request(observed_at=start, repositories=[observation(), second], complete=True)) partial = reconcile( session, request( snapshot_id="partial", observed_at=start + timedelta(minutes=1), repositories=[observation()], complete=False, ), ) examplevision = session.scalar( select(PortfolioRepositoryObservation).where( PortfolioRepositoryObservation.external_repository_id == "all-examplevision" ) ) assert partial.repositories_marked_stale == 0 assert examplevision and examplevision.stale_at is None def test_newer_complete_snapshot_marks_only_missing_rows_stale_without_deleting(session: Session) -> None: start = datetime(2026, 9, 23, 12, tzinfo=UTC) second = observation( repository_id="all-examplevision", project_key="examplevision", slug="ITWorx/ExampleVision" ) reconcile(session, request(observed_at=start, repositories=[observation(), second], complete=True)) result = reconcile( session, request( snapshot_id="complete-newer", observed_at=start + timedelta(minutes=2), repositories=[observation()], complete=True, ), ) examplevision = session.scalar( select(PortfolioRepositoryObservation).where( PortfolioRepositoryObservation.external_repository_id == "all-examplevision" ) ) pokevault_project = session.scalar(select(Project).where(Project.key == "examplevision")) assert result.repositories_marked_stale == 1 assert examplevision and examplevision.stale_at == (start + timedelta(minutes=2)).replace(tzinfo=None) assert pokevault_project and pokevault_project.active is True assert session.query(PortfolioRepositoryObservation).count() == 2 assert session.query(ProjectBinding).count() == 0 def test_runtime_observations_are_separate_and_monotone(session: Session) -> None: start = datetime(2026, 9, 23, 12, tzinfo=UTC) running = { "component": "backend", "state": "running", "observed_at": (start + timedelta(minutes=2)).isoformat(), "observation_source": "container_inventory", "git_revision": "1234567890ab", } reconcile( session, request(observed_at=start, repositories=[observation(runtime_evidence=[running])]), ) # A later source-only snapshot must not erase runtime evidence. reconcile( session, request( snapshot_id="source-only", observed_at=start + timedelta(minutes=3), repositories=[observation()], ), ) row = session.scalar(select(PortfolioRepositoryObservation)) assert row and row.classification == "gateway" assert row.runtime_evidence[0]["state"] == "running" from modelforge_api.domain.portfolio import PortfolioRuntimeEvidence runtime = PortfolioRuntimeEvidence.model_validate(row.runtime_evidence[0]) assert runtime.observed_at == start + timedelta(minutes=2) def test_payload_contract_rejects_secrets_and_unsafe_evidence_paths() -> None: body = request().model_dump(mode="json") body["api_key"] = "this-value-must-not-be-accepted" with pytest.raises(ValidationError): PortfolioReconcileRequest.model_validate(body) unsafe = request().model_dump(mode="json") unsafe["repositories"][0]["code_evidence"][0]["path"] = "../private-key.env" with pytest.raises(ValidationError): PortfolioReconcileRequest.model_validate(unsafe) windows = request().model_dump(mode="json") windows["repositories"][0]["code_evidence"][0]["path"] = "C:/private-key.env" with pytest.raises(ValidationError): PortfolioReconcileRequest.model_validate(windows) def test_inventory_and_reconcile_roles_and_validation_responses_are_safe( session: Session, ) -> None: tokens = {role: f"mfop_{role[0] * 43}" for role in ("viewer", "operator", "admin")} settings = Settings( _env_file=None, env="test", operator_identities=SecretStr( json.dumps( [ { "subject": role, "role": role, "credential_id": role, "token_sha256": hashlib.sha256(token.encode()).hexdigest(), "expires_at": (datetime.now(UTC) + timedelta(days=1)).isoformat(), } for role, token in tokens.items() ] ) ), ) app.dependency_overrides[get_settings] = lambda: settings app.dependency_overrides[get_session] = lambda: session try: client = TestClient(app) assert access_boundary_for_request("GET", "/api/v1/portfolio") is AccessBoundary.CONTROL_PLANE assert client.get("/api/v1/portfolio").status_code == 401 assert client.get( "/api/v1/portfolio", headers={"X-ModelForge-Admin-Token": tokens["viewer"]} ).status_code == 200 body = request().model_dump(mode="json") assert client.post("/api/v1/portfolio/reconcile", json=body).status_code == 401 assert client.post( "/api/v1/portfolio/reconcile", headers={"X-ModelForge-Admin-Token": tokens["viewer"]}, json=body, ).status_code == 403 invalid = dict(body) invalid["clone_url"] = "https://private-user:private-password@example.invalid/repo.git" invalid["api-key-secret-value-must-not-echo"] = "untrusted" rejected = client.post( "/api/v1/portfolio/reconcile", headers={"X-ModelForge-Admin-Token": tokens["operator"]}, json=invalid, ) assert rejected.status_code == 422 assert "private-password" not in rejected.text assert "example.invalid" not in rejected.text assert "api-key-secret-value-must-not-echo" not in rejected.text accepted = client.post( "/api/v1/portfolio/reconcile", headers={ "X-ModelForge-Admin-Token": tokens["operator"], "X-Correlation-ID": "portfolio-reconcile-test", }, json=body, ) assert accepted.status_code == 200 assert accepted.json()["repositories_created"] == 1 audit = session.scalar( select(AuditEvent).where(AuditEvent.action == "PORTFOLIO_RECONCILED") ) assert audit and audit.actor_id == "operator" assert audit.correlation_id == "portfolio-reconcile-test" finally: app.dependency_overrides.clear() def test_portfolio_migration_upgrades_and_downgrades_empty_history() -> None: engine = create_engine("sqlite+pysqlite:///:memory:") with engine.begin() as connection: connection.execute(text("CREATE TABLE projects (id VARCHAR(36) PRIMARY KEY)")) migration = _migration() migration.op = Operations(MigrationContext.configure(connection)) migration.upgrade() assert inspect(connection).has_table("portfolio_repository_observations") assert inspect(connection).has_table("portfolio_reconciliation_runs") foreign_keys = inspect(connection).get_foreign_keys("portfolio_repository_observations") assert any(item["referred_table"] == "projects" for item in foreign_keys) repository_unique_constraints = { item["name"] for item in inspect(connection).get_unique_constraints( "portfolio_repository_observations" ) } assert "uq_portfolio_source_project_key" in repository_unique_constraints assert "uq_portfolio_repository_project_id" in repository_unique_constraints migration.downgrade() assert not inspect(connection).has_table("portfolio_repository_observations") assert not inspect(connection).has_table("portfolio_reconciliation_runs") engine.dispose() def test_portfolio_migration_refuses_downgrade_after_a_snapshot_is_saved() -> None: engine = create_engine("sqlite+pysqlite:///:memory:") with engine.begin() as connection: connection.execute(text("CREATE TABLE projects (id VARCHAR(36) PRIMARY KEY)")) migration = _migration() migration.op = Operations(MigrationContext.configure(connection)) migration.upgrade() connection.execute( text( "INSERT INTO portfolio_reconciliation_runs " "(id, source_system, snapshot_id, payload_digest, observed_at, complete, " "stale_snapshot, projects_created, repositories_created, repositories_updated, " "stale_observations, repositories_marked_stale, runtime_observations_updated, " "head_changes_review_required, head_changes_accepted) " "VALUES ('00000000-0000-0000-0000-000000000001', 'gitea', 'snapshot-1', :digest, " "'2026-09-23 12:00:00', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)" ), {"digest": "a" * 64}, ) with pytest.raises(RuntimeError, match="portfolio reconciliation history exists"): migration.downgrade() assert inspect(connection).has_table("portfolio_reconciliation_runs") assert inspect(connection).has_table("portfolio_repository_observations") engine.dispose()