File size: 18,144 Bytes
e6cc775
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
090976a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
"""Round 8 — OAuth Personal Workspace 显式选择单测。

覆盖 spec `prompts/0426/spec/shared/oauth-workspace-selection.md` 关键路径:
  W-I1 三层兜底次序(decode → primary HTTP → UI fallback)
  W-I2 OAUTH_WS_NO_PERSONAL fail-fast 不重试
  W-I4 personal 三条件 OR 识别(structure / plan_type=free / is_personal)
  W-I6 evidence 不含 token / cookie 原值
  W-I7 不存在 time.sleep(8) 残留
"""
from __future__ import annotations

import base64
import json


class _FakeContext:
    def __init__(self, cookies):
        self._cookies = cookies

    def cookies(self, _url=None):
        return self._cookies


class _FakePage:
    """模拟 Playwright Page — 支持 cookies、evaluate、locator、goto。"""

    def __init__(self, cookies=None, evaluate_result=None, evaluate_exc=None,
                 url="https://auth.openai.com/sign-in-with-chatgpt/codex/consent",
                 locator_visible_count=0, goto_exc=None,
                 inner_text_value="Personal workspace",
                 page_title_value="OpenAI"):
        self.context = _FakeContext(cookies or [])
        self._evaluate_result = evaluate_result
        self._evaluate_exc = evaluate_exc
        self.url = url
        self._locator_visible_count = locator_visible_count
        self._goto_exc = goto_exc
        self._inner_text = inner_text_value
        self._title = page_title_value
        self.evaluate_calls = []
        self.goto_calls = []

    def evaluate(self, js, args=None):
        self.evaluate_calls.append((js[:50], args))
        if self._evaluate_exc:
            raise self._evaluate_exc
        return self._evaluate_result

    def goto(self, url, **kw):
        self.goto_calls.append(url)
        if self._goto_exc:
            raise self._goto_exc

    def inner_text(self, _sel, timeout=None):
        return self._inner_text

    def content(self):
        return self._inner_text

    def title(self):
        return self._title

    def locator(self, _sel):
        # 返回一个 mock locator,模拟 personal 按钮可见 + 点击成功
        page = self

        class _Loc:
            def __init__(self):
                self._first_called = False

            @property
            def first(self):
                if self._first_called:
                    return self
                self._first_called = True
                return self

            def is_visible(self, timeout=None):
                # 通过递减计数模拟前 N 个 selector 不可见、第 N+1 个可见
                page._locator_visible_count -= 1
                return page._locator_visible_count <= 0

            def inner_text(self, timeout=None):
                return "Personal account"

            def click(self, **kw):
                pass

        return _Loc()


def _make_jwt_session(workspaces):
    """构造 oai-oauth-session cookie value(JWT-like 三段格式,首段为 base64url payload)。

    实施按 spec §2.2.1 规则:含 "." → 取首段 base64url decode,所以将 payload 放首段。
    """
    payload = {"workspaces": workspaces, "user": {"email": "x@y.com"}}
    body = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
    return f"{body}.sig.tail"


def _make_plain_session(workspaces):
    """构造非 JWT 的 base64url 整段。"""
    payload = {"workspaces": workspaces}
    return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")


def test_decode_oauth_session_cookie_jwt_format():
    """spec §2.2.1 — JWT 三段格式取首段 base64url decode。"""
    from autoteam.oauth_workspace import decode_oauth_session_cookie

    workspaces = [{"id": "ws-1", "structure": "personal", "plan_type": "free"}]
    val = _make_jwt_session(workspaces)
    page = _FakePage(cookies=[{"name": "oai-oauth-session", "value": val}])

    result = decode_oauth_session_cookie(page)
    assert result is not None
    assert result["workspaces"][0]["id"] == "ws-1"


def test_decode_oauth_session_cookie_plain_b64():
    """非 JWT 格式整段 b64url decode 也应成功。"""
    from autoteam.oauth_workspace import decode_oauth_session_cookie

    workspaces = [{"id": "ws-2", "structure": "personal"}]
    val = _make_plain_session(workspaces)
    page = _FakePage(cookies=[{"name": "oai-oauth-session", "value": val}])

    result = decode_oauth_session_cookie(page)
    assert result is not None
    assert result["workspaces"][0]["id"] == "ws-2"


def test_decode_oauth_session_cookie_alt_name():
    """W-I1 alt — 兼容 oai-client-auth-session 这个 alt cookie name。"""
    from autoteam.oauth_workspace import decode_oauth_session_cookie

    workspaces = [{"id": "ws-3", "structure": "personal"}]
    val = _make_plain_session(workspaces)
    page = _FakePage(cookies=[{"name": "oai-client-auth-session", "value": val}])

    result = decode_oauth_session_cookie(page)
    assert result is not None and result["workspaces"][0]["id"] == "ws-3"


