178 lines
6.2 KiB
Python
178 lines
6.2 KiB
Python
from types import SimpleNamespace
|
|
|
|
from sqlmodel import Session, SQLModel, create_engine
|
|
|
|
from backend.app.services import action_execution
|
|
from backend.app.services.action_execution import approval_execution_supported
|
|
from backend.app.services.action_registry import (
|
|
ACTION_RISK,
|
|
ACTIONS,
|
|
action_default_target,
|
|
)
|
|
from backend.app.services.privatemuse_webhook_repair import (
|
|
PrivateMuseWebhookRepair,
|
|
_ENDPOINT,
|
|
_REPAIR_SCRIPT,
|
|
)
|
|
|
|
|
|
class FakeResponse:
|
|
def __init__(self, payload):
|
|
self.payload = payload
|
|
|
|
def json(self):
|
|
return self.payload
|
|
|
|
|
|
class FakePortainer:
|
|
def __init__(self, exit_code=0):
|
|
self.exit_code = exit_code
|
|
self.calls = []
|
|
|
|
def get_container_status(self, name):
|
|
assert name == "gitea-deploy-control"
|
|
return {
|
|
"status": "ok",
|
|
"container": {
|
|
"id": "controller-id",
|
|
"name": "gitea-deploy-control",
|
|
"state": "running",
|
|
},
|
|
}
|
|
|
|
def _config(self):
|
|
return SimpleNamespace(endpoint_id="3")
|
|
|
|
def _request(self, method, path, **kwargs):
|
|
self.calls.append((method, path, kwargs))
|
|
if path.endswith("/containers/controller-id/exec"):
|
|
return FakeResponse({"Id": "exec-webhook"})
|
|
if path.endswith("/exec/exec-webhook/start"):
|
|
return FakeResponse({})
|
|
if path.endswith("/exec/exec-webhook/json"):
|
|
return FakeResponse({"Running": False, "ExitCode": self.exit_code})
|
|
raise AssertionError(path)
|
|
|
|
|
|
def test_privatemuse_webhook_repair_is_fixed_high_risk_typed_action():
|
|
action = "deployment.webhook.repair_privatemuse"
|
|
assert action in ACTIONS
|
|
assert ACTION_RISK[action] == "high"
|
|
assert action_default_target(action) == "jens/privatemuse"
|
|
assert approval_execution_supported(action) is True
|
|
|
|
|
|
def test_privatemuse_webhook_repair_executes_only_fixed_contract():
|
|
fake = FakePortainer()
|
|
|
|
result = PrivateMuseWebhookRepair(fake).run()
|
|
|
|
assert result == {
|
|
"status": "repaired",
|
|
"repository": "jens/privatemuse",
|
|
"endpoint": _ENDPOINT,
|
|
"branch": "main",
|
|
"event": "push",
|
|
}
|
|
command = fake.calls[0][2]["json"]["Cmd"]
|
|
assert command[:3] == ["python3", "-c", _REPAIR_SCRIPT]
|
|
assert command[3:] == ["jens/privatemuse", _ENDPOINT]
|
|
|
|
|
|
def test_privatemuse_webhook_repair_script_never_returns_credentials():
|
|
assert "runner_guard_admin_token" in _REPAIR_SCRIPT
|
|
assert 'secret_root = Path("/opt/gitea-deploy/webhook-secrets")' in _REPAIR_SCRIPT
|
|
assert 'secret_path = secret_root / "jens__privatemuse"' in _REPAIR_SCRIPT
|
|
assert "secretMaterialReturned" not in _REPAIR_SCRIPT
|
|
assert 'print(f"privatemuse_runnerless_webhook_ready hook_id={hook_id}"' in _REPAIR_SCRIPT
|
|
assert "print(secret" not in _REPAIR_SCRIPT
|
|
assert "print(token" not in _REPAIR_SCRIPT
|
|
|
|
|
|
def test_privatemuse_webhook_repair_proves_effective_controller_reachability():
|
|
assert "gitea_config_path" not in _REPAIR_SCRIPT
|
|
assert '"docker", "exec", "Gitea"' in _REPAIR_SCRIPT
|
|
assert '"http://198.19.0.1:1299/health"' in _REPAIR_SCRIPT
|
|
assert 'health_payload.get("status") != "ok"' in _REPAIR_SCRIPT
|
|
assert 'health_payload.get("protocolVersion") != "itworx-controller-request/v2"' in _REPAIR_SCRIPT
|
|
|
|
|
|
def test_privatemuse_webhook_repair_is_fail_closed_and_non_destructive():
|
|
assert 'repository != "jens/privatemuse"' in _REPAIR_SCRIPT
|
|
assert 'project.get("branch") != "main"' in _REPAIR_SCRIPT
|
|
assert '"Managed validation / full (push)"' in _REPAIR_SCRIPT
|
|
assert 'url.endswith("/v1/gitea/push/jens/privatemuse")' in _REPAIR_SCRIPT
|
|
assert "len(exact) > 1 or semantic_other" in _REPAIR_SCRIPT
|
|
assert 'request("PATCH", f"/repos/Jens/PrivateMuse/hooks/{hook_id}", payload)' in _REPAIR_SCRIPT
|
|
assert 'request(\n "POST",\n "/repos/Jens/PrivateMuse/hooks",' in _REPAIR_SCRIPT
|
|
for forbidden in (
|
|
"docker restart",
|
|
"docker stop",
|
|
"docker rm",
|
|
"docker compose",
|
|
'request("DELETE"',
|
|
):
|
|
assert forbidden not in _REPAIR_SCRIPT
|
|
|
|
|
|
def test_privatemuse_webhook_repair_maps_validation_failures():
|
|
result = PrivateMuseWebhookRepair(FakePortainer(exit_code=34)).run()
|
|
|
|
assert result == {
|
|
"status": "validation_failed",
|
|
"reason": "PrivateMuse has ambiguous or stale runnerless webhook endpoints",
|
|
}
|
|
|
|
|
|
def test_privatemuse_webhook_repair_rejects_any_other_action_target():
|
|
result = action_execution.execute_governed_action(
|
|
None, # type: ignore[arg-type]
|
|
action="deployment.webhook.repair_privatemuse",
|
|
target="jens/not-privatemuse",
|
|
source="chatgpt-mcp",
|
|
risk="high",
|
|
reason="Regression test for exact-target enforcement.",
|
|
original_policy_decision="allowed",
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["result"] == "blocked"
|
|
assert result["message"] == "PrivateMuse webhook repair accepts only jens/privatemuse."
|
|
|
|
|
|
def test_privatemuse_webhook_repair_dispatches_fixed_executor(monkeypatch, tmp_path):
|
|
engine = create_engine(f"sqlite:///{tmp_path / 'webhook-action.db'}")
|
|
SQLModel.metadata.create_all(engine)
|
|
|
|
class FakeRepair:
|
|
def run(self):
|
|
return {
|
|
"status": "repaired",
|
|
"repository": "jens/privatemuse",
|
|
"endpoint": _ENDPOINT,
|
|
"branch": "main",
|
|
"event": "push",
|
|
}
|
|
|
|
import backend.app.services.privatemuse_webhook_repair as repair_module
|
|
|
|
monkeypatch.setattr(repair_module, "PrivateMuseWebhookRepair", FakeRepair)
|
|
|
|
with Session(engine) as session:
|
|
result = action_execution.execute_governed_action(
|
|
session,
|
|
action="deployment.webhook.repair_privatemuse",
|
|
target="jens/privatemuse",
|
|
source="chatgpt-mcp",
|
|
risk="high",
|
|
reason="Repair the fixed PrivateMuse runnerless hook.",
|
|
original_policy_decision="approval_required",
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["status"] == "success"
|
|
assert result["policyDecision"] == "allowed"
|
|
assert result["originalPolicyDecision"] == "approval_required"
|
|
assert result["jobId"].startswith("job-")
|
|
assert result["auditId"].startswith("audit-")
|