""" 验证 Cookie 登录的表单识别、验证码重试和同源跳转。 """ from lxml import etree from app.application.security.cookie import ( CookieHelper, configure_cookie_ports, reset_cookie_ports, ) class _CookieContext: """ 提供登录测试所需的最小浏览器上下文。 """ @staticmethod def cookies() -> list[dict[str, str]]: """ 返回登录后的会话 Cookie。 """ return [{"name": "session", "value": "authenticated"}] class _CookiePage: """ 模拟首页跳转登录页并提交表单的浏览器页面。 """ def __init__(self) -> None: self.url = "https://dicmusic.example/" self.context = _CookieContext() self.fills: list[tuple[str, str]] = [] self.goto_calls: list[tuple[str, dict]] = [] self.submitted = False def content(self) -> str: """ 按当前页面阶段返回对应 HTML。 """ if self.submitted: return '
退出' if self.url.endswith("/login.php"): return ( '" ) return '登录' def goto(self, url: str, **kwargs) -> None: """ 记录并切换页面地址。 """ self.url = url self.goto_calls.append((url, kwargs)) @staticmethod def wait_for_load_state(_state: str, timeout: int) -> None: """ 模拟页面已完成所需加载。 """ @staticmethod def wait_for_selector(_selector: str, *args, **kwargs) -> None: """ 模拟表单元素已经可用。 """ @staticmethod def query_selector(_selector: str): """ 当前页面没有保持登录复选框或验证码。 """ return None def fill(self, selector: str, value: str) -> None: """ 记录表单填充值。 """ self.fills.append((selector, value)) def click(self, _selector: str) -> None: """ 模拟提交登录表单。 """ self.submitted = True @staticmethod def evaluate(_expression: str) -> str: """ 返回浏览器 User-Agent。 """ return "Browser UA" class _CaptchaElement: """ 模拟可见验证码输入框。 """ def __init__(self, page: "_CaptchaPage") -> None: """ 绑定所属页面,以便记录验证码填写。 """ self._page = page def is_visible(self) -> bool: """ 返回验证码输入框可见。 """ return True def fill(self, value: str) -> None: """ 记录验证码内容。 """ self._page.captcha_values.append(value) class _CaptchaPage: """ 模拟使用非固定字段名并需要多次 OCR 尝试的登录页。 """ def __init__(self) -> None: """ 初始化登录页状态。 """ self.url = "https://pt.example/login" self.context = _CookieContext() self.fills: list[tuple[str, str]] = [] self.captcha_values: list[str] = [] self.captcha_refreshes = 0 self.submitted = False def content(self) -> str: """ 按提交状态返回登录表单或已登录页面。 """ if self.submitted: return '退出' return ( '" ) @staticmethod def wait_for_load_state(_state: str, timeout: int) -> None: """ 模拟页面加载完成。 """ @staticmethod def wait_for_selector(_selector: str, *args, **kwargs) -> None: """ 模拟输入框或按钮已经出现。 """ def query_selector(self, selector: str): """ 仅返回验证码输入框,其他可选元素视为不存在。 """ if "verification_code" in selector: return _CaptchaElement(self) return None def fill(self, selector: str, value: str) -> None: """ 记录普通字段的填写。 """ self.fills.append((selector, value)) def click(self, selector: str, **kwargs) -> None: """ 区分验证码刷新和登录提交。 """ if "img" in selector: self.captcha_refreshes += 1 return self.submitted = True @staticmethod def evaluate(_expression: str) -> str: """ 返回浏览器 User-Agent。 """ return "Captcha Browser UA" def test_cookie_login_infers_common_fields_and_retries_captcha(): """ 通用字段推断、验证码刷新重试和登录按钮点击应协同工作。 """ page = _CaptchaPage() ocr_results = iter(("", "", "ABC123")) class FakeBrowserPort: """ 把 Application 登录回调运行在验证码页面上。 """ @staticmethod def action(**kwargs): """ 执行登录回调。 """ return kwargs["callback"](page) class HttpPort: """ 提供验证码图片字节。 """ @staticmethod def fetch(**_kwargs): """ 返回固定验证码图片。 """ return b"captcha-image" class OcrPort: """ 按预设顺序返回 OCR 结果。 """ @staticmethod def recognize(_image_data: bytes) -> str: """ 返回下一次识别结果。 """ return next(ocr_results) configure_cookie_ports(browser=FakeBrowserPort(), http=HttpPort(), ocr=OcrPort()) try: cookie, ua, message = CookieHelper().get_site_cookie_ua( url=page.url, username="moviepilot@example.com", password="dummy-password", timeout=30, ) finally: reset_cookie_ports() assert cookie == "session=authenticated; " assert ua == "Captcha Browser UA" assert message == "" assert page.fills[:2] == [ ('//input[@name="txt_email"]', "moviepilot@example.com"), ('//input[@type="password"]', "dummy-password"), ] assert page.fills[2] == ('//input[@name="verification_code"]', "ABC123") assert page.captcha_refreshes == 2 assert page.submitted is True def test_cookie_click_falls_back_to_evaluate_when_overlay_blocks_pointer(): """ 提交按钮被遮挡时应继续尝试 JavaScript 点击。 """ class FallbackPage: """ 让普通、强制和元素点击都失败的页面。 """ def click(self, _selector: str, **_kwargs) -> None: """ 模拟指针点击被遮挡。 """ raise RuntimeError("pointer-events overlay") def query_selector(self, _selector: str): """ 返回一个同样无法指针点击的元素。 """ return self def evaluate(self, _script: str, _selector: str) -> bool: """ 模拟 XPath JavaScript 点击成功。 """ return True def is_visible(self) -> bool: """ 满足元素接口。 """ return True page = FallbackPage() CookieHelper._click_with_fallback(page, "//button[@type='submit']") def test_cookie_login_follows_same_origin_login_link(): """ 首页仅提供登录链接时应进入同源登录页后完成 Cookie 获取。 """ page = _CookiePage() class FakeBrowserPort: """ 把 Application 登录回调运行在测试页面上。 """ @staticmethod def action(**kwargs): """ 执行登录回调。 """ return kwargs["callback"](page) class UnusedPort: """ 标记当前无验证码流程不得触达的端口。 """ def __getattr__(self, _name): """ 拒绝任何意外调用。 """ raise AssertionError("无验证码登录不应使用该端口") configure_cookie_ports( browser=FakeBrowserPort(), http=UnusedPort(), ocr=UnusedPort() ) try: cookie, ua, message = CookieHelper().get_site_cookie_ua( url="https://dicmusic.example/", username="moviepilot", password="secret-password", timeout=30, ) finally: reset_cookie_ports() assert cookie == "session=authenticated; " assert ua == "Browser UA" assert message == "" assert page.goto_calls == [ ( "https://dicmusic.example/login.php", {"wait_until": "domcontentloaded", "timeout": 30000}, ) ] assert page.fills == [ ('//input[@name="username"]', "moviepilot"), ('//input[@name="password"]', "secret-password"), ] def test_cookie_login_rejects_cross_origin_login_link(): """ 跨域登录链接不得成为账号密码填充目标。 """ login_url = CookieHelper._find_login_page_url( etree.HTML( '登录' ), "https://dicmusic.example/", ) assert login_url is None def test_cookie_login_prefers_password_form_over_unrelated_username_input(): """ 页面有搜索框时,用户名候选应来自同一个密码表单。 """ html = etree.HTML( '' '' ) username_xpath, password_xpath = CookieHelper._find_login_fields(html) assert username_xpath is not None assert password_xpath is not None assert html.xpath(username_xpath)[0].get("name") == "member_id" assert html.xpath(password_xpath)[0].get("name") == "secret"