Files
helper/tests/test_account.py
T
2026-10-15 18:27:46 +08:00

221 lines
10 KiB
Python

"""账号自动识别、登录切换和启动前准备的验证,不发送微信消息。"""
from __future__ import annotations
from pathlib import Path
import struct
import sys
import unittest
from unittest.mock import Mock, patch
from wxbot_framework.account import (APP_GLOBAL_RVA, APP_VTABLE_RVA, CONTEXT_VTABLE_RVA,
APP_CONTEXT_GETTER_RVA, IDENTITY_GETTER_RVA, read_current_account,
select_account_process, read_current_profile)
from wxbot_framework.text import TextRouter
BASE, APP, CONTEXT, HEAP = 0x100000000, 0x300000000, 0x400000000, 0x500000000
SELF = "wxid_framework_current"
class AccountMemory:
def __init__(self, account=SELF):
self.regions = {}
self.add(BASE + APP_GLOBAL_RVA, 8)
self.add(APP, 0x80)
self.add(BASE + APP_VTABLE_RVA, 0x80)
self.add(CONTEXT, 0x100)
self.add(BASE + CONTEXT_VTABLE_RVA, 0x20)
self.add(BASE + IDENTITY_GETTER_RVA, 5)
self.add(HEAP, 128)
self.write(BASE + APP_GLOBAL_RVA, struct.pack("<Q", APP))
self.write(APP, struct.pack("<Q", BASE + APP_VTABLE_RVA))
self.write(APP + 0x68, struct.pack("<Q", CONTEXT))
self.write(BASE + APP_VTABLE_RVA + 0x60, struct.pack("<Q", BASE + APP_CONTEXT_GETTER_RVA))
self.write(CONTEXT, struct.pack("<Q", BASE + CONTEXT_VTABLE_RVA))
self.write(BASE + CONTEXT_VTABLE_RVA + 0x18, struct.pack("<Q", BASE + IDENTITY_GETTER_RVA))
self.write(BASE + IDENTITY_GETTER_RVA, b"\x48\x8d\x41\x48\xc3")
self.set_account(account)
# 微信号和账号 ID 是不同字段,不能把旁边的别名当成自身 ID。
alias = b"alias_name"
self.write(CONTEXT + 0x68, alias.ljust(16, b"\0") + struct.pack("<QQ", len(alias), 15))
def add(self, address, size):
self.regions[address] = bytearray(size)
def region(self, address, size):
for start, data in self.regions.items():
if start <= address and address + size <= start + len(data):
return data, address - start
raise OSError("模拟账号对象已释放")
def write(self, address, value):
region, offset = self.region(address, len(value))
region[offset:offset + len(value)] = value
def read(self, address, size):
region, offset = self.region(address, size)
return bytes(region[offset:offset + size])
def set_account(self, account):
body = account.encode("ascii")
if len(body) <= 15:
header = body.ljust(16, b"\0") + struct.pack("<QQ", len(body), 15)
else:
self.write(HEAP, body)
header = struct.pack("<QQQQ", HEAP, 0, len(body), 127)
self.write(CONTEXT + 0x48, header)
class AccountTests(unittest.TestCase):
def test_current_profile_reads_only_id_and_wechat_name(self):
ram = AccountMemory()
name = '测试号🌸'.encode('utf-8')
ram.write(CONTEXT + 0x88, name.ljust(16, b'\0') + struct.pack('<QQ', len(name), 15))
self.assertEqual(read_current_profile(ram.read, BASE), {'wxid': SELF, 'name': '测试号🌸'})
ram.write(CONTEXT + 0x88, b'\0' * 16 + struct.pack('<QQ', 0, 15))
self.assertIsNone(read_current_profile(ram.read, BASE))
def test_current_profile_change_during_snapshot_is_rejected(self):
ram = AccountMemory()
ram.write(CONTEXT + 0x88, b'DemoUser'.ljust(16, b'\0') + struct.pack('<QQ', 5, 15))
changed = False
def changing(address, size):
nonlocal changed
value = ram.read(address, size)
if address == CONTEXT + 0x88 and size == 32 and not changed:
changed = True
ram.set_account('wxid_different')
return value
self.assertIsNone(read_current_profile(changing, BASE))
def test_bound_account_is_selected_regardless_of_pid_or_enumeration_order(self):
for candidates in ([(123, "wxid_personal"), (456, SELF)],
[(789, SELF), (123, "wxid_personal")]):
expected = next(pid for pid, account in candidates if account == SELF)
self.assertEqual(select_account_process(candidates, SELF), expected)
def test_bound_account_does_not_fall_back_to_personal_account(self):
with self.assertRaisesRegex(RuntimeError, "等待绑定"):
select_account_process([(123, "wxid_personal"), (456, None)], SELF)
with self.assertRaisesRegex(RuntimeError, "等待唯一"):
select_account_process([(123, SELF), (456, SELF)], SELF)
def test_unbound_selection_uses_unique_logged_account_and_rejects_ambiguity(self):
self.assertEqual(select_account_process([(123, None), (456, SELF)]), 456)
with self.assertRaisesRegex(RuntimeError, "多个微信账号"):
select_account_process([(123, SELF), (456, "wxid_personal")])
with self.assertRaisesRegex(RuntimeError, "等待微信"):
select_account_process([(123, None)])
def test_live_layout_reads_id_not_alias_for_inline_and_heap_strings(self):
for account in (SELF, "old_username"):
ram = AccountMemory(account)
self.assertEqual(read_current_account(ram.read, BASE), account)
def test_not_logged_in_missing_context_and_unexpected_layout_are_not_cached(self):
for address, value in ((CONTEXT + 0x48, b"\0" * 16 + struct.pack("<QQ", 0, 15)),
(APP + 0x68, b"\0" * 8),
(CONTEXT, struct.pack("<Q", BASE + 0x1234)),
(BASE + IDENTITY_GETTER_RVA, b"\0" * 5)):
ram = AccountMemory()
ram.write(address, value)
self.assertIsNone(read_current_account(ram.read, BASE))
def test_malformed_or_non_account_strings_are_rejected(self):
for account in ("filehelper", "12345@chatroom", "gh_public", "weixin"):
ram = AccountMemory(account)
self.assertIsNone(read_current_account(ram.read, BASE))
ram = AccountMemory()
ram.write(CONTEXT + 0x48 + 16, struct.pack("<QQ", 128, 127))
self.assertIsNone(read_current_account(ram.read, BASE))
def test_account_string_changes_during_snapshot_are_retried(self):
ram = AccountMemory()
reads = 0
def changing_read(address, size):
nonlocal reads
if address == CONTEXT + 0x48 and size == 32:
reads += 1
if reads == 2:
ram.set_account("wxid_new_account")
return ram.read(address, size)
self.assertIsNone(read_current_account(changing_read, BASE))
self.assertEqual(read_current_account(ram.read, BASE), "wxid_new_account")
def test_login_context_changes_during_snapshot_are_retried(self):
ram = AccountMemory()
reads = 0
def changing_read(address, size):
nonlocal reads
if address == APP + 0x68:
reads += 1
if reads == 2:
ram.write(APP + 0x68, b"\0" * 8)
return ram.read(address, size)
self.assertIsNone(read_current_account(changing_read, BASE))
def test_first_private_message_works_without_filehelper_initialization(self):
router = TextRouter()
router.set_account(read_current_account(AccountMemory().read, BASE))
data = router.convert("wxid_friend", SELF, "echo 开机直接用")
self.assertEqual(data["chat_id"], "wxid_friend")
self.assertEqual(data["msg"], "echo 开机直接用")
self.assertEqual(router.unidentified, 0)
@unittest.skipUnless(sys.platform == "win32", "本机原生服务检查")
def test_service_prepares_identity_before_receive_thread(self):
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "native"))
from text_service import NativeTextService
service = NativeTextService()
service._read_account = Mock(return_value=SELF)
service._prepare_channel()
self.assertEqual(service.self_wxid, SELF)
self.assertIsNone(service._thread)
service._read_account.return_value = None
with self.assertRaisesRegex(RuntimeError, "自动等待"):
service._prepare_channel()
@unittest.skipUnless(sys.platform == "win32", "本机原生服务检查")
def test_explicit_wrong_pid_is_rejected_before_loading_bridge(self):
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "native"))
from text_service import NativeTextService
service = NativeTextService(pid=123, account=SELF)
service._run = Mock()
with patch("text_service.inspect_account", return_value="wxid_personal"):
with self.assertRaisesRegex(RuntimeError, "不是绑定"):
service.start()
service._run.assert_not_called()
self.assertFalse(service._owns_bridge)
@unittest.skipUnless(sys.platform == "win32", "本机原生服务检查")
def test_explicit_pid_cannot_override_bound_account(self):
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "native"))
from text_service import NativeTextService
service = NativeTextService(pid=123, account=SELF)
service._read_account = Mock(return_value="wxid_personal")
with self.assertRaisesRegex(RuntimeError, "不是绑定"):
service._prepare_channel()
self.assertEqual(service.self_wxid, "")
@unittest.skipUnless(sys.platform == "win32", "本机原生服务检查")
def test_send_rechecks_account_and_rejects_old_login_before_native_call(self):
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "native"))
from text_service import NativeTextService
service = NativeTextService()
service.router.set_account(SELF)
service._owns_bridge = True
service._read_account = Mock(return_value="wxid_different_account")
service._run = Mock()
with self.assertRaisesRegex(RuntimeError, "当前账号发生变化"):
service.send_text("wxid_friend", "不能通过新账号发送旧回复")
service._run.assert_not_called()
self.assertTrue(service._stop.is_set())
self.assertIsNotNone(service.last_error)
if __name__ == "__main__":
unittest.main()