"""Adversarial boundaries for the Hermes image publisher and trigger.""" from __future__ import annotations import importlib.util import io import json from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parents[2] RELEASE_SCRIPT = REPO_ROOT / "ci/scripts/hermes_image_release.py" TRIGGER_SCRIPT = REPO_ROOT / "services/hermes/scripts/jenkins_image_build_trigger.py" def _load(path: Path, name: str): spec = importlib.util.spec_from_file_location(name, path) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module class Response(io.BytesIO): """Small context-managed HTTP response fixture.""" def __init__( self, status: int, headers: dict[str, str] | None = None, body: bytes = b"", ): super().__init__(body) self.status = status self.headers = headers or {} def test_kaniko_evidence_binds_digest_destination_and_unique_build() -> None: """Both Kaniko artifacts must describe one exact non-replayable tag.""" module = _load(RELEASE_SCRIPT, "hermes_image_release_evidence") revision = "a" * 40 digest = "sha256:" + "b" * 64 destination = f"{module.DEFAULT_IMAGE}:git-{revision}-build-42" assert module.validate_destination(destination, revision, "42") == ( revision, "42", ) assert ( module.validate_kaniko_evidence( digest_text=f"{digest}\n", image_text=f"{destination}@{digest}\n", destination=destination, ) == digest ) @pytest.mark.parametrize( ("digest_text", "image_template"), [ ("sha256:" + "a" * 64 + "\nextra\n", "{destination}@{digest}"), ("sha256:" + "a" * 64, "{destination}@sha256:" + "b" * 64), ("sha256:" + "a" * 64, "registry.invalid/x:y@{digest}"), ("sha256:" + "a" * 64, "{destination}@{digest}\nextra"), ], ) def test_kaniko_evidence_rejects_cross_artifact_mismatch( digest_text: str, image_template: str ) -> None: """A digest, tag, repository, or cardinality mismatch stops rendering.""" module = _load(RELEASE_SCRIPT, "hermes_image_release_mismatch") revision = "c" * 40 digest = "sha256:" + "a" * 64 destination = f"{module.DEFAULT_IMAGE}:git-{revision}-build-8" image_text = image_template.format(destination=destination, digest=digest) with pytest.raises(ValueError): module.validate_kaniko_evidence( digest_text=digest_text, image_text=image_text, destination=destination, ) def test_registry_preflight_rejects_existing_tag_and_auth_failures() -> None: """Only an authenticated 404 permits Kaniko to claim the unique tag.""" module = _load(RELEASE_SCRIPT, "hermes_image_release_preflight") destination = f"{module.DEFAULT_IMAGE}:git-{'d' * 40}-build-3" captured = {} def missing(request, timeout): captured["request"] = request captured["timeout"] = timeout return Response(404) module.assert_tag_absent( destination, username="robot", password="private", opener=missing ) request = captured["request"] assert request.method == "GET" assert request.full_url.startswith( "https://registry.bstein.dev/api/v2.0/projects/bstein/repositories/" "hermes-agent/artifacts/" ) assert "private" not in request.full_url assert captured["timeout"] == 20 for status, message in ( (200, "already exists"), (401, "HTTP 401"), (503, "HTTP 503"), ): with pytest.raises(RuntimeError, match=message): module.assert_tag_absent( destination, username="robot", password="private", opener=lambda *_args, code=status: Response(code), ) def test_registry_preflight_requires_exact_server_immutable_policy() -> None: """The already-running controller cannot publish before Flux installs the rule.""" module = _load(RELEASE_SCRIPT, "hermes_image_release_policy_preflight") expected = { "disabled": False, "action": "immutable", "template": "immutable_template", "tag_selectors": [ { "kind": "doublestar", "decoration": "matches", "pattern": "git-*-build-*", } ], "scope_selectors": { "repository": [ { "kind": "doublestar", "decoration": "repoMatches", "pattern": "hermes-agent", } ] }, } captured = {} def exact(request, timeout): captured["url"] = request.full_url captured["timeout"] = timeout return Response( 200, {"X-Total-Count": "1"}, body=json.dumps([{"id": 9, **expected}]).encode(), ) module.verify_immutable_policy(username="robot", password="private", opener=exact) assert captured["url"].endswith( "/projects/bstein/immutabletagrules?page=1&page_size=100" ) assert captured["timeout"] == 20 for status, body, headers, message in ( (403, b"", {}, "HTTP 403"), (200, b"[]", {"X-Total-Count": "0"}, "absent"), ( 200, json.dumps([{**expected, "disabled": True}]).encode(), {"X-Total-Count": "1"}, "not exact", ), (200, b"not-json", {}, "invalid"), ( 200, json.dumps([expected]).encode(), {"X-Total-Count": "2"}, "truncated", ), ): with pytest.raises(RuntimeError, match=message): module.verify_immutable_policy( username="robot", password="private", opener=lambda *_args, code=status, value=body, evidence=headers: Response( code, evidence, body=value ), ) def test_harbor_client_fails_closed_on_input_size_auth_and_json( monkeypatch: pytest.MonkeyPatch, ) -> None: """Malformed inputs, oversized bodies, missing auth, and bad JSON all fail.""" module = _load(RELEASE_SCRIPT, "hermes_image_release_harbor_errors") destination = f"{module.DEFAULT_IMAGE}:git-{'1' * 40}-build-6" with pytest.raises(ValueError, match="destination"): module.assert_tag_absent( "registry.invalid/x:tag", username="robot", password="private" ) with pytest.raises(RuntimeError, match="credentials"): module.assert_tag_absent(destination, username="", password="private") with pytest.raises(RuntimeError, match="size limit"): module.assert_tag_absent( destination, username="robot", password="private", opener=lambda *_args: Response(200, body=b"x" * 1_048_577), ) with pytest.raises(RuntimeError, match="invalid artifact JSON"): module.verify_registry_digest( destination, "sha256:" + "2" * 64, username="robot", password="private", opener=lambda *_args: Response(200, body=b"\xff"), ) monkeypatch.delenv("HARBOR_USER", raising=False) monkeypatch.delenv("HARBOR_PASSWORD", raising=False) with pytest.raises(RuntimeError, match="unavailable"): module._credentials() def test_default_harbor_opener_returns_http_responses( monkeypatch: pytest.MonkeyPatch, ) -> None: """The real wrapper returns both normal and non-redirect HTTP responses.""" module = _load(RELEASE_SCRIPT, "hermes_image_release_opener") request = module.urllib.request.Request("https://registry.bstein.dev/test") class SuccessOpener: def open(self, _request, timeout): assert timeout == 7 return Response(204) monkeypatch.setattr( module.urllib.request, "build_opener", lambda *_handlers: SuccessOpener() ) assert module._registry_request(request, 7).status == 204 class ErrorOpener: def open(self, _request, timeout): assert timeout == 8 raise module.urllib.error.HTTPError( _request.full_url, 404, "missing", {}, None ) monkeypatch.setattr( module.urllib.request, "build_opener", lambda *_handlers: ErrorOpener() ) assert module._registry_request(request, 8).code == 404 @pytest.mark.parametrize( ("status", "artifact", "message"), [ (404, {}, "HTTP 404"), (200, {}, "omitted"), (200, {"digest": "sha256:" + "2" * 64}, "does not match"), (200, {"digest": "latest"}, "omitted"), ], ) def test_registry_verification_rejects_missing_or_mismatched_digest( status: int, artifact: dict[str, str], message: str ) -> None: """Rendering requires an independent exact Harbor digest response.""" module = _load(RELEASE_SCRIPT, "hermes_image_release_registry") digest = "sha256:" + "1" * 64 destination = f"{module.DEFAULT_IMAGE}:git-{'e' * 40}-build-4" body = json.dumps(artifact).encode("utf-8") with pytest.raises(RuntimeError, match=message): module.verify_registry_digest( destination, digest, username="robot", password="private", opener=lambda *_args: Response(status, body=body), ) def test_registry_verification_accepts_exact_pushed_digest() -> None: """An exact authenticated Harbor digest allows artifact rendering.""" module = _load(RELEASE_SCRIPT, "hermes_image_release_registry_ok") digest = "sha256:" + "4" * 64 destination = f"{module.DEFAULT_IMAGE}:git-{'f' * 40}-build-5" with pytest.raises(RuntimeError, match="expected tag"): module.verify_registry_digest( destination, digest, username="robot", password="private", opener=lambda *_args: Response( 200, body=json.dumps( {"digest": digest, "tags": [{"name": "different-tag"}]} ).encode("utf-8"), ), ) module.verify_registry_digest( destination, digest, username="robot", password="private", opener=lambda *_args: Response( 200, body=json.dumps( { "digest": digest, "tags": [ { "name": destination.rsplit(":", 1)[1], "immutable": True, } ], } ).encode("utf-8"), ), ) def test_registry_verification_rejects_tag_without_server_immutability() -> None: """Digest evidence is insufficient unless Harbor reports the build tag immutable.""" module = _load(RELEASE_SCRIPT, "hermes_image_release_registry_mutable") digest = "sha256:" + "7" * 64 destination = f"{module.DEFAULT_IMAGE}:git-{'8' * 40}-build-19" captured = {} def opener(request, _timeout): captured["url"] = request.full_url return Response( 200, body=json.dumps( { "digest": digest, "tags": [ { "name": destination.rsplit(":", 1)[1], "immutable": False, } ], } ).encode(), ) with pytest.raises(RuntimeError, match="immutable"): module.verify_registry_digest( destination, digest, username="robot", password="private", opener=opener, ) assert captured["url"].endswith("?with_immutable_status=true") def test_release_main_preflight_uses_fixed_credentials_and_destination( monkeypatch: pytest.MonkeyPatch, ) -> None: """The preflight CLI validates and checks exactly the requested build tag.""" module = _load(RELEASE_SCRIPT, "hermes_image_release_main_absent") revision = "3" * 40 destination = f"{module.DEFAULT_IMAGE}:git-{revision}-build-12" captured = {} monkeypatch.setenv("HARBOR_USER", "robot") monkeypatch.setenv("HARBOR_PASSWORD", "private") def assert_absent(value, **credentials): captured["destination"] = value captured["credentials"] = credentials def verify_policy(**credentials): captured["policy_credentials"] = credentials monkeypatch.setattr(module, "assert_tag_absent", assert_absent) monkeypatch.setattr(module, "verify_immutable_policy", verify_policy) monkeypatch.setattr( "sys.argv", [ "hermes_image_release.py", "assert-absent", "--source-revision", revision, "--build-number", "12", "--destination", destination, ], ) assert module.main() == 0 assert captured == { "destination": destination, "credentials": {"username": "robot", "password": "private"}, "policy_credentials": {"username": "robot", "password": "private"}, } def test_renderer_skips_a_matching_entry_without_a_digest() -> None: """Only the one complete image entry is changed when an earlier entry drifts.""" module = _load(RELEASE_SCRIPT, "hermes_image_release_manifest_scan") digest = "sha256:" + "5" * 64 source = ( "images:\n" f" - name: {module.DEFAULT_IMAGE}\n" " newTag: ignored\n" " - name: example.invalid/other\n" " newTag: stable\n" f" - name: {module.DEFAULT_IMAGE}\n" " digest: sha256:" + "0" * 64 + "\n" ) assert module.render_kustomization(source, digest).endswith(f"{digest}\n") @pytest.mark.parametrize("status", [200, 202, 204, 301, 302, 307, 308]) def test_trigger_rejects_non_plugin_success_status(tmp_path: Path, status: int) -> None: """Generic proxy successes and redirects are not proof of a queued build.""" module = _load(TRIGGER_SCRIPT, f"jenkins_trigger_status_{status}") token = tmp_path / "token" token.write_text("private\n", encoding="utf-8") response = lambda *_args, **_kwargs: Response( # noqa: E731 status, {"Location": "https://ci.bstein.dev/queue/item/9/"} ) with pytest.raises(RuntimeError, match=f"HTTP {status}"): module.trigger_build("a" * 40, token_file=token, opener=response) @pytest.mark.parametrize( "location", [ "", "https://evil.invalid/queue/item/9/", "http://ci.bstein.dev/queue/item/9/", "https://ci.bstein.dev/job/hermes-agent-image/9/", "https://ci.bstein.dev/queue/item/9/?token=leak", "https://ci.bstein.dev/queue/item/9/#fragment", "https://ci.bstein.dev/queue/item/not-a-number/", ], ) def test_trigger_rejects_missing_malformed_or_cross_origin_queue_location( tmp_path: Path, location: str ) -> None: """A real accepted status still needs the exact same-origin queue resource.""" module = _load(TRIGGER_SCRIPT, "jenkins_trigger_location") token = tmp_path / "token" token.write_text("private\n", encoding="utf-8") response = lambda *_args, **_kwargs: Response( # noqa: E731 201, {"Location": location} ) with pytest.raises(RuntimeError, match="Location"): module.trigger_build("b" * 40, token_file=token, opener=response) def test_trigger_uses_https_and_accepts_exact_relative_queue_path( tmp_path: Path, ) -> None: """A relative plugin Location is resolved only against the fixed HTTPS origin.""" module = _load(TRIGGER_SCRIPT, "jenkins_trigger_origin") token = tmp_path / "token" token.write_text("private\n", encoding="utf-8") captured = {} def opener(request, timeout): captured["url"] = request.full_url captured["timeout"] = timeout return Response(201, {"Location": "/queue/item/11/"}) result = module.trigger_build("c" * 40, token_file=token, opener=opener) assert captured["url"].startswith("https://ci.bstein.dev/") assert captured["timeout"] == 20 assert result["queue_path"] == "/queue/item/11/"