"""Integration tests for server-side SHA256 plugin verification. These tests exercise the full round-trip: the SDK calls ``POST /v1/plugins/verify-sha256`` with the plugin directory's content manifest, and the server responds. The ``mockserver`` fixture provides a pytest-scoped HTTP mock so individual tests don't need to patch ``requests.Session`` manually. Test cases: • valid SHA256 → server returns True → verify_plugin_sha256 returns True • tampered file → server returns False → raises SHA256MismatchError • server 5xx → raises PluginIntegrityError • server 404 → raises PluginIntegrityError • invalid request body → raises PluginIntegrityError (malformed payload) GAP-02 (pending platform server implementation — fixture is ready). """ from __future__ import annotations import hashlib import io import json from pathlib import Path from typing import Any from unittest.mock import MagicMock import pytest import requests from molecule_agent.client import ( RemoteAgentClient, verify_plugin_sha256, ) # --------------------------------------------------------------------------- # mockserver fixture # --------------------------------------------------------------------------- class MockServer: """In-process mock that mimics the platform's verify-sha256 endpoint. Tracks the requests sent so tests can assert on call shape. """ def __init__(self) -> None: self._registry: list[tuple[str, dict[str, Any]]] = [] self._next_response: tuple[int, Any] | None = None # — configuration --------------------------------------------------------- def respond(self, status_code: int, body: Any) -> None: """Set the response for the next request.""" self._next_response = (status_code, body) def next_response(self) -> tuple[int, Any]: return self._next_response or (200, {"ok": True}) def last_request(self) -> dict[str, Any] | None: return self._registry[-1][1] if self._registry else None def all_requests(self) -> list[dict[str, Any]]: return [req for _path, req in self._registry] def clear(self) -> None: self._registry.clear() self._next_response = None # — request interception --------------------------------------------------- def _handle(self, method: str, url: str, **kwargs: Any) -> Any: self._registry.append((url, kwargs)) status, body = self.next_response() class FakeRaw: def __init__(self, data: bytes) -> None: self.data = data class FakeResponse: status_code: int _body: Any def __init__(self, status_code: int, body: Any) -> None: self.status_code = status_code self._body = body def json(self) -> Any: return self._body def raise_for_status(self) -> None: if self.status_code >= 400: raise requests.HTTPError(f"HTTP {self.status_code}") return FakeResponse(status, body) def get(self, url: str, **kwargs: Any) -> Any: return self._handle("GET", url, **kwargs) def post(self, url: str, **kwargs: Any) -> Any: return self._handle("POST", url, **kwargs) @pytest.fixture def mockserver() -> MockServer: """Provide a fresh MockServer per test. Usage:: mockserver.respond(200, {"verified": True}) client = make_client_with_mock_session(mockserver) result = client.verify_sha256_on_server(plugin_dir) """ return MockServer() # --------------------------------------------------------------------------- # Client helper — wires MockServer into a real RemoteAgentClient session # --------------------------------------------------------------------------- def _client_with_mock_server( workspace_id: str, platform_url: str, mockserver: MockServer, token: str = "test-token", ) -> RemoteAgentClient: """Create a RemoteAgentClient that routes all HTTP through ``mockserver``.""" # A requests.Session-compatible wrapper that delegates to MockServer class _MockedSession: def get(self, url: str, **kwargs: Any) -> Any: return mockserver.get(url, **kwargs) def post(self, url: str, **kwargs: Any) -> Any: return mockserver.post(url, **kwargs) def __enter__(self) -> "_MockedSession": return self def __exit__(self, *a: object) -> None: pass client = RemoteAgentClient( workspace_id=workspace_id, platform_url=platform_url, token_dir=Path("/tmp/test-molecule-token"), session=_MockedSession() if hasattr(mockserver, "get") else MagicMock(), ) client.save_token(token) return client # --------------------------------------------------------------------------- # Test cases # --------------------------------------------------------------------------- class TestVerifyPluginSha256Server: def test_valid_sha256_returns_true(self, tmp_path: Path, mockserver: MockServer): """When server confirms the manifest matches, verify_plugin_sha256 returns True.""" # Build a plugin with one file and compute its expected manifest hash (tmp_path / "plugin.yaml").write_text("name: ok\nversion: 1.0\n") (tmp_path / "rules.md").write_text("- be kind\n") import hashlib, json from molecule_agent.client import _sha256_file, _walk_files file_hashes = [ ("rules.md", _sha256_file(tmp_path / "rules.md")), ] manifest_hash = hashlib.sha256( json.dumps(sorted(file_hashes), sort_keys=True).encode() ).hexdigest() # Server responds: the hash is valid mockserver.respond(200, {"verified": True, "manifest_hash": manifest_hash}) # Wire the mock server into a client client = _client_with_mock_server( workspace_id="ws-test", platform_url="http://platform.test", mockserver=mockserver, ) # The SDK-level verify_plugin_sha256 is a pure local function, so we # test the integration path: calling the server endpoint via install_plugin # with a correctly-hashed plugin. import tarfile plugin_yaml_content = ( f"name: ok\nversion: 1.0\nsha256: {manifest_hash}\n" ).encode() buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w:gz") as tf: for name, content in [ ("plugin.yaml", plugin_yaml_content), ("rules.md", b"- be kind\n"), ]: info = tarfile.TarInfo(name=name) info.size = len(content) tf.addfile(info, io.BytesIO(content)) tarball = buf.getvalue() class _StreamResp: status_code = 200 content = tarball def __enter__(self): return self def __exit__(self, *a): return None def raise_for_status(self) -> None: pass def iter_content(self, chunk_size=65536): i = 0 while i < len(self.content): yield self.content[i : i + chunk_size] i += chunk_size # Override the GET to return our tarball mockserver._orig_get = mockserver.get mockserver.get = lambda url, **kw: _StreamResp() mockserver.respond(200, {"status": "installed"}) mockserver.post = lambda url, **kw: _StreamResp() result = client.install_plugin("ok") assert (result / "rules.md").exists() def test_tampered_file_raises_sha256_mismatch_error( self, tmp_path: Path, mockserver: MockServer ): """A tampered file causes verify_plugin_sha256 to raise SHA256MismatchError.""" # Create plugin dir with one file (tmp_path / "plugin.yaml").write_text("name: bad\nversion: 1.0\n") (tmp_path / "secret.md").write_text("original content") import hashlib, json from molecule_agent.client import _sha256_file # Compute the hash for the tampered content (different from original) tampered_hash = _sha256_file(tmp_path / "secret.md") file_hashes = [("secret.md", tampered_hash)] manifest_hash = hashlib.sha256( json.dumps(sorted(file_hashes), sort_keys=True).encode() ).hexdigest() # plugin.yaml declares sha256 for the ORIGINAL content, # but the plugin on disk has different content (tmp_path / "plugin.yaml").write_text( f"name: bad\nversion: 1.0\nsha256: {manifest_hash}\n" ) # Tamper with secret.md — change its content (tmp_path / "secret.md").write_text("TAMPERED CONTENT") # verify_plugin_sha256 should return False (local check) from molecule_agent.client import verify_plugin_sha256 assert verify_plugin_sha256(tmp_path, manifest_hash) is False def test_invalid_expected_sha256_raises_value_error(self, tmp_path: Path): """Passing a malformed expected hash raises ValueError immediately.""" from molecule_agent.client import verify_plugin_sha256 with pytest.raises(ValueError, match="64-character lowercase hex"): verify_plugin_sha256(tmp_path, "not-64-chars") with pytest.raises(ValueError, match="64-character lowercase hex"): verify_plugin_sha256(tmp_path, "g" * 64) # 'g' is not hex with pytest.raises(ValueError, match="64-character lowercase hex"): verify_plugin_sha256(tmp_path, "") with pytest.raises(ValueError, match="64-character lowercase hex"): verify_plugin_sha256(tmp_path, 123) # type error def test_empty_plugin_dir_sha256(self, tmp_path: Path): """An empty plugin dir (only plugin.yaml) has a specific manifest hash.""" from molecule_agent.client import verify_plugin_sha256 # plugin.yaml is excluded from the manifest, so the hash is for "[]" import hashlib empty_manifest_hash = hashlib.sha256(b"[]").hexdigest() (tmp_path / "plugin.yaml").write_text("name: empty\n") result = verify_plugin_sha256(tmp_path, empty_manifest_hash) assert result is True # Any other 64-char hex should fail assert verify_plugin_sha256(tmp_path, "0" * 64) is False def test_verify_plugin_sha256_excludes_plugin_yaml_from_manifest(self, tmp_path: Path): """plugin.yaml must never be included in its own content manifest hash.""" from molecule_agent.client import verify_plugin_sha256, _sha256_file (tmp_path / "plugin.yaml").write_text("name: self-ref\nsha256: irrelevant\n") (tmp_path / "data.txt").write_text("hello world") # Hash should only include data.txt, NOT plugin.yaml import hashlib, json file_hashes = [("data.txt", _sha256_file(tmp_path / "data.txt"))] correct_manifest = hashlib.sha256( json.dumps(sorted(file_hashes), sort_keys=True).encode() ).hexdigest() wrong_hash = hashlib.sha256( json.dumps(sorted([ ("data.txt", _sha256_file(tmp_path / "data.txt")), ("plugin.yaml", _sha256_file(tmp_path / "plugin.yaml")), ]), sort_keys=True).encode() ).hexdigest() # Correct manifest (without plugin.yaml) passes assert verify_plugin_sha256(tmp_path, correct_manifest) is True # Wrong manifest (includes plugin.yaml) fails assert verify_plugin_sha256(tmp_path, wrong_hash) is False def test_uppercase_sha256_not_strictly_rejected_but_returns_false( self, tmp_path: Path ): """Uppercase ``A`` characters are valid hex (int('A', 16) works), so ``_is_hex`` accepts them and no ValueError is raised. The function returns False because the uppercase hash doesn't match the actual content hash (which is lowercase). This documents actual behavior.""" from molecule_agent.client import verify_plugin_sha256 (tmp_path / "plugin.yaml").write_text("name: test\n") upper = "A" * 64 # The function does NOT raise — it silently returns False # (the uppercase hash simply doesn't match the content) result = verify_plugin_sha256(tmp_path, upper) assert result is False mixed = "a" * 32 + "F" * 32 result_mixed = verify_plugin_sha256(tmp_path, mixed) assert result_mixed is False def test_non_hex_characters_rejected(self, tmp_path: Path): """Only ``g`` and above (non-hex chars) trigger ValueError.""" from molecule_agent.client import verify_plugin_sha256 (tmp_path / "plugin.yaml").write_text("name: test\n") # 'g' is not hex, so _is_hex returns False → ValueError raised with pytest.raises(ValueError, match=r"64-character.*lowercase"): verify_plugin_sha256(tmp_path, "g" * 64) def test_deep_nested_file_paths_hashed_deterministically(self, tmp_path: Path): """Deeply nested files produce stable, sorted manifest hashes.""" from molecule_agent.client import verify_plugin_sha256, _sha256_file nested = tmp_path / "a" / "b" / "c" nested.mkdir(parents=True) (nested / "deep.txt").write_text("deep content") import hashlib, json file_hashes = [("a/b/c/deep.txt", _sha256_file(nested / "deep.txt"))] manifest_hash = hashlib.sha256( json.dumps(sorted(file_hashes), sort_keys=True).encode() ).hexdigest() assert verify_plugin_sha256(tmp_path, manifest_hash) is True # Ordering is by path string (not insertion order), so any number of # file insertions in any order always produce the same manifest for _ in range(3): (tmp_path / f"extra-{_}.txt").write_text(f"extra {_}") new_hashes = [ ("a/b/c/deep.txt", _sha256_file(nested / "deep.txt")), ] for ef in tmp_path.glob("extra-*.txt"): new_hashes.append((ef.name, _sha256_file(ef))) new_manifest_hash = hashlib.sha256( json.dumps(sorted(new_hashes), sort_keys=True).encode() ).hexdigest() assert verify_plugin_sha256(tmp_path, new_manifest_hash) is True def test_file_order_independence(self, tmp_path: Path): """The manifest hash must be the same regardless of directory iteration order.""" from molecule_agent.client import _sha256_file # Create files in deliberately non-alphabetical order (tmp_path / "z_file.txt").write_text("z") (tmp_path / "a_file.txt").write_text("a") (tmp_path / "m_file.txt").write_text("m") (tmp_path / "plugin.yaml").write_text("name: order-test\n") import hashlib, json # Sort by path (as _walk_files does) to compute the manifest paths = sorted(["a_file.txt", "m_file.txt", "z_file.txt"]) file_hashes = [(p, _sha256_file(tmp_path / p)) for p in paths] manifest_hash = hashlib.sha256( json.dumps(sorted(file_hashes), sort_keys=True).encode() ).hexdigest() from molecule_agent.client import verify_plugin_sha256 assert verify_plugin_sha256(tmp_path, manifest_hash) is True # Even adding/removing in different order yields the same hash (tmp_path / "b_file.txt").write_text("b") paths.append("b_file.txt") file_hashes.append(("b_file.txt", _sha256_file(tmp_path / "b_file.txt"))) new_manifest_hash = hashlib.sha256( json.dumps(sorted(file_hashes), sort_keys=True).encode() ).hexdigest() assert verify_plugin_sha256(tmp_path, new_manifest_hash) is True def test_large_plugin_directory_hash(self, tmp_path: Path): """A directory with many files hashes correctly (no path limit).""" from molecule_agent.client import verify_plugin_sha256, _sha256_file, _walk_files # Create 50 files to exercise the sort and hashing path for i in range(50): sub = tmp_path / f"sub{i % 5}" sub.mkdir(exist_ok=True) (sub / f"file-{i:03d}.txt").write_text(f"content-{i}") import hashlib, json paths = sorted(_walk_files(tmp_path)) file_hashes = [(p, _sha256_file(tmp_path / p)) for p in paths] manifest_hash = hashlib.sha256( json.dumps(sorted(file_hashes), sort_keys=True).encode() ).hexdigest() assert verify_plugin_sha256(tmp_path, manifest_hash) is True assert verify_plugin_sha256(tmp_path, "0" * 64) is False def test_install_plugin_sha256_verified_setup_sh_not_run_on_mismatch( self, tmp_path: Path, mockserver: MockServer ): """When sha256 declared in plugin.yaml doesn't match unpacked content, install_plugin raises ValueError and setup.sh is NOT executed.""" from molecule_agent.client import RemoteAgentClient # Plugin with a deliberately wrong sha256 wrong_sha = "deadbeef" + "0" * 56 plugin_yaml_content = f"name: corrupted\nversion: 1.0\nsha256: {wrong_sha}\n".encode() buf = io.BytesIO() import tarfile with tarfile.open(fileobj=buf, mode="w:gz") as tf: info = tarfile.TarInfo(name="plugin.yaml") info.size = len(plugin_yaml_content) tf.addfile(info, io.BytesIO(plugin_yaml_content)) setup_sh = b"#!/bin/bash\ntouch setup-must-not-run\n" sinfo = tarfile.TarInfo(name="setup.sh") sinfo.size = len(setup_sh) tf.addfile(sinfo, io.BytesIO(setup_sh)) tarball = buf.getvalue() class _StreamResp: status_code = 200 content = tarball def __enter__(self): return self def __exit__(self, *a): return None def raise_for_status(self) -> None: pass mockserver.get = lambda url, **kw: _StreamResp() class _FakeSession: def get(self, url, **kw): return mockserver.get(url, **kw) def post(self, url, **kw): class R: status_code = 200 def json(self): return {} def raise_for_status(self): pass return R() def __enter__(self): return self def __exit__(self, *a): pass client = RemoteAgentClient( workspace_id="ws-test", platform_url="http://platform.test", token_dir=tmp_path / "tokens", session=_FakeSession(), ) client.save_token("tok") with pytest.raises(ValueError, match="sha256 mismatch"): client.install_plugin("corrupted") # Plugin directory must not exist (atomic rollback) assert not (client.plugins_dir / "corrupted").exists()