feat(client): add guarded product link capture

This commit is contained in:
QiuSW
2026-08-04 09:26:37 +08:00
parent e7a4be1b9b
commit 7040bb61d8
11 changed files with 808 additions and 13 deletions
+93 -2
View File
@@ -16,11 +16,14 @@ from cmbuyer_client.device.adb import (
AdbClient,
CommandResult,
DeviceIdentityUnconfirmedError,
DeviceCommandError,
DeviceCommandTimeoutError,
DeviceNotFoundError,
DeviceOfflineError,
DeviceStateError,
DeviceUnauthorizedError,
DuplicatePhysicalDeviceError,
IntentLaunchUnconfirmedError,
SerialRequiredError,
)
@@ -176,7 +179,95 @@ class AdbClientTests(unittest.TestCase):
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)
def test_product_intent_is_fixed_to_action_view_and_pdd_package(self) -> None:
class IntentRunner:
def __init__(self) -> None:
self.calls: list[tuple[str, ...]] = []
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
self.calls.append(tuple(arguments))
return CommandResult(stdout="Status: ok\n")
runner = IntentRunner()
summary = AdbClient(runner).start_pdd_view_intent(
USB_SERIAL,
"123",
)
self.assertEqual(summary.status, "ok")
self.assertEqual(
runner.calls,
[
(
"-s",
USB_SERIAL,
"shell",
"am",
"start",
"-W",
"-a",
"android.intent.action.VIEW",
"-d",
"https://mobile.yangkeduo.com/goods.html?goods_id=123",
"-p",
"com.xunmeng.pinduoduo",
)
],
)
def test_product_intent_without_explicit_success_is_rejected(self) -> None:
class UnknownIntentRunner:
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
return CommandResult(stdout="Starting: Intent { ... }\n")
with self.assertRaises(IntentLaunchUnconfirmedError):
AdbClient(UnknownIntentRunner()).start_pdd_view_intent(
USB_SERIAL,
"123",
)
def test_product_intent_rejects_invalid_goods_id_before_runner(self) -> None:
class RecordingRunner:
def __init__(self) -> None:
self.calls: list[tuple[str, ...]] = []
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
self.calls.append(tuple(arguments))
return CommandResult(stdout="Status: ok\n")
invalid_values: tuple[object, ...] = (
"",
"12a",
"123",
" 123",
"123 ",
"https://mobile.yangkeduo.com/goods.html?goods_id=123",
"am start -W -d anything",
123,
None,
)
for value in invalid_values:
with self.subTest(value=repr(value)):
runner = RecordingRunner()
with self.assertRaises(ValueError):
AdbClient(runner).start_pdd_view_intent(USB_SERIAL, value) # type: ignore[arg-type]
self.assertEqual(runner.calls, [])
def test_product_intent_nonzero_and_timeout_remain_distinct(self) -> None:
class FailedIntentRunner:
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
return CommandResult(stdout="sensitive command output", returncode=1)
class TimeoutIntentRunner:
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
raise subprocess.TimeoutExpired(arguments, timeout_seconds)
with self.assertRaises(DeviceCommandError) as command_error:
AdbClient(FailedIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123")
self.assertNotIn("sensitive command output", str(command_error.exception))
with self.assertRaises(DeviceCommandTimeoutError):
AdbClient(TimeoutIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123")
+1
View File
@@ -0,0 +1 @@
"""拼多多受限打开模块的离线测试。"""
+186
View File
@@ -0,0 +1,186 @@
"""商品打开围栏的离线测试;所有设备和命令均为 fake。"""
from __future__ import annotations
import base64
from io import BytesIO
from pathlib import Path
import sys
from tempfile import TemporaryDirectory
import unittest
from PIL import Image
CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection, IntentLaunchSummary
from cmbuyer_client.pdd.product_open import (
ProductOpenCapturer,
ProductOpenTimeoutError,
ProductOpenUiDevice,
ProductHierarchyCaptureError,
ProductPackageMismatchError,
ProductScreenshotCaptureError,
ProductVersionMismatchError,
)
from cmbuyer_client.pdd.product_url import ProductUrl, ProductUrlError
SERIAL = "192.168.0.173:5555"
URL = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'><node /></hierarchy>"
def _png_base64() -> str:
image_data = BytesIO()
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
return base64.b64encode(image_data.getvalue()).decode("ascii")
class FakeAdbClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str | None]] = []
self.inspection = DeviceInspection(
device=AdbDevice(serial=SERIAL, state="device", model="PKG110"),
model="PKG110",
android_version="16",
)
def inspect(self, serial: str) -> DeviceInspection:
self.calls.append(("inspect", serial))
return self.inspection
def start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary:
self.calls.append(("intent", goods_id))
return IntentLaunchSummary(status="ok", returncode=0)
class FakeUiDevice:
def __init__(
self,
*,
version: str = "8.17.0",
current_package: str = "com.xunmeng.pinduoduo",
hierarchy: str = HIERARCHY,
timeout_on_screenshot: bool = False,
) -> None:
self.version = version
self.current_package = current_package
self.hierarchy = hierarchy
self.timeout_on_screenshot = timeout_on_screenshot
self.calls: list[str] = []
def app_info(self, package_name: str) -> dict[str, str]:
self.calls.append("app_info")
return {"versionName": self.version}
def app_current(self) -> dict[str, str]:
self.calls.append("app_current")
return {"package": self.current_package, "activity": "sensitive.activity.name"}
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
self.calls.append(method)
if method == "takeScreenshot":
if self.timeout_on_screenshot:
raise TimeoutError("raw remote detail")
return _png_base64()
if method == "dumpWindowHierarchy":
return self.hierarchy
raise AssertionError(f"unexpected RPC {method}")
class ProductOpenTests(unittest.TestCase):
def _capturer(self, adb: FakeAdbClient, device: FakeUiDevice) -> ProductOpenCapturer:
return ProductOpenCapturer(adb, lambda serial: device, timeout_seconds=2)
def test_success_uses_canonical_url_and_redacted_atomic_manifest(self) -> None:
adb = FakeAdbClient()
device = FakeUiDevice()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
result = self._capturer(adb, device).open_and_capture(SERIAL, URL, target)
manifest = result.manifest_path.read_text(encoding="utf-8")
self.assertTrue(result.screenshot_path.exists())
self.assertTrue(result.hierarchy_path.exists())
self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")])
self.assertEqual(device.calls, ["app_info", "app_current", "takeScreenshot", "dumpWindowHierarchy"])
self.assertIn('"goods_id": "123"', manifest)
self.assertIn('"canonical_url": "https://mobile.yangkeduo.com/goods.html?goods_id=123"', manifest)
self.assertNotIn(SERIAL, manifest)
self.assertNotIn("sensitive.activity.name", manifest)
self.assertNotIn(HIERARCHY, manifest)
def test_version_mismatch_halts_before_intent(self) -> None:
adb = FakeAdbClient()
for version in ("8.17.1", " 8.17.0 "):
with self.subTest(version=version), TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(ProductVersionMismatchError):
self._capturer(adb, FakeUiDevice(version=version)).open_and_capture(SERIAL, URL, target)
self.assertEqual(adb.calls[-1:], [("inspect", SERIAL)])
self.assertFalse(target.exists())
def test_public_entry_rejects_caller_constructed_url_value_object(self) -> None:
adb = FakeAdbClient()
with TemporaryDirectory() as temporary:
with self.assertRaises(ProductUrlError):
self._capturer(adb, FakeUiDevice()).open_and_capture(
SERIAL,
ProductUrl(goods_id="123", canonical_url="https://example.invalid/"), # type: ignore[arg-type]
Path(temporary) / "evidence",
)
self.assertEqual(adb.calls, [])
def test_foreground_package_mismatch_halts_before_capture(self) -> None:
adb = FakeAdbClient()
device = FakeUiDevice(current_package="com.example.other")
with TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(ProductPackageMismatchError):
self._capturer(adb, device).open_and_capture(SERIAL, URL, target)
self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")])
self.assertEqual(device.calls, ["app_info", "app_current"])
self.assertFalse(target.exists())
def test_timeout_and_invalid_hierarchy_leave_no_partial_evidence(self) -> None:
scenarios = (
(FakeUiDevice(timeout_on_screenshot=True), ProductOpenTimeoutError),
(FakeUiDevice(hierarchy="<not-hierarchy />"), ProductHierarchyCaptureError),
)
for device, error_type in scenarios:
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(error_type):
self._capturer(FakeAdbClient(), device).open_and_capture(SERIAL, URL, target)
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
def test_invalid_screenshot_is_a_distinct_redacted_failure(self) -> None:
class InvalidScreenshotDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "takeScreenshot":
self.calls.append(method)
return "not valid base64!"
return super().jsonrpc_call(method, params, timeout)
with TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(ProductScreenshotCaptureError) as raised:
self._capturer(FakeAdbClient(), InvalidScreenshotDevice()).open_and_capture(SERIAL, URL, target)
self.assertNotIn("base64", str(raised.exception).lower())
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
def test_read_only_protocol_has_no_ui_operation_methods(self) -> None:
forbidden = {"click", "swipe", "send_keys", "set_text", "press", "long_click"}
self.assertTrue(forbidden.isdisjoint(ProductOpenUiDevice.__dict__))
self.assertEqual(base64.b64decode(_png_base64())[:8], b"\x89PNG\r\n\x1a\n")
+49
View File
@@ -0,0 +1,49 @@
"""canonical 商品 URL 的离线解析测试。"""
from __future__ import annotations
from pathlib import Path
import sys
import unittest
CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
class ProductUrlTests(unittest.TestCase):
def test_rebuilds_url_from_goods_id(self) -> None:
link = parse_product_url("https://mobile.yangkeduo.com/goods.html?goods_id=00123")
self.assertEqual(link.goods_id, "00123")
self.assertEqual(
link.canonical_url,
"https://mobile.yangkeduo.com/goods.html?goods_id=00123",
)
def test_rejects_noncanonical_and_ambiguous_urls(self) -> None:
rejected = (
"http://mobile.yangkeduo.com/goods.html?goods_id=123",
"https://other.example/goods.html?goods_id=123",
"https://mobile.yangkeduo.com/other.html?goods_id=123",
"https://user@mobile.yangkeduo.com/goods.html?goods_id=123",
"https://mobile.yangkeduo.com:8443/goods.html?goods_id=123",
"https://mobile.yangkeduo.com:443/goods.html?goods_id=123",
"https://mobile.yangkeduo.com/goods.html?goods_id=123#fragment",
"https://mobile.yangkeduo.com/goods.html",
"https://mobile.yangkeduo.com/goods.html?goods_id=123&goods_id=456",
"https://mobile.yangkeduo.com/goods.html?goods_id=123&source=share",
"https://mobile.yangkeduo.com/goods.html?goods_id=12a",
"https://mobile.yangkeduo.com/goods.html?goods_id=%EF%BC%91%EF%BC%92%EF%BC%93",
"https://mobile.yangkeduo.com/goods.html?goods_id=",
" https://mobile.yangkeduo.com/goods.html?goods_id=123",
"https://MOBILE.YANGKEDUO.COM/goods.html?goods_id=123",
"https://mobile.yangkeduo.com/goods.html?goods_id=%31%32%33",
)
for value in rejected:
with self.subTest(value=value):
with self.assertRaises(ProductUrlError):
parse_product_url(value)