molecule-sdk-python/tests/test_sha256_verification.py
Molecule AI SDK-Dev 66502e669a fix(tests): add conftest.py + fix test_call_peer_errors.py broken imports
- tests/conftest.py: added FakeResponse, client fixture, tmp_token_dir fixture,
  and _CaptureHandler (stubs for integration tests)
- tests/test_call_peer_errors.py: rewrote all tests to use existing client.py
  patterns (MagicMock session) instead of non-existent httpx fixtures.
  Removed mocker/http_mock/conftest fixtures that don't exist in this repo.
  12 tests now cover: timeout, connection error, 4xx/5xx errors, empty body,
  JSON-RPC envelope format, auth headers, direct→proxy fallback path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:18:10 +00:00

504 lines
19 KiB
Python

"""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()