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"
    )