Spaces:
Paused
Paused
| """Round 11 四轮 — chatgpt.com session_token 注入到 personal OAuth 跳过 /log-in. | |
| 阻塞场景: | |
| - 刚被 master kick 出 Team 的子号在 OAuth /log-in 页 fill email 后,Continue 按钮变灰禁用, | |
| login flow 永远卡在 email 步骤,bundle=None。 | |
| - 5 次外层重试全失败,W-I9 / 二轮 / 三轮 修复都救不回来。 | |
| 修复路径(本测试文件覆盖): | |
| 1. `_register_direct_once` 注册成功前从 chatgpt.com BrowserContext 抽 | |
| `__Secure-next-auth.session-token` (含 chunked .0 / .1) 并返回 (success, session_token)。 | |
| 2. `create_account_direct` 把 session_token 透给 `_run_post_register_oauth`。 | |
| 3. `_run_post_register_oauth` 把 session_token 透给 `login_codex_via_browser`。 | |
| 4. `login_codex_via_browser` 在 use_personal=True + session_token 时,把 cookie 注入 | |
| `auth.openai.com`,/oauth/authorize 看到有效 session 直接跳过 /log-in。 | |
| 参考:`SessionCodexAuthFlow._inject_auth_cookies`(主号专用 cookie 注入模式), | |
| 本轮把模式扩展给 personal 子号。 | |
| """ | |
| from __future__ import annotations | |
| import inspect | |
| from unittest.mock import MagicMock | |
| import pytest | |
| from autoteam import codex_auth, manager | |
| # --------------------------------------------------------------------------- | |
| # Helper: _extract_session_token_from_context | |
| # --------------------------------------------------------------------------- | |
| class _FakeContext: | |
| def __init__(self, cookies): | |
| self._cookies = cookies | |
| def cookies(self): | |
| return list(self._cookies) | |
| def test_extract_session_token_single_cookie(): | |
| ctx = _FakeContext( | |
| [ | |
| {"name": "__Secure-next-auth.session-token", "value": "abc123"}, | |
| {"name": "_account", "value": "ws-xxx"}, | |
| ] | |
| ) | |
| assert manager._extract_session_token_from_context(ctx) == "abc123" | |
| def test_extract_session_token_chunked_cookies(): | |
| """大 token 切成 .0 / .1 — 必须按 suffix 排序拼回。""" | |
| ctx = _FakeContext( | |
| [ | |
| {"name": "__Secure-next-auth.session-token.1", "value": "_part1"}, | |
| {"name": "__Secure-next-auth.session-token.0", "value": "part0"}, | |
| ] | |
| ) | |
| assert manager._extract_session_token_from_context(ctx) == "part0_part1" | |
| def test_extract_session_token_missing_returns_none(): | |
| ctx = _FakeContext([{"name": "_account", "value": "ws-xxx"}]) | |
| assert manager._extract_session_token_from_context(ctx) is None | |
| def test_extract_session_token_handles_cookies_exception(): | |
| ctx = MagicMock() | |
| ctx.cookies.side_effect = RuntimeError("playwright dead") | |
| assert manager._extract_session_token_from_context(ctx) is None | |
| # --------------------------------------------------------------------------- | |
| # _inject_personal_session_cookies | |
| # --------------------------------------------------------------------------- | |
| class _RecordingContext: | |
| def __init__(self): | |
| self.added = [] | |
| def add_cookies(self, cookies): | |
| self.added.extend(cookies) | |
| def test_inject_session_cookies_short_token_dual_domain(): | |
| """短 token 双域注入:chatgpt.com + auth.openai.com 各 1 个 cookie。""" | |
| ctx = _RecordingContext() | |
| codex_auth._inject_personal_session_cookies(ctx, "short_token") | |
| assert len(ctx.added) == 2 | |
| domains = sorted(c["domain"] for c in ctx.added) | |
| assert domains == ["auth.openai.com", "chatgpt.com"] | |
| for cookie in ctx.added: | |
| assert cookie["name"] == "__Secure-next-auth.session-token" | |
| assert cookie["value"] == "short_token" | |
| assert cookie["httpOnly"] is True | |
| assert cookie["secure"] is True | |
| def test_inject_session_cookies_chunked_token_dual_domain(): | |
| """>3800 字节双域 × 切两段 = 4 个 cookies。""" | |
| ctx = _RecordingContext() | |
| big_token = "X" * 4000 | |
| codex_auth._inject_personal_session_cookies(ctx, big_token) | |
| assert len(ctx.added) == 4 | |
| # 每个域应该有 .0 和 .1 两个分片 | |
| by_domain = {} | |
| for cookie in ctx.added: | |
| by_domain.setdefault(cookie["domain"], []).append(cookie) | |
| assert set(by_domain.keys()) == {"chatgpt.com", "auth.openai.com"} | |
| for domain, cookies in by_domain.items(): | |
| names = sorted(c["name"] for c in cookies) | |
| assert names == [ | |
| "__Secure-next-auth.session-token.0", | |
| "__Secure-next-auth.session-token.1", | |
| ], f"domain {domain} 缺少分片" | |
| chunks = {c["name"]: c["value"] for c in cookies} | |
| assert chunks["__Secure-next-auth.session-token.0"] == "X" * 3800 | |
| assert chunks["__Secure-next-auth.session-token.1"] == "X" * 200 | |
| for c in cookies: | |
| assert c["httpOnly"] is True | |
| assert c["secure"] is True | |
| def test_inject_session_cookies_empty_token_noop(): | |
| ctx = _RecordingContext() | |
| codex_auth._inject_personal_session_cookies(ctx, None) | |
| codex_auth._inject_personal_session_cookies(ctx, "") | |
| assert ctx.added == [] | |
| # --------------------------------------------------------------------------- | |
| # Signature contract checks (防御性,签名变更需要更新本测试) | |
| # --------------------------------------------------------------------------- | |
| def test_login_codex_via_browser_accepts_session_token_kwarg(): | |
| sig = inspect.signature(codex_auth.login_codex_via_browser) | |
| assert "chatgpt_session_token" in sig.parameters | |
| assert sig.parameters["chatgpt_session_token"].default is None | |
| assert ( | |
| sig.parameters["chatgpt_session_token"].kind | |
| is inspect.Parameter.KEYWORD_ONLY | |
| ) | |
| def test_run_post_register_oauth_accepts_session_token_kwarg(): | |
| sig = inspect.signature(manager._run_post_register_oauth) | |
| assert "chatgpt_session_token" in sig.parameters | |
| assert sig.parameters["chatgpt_session_token"].default is None | |
| def test_register_direct_once_returns_tuple(): | |
| """source 应表明返回 (success, session_token) 元组。""" | |
| src = inspect.getsource(manager._register_direct_once) | |
| # 函数体里至少有一处 `return success, session_token` (主路径) 和早期 return False 转 False, None | |
| assert "return success, session_token" in src | |
| def test_register_direct_once_no_bare_return_false_or_true(): | |
| """所有 return 都必须是 tuple 形式,不能有 `return False` / `return True` 单值。 | |
| 实战 bug:把 final return 改成 tuple,但漏改了多处 early-return, | |
| `success, session_token = _register_direct_once(...)` 解构时炸 cannot unpack non-iterable bool。 | |
| """ | |
| import re | |
| src = inspect.getsource(manager._register_direct_once) | |
| bad_returns = [] | |
| for m in re.finditer(r"^\s*return\s+(.*)$", src, re.MULTILINE): | |
| val = m.group(1).strip() | |
| # 允许:return False, None / return True, ... / return success, session_token | |
| # 禁止:return False / return True / return success / return None | |
| if val in ("False", "True", "success", "None"): | |
| bad_returns.append(val) | |
| assert not bad_returns, f"_register_direct_once 仍有非 tuple return: {bad_returns}" | |
| # --------------------------------------------------------------------------- | |
| # 整合点 — _run_post_register_oauth 透传 session_token 到 login_codex_via_browser | |
| # --------------------------------------------------------------------------- | |
| def test_post_register_oauth_forwards_session_token_to_login(monkeypatch): | |
| """personal 路径下,_run_post_register_oauth 必须把 chatgpt_session_token 透给 login_codex_via_browser。""" | |
| captured = {} | |
| def fake_login( | |
| email, | |
| password, | |
| *, | |
| mail_client=None, | |
| use_personal=False, | |
| chatgpt_session_token=None, | |
| signup_profile=None, | |
| ): | |
| captured["use_personal"] = use_personal | |
| captured["chatgpt_session_token"] = chatgpt_session_token | |
| captured["signup_profile"] = signup_profile | |
| # 直接返回失败 bundle 让上层 5 次重试结束(再失败也继续测试关注的 forward 行为) | |
| return None | |
| # _run_post_register_oauth 里 login_codex_via_browser 是从 codex_auth 模块导入, | |
| # monkey patch 模块属性即可。 | |
| monkeypatch.setattr(manager, "login_codex_via_browser", fake_login) | |
| # 让 master health probe 直接通过(避免触发真 API) | |
| fake_master = MagicMock() | |
| fake_master.start.return_value = None | |
| fake_master.stop.return_value = None | |
| monkeypatch.setattr(manager, "ChatGPTTeamAPI", lambda *a, **kw: fake_master) | |
| # is_master_subscription_healthy 必须 True 才走 kick | |
| monkeypatch.setattr( | |
| "autoteam.master_health.is_master_subscription_healthy", | |
| lambda api: (True, "active", {"account_id": "x"}), | |
| ) | |
| # remove_from_team 走通 | |
| monkeypatch.setattr(manager, "remove_from_team", lambda api, email, return_status: "removed") | |
| # update_account / record_failure / delete_account 全部桩成 noop,避免写文件 | |
| monkeypatch.setattr(manager, "update_account", lambda *a, **kw: None) | |
| monkeypatch.setattr(manager, "record_failure", lambda *a, **kw: None) | |
| monkeypatch.setattr(manager, "delete_account", lambda *a, **kw: None) | |
| # 重试间 sleep 太长,patch 掉 | |
| monkeypatch.setattr(manager.time, "sleep", lambda *a, **kw: None) | |
| out = {} | |
| result = manager._run_post_register_oauth( | |
| "test@example.com", | |
| "pwd", | |
| mail_client=MagicMock(), | |
| leave_workspace=True, | |
| out_outcome=out, | |
| chatgpt_session_token="my_session_token_12345", | |
| ) | |
| # bundle=None 5 次重试都失败 → 返回 None,但 forward 应已发生 | |
| assert result is None | |
| assert captured.get("use_personal") is True | |
| assert captured.get("chatgpt_session_token") == "my_session_token_12345" | |
| # --------------------------------------------------------------------------- | |
| # Source-level invariant — login_codex_via_browser 在 personal+token 路径调用 inject helper | |
| # --------------------------------------------------------------------------- | |
| def test_login_codex_calls_inject_helper_in_personal_with_token(): | |
| """source 必须包含 `if use_personal and chatgpt_session_token:` 守卫和注入调用。""" | |
| src = inspect.getsource(codex_auth.login_codex_via_browser) | |
| assert "use_personal and chatgpt_session_token" in src | |
| assert "_inject_personal_session_cookies" in src | |
| # --------------------------------------------------------------------------- | |
| # Source invariant — manager 透传链不丢失 | |
| # --------------------------------------------------------------------------- | |
| def test_create_account_direct_threads_session_token_into_post_oauth(): | |
| src = inspect.getsource(manager.create_account_direct) | |
| assert "chatgpt_session_token=session_token" in src | |
| def test_register_direct_once_extracts_session_token_before_cleanup(): | |
| """注册主路径里必须在统一 Playwright cleanup 之前调用 extract helper。 | |
| `_register_direct_once` 有多处 early-return cleanup(失败路径), | |
| 成功路径的 cleanup 是最后一个,extract 必须在它之前。 | |
| """ | |
| src = inspect.getsource(manager._register_direct_once) | |
| extract_idx = src.find("_extract_session_token_from_context") | |
| final_cleanup_idx = src.rfind("cleanup_direct_register()") | |
| assert extract_idx != -1, "_register_direct_once 必须调 _extract_session_token_from_context" | |
| assert final_cleanup_idx != -1 | |
| assert extract_idx < final_cleanup_idx, "extract 必须在成功路径的最终 cleanup 之前" | |
| def test_register_direct_once_uses_unified_playwright_cleanup(): | |
| src = inspect.getsource(manager._register_direct_once) | |
| assert "browser.close()" not in src | |
| assert "close_playwright_objects(page, context, browser" in src | |
| # --------------------------------------------------------------------------- | |
| # 完整闭环 — fill 个人 / Team 共存性,Team 路径不该带 token | |
| # --------------------------------------------------------------------------- | |
| def test_team_path_does_not_inject_session_token(): | |
| """Team 模式(use_personal=False)即使 caller 传了 session_token,也不应该注入。""" | |
| src = inspect.getsource(codex_auth.login_codex_via_browser) | |
| # 守卫确保只有 personal 路径触发注入 | |
| inject_block_idx = src.find("_inject_personal_session_cookies(context, chatgpt_session_token)") | |
| guard_idx = src.find("if use_personal and chatgpt_session_token:") | |
| assert inject_block_idx > guard_idx > 0, "注入必须被 personal+token 守卫包裹" | |
| if __name__ == "__main__": | |
| pytest.main([__file__, "-v"]) | |