155 lines
8.2 KiB
Python
155 lines
8.2 KiB
Python
from __future__ import annotations
|
|
|
|
import sys
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from test_hub_component_transport import call
|
|
from test_hub_component_transport import controller as _controller_fixture
|
|
|
|
REPO = "jens/quiz-monitor"
|
|
REQUEST = "11111111-1111-4111-8111-111111111111"
|
|
PREFIX = "/v1/appops/registered-legacy/"
|
|
|
|
|
|
@pytest.fixture
|
|
def controller(monkeypatch):
|
|
return _controller_fixture.__wrapped__(monkeypatch)
|
|
|
|
|
|
@pytest.fixture
|
|
def adoption(monkeypatch):
|
|
observations = []
|
|
def status(payload, **_kwargs):
|
|
return {"status": "adopted", "repository": payload["repository"], "requestId": payload["requestId"],
|
|
"revision": "a" * 40, "expectedStateSha256": "b" * 64}
|
|
fake = SimpleNamespace(RegisteredLegacyAdoptionError=ValueError, read_registered_state=lambda *_a: ({}, None),
|
|
status=status, baseline=lambda payload, **_k: {"status": "ready", **payload},
|
|
accept_rollback=lambda payload, **_k: {"status": "rollback_pending", **payload},
|
|
record_worker_failure=lambda *args, **_k: observations.append(args))
|
|
monkeypatch.setitem(sys.modules, "registered_legacy_adoption", fake)
|
|
return fake, observations
|
|
|
|
|
|
def invoke(module, stage, payload):
|
|
return call(module, PREFIX + stage, payload, "_handle_registered_legacy")
|
|
|
|
|
|
def rollback_payload():
|
|
return {"repository": REPO, "requestId": REQUEST, "revision": "a" * 40, "expectedStateSha256": "b" * 64}
|
|
|
|
|
|
def test_generic_authentication_rejects_before_helper_loading(controller, monkeypatch):
|
|
module, _ = controller
|
|
monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_a, **_k: False)
|
|
assert invoke(module, "adopt", {"image": "attacker/image"})[0] == 403
|
|
|
|
|
|
@pytest.mark.parametrize("change", [{"repository": "Jens/quiz-monitor"}, {"repository": "../escape"},
|
|
{"requestId": "bad"}, {"requestId": 123}])
|
|
def test_generic_envelope_rejects_invalid_identity_before_helpers(controller, adoption, change):
|
|
module, _ = controller
|
|
assert invoke(module, "baseline", {"repository": REPO, "requestId": REQUEST, **change})[0] == 400
|
|
|
|
|
|
def test_delivery_requires_adopted_exact_target_and_preserves_original_revalidation(controller, adoption, monkeypatch):
|
|
module, _ = controller
|
|
called = []
|
|
monkeypatch.setattr(module, "_request_terminal_revalidation", lambda *args, **kwargs: called.append((args, kwargs)) or {"status": "awaiting_validation"})
|
|
payload = {"repository": REPO, "requestId": REQUEST, "revision": "a" * 40}
|
|
assert invoke(module, "delivery", {**payload, "revision": "c" * 40})[1]["reason"] == "registered_delivery_adoption_required"
|
|
assert invoke(module, "delivery", {**payload, "image": "caller/image"})[1]["reason"] == "registered_delivery_contract_invalid"
|
|
assert not called
|
|
assert invoke(module, "delivery", payload)[1]["status"] == "awaiting_validation"
|
|
assert called == [((REPO, "a" * 40), {"registered_legacy_request_id": REQUEST})]
|
|
|
|
|
|
def test_only_internal_adopted_retry_binds_request_to_deployer_environment(controller, monkeypatch):
|
|
module, _ = controller
|
|
called = []
|
|
monkeypatch.setenv("APPOPS_REGISTERED_LEGACY_REQUEST_ID", "inherited-untrusted-value")
|
|
monkeypatch.setattr(module.subprocess, "run", lambda args, **kwargs: called.append((args, kwargs)) or SimpleNamespace(returncode=0))
|
|
module._run_registered_deploy(REPO, "a" * 40)
|
|
assert "APPOPS_REGISTERED_LEGACY_REQUEST_ID" not in called[-1][1]["env"]
|
|
module._run_registered_deploy(REPO, "a" * 40, registered_legacy_request_id=REQUEST)
|
|
assert called[-1][1]["env"]["APPOPS_REGISTERED_LEGACY_REQUEST_ID"] == REQUEST
|
|
assert REQUEST not in called[-1][0]
|
|
with pytest.raises(ValueError):
|
|
module._run_registered_deploy(REPO, "a" * 40, registered_legacy_request_id="bad")
|
|
with pytest.raises(ValueError):
|
|
module._run_registered_deploy(module.REGISTERED_AUTO_APPLY_REPOSITORY, "a" * 40, registered_legacy_request_id=REQUEST)
|
|
|
|
|
|
def test_rollback_queue_is_bounded_idempotent_and_rejects_changed_binding(controller, adoption, monkeypatch):
|
|
module, _ = controller
|
|
starts = []
|
|
monkeypatch.setattr(module.threading, "Thread", lambda **kwargs: SimpleNamespace(start=lambda: starts.append(kwargs)))
|
|
payload = rollback_payload()
|
|
assert invoke(module, "rollback", payload)[0] == 202
|
|
assert invoke(module, "rollback", payload)[0] == 200
|
|
assert len(starts) == 1
|
|
assert invoke(module, "rollback", {**payload, "expectedStateSha256": "c" * 64})[1]["reason"] == "registered_rollback_binding_changed"
|
|
assert invoke(module, "rollback", {**payload, "requestId": "22222222-2222-4222-8222-222222222222"})[1]["reason"] == "registered_rollback_capacity_busy"
|
|
|
|
|
|
def test_rollback_start_failure_is_recorded_and_capacity_released(controller, adoption, monkeypatch):
|
|
module, _ = controller
|
|
_, observations = adoption
|
|
def fail_start(**_kwargs):
|
|
raise RuntimeError("thread start unavailable")
|
|
monkeypatch.setattr(module.threading, "Thread", fail_start)
|
|
assert invoke(module, "rollback", rollback_payload())[1]["reason"] == "registered_rollback_worker_start_failed"
|
|
assert observations == [(REPO, REQUEST, "registered_rollback_worker_start_failed")]
|
|
assert not module._registered_legacy_worker_requests
|
|
|
|
|
|
def test_worker_never_discloses_raw_failure_and_releases_capacity(controller, adoption):
|
|
module, _ = controller
|
|
fake, observations = adoption
|
|
def fail(*_args, **_kwargs):
|
|
raise RuntimeError("private environment credential")
|
|
fake.rollback = fail
|
|
module._registered_legacy_worker_requests.add(REPO + "/" + REQUEST)
|
|
module._run_registered_legacy_rollback(rollback_payload(), b"x" * 64)
|
|
assert observations == [(REPO, REQUEST, "registered_rollback_worker_failed")]
|
|
assert not module._registered_legacy_worker_requests
|
|
|
|
|
|
def test_failed_delivery_retry_requires_explicit_adoption_and_keeps_original_record(controller, monkeypatch, tmp_path):
|
|
module, _ = controller
|
|
revision = "a" * 40
|
|
semantic = module._webhook_semantic_digest(6, "refs/heads/main", revision)
|
|
original = {"schema": module._WEBHOOK_SCHEMA, "semantic_sha256": semantic,
|
|
"gitea_repository_id": 6, "ref": "refs/heads/main", "repository": REPO,
|
|
"revision": revision, "body_sha256": "b" * 64, "validation": {},
|
|
"status": "failed", "reason": "deployment_failed"}
|
|
written = []
|
|
monkeypatch.setattr(module, "WEBHOOK_INBOX_ROOT", tmp_path)
|
|
monkeypatch.setattr(module, "_secure_inbox_root", lambda: None)
|
|
monkeypatch.setattr(module, "_webhook_policy", lambda *_a: (6, "main", "fixed-secret-file"))
|
|
monkeypatch.setattr(module, "_validation_policy", lambda *_a: {})
|
|
monkeypatch.setattr(module, "_validation_snapshot_matches", lambda *_a: True)
|
|
monkeypatch.setattr(module, "_validation_snapshot", lambda *_a: {"required": True})
|
|
monkeypatch.setattr(module, "_read_inbox_record", lambda _p: original)
|
|
monkeypatch.setattr(module, "_is_current_branch_head", lambda *_a: True)
|
|
monkeypatch.setattr(module, "_write_new_inbox_record", lambda path, record: written.append((path, record)))
|
|
assert module._enqueue_terminal_revalidation(REPO, revision, {})[0] == "not_revalidatable"
|
|
assert module._enqueue_terminal_revalidation(REPO, revision, {}, registered_legacy_request_id="bad")[0] == "invalid"
|
|
assert not written
|
|
assert module._enqueue_terminal_revalidation(REPO, revision, {}, registered_legacy_request_id=REQUEST)[0] == "awaiting_validation"
|
|
path, record = written[0]
|
|
assert path.name == semantic + "." + REQUEST + ".revalidation.json"
|
|
assert record["registered_legacy_request_id"] == REQUEST
|
|
assert record["validation"] == {"required": True}
|
|
assert original["status"] == "failed" and original["reason"] == "deployment_failed"
|
|
|
|
|
|
def test_webhook_cannot_inject_registered_adoption_binding(controller, monkeypatch, tmp_path):
|
|
module, _ = controller
|
|
monkeypatch.setattr(module, "_read_inbox_record", lambda _p: {
|
|
"schema": module._WEBHOOK_SCHEMA, "status": "validation_passed",
|
|
"registered_legacy_request_id": REQUEST,
|
|
})
|
|
monkeypatch.setattr(module, "_load_projects", lambda: pytest.fail("injected webhook must be rejected before processing"))
|
|
module._process_webhook_record(tmp_path / "a.json")
|