345 lines
17 KiB
Python
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()
|