def test_decode_oauth_session_cookie_missing_returns_none():
    """没有目标 cookie 时应返回 None,不抛异常。"""
    from autoteam.oauth_workspace import decode_oauth_session_cookie

    page = _FakePage(cookies=[{"name": "other-cookie", "value": "x"}])
    assert decode_oauth_session_cookie(page) is None


def test_decode_oauth_session_cookie_corrupt_value_returns_none():
    """value 不可解码时应返回 None,不抛异常。"""
    from autoteam.oauth_workspace import decode_oauth_session_cookie

    page = _FakePage(cookies=[{"name": "oai-oauth-session", "value": "!@#$%^&*"}])
    assert decode_oauth_session_cookie(page) is None


def test_is_personal_workspace_three_or_conditions():
    """W-I4 — structure / plan_type=free / is_personal 三条件 OR 识别。"""
    from autoteam.oauth_workspace import _is_personal_workspace

    assert _is_personal_workspace({"structure": "personal"})
    assert _is_personal_workspace({"structure": "personal_v2"})
    assert _is_personal_workspace({"plan_type": "free"})
    assert _is_personal_workspace({"is_personal": True})
    # 三条件都不命中 → False
    assert not _is_personal_workspace(
        {"structure": "workspace", "plan_type": "team", "is_personal": False}
    )


def test_select_oauth_workspace_success_2xx():
    """spec §2.2.2 — 200 + body 含 continue_url → success+redirect。"""
    from autoteam.oauth_workspace import select_oauth_workspace

    page = _FakePage(evaluate_result={
        "ok": True, "status": 200,
        "body": json.dumps({"continue_url": "https://auth.openai.com/callback?code=X"}),
        "location": "",
    })
    ok, redirect, evidence = select_oauth_workspace(
        page, "ws-1", consent_url="https://auth.openai.com/consent",
    )
    assert ok is True
    assert "callback" in redirect
    assert evidence["http_status"] == 200


def test_select_oauth_workspace_success_302_with_location():
    """302 redirect + Location 头 → success+location。"""
    from autoteam.oauth_workspace import select_oauth_workspace

    page = _FakePage(evaluate_result={
        "ok": False, "status": 302,
        "body": "",
        "location": "https://auth.openai.com/authorize/callback?code=ABC",
    })
    ok, redirect, _ = select_oauth_workspace(
        page, "ws-1", consent_url="https://auth.openai.com/consent",
    )
    assert ok is True
    assert "callback" in redirect


def test_select_oauth_workspace_404_endpoint_error():
    """404 → ok=False evidence 含 http_status。"""
    from autoteam.oauth_workspace import select_oauth_workspace

    page = _FakePage(evaluate_result={
        "ok": False, "status": 404, "body": "Not Found", "location": "",
    })
    ok, redirect, evidence = select_oauth_workspace(
        page, "ws-1", consent_url="https://auth.openai.com/consent",
    )
    assert ok is False
    assert redirect is None
    assert evidence["http_status"] == 404


def test_select_oauth_workspace_401_marked_auth_required():
    """401/403 evidence detail 标 auth_or_sentinel_required。"""
    from autoteam.oauth_workspace import select_oauth_workspace

    page = _FakePage(evaluate_result={
        "ok": False, "status": 401, "body": "", "location": "",
    })
    ok, _, evidence = select_oauth_workspace(
        page, "ws-1", consent_url="https://auth.openai.com/consent",
    )
    assert ok is False
    assert evidence.get("detail") == "auth_or_sentinel_required"


def test_select_oauth_workspace_evaluate_exception_returns_false():
    """page.evaluate 抛异常 → ok=False evidence 含 exception 类型,不向上传。"""
    from autoteam.oauth_workspace import select_oauth_workspace

    page = _FakePage(evaluate_exc=RuntimeError("boom"))
    ok, _, evidence = select_oauth_workspace(
        page, "ws-1", consent_url="https://auth.openai.com/consent",
    )
    assert ok is False
    assert "RuntimeError" in (evidence.get("exception") or "")


def test_ensure_personal_no_personal_workspace_fail_fast():
    """W-I2 — workspaces[] 不含 personal → OAUTH_WS_NO_PERSONAL,不走 fallback。"""
    from autoteam.oauth_workspace import (
        OAUTH_WS_NO_PERSONAL,
        ensure_personal_workspace_selected,
    )

    val = _make_plain_session([
        {"id": "ws-team", "structure": "workspace", "plan_type": "team"}
    ])
    page = _FakePage(cookies=[{"name": "oai-oauth-session", "value": val}])

    ok, fail_cat, evidence = ensure_personal_workspace_selected(
        page, consent_url="https://auth.openai.com/consent",
    )
    assert ok is False
    assert fail_cat == OAUTH_WS_NO_PERSONAL
    # 不应走 fallback(W-I2)
    assert "fallback" not in evidence


