2026-08-04 11:16:11 +08:00
|
|
|
|
"""T-103 脱敏器测试:全部证据为合成数据,绝不读取真实 raw 目录。"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from hashlib import sha256
|
|
|
|
|
|
import json
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
import sys
|
|
|
|
|
|
from tempfile import TemporaryDirectory
|
|
|
|
|
|
import unittest
|
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
from xml.etree import ElementTree
|
|
|
|
|
|
|
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
|
|
|
|
|
|
|
|
|
|
|
from cmbuyer_client.device.sku_evidence_sanitizer import (
|
|
|
|
|
|
EXPECTED_GOODS_ID,
|
2026-08-04 11:57:45 +08:00
|
|
|
|
EXPECTED_SCREENSHOT_HEIGHT,
|
|
|
|
|
|
EXPECTED_SCREENSHOT_WIDTH,
|
|
|
|
|
|
EXPECTED_XML_HEIGHT,
|
|
|
|
|
|
EXPECTED_XML_WIDTH,
|
2026-08-04 11:16:11 +08:00
|
|
|
|
HUMAN_DECLARED_STATES,
|
|
|
|
|
|
SkuEvidenceSanitizationError,
|
|
|
|
|
|
sanitize_sku_panel_evidence,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
TEST_SERIAL = "synthetic-serial-never-publish"
|
|
|
|
|
|
TEST_ADDRESS = "SYNTHETIC_ADDRESS_NEVER_PUBLISH"
|
|
|
|
|
|
FULL_PHONE = "13800138000"
|
|
|
|
|
|
MASKED_PHONE = "138****0000"
|
|
|
|
|
|
SAFE_TEXT = "synthetic-safe-lower-content"
|
2026-08-04 14:42:49 +08:00
|
|
|
|
CURRENT_PRICE = "快卖光 ¥12.88"
|
|
|
|
|
|
ORIGINAL_PRICE = "¥29.00"
|
|
|
|
|
|
PRICE_CURRENT_BOUNDS = "[396,503][712,570]"
|
|
|
|
|
|
PRICE_ORIGINAL_BOUNDS = "[730,503][895,570]"
|
2026-08-04 11:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hash(path: Path) -> str:
|
|
|
|
|
|
digest = sha256()
|
|
|
|
|
|
with path.open("rb") as source:
|
|
|
|
|
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
|
|
|
|
digest.update(chunk)
|
|
|
|
|
|
return digest.hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _default_xml() -> str:
|
|
|
|
|
|
return (
|
|
|
|
|
|
"<hierarchy rotation='0'>"
|
|
|
|
|
|
f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE} {FULL_PHONE}' />"
|
2026-08-04 14:42:49 +08:00
|
|
|
|
f"{_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS)}"
|
|
|
|
|
|
f"{_price_node(ORIGINAL_PRICE, PRICE_ORIGINAL_BOUNDS)}"
|
2026-08-04 14:04:49 +08:00
|
|
|
|
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
|
2026-08-04 11:16:11 +08:00
|
|
|
|
"</hierarchy>"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 14:42:49 +08:00
|
|
|
|
def _price_node(
|
|
|
|
|
|
text: str,
|
|
|
|
|
|
bounds: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
package: str = "com.xunmeng.pinduoduo",
|
|
|
|
|
|
node_class: str = "android.widget.TextView",
|
|
|
|
|
|
clickable: str = "false",
|
|
|
|
|
|
enabled: str = "true",
|
|
|
|
|
|
visible: str = "true",
|
|
|
|
|
|
extra_attributes: str = "",
|
|
|
|
|
|
children: str = "",
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
attributes = (
|
|
|
|
|
|
f"bounds='{bounds}' text='{text}' package='{package}' class='{node_class}' "
|
|
|
|
|
|
f"clickable='{clickable}' enabled='{enabled}' visible-to-user='{visible}'{extra_attributes}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return f"<node {attributes}>{children}</node>"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _xml_with_prices(
|
|
|
|
|
|
current: str = CURRENT_PRICE,
|
|
|
|
|
|
original: str = ORIGINAL_PRICE,
|
|
|
|
|
|
*,
|
|
|
|
|
|
current_node: str | None = None,
|
|
|
|
|
|
original_node: str | None = None,
|
|
|
|
|
|
include_original: bool = True,
|
|
|
|
|
|
extra_nodes: str = "",
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
current_markup = current_node if current_node is not None else _price_node(current, PRICE_CURRENT_BOUNDS)
|
|
|
|
|
|
original_markup = (
|
|
|
|
|
|
original_node if original_node is not None else _price_node(original, PRICE_ORIGINAL_BOUNDS)
|
|
|
|
|
|
) if include_original else ""
|
|
|
|
|
|
return (
|
|
|
|
|
|
"<hierarchy>"
|
|
|
|
|
|
f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' />"
|
|
|
|
|
|
f"{current_markup}"
|
|
|
|
|
|
f"{original_markup}"
|
|
|
|
|
|
f"{extra_nodes}"
|
|
|
|
|
|
f"<node bounds='[0,570][1080,2376]' text='{SAFE_TEXT}' />"
|
|
|
|
|
|
"</hierarchy>"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 11:16:11 +08:00
|
|
|
|
def _write_raw(
|
|
|
|
|
|
root: Path,
|
|
|
|
|
|
*,
|
2026-08-04 11:38:15 +08:00
|
|
|
|
state: str = "panel-opened-target-preselected",
|
2026-08-04 11:16:11 +08:00
|
|
|
|
goods_id: str = EXPECTED_GOODS_ID,
|
|
|
|
|
|
model: str = "PKG110",
|
|
|
|
|
|
android_version: str = "16",
|
|
|
|
|
|
pdd_version: str = "8.17.0",
|
2026-08-04 11:57:45 +08:00
|
|
|
|
size: tuple[int, int] = (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT),
|
2026-08-04 11:16:11 +08:00
|
|
|
|
xml: str | None = None,
|
|
|
|
|
|
) -> Path:
|
|
|
|
|
|
raw = root / "raw"
|
|
|
|
|
|
raw.mkdir(parents=True)
|
|
|
|
|
|
screenshot = raw / "screenshot.png"
|
|
|
|
|
|
image = Image.new("RGB", size, color=(0, 180, 0))
|
2026-08-04 11:57:45 +08:00
|
|
|
|
if size == (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT):
|
2026-08-04 11:16:11 +08:00
|
|
|
|
for y in range(540):
|
|
|
|
|
|
for x in range(8):
|
|
|
|
|
|
image.putpixel((x, y), (255, 0, 0))
|
|
|
|
|
|
image.save(screenshot, format="PNG")
|
|
|
|
|
|
hierarchy = raw / "hierarchy.xml"
|
|
|
|
|
|
hierarchy.write_text(_default_xml() if xml is None else xml, encoding="utf-8")
|
|
|
|
|
|
manifest = {
|
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
|
"product": {
|
|
|
|
|
|
"goods_id": goods_id,
|
|
|
|
|
|
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
|
|
|
|
|
|
},
|
|
|
|
|
|
"human_declared_state": state,
|
|
|
|
|
|
"serial": TEST_SERIAL,
|
|
|
|
|
|
"channel": "wifi",
|
|
|
|
|
|
"device": {
|
|
|
|
|
|
"model": model,
|
|
|
|
|
|
"android_version": android_version,
|
|
|
|
|
|
"pdd_package": "com.xunmeng.pinduoduo",
|
|
|
|
|
|
"pdd_version": pdd_version,
|
|
|
|
|
|
},
|
|
|
|
|
|
"artifacts": [
|
|
|
|
|
|
{"path": "screenshot.png", "sha256": _hash(screenshot)},
|
|
|
|
|
|
{"path": "hierarchy.xml", "sha256": _hash(hierarchy)},
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
(raw / "manifest.json").write_text(json.dumps(manifest, sort_keys=True), encoding="utf-8")
|
|
|
|
|
|
return raw
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SkuEvidenceSanitizerTests(unittest.TestCase):
|
|
|
|
|
|
def test_all_declared_states_mask_screenshot_and_xml_without_raw_metadata(self) -> None:
|
|
|
|
|
|
for state in sorted(HUMAN_DECLARED_STATES):
|
|
|
|
|
|
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), state=state)
|
|
|
|
|
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
with Image.open(result.screenshot_path) as image:
|
|
|
|
|
|
self.assertEqual(image.getpixel((0, 0)), (0, 0, 0, 255))
|
|
|
|
|
|
self.assertEqual(image.getpixel((100, 600)), (0, 180, 0, 255))
|
|
|
|
|
|
derived_xml = result.hierarchy_path.read_text(encoding="utf-8")
|
|
|
|
|
|
manifest = result.manifest_path.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
self.assertIn(SAFE_TEXT, derived_xml)
|
|
|
|
|
|
self.assertNotIn(TEST_ADDRESS, derived_xml)
|
|
|
|
|
|
self.assertNotIn(FULL_PHONE, derived_xml)
|
|
|
|
|
|
self.assertNotIn(MASKED_PHONE, derived_xml)
|
|
|
|
|
|
self.assertIn(f'"human_declared_state": "{state}"', manifest)
|
|
|
|
|
|
self.assertIn('"privacy_tier": "SANITIZED"', manifest)
|
2026-08-04 14:42:49 +08:00
|
|
|
|
self.assertIn('"sanitizer_version": "t103-privacy-v4"', manifest)
|
2026-08-04 11:57:45 +08:00
|
|
|
|
self.assertIn('"screenshot_space": {', manifest)
|
|
|
|
|
|
self.assertIn('"xml_coordinate_space": {', manifest)
|
|
|
|
|
|
self.assertIn('"height": 2376', manifest)
|
2026-08-04 14:04:49 +08:00
|
|
|
|
self.assertIn('"height": 2376', manifest)
|
2026-08-04 11:57:45 +08:00
|
|
|
|
self.assertIn('"privacy_mask_rectangle": [', manifest)
|
2026-08-04 11:16:11 +08:00
|
|
|
|
self.assertIn('"removed_nodes": 1', manifest)
|
|
|
|
|
|
self.assertIn('"cleared_crossing_nodes": 0', manifest)
|
2026-08-04 14:42:49 +08:00
|
|
|
|
self.assertIn('"preserved_crossing_price_nodes": 2', manifest)
|
2026-08-04 11:16:11 +08:00
|
|
|
|
self.assertIn('"retained_below_nodes": 1', manifest)
|
2026-08-04 11:57:45 +08:00
|
|
|
|
self.assertIn('"max_right": 1080', manifest)
|
2026-08-04 14:04:49 +08:00
|
|
|
|
self.assertIn('"max_bottom": 2376', manifest)
|
2026-08-04 11:16:11 +08:00
|
|
|
|
self.assertNotIn("canonical_url", manifest)
|
|
|
|
|
|
self.assertNotIn(TEST_SERIAL, manifest)
|
|
|
|
|
|
self.assertNotIn("serial", manifest)
|
|
|
|
|
|
self.assertNotIn("channel", manifest)
|
|
|
|
|
|
self.assertNotIn(TEST_ADDRESS, manifest)
|
|
|
|
|
|
self.assertNotIn(FULL_PHONE, manifest)
|
|
|
|
|
|
|
|
|
|
|
|
def test_crossing_container_keeps_lower_children_but_clears_its_text(self) -> None:
|
|
|
|
|
|
xml = (
|
|
|
|
|
|
"<hierarchy>"
|
2026-08-04 14:04:49 +08:00
|
|
|
|
f"<node bounds='[0,0][1080,2376]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE}'>"
|
2026-08-04 11:16:11 +08:00
|
|
|
|
f"<node bounds='[0,0][1080,540]' text='{FULL_PHONE}' />"
|
2026-08-04 14:42:49 +08:00
|
|
|
|
f"{_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS)}"
|
|
|
|
|
|
f"{_price_node(ORIGINAL_PRICE, PRICE_ORIGINAL_BOUNDS)}"
|
2026-08-04 14:04:49 +08:00
|
|
|
|
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
|
2026-08-04 11:16:11 +08:00
|
|
|
|
"</node></hierarchy>"
|
|
|
|
|
|
)
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), xml=xml)
|
|
|
|
|
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
root = ElementTree.parse(result.hierarchy_path).getroot()
|
|
|
|
|
|
crossing = root.find("node")
|
|
|
|
|
|
|
|
|
|
|
|
self.assertIsNotNone(crossing)
|
|
|
|
|
|
assert crossing is not None
|
2026-08-04 14:04:49 +08:00
|
|
|
|
self.assertEqual(crossing.attrib, {"bounds": "[0,0][1080,2376]"})
|
2026-08-04 14:42:49 +08:00
|
|
|
|
self.assertEqual(len(list(crossing)), 3)
|
|
|
|
|
|
self.assertEqual(list(crossing)[-1].get("text"), SAFE_TEXT)
|
2026-08-04 11:16:11 +08:00
|
|
|
|
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
manifest["privacy_cleanup"],
|
2026-08-04 11:57:45 +08:00
|
|
|
|
{
|
|
|
|
|
|
"removed_nodes": 1,
|
|
|
|
|
|
"cleared_crossing_nodes": 1,
|
2026-08-04 14:42:49 +08:00
|
|
|
|
"preserved_crossing_price_nodes": 2,
|
2026-08-04 11:57:45 +08:00
|
|
|
|
"retained_below_nodes": 1,
|
|
|
|
|
|
"max_right": 1080,
|
2026-08-04 14:04:49 +08:00
|
|
|
|
"max_bottom": 2376,
|
2026-08-04 11:57:45 +08:00
|
|
|
|
},
|
2026-08-04 11:16:11 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-04 14:42:49 +08:00
|
|
|
|
def test_only_strict_crossing_price_leaves_are_projected_with_whitelisted_attributes(self) -> None:
|
|
|
|
|
|
xml = _xml_with_prices(
|
|
|
|
|
|
current_node=_price_node(
|
|
|
|
|
|
CURRENT_PRICE,
|
|
|
|
|
|
PRICE_CURRENT_BOUNDS,
|
|
|
|
|
|
extra_attributes=" content-desc='discard' resource-id='discard' focused='true'",
|
|
|
|
|
|
),
|
|
|
|
|
|
original_node=_price_node(
|
|
|
|
|
|
ORIGINAL_PRICE,
|
|
|
|
|
|
PRICE_ORIGINAL_BOUNDS,
|
|
|
|
|
|
extra_attributes=" content-desc='discard-too' resource-id='discard-too'",
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), xml=xml)
|
|
|
|
|
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
root = ElementTree.parse(result.hierarchy_path).getroot()
|
|
|
|
|
|
prices = [node for node in root.findall("node") if node.get("text") in {CURRENT_PRICE, ORIGINAL_PRICE}]
|
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(len(prices), 2)
|
|
|
|
|
|
for node in prices:
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
set(node.attrib),
|
|
|
|
|
|
{"bounds", "text", "package", "class", "clickable", "enabled", "visible-to-user"},
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(node.get("package"), "com.xunmeng.pinduoduo")
|
|
|
|
|
|
self.assertEqual(node.get("class"), "android.widget.TextView")
|
|
|
|
|
|
self.assertEqual(node.get("clickable"), "false")
|
|
|
|
|
|
self.assertEqual(node.get("enabled"), "true")
|
|
|
|
|
|
self.assertEqual(node.get("visible-to-user"), "true")
|
|
|
|
|
|
|
|
|
|
|
|
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
|
|
|
|
|
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 2)
|
|
|
|
|
|
self.assertNotIn("discard", result.hierarchy_path.read_text(encoding="utf-8"))
|
|
|
|
|
|
|
|
|
|
|
|
def test_crossing_price_window_rejects_text_and_structure_drift(self) -> None:
|
|
|
|
|
|
bad_texts = (
|
|
|
|
|
|
f"快卖光 ¥12.88 {TEST_ADDRESS}",
|
|
|
|
|
|
f"快卖光 ¥12.88 {FULL_PHONE}",
|
|
|
|
|
|
"快卖光 ¥12.88 使用微信支付",
|
|
|
|
|
|
"快卖光 ¥12.88 提交订单",
|
|
|
|
|
|
"快卖光 ¥12.88 优惠-11元",
|
|
|
|
|
|
"快要抢光 ¥12.88",
|
|
|
|
|
|
"快卖光 ¥0.00",
|
|
|
|
|
|
"快卖光 ¥12.8",
|
|
|
|
|
|
"快卖光 ¥12.880",
|
|
|
|
|
|
"快卖光 ¥12.88",
|
|
|
|
|
|
)
|
|
|
|
|
|
for text in bad_texts:
|
|
|
|
|
|
with self.subTest(text=text), TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text))
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
self.assertFalse((raw.parent / "derived").exists())
|
|
|
|
|
|
|
2026-08-04 14:55:14 +08:00
|
|
|
|
def test_crossing_price_text_mismatch_reports_only_fixed_slot_and_reason(self) -> None:
|
|
|
|
|
|
newline_node = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
|
|
|
|
|
|
"快卖光 ¥12.88", "快卖光 ¥12.88"
|
|
|
|
|
|
)
|
|
|
|
|
|
cases = (
|
|
|
|
|
|
("newline", _xml_with_prices(current_node=newline_node), "newline", PRICE_CURRENT_BOUNDS),
|
|
|
|
|
|
(
|
|
|
|
|
|
"non-ascii-whitespace",
|
|
|
|
|
|
_xml_with_prices(current="快卖光 ¥12.88"),
|
|
|
|
|
|
"non_ascii_whitespace",
|
|
|
|
|
|
PRICE_CURRENT_BOUNDS,
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"known-prefix-missing",
|
|
|
|
|
|
_xml_with_prices(current="快要抢光 ¥12.88"),
|
|
|
|
|
|
"known_prefix_missing",
|
|
|
|
|
|
PRICE_CURRENT_BOUNDS,
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"currency-missing",
|
|
|
|
|
|
_xml_with_prices(current="快卖光 12.88"),
|
|
|
|
|
|
"currency_missing",
|
|
|
|
|
|
PRICE_CURRENT_BOUNDS,
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"amount-shape",
|
|
|
|
|
|
_xml_with_prices(current="快卖光 ¥12.8"),
|
|
|
|
|
|
"amount_shape",
|
|
|
|
|
|
PRICE_CURRENT_BOUNDS,
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"extra-or-order",
|
|
|
|
|
|
_xml_with_prices(current="提交订单 ¥12.88"),
|
|
|
|
|
|
"extra_or_order",
|
|
|
|
|
|
PRICE_CURRENT_BOUNDS,
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"forbidden-characters",
|
|
|
|
|
|
_xml_with_prices(current="商品 ¥12.88"),
|
|
|
|
|
|
"forbidden_characters",
|
|
|
|
|
|
PRICE_CURRENT_BOUNDS,
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"right-slot-amount-shape",
|
|
|
|
|
|
_xml_with_prices(original="¥29.0"),
|
|
|
|
|
|
"amount_shape",
|
|
|
|
|
|
PRICE_ORIGINAL_BOUNDS,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
for name, xml, reason, bounds in cases:
|
|
|
|
|
|
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), xml=xml)
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
str(raised.exception),
|
|
|
|
|
|
f"跨界价格节点文本不匹配:slot={bounds};reason={reason}。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertFalse((raw.parent / "derived").exists())
|
|
|
|
|
|
|
|
|
|
|
|
def test_crossing_price_text_mismatch_never_echoes_sensitive_or_order_text(self) -> None:
|
|
|
|
|
|
cases = (
|
|
|
|
|
|
f"快卖光 ¥12.88 {TEST_ADDRESS}",
|
|
|
|
|
|
f"快卖光 ¥12.88 {FULL_PHONE}",
|
|
|
|
|
|
"快卖光 ¥12.88 使用微信支付",
|
|
|
|
|
|
"快卖光 ¥12.88 提交订单",
|
|
|
|
|
|
)
|
|
|
|
|
|
for text in cases:
|
|
|
|
|
|
with self.subTest(text=text), TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text))
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
|
|
|
|
|
|
message = str(raised.exception)
|
|
|
|
|
|
self.assertIn("slot=[396,503][712,570]", message)
|
|
|
|
|
|
self.assertIn("reason=extra_or_order", message)
|
|
|
|
|
|
for raw_fragment in (TEST_ADDRESS, FULL_PHONE, "使用微信支付", "提交订单", "¥12.88"):
|
|
|
|
|
|
self.assertNotIn(raw_fragment, message)
|
|
|
|
|
|
|
2026-08-04 14:42:49 +08:00
|
|
|
|
def test_crossing_price_projection_allows_only_limited_ascii_spaces_and_yen_variants(self) -> None:
|
|
|
|
|
|
for current in ("快卖光 ¥12.88", " 快卖光 ¥ 12.88 ", "快卖光 ¥12.88"):
|
|
|
|
|
|
with self.subTest(current=current), TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=current))
|
|
|
|
|
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
|
|
|
|
|
|
self.assertIn(current, hierarchy)
|
|
|
|
|
|
|
|
|
|
|
|
def test_unique_current_price_without_original_price_is_published(self) -> None:
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(include_original=False))
|
|
|
|
|
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
|
|
|
|
|
|
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
|
|
|
|
|
|
|
|
|
|
|
self.assertIn(CURRENT_PRICE, hierarchy)
|
|
|
|
|
|
self.assertNotIn(ORIGINAL_PRICE, hierarchy)
|
|
|
|
|
|
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 1)
|
|
|
|
|
|
|
|
|
|
|
|
def test_crossing_price_projection_rejects_newline_and_structure_drift(self) -> None:
|
|
|
|
|
|
encoded_newline = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
|
|
|
|
|
|
"快卖光 ¥12.88", "快卖光 ¥12.88"
|
|
|
|
|
|
)
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current_node=encoded_newline))
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
self.assertFalse((raw.parent / "derived").exists())
|
|
|
|
|
|
|
|
|
|
|
|
bad_structure = (
|
|
|
|
|
|
("package", {"package": "com.android.systemui"}),
|
|
|
|
|
|
("class", {"node_class": "android.view.View"}),
|
|
|
|
|
|
("clickable", {"clickable": "true"}),
|
|
|
|
|
|
("disabled", {"enabled": "false"}),
|
|
|
|
|
|
("hidden", {"visible": "false"}),
|
|
|
|
|
|
("children", {"children": "<node bounds='[400,510][500,520]' />"}),
|
|
|
|
|
|
)
|
|
|
|
|
|
for name, kwargs in bad_structure:
|
|
|
|
|
|
with self.subTest(structure=name), TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(
|
|
|
|
|
|
Path(temporary),
|
|
|
|
|
|
xml=_xml_with_prices(current_node=_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS, **kwargs)),
|
|
|
|
|
|
)
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
self.assertFalse((raw.parent / "derived").exists())
|
|
|
|
|
|
|
|
|
|
|
|
def test_crossing_price_candidates_require_unique_current_and_at_most_one_original(self) -> None:
|
|
|
|
|
|
scenarios = (
|
|
|
|
|
|
(
|
|
|
|
|
|
"missing-current",
|
|
|
|
|
|
_xml_with_prices(current="¥12.88", original=ORIGINAL_PRICE),
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"duplicate-current",
|
|
|
|
|
|
_xml_with_prices(current=CURRENT_PRICE, original=f"快卖光 {ORIGINAL_PRICE}"),
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"multiple-original",
|
|
|
|
|
|
_xml_with_prices(
|
|
|
|
|
|
extra_nodes=_price_node("¥39.88", PRICE_ORIGINAL_BOUNDS),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
for name, xml in scenarios:
|
|
|
|
|
|
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), xml=xml)
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
self.assertFalse((raw.parent / "derived").exists())
|
|
|
|
|
|
|
|
|
|
|
|
def test_crossing_price_outside_fixed_windows_is_cleared_and_submit_price_is_not_candidate(self) -> None:
|
|
|
|
|
|
outside_crossing = _price_node("快卖光 ¥99.99", "[396,498][712,570]")
|
|
|
|
|
|
submit = (
|
|
|
|
|
|
"<node bounds='[369,2225][710,2284]' text='提交订单 ¥12.88' "
|
|
|
|
|
|
"package='com.xunmeng.pinduoduo' class='android.widget.TextView' clickable='false' "
|
|
|
|
|
|
"enabled='true' visible-to-user='true' resource-id='submit-button' />"
|
|
|
|
|
|
)
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(extra_nodes=outside_crossing + submit))
|
|
|
|
|
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
|
|
|
|
|
|
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
|
|
|
|
|
|
|
|
|
|
|
self.assertNotIn("快卖光 ¥99.99", hierarchy)
|
|
|
|
|
|
self.assertIn("提交订单 ¥12.88", hierarchy)
|
|
|
|
|
|
self.assertIn("submit-button", hierarchy)
|
|
|
|
|
|
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 2)
|
|
|
|
|
|
|
2026-08-04 11:16:11 +08:00
|
|
|
|
def test_same_raw_and_config_produce_identical_derived_files(self) -> None:
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
root = Path(temporary)
|
|
|
|
|
|
left_raw = _write_raw(root / "left")
|
|
|
|
|
|
right_raw = _write_raw(root / "right")
|
|
|
|
|
|
left = sanitize_sku_panel_evidence(left_raw, left_raw.parent / "derived")
|
|
|
|
|
|
right = sanitize_sku_panel_evidence(right_raw, right_raw.parent / "derived")
|
|
|
|
|
|
|
|
|
|
|
|
for left_path, right_path in (
|
|
|
|
|
|
(left.screenshot_path, right.screenshot_path),
|
|
|
|
|
|
(left.hierarchy_path, right.hierarchy_path),
|
|
|
|
|
|
(left.manifest_path, right.manifest_path),
|
|
|
|
|
|
):
|
|
|
|
|
|
self.assertEqual(left_path.read_bytes(), right_path.read_bytes())
|
|
|
|
|
|
|
2026-08-04 11:57:45 +08:00
|
|
|
|
def test_manifest_records_distinct_screenshot_and_xml_coordinate_spaces(self) -> None:
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary))
|
|
|
|
|
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
manifest["screenshot_space"],
|
|
|
|
|
|
{"width": 1080, "height": 2376, "privacy_mask_rectangle": [0, 0, 1080, 540]},
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
manifest["xml_coordinate_space"],
|
|
|
|
|
|
{
|
|
|
|
|
|
"width": 1080,
|
2026-08-04 14:04:49 +08:00
|
|
|
|
"height": 2376,
|
2026-08-04 11:57:45 +08:00
|
|
|
|
"privacy_mask_rectangle": [0, 0, 1080, 540],
|
2026-08-04 14:04:49 +08:00
|
|
|
|
"observed_max": {"right": 1080, "bottom": 2376},
|
2026-08-04 11:57:45 +08:00
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-04 11:16:11 +08:00
|
|
|
|
def test_hash_and_metadata_mismatch_fail_closed_without_leak(self) -> None:
|
|
|
|
|
|
scenarios = (
|
|
|
|
|
|
("hash", {}, "screenshot"),
|
|
|
|
|
|
("model", {"model": "other"}, None),
|
|
|
|
|
|
("android", {"android_version": "15"}, None),
|
|
|
|
|
|
("version", {"pdd_version": "8.17.1"}, None),
|
|
|
|
|
|
("goods", {"goods_id": "123"}, None),
|
|
|
|
|
|
("state", {"state": "guessed"}, None),
|
2026-08-04 11:57:45 +08:00
|
|
|
|
("old-screenshot-space", {"size": (1080, 2400)}, None),
|
|
|
|
|
|
("other-screenshot-space", {"size": (100, 100)}, None),
|
2026-08-04 11:16:11 +08:00
|
|
|
|
)
|
|
|
|
|
|
for name, kwargs, corrupt_file in scenarios:
|
|
|
|
|
|
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), **kwargs)
|
|
|
|
|
|
if corrupt_file is not None:
|
|
|
|
|
|
(raw / f"{corrupt_file}.png").write_bytes(b"changed")
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
|
|
|
|
|
|
message = str(raised.exception)
|
|
|
|
|
|
self.assertNotIn(TEST_SERIAL, message)
|
|
|
|
|
|
self.assertNotIn(TEST_ADDRESS, message)
|
|
|
|
|
|
self.assertNotIn(FULL_PHONE, message)
|
|
|
|
|
|
self.assertFalse((raw.parent / "derived").exists())
|
|
|
|
|
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
|
|
|
|
|
|
2026-08-04 11:38:15 +08:00
|
|
|
|
def test_old_and_unknown_human_states_are_rejected(self) -> None:
|
|
|
|
|
|
for state in ("initial", "one-dimension-selected", "all-dimensions-selected", "guessed"):
|
|
|
|
|
|
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), state=state)
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
self.assertFalse((raw.parent / "derived").exists())
|
|
|
|
|
|
|
2026-08-04 11:16:11 +08:00
|
|
|
|
def test_malformed_inputs_and_bounds_or_phone_residue_fail_closed(self) -> None:
|
|
|
|
|
|
malformed = (
|
|
|
|
|
|
("manifest", None),
|
|
|
|
|
|
("png", None),
|
|
|
|
|
|
("xml", None),
|
|
|
|
|
|
("bounds", "<hierarchy><node text='missing bounds' /></hierarchy>"),
|
|
|
|
|
|
(
|
|
|
|
|
|
"private-parent-with-lower-child",
|
2026-08-04 14:04:49 +08:00
|
|
|
|
"<hierarchy><node bounds='[0,0][1080,540]'><node bounds='[0,540][1080,2376]' text='x' /></node></hierarchy>",
|
2026-08-04 11:16:11 +08:00
|
|
|
|
),
|
2026-08-04 14:04:49 +08:00
|
|
|
|
("full-phone-below", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{FULL_PHONE}' /></hierarchy>"),
|
|
|
|
|
|
("masked-phone-below", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{MASKED_PHONE}' /></hierarchy>"),
|
2026-08-04 11:16:11 +08:00
|
|
|
|
)
|
|
|
|
|
|
for kind, xml in malformed:
|
|
|
|
|
|
with self.subTest(kind=kind), TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), xml=xml)
|
|
|
|
|
|
if kind == "manifest":
|
|
|
|
|
|
(raw / "manifest.json").write_text("{invalid", encoding="utf-8")
|
|
|
|
|
|
elif kind == "png":
|
|
|
|
|
|
(raw / "screenshot.png").write_bytes(b"not a png")
|
|
|
|
|
|
manifest = json.loads((raw / "manifest.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
manifest["artifacts"][0]["sha256"] = _hash(raw / "screenshot.png")
|
|
|
|
|
|
(raw / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
|
|
|
|
|
elif kind == "xml":
|
|
|
|
|
|
(raw / "hierarchy.xml").write_text("<hierarchy>", encoding="utf-8")
|
|
|
|
|
|
manifest = json.loads((raw / "manifest.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
manifest["artifacts"][1]["sha256"] = _hash(raw / "hierarchy.xml")
|
|
|
|
|
|
(raw / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
|
|
|
|
|
|
self.assertFalse((raw.parent / "derived").exists())
|
|
|
|
|
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
|
|
|
|
|
|
|
|
|
|
|
def test_phone_recheck_rejects_separator_mask_and_cross_node_bypasses(self) -> None:
|
|
|
|
|
|
variants = (
|
|
|
|
|
|
"138 0013-8000",
|
|
|
|
|
|
"138****0000",
|
|
|
|
|
|
"138••••0000",
|
|
|
|
|
|
"138xxxx0000",
|
|
|
|
|
|
"138XXXX0000",
|
|
|
|
|
|
)
|
|
|
|
|
|
for value in variants:
|
|
|
|
|
|
xml = (
|
|
|
|
|
|
"<hierarchy>"
|
|
|
|
|
|
"<node bounds='[0,0][1080,540]' text='private' />"
|
2026-08-04 14:04:49 +08:00
|
|
|
|
f"<node bounds='[0,540][1080,2376]' text='{value}' />"
|
2026-08-04 11:16:11 +08:00
|
|
|
|
"</hierarchy>"
|
|
|
|
|
|
)
|
|
|
|
|
|
with self.subTest(value=value), TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), xml=xml)
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
self.assertFalse((raw.parent / "derived").exists())
|
|
|
|
|
|
|
|
|
|
|
|
split_xml = (
|
|
|
|
|
|
"<hierarchy>"
|
|
|
|
|
|
"<node bounds='[0,0][1080,540]' text='private' />"
|
|
|
|
|
|
"<node bounds='[0,540][1080,1000]' text='138' content-desc='0013' />"
|
2026-08-04 14:04:49 +08:00
|
|
|
|
"<node bounds='[0,1000][1080,2376]' text='8000' />"
|
2026-08-04 11:16:11 +08:00
|
|
|
|
"</hierarchy>"
|
|
|
|
|
|
)
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), xml=split_xml)
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
self.assertFalse((raw.parent / "derived").exists())
|
|
|
|
|
|
|
|
|
|
|
|
def test_privacy_geometry_requires_removed_and_retained_nodes(self) -> None:
|
|
|
|
|
|
scenarios = (
|
2026-08-04 14:04:49 +08:00
|
|
|
|
("no-private", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' /></hierarchy>"),
|
2026-08-04 11:16:11 +08:00
|
|
|
|
("no-below", "<hierarchy><node bounds='[0,0][1080,540]' text='private' /></hierarchy>"),
|
|
|
|
|
|
)
|
|
|
|
|
|
for name, xml in scenarios:
|
|
|
|
|
|
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), xml=xml)
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
self.assertFalse((raw.parent / "derived").exists())
|
|
|
|
|
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
|
|
|
|
|
|
2026-08-04 11:57:45 +08:00
|
|
|
|
def test_xml_coordinate_space_must_have_exact_configured_maximums(self) -> None:
|
|
|
|
|
|
scenarios = (
|
|
|
|
|
|
(
|
|
|
|
|
|
"short-width",
|
|
|
|
|
|
"<hierarchy><node bounds='[0,0][1079,540]' text='private' />"
|
2026-08-04 14:04:49 +08:00
|
|
|
|
"<node bounds='[0,540][1079,2376]' text='safe' /></hierarchy>",
|
|
|
|
|
|
"1079x2376",
|
2026-08-04 11:57:45 +08:00
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"short-height",
|
|
|
|
|
|
"<hierarchy><node bounds='[0,0][1080,540]' text='private' />"
|
2026-08-04 14:04:49 +08:00
|
|
|
|
"<node bounds='[0,540][1080,2375]' text='safe' /></hierarchy>",
|
|
|
|
|
|
"1080x2375",
|
2026-08-04 11:57:45 +08:00
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"wide-width",
|
|
|
|
|
|
"<hierarchy><node bounds='[0,0][1081,540]' text='private' />"
|
2026-08-04 14:04:49 +08:00
|
|
|
|
"<node bounds='[0,540][1081,2376]' text='safe' /></hierarchy>",
|
|
|
|
|
|
"1081x2376",
|
2026-08-04 11:57:45 +08:00
|
|
|
|
),
|
|
|
|
|
|
(
|
2026-08-04 14:04:49 +08:00
|
|
|
|
"old-v2-xml-height",
|
2026-08-04 11:57:45 +08:00
|
|
|
|
"<hierarchy><node bounds='[0,0][1080,540]' text='private' />"
|
2026-08-04 14:04:49 +08:00
|
|
|
|
"<node bounds='[0,540][1080,2400]' text='safe' /></hierarchy>",
|
|
|
|
|
|
"1080x2400",
|
2026-08-04 11:57:45 +08:00
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
for name, xml, observed in scenarios:
|
|
|
|
|
|
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary), xml=xml)
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
|
|
|
|
|
|
self.assertIn(observed, str(raised.exception))
|
|
|
|
|
|
self.assertNotIn(TEST_SERIAL, str(raised.exception))
|
|
|
|
|
|
self.assertFalse((raw.parent / "derived").exists())
|
|
|
|
|
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
|
|
|
|
|
|
2026-08-04 11:16:11 +08:00
|
|
|
|
def test_manifest_schema_package_and_artifact_structure_are_required(self) -> None:
|
|
|
|
|
|
def mutate(manifest: dict[str, object], kind: str) -> None:
|
|
|
|
|
|
if kind == "schema":
|
|
|
|
|
|
manifest["schema_version"] = 2
|
|
|
|
|
|
elif kind == "package":
|
|
|
|
|
|
manifest["device"]["pdd_package"] = "com.example.other" # type: ignore[index]
|
|
|
|
|
|
elif kind == "missing":
|
|
|
|
|
|
manifest.pop("artifacts")
|
|
|
|
|
|
elif kind == "duplicate":
|
|
|
|
|
|
manifest["artifacts"].append(manifest["artifacts"][0]) # type: ignore[index]
|
|
|
|
|
|
elif kind == "bad-hash":
|
|
|
|
|
|
manifest["artifacts"][0]["sha256"] = "g" * 64 # type: ignore[index]
|
|
|
|
|
|
|
|
|
|
|
|
for kind in ("schema", "package", "missing", "duplicate", "bad-hash"):
|
|
|
|
|
|
with self.subTest(kind=kind), TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary))
|
|
|
|
|
|
manifest_path = raw / "manifest.json"
|
|
|
|
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
|
|
|
|
mutate(manifest, kind)
|
|
|
|
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
|
|
|
|
|
|
|
|
|
|
|
self.assertNotIn(TEST_SERIAL, str(raised.exception))
|
|
|
|
|
|
self.assertNotIn(TEST_ADDRESS, str(raised.exception))
|
|
|
|
|
|
self.assertFalse((raw.parent / "derived").exists())
|
|
|
|
|
|
|
|
|
|
|
|
def test_target_created_during_publish_is_preserved_and_staging_is_removed(self) -> None:
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary))
|
|
|
|
|
|
target = raw.parent / "derived"
|
|
|
|
|
|
|
|
|
|
|
|
def race_rename(destination: Path) -> None:
|
|
|
|
|
|
destination.mkdir()
|
|
|
|
|
|
(destination / "sentinel.txt").write_text("keep", encoding="utf-8")
|
|
|
|
|
|
raise FileExistsError("simulated publish race")
|
|
|
|
|
|
|
|
|
|
|
|
with patch("cmbuyer_client.device.sku_evidence_sanitizer.Path.rename", side_effect=race_rename):
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, target)
|
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual((target / "sentinel.txt").read_text(encoding="utf-8"), "keep")
|
|
|
|
|
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
|
|
|
|
|
|
|
|
|
|
|
def test_existing_derived_is_preserved_without_reading_or_writing_raw(self) -> None:
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
raw = _write_raw(Path(temporary))
|
|
|
|
|
|
target = raw.parent / "derived"
|
|
|
|
|
|
target.mkdir()
|
|
|
|
|
|
sentinel = target / "sentinel.txt"
|
|
|
|
|
|
sentinel.write_text("keep", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, target)
|
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
|
|
|
|
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
|
|
|
|
|
|
|
|
|
|
|
def test_directory_contract_rejects_non_sibling_paths(self) -> None:
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
root = Path(temporary)
|
|
|
|
|
|
raw = _write_raw(root)
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
|
|
|
|
|
sanitize_sku_panel_evidence(raw, root / "not-derived")
|
|
|
|
|
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
|
|
|
|
|
sanitize_sku_panel_evidence(root / "not-raw", root / "derived")
|