更新
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -18,13 +20,114 @@ if str(BACKEND_DIR) not in sys.path:
|
||||
|
||||
from rpa_engine.douyin_im.session import DouyinImSession
|
||||
from rpa_engine import account_profile as account_profile_module
|
||||
from rpa_engine.credential import build_im_session_from_storage
|
||||
from rpa_engine.playwright_worker import DouyinWorker
|
||||
|
||||
|
||||
class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_im_direct_missing_sec_user_id_exits_before_online_state(self):
|
||||
async def test_current_profile_uid_overrides_browser_runtime_uid(self):
|
||||
now = datetime.utcnow()
|
||||
row = SimpleNamespace(
|
||||
im_session_data=None,
|
||||
cookie_updated_at=now,
|
||||
uid="2609567359568155",
|
||||
profile_updated_at=now,
|
||||
)
|
||||
result = MagicMock()
|
||||
result.one_or_none.return_value = row
|
||||
db = SimpleNamespace(
|
||||
execute=AsyncMock(return_value=result),
|
||||
close=AsyncMock(),
|
||||
)
|
||||
worker = DouyinWorker(account_id=300, login_mode="im_direct")
|
||||
worker.get_db = AsyncMock(return_value=db)
|
||||
worker._load_raw_user_agent = AsyncMock(return_value="test-agent")
|
||||
storage = {
|
||||
"cookies": [{"name": "sessionid", "value": "test-session"}],
|
||||
"my_uid": 7678285795559818786,
|
||||
"origins": [
|
||||
{
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "web_runtime_security_uid",
|
||||
"value": "7678285795559818786",
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
session = await worker._build_im_session_from_storage(storage)
|
||||
|
||||
self.assertEqual(session.my_uid, 2609567359568155)
|
||||
# device_id 必须与权威 UID 同步(protobuf 发送时 device_id 优先取
|
||||
# session.device_id,残留的浏览器采集值会导致 device_id != my_uid -> KICK)
|
||||
self.assertEqual(session.device_id, "2609567359568155")
|
||||
self.assertTrue(session.uid_verified)
|
||||
db.close.assert_awaited_once()
|
||||
|
||||
async def test_stale_profile_uid_does_not_override_new_cookie(self):
|
||||
now = datetime.utcnow()
|
||||
row = SimpleNamespace(
|
||||
im_session_data=None,
|
||||
cookie_updated_at=now,
|
||||
uid="2609567359568155",
|
||||
profile_updated_at=now - timedelta(seconds=1),
|
||||
)
|
||||
result = MagicMock()
|
||||
result.one_or_none.return_value = row
|
||||
db = SimpleNamespace(
|
||||
execute=AsyncMock(return_value=result),
|
||||
close=AsyncMock(),
|
||||
)
|
||||
worker = DouyinWorker(account_id=300, login_mode="im_direct")
|
||||
worker.get_db = AsyncMock(return_value=db)
|
||||
worker._load_raw_user_agent = AsyncMock(return_value="test-agent")
|
||||
|
||||
session = await worker._build_im_session_from_storage(
|
||||
{
|
||||
"cookies": [{"name": "sessionid", "value": "test-session"}],
|
||||
"my_uid": 7678285795559818786,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(session.my_uid, 7678285795559818786)
|
||||
self.assertFalse(session.uid_verified)
|
||||
|
||||
def test_persisted_verified_uid_wins_in_generic_session_builder(self):
|
||||
saved = DouyinImSession(
|
||||
cookies={"sessionid": "test-session"},
|
||||
my_uid=2609567359568155,
|
||||
device_id="7678285795559818786",
|
||||
uid_verified=True,
|
||||
)
|
||||
|
||||
session = build_im_session_from_storage(
|
||||
{
|
||||
"cookies": [{"name": "sessionid", "value": "test-session"}],
|
||||
"my_uid": 7678285795559818786,
|
||||
"origins": [
|
||||
{
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "web_runtime_security_uid",
|
||||
"value": "7678285795559818786",
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
json.dumps(saved.to_dict()),
|
||||
)
|
||||
|
||||
self.assertEqual(session.my_uid, 2609567359568155)
|
||||
# device_id 同步为已核验 UID,避免凭证残留设备号导致 device_id != my_uid
|
||||
self.assertEqual(session.device_id, "2609567359568155")
|
||||
self.assertTrue(session.uid_verified)
|
||||
|
||||
async def test_im_direct_missing_sec_user_id_continues_im_hosting(self):
|
||||
worker = DouyinWorker(account_id=301, login_mode="im_direct")
|
||||
worker._require_sec_user_id = AsyncMock(return_value=False)
|
||||
worker._best_effort_sec_user_id = AsyncMock(return_value="")
|
||||
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
||||
im_session = DouyinImSession(
|
||||
cookies={"sessionid": "test-session"},
|
||||
@@ -42,37 +145,34 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
{"cookies": [{"name": "sessionid", "value": "test-session"}]}
|
||||
)
|
||||
|
||||
self.assertFalse(started)
|
||||
self.assertIn("sec_user_id", reason)
|
||||
worker._require_sec_user_id.assert_awaited_once()
|
||||
require_call = worker._require_sec_user_id.await_args
|
||||
self.assertTrue(require_call.kwargs["refresh_if_missing"])
|
||||
self.assertTrue(require_call.kwargs["refresh_if_stale"])
|
||||
self.assertTrue(started)
|
||||
self.assertEqual(reason, "")
|
||||
worker._best_effort_sec_user_id.assert_awaited_once_with(
|
||||
refresh_if_missing=True,
|
||||
refresh_if_stale=True,
|
||||
)
|
||||
worker._build_im_session_from_storage.assert_awaited_once()
|
||||
validate.assert_awaited_once_with(im_session)
|
||||
worker._persist_im_session.assert_not_awaited()
|
||||
worker._run_im_direct_service.assert_not_awaited()
|
||||
worker._persist_im_session.assert_awaited_once()
|
||||
worker._run_im_direct_service.assert_awaited_once_with(im_session)
|
||||
|
||||
async def test_running_guard_precedes_disabled_follow_welcome_setting(self):
|
||||
async def test_disabled_follow_welcome_does_not_require_identity(self):
|
||||
worker = DouyinWorker(account_id=302, login_mode="im_direct")
|
||||
worker.is_running = True
|
||||
worker._im_service = SimpleNamespace(_running=True)
|
||||
worker._require_sec_user_id = AsyncMock(return_value=False)
|
||||
worker._refresh_follow_welcome_config = AsyncMock()
|
||||
worker._best_effort_sec_user_id = AsyncMock()
|
||||
worker._refresh_follow_welcome_config = AsyncMock(
|
||||
return_value=(False, "", "")
|
||||
)
|
||||
worker.get_db = AsyncMock()
|
||||
|
||||
await worker.follow_welcome_tick()
|
||||
|
||||
worker._require_sec_user_id.assert_awaited_once()
|
||||
require_call = worker._require_sec_user_id.await_args
|
||||
self.assertFalse(require_call.kwargs.get("refresh_if_missing", False))
|
||||
# The identity guard must run before the account's follow-welcome flag
|
||||
# is queried. Otherwise accounts with that feature disabled could stay
|
||||
# hosted indefinitely without a sec_user_id.
|
||||
worker._refresh_follow_welcome_config.assert_not_awaited()
|
||||
worker._refresh_follow_welcome_config.assert_awaited_once_with()
|
||||
worker._best_effort_sec_user_id.assert_not_awaited()
|
||||
worker.get_db.assert_not_awaited()
|
||||
|
||||
async def test_cached_disabled_follow_setting_still_guards_missing_identity(self):
|
||||
async def test_cached_disabled_follow_setting_skips_missing_identity(self):
|
||||
worker = DouyinWorker(account_id=311, login_mode="im_direct")
|
||||
worker.is_running = True
|
||||
worker._im_service = SimpleNamespace(_running=True)
|
||||
@@ -80,12 +180,12 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
worker._refresh_follow_welcome_config = AsyncMock(
|
||||
return_value=(False, "", "")
|
||||
)
|
||||
worker._require_sec_user_id = AsyncMock(return_value="")
|
||||
worker._best_effort_sec_user_id = AsyncMock(return_value="")
|
||||
|
||||
await worker.follow_welcome_tick()
|
||||
|
||||
worker._refresh_follow_welcome_config.assert_awaited_once_with()
|
||||
worker._require_sec_user_id.assert_awaited_once_with("托管运行中")
|
||||
worker._best_effort_sec_user_id.assert_not_awaited()
|
||||
|
||||
async def test_blank_sec_user_id_is_missing_and_stops_hosting(self):
|
||||
worker = DouyinWorker(account_id=303, login_mode="im_direct")
|
||||
@@ -297,6 +397,141 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn("托管已自动退出", status_call.kwargs["error_msg"])
|
||||
record_system_log.assert_called_once()
|
||||
|
||||
def test_profile_payload_uid_outranks_query_user_and_cookie_uid(self):
|
||||
"""资料接口的 UID 必须压过 query/user 的 user_uid 和 cookie 兜底。
|
||||
|
||||
实测同一个 Cookie:query/user 返回 user_uid=938334054809296,
|
||||
而账号资料 UID 是 2609567359568155。以前前者先占位,导致账号卡片
|
||||
一直显示「用户938334054809296」,反查 sec_user_id 也永远失败。
|
||||
"""
|
||||
auth = SimpleNamespace(
|
||||
cookie={},
|
||||
msToken="test-ms-token",
|
||||
get_uid=lambda: "938334054809296",
|
||||
)
|
||||
payloads = [
|
||||
# 1) query/user:只有 user_uid,属于弱兜底
|
||||
{"status_code": 0, "user_uid": "938334054809296"},
|
||||
# 2) user/profile/self:权威账号资料
|
||||
{
|
||||
"status_code": 0,
|
||||
"user": {
|
||||
"uid": "2609567359568155",
|
||||
"nickname": "凤的心",
|
||||
"sec_uid": "MS4wLjABAAAA-real-sec-user-id",
|
||||
"unique_id": "39688250979",
|
||||
"aweme_count": 50,
|
||||
"follower_count": 154,
|
||||
},
|
||||
},
|
||||
]
|
||||
responses = [SimpleNamespace(json=lambda value=value: value) for value in payloads]
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
account_profile_module, "_build_auth",
|
||||
return_value=(auth, "test-agent"),
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module.requests, "get", side_effect=responses,
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module, "generate_a_bogus", return_value="a-bogus",
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module, "generate_webid", return_value="web-id",
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module, "_requests_proxies", return_value=None,
|
||||
),
|
||||
):
|
||||
detail = account_profile_module.fetch_douyin_profile_detail_sync(
|
||||
"cookie-json", "test-agent",
|
||||
)
|
||||
|
||||
self.assertEqual(detail["uid"], "2609567359568155")
|
||||
self.assertEqual(detail["nickname"], "凤的心")
|
||||
self.assertTrue(detail["fetched"])
|
||||
|
||||
async def test_sync_writes_one_identity_to_both_account_and_profile(self):
|
||||
"""卡片与详细资料必须来自同一次抓取,不能各抓一次抓出两个身份。"""
|
||||
detail = {
|
||||
"uid": "2609567359568155",
|
||||
"nickname": "凤的心",
|
||||
"avatar_url": "https://example.test/avatar.png",
|
||||
"unique_id": "39688250979",
|
||||
"signature": "",
|
||||
"sec_user_id": "MS4wLjABAAAA-real-sec-user-id",
|
||||
"sec_user_id_status": "found",
|
||||
"video_count": 50,
|
||||
"follower_count": 154,
|
||||
"following_count": 162,
|
||||
"total_favorited": 612,
|
||||
"favoriting_count": 0,
|
||||
"fetched": True,
|
||||
"message": "",
|
||||
}
|
||||
account = SimpleNamespace(
|
||||
id=1,
|
||||
username="用户938334054809296",
|
||||
douyin_uid="938334054809296",
|
||||
avatar_url=None,
|
||||
user_agent="test-agent",
|
||||
)
|
||||
profile = SimpleNamespace(
|
||||
account_id=1, uid=None, nickname=None, avatar_url=None,
|
||||
unique_id=None, signature=None, sec_user_id=None,
|
||||
follower_count=None, following_count=None, total_favorited=None,
|
||||
favoriting_count=None, video_count=None, synced_at=None,
|
||||
sync_message=None,
|
||||
)
|
||||
profile_result = MagicMock()
|
||||
profile_result.scalar_one_or_none.return_value = profile
|
||||
username_result = MagicMock()
|
||||
username_result.scalar_one_or_none.return_value = None
|
||||
db = SimpleNamespace(
|
||||
execute=AsyncMock(side_effect=lambda stmt: (
|
||||
username_result if "accounts.username" in str(stmt).lower()
|
||||
or "username" in str(stmt).lower() else profile_result
|
||||
)),
|
||||
add=MagicMock(),
|
||||
commit=AsyncMock(),
|
||||
refresh=AsyncMock(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
account_profile_module,
|
||||
"fetch_douyin_profile_detail_with_sec_user_id",
|
||||
new=AsyncMock(return_value=detail),
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module,
|
||||
"fetch_douyin_user_videos",
|
||||
new=AsyncMock(return_value={"videos": [], "message": "无作品"}),
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module,
|
||||
"fetch_douyin_profile",
|
||||
new=AsyncMock(side_effect=AssertionError("must not re-fetch")),
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module,
|
||||
"load_account_profile_from_db",
|
||||
new=AsyncMock(return_value={}),
|
||||
),
|
||||
):
|
||||
await account_profile_module.sync_account_profile_to_db(
|
||||
db, account, "cookie-json",
|
||||
)
|
||||
|
||||
# 同一个身份同时写进 accounts 行和 account_profile_details 行
|
||||
self.assertEqual(account.douyin_uid, "2609567359568155")
|
||||
self.assertEqual(account.username, "凤的心")
|
||||
self.assertEqual(account.avatar_url, "https://example.test/avatar.png")
|
||||
self.assertEqual(profile.uid, "2609567359568155")
|
||||
self.assertEqual(profile.nickname, "凤的心")
|
||||
|
||||
async def test_cookie_uid_without_valid_profile_payload_stays_unknown(self):
|
||||
auth = SimpleNamespace(
|
||||
cookie={},
|
||||
@@ -490,7 +725,7 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module,
|
||||
"apply_douyin_profile",
|
||||
"apply_profile_to_account",
|
||||
apply_profile,
|
||||
),
|
||||
patch.object(
|
||||
@@ -518,19 +753,19 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
"profile endpoint temporarily unavailable",
|
||||
)
|
||||
|
||||
async def test_browser_login_missing_sec_user_id_closes_browser_and_skips_im(self):
|
||||
async def test_browser_login_missing_sec_user_id_still_starts_im(self):
|
||||
worker = DouyinWorker(account_id=305, login_mode="browser")
|
||||
worker.is_running = True
|
||||
events: list[str] = []
|
||||
|
||||
async def reject_identity(*_args, **_kwargs):
|
||||
events.append("require-sec-user-id")
|
||||
return False
|
||||
return ""
|
||||
|
||||
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
||||
worker._probe_existing_login = AsyncMock(return_value=True)
|
||||
worker._finalize_login_session = AsyncMock()
|
||||
worker._require_sec_user_id = AsyncMock(side_effect=reject_identity)
|
||||
worker._best_effort_sec_user_id = AsyncMock(side_effect=reject_identity)
|
||||
worker._setup_im_network_listener = AsyncMock()
|
||||
worker._navigate_to_message_center = AsyncMock()
|
||||
worker._harvest_im_credentials = AsyncMock()
|
||||
@@ -595,8 +830,8 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
|
||||
worker._finalize_login_session.assert_awaited_once_with()
|
||||
worker._require_sec_user_id.assert_awaited_once()
|
||||
require_call = worker._require_sec_user_id.await_args
|
||||
worker._best_effort_sec_user_id.assert_awaited_once()
|
||||
require_call = worker._best_effort_sec_user_id.await_args
|
||||
self.assertTrue(require_call.kwargs["force_refresh"])
|
||||
self.assertEqual(
|
||||
events,
|
||||
@@ -607,8 +842,10 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
worker._harvest_im_credentials.assert_awaited_once_with(timeout=25)
|
||||
worker._persist_cookies.assert_awaited_once_with()
|
||||
worker._build_im_session.assert_awaited_once_with()
|
||||
worker._persist_im_session.assert_not_awaited()
|
||||
worker._run_im_direct_service.assert_not_awaited()
|
||||
# sec_user_id 只服务关注欢迎语;缺它不能阻断私信托管,
|
||||
# 否则浏览器登录后账号立刻下线、永远不会自动回复。
|
||||
worker._persist_im_session.assert_awaited_once()
|
||||
worker._run_im_direct_service.assert_awaited_once_with(im_session)
|
||||
worker._close_browser_only.assert_awaited_once_with()
|
||||
|
||||
async def test_browser_login_with_sec_user_id_continues_to_im(self):
|
||||
@@ -617,7 +854,7 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
||||
worker._probe_existing_login = AsyncMock(return_value=True)
|
||||
worker._finalize_login_session = AsyncMock()
|
||||
worker._require_sec_user_id = AsyncMock(return_value=True)
|
||||
worker._best_effort_sec_user_id = AsyncMock(return_value="sec-uid-306")
|
||||
worker._setup_im_network_listener = AsyncMock()
|
||||
worker._navigate_to_message_center = AsyncMock()
|
||||
worker._harvest_im_credentials = AsyncMock()
|
||||
@@ -674,8 +911,10 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
},
|
||||
)
|
||||
|
||||
worker._require_sec_user_id.assert_awaited_once()
|
||||
self.assertTrue(worker._require_sec_user_id.await_args.kwargs["force_refresh"])
|
||||
worker._best_effort_sec_user_id.assert_awaited_once()
|
||||
self.assertTrue(
|
||||
worker._best_effort_sec_user_id.await_args.kwargs["force_refresh"]
|
||||
)
|
||||
worker._setup_im_network_listener.assert_awaited_once_with()
|
||||
worker._navigate_to_message_center.assert_awaited_once_with()
|
||||
worker._harvest_im_credentials.assert_awaited_once_with(timeout=25)
|
||||
|
||||
Reference in New Issue
Block a user