from __future__ import annotations import json from pathlib import Path import subprocess import sys import tempfile import unittest from unittest.mock import patch META_ROOT = Path(__file__).resolve().parents[1] RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release" if str(RELEASE_TOOLS_ROOT) not in sys.path: sys.path.insert(0, str(RELEASE_TOOLS_ROOT)) from govoplan_release.publisher import ( # noqa: E402 publish_catalog_candidate, verify_committed_publication, ) class ReleaseCatalogPublicationTests(unittest.TestCase): def test_committed_publication_must_match_validated_blobs_exactly(self) -> None: with tempfile.TemporaryDirectory() as tmp: web_root = Path(tmp) / "website" module_root = web_root / "public" / "catalogs" / "v1" / "modules" module_root.mkdir(parents=True) catalog = web_root / "public" / "catalogs" / "v1" / "channels" / "stable.json" keyring = web_root / "public" / "catalogs" / "v1" / "keyring.json" catalog.parent.mkdir(parents=True) expected = { catalog.relative_to(web_root).as_posix(): b'{"catalog":true}\n', keyring.relative_to(web_root).as_posix(): b'{"keys":[]}\n', (module_root / "index.json").relative_to(web_root).as_posix(): b'{"modules":[]}\n', } for relative, encoded in expected.items(): target = web_root / relative target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(encoded) self._git(web_root, "init", "--quiet") self._git(web_root, "config", "user.email", "test@example.test") self._git(web_root, "config", "user.name", "Test") self._git(web_root, "add", ".") self._git(web_root, "commit", "--quiet", "-m", "publication") commit_sha = self._git(web_root, "rev-parse", "HEAD").strip() verify_committed_publication( web_root=web_root, commit_sha=commit_sha, expected_blobs=expected, module_root=module_root, ) changed = dict(expected) changed[catalog.relative_to(web_root).as_posix()] = b'{"catalog":false}\n' with self.assertRaisesRegex(RuntimeError, "differs"): verify_committed_publication( web_root=web_root, commit_sha=commit_sha, expected_blobs=changed, module_root=module_root, ) catalog_path = catalog.relative_to(web_root).as_posix() self._git(web_root, "update-index", "--chmod=+x", catalog_path) self._git(web_root, "commit", "--quiet", "-m", "unsafe mode") executable_commit = self._git(web_root, "rev-parse", "HEAD").strip() with self.assertRaisesRegex(RuntimeError, "regular blob"): verify_committed_publication( web_root=web_root, commit_sha=executable_commit, expected_blobs=expected, module_root=module_root, ) def test_apply_writes_validated_objects_even_if_candidate_path_changes(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) candidate = self._candidate(root, key="trusted-key") catalog_path = candidate / "channels" / "stable.json" catalog = json.loads(catalog_path.read_text(encoding="utf-8")) catalog.update({"channel": "stable", "sequence": 1}) catalog["core_release"]["python_package"] = "govoplan-core" catalog["release"] = { "selected_units": [ { "repo": "govoplan-core", "version": "1.2.3", "tag": "v1.2.3", "commit_sha": "a" * 40, "tag_object_sha": "b" * 40, } ], "artifacts": [ { "artifact_kind": "python-wheel", "package_name": "govoplan-core", "package_version": "1.2.3", "archive_sha256": "c" * 64, "archive_size": 100, "installed_payload": { "algorithm": "govoplan-wheel-declared-payload-v1", "sha256": "d" * 64, "file_count": 1, }, "requires_installer_receipt": True, } ], } catalog["signatures"] = [{}] catalog_path.write_text(json.dumps(catalog), encoding="utf-8") web_root = root / "website" target_keyring = web_root / "public" / "catalogs" / "v1" / "keyring.json" target_keyring.parent.mkdir(parents=True) target_keyring.write_text(json.dumps(self._keyring("trusted-key"))) (web_root / ".git").mkdir() def validate_and_swap(*args, **kwargs): del args, kwargs catalog_path.write_text( json.dumps( { "channel": "stable", "sequence": 999, "malicious": True, "signatures": [{}], } ), encoding="utf-8", ) return {"valid": True, "warnings": [], "error": None} with ( patch( "govoplan_release.publisher.validate_module_package_catalog", side_effect=validate_and_swap, ), patch( "govoplan_release.publisher.source_tag_provenance_issues", return_value=(), ), patch("govoplan_release.publisher.website_dirty", return_value=False), ): result = publish_catalog_candidate( candidate_dir=candidate, web_root=web_root, workspace_root=root, apply=True, allow_dirty_website=True, ) published = json.loads( ( web_root / "public" / "catalogs" / "v1" / "channels" / "stable.json" ).read_text(encoding="utf-8") ) self.assertEqual("applied", result.status) self.assertEqual(1, published["sequence"]) self.assertNotIn("malicious", published) def test_apply_blocks_selected_python_release_without_built_identity(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) candidate = self._candidate(root, key="trusted-key") catalog_path = candidate / "channels" / "stable.json" catalog = json.loads(catalog_path.read_text(encoding="utf-8")) catalog["core_release"]["python_package"] = "govoplan-core" catalog["release"] = { "selected_units": [ { "repo": "govoplan-core", "version": "1.2.3", "tag": "v1.2.3", "commit_sha": "a" * 40, "tag_object_sha": "b" * 40, } ] } catalog_path.write_text(json.dumps(catalog), encoding="utf-8") web_root = root / "website" target_keyring = web_root / "public" / "catalogs" / "v1" / "keyring.json" target_keyring.parent.mkdir(parents=True) target_keyring.write_text(json.dumps(self._keyring("trusted-key"))) (web_root / ".git").mkdir() with ( patch( "govoplan_release.publisher.validate_module_package_catalog", return_value={"valid": True, "warnings": [], "error": None}, ), patch( "govoplan_release.publisher.source_tag_provenance_issues", return_value=(), ), patch("govoplan_release.publisher.website_dirty", return_value=False), ): result = publish_catalog_candidate( candidate_dir=candidate, web_root=web_root, workspace_root=root, apply=True, ) self.assertEqual("blocked", result.status) self.assertIn( "selected Python repository govoplan-core has no built artifact identity", " ".join(result.notes), ) def test_publication_requires_an_existing_trust_anchor(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) candidate = self._candidate(root, key="candidate-key") web_root = root / "website" web_root.mkdir() with patch( "govoplan_release.publisher.validate_module_package_catalog", return_value={"valid": True, "warnings": [], "error": None}, ): result = publish_catalog_candidate( candidate_dir=candidate, web_root=web_root, workspace_root=root, ) self.assertEqual("blocked", result.status) self.assertIn("publication trust anchor is missing", " ".join(result.notes)) def test_publication_rejects_rebinding_an_existing_key_id(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) candidate = self._candidate(root, key="replacement-key") web_root = root / "website" target_keyring = web_root / "public" / "catalogs" / "v1" / "keyring.json" target_keyring.parent.mkdir(parents=True) target_keyring.write_text(json.dumps(self._keyring("trusted-key"))) with patch( "govoplan_release.publisher.validate_module_package_catalog", return_value={"valid": True, "warnings": [], "error": None}, ): result = publish_catalog_candidate( candidate_dir=candidate, web_root=web_root, workspace_root=root, ) self.assertEqual("blocked", result.status) self.assertIn("changes the public key", " ".join(result.notes)) @staticmethod def _candidate(root: Path, *, key: str) -> Path: candidate = root / "candidate" channel = candidate / "channels" channel.mkdir(parents=True) channel.joinpath("stable.json").write_text( json.dumps( { "channel": "stable", "core_release": { "version": "1.2.3", "python_ref": ( "govoplan-core @ git+ssh://git@example.test/acme/" "govoplan-core.git@v1.2.3" ), }, "modules": [], } ) ) candidate.joinpath("keyring.json").write_text(json.dumps(ReleaseCatalogPublicationTests._keyring(key))) return candidate @staticmethod def _keyring(key: str) -> dict[str, object]: return { "keys": [ { "key_id": "release-key", "status": "active", "public_key": key, } ] } @staticmethod def _git(root: Path, *args: str) -> str: return subprocess.run( ["git", *args], cwd=root, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ).stdout if __name__ == "__main__": unittest.main()