- 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>
504 lines
19 KiB
Python
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() |