Files
Android-RE/tests/test_mcp_static.py
John Smith fb74beb6f4 Initial import: Android-RE monorepo
Fresh re-import of the project. The prior history of the
Heretek-AI/Android-RE repository was rebuilt from a single squashed
initial commit per the project's vendor-neutrality policy (see
CLAUDE.md for the policy and translation table).

The audit trail for the rebuild is preserved at
/tmp/sanitize/pre-destroy/ on the analyst host. The redaction spec
is at /tmp/sanitize/redacts.json; the audit decision log is at
/tmp/sanitize/audit-decision.md.
2026-06-05 13:51:51 -04:00

357 lines
12 KiB
Python

"""In-memory contract tests for the static MCP server.
These tests build the FastMCP server in-process, connect to it via an
in-memory :class:`mcp.Client`, and assert that the tool schemas and
responses are correct. They do **not** require a real APK or device.
For tests that need a real APK, see :file:`test_mcp_static_real_apk.py`
(Phase 2, gated on a CrackMe fixture).
"""
from __future__ import annotations
import json
import socket
from pathlib import Path
import pytest
# We import lazily inside the fixture so that pytest can collect tests
# even if ``mcp`` is not yet installed in the host environment.
# ---------------------------------------------------------------------------
# Fixture: in-memory MCP client for the static server
# ---------------------------------------------------------------------------
def _port_is_open(host: str, port: int) -> bool:
"""Cheap reachability check used to skip live-listening tests."""
try:
with socket.create_connection((host, port), timeout=0.5):
return True
except OSError:
return False
@pytest.fixture
async def static_client():
"""Build the static MCP server and connect via in-memory transport.
Skips the test if the ``mcp`` package or ``mcp[client]`` is not
installed in the test environment.
"""
pytest.importorskip("mcp", reason="mcp package not installed; run uv sync --all-packages")
pytest.importorskip("androguard", reason="androguard not installed; run uv sync --all-packages")
pytest.importorskip("lief", reason="lief not installed; run uv sync --all-packages")
# ``build_server`` returns a FastMCP; for in-memory testing we use
# ``FastMCP.run``'s in-process variant. The simplest portable
# pattern is to launch the server in stdio mode and connect to it.
import sys
from mcp import Client, StdioServerParameters # type: ignore[import-untyped]
from mcp.client.stdio import stdio_client # type: ignore[import-untyped]
from android_re_mcp_static.server import build_server # type: ignore[import-untyped]
_ = build_server() # eager import to surface config errors early
params = StdioServerParameters(
command=sys.executable,
args=["-m", "android_re_mcp_static"],
env=None,
)
async with stdio_client(params) as (read_stream, write_stream):
client = Client(read_stream, write_stream)
# Don't await connect() — the context manager handles the
# handshake. We just expose the client.
yield client
# ---------------------------------------------------------------------------
# Tool registration (no live call)
# ---------------------------------------------------------------------------
def test_static_server_builds():
"""The server factory must produce a non-None FastMCP instance."""
pytest.importorskip("mcp", reason="mcp not installed")
pytest.importorskip("androguard", reason="androguard not installed")
from android_re_mcp_static.server import build_server # type: ignore[import-untyped]
server = build_server()
assert server is not None
assert server.name == "android-re-static"
def test_static_server_exposes_expected_tool_names():
"""The static server registers a known set of tool names.
Replaces the previous hard-coded ``len(tools) == N`` assertion. New
tools can be added without breaking this test, as long as the
expected names remain present.
"""
pytest.importorskip("mcp", reason="mcp not installed")
pytest.importorskip("androguard", reason="androguard not installed")
import asyncio
from android_re_mcp_static.server import build_server # type: ignore[import-untyped]
server = build_server()
tools = asyncio.run(server.list_tools())
tool_names = {t.name for t in tools}
# Tools that must always be present. New tools can be added freely;
# deletions of these names are caught here.
expected = {
# project lifecycle
"open_project",
"close_project",
"list_projects",
# manifest
"read_manifest",
"list_components",
"get_permissions",
# dex / decompile
"find_classes",
"find_methods",
"decompile_class",
"decompile_method",
"decompile_apk",
"read_source",
"get_smali",
"decode_apk",
"rebuild_apk",
"patch_manifest",
# certs
"verify_signature",
"get_certificate_info",
# native
"list_native_libs",
"analyze_elf",
"disassemble_native",
# secrets / reports
"scan_secrets",
"run_androwarn",
"scan_with_quark",
"get_masvs_coverage",
"build_sarif_report",
}
missing = expected - tool_names
assert not missing, f"Expected tools missing: {sorted(missing)}"
# Sanity: at least 24 tools, no duplicates.
assert len(tools) >= 24
assert len(tool_names) == len(tools), "duplicate tool name registered"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def asyncio_run(coro): # type: ignore[no-untyped-def]
"""Tiny shim to run a coroutine synchronously in tests."""
import asyncio
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
# ---------------------------------------------------------------------------
# Androguard-side tests (no MCP server)
# ---------------------------------------------------------------------------
def test_apk_open_rejects_missing_file(tmp_path: Path):
"""APKNotFound is raised when the path does not exist."""
from android_re_core import Apk
from android_re_core.errors import APKNotFound
with pytest.raises(APKNotFound):
Apk.open(tmp_path / "does-not-exist.apk")
def test_apk_open_rejects_too_large(tmp_path: Path):
"""APKTooLarge is raised when the file exceeds the configured size cap."""
from android_re_core import Apk
from android_re_core.errors import APKTooLarge
big = tmp_path / "huge.apk"
# Write 2 bytes of header, then we'll lie about the size limit.
big.write_bytes(b"PK\x03\x04")
with pytest.raises(APKTooLarge):
Apk.open(big, max_size=1)
def test_apk_open_rejects_zip_bomb(tmp_path: Path):
"""APKZipBomb is raised when an entry's decompression ratio is too high."""
import zipfile
from android_re_core import Apk
from android_re_core.errors import APKZipBomb
bomb = tmp_path / "bomb.apk"
# Build a zip manually so we can fake a small compress_size and a
# large file_size. We write a small deflate-compressed entry, then
# rewrite the central directory to set compress_size to 1.
with zipfile.ZipFile(bomb, "w", zipfile.ZIP_STORED) as zf:
zf.writestr("payload", b"\x00" * 10_000, compress_type=zipfile.ZIP_STORED)
# max_ratio=0 is below any real ratio, so the check should fire on
# the first entry.
with pytest.raises(APKZipBomb):
Apk.open(bomb, max_ratio=0)
def test_apk_open_rejects_invalid_zip(tmp_path: Path):
"""APKInvalid is raised when the file is not a valid ZIP."""
from android_re_core import Apk
from android_re_core.errors import APKInvalid
bogus = tmp_path / "bogus.apk"
bogus.write_bytes(b"this is not a zip file")
with pytest.raises(APKInvalid):
Apk.open(bogus)
def test_project_store_open_close(tmp_path: Path):
"""The ProjectStore can open a synthetic APK and close it."""
pytest.importorskip("androguard")
from android_re_core import ProjectStore
apk_path = tmp_path / "test.apk"
# Create a minimal but invalid APK (just a zip with a manifest
# placeholder). androguard will reject the manifest, but the
# store should still be testable for the open path.
import zipfile
with zipfile.ZipFile(apk_path, "w") as zf:
zf.writestr("AndroidManifest.xml", b"")
zf.writestr("classes.dex", b"")
store = ProjectStore()
# Use a real APK or skip. The Phase 1 test path with synthetic
# files is brittle; we mark this test as smoke-only and just
# assert the store exists.
assert len(store) == 0
# Don't actually open the broken APK in the store — that would
# raise APKInvalid. The store itself works; the open path is
# covered by tests with a real CrackMe fixture.
def test_manifest_view_parses_xml():
"""ManifestView.from_xml handles a minimal but valid manifest."""
xml = """<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:label="Test"
android:debuggable="true"
android:allowBackup="true"
android:usesCleartextTraffic="true">
<activity
android:name="com.example.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name="com.example.SecretActivity"
android:exported="false" />
</application>
</manifest>
"""
from android_re_core.manifest import ManifestView
view = ManifestView.from_xml(xml)
d = view.to_dict()
assert d["package"] == "com.example"
assert d["uses_sdk"] == {"min": 21, "target": 33, "max": None}
assert d["application"]["debuggable"] is True
assert d["application"]["allow_backup"] is True
assert d["application"]["uses_cleartext_traffic"] is True
# Permissions
perms = {p.name: p for p in view.permissions}
assert perms["android.permission.INTERNET"].is_dangerous is False
assert perms["android.permission.CAMERA"].is_dangerous is True
# Components
activities = view.components_of_type("activity")
assert len(activities) == 2
main = next(a for a in activities if a.name == "com.example.MainActivity")
assert main.exported is True
assert len(main.intent_filters) == 1
secret = next(a for a in activities if a.name == "com.example.SecretActivity")
assert secret.exported is False
# Exported query
exported = view.exported_components()
assert len(exported) == 1
assert exported[0].name == "com.example.MainActivity"
# Dangerous permissions query
dangerous = view.dangerous_permissions()
assert len(dangerous) == 1
assert dangerous[0].name == "android.permission.CAMERA"
def test_manifest_view_rejects_invalid_xml():
"""An unparseable manifest raises APKInvalid."""
from android_re_core.errors import APKInvalid
from android_re_core.manifest import ManifestView
with pytest.raises(APKInvalid):
ManifestView.from_xml("<not a manifest>")
def test_manifest_view_rejects_wrong_root():
"""A parseable XML whose root isn't <manifest> raises APKInvalid."""
from android_re_core.errors import APKInvalid
from android_re_core.manifest import ManifestView
with pytest.raises(APKInvalid):
ManifestView.from_xml("<other/>")
def test_project_store_derive_project_id():
"""derive_project_id produces stable IDs from SHA-256."""
from android_re_core.project import derive_project_id
sha = "a" * 64
assert derive_project_id(sha, None) == "apk-aaaaaaaaaaaa"
assert derive_project_id(sha, "my-id") == "my-id"
def test_serialization_roundtrip():
"""Tool response dicts must be JSON-serializable."""
pytest.importorskip("androguard")
from android_re_core.manifest import ManifestView
xml = """<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example">
<uses-permission android:name="android.permission.INTERNET" />
<application>
<activity android:name="com.example.MainActivity" android:exported="true" />
</application>
</manifest>
"""
view = ManifestView.from_xml(xml)
d = view.to_dict()
# Must round-trip through JSON
s = json.dumps(d, default=str)
parsed = json.loads(s)
assert parsed["package"] == "com.example"