295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""托管多个账号时的会话归属隔离回归测试。
|
|
|
|
复现的缺陷:账号 A 的处理链路收到属于账号 B 的会话(0:1:B:B的好友)后,
|
|
resolve_peer_uid 把末段当成「对方」、normalize_conversation_id 再拼成
|
|
0:1:A:B的好友,于是账号 A 用自己的凭证把自动回复发给了账号 B 的好友。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
|
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
|
os.environ.setdefault("KEFU_DB_TYPE", "sqlite")
|
|
os.environ.setdefault("KEFU_DATABASE_URL", "")
|
|
os.environ.setdefault("KEFU_DB_PATH", str(BACKEND_DIR / "kefu.db"))
|
|
if str(BACKEND_DIR) not in sys.path:
|
|
sys.path.insert(0, str(BACKEND_DIR))
|
|
|
|
from rpa_engine.douyin_im import hosted_registry
|
|
from rpa_engine.douyin_im import ws_client as ws_module
|
|
from rpa_engine.douyin_im.conv_util import conversation_belongs_to
|
|
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
|
|
from rpa_engine.douyin_im.service import DouyinImService
|
|
from rpa_engine.douyin_im.session import DouyinImSession
|
|
from rpa_engine.douyin_im.ws_client import DouyinImWsClient
|
|
|
|
ACCOUNT_A_UID = 7670159096859706425
|
|
ACCOUNT_B_UID = 7670157997767050299
|
|
PEER_OF_B = 66578464308
|
|
|
|
|
|
class ConversationOwnershipTests(unittest.TestCase):
|
|
def test_foreign_single_chat_is_rejected(self):
|
|
self.assertFalse(
|
|
conversation_belongs_to(
|
|
f"0:1:{ACCOUNT_B_UID}:{PEER_OF_B}", ACCOUNT_A_UID
|
|
)
|
|
)
|
|
|
|
def test_own_conversation_in_either_position(self):
|
|
self.assertTrue(
|
|
conversation_belongs_to(f"0:1:{ACCOUNT_A_UID}:{PEER_OF_B}", ACCOUNT_A_UID)
|
|
)
|
|
self.assertTrue(
|
|
conversation_belongs_to(f"0:1:{PEER_OF_B}:{ACCOUNT_A_UID}", ACCOUNT_A_UID)
|
|
)
|
|
|
|
def test_undecidable_shapes_pass_through(self):
|
|
# 缺 my_uid / 群聊 / 裸 UID:本来就判不了归属,保守放行
|
|
self.assertTrue(conversation_belongs_to(f"0:1:{ACCOUNT_B_UID}:{PEER_OF_B}", 0))
|
|
self.assertTrue(conversation_belongs_to("0:2:123:456", ACCOUNT_A_UID))
|
|
self.assertTrue(conversation_belongs_to(str(PEER_OF_B), ACCOUNT_A_UID))
|
|
self.assertTrue(conversation_belongs_to("", ACCOUNT_A_UID))
|
|
|
|
|
|
class ForeignMessageDropTests(unittest.IsolatedAsyncioTestCase):
|
|
def _service(self) -> DouyinImService:
|
|
service = DouyinImService(
|
|
session=DouyinImSession(cookies={"sessionid": "a"}, my_uid=ACCOUNT_A_UID),
|
|
match_reply=AsyncMock(return_value=["自动回复"]),
|
|
log_fn=AsyncMock(),
|
|
account_id=1,
|
|
)
|
|
service._running = True
|
|
return service
|
|
|
|
async def test_message_from_another_account_never_schedules_a_reply(self):
|
|
service = self._service()
|
|
service._resolve_peer_profile = AsyncMock(
|
|
return_value=("B 的好友", "", str(PEER_OF_B))
|
|
)
|
|
|
|
with patch(
|
|
"rpa_engine.douyin_im.service.system_logger.record", Mock()
|
|
) as record:
|
|
result = await service._prepare_incoming(
|
|
{
|
|
"conversation_id": f"0:1:{ACCOUNT_B_UID}:{PEER_OF_B}",
|
|
"sender_uid": str(PEER_OF_B),
|
|
"content": "在吗",
|
|
"server_message_id": "7665317099296081465",
|
|
}
|
|
)
|
|
|
|
self.assertIsNone(result)
|
|
service.match_reply.assert_not_awaited()
|
|
service.log_fn.assert_not_awaited()
|
|
self.assertEqual(service._conv_meta, {})
|
|
self.assertTrue(record.called)
|
|
|
|
async def test_own_message_is_still_processed(self):
|
|
service = self._service()
|
|
conv_id = f"0:1:{ACCOUNT_A_UID}:{PEER_OF_B}"
|
|
service._resolve_peer_profile = AsyncMock(
|
|
return_value=("我的好友", "", str(PEER_OF_B))
|
|
)
|
|
service._resolve_cooldown_seconds = AsyncMock(return_value=0)
|
|
service._resolve_reply_delay_seconds = AsyncMock(return_value=0)
|
|
service._send_auto_reply = AsyncMock()
|
|
|
|
with patch("rpa_engine.douyin_im.service.system_logger.record", Mock()):
|
|
send_reply = await service._prepare_incoming(
|
|
{
|
|
"conversation_id": conv_id,
|
|
"sender_uid": str(PEER_OF_B),
|
|
"content": "在吗",
|
|
"server_message_id": "7665317099296081466",
|
|
}
|
|
)
|
|
|
|
self.assertIsNotNone(send_reply)
|
|
service.match_reply.assert_awaited()
|
|
self.assertIn(conv_id, service._conv_meta)
|
|
|
|
|
|
class ForeignSendRefusalTests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_send_refuses_a_conversation_owned_by_another_account(self):
|
|
client = DouyinImHttpClient(
|
|
DouyinImSession(cookies={"sessionid": "a"}, my_uid=ACCOUNT_A_UID),
|
|
account_id=1,
|
|
)
|
|
resolve_meta = AsyncMock()
|
|
|
|
with (
|
|
patch.object(
|
|
DouyinImHttpClient,
|
|
"_resolve_authoritative_uid",
|
|
return_value=ACCOUNT_A_UID,
|
|
),
|
|
patch.object(
|
|
DouyinImHttpClient, "resolve_conversation_meta", resolve_meta
|
|
),
|
|
patch("rpa_engine.douyin_im.http_client.system_logger.record", Mock()),
|
|
):
|
|
sent = await client.send_text_message(
|
|
f"0:1:{ACCOUNT_B_UID}:{PEER_OF_B}",
|
|
"你好",
|
|
_bypass_global_queue=True,
|
|
)
|
|
|
|
self.assertFalse(sent)
|
|
# 关键断言:拒发必须发生在解析 ticket / 真正写出去之前
|
|
resolve_meta.assert_not_awaited()
|
|
self.assertIn("不是本账号", client.last_error)
|
|
self.assertFalse(client.last_send_channel_retryable)
|
|
|
|
|
|
class HostedPeerLoopTests(unittest.IsolatedAsyncioTestCase):
|
|
"""两个本系统托管的账号之间不得互相自动回复(无限回环 → 抖音风控)。"""
|
|
|
|
def _service(self) -> DouyinImService:
|
|
service = DouyinImService(
|
|
session=DouyinImSession(cookies={"sessionid": "a"}, my_uid=ACCOUNT_A_UID),
|
|
match_reply=AsyncMock(return_value=["自动回复"]),
|
|
log_fn=AsyncMock(),
|
|
account_id=1,
|
|
)
|
|
service._running = True
|
|
service._resolve_cooldown_seconds = AsyncMock(return_value=0)
|
|
service._resolve_reply_delay_seconds = AsyncMock(return_value=0)
|
|
return service
|
|
|
|
def tearDown(self):
|
|
hosted_registry.unregister(ACCOUNT_B_UID)
|
|
|
|
async def _incoming_from(self, service, peer_uid: int, message_id: str):
|
|
service._resolve_peer_profile = AsyncMock(
|
|
return_value=("对方", "", str(peer_uid))
|
|
)
|
|
with patch("rpa_engine.douyin_im.service.system_logger.record", Mock()):
|
|
return await service._prepare_incoming(
|
|
{
|
|
"conversation_id": f"0:1:{ACCOUNT_A_UID}:{peer_uid}",
|
|
"sender_uid": str(peer_uid),
|
|
"content": "在吗",
|
|
"server_message_id": message_id,
|
|
}
|
|
)
|
|
|
|
async def test_no_auto_reply_to_another_hosted_account(self):
|
|
hosted_registry.register(ACCOUNT_B_UID)
|
|
service = self._service()
|
|
|
|
result = await self._incoming_from(service, ACCOUNT_B_UID, "1")
|
|
|
|
self.assertIsNone(result)
|
|
service.match_reply.assert_not_awaited()
|
|
# 消息本身照常入库,只是标记为未回复
|
|
statuses = [
|
|
call.kwargs.get("status") for call in service.log_fn.await_args_list
|
|
]
|
|
self.assertIn("received", statuses)
|
|
self.assertIn("ignored", statuses)
|
|
|
|
async def test_ordinary_follower_still_gets_a_reply(self):
|
|
hosted_registry.register(ACCOUNT_B_UID)
|
|
service = self._service()
|
|
service._send_auto_reply = AsyncMock()
|
|
|
|
result = await self._incoming_from(service, PEER_OF_B, "2")
|
|
|
|
self.assertIsNotNone(result)
|
|
service.match_reply.assert_awaited()
|
|
|
|
|
|
class FrontierDeviceExclusivityTests(unittest.IsolatedAsyncioTestCase):
|
|
"""同一个 frontier 设备号同时只允许一个账号建连。"""
|
|
|
|
WS_URL = (
|
|
"wss://frontier-im.douyin.com/ws/v2?fpid=9&device_id=987654321&"
|
|
"token=shared-token"
|
|
)
|
|
|
|
def setUp(self):
|
|
ws_module._FRONTIER_DEVICE_OWNERS.clear()
|
|
|
|
def tearDown(self):
|
|
ws_module._FRONTIER_DEVICE_OWNERS.clear()
|
|
|
|
def _client(self, account_id: int) -> DouyinImWsClient:
|
|
client = DouyinImWsClient(
|
|
DouyinImSession(cookies={"sessionid": "s"}, ws_urls=[self.WS_URL]),
|
|
AsyncMock(),
|
|
account_id=account_id,
|
|
)
|
|
client._running = True
|
|
client._task = SimpleNamespace(done=lambda: False)
|
|
return client
|
|
|
|
def test_second_account_is_denied_while_the_first_holds_the_device(self):
|
|
first = self._client(11)
|
|
second = self._client(12)
|
|
|
|
self.assertTrue(first._claim_frontier_device(self.WS_URL))
|
|
self.assertFalse(second._claim_frontier_device(self.WS_URL))
|
|
self.assertEqual(second._blocked_device_owner_id, 11)
|
|
# 让出方不会被误标为已占用,重连时仍是 HTTP 轮询兜底
|
|
self.assertFalse(second.connected)
|
|
|
|
def test_device_is_taken_over_after_the_owner_stops(self):
|
|
first = self._client(11)
|
|
second = self._client(12)
|
|
self.assertTrue(first._claim_frontier_device(self.WS_URL))
|
|
|
|
first._running = False
|
|
first._release_frontier_device()
|
|
|
|
self.assertTrue(second._claim_frontier_device(self.WS_URL))
|
|
|
|
def test_same_account_reconnect_keeps_its_own_device(self):
|
|
client = self._client(11)
|
|
self.assertTrue(client._claim_frontier_device(self.WS_URL))
|
|
self.assertTrue(client._claim_frontier_device(self.WS_URL))
|
|
|
|
async def test_run_loop_does_not_open_a_second_connection(self):
|
|
owner = self._client(11)
|
|
self.assertTrue(owner._claim_frontier_device(self.WS_URL))
|
|
|
|
blocked = self._client(12)
|
|
blocked._prepare_url = AsyncMock(return_value=self.WS_URL)
|
|
run_connection = AsyncMock()
|
|
blocked._run_connection = run_connection
|
|
|
|
async def stop_after_first_backoff(_seconds):
|
|
blocked._running = False
|
|
|
|
with (
|
|
patch.object(ws_module, "_reconnect_delay", return_value=0.0),
|
|
patch.object(ws_module.system_logger, "record") as record,
|
|
patch.object(ws_module.asyncio, "sleep", stop_after_first_backoff),
|
|
):
|
|
await asyncio.wait_for(blocked._run_loop(self.WS_URL), timeout=1.0)
|
|
|
|
run_connection.assert_not_awaited()
|
|
self.assertFalse(blocked.connected)
|
|
self.assertTrue(record.called)
|
|
|
|
def test_url_without_device_id_is_not_blocked(self):
|
|
first = self._client(11)
|
|
second = self._client(12)
|
|
url = "wss://frontier-im.douyin.com/ws/v2?fpid=9&token=t"
|
|
|
|
self.assertTrue(first._claim_frontier_device(url))
|
|
self.assertTrue(second._claim_frontier_device(url))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|