from __future__ import annotations import json import pytest from sqlalchemy.pool import StaticPool from sqlmodel import Session, SQLModel, create_engine, select from backend.app.db.models import Policy from backend.app.services.gitea_project_auto_registration import ( GiteaProjectAutoRegistration, ) class FakeClient: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False class FakeTargetRegistry: def __init__(self): self.calls = [] def register_compose_target(self, repository, revision, **kwargs): self.calls.append((repository, revision, kwargs)) return { "status": "registered", "repository": repository, "revision": revision, } class FakeProjectSync(GiteaProjectAutoRegistration): def __init__(self, repositories, contracts, *, target_registry): super().__init__(target_registry=target_registry) self.repositories = repositories self.contracts = contracts self.branch_calls = [] self.contract_calls = [] def _client(self): return FakeClient() def _repositories(self, client): return list(self.repositories) def _branch_head(self, client, *, repository_name, branch): self.branch_calls.append((repository_name, branch)) return (repository_name[0].lower() if repository_name else "a") * 40 def _compose_contract(self, client, *, repository_name, revision): self.contract_calls.append((repository_name, revision)) return self.contracts.get(repository_name) class FakeComposeInspection(GiteaProjectAutoRegistration): def __init__(self, root_names, files): super().__init__(target_registry=FakeTargetRegistry()) self.root_names = list(root_names) self.files = dict(files) def _contents( self, client, *, repository_name, revision, path="", allow_not_found=False, ): if not path: return [{"name": name} for name in self.root_names] raise AssertionError(f"unexpected direct contents read: {path}") def _read_text( self, client, *, repository_name, revision, path, allow_not_found=False, ): value = self.files.get(path) if value is None and not allow_not_found: raise AssertionError(f"missing fake file: {path}") return value def make_session(): engine = create_engine( "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) SQLModel.metadata.create_all(engine) return Session(engine) def repo(name, *, branch="main", updated="2026-09-16T12:00:00Z", archived=False, empty=False): return { "name": name, "full_name": f"Jens/{name}", "default_branch": branch, "updated_at": updated, "archived": archived, "empty": empty, "private": True, } def deployable_contract(): return { "applicationId": "deployable", "composeFile": "docker-compose.yml", "composeProject": "deployable", "containers": ["deployable"], "serviceNames": ["deployable"], "healthTimeout": 180, "verifyOciRevision": False, } def test_repository_names_normalize_to_deployment_plan_application_ids(): assert GiteaProjectAutoRegistration._application_id("ITWorx_MCP_Hub") == "itworx-mcp-hub" assert GiteaProjectAutoRegistration._application_id("Media.Codec_Dashboard") == "media-codec-dashboard" def test_single_service_compose_can_be_inferred_without_descriptor(): service = FakeComposeInspection( ["docker-compose.yml"], { "docker-compose.yml": "services:\n my-app:\n image: example/app:1\n", }, ) contract = service._compose_contract( FakeClient(), repository_name="My_App", revision="a" * 40 ) assert contract is not None assert contract["applicationId"] == "my-app" assert contract["composeProject"] == "my-app" assert contract["serviceNames"] == ["my-app"] assert contract["containers"] == ["my-app-my-app-1"] def test_ambiguous_multi_service_compose_stays_known_but_unconfigured(): service = FakeComposeInspection( ["compose.yml"], { "compose.yml": "services:\n api:\n image: example/api:1\n worker:\n image: example/worker:1\n", }, ) assert ( service._compose_contract( FakeClient(), repository_name="DifferentName", revision="b" * 40 ) is None ) def test_descriptor_can_select_primary_service_for_multi_service_project(): descriptor = { "apiVersion": "itworx.deployment-target/v1", "applicationId": "api", "composeFile": "compose.yml", } service = FakeComposeInspection( [".itworx", "compose.yml"], { ".itworx/deployment-target.json": json.dumps(descriptor), "compose.yml": "services:\n api:\n image: example/api:1\n worker:\n image: example/worker:1\n", }, ) contract = service._compose_contract( FakeClient(), repository_name="MultiService", revision="c" * 40 ) assert contract is not None assert contract["applicationId"] == "api" assert contract["composeProject"] == "api" assert contract["primaryService"] == "api" assert contract["serviceNames"] == ["api", "worker"] assert contract["containers"] == ["api-api-1", "api-worker-1"] def test_descriptor_separates_application_identity_compose_project_and_primary_service(): descriptor = { "apiVersion": "itworx.deployment-target/v1", "applicationId": "quizmonitor", "composeProject": "quiz-monitor", "primaryService": "quiz-server", "composeFile": "docker-compose.yml", } service = FakeComposeInspection( [".itworx", "docker-compose.yml"], { ".itworx/deployment-target.json": json.dumps(descriptor), "docker-compose.yml": "services:\n quiz-server:\n image: example/quiz-server:1\n", }, ) contract = service._compose_contract( FakeClient(), repository_name="QuizMonitor", revision="d" * 40 ) assert contract == { "applicationId": "quizmonitor", "composeFile": "docker-compose.yml", "composeProject": "quiz-monitor", "primaryService": "quiz-server", "containers": ["quiz-monitor-quiz-server-1"], "serviceNames": ["quiz-server"], "healthTimeout": 180, "verifyOciRevision": False, } def test_descriptor_rejects_invalid_compose_project_and_missing_primary_service(): with pytest.raises(ValueError, match="composeProject is invalid"): GiteaProjectAutoRegistration._safe_descriptor( { "apiVersion": "itworx.deployment-target/v1", "applicationId": "quizmonitor", "composeProject": "../quiz-monitor", "primaryService": "quiz-server", }, "QuizMonitor", ) service = FakeComposeInspection( [".itworx", "compose.yml"], { ".itworx/deployment-target.json": json.dumps({ "apiVersion": "itworx.deployment-target/v1", "applicationId": "quizmonitor", "composeProject": "quiz-monitor", "primaryService": "quiz-server", }), "compose.yml": "services:\n web:\n image: example/web:1\n", }, ) with pytest.raises(ValueError, match="primaryService must name a Compose service"): service._compose_contract( FakeClient(), repository_name="QuizMonitor", revision="e" * 40 ) def test_sync_catalogs_every_repo_but_only_activates_proven_compose_targets(): registry = FakeTargetRegistry() contract = deployable_contract() contract["containers"] = ["deployable-api-1", "deployable-web-1"] contract["serviceNames"] = ["deployable", "worker"] service = FakeProjectSync( [repo("Deployable"), repo("DocsOnly"), repo("Legacy", branch="master")], { "Deployable": contract, "DocsOnly": None, "Legacy": None, }, target_registry=registry, ) with make_session() as session: result = service.sync(session) assert result["status"] == "ok" assert result["summary"]["total"] == 3 assert result["summary"]["registered"] == 1 assert result["summary"]["knownUnconfigured"] == 2 assert len(registry.calls) == 1 repository, revision, kwargs = registry.calls[0] assert repository == "jens/deployable" assert len(revision) == 40 assert kwargs["branch"] == "main" assert kwargs["containers"] == ["deployable-api-1", "deployable-web-1"] policies = session.exec( select(Policy).where(Policy.target == "deployable") ).all() assert {item.action for item in policies} == { "deployment.apply", "deployment.rollback", "deployment.runnerless.provision", } assert all(item.manual == "allowed" for item in policies) assert all(item.voice == "blocked" for item in policies) def test_sync_registers_quizmonitor_identity_without_changing_compose_project_or_service(): registry = FakeTargetRegistry() contract = { "applicationId": "quizmonitor", "composeFile": "docker-compose.yml", "composeProject": "quiz-monitor", "primaryService": "quiz-server", "containers": ["quiz-monitor-quiz-server-1"], "serviceNames": ["quiz-server"], "healthTimeout": 180, "verifyOciRevision": False, } service = FakeProjectSync( [repo("QuizMonitor")], {"QuizMonitor": contract}, target_registry=registry, ) with make_session() as session: result = service.sync(session) catalog = GiteaProjectAutoRegistration.catalog(session) assert result["summary"]["registered"] == 1 assert len(registry.calls) == 1 repository, revision, kwargs = registry.calls[0] assert repository == "jens/quizmonitor" assert kwargs["compose_project"] == "quiz-monitor" assert kwargs["containers"] == ["quiz-monitor-quiz-server-1"] assert catalog["projects"][0]["applicationId"] == "quizmonitor" assert catalog["projects"][0]["composeProject"] == "quiz-monitor" assert catalog["projects"][0]["primaryService"] == "quiz-server" def test_unchanged_catalog_entries_do_not_repeat_registration_work(): registry = FakeTargetRegistry() repositories = [repo("Deployable")] service = FakeProjectSync( repositories, {"Deployable": deployable_contract()}, target_registry=registry, ) with make_session() as session: first = service.sync(session) assert first["summary"]["registered"] == 1 assert len(registry.calls) == 1 first_branch_calls = len(service.branch_calls) first_contract_calls = len(service.contract_calls) second = service.sync(session) assert second["summary"]["registered"] == 1 assert second["summary"]["changedThisSync"] == 0 assert len(registry.calls) == 1 assert len(service.branch_calls) == first_branch_calls assert len(service.contract_calls) == first_contract_calls def test_existing_exact_policy_is_not_repurposed_when_it_blocks_deployments(): registry = FakeTargetRegistry() service = FakeProjectSync( [repo("Deployable")], {"Deployable": deployable_contract()}, target_registry=registry, ) with make_session() as session: session.add( Policy( id="operator-owned-policy", action="deployment.apply", target="deployable", voice="blocked", manual="blocked", risk="high", reason="operator-owned denial", ) ) session.commit() result = service.sync(session) project = result["projects"][0] assert project["state"] == "policy_conflict" preserved = session.get(Policy, "operator-owned-policy") assert preserved is not None assert preserved.manual == "blocked" policies = session.exec(select(Policy).where(Policy.target == "deployable")).all() assert [item.id for item in policies] == ["operator-owned-policy"] def test_rollback_policy_conflict_does_not_stage_or_commit_apply_policy(): registry = FakeTargetRegistry() service = FakeProjectSync( [repo("Deployable")], {"Deployable": deployable_contract()}, target_registry=registry, ) with make_session() as session: session.add( Policy( id="operator-owned-rollback-denial", action="deployment.rollback", target="deployable", voice="blocked", manual="blocked", risk="high", reason="operator-owned rollback denial", ) ) session.commit() result = service.sync(session) assert result["projects"][0]["state"] == "policy_conflict" policies = session.exec(select(Policy).where(Policy.target == "deployable")).all() assert [(item.id, item.action, item.manual) for item in policies] == [ ("operator-owned-rollback-denial", "deployment.rollback", "blocked") ] def test_unchanged_controller_conflict_is_retried_and_can_self_heal(): class ConflictThenRegisteredRegistry(FakeTargetRegistry): def register_compose_target(self, repository, revision, **kwargs): self.calls.append((repository, revision, kwargs)) if len(self.calls) == 1: return { "status": "conflict", "repository": repository, "revision": revision, "reason": "deployment target already exists with different topology", } return { "status": "registered", "repository": repository, "revision": revision, } registry = ConflictThenRegisteredRegistry() service = FakeProjectSync( [repo("Deployable")], {"Deployable": deployable_contract()}, target_registry=registry, ) with make_session() as session: first = service.sync(session) assert first["projects"][0]["state"] == "controller_conflict" assert len(registry.calls) == 1 second = service.sync(session) assert second["projects"][0]["state"] == "registered" assert second["summary"]["changedThisSync"] == 1 assert len(registry.calls) == 2 assert len(second["projects"][0]["policyIds"]) == 3 def test_unchanged_policy_conflict_is_retried_after_operator_owned_policy_is_repaired(): registry = FakeTargetRegistry() service = FakeProjectSync( [repo("Deployable")], {"Deployable": deployable_contract()}, target_registry=registry, ) with make_session() as session: policy = Policy( id="operator-owned-policy", action="deployment.apply", target="deployable", voice="blocked", manual="blocked", risk="high", reason="operator-owned denial", ) session.add(policy) session.commit() first = service.sync(session) assert first["projects"][0]["state"] == "policy_conflict" assert len(registry.calls) == 1 policy.manual = "allowed" session.add(policy) session.commit() second = service.sync(session) assert second["projects"][0]["state"] == "registered" assert second["summary"]["changedThisSync"] == 1 assert len(registry.calls) == 2 assert { item.action for item in session.exec(select(Policy).where(Policy.target == "deployable")).all() } == {"deployment.apply", "deployment.rollback", "deployment.runnerless.provision"}