from __future__ import annotations import hashlib import importlib import json import pytest from sqlmodel import Session, SQLModel, create_engine from backend.app.core.config import settings from backend.app.db.models import AppSetting from backend.app.schemas.enrollment import EnrollmentPlanV1 from backend.app.services import repopulse_adoption_journal as journal from backend.app.services import repopulse_legacy_adoption_protocol as protocol from backend.app.services.secret_store import set_secret def _sha(char: str) -> str: return char * 64 def _passing_preflight() -> dict: check_codes = ( "legacy_registry", "controller_state", "gitea_ancestry", "runtime_identity_and_health", "legacy_storage_topology", "legacy_appdata", "product_api", "database_health", "queue_quiescence", "database_baseline", ) return { "apiVersion": "itworx.repopulse-legacy-adoption-preflight/v1", "repository": "jens/repopulse", "status": "preflight_passed", "legacyCompatibility": { "status": "passed", "checks": [ {"code": code, "status": "passed", "gate": "legacy_compatibility"} for code in check_codes ], }, "targetAcceptance": {"status": "blocked", "checks": []}, "readOnly": True, "adoptionAuthorized": False, "evidence": { "main_revision": "a" * 40, "live_revision": "b" * 40, "container_id_sha256": _sha("d"), "container_image_sha256": _sha("1"), "container_state": "running", "container_health": "healthy", "queue": {"queued": 0, "active": 0, "completed": 569, "failed": 0}, "database": { "database_bytes": 44_576_768, "journal_bytes": 0, "scan_count": 572, "compacted_scan_count": 96, }, }, } def _plan_sha(revision: str) -> str: plan = EnrollmentPlanV1.model_validate( { "application_id": "repopulse", "display_name": "RepoPulse", "environment": "production", "repository": "Jens/RepoPulse", "revision": revision, "profile": "repopulse-authenticated", "target": { "target_identity": "unraid-controller", "container_name": "repopulse", }, "storage": {"runtime_uid": 10001, "runtime_gid": 10001}, "network": {"docker_network": "itworx-mcp-hub-npm", "internal_port": 8000}, "artifact": { "release_version": "1.5.0", "image_repository": "repopulse-managed", "image_tag": "production", "health_path": "/health", }, "exposure": { "hostname": "repopulse.example.com", "upstream_service": "repopulse", }, "secrets": {"additional_secret_refs": ["REPOPULSE_GITEA_TOKEN"]}, } ).model_dump(mode="json") encoded = json.dumps(plan, sort_keys=True, separators=(",", ":")).encode("utf-8") return hashlib.sha256(encoded).hexdigest() def _document_sha(value: dict) -> str: encoded = (json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii") return hashlib.sha256(encoded).hexdigest() def _completion_sha(value: dict) -> str: encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii") return hashlib.sha256(encoded).hexdigest() def _controller_result(intent_id: str, binding: str) -> dict: plan_sha = _plan_sha("a" * 40) gates = { "profileId": "repopulse-authenticated", "profileVersion": "enrollment-profile/v1", "planSha256": plan_sha, **{ key: True for key in ( "profileReady", "controllerTrustReady", "controllerBootstrapSecretImported", "typedEnrollmentTrustReady", "targetSecretReady", "exactPolicyReady", "authenticatedRouteReady", "accessListReady", "mutationBoundaryReady", "managedRuntimeReady", "scanSourceMatches", "scanMountReadOnly", "scanPermissionPolicyPersistent", "scanAccessForRuntimeIdentityReady", "ownedDataTargetReady", "managedEnvironmentReady", "legacyEnvironmentRetained", "maintenanceAuthReady", "controllerAdoptionCapabilityReady", "controllerRegistryWriterReady", "controllerSecretWriterReady", ) }, } quiescence = { "intentId": intent_id, "globalDeployLockHeld": True, "projectLockHeld": True, "maintenanceModeActive": True, "queueFrozen": True, "queuedJobs": 0, "activeJobs": 0, "maintenanceModeAuthenticated": True, "containerName": "repopulse", "containerIdSha256": _sha("d"), "containerImageSha256": _sha("1"), "containerState": "exited", "legacyVolume": "repopulse_data", "controllerIntentDurable": True, "controllerIntentBindingSha256": binding, } snapshot = { "intentId": intent_id, "globalDeployLockHeld": True, "projectLockHeld": True, "legacyContainerStopped": True, "legacyVolume": "repopulse_data", "legacyVolumeRetained": True, "sqliteBackupMethod": "sqlite_online_backup", "quickCheck": "ok", "integrityCheck": "ok", "sourceLogicalSha256": _sha("e"), "snapshotLogicalSha256": _sha("e"), "snapshotFileSha256": _sha("f"), "sourceScanCount": 572, "snapshotScanCount": 572, "sourceCompactedScanCount": 96, "snapshotCompactedScanCount": 96, "sourceQueueSha256": _sha("1"), "snapshotQueueSha256": _sha("1"), "sourceAuxiliaryTreeSha256": _sha("3"), "sourceAuxiliaryFileCount": 42, "targetDataPathWasAbsent": True, "snapshotReceiptDurable": True, "legacyEnvironmentRetained": True, "managedEnvironmentFilePrivate": True, "controllerIntentBindingSha256": binding, } copied = { "intentId": intent_id, "snapshotFileSha256": _sha("f"), "targetLogicalSha256": _sha("e"), "targetScanCount": 572, "targetCompactedScanCount": 96, "targetQueueSha256": _sha("1"), "sourceAuxiliaryTreeSha256": _sha("3"), "targetAuxiliaryTreeSha256": _sha("3"), "sourceAuxiliaryFileCount": 42, "targetAuxiliaryFileCount": 42, "targetQuickCheck": "ok", "targetIntegrityCheck": "ok", "targetDataUid": 10001, "targetDataGid": 10001, "targetRecoveryStatus": "healthy", "managedDataMountMatches": True, "copySourceIsVerifiedSnapshot": True, "legacyVolumeRetained": True, "legacyEnvironmentRetained": True, "managedEnvironmentFilePrivate": True, } enrollment_id = f"enr-{intent_id[4:]}" registry_receipt = { "apiVersion": "itworx.repopulse-controller-adoption-store-receipt/v1", "intentId": intent_id, "repository": "jens/repopulse", "applicationId": "repopulse", "phase": "registry_committed", "intentBindingSha256": binding, "legacyVolumeRetained": True, "intentDurable": True, "managedEnrollmentId": enrollment_id, "projectsSha256": _sha("2"), "snapshotFileSha256": _sha("f"), } registry_receipt["receiptSha256"] = _document_sha(registry_receipt) deployment_receipt = { "repository": "jens/repopulse", "revision": "a" * 40, "containerRevision": "a" * 40, "containerIdSha256": _sha("4"), "deploymentReceiptSchema": "gitea-deploy-receipt.v1", "deploymentReceiptSha256": _sha("5"), "deploymentReceiptVerified": True, "runtimeHealthy": True, "publishedHostPorts": [], "managedDataMountMatches": True, "targetDataUid": 10001, "targetDataGid": 10001, "targetLogicalSha256": _sha("e"), "targetScanCount": 572, "targetCompactedScanCount": 96, "targetQueueSha256": _sha("1"), "targetAuxiliaryTreeSha256": _sha("3"), "targetAuxiliaryFileCount": 42, "targetQuickCheck": "ok", "targetIntegrityCheck": "ok", "targetRecoveryStatus": "healthy", "targetQueuedJobs": 0, "targetActiveJobs": 0, "legacyVolume": "repopulse_data", "legacyVolumeRetained": True, "legacyEnvironmentRetained": True, "managedEnvironmentFilePrivate": True, "scanSourceMatches": True, "scanMountReadOnly": True, "scanPermissionPolicyPersistent": True, "scanAccessForRuntimeIdentityReady": True, "containerName": "repopulse", "containerImageSha256": _sha("6"), "snapshotFileSha256": _sha("f"), } completion = { "apiVersion": "itworx.repopulse-controller-adoption-completion/v1", "intentId": intent_id, "intentBindingSha256": binding, "controllerRegistrationReceiptSha256": registry_receipt["receiptSha256"], "mainRevision": "a" * 40, "deploymentReceiptSha256": deployment_receipt["deploymentReceiptSha256"], "snapshotFileSha256": snapshot["snapshotFileSha256"], "snapshotLogicalSha256": snapshot["snapshotLogicalSha256"], "snapshotQueueSha256": snapshot["snapshotQueueSha256"], "auxiliaryTreeSha256": copied["targetAuxiliaryTreeSha256"], "containerIdSha256": deployment_receipt["containerIdSha256"], "containerImageSha256": deployment_receipt["containerImageSha256"], "legacyVolume": "repopulse_data", "legacyVolumeRetained": True, } completion["completionSha256"] = _completion_sha(completion) return { "apiVersion": "itworx.repopulse-controller-adoption-execution-result/v1", "repository": "jens/repopulse", "applicationId": "repopulse", "intentId": intent_id, "status": "deployment_verified", "phase": "completion_verified", "legacyVolume": "repopulse_data", "legacyVolumeRetained": True, "targetGates": gates, "exactShaValidation": { "repository": "jens/repopulse", "revision": "a" * 40, "contexts": {"Managed validation / full (push)": "success"}, }, "quiescence": quiescence, "snapshot": snapshot, "copy": copied, "registryReceipt": registry_receipt, "deploymentReceipt": deployment_receipt, "completion": completion, } class FakePreflight: def __init__(self) -> None: self.calls = 0 def run(self) -> dict: self.calls += 1 return _passing_preflight() class FakeBroker: base_url = "https://controller.test" verify_tls = True ca_file = None def __init__( self, *, reserve_result: dict | None = None, status_result: dict | None = None, ) -> None: self.reserve_result = reserve_result self.status_result = status_result self.execute_calls: list[dict] = [] self.status_calls = 0 self.reserve_calls: list[dict] = [] def configuration_error(self): return None def repopulse_adoption_capabilities(self, *, secret: str) -> dict: return { "apiVersion": "itworx.repopulse-adoption-capabilities/v1", "status": "ready", "repository": "jens/repopulse", "adoptionStore": {"executionEnabled": True}, "privateTls": {"status": "ready"}, "controller": { "aliases": "ready", "registry": "ready", "secretRoot": "ready", }, } def repopulse_adoption_reserve(self, **kwargs) -> dict: self.reserve_calls.append(kwargs) if self.reserve_result is not None: return self.reserve_result binding = hashlib.sha256( json.dumps( { "intentId": kwargs["intent_id"], "repository": "jens/repopulse", "applicationId": "repopulse", "preflightSha256": kwargs["preflight_sha256"], "idempotencySha256": kwargs["idempotency_sha256"], }, sort_keys=True, separators=(",", ":"), ensure_ascii=True, ).encode("ascii") ).hexdigest() return { "apiVersion": "itworx.repopulse-adoption-reserve/v1", "status": "reserved", "intentId": kwargs["intent_id"], "phase": "reserved", "intentSha256": _sha("7"), "intentBindingSha256": binding, } def repopulse_adoption_execute(self, **kwargs) -> dict: self.execute_calls.append(kwargs) raise AssertionError("AppOps must not invoke the controller cutover") def repopulse_adoption_status(self, *, secret: str) -> dict: self.status_calls += 1 return self.status_result or { "apiVersion": "itworx.repopulse-adoption-status/v1", "status": "not_reserved", "phase": None, "hashes": {}, } def _runner_type(): try: module = importlib.import_module("backend.app.services.repopulse_adoption_runner") except ModuleNotFoundError: pytest.fail("fixed RepoPulse AppOps runner is missing") return module.RepoPulseAdoptionRunner def _controller_status(intent: dict, controller_result: dict) -> dict: snapshot = controller_result["snapshot"] gates = controller_result["targetGates"] registry = controller_result["registryReceipt"] return { "apiVersion": "itworx.repopulse-adoption-status/v1", "status": "in_progress", "phase": "snapshot_verified", "hashes": { "intentSha256": _sha("7"), "intentBindingSha256": protocol.intent_binding_sha256(intent), "idempotencySha256": intent["idempotencySha256"], "preflightSha256": intent["preflightSha256"], "expectedProjectsSha256": registry["projectsSha256"], "quiescenceSha256": _sha("d"), "planSha256": gates["planSha256"], "snapshotFileSha256": snapshot["snapshotFileSha256"], "snapshotLogicalSha256": snapshot["snapshotLogicalSha256"], "snapshotQueueSha256": snapshot["snapshotQueueSha256"], "targetProjectsSha256": registry["projectsSha256"], "receiptSha256": registry["receiptSha256"], }, } def _session(monkeypatch) -> Session: monkeypatch.setattr(settings, "environment", "production") monkeypatch.setattr(settings, "secret_key", "appops-test-key-with-at-least-32-bytes") monkeypatch.setattr(settings, "secret_key_source", "env") engine = create_engine("sqlite://") SQLModel.metadata.create_all(engine) session = Session(engine) session.add( AppSetting( key="deployment_enrollment:trust", is_secret=False, value_json={ "controller_identity": "unraid-controller", "bootstrap_secret_ref": "APPOPS_DEPLOYMENT_BOOTSTRAP", "approved_networks": ["itworx-mcp-hub-npm"], "allowed_domain_suffixes": ["example.com"], "autonomous_profiles": ["repopulse-authenticated"], "repopulse_proxy_access_list_id": 1, }, ) ) session.commit() set_secret(session, "APPOPS_DEPLOYMENT_BOOTSTRAP", "B" * 64) return session def test_synthetic_prepare_and_finalize_commits_protocol_ownership_and_completion(monkeypatch): runner_type = _runner_type() monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True) session = _session(monkeypatch) preflight = FakePreflight() report = _passing_preflight() candidate = protocol.create_intent(report, "repopulse-adoption-2026") binding = protocol.intent_binding_sha256(candidate) controller_result = _controller_result(candidate["intentId"], binding) broker = FakeBroker() runner = runner_type(session=session, preflight=preflight, broker=broker) handoff = runner.prepare_controller_cli_handoff("repopulse-adoption-2026") prepared = journal.get_repopulse_adoption_status(session) broker.status_result = _controller_status(candidate, controller_result) result = runner.finalize(controller_result) assert prepared["status"] == "in_progress" assert prepared["phase"] == "reserved" assert handoff["intentId"] == candidate["intentId"] assert result["status"] == "completed" assert result["phase"] == "completed" assert result["revision"] == 7 assert preflight.calls == 1 assert len(broker.reserve_calls) == 1 assert broker.reserve_calls[0]["intent_id"] == candidate["intentId"] assert broker.reserve_calls[0]["preflight_sha256"] == candidate["preflightSha256"] assert broker.execute_calls == [] def test_prepared_and_completed_replays_never_repeat_external_mutations(monkeypatch): runner_type = _runner_type() monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True) session = _session(monkeypatch) report = _passing_preflight() candidate = protocol.create_intent(report, "repopulse-adoption-2026") binding = protocol.intent_binding_sha256(candidate) controller_result = _controller_result(candidate["intentId"], binding) broker = FakeBroker() runner = runner_type(session=session, preflight=FakePreflight(), broker=broker) broker.status_result = _controller_status(candidate, controller_result) handoff = runner.prepare_controller_cli_handoff("repopulse-adoption-2026") replay = runner.prepare("repopulse-adoption-2026") completed = runner.finalize(controller_result) completed_replay = runner.prepare("repopulse-adoption-2026") assert handoff["intentId"] == candidate["intentId"] assert replay["status"] == "prepared" assert completed["status"] == completed_replay["status"] == "completed" assert completed_replay["revision"] == completed["revision"] assert len(broker.reserve_calls) == 1 assert broker.execute_calls == [] def test_controller_cli_handoff_is_exact_in_memory_contract_and_not_printed(monkeypatch, capsys): runner_type = _runner_type() monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True) session = _session(monkeypatch) report = _passing_preflight() candidate = protocol.create_intent(report, "repopulse-adoption-2026") broker = FakeBroker() runner = runner_type(session=session, preflight=FakePreflight(), broker=broker) handoff = runner.prepare_controller_cli_handoff("repopulse-adoption-2026") assert set(handoff) == { "intentId", "idempotencySha256", "preflightSha256", "preflightProjection", "targetCredential", } assert handoff["intentId"] == candidate["intentId"] assert handoff["idempotencySha256"] == candidate["idempotencySha256"] assert handoff["preflightSha256"] == candidate["preflightSha256"] assert handoff["preflightProjection"] == candidate["evidence"]["preflight"] assert isinstance(handoff["targetCredential"], str) assert 32 <= len(handoff["targetCredential"]) <= 256 assert capsys.readouterr().out == "" assert len(broker.reserve_calls) == 1 assert broker.execute_calls == [] def test_controller_cli_handoff_cannot_be_reissued(monkeypatch): runner_type = _runner_type() monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True) session = _session(monkeypatch) candidate = protocol.create_intent(_passing_preflight(), "repopulse-adoption-2026") controller_result = _controller_result( candidate["intentId"], protocol.intent_binding_sha256(candidate) ) broker = FakeBroker(status_result=_controller_status(candidate, controller_result)) runner = runner_type(session=session, preflight=FakePreflight(), broker=broker) first = runner.prepare_controller_cli_handoff("repopulse-adoption-2026") with pytest.raises(ValueError, match="controller_cli_handoff_replay_requires_recovery"): runner.prepare_controller_cli_handoff("repopulse-adoption-2026") status = journal.get_repopulse_adoption_status(session) assert first["intentId"] == candidate["intentId"] assert status["status"] == "recovery_required" assert status["phase"] == "reserved" assert len(broker.reserve_calls) == 1 assert broker.execute_calls == [] def test_operator_runner_hydrates_portainer_settings_without_fastapi_lifespan(monkeypatch): runner_type = _runner_type() monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True) session = _session(monkeypatch) calls = [] runner_module = importlib.import_module( "backend.app.services.repopulse_adoption_runner" ) monkeypatch.setattr( runner_module, "apply_stored_connector_settings", lambda active_session, connector_ids: calls.append( (active_session, connector_ids) ), ) runner = runner_type(session=session, preflight=FakePreflight(), broker=FakeBroker()) assert calls == [] runner.prepare("repopulse-adoption-2026") assert calls == [(session, {"portainer"})] def test_execution_flag_remains_fail_closed_in_source(monkeypatch): runner_type = _runner_type() monkeypatch.setattr(protocol, "EXECUTION_ENABLED", False) session = _session(monkeypatch) preflight = FakePreflight() broker = FakeBroker() runner = runner_type(session=session, preflight=preflight, broker=broker) assert runner.prepare("repopulse-adoption-2026")["status"] == "disabled" assert preflight.calls == 0 assert broker.reserve_calls == [] assert broker.execute_calls == [] def test_tampered_completion_is_rejected_and_marked_for_recovery(monkeypatch): runner_type = _runner_type() monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True) session = _session(monkeypatch) candidate = protocol.create_intent(_passing_preflight(), "repopulse-adoption-2026") binding = protocol.intent_binding_sha256(candidate) controller_result = _controller_result(candidate["intentId"], binding) broker = FakeBroker() runner = runner_type(session=session, preflight=FakePreflight(), broker=broker) runner.prepare_controller_cli_handoff("repopulse-adoption-2026") broker.status_result = _controller_status(candidate, controller_result) controller_result["completion"]["completionSha256"] = _sha("0") result = runner.finalize(controller_result) assert result["status"] == "recovery_required" assert result["phase"] == "reserved" assert broker.status_calls == 1 assert broker.execute_calls == [] def test_ambiguous_controller_reserve_queries_status_and_requires_recovery(monkeypatch): runner_type = _runner_type() monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True) session = _session(monkeypatch) broker = FakeBroker( reserve_result={ "status": "unreachable", "reason": "secret-bearing raw transport error", } ) runner = runner_type(session=session, preflight=FakePreflight(), broker=broker) result = runner.prepare("repopulse-adoption-2026") assert result["status"] == "recovery_required" assert result["reason"] == "unknown_external_effect" assert broker.status_calls == 1 assert len(broker.reserve_calls) == 1 assert broker.execute_calls == [] assert "secret-bearing raw transport error" not in json.dumps(result) replay = runner.prepare("repopulse-adoption-2026") assert replay["status"] == "recovery_required" assert len(broker.reserve_calls) == 1