def test_ensure_personal_primary_path_success():
    """spec §2.2.4 — workspaces[] 含 personal + primary 200 → ok=True。"""
    from autoteam.oauth_workspace import ensure_personal_workspace_selected

    val = _make_plain_session([
        {"id": "ws-personal", "structure": "personal", "plan_type": "free"}
    ])
    page = _FakePage(
        cookies=[{"name": "oai-oauth-session", "value": val}],
        evaluate_result={
            "ok": True, "status": 200,
            "body": json.dumps({"continue_url": "https://x"}),
            "location": "",
        },
    )

    ok, fail_cat, evidence = ensure_personal_workspace_selected(
        page, consent_url="https://auth.openai.com/consent",
    )
    assert ok is True
    assert fail_cat == ""
    assert evidence["primary"]["http_status"] == 200


def test_ensure_personal_primary_fail_fallback_success():
    """主路径失败 → UI fallback 成功 → ok=True(三层兜底)。"""
    from autoteam.oauth_workspace import ensure_personal_workspace_selected

    val = _make_plain_session([
        {"id": "ws-personal", "structure": "personal"}
    ])
    page = _FakePage(
        cookies=[{"name": "oai-oauth-session", "value": val}],
        evaluate_result={"ok": False, "status": 404, "body": "", "location": ""},
        url="https://auth.openai.com/workspace",  # _is_workspace_selection_page 触发
        inner_text_value="select a workspace",
        locator_visible_count=1,  # 第 1 个 selector 不可见,第 2 个可见
    )

    ok, fail_cat, evidence = ensure_personal_workspace_selected(
        page, consent_url="https://auth.openai.com/consent",
    )
    # primary 失败 → fallback 应被调用
    assert evidence.get("primary_failed") is True
    assert "fallback" in evidence
    assert ok is True
    assert fail_cat == ""


def test_ensure_personal_primary_and_fallback_both_fail():
    """主路径 + fallback 都失败 → OAUTH_WS_ENDPOINT_ERROR。"""
    from autoteam.oauth_workspace import (
        OAUTH_WS_ENDPOINT_ERROR,
        ensure_personal_workspace_selected,
    )

    val = _make_plain_session([
        {"id": "ws-personal", "structure": "personal"}
    ])
    # primary 404 + fallback URL 不是 workspace 页
    page = _FakePage(
        cookies=[{"name": "oai-oauth-session", "value": val}],
        evaluate_result={"ok": False, "status": 404, "body": "", "location": ""},
        url="https://example.com",  # 非 workspace 页
        inner_text_value="other content",
    )

    ok, fail_cat, _ = ensure_personal_workspace_selected(
        page, consent_url="https://auth.openai.com/consent",
    )
    assert ok is False
    assert fail_cat == OAUTH_WS_ENDPOINT_ERROR


def test_ensure_personal_decode_failed_falls_back_to_ui():
    """W-I3 — cookie 解码失败应 fallback 到 UI(不直接 fail)。"""
    from autoteam.oauth_workspace import ensure_personal_workspace_selected

    page = _FakePage(
        cookies=[],  # 没有 cookie → decode 返回 None
        url="https://example.com",
        inner_text_value="other",
    )
    # primary 不会执行(没 personal id),只走 fallback,fallback 失败
    ok, fail_cat, evidence = ensure_personal_workspace_selected(
        page, consent_url="https://auth.openai.com/consent",
    )
    assert ok is False
    # primary skip,只看 fallback
    assert "fallback" in evidence


def test_redact_workspaces_strips_sensitive_fields():
    """W-I6 — _redact_workspaces 应只保留白名单字段,不暴露 token。"""
    from autoteam.oauth_workspace import _redact_workspaces

    raw = [{
        "id": "ws-1",
        "name": "My Personal",
        "structure": "personal",
        "role": "owner",
        "plan_type": "free",
        "access_token": "DO_NOT_PERSIST",
        "session_token": "DO_NOT_PERSIST",
    }]
    out = _redact_workspaces(raw)
    assert len(out) == 1
    assert "access_token" not in out[0]
    assert "session_token" not in out[0]
    assert out[0]["id"] == "ws-1"


