更新
This commit is contained in:
@@ -112,6 +112,40 @@ class AccountPaginationTests(unittest.IsolatedAsyncioTestCase):
|
||||
finally:
|
||||
main.manager.workers = original_workers
|
||||
|
||||
async def test_account_channel_change_stops_running_worker(self):
|
||||
account = SimpleNamespace(
|
||||
id=2202,
|
||||
egress_public_ip="116.62.23.103",
|
||||
status="online",
|
||||
)
|
||||
db = SimpleNamespace(
|
||||
commit=AsyncMock(),
|
||||
refresh=AsyncMock(),
|
||||
execute=AsyncMock(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(main, "get_owned_account", AsyncMock(return_value=account)),
|
||||
patch.object(main.manager, "is_running", return_value=True),
|
||||
patch.object(main.manager, "stop_worker", AsyncMock(return_value=True)) as stop,
|
||||
patch.object(main, "_build_account_response", return_value={"id": 2202}),
|
||||
):
|
||||
response = await main.update_account(
|
||||
account_id=2202,
|
||||
body=main.AccountUpdate(egress_public_ip="47.96.154.74"),
|
||||
db=db,
|
||||
user=SimpleNamespace(id=7, role="operator"),
|
||||
)
|
||||
|
||||
self.assertEqual(response, {"id": 2202})
|
||||
self.assertEqual(account.egress_public_ip, "47.96.154.74")
|
||||
stop.assert_awaited_once_with(2202)
|
||||
self.assertEqual(db.commit.await_count, 2)
|
||||
values = db.execute.await_args.args[0].compile().params
|
||||
self.assertIn("已保留登录凭证", values["error_message"])
|
||||
self.assertNotIn("cookie_data", values)
|
||||
self.assertNotIn("im_session_data", values)
|
||||
|
||||
async def test_log_stats_uses_one_aggregate_and_respects_ownership(self):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as connection:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
@@ -262,6 +263,95 @@ class BatchStartApiTests(unittest.IsolatedAsyncioTestCase):
|
||||
# return the connection: one before validation, one before the wait.
|
||||
self.assertEqual(events, ["release", "assess", "release", "start-worker"])
|
||||
|
||||
async def test_changed_egress_preserves_valid_credentials(self):
|
||||
ready_assessment = {
|
||||
"login_mode": "im_direct",
|
||||
"should_reset": False,
|
||||
"can_skip_browser": True,
|
||||
"message": "ready",
|
||||
"cookie_valid": True,
|
||||
"im_ready": True,
|
||||
}
|
||||
scenarios = (
|
||||
({"cookies": []}, "47.96.154.74"),
|
||||
({"cookies": [], "credential_egress_public_ip": "116.62.23.103"}, "47.96.154.74"),
|
||||
({"cookies": [], "credential_egress_public_ip": "47.96.154.74"}, ""),
|
||||
)
|
||||
modes = (("im_direct", False), (None, False), (None, True))
|
||||
for storage, selected_ip in scenarios:
|
||||
for requested_mode, wait_for_ready in modes:
|
||||
with self.subTest(storage=storage, mode=requested_mode, batch=wait_for_ready):
|
||||
cookie_data = json.dumps(storage)
|
||||
account = SimpleNamespace(
|
||||
id=506,
|
||||
status="offline",
|
||||
qr_code_base64=None,
|
||||
error_message="old channel warning",
|
||||
cookie_data=cookie_data,
|
||||
im_session_data="saved-session",
|
||||
egress_public_ip=selected_ip,
|
||||
)
|
||||
db = SimpleNamespace(commit=AsyncMock())
|
||||
with (
|
||||
patch.object(main.manager, "is_running", return_value=False),
|
||||
patch.object(main.manager, "start_worker", AsyncMock(return_value=True)) as start,
|
||||
patch.object(main, "_get_account_cookie_data", return_value=cookie_data),
|
||||
patch.object(main, "_reset_account_credentials", AsyncMock()) as reset,
|
||||
patch.object(main, "assess_account_credential", AsyncMock(return_value=ready_assessment)) as assess,
|
||||
):
|
||||
result = await main._start_account_rpa_impl(
|
||||
account, db, requested_mode, wait_for_ready=wait_for_ready
|
||||
)
|
||||
|
||||
reset.assert_not_awaited()
|
||||
assess.assert_awaited_once_with(
|
||||
cookie_data, "saved-session",
|
||||
startup_priority=True, egress_public_ip=selected_ip,
|
||||
)
|
||||
start.assert_awaited_once_with(
|
||||
506, login_mode="im_direct",
|
||||
wait_until_ready=wait_for_ready, credential_prevalidated=True,
|
||||
)
|
||||
self.assertEqual(account.cookie_data, cookie_data)
|
||||
self.assertEqual(account.im_session_data, "saved-session")
|
||||
self.assertIsNone(account.error_message)
|
||||
self.assertTrue(result["skip_qr"])
|
||||
self.assertTrue(result["skip_browser"])
|
||||
|
||||
async def test_changed_egress_still_rejects_invalid_im_credentials(self):
|
||||
account = SimpleNamespace(
|
||||
id=506,
|
||||
status="offline",
|
||||
qr_code_base64=None,
|
||||
error_message=None,
|
||||
im_session_data="saved-session",
|
||||
egress_public_ip="47.96.154.74",
|
||||
)
|
||||
db = SimpleNamespace(commit=AsyncMock())
|
||||
invalid_assessment = {
|
||||
"login_mode": "browser",
|
||||
"should_reset": False,
|
||||
"can_skip_browser": False,
|
||||
"message": "缺少 IM 签名密钥(web_protect/keys),请用浏览器登录补全",
|
||||
"cookie_valid": True,
|
||||
"im_ready": False,
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(main.manager, "is_running", return_value=False),
|
||||
patch.object(main.manager, "start_worker", AsyncMock()) as start,
|
||||
patch.object(main, "_get_account_cookie_data", return_value='{"cookies": []}'),
|
||||
patch.object(main, "_reset_account_credentials", AsyncMock()) as reset,
|
||||
patch.object(main, "assess_account_credential", AsyncMock(return_value=invalid_assessment)),
|
||||
):
|
||||
with self.assertRaises(main.HTTPException) as error:
|
||||
await main._start_account_rpa_impl(account, db, "im_direct")
|
||||
|
||||
self.assertEqual(error.exception.status_code, 400)
|
||||
self.assertEqual(error.exception.detail, invalid_assessment["message"])
|
||||
reset.assert_not_awaited()
|
||||
start.assert_not_awaited()
|
||||
|
||||
async def test_batch_start_does_not_launch_interactive_browser_login(self):
|
||||
account = SimpleNamespace(
|
||||
id=504,
|
||||
|
||||
@@ -3,10 +3,26 @@ import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from rpa_engine.credential import validate_im_session
|
||||
from rpa_engine.credential import credential_egress_mismatch, validate_im_session
|
||||
|
||||
|
||||
class CredentialResponsivenessTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_legacy_egress_marker_comparison_is_diagnostic(self):
|
||||
legacy = '{"cookies": []}'
|
||||
|
||||
self.assertFalse(credential_egress_mismatch(legacy, ""))
|
||||
self.assertTrue(credential_egress_mismatch(legacy, "47.96.154.74"))
|
||||
|
||||
def test_stamped_egress_marker_comparison(self):
|
||||
stamped = (
|
||||
'{"cookies": [], '
|
||||
'"credential_egress_public_ip": "47.96.154.74"}'
|
||||
)
|
||||
|
||||
self.assertFalse(credential_egress_mismatch(stamped, "47.96.154.74"))
|
||||
self.assertTrue(credential_egress_mismatch(stamped, "116.62.23.103"))
|
||||
self.assertTrue(credential_egress_mismatch(stamped, ""))
|
||||
|
||||
async def test_uid_lookup_does_not_block_event_loop(self):
|
||||
event_loop_thread_id = threading.get_ident()
|
||||
lookup_thread_ids = []
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""托管多个账号时的会话归属隔离回归测试。
|
||||
|
||||
复现的缺陷:账号 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()
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
@@ -25,6 +26,7 @@ from rpa_engine.egress_channels import (
|
||||
reset_egress_cache_for_tests,
|
||||
resolve_send_channels,
|
||||
)
|
||||
from rpa_engine.source_bound_proxy import SourceBoundProxy
|
||||
from models.db_migrate import migrate_accounts_table
|
||||
from models.models import Account
|
||||
|
||||
@@ -99,6 +101,49 @@ class EgressChannelTests(unittest.IsolatedAsyncioTestCase):
|
||||
with self.assertRaises(EgressChannelUnavailable):
|
||||
await resolve_send_channels("198.51.100.99", 2)
|
||||
|
||||
async def test_browser_proxy_binds_selected_source_address(self):
|
||||
observed_peer = asyncio.get_running_loop().create_future()
|
||||
|
||||
async def target_handler(reader, writer):
|
||||
if not observed_peer.done():
|
||||
observed_peer.set_result(writer.get_extra_info("peername")[0])
|
||||
payload = await reader.readexactly(4)
|
||||
writer.write(payload)
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
target = await asyncio.start_server(target_handler, "127.0.0.1", 0)
|
||||
target_port = target.sockets[0].getsockname()[1]
|
||||
proxy = await SourceBoundProxy("127.0.0.2").start()
|
||||
writer = None
|
||||
try:
|
||||
reader, writer = await asyncio.open_connection(
|
||||
"127.0.0.1",
|
||||
int(proxy.server_url.rpartition(":")[2]),
|
||||
)
|
||||
writer.write(
|
||||
(
|
||||
f"CONNECT 127.0.0.1:{target_port} HTTP/1.1\r\n"
|
||||
f"Host: 127.0.0.1:{target_port}\r\n\r\n"
|
||||
).encode("ascii")
|
||||
)
|
||||
await writer.drain()
|
||||
response = await reader.readuntil(b"\r\n\r\n")
|
||||
self.assertIn(b"200 Connection Established", response)
|
||||
|
||||
writer.write(b"ping")
|
||||
await writer.drain()
|
||||
self.assertEqual(await reader.readexactly(4), b"ping")
|
||||
self.assertEqual(await asyncio.wait_for(observed_peer, 1), "127.0.0.2")
|
||||
finally:
|
||||
if writer is not None:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
await proxy.close()
|
||||
target.close()
|
||||
await target.wait_closed()
|
||||
|
||||
|
||||
class EgressMigrationTests(unittest.TestCase):
|
||||
def test_mysql_accounts_uses_longtext_for_browser_payloads(self):
|
||||
|
||||
@@ -18,6 +18,8 @@ if str(BACKEND_DIR) not in sys.path:
|
||||
|
||||
from auth.system_settings import SystemSettingsData, set_cached_settings
|
||||
from rpa_engine.douyin_im.service import DouyinImService
|
||||
from rpa_engine.douyin_im.session import DouyinImSession
|
||||
from rpa_engine.douyin_im import service as service_module
|
||||
from rpa_engine.playwright_worker import DouyinWorker
|
||||
|
||||
|
||||
@@ -114,6 +116,74 @@ def _build_service(delay_seconds: int = 60):
|
||||
|
||||
|
||||
class ReplyQueueIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_kick_does_not_replay_through_browser_fallback(self):
|
||||
callback = AsyncMock()
|
||||
fallback = AsyncMock(return_value=(True, "must not run"))
|
||||
session = DouyinImSession(cookies={"sessionid": "test"}, my_uid=999)
|
||||
service = DouyinImService(
|
||||
session=session,
|
||||
match_reply=AsyncMock(),
|
||||
log_fn=AsyncMock(),
|
||||
account_id=1,
|
||||
send_fallback=fallback,
|
||||
on_session_invalid=callback,
|
||||
)
|
||||
service._running = True
|
||||
kicked_http = SimpleNamespace(
|
||||
send_text_message=AsyncMock(return_value=False),
|
||||
last_error="decision=KICK",
|
||||
last_send_needs_refresh=False,
|
||||
)
|
||||
context = AsyncMock()
|
||||
context.__aenter__.return_value = kicked_http
|
||||
context.__aexit__.return_value = None
|
||||
|
||||
with (
|
||||
unittest.mock.patch.object(
|
||||
service_module, "DouyinImHttpClient", return_value=context
|
||||
),
|
||||
unittest.mock.patch(
|
||||
"rpa_engine.douyin_im.service.system_logger.record", Mock()
|
||||
),
|
||||
):
|
||||
sent, _ = await service._send_text("0:1:999:123", "hello")
|
||||
|
||||
self.assertFalse(sent)
|
||||
fallback.assert_not_awaited()
|
||||
callback.assert_awaited_once()
|
||||
|
||||
async def test_fresh_session_replaces_send_and_ws_state_atomically(self):
|
||||
current = DouyinImSession(
|
||||
cookies={"sessionid": "old"},
|
||||
my_uid=999,
|
||||
conv_meta={"old": {"ticket": "one"}},
|
||||
)
|
||||
current.egress_public_ip = "203.0.113.10"
|
||||
current.egress_source_ip = "10.0.0.10"
|
||||
current.egress_auto_attempts = 2
|
||||
service = DouyinImService(
|
||||
session=current,
|
||||
match_reply=AsyncMock(),
|
||||
log_fn=AsyncMock(),
|
||||
account_id=1,
|
||||
)
|
||||
service._ws_client = SimpleNamespace(session=current)
|
||||
fresh = DouyinImSession(
|
||||
cookies={"sessionid": "fresh"},
|
||||
my_uid=999,
|
||||
conv_meta={"new": {"ticket": "two"}},
|
||||
)
|
||||
|
||||
await service.replace_session(fresh)
|
||||
|
||||
self.assertIs(service.session, fresh)
|
||||
self.assertIs(service._ws_client.session, fresh)
|
||||
self.assertEqual(service.session.cookies["sessionid"], "fresh")
|
||||
self.assertEqual(set(service.session.conv_meta), {"old", "new"})
|
||||
self.assertEqual(service.session.egress_public_ip, "203.0.113.10")
|
||||
self.assertEqual(service.session.egress_source_ip, "10.0.0.10")
|
||||
self.assertEqual(service.session.egress_auto_attempts, 2)
|
||||
|
||||
async def test_kick_response_takes_account_offline_immediately(self):
|
||||
callback = AsyncMock()
|
||||
service, _, _ = _build_service()
|
||||
|
||||
@@ -205,7 +205,7 @@ class SendTextMessageEntryTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertTrue(client.last_send_needs_refresh)
|
||||
self.assertEqual(client.last_request_debug, "response status=401")
|
||||
|
||||
async def test_retryable_rejection_switches_channels_serially(self):
|
||||
async def test_retryable_network_failure_switches_channels_serially(self):
|
||||
client = self._make_client(account_id=89)
|
||||
client.session.egress_auto_attempts = 2
|
||||
routes = [
|
||||
@@ -216,7 +216,7 @@ class SendTextMessageEntryTests(unittest.IsolatedAsyncioTestCase):
|
||||
first = SimpleNamespace(
|
||||
send_text_message=AsyncMock(return_value=False),
|
||||
last_send_meta={},
|
||||
last_error="decision=KICK",
|
||||
last_error="connect timeout",
|
||||
last_send_needs_refresh=False,
|
||||
last_send_channel_retryable=True,
|
||||
last_request_debug="first route",
|
||||
@@ -264,8 +264,82 @@ class SendTextMessageEntryTests(unittest.IsolatedAsyncioTestCase):
|
||||
second.send_text_message.assert_awaited_once()
|
||||
self.assertEqual(client.last_request_debug, "second route")
|
||||
|
||||
async def test_kick_never_switches_public_channels(self):
|
||||
client = self._make_client(account_id=90)
|
||||
client.session.egress_auto_attempts = 2
|
||||
routes = [
|
||||
EgressChannel("198.51.100.10", "10.0.0.10", "eth0", True),
|
||||
EgressChannel("198.51.100.11", "10.0.0.11", "eth1", False),
|
||||
]
|
||||
kicked = SimpleNamespace(
|
||||
send_text_message=AsyncMock(return_value=False),
|
||||
last_send_meta={},
|
||||
last_error="decision=KICK",
|
||||
last_send_needs_refresh=False,
|
||||
last_send_channel_retryable=False,
|
||||
last_request_debug="terminal kick",
|
||||
)
|
||||
context = MagicMock()
|
||||
context.__aenter__ = AsyncMock(return_value=kicked)
|
||||
context.__aexit__ = AsyncMock(return_value=None)
|
||||
queued_factory = MagicMock(return_value=context)
|
||||
|
||||
async def execute_submission(account_id, operation, description=""):
|
||||
return await operation()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"rpa_engine.douyin_im.traffic_control.submit_outbound",
|
||||
AsyncMock(side_effect=execute_submission),
|
||||
),
|
||||
patch(
|
||||
"rpa_engine.douyin_im.http_client.resolve_send_channels",
|
||||
AsyncMock(return_value=routes),
|
||||
),
|
||||
patch.object(http_client_module, "DouyinImHttpClient", queued_factory),
|
||||
):
|
||||
sent = await client.send_text_message("0:1:10001:20002", "hello")
|
||||
|
||||
self.assertFalse(sent)
|
||||
queued_factory.assert_called_once()
|
||||
kicked.send_text_message.assert_awaited_once()
|
||||
|
||||
|
||||
class WorkerLifecycleTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_browser_launch_uses_selected_source_proxy(self):
|
||||
launch = AsyncMock(return_value="browser")
|
||||
pw = SimpleNamespace(chromium=SimpleNamespace(launch=launch))
|
||||
source_proxy = AsyncMock(return_value={"server": "http://127.0.0.1:43210"})
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
playwright_worker_module,
|
||||
"ensure_browser_display",
|
||||
AsyncMock(),
|
||||
),
|
||||
patch.object(
|
||||
playwright_worker_module,
|
||||
"playwright_proxy_for_source",
|
||||
source_proxy,
|
||||
),
|
||||
patch.object(playwright_worker_module, "playwright_proxy") as global_proxy,
|
||||
):
|
||||
browser = await playwright_worker_module._launch_chromium(
|
||||
pw,
|
||||
["--no-sandbox"],
|
||||
headless=True,
|
||||
source_ip="10.0.0.6",
|
||||
)
|
||||
|
||||
self.assertEqual(browser, "browser")
|
||||
source_proxy.assert_awaited_once_with("10.0.0.6")
|
||||
global_proxy.assert_not_called()
|
||||
launch.assert_awaited_once_with(
|
||||
headless=True,
|
||||
args=["--no-sandbox"],
|
||||
proxy={"server": "http://127.0.0.1:43210"},
|
||||
)
|
||||
|
||||
async def test_virtual_display_starts_before_playwright_driver(self):
|
||||
ensure_display = AsyncMock()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user