import unittest from contextlib import redirect_stderr, redirect_stdout from io import StringIO from types import ModuleType from unittest.mock import MagicMock, call, patch from core.device_profile import DeviceProfileGenerator from core.sig3 import Kwsg10418State from core.sms_login import LoginSession, RegionTicket from tools import sms_login_cli from tools.sms_login_cli import build_parser class SmsLoginCliTests(unittest.TestCase): def test_parser_only_exposes_login_inputs_and_transport(self): parser = build_parser() option_strings = { option for action in parser._actions for option in action.option_strings } self.assertEqual( option_strings, { "-h", "--help", "--mobile", "--code", "--base-url", "--transport", }, ) def test_parser_rejects_removed_device_reuse_options(self): parser = build_parser() for removed_option in ( "--device-profile", "--device-seed", "--no-device-online", "--app-fields", "--dry-run", "--out", ): with self.subTest(option=removed_option): with redirect_stderr(StringIO()), self.assertRaises(SystemExit): parser.parse_args(["--mobile", "13800000000", removed_option]) def test_parser_rejects_mobile_placeholders(self): parser = build_parser() with redirect_stderr(StringIO()), self.assertRaises(SystemExit): parser.parse_args(["--mobile", "MOBILE"]) def test_http_captcha_failure_does_not_open_a_browser(self): self.assertFalse(hasattr(sms_login_cli, "complete_captcha_in_browser")) self.assertFalse(hasattr(sms_login_cli, "webbrowser")) with ( patch.object(sms_login_cli, "_solve_captcha_http", return_value=""), redirect_stdout(StringIO()) as output, ): token = sms_login_cli._complete_captcha_handoff( "https://app.m.kuaishou.com/verify/captcha.html?key=1&type=7", stage="mobileVerifyCode", session=object(), ) self.assertEqual(token, "") self.assertIn("不启用浏览器", output.getvalue()) def test_http_captcha_diagnoses_known_result_codes(self): self.assertEqual( set(sms_login_cli._CAPTCHA_RESULT_HINTS), {350001, 350002, 350005, 350009, 350014, 350017, 350029}, ) solver_module = ModuleType("tools.captcha_solver_http") solver_module.solve_captcha = lambda **kwargs: { "ok": False, "stage": "verify", "result": 350029, "captcha_token": "", "raw": { "result": 350029, "desc": "switch captcha type", "type": "4", "configPath": "/rest/zt/captcha/wordClick/config", }, } with ( patch.dict( sms_login_cli.sys.modules, {"tools.captcha_solver_http": solver_module}, ), redirect_stdout(StringIO()) as output, ): token = sms_login_cli._solve_captcha_http( "https://app.m.kuaishou.com/verify/captcha.html?key=1&type=7", session=object(), ) self.assertEqual(token, "") self.assertIn("result=350029", output.getvalue()) self.assertIn("next_type=4", output.getvalue()) self.assertIn( "config_path=/rest/zt/captcha/wordClick/config", output.getvalue(), ) def test_each_main_call_generates_and_registers_a_fresh_device(self): profiles = [ DeviceProfileGenerator(seed=20260730).new_profile(), DeviceProfileGenerator(seed=20260731).new_profile(), ] generators = [MagicMock(), MagicMock()] for generator, profile in zip(generators, profiles, strict=True): generator.new_profile.return_value = profile http_sessions = [MagicMock(), MagicMock()] anonymous_regions = [ RegionTicket(uid="0", ticket="RT_ANON_1", status=200), RegionTicket(uid="0", ticket="RT_ANON_2", status=200), ] account_regions = [ RegionTicket(uid="101", ticket="RT_ACCOUNT_1", status=200), RegionTicket(uid="102", ticket="RT_ACCOUNT_2", status=200), ] login_sessions = [ LoginSession( api_st="API_ST_1", h5_st="H5_ST_1", client_salt="SALT_1", user_id="101", mobile="13800000000", ), LoginSession( api_st="API_ST_2", h5_st="H5_ST_2", client_salt="SALT_2", user_id="102", mobile="13800000000", ), ] output = StringIO() with ( patch.object( sms_login_cli, "DeviceProfileGenerator", side_effect=generators, ), patch.object(sms_login_cli, "run_online_bootstrap") as bootstrap, patch.object( sms_login_cli, "generate_profile_passport_account_image", side_effect=("PASSPORT_1", "PASSPORT_2"), ) as generate_passport, patch.object(sms_login_cli, "WeaponProofProvider"), patch.object( sms_login_cli, "_new_sig3_state", side_effect=( Kwsg10418State(session_seed=1, counter=0x5F), Kwsg10418State(session_seed=2, counter=0x5F), ), ), patch.object( sms_login_cli, "create_http_session", side_effect=http_sessions, ), patch.object( sms_login_cli, "refresh_region_ticket", side_effect=( anonymous_regions[0], account_regions[0], anonymous_regions[1], account_regions[1], ), ), patch.object( sms_login_cli, "loginhelper_encrypt_mobile", side_effect=("ENC_1", "ENC_2"), ), patch.object( sms_login_cli, "build_account_security_fields", return_value={"raw": "RAW", "publicKey": "PUB", "secret": "SEC"}, ), patch.object( sms_login_cli, "login_by_code_try_paths", side_effect=( (login_sessions[0], [("mobileVerifyCode", login_sessions[0])]), (login_sessions[1], [("mobileVerifyCode", login_sessions[1])]), ), ) as login, patch.object( sms_login_cli.sys, "argv", ["sms_login_cli.py", "--mobile", "13800000000", "--code", "123456"], ), redirect_stdout(output), ): self.assertEqual(sms_login_cli.main(), 0) self.assertEqual(sms_login_cli.main(), 0) self.assertNotEqual(profiles[0].did, profiles[1].did) self.assertEqual( bootstrap.call_args_list, [ call(profiles[0], timeout=sms_login_cli.REQUEST_TIMEOUT_SECONDS), call(profiles[1], timeout=sms_login_cli.REQUEST_TIMEOUT_SECONDS), ], ) self.assertEqual( generate_passport.call_args_list, [call(profiles[0]), call(profiles[1])], ) self.assertIs(login.call_args_list[0].args[0], profiles[0]) self.assertIs(login.call_args_list[1].args[0], profiles[1]) text = output.getvalue() self.assertIn('ksck="账号#', text) self.assertIn(f"did={profiles[0].did}", text) self.assertIn("userId=101", text) self.assertIn("kuaishou.api_st=API_ST_1", text) self.assertIn("token=API_ST_1", text) self.assertIn("region_ticket=RT_ACCOUNT_1", text) self.assertIn("kuaishou.h5_st=H5_ST_1", text) self.assertIn("os=android#SALT_1\"", text) self.assertIn("#SALT_1\"", text) if __name__ == "__main__": unittest.main()