Spaces:
Paused
Paused
File size: 12,814 Bytes
2525f56 e49e827 2525f56 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 | """Round 10 (2026-04-28) — `login_codex_via_session` thin-wrapper + `refresh_main_auth_file` 单测。
PRD: `.trellis/tasks/04-28-master-codex-oauth-session-fallback/prd.md`
- B1: `login_codex_via_session` 重构为 thin wrapper,委托给 `SessionCodexAuthFlow`
- B2: 5 个 case 覆盖 wrapper / refresh 路径
契约约束(对齐 upstream cnitlrt/AutoTeam codex_auth.py:1017-1043):
1. wrapper 调 `flow.start()`;若 step == "completed" → 调 `flow.complete()` 取 bundle 返回
2. step != "completed" → 返回 None + log warning("未直接完成")
3. flow.start() raise → 异常往上抛,但 finally 必跑 flow.stop()
4. refresh_main_auth_file:bundle 非空 → 调 save_main_auth_file + 返回 dict
5. refresh_main_auth_file:bundle=None → 抛 RuntimeError("无法基于管理员登录态生成主号 Codex 认证文件")
(文案保留,API 层依赖此错误信息)
"""
from __future__ import annotations
import logging
import pytest
# ---------------------------------------------------------------------------
# Fixtures — mock admin_state 提供 email/session/account_id/workspace_name
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _stub_admin_state(monkeypatch):
"""所有 case 默认提供合法的 admin 凭证,避免 SessionCodexAuthFlow.__init__ 中 _build_auth_url 失败。"""
monkeypatch.setattr("autoteam.codex_auth.get_admin_email", lambda: "admin@example.com")
monkeypatch.setattr("autoteam.codex_auth.get_admin_session_token", lambda: "fake-session-token-abc123")
monkeypatch.setattr("autoteam.codex_auth.get_chatgpt_account_id", lambda: "ws-account-uuid-xyz")
monkeypatch.setattr("autoteam.codex_auth.get_chatgpt_workspace_name", lambda: "Master Team")
def _make_complete_bundle():
"""构造一个完整的 OAuth bundle,模拟 _exchange_auth_code 的成功返回。"""
return {
"access_token": "fake.access.token",
"refresh_token": "fake_refresh_token_long_string_abc",
"id_token": "fake.id.token.jwt",
"account_id": "ws-account-uuid-xyz",
"email": "admin@example.com",
"plan_type": "team",
"expired": 1779000000.0,
}
# ---------------------------------------------------------------------------
# Test 1: wrapper.start() returns step=completed → complete() + return bundle
# ---------------------------------------------------------------------------
def test_wrapper_completes_returns_bundle(monkeypatch):
"""flow.start() 返回 step=completed → wrapper 调 flow.complete() 拿 bundle 返回."""
from autoteam import codex_auth
bundle = _make_complete_bundle()
calls = {"start": 0, "complete": 0, "stop": 0}
class _FakeFlow:
def __init__(self, **kwargs):
# 验证关键参数从 admin_state 注入
assert kwargs["email"] == "admin@example.com"
assert kwargs["session_token"] == "fake-session-token-abc123"
assert kwargs["account_id"] == "ws-account-uuid-xyz"
assert kwargs["workspace_name"] == "Master Team"
assert kwargs["password"] == ""
assert kwargs["password_callback"] is None
assert callable(kwargs["auth_file_callback"])
def start(self):
calls["start"] += 1
return {"step": "completed", "detail": None}
def complete(self):
calls["complete"] += 1
return {"email": bundle["email"], "auth_file": "", "plan_type": "team", "bundle": bundle}
def stop(self):
calls["stop"] += 1
monkeypatch.setattr(codex_auth, "SessionCodexAuthFlow", _FakeFlow)
result = codex_auth.login_codex_via_session()
assert result == bundle
assert calls == {"start": 1, "complete": 1, "stop": 1}
# ---------------------------------------------------------------------------
# Test 2: wrapper.start() returns step=email_required → return None + log warning
# ---------------------------------------------------------------------------
def test_wrapper_email_required_returns_none(monkeypatch, caplog):
"""flow.start() 返回 step != completed → wrapper 返回 None + log warning."""
from autoteam import codex_auth
calls = {"start": 0, "complete": 0, "stop": 0}
class _FakeFlow:
def __init__(self, **kwargs):
pass
def start(self):
calls["start"] += 1
return {"step": "email_required", "detail": "still on email-input page"}
def complete(self):
calls["complete"] += 1
raise AssertionError("complete 不应被调用")
def stop(self):
calls["stop"] += 1
monkeypatch.setattr(codex_auth, "SessionCodexAuthFlow", _FakeFlow)
with caplog.at_level(logging.WARNING, logger="autoteam.codex_auth"):
result = codex_auth.login_codex_via_session()
assert result is None
assert calls == {"start": 1, "complete": 0, "stop": 1}
# 验证 warning 文案包含"未直接完成"
assert any("未直接完成" in rec.getMessage() for rec in caplog.records)
# ---------------------------------------------------------------------------
# Test 3: wrapper.start() raises → exception propagates AND flow.stop() still runs
# ---------------------------------------------------------------------------
def test_wrapper_exception_still_stops_flow(monkeypatch):
"""flow.start() raise → 异常往上抛,但 finally 必跑 flow.stop()."""
from autoteam import codex_auth
calls = {"start": 0, "complete": 0, "stop": 0}
class _FakeFlow:
def __init__(self, **kwargs):
pass
def start(self):
calls["start"] += 1
raise RuntimeError("boom — playwright crashed")
def complete(self):
calls["complete"] += 1
raise AssertionError("complete 不应被调用")
def stop(self):
calls["stop"] += 1
monkeypatch.setattr(codex_auth, "SessionCodexAuthFlow", _FakeFlow)
with pytest.raises(RuntimeError, match="boom"):
codex_auth.login_codex_via_session()
# finally 块必须跑 stop()
assert calls == {"start": 1, "complete": 0, "stop": 1}
# ---------------------------------------------------------------------------
# Test 4: refresh_main_auth_file — bundle 成功 → save_main_auth_file + return dict
# ---------------------------------------------------------------------------
def test_refresh_main_auth_file_saves_on_success(monkeypatch, tmp_path):
"""login_codex_via_session 返回 bundle → refresh 调 save_main_auth_file + 返回 dict."""
from autoteam import codex_auth
bundle = _make_complete_bundle()
fake_path = str(tmp_path / "codex-main-ws-account-uuid-xyz.json")
save_calls = []
def _fake_save(b):
save_calls.append(b)
return fake_path
monkeypatch.setattr(codex_auth, "login_codex_via_session", lambda: bundle)
monkeypatch.setattr(codex_auth, "save_main_auth_file", _fake_save)
result = codex_auth.refresh_main_auth_file()
assert result == {
"email": "admin@example.com",
"auth_file": fake_path,
"plan_type": "team",
}
assert len(save_calls) == 1
assert save_calls[0] == bundle
# ---------------------------------------------------------------------------
# Test 5: refresh_main_auth_file — bundle=None → RuntimeError(文案保留)
# ---------------------------------------------------------------------------
def test_refresh_main_auth_file_raises_on_none(monkeypatch):
"""login_codex_via_session 返回 None → refresh 抛 RuntimeError 文案保留(向后兼容).
API 层(api.py:1259)依赖此错误文案 swallow 进 info["main_auth_error"] 字段,
任何修改都是 breaking change。
"""
from autoteam import codex_auth
monkeypatch.setattr(codex_auth, "login_codex_via_session", lambda: None)
with pytest.raises(RuntimeError, match="无法基于管理员登录态生成主号 Codex 认证文件"):
codex_auth.refresh_main_auth_file()
# ---------------------------------------------------------------------------
# Bonus: 验证 SessionCodexAuthFlow 关键方法存在(Round 10 实施前置条件)
# ---------------------------------------------------------------------------
def test_session_codex_auth_flow_has_required_methods():
"""SessionCodexAuthFlow 必须含 wrapper 调用所需的全套方法 — Round 10 前置条件.
若任一方法缺失,Approach A 重构会运行时崩溃。
"""
from autoteam.codex_auth import SessionCodexAuthFlow
required = {
"_auto_fill_email",
"_advance",
"_detect_step",
"_inject_auth_cookies",
"_attach_callback_listeners",
"start",
"complete",
"stop",
}
actual = set(dir(SessionCodexAuthFlow))
missing = required - actual
assert not missing, f"SessionCodexAuthFlow 缺少必需方法: {missing}"
def test_main_codex_login_flow_saves_without_remote_sync(monkeypatch):
"""MainCodexLoginFlow 只保存本地主号 auth,不调用远端同步。"""
from autoteam import codex_auth
sync_calls = []
monkeypatch.setattr(codex_auth, "get_admin_email", lambda: "admin@example.com")
monkeypatch.setattr(codex_auth, "get_admin_session_token", lambda: "fake-session-token-abc123")
monkeypatch.setattr(codex_auth, "get_chatgpt_account_id", lambda: "ws-account-uuid-xyz")
monkeypatch.setattr(codex_auth, "get_chatgpt_workspace_name", lambda: "Master Team")
monkeypatch.setattr("autoteam.admin_state.get_admin_password", lambda: "admin-password")
monkeypatch.setattr(
codex_auth.SessionCodexAuthFlow,
"complete",
lambda self: {
"email": "admin@example.com",
"auth_file": "/tmp/codex-main-ws-account-uuid-xyz.json",
"plan_type": "team",
"bundle": _make_complete_bundle(),
},
)
monkeypatch.setattr(
"autoteam.sync_targets.sync_main_codex_to_configured_targets",
lambda filepath: sync_calls.append(filepath),
)
result = codex_auth.MainCodexLoginFlow().complete()
assert result == {
"email": "admin@example.com",
"auth_file": "/tmp/codex-main-ws-account-uuid-xyz.json",
"plan_type": "team",
}
assert sync_calls == []
def test_main_codex_login_flow_passes_saved_admin_password(monkeypatch):
"""主号 Codex 登录在 OTP 入口不可用时可回退保存的管理员密码。"""
from autoteam import codex_auth
captured = {}
def fake_init(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr(codex_auth, "get_admin_email", lambda: "admin@example.com")
monkeypatch.setattr(codex_auth, "get_admin_session_token", lambda: "fake-session-token-abc123")
monkeypatch.setattr(codex_auth, "get_chatgpt_account_id", lambda: "ws-account-uuid-xyz")
monkeypatch.setattr(codex_auth, "get_chatgpt_workspace_name", lambda: "Master Team")
monkeypatch.setattr("autoteam.admin_state.get_admin_password", lambda: "admin-password")
monkeypatch.setattr(codex_auth.SessionCodexAuthFlow, "__init__", fake_init)
codex_auth.MainCodexLoginFlow()
assert captured["password"] == "admin-password"
def test_session_codex_auth_flow_uses_password_when_otp_switch_unavailable(monkeypatch):
from autoteam import codex_auth
flow = codex_auth.SessionCodexAuthFlow(
email="admin@example.com",
session_token="session-token",
account_id="account-id",
workspace_name="workspace",
password="admin-password",
)
steps = iter([("password_required", None), ("completed", None)])
calls = []
monkeypatch.setattr(flow, "_detect_step", lambda: next(steps))
monkeypatch.setattr(flow, "_switch_password_to_otp", lambda: False)
monkeypatch.setattr(flow, "_auto_fill_password", lambda: calls.append("password") or True)
assert flow._advance(attempts=2) == {"step": "completed", "detail": None}
assert calls == ["password"]
def test_inject_auth_cookies_guards_account_id():
"""_inject_auth_cookies 必须用 `if self.account_id:` 守护,避免空字符串污染 _account cookie.
upstream codex_auth.py:1203 有此守卫;本地若没有需补。
本测试以源码静态扫描方式验证 — 不要求实际跑 Playwright。
"""
import inspect
from autoteam.codex_auth import SessionCodexAuthFlow
src = inspect.getsource(SessionCodexAuthFlow._inject_auth_cookies)
# 必须有 `if self.account_id:` 这种守卫(允许空白 / 注释微差异)
assert "if self.account_id" in src, (
"SessionCodexAuthFlow._inject_auth_cookies 必须用 `if self.account_id:` 守 _account cookie"
)
|