def test_no_time_sleep_8_in_manager_personal_branch():
    """W-I7 — manager.py _run_post_register_oauth personal 分支不应残留可执行的 time.sleep(8)。

    注释里出现 "time.sleep(8)" 字面串(描述 Round 8 删除决策)是允许的,
    所以筛选去注释行后再校验。
    """
    import inspect
    import re

    from autoteam import manager

    src = inspect.getsource(manager._run_post_register_oauth)
    # 移除注释行(以 # 开头的整行)再校验,只检查可执行代码
    lines = []
    for line in src.splitlines():
        stripped = line.lstrip()
        if stripped.startswith("#"):
            continue
        lines.append(line)
    code_only = "\n".join(lines)
    # 不应有可执行的 time.sleep(8) — Round 8 删了这个旧的"等 default unset"魔法等待
    assert not re.search(r"^\s*time\.sleep\(8\)", code_only, re.MULTILINE)
    assert "Round 8" in src  # 说明已应用 Round 8 改造


def test_ensure_personal_skip_ui_fallback_on_empty_workspaces():
    """Round 11 三轮 — skip_ui_fallback_on_empty=True 时,空 workspaces[] 不调用 UI fallback。

    场景:刚踢出 Team 的新号,oai-oauth-session.workspaces=[](server-side 状态),
    /workspace UI 显示 "Workspaces not found in client auth session" 错误页。
    如果调用方传 skip_ui_fallback_on_empty=True,本函数必须直接 fail-fast 不导航,
    避免浏览器停在错误页阻塞外层 consent loop。

    断言:
      - 返回 (False, OAUTH_WS_ENDPOINT_ERROR, evidence)
      - evidence.primary.phase == "skipped_empty_workspaces"
      - evidence.fallback.phase == "skipped_by_caller_request"
      - page.goto_calls 不含 /workspace(没导航)
    """
    from autoteam.oauth_workspace import (
        OAUTH_WS_ENDPOINT_ERROR,
        ensure_personal_workspace_selected,
    )

    # 构造 cookie:可解码但 workspaces=[]
    session_payload = {"workspaces": []}
    cookie_val = base64.b64encode(json.dumps(session_payload).encode()).decode()
    page = _FakePage(
        cookies=[{"name": "oai-oauth-session", "value": cookie_val}],
        url="https://auth.openai.com/sign-in-with-chatgpt/codex/consent",
    )

    ok, fail_cat, evidence = ensure_personal_workspace_selected(
        page,
        consent_url="https://auth.openai.com/oauth/authorize?client_id=codex",
        skip_ui_fallback_on_empty=True,
    )

    assert ok is False, "空 workspaces[] + skip_ui_fallback_on_empty=True 必须 fail"
    assert fail_cat == OAUTH_WS_ENDPOINT_ERROR
    assert evidence["primary"]["phase"] == "skipped_empty_workspaces"
    assert evidence["fallback"]["phase"] == "skipped_by_caller_request", (
        "fallback.phase 必须明示是 caller 请求跳过(skipped_by_caller_request),"
        "不应是 force_select_personal_via_ui 的实际 phase"
    )
    # 关键:不应该导航到 /workspace UI
    assert not any("workspace" in url for url in page.goto_calls), (
        f"skip_ui_fallback_on_empty=True 时不应 goto /workspace,"
        f"但实际 goto_calls={page.goto_calls}"
    )


def test_ensure_personal_default_still_falls_back_on_empty_workspaces():
    """Round 11 三轮 — skip_ui_fallback_on_empty 默认 False,保留旧行为(走 UI fallback)。

    本测试守住默认参数语义,防止未来误改默认值导致非 codex_auth 调用方行为偏移。
    """
    from autoteam.oauth_workspace import ensure_personal_workspace_selected

    session_payload = {"workspaces": []}
    cookie_val = base64.b64encode(json.dumps(session_payload).encode()).decode()
    page = _FakePage(
        cookies=[{"name": "oai-oauth-session", "value": cookie_val}],
        url="https://auth.openai.com/sign-in-with-chatgpt/codex/consent",
    )

    # 默认参数 — 不传 skip_ui_fallback_on_empty
    ok, _fail_cat, evidence = ensure_personal_workspace_selected(
        page,
        consent_url="https://auth.openai.com/oauth/authorize?client_id=codex",
    )

    # 默认应该走 UI fallback(_FakePage 不会真的找到 personal button → 失败)
    assert evidence["primary"]["phase"] == "skipped_empty_workspaces"
    # fallback 应该被尝试(phase 不是 skipped_by_caller_request)
    fb_phase = evidence.get("fallback", {}).get("phase", "")
    assert fb_phase != "skipped_by_caller_request", (
        "默认参数不应该跳过 fallback,fallback.phase 应该是实际尝试的状态"
    )
    # ok 是 False 因为 _FakePage 没有真正的 Personal button
    assert ok is False