Files
xieyi-weixin-/test_remote_control.py
2026-09-29 15:51:28 +08:00

345 lines
17 KiB
Python

# -*- coding: utf-8 -*-
"""手机远程控制台的桌面端:命令通道、黑名单缓存同步、以服务端为准的移出。"""
import shutil
import sqlite3
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
import backend_client
import conversation_blacklist as cb
import remote_control
class BlacklistCacheTest(unittest.TestCase):
def setUp(self):
self.root = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.root, True)
def legacy_database(self):
"""升级前的本地库:没有 sync_state 列。"""
with sqlite3.connect(self.root / "conversation_blacklist.sqlite3") as db:
db.execute("CREATE TABLE blocked (account TEXT NOT NULL, conversation TEXT NOT NULL,"
" name TEXT NOT NULL DEFAULT '', PRIMARY KEY(account,conversation))")
db.execute("INSERT INTO blocked(account,conversation,name) VALUES('a1','S:old','老客户')")
def test_legacy_entries_are_uploaded_once(self):
self.legacy_database()
self.assertEqual(cb.pending_entries(self.root)[0]["conversation"], "S:old")
self.assertIsNone(cb.synced_version(self.root))
cb.apply_server_snapshot([{"account": "a1", "conversation": "S:old", "name": "老客户"}], 3, root=self.root)
self.assertEqual(cb.pending_entries(self.root), [])
self.assertEqual(cb.synced_version(self.root), 3)
self.assertIn(("a1", "S:old"), cb.entries(self.root))
def test_snapshot_removes_synced_rows_but_keeps_unsent_local_adds(self):
cb.apply_server_snapshot([
{"account": "a1", "conversation": "S:1", "reason": "投诉", "created_at": "2026-09-28T10:00:00+08:00"},
{"account": "a1", "conversation": "S:2"},
], 2, root=self.root)
cb.set_blocked("a1", "S:local", True, root=self.root, name="刚拉黑")
summary = cb.apply_server_snapshot([{"account": "a1", "conversation": "S:2"}], 4, root=self.root)
self.assertEqual(summary["removed"], 1)
self.assertEqual(cb.entries(self.root), {("a1", "S:2"), ("a1", "S:local")})
states = cb.local_states(self.root)
self.assertEqual(states[("a1", "S:local")], "pending")
self.assertEqual(states[("a1", "S:2")], "synced")
rows = {row["conversation"]: row for row in cb.list_entries(self.root)}
self.assertEqual(rows["S:local"]["name"], "刚拉黑")
self.assertEqual(rows["S:local"]["sync_state"], "pending")
class RemoveEntriesTest(unittest.TestCase):
def setUp(self):
self.root = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.root, True)
cb.apply_server_snapshot([{"account": "a1", "conversation": "S:synced"}], 1, root=self.root)
cb.set_blocked("a1", "S:pending", True, root=self.root)
def test_unsent_entries_are_removed_locally_without_the_server(self):
with mock.patch.object(backend_client, "remove_remote_blacklist") as remote:
outcome = remote_control.remove_blacklist_entries(
[{"account": "a1", "conversation": "S:pending"}], root=self.root)
remote.assert_not_called()
self.assertEqual(outcome["removed"], 1)
self.assertNotIn(("a1", "S:pending"), cb.entries(self.root))
def test_synced_entries_need_the_server_and_its_permission(self):
denied = backend_client.RemotePermissionDenied("移出黑名单需要 ZYT 权限 wecom_rpa.blacklist/remove", status=403)
with mock.patch.object(backend_client, "remove_remote_blacklist", side_effect=denied):
outcome = remote_control.remove_blacklist_entries(
[{"account": "a1", "conversation": "S:synced"}], root=self.root)
self.assertFalse(outcome["ok"])
self.assertIn("wecom_rpa.blacklist/remove", outcome["message"])
self.assertIn(("a1", "S:synced"), cb.entries(self.root), "没有权限时本机必须继续拦截")
with mock.patch.object(backend_client, "remove_remote_blacklist", return_value={"ok": True}):
outcome = remote_control.remove_blacklist_entries(
[{"account": "a1", "conversation": "S:synced"}], root=self.root)
self.assertTrue(outcome["ok"])
self.assertNotIn(("a1", "S:synced"), cb.entries(self.root))
def test_offline_removal_keeps_blocking(self):
offline = backend_client.BackendError("无法连接后台:timed out")
with mock.patch.object(backend_client, "remove_remote_blacklist", side_effect=offline):
outcome = remote_control.remove_blacklist_entries(
[{"account": "a1", "conversation": "S:synced"}], root=self.root)
self.assertFalse(outcome["ok"])
self.assertIn(("a1", "S:synced"), cb.entries(self.root))
def test_already_removed_on_the_server_is_removed_here(self):
gone = backend_client.BackendError("该会话不在黑名单中", status=404, code="not_blocked")
with mock.patch.object(backend_client, "remove_remote_blacklist", side_effect=gone):
outcome = remote_control.remove_blacklist_entries(
[{"account": "a1", "conversation": "S:synced"}], root=self.root)
self.assertEqual(outcome["removed"], 1)
def test_a_missing_route_is_not_mistaken_for_an_approved_removal(self):
# 旧版或回滚后的管理端没有这个接口,同样是 404,但不能据此删掉本机拦截。
missing = backend_client.BackendError("Not Found", status=404)
with mock.patch.object(backend_client, "remove_remote_blacklist", side_effect=missing):
outcome = remote_control.remove_blacklist_entries(
[{"account": "a1", "conversation": "S:synced"}], root=self.root)
self.assertFalse(outcome["ok"])
self.assertIn(("a1", "S:synced"), cb.entries(self.root))
def test_first_connection_failure_stops_the_batch(self):
cb.apply_server_snapshot([{"account": "a1", "conversation": f"S:{index}"} for index in range(5)],
2, root=self.root)
offline = backend_client.BackendError("无法连接后台")
with mock.patch.object(backend_client, "remove_remote_blacklist", side_effect=offline) as remote:
outcome = remote_control.remove_blacklist_entries(
[{"account": "a1", "conversation": f"S:{index}"} for index in range(5)], root=self.root)
self.assertEqual(remote.call_count, 1, "界面线程上不能对每一条都等一次超时")
self.assertEqual(len(outcome["failed"]), 5)
def test_local_only_delete_skips_rows_that_just_got_synced(self):
cb.apply_server_snapshot([{"account": "a1", "conversation": "S:pending"}], 3, root=self.root)
removed = cb.remove_entries([{"account": "a1", "conversation": "S:pending"}], root=self.root,
only_pending=True)
self.assertEqual(removed, 0)
self.assertIn(("a1", "S:pending"), cb.entries(self.root))
class TenantBindingTest(unittest.TestCase):
def setUp(self):
self.root = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.root, True)
def test_switching_accounts_keeps_blocking_and_resyncs(self):
self.assertTrue(cb.adopt_tenant("tenant-a", root=self.root))
cb.apply_server_snapshot([{"account": "a1", "conversation": "S:1"}], 5, root=self.root, tenant="tenant-a")
self.assertEqual(cb.synced_version(self.root, "tenant-a"), 5)
self.assertIsNone(cb.synced_version(self.root, "tenant-b"))
self.assertTrue(cb.adopt_tenant("tenant-b", root=self.root))
self.assertEqual(cb.local_states(self.root)[("a1", "S:1")], "pending")
self.assertIsNone(cb.synced_version(self.root, "tenant-b"))
self.assertIn(("a1", "S:1"), cb.entries(self.root))
# 迟到的旧账号快照不能覆盖新绑定
stale = cb.apply_server_snapshot([], 9, root=self.root, tenant="tenant-a")
self.assertTrue(stale.get("stale"))
self.assertIn(("a1", "S:1"), cb.entries(self.root))
self.assertFalse(cb.adopt_tenant("tenant-b", root=self.root))
def test_rejected_rows_keep_blocking_but_stop_retrying(self):
cb.set_blocked("a1", "S:bad", True, root=self.root)
self.assertEqual(cb.pending_count(self.root), 1)
cb.mark_rejected([{"account": "a1", "conversation": "S:bad"}], root=self.root)
self.assertEqual(cb.pending_count(self.root), 0)
self.assertEqual(cb.pending_entries(self.root), [])
self.assertIn(("a1", "S:bad"), cb.entries(self.root))
class FakeServer:
"""按顺序回放长轮询响应,记录回执和同步请求。"""
def __init__(self, responses):
self.responses = list(responses)
self.polls = []
self.acks = []
self.syncs = []
self.sync_error = None
self.sync_delay = 0.0
self.version = 7
self.done = threading.Event()
def poll(self, status, *, wait, blacklist_version=None, timeout=None):
self.polls.append({"status": status, "blacklist_version": blacklist_version, "at": time.monotonic()})
if not self.responses:
self.done.set()
time.sleep(0.05)
return {"commands": [], "blacklist_version": self.version}
item = self.responses.pop(0)
if isinstance(item, BaseException):
raise item
return item
def ack(self, command_id, *, ok, result=None, error="", status=None, timeout=10.0):
self.acks.append({"id": command_id, "ok": ok, "result": result, "error": error, "status": status})
return {"ok": True}
def sync(self, adds, *, timeout=15.0):
self.syncs.append(list(adds))
if self.sync_delay:
time.sleep(self.sync_delay)
if self.sync_error is not None:
raise self.sync_error
return {"version": self.version, "items": [{"account": "a1", "conversation": "S:server"}],
"rejected": []}
class ChannelTest(unittest.TestCase):
def setUp(self):
self.root = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.root, True)
patch = mock.patch.object(remote_control, "MIN_POLL_INTERVAL_SECONDS", 0.01)
patch.start()
self.addCleanup(patch.stop)
def start_channel(self, server, executor=None, executed=None):
executed = executed if executed is not None else []
def execute(command):
executed.append(command["id"])
return {"ok": True, "result": {"listening": command["type"] == "start_listening", "message": "好了"}}
patches = [
mock.patch.object(backend_client, "desktop_access_token", return_value="desk-token"),
mock.patch.object(backend_client, "poll_remote_commands", side_effect=server.poll),
mock.patch.object(backend_client, "ack_remote_command", side_effect=server.ack),
mock.patch.object(backend_client, "sync_remote_blacklist", side_effect=server.sync),
]
for patch in patches:
patch.start()
self.addCleanup(patch.stop)
channel = remote_control.RemoteControlChannel(
status_provider=lambda: {"listening": False, "state": "stopped"},
command_executor=executor or execute, root=self.root, poll_wait=0,
tenant_provider=lambda: "tenant-a",
)
channel.start()
self.addCleanup(channel.stop)
return channel, executed
def run_channel(self, server, executor=None):
channel, executed = self.start_channel(server, executor)
self.assertTrue(server.done.wait(10), "通道没有把回放的响应消费完")
channel.stop()
return executed
def test_commands_run_once_even_when_redelivered(self):
command = {"id": "c1", "type": "stop_listening", "expires_at": time.time() + 600, "created_by": "主管"}
server = FakeServer([
{"commands": [command], "blacklist_version": 7},
{"commands": [command], "blacklist_version": 7},
])
executed = self.run_channel(server)
self.assertEqual(executed, ["c1"])
self.assertEqual([ack["id"] for ack in server.acks], ["c1", "c1"])
self.assertTrue(all(ack["ok"] for ack in server.acks))
self.assertIn("commands_v1", server.polls[0]["status"]["capabilities"])
def test_expired_commands_are_not_executed(self):
server = FakeServer([{"commands": [{"id": "old", "type": "start_listening",
"expires_at": time.time() - 5}], "blacklist_version": 7}])
executed = self.run_channel(server)
self.assertEqual(executed, [])
self.assertFalse(server.acks[0]["ok"])
self.assertIn("有效期", server.acks[0]["error"])
def test_startup_uploads_pending_entries_and_adopts_the_server_version(self):
cb.set_blocked("a1", "S:local", True, root=self.root)
server = FakeServer([{"commands": [], "blacklist_version": 7}])
self.run_channel(server)
self.assertEqual(server.syncs[0][0]["conversation"], "S:local")
self.assertEqual(cb.synced_version(self.root), 7)
self.assertIn(("a1", "S:server"), cb.entries(self.root))
# 同步之后的长轮询带上本机版本,服务端据此判断要不要立刻叫醒
self.assertEqual(server.polls[-1]["blacklist_version"], 7)
def test_failed_sync_cools_down_instead_of_spinning(self):
server = FakeServer([{"commands": [], "blacklist_version": 9}] * 3)
server.sync_error = backend_client.BackendError("无法连接后台")
self.run_channel(server)
self.assertEqual(len(server.syncs), 1, "同步失败后 60 秒内不应反复重试")
self.assertTrue(all(poll["blacklist_version"] is None for poll in server.polls[1:]))
def test_executor_errors_become_failed_acks(self):
def broken(_command):
raise RuntimeError("界面线程没响应")
server = FakeServer([{"commands": [{"id": "c2", "type": "start_listening",
"expires_at": time.time() + 600}], "blacklist_version": 7}])
self.run_channel(server, executor=broken)
self.assertFalse(server.acks[0]["ok"])
self.assertIn("界面线程没响应", server.acks[0]["error"])
def test_the_thread_survives_unexpected_network_errors(self):
import http.client
command = {"id": "c3", "type": "stop_listening", "expires_at": time.time() + 600}
server = FakeServer([
ConnectionResetError("reset by peer"),
http.client.RemoteDisconnected("closed"),
{"commands": [command], "blacklist_version": 7},
])
with mock.patch.object(remote_control, "ERROR_BACKOFF_SECONDS", 0.01):
executed = self.run_channel(server)
self.assertEqual(executed, ["c3"])
def test_server_clock_decides_expiry(self):
# 电脑时钟快了 20 分钟:按服务端时间这条指令还有 5 分钟有效期,必须执行。
server_now = time.time() - 1200
server = FakeServer([{"commands": [{"id": "c4", "type": "start_listening", "expires_at": server_now + 300}],
"blacklist_version": 7, "server_time": server_now}])
self.assertEqual(self.run_channel(server), ["c4"])
def test_pending_rows_are_retried_even_when_versions_match(self):
server = FakeServer([{"commands": [], "blacklist_version": 7}] * 2)
channel, _executed = self.start_channel(server)
self.assertTrue(server.done.wait(10))
before = len(server.syncs)
cb.set_blocked("a1", "S:later", True, root=self.root)
server.done.clear()
server.responses = [{"commands": [], "blacklist_version": 7}] * 2
self.assertTrue(server.done.wait(10))
channel.stop()
self.assertGreater(len(server.syncs), before)
self.assertEqual(cb.local_states(self.root).get(("a1", "S:later")), "pending")
def test_no_spin_while_another_sync_is_in_flight(self):
"""本机刚拉黑触发的同步还没回来时,长轮询因版本变化立即返回,不能空转。"""
server = FakeServer([])
server.version = 7
channel, _executed = self.start_channel(server)
self.assertTrue(server.done.wait(10))
server.sync_delay = 1.0
server.version = 8
server.responses = [{"commands": [], "blacklist_version": 8}] * 200
syncs_before = len(server.syncs)
channel.sync_blacklist_soon()
time.sleep(0.15)
polls_before = len(server.polls)
time.sleep(0.7) # 帮手线程的同步还在进行
polls_during = len(server.polls) - polls_before
time.sleep(0.6)
channel.stop()
self.assertLessEqual(polls_during, 2, "同步进行中不应反复长轮询")
self.assertEqual(len(server.syncs) - syncs_before, 1, "等到的那一轮已对齐版本,不该再同步一次")
def test_timed_out_commands_are_cancelled_so_they_never_run_late(self):
import concurrent.futures
future = concurrent.futures.Future()
outcome = remote_control.future_result(future, timeout=0.05)
self.assertFalse(outcome["ok"])
self.assertTrue(future.cancelled())
if __name__ == "__main__":
unittest.main()