632 lines
24 KiB
Python
632 lines
24 KiB
Python
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
|