from __future__ import annotations import pytest from sqlmodel import Session, SQLModel, create_engine, select from backend.app.db.models import DeploymentJob, Policy from backend.app.schemas.deployment import DeploymentPlanV1 from backend.app.services.deployment_control_plane import ( DeploymentControlError, DeploymentControlPlane, ) from backend.app.services.secret_store import set_secret def _plan() -> DeploymentPlanV1: return DeploymentPlanV1.model_validate( { "application_id": "itworx-mcp-hub", "repository": "Jens/ITWorx_MCP_Hub", "revision": "a" * 40, "release_version": "1.0.0", "image_repository": "itworx-mcp-hub", "image_tag": "1.0.0", "topology": { "compose_project": "itworx-mcp-hub", "containers": ["itworx-mcp-hub"], "proxy_network": "itworx-npm", "internal_port": 8080, }, "proxy": {"hostname": "mcp-test.example.com"}, "oidc": None, "broker_secret_ref": "MCP_HUB_TEST_DEPLOYMENT_BROKER", } ) class _Broker: def __init__(self) -> None: self.preflight_calls = 0 self.apply_calls: list[str] = [] self.rollback_calls: list[str] = [] def preflight(self, plan, *, secret): self.preflight_calls += 1 return {"status": "preflight_ok"} def apply(self, plan, *, secret, idempotency_key): self.apply_calls.append(idempotency_key) return {"status": "deployed", "request": "a" * 64} def rollback(self, plan, *, secret, idempotency_key): self.rollback_calls.append(idempotency_key) return {"status": "rolled_back", "request": "b" * 64} def status(self, request_id, *, secret): return {"status": "rolled_back", "request": request_id} class _Portainer: def list_containers(self): return {"status": "ok", "containers": []} class _FailingNpm: def proxy_hosts(self): return {"success": True, "items": []} def ensure_managed_proxy_host(self, **kwargs): return {"success": False, "status": "failed"} class _Unraid: def get_share_summary(self): return {"status": "ok"} class _Authentik: def test_connection(self): return {"status": "connected"} def applications(self): return {"success": True, "items": []} def _session(tmp_path) -> Session: engine = create_engine(f"sqlite:///{tmp_path / 'auto-rollback.db'}") SQLModel.metadata.create_all(engine) return Session(engine) def _policy(action: str) -> Policy: return Policy( id=f"allow-mcp-hub-{action.replace('.', '-')}", action=action, target="itworx-mcp-hub", voice="blocked", manual="allowed", risk="high", reason="Exact-target no-regression deployment test policy.", ) def _plane(broker: _Broker) -> DeploymentControlPlane: return DeploymentControlPlane( broker=broker, portainer=_Portainer(), npm=_FailingNpm(), authentik=_Authentik(), unraid=_Unraid(), ) def _configure(session: Session) -> None: set_secret(session, "MCP_HUB_TEST_DEPLOYMENT_BROKER", "x" * 32) session.add(_policy("deployment.apply")) session.add(_policy("deployment.rollback")) session.commit() def test_default_compose_file_survives_durable_plan_roundtrip(): plan = _plan() restored = DeploymentPlanV1.model_validate(plan.model_dump(mode="json")) assert restored.topology.compose_file == ".itworx/deployment.yml" @pytest.mark.parametrize("operation", ["apply", "rollback"]) def test_hub_typed_mutation_is_rejected_before_job_or_broker_queue( tmp_path, operation ): broker = _Broker() plane = _plane(broker) with _session(tmp_path) as session: _configure(session) with pytest.raises(DeploymentControlError, match="signed protected push"): plane.execute( session, plan=_plan(), idempotency_key=f"mcp-hub-{operation}-typed-0001", operation=operation, ) assert session.exec(select(DeploymentJob)).all() == [] assert broker.preflight_calls == 0 assert broker.apply_calls == [] assert broker.rollback_calls == [] def test_hub_read_only_preflight_remains_available(tmp_path): broker = _Broker() plane = _plane(broker) with _session(tmp_path) as session: _configure(session) result = plane.execute( session, plan=_plan(), idempotency_key="mcp-hub-preflight-0001", operation="preflight", ) assert result["status"] == "preflight_succeeded" assert broker.preflight_calls == 1 assert broker.apply_calls == [] assert broker.rollback_calls == [] def test_hub_post_apply_failure_records_data_aware_recovery_without_rollback(tmp_path): broker = _Broker() plane = _plane(broker) plan = _plan() job = DeploymentJob( id="mcp-hub-existing-apply", application_id=plan.application_id, repository=plan.repository, revision=plan.revision, plan_sha256="a" * 64, idempotency_sha256="b" * 64, status="applying", plan_json=plan.model_dump(mode="json"), evidence_json={"brokerStatus": "deployed"}, ) with _session(tmp_path) as session: _configure(session) plane._complete_apply_job(session, job, plan) assert job.status == "failed" assert job.stage == "external_integration_failed" assert job.evidence_json["postApplyVerification"]["status"] == "failed" assert job.evidence_json["recovery"] == { "status": "manual_required", "reason": "hub_data_aware_recovery_required", } assert "Automatic image-only rollback is disabled" in job.result_summary assert broker.rollback_calls == []