"""Release probes: synthetic data and loopback HTTPS only, never real accounts.""" import io import json import tempfile from pathlib import Path from unittest import mock import backend_client as backend def check_login_routing(*, legacy_terminal: bool = False) -> None: """Exercise config -> identity login -> desktop exchange through real _request.""" identity_url = "https://identity.example.invalid" backend_url = backend.PACKAGED_SERVER_URL calls = [] def transport(request, **kwargs): body = json.loads(request.data) if request.data else {} calls.append((request.method, request.full_url, body)) if request.full_url == backend_url + backend.DESKTOP_AUTH_CONFIG_PATH: payload = {"zyt_api_url": identity_url, "terminal": 7} elif request.full_url == identity_url + backend.ZYT_LOGIN_PATH: payload = ( {"code": 0, "msg": "terminal unsupported"} if legacy_terminal and body.get("terminal") == 7 else {"code": 1, "data": {"token": "probe-identity-token"}} ) elif request.full_url == backend_url + backend.DESKTOP_AUTH_EXCHANGE_PATH: if request.get_header("Authorization") != "Bearer probe-identity-token": raise RuntimeError("Desktop exchange did not receive the identity token") if "password" in body or "account" in body: raise RuntimeError("Password payload was sent to the desktop backend") payload = {"access_token": "probe-desktop-token", "account": {"id": 1}} else: raise RuntimeError("Unexpected authentication request destination") response = io.BytesIO(json.dumps(payload).encode("utf-8")) response.status = 200 return response with tempfile.TemporaryDirectory(prefix="wecom-login-probe-") as temporary, \ mock.patch.object(backend, "is_frozen", return_value=True), \ mock.patch.object(backend, "CONNECTION_FILE", Path(temporary) / "connection.json"), \ mock.patch.object(backend, "device_id", return_value="probe-device"), \ mock.patch.object(backend, "_protect_secret", return_value="probe-protected"), \ mock.patch.object(backend.urllib.request, "urlopen", side_effect=transport): result = backend.desktop_login("http://ignored.example.invalid", "probe-account", "probe-password") if result["access_token"] != "probe-desktop-token": raise RuntimeError("Desktop login flow did not complete") expected = [ ("GET", backend_url + backend.DESKTOP_AUTH_CONFIG_PATH), ("POST", identity_url + backend.ZYT_LOGIN_PATH), ] if legacy_terminal: expected.append(("POST", identity_url + backend.ZYT_LOGIN_PATH)) expected.append(("POST", backend_url + backend.DESKTOP_AUTH_EXCHANGE_PATH)) if [(method, url) for method, url, _ in calls] != expected: raise RuntimeError("Authentication requests were routed incorrectly") def check_database_initialization() -> None: """Exercise an empty installed data directory with a synthetic local database.""" import sqlite3 import os import wxwork_db as database import wxwork_key import wxwork_local_setup as setup import wxwork_message_browser as browser with tempfile.TemporaryDirectory(prefix="wecom-db-probe-") as temporary: root = Path(temporary) source = root / "WXWork" message = source / "test-account" / "Data" / "message.db" message.parent.mkdir(parents=True) connection = sqlite3.connect(message) connection.execute("PRAGMA page_size=4096") connection.execute("CREATE TABLE message_table(sender_id TEXT, conversation_id TEXT, content_type INT, send_time INT, content TEXT)") connection.execute("INSERT INTO message_table VALUES('test','M:test',2,123,'probe')") connection.commit() connection.close() # 合成数据库和合成内存,覆盖打包后的密钥提取/持久化/解密链路。 from Crypto.Cipher import AES from wxwork_crypto import page_key, generate_iv key = bytes(range(1, 17)) encrypted = bytearray() raw = message.read_bytes() for offset in range(0, len(raw), 4096): page = offset // 4096 + 1 block = bytearray(raw[offset:offset + 4096]) def encode(value): return AES.new(page_key(key, page), AES.MODE_CBC, generate_iv(page)).encrypt(value) if page == 1: header = bytes(block[16:24]) block[:16] = encode(bytes(block[:16])) block[16:] = encode(bytes(block[16:])) block[8:16] = block[16:24] block[16:24] = header else: block[:] = encode(bytes(block)) encrypted.extend(block) message.write_bytes(encrypted) memory = b"raw:" + key process = mock.Mock() process.regions.return_value = [(0, len(memory))] process.read.side_effect = lambda base, size: memory[base:base + size] def acquire(): if setup.key_worker_main() != 0: raise RuntimeError("Synthetic database key worker failed") data = root / "installed-data" with mock.patch.object(database, "_SCRIPT_DIR", str(data)), \ mock.patch.object(database, "KEYS_FILE", str(data / "wxwork_keys.json")), \ mock.patch.object(setup, "application_data_dir", return_value=data), \ mock.patch.object(browser, "application_data_dir", return_value=data), \ mock.patch.dict(os.environ, {"WECOM_ARCHIVE_SOURCE_DIR": ""}), \ mock.patch.object(database, "_windows_documents_dirs", return_value=[str(root)]), \ mock.patch.object(wxwork_key, "find_wxwork_pids", return_value=[123]), \ mock.patch.object(wxwork_key, "_ProcessMemory", return_value=process), \ mock.patch.object(setup, "acquire_local_keys", side_effect=acquire) as acquired: state = browser.load_browser_snapshot(cache_roots=[data / "wxwork_decrypted"], auto_initialize=True) acquired.assert_called_once_with() if state["manualSetupRequired"] or not (data / "wxwork_keys.json").is_file(): raise RuntimeError("Packaged automatic key acquisition failed") if message.read_bytes() != encrypted: raise RuntimeError("Live database was unexpectedly modified") if state["error"] or state["accountCount"] != 1 or state["messageCount"] != 1: raise RuntimeError("Packaged database initialization failed") (data / "wxwork_keys.json").unlink() _check_archive_upload(source, data) if acquired.call_count != 2: raise RuntimeError("Archive upload did not initialize missing keys") _check_live_database_reply(source, data) def check_https_runtime() -> None: """Exercise real urllib HTTPS, TLS and certificate verification on loopback.""" import datetime import ipaddress import ssl import threading import urllib.error import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID if not hasattr(urllib.request, "HTTPSHandler"): raise RuntimeError("Python HTTPS handler is missing") key = rsa.generate_private_key(public_exponent=65537, key_size=2048) subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) now = datetime.datetime.now(datetime.timezone.utc) certificate = ( x509.CertificateBuilder().subject_name(subject).issuer_name(subject) .public_key(key.public_key()).serial_number(x509.random_serial_number()) .not_valid_before(now - datetime.timedelta(minutes=5)) .not_valid_after(now + datetime.timedelta(days=1)) .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) .add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), critical=False) .sign(key, hashes.SHA256()) ) class ProbeHandler(BaseHTTPRequestHandler): def do_GET(self): payload = b'{"https_ready": true}' self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) def log_message(self, *_args): pass with tempfile.TemporaryDirectory(prefix="wecom-tls-probe-") as temporary: root = Path(temporary) cert_path = root / "localhost.pem" key_path = root / "localhost-key.pem" cert_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) server_context.load_cert_chain(cert_path, key_path) server = ThreadingHTTPServer(("127.0.0.1", 0), ProbeHandler) server.socket = server_context.wrap_socket(server.socket, server_side=True) worker = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True) worker.start() try: url = f"https://127.0.0.1:{server.server_port}" # Trust this probe certificate only, keeping hostname/chain checks enabled. client_context = ssl.create_default_context(cafile=str(cert_path)) opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), urllib.request.HTTPSHandler(context=client_context)) with mock.patch.object(backend.urllib.request, "urlopen", side_effect=opener.open): status, payload = backend._request("GET", url, "/probe", identity_service=True, timeout=5) if status != 200 or payload != {"https_ready": True}: raise RuntimeError("Real HTTPS request did not complete") # The same self-signed endpoint must be rejected without explicit trust. untrusted = urllib.request.build_opener(urllib.request.ProxyHandler({}), urllib.request.HTTPSHandler(context=ssl.create_default_context())) try: with untrusted.open(url + "/probe", timeout=5): raise RuntimeError("Untrusted HTTPS certificate was accepted") except urllib.error.URLError as exc: if not isinstance(exc.reason, ssl.SSLCertVerificationError): raise RuntimeError("HTTPS failed before certificate verification") from exc finally: server.shutdown() server.server_close() worker.join(timeout=5) def _check_archive_upload(source: Path, data: Path) -> None: """Encrypted synthetic DB -> actual exporter/API client -> mocked cloud transport.""" import archive_auto_backup as archive import requests cloud = backend.PACKAGED_SERVER_URL config = archive.AutoBackupConfig( exporter_root=archive._discover_exporter_root(), source_root=source, work_root=data / "archive_auto_backup", api_urls=(cloud,), batch_size=1, ) cursor = {} uploaded = [] calls = [] def transport(session, request, **kwargs): nonlocal cursor if not request.url.startswith(cloud + "/api/v2/"): raise RuntimeError("Archive request escaped the configured backend") if request.headers.get("Authorization") != "Bearer probe-archive-token": raise RuntimeError("Archive request lost the desktop credential") from urllib.parse import urlsplit path = urlsplit(request.url).path calls.append((request.method, path)) payload = json.loads(request.body) if request.body else {} if path == "/api/v2/health": result = {"status": "ok"} elif path.endswith("/checkpoint"): if request.method == "POST": cursor = payload["checkpoint"] result = {"checkpoint": cursor} elif path.endswith("/imports/metadata"): result = {"people_synced": len(payload.get("people", [])), "conversations_updated": 0} elif path.endswith("/imports/messages"): uploaded.extend(payload["messages"]) cursor = payload["checkpoint"] result = {"received": len(payload["messages"]), "inserted": len(payload["messages"]), "duplicates": 0} elif path.endswith("/pending-attachments"): result = {"source_message_ids": []} else: raise RuntimeError("Unexpected archive endpoint: " + path) response = requests.Response() response.status_code = 200 response._content = json.dumps(result).encode("utf-8") response.request = request response.url = request.url return response with mock.patch.object(archive, "_configured_source_root", return_value=source), \ mock.patch.object(archive, "_load_exporter_keys", return_value={}), \ mock.patch.object(archive, "application_data_dir", return_value=data), \ mock.patch.object(archive, "_backup_policy", return_value=(True, 1)), \ mock.patch.object(archive, "is_frozen", return_value=True), \ mock.patch.object(backend, "is_frozen", return_value=True), \ mock.patch.object(backend, "desktop_access_token", return_value="probe-archive-token"), \ mock.patch.object(requests.Session, "send", new=transport): if archive._api_candidates("http://127.0.0.1:8766") != [cloud]: raise RuntimeError("Installed archive destination is incorrect") first = archive.run_backup_once(config) second = archive.run_backup_once(config) if first["summary"]["inserted"] != 1 or second["summary"]["inserted"] != 0: raise RuntimeError("Archive incremental upload failed") if len(uploaded) != 1 or not cursor.get("rowid"): raise RuntimeError("Cloud archive checkpoint was not respected") if archive.read_backup_status()["status"] != "completed": raise RuntimeError("Cloud upload result was not available to the UI") if ("POST", "/api/v2/archive/desktop/imports/messages") not in calls: raise RuntimeError("No message import was sent through the API client") def check_message_navigation() -> None: """Bundled OCR must identify Messages below Assistant; all input is mocked.""" check_cross_machine_capture() check_reply_feedback() import numpy as np from PIL import Image, ImageDraw, ImageFont from runtime_paths import resource_path from wechat_bot import WeChatBot surface = Image.new("RGB", (1200, 740), (245, 245, 245)) draw = ImageDraw.Draw(surface) draw.rectangle((0, 0, 115, 739), fill=(215, 232, 250)) font = ImageFont.truetype(str(resource_path("assets/fonts/HarmonyOS_Sans_SC_Regular.ttf")), 24) draw.ellipse((36, 138, 80, 182), fill=(125, 140, 156)) draw.text((10, 192), "智能助理", font=font, fill=(80, 98, 118), anchor="lt") draw.rounded_rectangle((8, 240, 103, 335), radius=12, fill=(206, 224, 245)) draw.ellipse((36, 252, 80, 296), fill=(36, 126, 238)) draw.text((34, 305), "消息", font=font, fill=(36, 126, 238), anchor="lt") image = np.ascontiguousarray(np.asarray(surface)[:, :, ::-1]) bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.hwnd = 100 bot._strict_visual_actions = True y1, y2, located = bot._message_nav_band(image) if not located or not 240 < (y1 + y2) // 2 < 336: raise RuntimeError("Bundled OCR did not locate Messages below Assistant") if not bot._message_nav_selected(image): raise RuntimeError("Bundled navigation did not recognize selected Messages") gray = image.copy() background = np.all(gray == (250, 232, 215), axis=2) gray[background] = 190 y1, y2, located = bot._message_nav_band(gray) if not located or not 240 < (y1 + y2) // 2 < 336 or not bot._message_nav_selected(gray): raise RuntimeError("Bundled OCR could not locate Messages on a gray sidebar") bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._security_gate_visible = mock.Mock(return_value=False) bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._capture_full_window = mock.Mock(return_value=image) bot._refresh_message_geometry = mock.Mock() with mock.patch("wechat_bot.safe_set_foreground", return_value=True), \ mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=100), \ mock.patch("wechat_bot.win32gui.GetWindowRect", return_value=(0, 0, 1200, 740)), \ mock.patch("wechat_bot.time.sleep"), \ mock.patch("wechat_bot.pyautogui.click") as click: if not bot._open_messages_page("安装包自检:"): raise RuntimeError("Verified Messages navigation did not complete") if click.call_count != 1: raise RuntimeError("Navigation attempted multiple clicks") x, y = click.call_args.args if not 0 < x < 116 or not 240 < y < 336: raise RuntimeError("Navigation targeted Assistant or the conversation list") click.reset_mock() with mock.patch.object(bot, "_locate_message_nav_band_by_text", return_value=None): if bot._open_messages_page("安装包自检:") or click.called: raise RuntimeError("Unidentified navigation used a fallback click") def check_cross_machine_capture() -> None: """Synthetic local pixels -> actual unread detection -> mocked mouse, no uploads.""" import numpy as np from PIL import Image import wechat_bot as module for scale in (1.0, 1.25, 1.5, 2.0): for fallback in (False, True): width, height = int(600 * scale), int(500 * scale) left, top = (-1600 if fallback else 400), 180 rect = (left, top, left + width, top + height) pixels = np.full((height, width, 3), 245, dtype=np.uint8) pixels[:, :int(58 * scale)] = 190 # No blue sidebar assumption. lx, ly, lw, lh = (int(n * scale) for n in (58, 100, 245, 350)) bx, by, radius = int(48 * scale), int(90 * scale), max(4, int(6 * scale)) yy, xx = np.ogrid[:height, :width] pixels[(xx-lx-bx)**2 + (yy-ly-by)**2 <= radius**2] = (81, 81, 250) # Simulate a virtualized PrintWindow bitmap with undefined zero alpha. high = np.repeat(np.repeat(pixels, 2, axis=0), 2, axis=1) high = np.concatenate((high, np.zeros((*high.shape[:2], 1), dtype=np.uint8)), axis=2) if fallback: high.fill(0) bot = module.WeChatBot.__new__(module.WeChatBot) bot.scale, bot.hwnd = scale, 100 bot.L, bot.T, bot.R, bot.B = rect bot._list_x, bot._list_y, bot._list_w, bot._list_h = lx, ly, lw, lh bot.list_region = {"top": top + ly} bot.list_click_x = left + lx + int(120 * scale) bot.badge_scan_x_start = int(35 * scale) bot.badge_scan_x_end = int(80 * scale) target = b"synthetic-customer" # Identity is isolated here; navigation OCR is verified separately. bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._message_workspace_selected = mock.Mock(return_value=True) bot._session_fingerprint = mock.Mock(return_value=target) bot._chat_identity_signature = mock.Mock(side_effect=[b"before", b"after"]) bot._raw_selected_session_fingerprint = mock.Mock(return_value=target) bot._remember_active_surface = mock.Mock() bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() with mock.patch.object(module.win32gui, "GetWindowRect", return_value=rect), \ mock.patch.object(module, "window_dpi_virtualization_factor", return_value=2), \ mock.patch.object(module, "_capture_window_bitmap", return_value=high), \ mock.patch.object(module, "_window_screen_capture_allowed", return_value=True), \ mock.patch("PIL.ImageGrab.grab", return_value=Image.fromarray(pixels[:, :, ::-1])) as screen, \ mock.patch.object(module.pyautogui, "click") as click, \ mock.patch.object(module.time, "sleep"): rows = bot.detect_badge_rows(bot.capture_session_list()) if len(rows) != 1 or abs(rows[0] - by) > 1: raise RuntimeError(f"Unread badge lost at scale {scale}, fallback={fallback}") # Feed a stale row: the actual click must re-find the badge. if not bot.click_session(0, expected_fp=target): raise RuntimeError("Unread click failed after local capture recovery") click.assert_called_once() x, y = click.call_args.args if x != bot.list_click_x or abs(y - (top + ly + by + int(16 * scale))) > 1: raise RuntimeError("Unread click used a different coordinate space") if bool(screen.called) != fallback: raise RuntimeError("Screen fallback was not selected correctly") def _check_live_database_reply(source: Path, data: Path) -> None: """Frozen daemon reader -> DB event -> actual reply queue/content; no desktop input.""" import threading import time import reply_database from engine_b import DataEngine from wechat_bot import WeChatBot from wxwork_db import session_fp_from_name bot = WeChatBot.__new__(WeChatBot) bot._pending_lock = threading.RLock() bot._pending_reply_sessions = {} bot._cancelled_reply_sessions = set() bot._persist_pending_replies = mock.Mock(return_value=True) bot._log_queue_event = mock.Mock() bot._live_render_ids_for = mock.Mock(return_value=[]) bot.identity_by_name = True bot._open_chat_display_name = mock.Mock(return_value="test") bot.report_operation = mock.Mock() bot._reply_wakeup = threading.Event() fp = bytes.fromhex(session_fp_from_name("test")) bot._active_session_fp = fp with mock.patch.object(reply_database, "application_data_dir", return_value=data): service = reply_database.LiveReplyDatabase(read_timeout=5) service._since = 0 # Synthetic message timestamp. bot._db_source = service try: engine = DataEngine(bot=bot, db_source=service, data_source_mode="db") engine._poll_db_once() if engine.enqueued_count != 1 or not service.health_check(): raise RuntimeError("Frozen background database detection failed") pending = bot._pending_reply_sessions.get(fp.hex(), {}) if pending.get("identity_signature") or not pending.get("database_event"): raise RuntimeError("Database task bound the wrong foreground identity") if not bot._reply_wakeup.is_set(): raise RuntimeError("New database message did not wake reply processing") bot._session_identity_trustworthy = mock.Mock(return_value=True) bot._row_display_name = mock.Mock(return_value="test@微信") if bot._session_fingerprint(None, 0) != fp: raise RuntimeError("External-contact label broke database task navigation") bot._composer_geometry_valid = False with mock.patch("wechat_bot.pyautogui.hotkey") as hotkey, \ mock.patch("wechat_bot.pyautogui.dragTo") as drag: text = bot.extract_chat_text() if "probe" not in text or hotkey.called or drag.called: raise RuntimeError("Frozen reply path did not prefer database over clipboard") if not bot._has_pending_customer_message(text, fp): raise RuntimeError("Database message direction was not preserved") engine._poll_db_once() if engine.enqueued_count != 1: raise RuntimeError("Frozen reader duplicated the same database row") finally: service.close() if service._thread: service._thread.join(5) def check_reply_feedback() -> None: """Completed archives and outgoing messages must never start another reply.""" import ai_config from conversation_store import ConversationStore from engine_b import DataEngine import wechat_bot as module with tempfile.TemporaryDirectory(prefix="wecom-feedback-probe-") as temporary: store = ConversationStore(str(Path(temporary) / "conversations.json")) receiver = mock.Mock() receiver.has_active_pending.return_value = False receiver.enqueue_detected.return_value = (True, "queued") engine = DataEngine(bot=receiver, conversations_path=store.path) for i in range(8): store.append_exchange_once("aa", "synthetic question", "synthetic answer", f"probe-{i}") engine.poll_once() receiver.enqueue_detected.assert_not_called() # Restarting the detector must not revive a completed exchange either. DataEngine(bot=receiver, conversations_path=store.path).poll_once() receiver.enqueue_detected.assert_not_called() store.append("aa", "user", "synthetic question") store.save() engine.poll_once() receiver.enqueue_detected.assert_called_once() bot = module.WeChatBot.__new__(module.WeChatBot) fp = b"p" * 40 bot._active_session_fp = fp bot._active_identity_signature = b"title" bot._pending_reply_sessions = {fp.hex(): { "confirmed_unread": True, "display_name": "probe customer", "identity_signature": b"title", "generation_surface_signature": b"chat", }} bot._known_outgoing_speakers = mock.Mock(return_value={"configured staff"}) bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=True) bot._open_chat_display_name = mock.Mock(return_value="probe customer") bot.extract_chat_text = mock.Mock(return_value="different staff 14:12:01\nsynthetic answer") bot._clear_reply_pending = mock.Mock() bot._remember_active_surface = mock.Mock() bot._log_queue_event = mock.Mock() bot.report_operation = mock.Mock() bot._set_task_stage = mock.Mock(return_value=True) bot._reply_task_cancelled = mock.Mock(return_value=False) bot._security_gate_visible = mock.Mock(return_value=False) bot._await_send_gate = mock.Mock(return_value=True) bot.wait_before_send = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._capture_full_window = mock.Mock(return_value=None) bot._message_workspace_selected = mock.Mock(return_value=True) bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp) bot._chat_identity_signature = mock.Mock(return_value=b"title") bot._chat_surface_signature = mock.Mock(return_value=b"chat") bot._chat_target_matches = mock.Mock(return_value=True) bot._orchestrated_reply = mock.Mock() bot._begin_bot_mouse = mock.Mock() with mock.patch.object(ai_config, "AI_ENABLED", True), \ mock.patch.object(module.pyautogui, "click") as click, \ mock.patch.object(module.pyautogui, "hotkey") as hotkey, \ mock.patch.object(module.pyautogui, "press") as press: if bot._generate_ai_reply_impl(fp, chat_text=bot.extract_chat_text(), confirmed_unread=True): raise RuntimeError("Outgoing tail generated another reply") bot._orchestrated_reply.assert_not_called() if bot.send_reply("must not send", session_id=fp.hex(), expected_fp=fp): raise RuntimeError("Outgoing tail was sent another reply") if bot._last_send_failure_reason != "最后一条已是我方消息,取消残留回复任务": raise RuntimeError("Send did not reach the outgoing-message guard") click.assert_not_called() hotkey.assert_not_called() press.assert_not_called() bot._begin_bot_mouse.assert_not_called()