Files
Unraid-AppOps-Gateway-Public/backend/tests/test_hub_component_transport.py
T

178 lines
9.5 KiB
Python

from __future__ import annotations
import hashlib
import io
import json
import subprocess
import sys
from email.message import Message
from types import SimpleNamespace
import pytest
from test_deploy_broker import BROKER_ROOT, _load_module
@pytest.fixture
def controller(monkeypatch):
module = _load_module("component_transport_test", BROKER_ROOT / "status_server.py")
monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_a, **_k: True)
monkeypatch.setattr(module, "_self_upgrade_secret", lambda: b"transport-test-key-" + b"x" * 48)
projects = {module.REGISTERED_AUTO_APPLY_REPOSITORY: {"branch": "main", "validation": {"required": True}},
"other/unchanged": {"branch": "main"}}
monkeypatch.setattr(module, "_load_projects", lambda: projects)
monkeypatch.setattr(module, "_write_projects", lambda updated: (projects.clear(), projects.update(updated)))
monkeypatch.setattr(module, "_validation_policy", lambda *_a: {"required": True})
return module, projects
def call(module, path, payload, method="_handle_registered_auto_apply"):
handler = module.Handler.__new__(module.Handler)
body = json.dumps(payload).encode()
handler.path = path
handler.headers = Message()
handler.headers["Content-Length"] = str(len(body))
handler.headers["Content-Type"] = "application/json"
handler.rfile = io.BytesIO(body)
handler._trusted_client = lambda: True
responses = []
handler._send_json = lambda status, data: responses.append((status, json.loads(data)))
getattr(handler, method)()
return responses[0]
def intent(module, projects):
project = projects[module.REGISTERED_AUTO_APPLY_REPOSITORY]
return {"repository": module.REGISTERED_AUTO_APPLY_REPOSITORY, "held": True,
"expectedRegistrySha256": hashlib.sha256(json.dumps(project, sort_keys=True, separators=(",", ":")).encode()).hexdigest(),
"reason": "Limit rollout to the validated Gitea component", "requestId": "11111111-1111-4111-8111-111111111111"}
def test_hold_is_cas_bound_and_retains_registered_policy(controller):
module, projects = controller
payload = intent(module, projects)
original = dict(projects[module.REGISTERED_AUTO_APPLY_REPOSITORY])
code, report = call(module, module.REGISTERED_AUTO_APPLY_PATH, payload)
assert code == 200 and report["status"] == "held" and report["autoApply"] is False
updated = projects[module.REGISTERED_AUTO_APPLY_REPOSITORY]
assert updated["branch"] == original["branch"]
assert updated["validation"] == original["validation"]
assert projects["other/unchanged"] == {"branch": "main"}
assert payload["reason"] not in json.dumps(updated)
assert call(module, module.REGISTERED_AUTO_APPLY_PATH, payload)[1]["reason"] == "registered_target_policy_changed"
code, preflight = call(module, module.REGISTERED_AUTO_APPLY_PATH + "/preflight", {"repository": payload["repository"]})
assert code == 200 and preflight["autoApply"] is False
payload.update(held=False, expectedRegistrySha256=preflight["registrySha256"])
assert call(module, module.REGISTERED_AUTO_APPLY_PATH, payload)[1]["status"] == "released"
@pytest.mark.parametrize("change", [{"repository": "other/unchanged"}, {"held": "true"}, {"image": "caller/image"},
{"requestId": "bad"}, {"expectedRegistrySha256": "0" * 64}])
def test_hold_rejects_unknown_target_input_and_stale_fingerprint(controller, change):
module, projects = controller
before = json.dumps(projects, sort_keys=True)
code, _ = call(module, module.REGISTERED_AUTO_APPLY_PATH, {**intent(module, projects), **change})
assert code in {400, 409}
assert json.dumps(projects, sort_keys=True) == before
def test_hold_authentication_and_inflight_deployment_remain_fail_closed(controller, monkeypatch):
module, projects = controller
payload = intent(module, projects)
monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_a, **_k: False)
assert call(module, module.REGISTERED_AUTO_APPLY_PATH, payload)[0] == 403
monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_a, **_k: True)
module._registered_auto_apply_lock.acquire()
try:
assert call(module, module.REGISTERED_AUTO_APPLY_PATH, payload)[1]["reason"] == "registered_target_deployment_active"
finally:
module._registered_auto_apply_lock.release()
def test_execution_rechecks_hold_under_same_lock(controller, monkeypatch):
module, projects = controller
invoked = []
def execute(command, **_kwargs):
assert module._registered_auto_apply_lock.acquire(blocking=False) is False
invoked.append(command)
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(module.subprocess, "run", execute)
projects[module.REGISTERED_AUTO_APPLY_REPOSITORY]["auto_apply"] = False
assert module._run_registered_deploy(module.REGISTERED_AUTO_APPLY_REPOSITORY, "a" * 40).returncode == 4
assert invoked == []
projects[module.REGISTERED_AUTO_APPLY_REPOSITORY]["auto_apply"] = True
assert module._run_registered_deploy(module.REGISTERED_AUTO_APPLY_REPOSITORY, "a" * 40).returncode == 0
assert invoked == [["python3", str(module.ROOT / "deploy.py"), "deploy", module.REGISTERED_AUTO_APPLY_REPOSITORY, "a" * 40]]
def test_component_authentication_blocks_before_reading_or_mutating(controller, monkeypatch):
module, _ = controller
monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_a, **_k: False)
assert call(module, "/v1/appops/hub-components/apply", {"image": "caller/image"}, "_handle_hub_component")[0] == 403
def test_component_worker_serializes_hold_and_records_sanitized_failure(controller, monkeypatch):
module, _ = controller
failures = []
plan_id = "a" * 64
def apply(*_args, **_kwargs):
assert module._registered_auto_apply_lock.acquire(blocking=False) is False
raise RuntimeError("secret runtime configuration must never escape")
fake = SimpleNamespace(apply=apply, PromotionRejected=ValueError,
record_worker_failure=lambda *args, **_kw: failures.append(args))
monkeypatch.setitem(sys.modules, "immutable_service_promotion", fake)
module._component_worker_plans.add(plan_id)
module._run_component_worker({"planId": plan_id}, b"x" * 64)
assert failures == [(plan_id, "component_worker_failed")]
assert plan_id not in module._component_worker_plans
assert module._registered_auto_apply_lock.acquire(blocking=False)
module._registered_auto_apply_lock.release()
def test_component_queue_is_idempotent_and_failed_start_is_durable(controller, monkeypatch):
module, _ = controller
failures, starts = [], []
plan_id = "a" * 64
fake = SimpleNamespace(accept_apply=lambda *_a, **_k: {"status": "queued", "planId": plan_id},
PromotionRejected=ValueError,
record_worker_failure=lambda *args, **_kw: failures.append(args))
monkeypatch.setitem(sys.modules, "immutable_service_promotion", fake)
monkeypatch.setattr(module.threading, "Thread", lambda **kwargs: SimpleNamespace(start=lambda: starts.append(kwargs)))
payload = {"planId": plan_id}
assert call(module, "/v1/appops/hub-components/apply", payload, "_handle_hub_component")[0] == 202
assert call(module, "/v1/appops/hub-components/apply", payload, "_handle_hub_component")[0] == 202
assert len(starts) == 1
module._component_worker_plans.clear()
def fail_start(**_kwargs):
raise RuntimeError("thread unavailable")
monkeypatch.setattr(module.threading, "Thread", fail_start)
assert call(module, "/v1/appops/hub-components/apply", payload, "_handle_hub_component")[1]["reason"] == "component_worker_start_failed"
assert failures == [(plan_id, "component_worker_start_failed")]
assert not module._component_worker_plans
@pytest.mark.parametrize("validated", [False, True])
def test_hold_retains_push_validation_and_candidate_preparation_without_deploy(controller, monkeypatch, tmp_path, validated):
module, projects = controller
repository = module.REGISTERED_AUTO_APPLY_REPOSITORY
projects[repository]["auto_apply"] = False
revision = "a" * 40
semantic = module._webhook_semantic_digest(34, "refs/heads/main", revision)
marker = tmp_path / (semantic + ".json")
record = {"schema": module._WEBHOOK_SCHEMA, "repository": repository, "gitea_repository_id": 34,
"ref": "refs/heads/main", "revision": revision, "semantic_sha256": semantic,
"body_sha256": "b" * 64, "status": "validation_passed" if validated else "awaiting_validation"}
monkeypatch.setattr(module, "_read_inbox_record", lambda _path: record)
monkeypatch.setattr(module, "_webhook_policy", lambda *_a: (34, "main"))
monkeypatch.setattr(module, "_record_policy_for_processing", lambda *_a: True)
monkeypatch.setattr(module, "_process_validation", lambda *_a: False)
monkeypatch.setattr(module, "_atomic_inbox_update", lambda *_a: None)
monkeypatch.setattr(module, "_supersede_older_deliveries", lambda *_a: None)
prepared = []
monkeypatch.setattr(module, "_prepare_hub_candidate_delivery", lambda *_a: prepared.append(revision) or True)
monkeypatch.setattr(module, "_run_registered_deploy", lambda *_a: pytest.fail("held target must never deploy"))
module._process_webhook_record(marker)
assert prepared == ([revision] if validated else [])
assert record["status"] == ("validation_passed" if validated else "awaiting_validation")
if validated:
assert record["reason"] == "registered_target_auto_apply_held"