feat(client): add T-101 device baseline

This commit is contained in:
QiuSW
2026-08-03 19:28:39 +08:00
parent f3294633c2
commit de8187eb5b
12 changed files with 1021 additions and 12 deletions
+1
View File
@@ -0,0 +1 @@
"""设备连接与基线取证的离线测试。"""
+182
View File
@@ -0,0 +1,182 @@
"""ADB 设备边界测试:所有命令执行器均为 mock,不连接真机。"""
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
import subprocess
import sys
import unittest
CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import (
AdbClient,
CommandResult,
DeviceIdentityUnconfirmedError,
DeviceNotFoundError,
DeviceOfflineError,
DeviceStateError,
DeviceUnauthorizedError,
DuplicatePhysicalDeviceError,
SerialRequiredError,
)
USB_SERIAL = "3B65BD02H7F00000"
WIFI_SERIAL = "192.168.0.173:5555"
class FakeRunner:
def __init__(self, devices_output: str, properties: dict[tuple[str, str], CommandResult | str]) -> None:
self.devices_output = devices_output
self.properties = properties
self.calls: list[tuple[str, ...]] = []
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
self.calls.append(tuple(arguments))
if tuple(arguments) == ("devices", "-l"):
return CommandResult(stdout=self.devices_output)
key = (arguments[1], arguments[-1])
value = self.properties.get(key, "")
return value if isinstance(value, CommandResult) else CommandResult(stdout=value)
def _properties(serials: tuple[str, ...]) -> dict[tuple[str, str], str]:
values: dict[tuple[str, str], str] = {}
for serial in serials:
values[(serial, "ro.serialno")] = "physical-phone-1"
values[(serial, "ro.boot.serialno")] = "physical-phone-1"
values[(serial, "ro.product.model")] = "PKG110"
values[(serial, "ro.product.name")] = "PKG110"
values[(serial, "ro.product.device")] = "OP5D2BL1"
values[(serial, "ro.build.version.release")] = "16"
return values
class AdbClientTests(unittest.TestCase):
def test_requires_explicit_serial(self) -> None:
runner = FakeRunner("List of devices attached\n", {})
with self.assertRaises(SerialRequiredError):
AdbClient(runner).inspect(" ")
self.assertEqual(runner.calls, [])
def test_missing_offline_and_unauthorized_are_distinct(self) -> None:
missing = AdbClient(FakeRunner("List of devices attached\n", {}))
with self.assertRaises(DeviceNotFoundError):
missing.inspect(USB_SERIAL)
offline = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\toffline\n", {}))
with self.assertRaises(DeviceOfflineError):
offline.inspect(USB_SERIAL)
unauthorized = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\tunauthorized\n", {}))
with self.assertRaises(DeviceUnauthorizedError):
unauthorized.inspect(USB_SERIAL)
def test_two_channels_with_same_physical_identity_fail_closed(self) -> None:
output = (
"List of devices attached\n"
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
)
runner = FakeRunner(output, _properties((WIFI_SERIAL, USB_SERIAL)))
with self.assertRaises(DuplicatePhysicalDeviceError):
AdbClient(runner).inspect(USB_SERIAL)
self.assertIn(("-s", WIFI_SERIAL, "shell", "getprop", "ro.serialno"), runner.calls)
self.assertIn(("-s", USB_SERIAL, "shell", "getprop", "ro.serialno"), runner.calls)
def test_multiple_online_devices_with_failed_identity_fail_closed(self) -> None:
output = (
"List of devices attached\n"
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
)
properties = _properties((WIFI_SERIAL, USB_SERIAL))
properties[(WIFI_SERIAL, "ro.serialno")] = CommandResult(stdout="", returncode=1)
runner = FakeRunner(output, properties)
with self.assertRaises(DeviceIdentityUnconfirmedError):
AdbClient(runner).inspect(USB_SERIAL)
def test_online_explicit_serial_reads_non_sensitive_metadata(self) -> None:
output = f"List of devices attached\n{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
inspection = AdbClient(FakeRunner(output, _properties((USB_SERIAL,)))).inspect(USB_SERIAL)
self.assertEqual(inspection.device.serial, USB_SERIAL)
self.assertEqual(inspection.model, "PKG110")
self.assertEqual(inspection.android_version, "16")
def test_single_online_device_does_not_require_hardware_identity(self) -> None:
output = f"List of devices attached\n{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
properties = _properties((USB_SERIAL,))
properties[(USB_SERIAL, "ro.serialno")] = ""
properties[(USB_SERIAL, "ro.boot.serialno")] = ""
inspection = AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
self.assertEqual(inspection.model, "PKG110")
def test_multiple_online_devices_without_hardware_identity_are_unconfirmed(self) -> None:
output = (
"List of devices attached\n"
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
)
properties = _properties((WIFI_SERIAL, USB_SERIAL))
properties[(WIFI_SERIAL, "ro.serialno")] = ""
properties[(WIFI_SERIAL, "ro.boot.serialno")] = ""
with self.assertRaises(DeviceIdentityUnconfirmedError):
AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
def test_multiple_online_devices_with_different_identity_keep_explicit_selection(self) -> None:
output = (
"List of devices attached\n"
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
)
properties = _properties((WIFI_SERIAL, USB_SERIAL))
properties[(WIFI_SERIAL, "ro.serialno")] = "physical-phone-2"
properties[(WIFI_SERIAL, "ro.boot.serialno")] = "physical-phone-2"
inspection = AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
self.assertEqual(inspection.device.serial, USB_SERIAL)
def test_shared_boot_serial_is_duplicate_even_when_ro_serial_differs(self) -> None:
output = (
"List of devices attached\n"
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
)
properties = _properties((WIFI_SERIAL, USB_SERIAL))
properties[(WIFI_SERIAL, "ro.serialno")] = "wifi-transport-serial"
properties[(USB_SERIAL, "ro.serialno")] = "usb-transport-serial"
properties[(WIFI_SERIAL, "ro.boot.serialno")] = "shared-hardware-serial"
properties[(USB_SERIAL, "ro.boot.serialno")] = "shared-hardware-serial"
with self.assertRaises(DuplicatePhysicalDeviceError):
AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
def test_unknown_adb_state_is_rejected(self) -> None:
client = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\trecovery\n", {}))
with self.assertRaises(DeviceStateError):
client.inspect(USB_SERIAL)
def test_runner_timeout_is_a_distinct_connection_error(self) -> None:
class TimeoutRunner:
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
raise subprocess.TimeoutExpired(arguments, timeout_seconds)
from cmbuyer_client.device.adb import DeviceCommandTimeoutError
with self.assertRaises(DeviceCommandTimeoutError):
AdbClient(TimeoutRunner()).inspect(USB_SERIAL)
+213
View File
@@ -0,0 +1,213 @@
"""基线取证测试:mock ADB/uiautomator2,不连接手机。"""
from __future__ import annotations
from pathlib import Path
import base64
from io import BytesIO
import sys
import tempfile
import unittest
from PIL import Image
from uiautomator2.exceptions import HTTPTimeoutError
CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
sys.path.insert(0, str(CLIENT_ROOT / "scripts"))
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
from cmbuyer_client.device.baseline import (
BaselineCaptureError,
BaselineCaptureTimeoutError,
DeviceBaselineCapturer,
NoReconnectUiautomatorConnector,
PDD_PACKAGE,
)
from capture_device_baseline import parse_arguments, validate_arguments
SERIAL = "USB-serial-for-test"
class StaticAdbClient:
def __init__(self) -> None:
self.serials: list[str] = []
def inspect(self, serial: str) -> DeviceInspection:
self.serials.append(serial)
return DeviceInspection(
device=AdbDevice(serial=serial, state="device", model="Test Model"),
model="Test Model",
android_version="16",
)
class FakeUiDevice:
def __init__(self, fail_dump: bool = False) -> None:
self.fail_dump = fail_dump
self.rpc_calls: list[tuple[str, object, float]] = []
self.app_info_calls: list[str] = []
def app_info(self, package_name: str) -> dict[str, str]:
self.app_info_calls.append(package_name)
return {"versionName": "8.17.0"}
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
self.rpc_calls.append((method, params, timeout))
if method == "takeScreenshot":
image_data = BytesIO()
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
return base64.b64encode(image_data.getvalue()).decode("ascii")
if method != "dumpWindowHierarchy":
raise AssertionError(f"unexpected method: {method}")
if self.fail_dump:
raise RuntimeError("mock dump failed")
return "<hierarchy><node text='page body must stay out of manifest'/></hierarchy>"
class BaselineCaptureTests(unittest.TestCase):
def test_capture_writes_hashes_without_xml_or_raw_serial_in_manifest(self) -> None:
adb = StaticAdbClient()
device = FakeUiDevice()
capturer = DeviceBaselineCapturer(adb, lambda serial: device, timeout_seconds=7.5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
result = capturer.capture(SERIAL, output)
manifest = result.manifest_path.read_text(encoding="utf-8")
self.assertEqual(adb.serials, [SERIAL])
self.assertEqual(device.app_info_calls, [PDD_PACKAGE])
self.assertEqual(
device.rpc_calls,
[
("takeScreenshot", [1, 80], 7.5),
("dumpWindowHierarchy", [False, 50], 7.5),
],
)
self.assertTrue(result.screenshot_path.is_file())
self.assertTrue(result.hierarchy_path.is_file())
self.assertIn('"sha256"', manifest)
self.assertNotIn("page body must stay out of manifest", manifest)
self.assertNotIn(SERIAL, manifest)
self.assertIn('"channel": "usb"', manifest)
def test_capture_failure_cleans_staging_and_does_not_publish_partial_output(self) -> None:
device = FakeUiDevice(fail_dump=True)
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: device, timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
output = parent / "baseline"
with self.assertRaises(BaselineCaptureError) as raised:
capturer.capture(SERIAL, output)
self.assertFalse(output.exists())
self.assertEqual(list(parent.iterdir()), [])
self.assertNotIn("mock dump failed", str(raised.exception))
def test_existing_output_is_never_overwritten(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
output.mkdir()
sentinel = output / "keep.txt"
sentinel.write_text("preserve", encoding="utf-8")
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: FakeUiDevice(), timeout_seconds=5)
with self.assertRaises(BaselineCaptureError):
capturer.capture(SERIAL, output)
self.assertEqual(sentinel.read_text(encoding="utf-8"), "preserve")
def test_no_reconnect_connector_passes_only_current_adb_device_object(self) -> None:
class ListedDevice:
serial = SERIAL
listed = ListedDevice()
connected: list[object] = []
connector = NoReconnectUiautomatorConnector(lambda: [listed], lambda device: connected.append(device) or FakeUiDevice())
connector(SERIAL)
self.assertEqual(connected, [listed])
def test_no_reconnect_connector_refuses_disappeared_serial(self) -> None:
connector = NoReconnectUiautomatorConnector(lambda: [], lambda device: FakeUiDevice())
with self.assertRaises(BaselineCaptureError) as raised:
connector(SERIAL)
self.assertIn("拒绝自动重连", str(raised.exception))
def test_connector_exception_is_redacted_and_publishes_no_partial_output(self) -> None:
def failing_connector(serial: str) -> FakeUiDevice:
raise RuntimeError(f"third party leaked {serial}")
capturer = DeviceBaselineCapturer(StaticAdbClient(), failing_connector, timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
with self.assertRaises(BaselineCaptureError) as raised:
capturer.capture(SERIAL, output)
self.assertNotIn(SERIAL, str(raised.exception))
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_invalid_screenshot_base64_fails_closed_without_partial_output(self) -> None:
class InvalidScreenshotDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "takeScreenshot":
return "not-valid-base64"
return super().jsonrpc_call(method, params, timeout)
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: InvalidScreenshotDevice(), timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
with self.assertRaises(BaselineCaptureError):
capturer.capture(SERIAL, output)
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_invalid_or_non_hierarchy_xml_fails_closed_without_partial_output(self) -> None:
class InvalidHierarchyDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy":
return "<not-hierarchy/>"
return super().jsonrpc_call(method, params, timeout)
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: InvalidHierarchyDevice(), timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
with self.assertRaises(BaselineCaptureError) as raised:
capturer.capture(SERIAL, output)
self.assertNotIn("not-hierarchy", str(raised.exception))
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_rpc_timeout_is_distinct_redacted_and_does_not_publish_partial_output(self) -> None:
class TimeoutRpcDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
raise HTTPTimeoutError(f"raw serial={SERIAL} xml=<hierarchy/>")
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: TimeoutRpcDevice(), timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
with self.assertRaises(BaselineCaptureTimeoutError) as raised:
capturer.capture(SERIAL, output)
self.assertIn("超时", str(raised.exception))
self.assertNotIn(SERIAL, str(raised.exception))
self.assertNotIn("hierarchy", str(raised.exception))
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_cli_validation_rejects_empty_serial_and_nonpositive_timeout(self) -> None:
empty_serial = parse_arguments(["--serial", "", "--output-dir", "baseline"])
with self.assertRaisesRegex(ValueError, "非空 --serial"):
validate_arguments(empty_serial)
nonpositive_timeout = parse_arguments(["--serial", SERIAL, "--output-dir", "baseline", "--timeout", "0"])
with self.assertRaisesRegex(ValueError, "必须大于 0"):
validate_arguments(nonpositive_timeout)