test_cpa_oauth.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. import unittest
  2. from unittest.mock import patch
  3. class CallbackUrlTests(unittest.TestCase):
  4. def test_accepts_localhost_auth_and_codex_callbacks_with_code_and_state(self):
  5. import cpa_oauth
  6. self.assertTrue(cpa_oauth.is_localhost_oauth_callback_url("http://localhost:1455/auth/callback?code=abc&state=xyz"))
  7. self.assertTrue(cpa_oauth.is_localhost_oauth_callback_url("http://127.0.0.1:1455/codex/callback?code=abc&state=xyz"))
  8. def test_rejects_non_localhost_or_missing_oauth_params(self):
  9. import cpa_oauth
  10. self.assertFalse(cpa_oauth.is_localhost_oauth_callback_url("https://example.com/auth/callback?code=abc&state=xyz"))
  11. self.assertFalse(cpa_oauth.is_localhost_oauth_callback_url("http://localhost:1455/auth/callback?code=abc"))
  12. self.assertFalse(cpa_oauth.is_localhost_oauth_callback_url("http://localhost:1455/other?code=abc&state=xyz"))
  13. class FetchOauthUrlTests(unittest.TestCase):
  14. def test_stops_retrying_when_management_login_stays_on_login_page(self):
  15. import cpa_oauth
  16. class _FakeLocator:
  17. def __init__(self, page, selector):
  18. self.page = page
  19. self.selector = selector
  20. def count(self):
  21. if "password" in self.selector:
  22. return 1
  23. if 'has-text("Login")' in self.selector:
  24. return 1
  25. return 0
  26. def nth(self, _idx):
  27. return self
  28. def is_visible(self):
  29. return True
  30. def is_enabled(self):
  31. return True
  32. def fill(self, value):
  33. self.page.filled.append((self.selector, value))
  34. def click(self, **_kwargs):
  35. self.page.clicked.append(self.selector)
  36. class _FakePage:
  37. def __init__(self):
  38. self.url = ""
  39. self.filled = []
  40. self.clicked = []
  41. def goto(self, url, **_kwargs):
  42. self.url = url.rsplit("#", 1)[0] + "#/login"
  43. def wait_for_timeout(self, *_args, **_kwargs):
  44. pass
  45. def locator(self, selector):
  46. return _FakeLocator(self, selector)
  47. page = _FakePage()
  48. with self.assertRaisesRegex(RuntimeError, "CPA 管理登录失败"):
  49. cpa_oauth.fetch_cpa_oauth_url(
  50. page,
  51. cpa_url="https://cpa.example/management.html#/oauth",
  52. management_key="bad-key",
  53. log=lambda *_: None,
  54. timeout_sec=5,
  55. )
  56. self.assertLessEqual(len(page.clicked), 3)
  57. def test_waits_for_oauth_panel_after_management_login(self):
  58. import cpa_oauth
  59. class _FakeLocator:
  60. def __init__(self, page, selector):
  61. self.page = page
  62. self.selector = selector
  63. def count(self):
  64. if "authUrlValue" in self.selector:
  65. if self.page.state == "oauth_generating":
  66. self.page.auth_url_polls += 1
  67. if self.page.auth_url_polls >= 2:
  68. self.page.state = "auth_url"
  69. return 1 if self.page.state == "auth_url" else 0
  70. if "password" in self.selector:
  71. return 1 if self.page.state == "login" else 0
  72. if 'has-text("Login")' in self.selector or 'has-text("登录")' in self.selector:
  73. return 1 if self.page.state in ("login", "oauth_card") else 0
  74. return 0
  75. def nth(self, _idx):
  76. return self
  77. def is_visible(self):
  78. return True
  79. def is_enabled(self):
  80. return True
  81. def fill(self, value):
  82. self.page.filled.append((self.selector, value))
  83. def click(self, **_kwargs):
  84. self.page.clicked.append((self.page.state, self.selector))
  85. if self.page.state == "login":
  86. self.page.state = "loading_after_login"
  87. elif self.page.state == "oauth_card":
  88. self.page.state = "oauth_generating"
  89. def inner_text(self, **_kwargs):
  90. if self.page.state == "auth_url":
  91. return "https://auth.openai.com/oauth/authorize?client_id=codex"
  92. return ""
  93. class _FakePage:
  94. def __init__(self):
  95. self.url = ""
  96. self.state = "new"
  97. self.wait_calls = 0
  98. self.auth_url_polls = 0
  99. self.clicked = []
  100. self.filled = []
  101. def goto(self, url, **_kwargs):
  102. self.url = url.rsplit("#", 1)[0] + "#/login"
  103. self.state = "login"
  104. def wait_for_timeout(self, *_args, **_kwargs):
  105. self.wait_calls += 1
  106. if self.state == "loading_after_login" and self.wait_calls >= 3:
  107. self.url = self.url.rsplit("#", 1)[0] + "#/oauth"
  108. self.state = "oauth_card"
  109. def locator(self, selector):
  110. return _FakeLocator(self, selector)
  111. page = _FakePage()
  112. oauth_url = cpa_oauth.fetch_cpa_oauth_url(
  113. page,
  114. cpa_url="https://cpa.example/management.html#/oauth",
  115. management_key="secret",
  116. log=lambda *_: None,
  117. timeout_sec=2,
  118. )
  119. self.assertTrue(oauth_url.startswith("https://auth.openai.com/oauth/authorize"))
  120. self.assertTrue(any(state == "login" for state, _selector in page.clicked))
  121. self.assertTrue(any(state == "oauth_card" for state, _selector in page.clicked))
  122. def test_reopens_oauth_route_when_management_login_lands_on_home(self):
  123. import cpa_oauth
  124. class _FakeLocator:
  125. def __init__(self, page, selector):
  126. self.page = page
  127. self.selector = selector
  128. def count(self):
  129. if "authUrlValue" in self.selector:
  130. if self.page.state == "oauth_generating":
  131. self.page.state = "auth_url"
  132. return 1 if self.page.state == "auth_url" else 0
  133. if "password" in self.selector:
  134. return 1 if self.page.state == "login" else 0
  135. if 'has-text("Login")' in self.selector or 'has-text("登录")' in self.selector:
  136. return 1 if self.page.state in ("login", "oauth_card") else 0
  137. return 0
  138. def nth(self, _idx):
  139. return self
  140. def is_visible(self):
  141. return True
  142. def is_enabled(self):
  143. return True
  144. def fill(self, value):
  145. self.page.filled.append((self.selector, value))
  146. def click(self, **_kwargs):
  147. self.page.clicked.append((self.page.state, self.selector))
  148. if self.page.state == "login":
  149. self.page.url = "https://cpa.example/management.html#/"
  150. self.page.state = "home"
  151. elif self.page.state == "oauth_card":
  152. self.page.state = "oauth_generating"
  153. def inner_text(self, **_kwargs):
  154. if self.page.state == "auth_url":
  155. return "https://auth.openai.com/oauth/authorize?client_id=codex"
  156. return ""
  157. class _FakePage:
  158. def __init__(self):
  159. self.url = ""
  160. self.state = "new"
  161. self.goto_calls = []
  162. self.clicked = []
  163. self.filled = []
  164. def goto(self, url, **_kwargs):
  165. self.goto_calls.append(url)
  166. self.url = url
  167. if len(self.goto_calls) == 1:
  168. self.url = "https://cpa.example/management.html#/login"
  169. self.state = "login"
  170. elif url.endswith("#/oauth"):
  171. self.state = "oauth_card"
  172. def wait_for_timeout(self, *_args, **_kwargs):
  173. pass
  174. def locator(self, selector):
  175. return _FakeLocator(self, selector)
  176. page = _FakePage()
  177. oauth_url = cpa_oauth.fetch_cpa_oauth_url(
  178. page,
  179. cpa_url="https://cpa.example/management.html#/oauth",
  180. management_key="secret",
  181. log=lambda *_: None,
  182. timeout_sec=2,
  183. )
  184. self.assertTrue(oauth_url.startswith("https://auth.openai.com/oauth/authorize"))
  185. self.assertGreaterEqual(page.goto_calls.count("https://cpa.example/management.html#/oauth"), 2)
  186. self.assertTrue(any(state == "home" for state, _selector in page.clicked) is False)
  187. class SubmitCallbackTests(unittest.TestCase):
  188. def test_submit_callback_fills_url_and_clicks_submit(self):
  189. import cpa_oauth
  190. class _FakeLocator:
  191. def __init__(self, page, selector):
  192. self.page = page
  193. self.selector = selector
  194. def count(self):
  195. if self.selector == 'input[placeholder*="localhost"]':
  196. return 1
  197. if "Submit Callback URL" in self.selector:
  198. return 1
  199. return 0
  200. def nth(self, _idx):
  201. return self
  202. def is_visible(self):
  203. return True
  204. def is_enabled(self):
  205. return True
  206. @property
  207. def first(self):
  208. return self
  209. def click(self, **_kwargs):
  210. self.page.clicked.append(self.selector)
  211. def fill(self, value):
  212. self.page.filled.append((self.selector, value))
  213. class _FakePage:
  214. def __init__(self):
  215. self.url = ""
  216. self.clicked = []
  217. self.filled = []
  218. self.goto_calls = []
  219. def goto(self, url, **_kwargs):
  220. self.url = url
  221. self.goto_calls.append(url)
  222. def locator(self, selector):
  223. return _FakeLocator(self, selector)
  224. def wait_for_timeout(self, *_args, **_kwargs):
  225. pass
  226. page = _FakePage()
  227. callback_url = "http://localhost:1455/auth/callback?code=abc&state=xyz"
  228. cpa_oauth.submit_oauth_callback_to_cpa(
  229. page,
  230. cpa_url="https://cpa.example/management.html#/oauth",
  231. callback_url=callback_url,
  232. log=lambda *_: None,
  233. wait_for_success=False,
  234. )
  235. self.assertEqual(page.goto_calls, ["https://cpa.example/management.html#/oauth"])
  236. self.assertEqual(page.filled, [('input[placeholder*="localhost"]', callback_url)])
  237. self.assertTrue(any("Submit Callback URL" in selector for selector in page.clicked))
  238. class CaptureCallbackTests(unittest.TestCase):
  239. def test_fills_email_hint_when_openai_oauth_shows_email_prompt(self):
  240. import cpa_oauth
  241. callback_url = "http://localhost:1455/auth/callback?code=abc&state=xyz"
  242. class _FakeLocator:
  243. def __init__(self, page, selector):
  244. self.page = page
  245. self.selector = selector
  246. def count(self):
  247. if 'input[type="email"]' in self.selector:
  248. return 1 if self.page.state == "email_prompt" else 0
  249. if 'button[type="submit"]' in self.selector:
  250. return 1 if self.page.state == "email_prompt" else 0
  251. return 0
  252. def nth(self, _idx):
  253. return self
  254. def is_visible(self):
  255. return True
  256. def is_enabled(self):
  257. return True
  258. def fill(self, value):
  259. self.page.filled.append((self.selector, value))
  260. def click(self, **_kwargs):
  261. self.page.clicked.append(self.selector)
  262. self.page.url = callback_url
  263. self.page.state = "callback"
  264. class _FakePage:
  265. def __init__(self):
  266. self.url = ""
  267. self.state = "new"
  268. self.filled = []
  269. self.clicked = []
  270. def goto(self, url, **_kwargs):
  271. self.url = url
  272. self.state = "email_prompt"
  273. def wait_for_timeout(self, *_args, **_kwargs):
  274. pass
  275. def locator(self, selector):
  276. return _FakeLocator(self, selector)
  277. page = _FakePage()
  278. captured = cpa_oauth.approve_openai_oauth_and_capture_callback(
  279. page,
  280. "https://auth.openai.com/oauth/authorize?client_id=codex",
  281. email_hint="n@example.com",
  282. log=lambda *_: None,
  283. )
  284. self.assertEqual(captured, callback_url)
  285. self.assertEqual(page.filled, [('input[type="email"]', "n@example.com")])
  286. self.assertIn('button[type="submit"]', page.clicked)
  287. def test_clicks_delayed_codex_consent_continue_after_email_continue(self):
  288. import cpa_oauth
  289. callback_url = "http://localhost:1455/auth/callback?code=abc&state=xyz"
  290. class _FakeLocator:
  291. def __init__(self, page, selector):
  292. self.page = page
  293. self.selector = selector
  294. def count(self):
  295. if 'input[type="email"]' in self.selector:
  296. return 1 if self.page.state == "email_prompt" else 0
  297. if 'button[type="submit"]' in self.selector:
  298. if self.page.state == "email_prompt":
  299. return 1
  300. if self.page.state == "loading_consent":
  301. self.page.button_polls += 1
  302. if self.page.button_polls >= 2:
  303. self.page.state = "consent"
  304. self.page.url = "https://auth.openai.com/sign-in-with-chatgpt/codex/consent"
  305. return 1
  306. return 1 if self.page.state == "consent" else 0
  307. return 0
  308. def nth(self, _idx):
  309. return self
  310. def is_visible(self):
  311. return True
  312. def is_enabled(self):
  313. return True
  314. def fill(self, value):
  315. self.page.filled.append((self.selector, value))
  316. def click(self, **_kwargs):
  317. self.page.clicked.append((self.page.state, self.selector))
  318. if self.page.state == "email_prompt":
  319. self.page.url = "https://auth.openai.com/log-in"
  320. self.page.state = "loading_consent"
  321. elif self.page.state == "consent":
  322. self.page.url = callback_url
  323. self.page.state = "callback"
  324. class _FakePage:
  325. def __init__(self):
  326. self.url = ""
  327. self.state = "new"
  328. self.button_polls = 0
  329. self.filled = []
  330. self.clicked = []
  331. def goto(self, url, **_kwargs):
  332. self.url = url
  333. self.state = "email_prompt"
  334. def wait_for_timeout(self, *_args, **_kwargs):
  335. pass
  336. def locator(self, selector):
  337. return _FakeLocator(self, selector)
  338. page = _FakePage()
  339. captured = cpa_oauth.approve_openai_oauth_and_capture_callback(
  340. page,
  341. "https://auth.openai.com/oauth/authorize?client_id=codex",
  342. email_hint="n@example.com",
  343. log=lambda *_: None,
  344. timeout_sec=1,
  345. )
  346. self.assertEqual(captured, callback_url)
  347. self.assertIn(("email_prompt", 'button[type="submit"]'), page.clicked)
  348. self.assertIn(("consent", 'button[type="submit"]'), page.clicked)
  349. def test_submits_email_only_once_when_prompt_remains_visible_before_consent(self):
  350. import cpa_oauth
  351. callback_url = "http://localhost:1455/auth/callback?code=abc&state=xyz"
  352. class _FakeLocator:
  353. def __init__(self, page, selector):
  354. self.page = page
  355. self.selector = selector
  356. def count(self):
  357. if 'input[type="email"]' in self.selector:
  358. return 1 if self.page.state in ("email_prompt", "loading_consent") else 0
  359. if 'button[type="submit"]' in self.selector:
  360. return 1 if self.page.state in ("email_prompt", "loading_consent", "consent") else 0
  361. return 0
  362. def nth(self, _idx):
  363. return self
  364. def is_visible(self):
  365. return True
  366. def is_enabled(self):
  367. return True
  368. def fill(self, value):
  369. self.page.filled.append((self.page.state, self.selector, value))
  370. def click(self, **_kwargs):
  371. self.page.clicked.append((self.page.state, self.selector))
  372. if self.page.state == "email_prompt":
  373. self.page.state = "loading_consent"
  374. self.page.url = "https://auth.openai.com/log-in"
  375. elif self.page.state == "loading_consent":
  376. self.page.state = "consent"
  377. self.page.url = "https://auth.openai.com/sign-in-with-chatgpt/codex/consent"
  378. elif self.page.state == "consent":
  379. self.page.url = callback_url
  380. self.page.state = "callback"
  381. class _FakePage:
  382. def __init__(self):
  383. self.url = ""
  384. self.state = "new"
  385. self.filled = []
  386. self.clicked = []
  387. def goto(self, url, **_kwargs):
  388. self.url = url
  389. self.state = "email_prompt"
  390. def wait_for_timeout(self, *_args, **_kwargs):
  391. pass
  392. def locator(self, selector):
  393. return _FakeLocator(self, selector)
  394. page = _FakePage()
  395. captured = cpa_oauth.approve_openai_oauth_and_capture_callback(
  396. page,
  397. "https://auth.openai.com/oauth/authorize?client_id=codex",
  398. email_hint="n@example.com",
  399. log=lambda *_: None,
  400. timeout_sec=1,
  401. )
  402. self.assertEqual(captured, callback_url)
  403. self.assertEqual(page.filled, [("email_prompt", 'input[type="email"]', "n@example.com")])
  404. self.assertIn(("consent", 'button[type="submit"]'), page.clicked)
  405. def test_detects_callback_opened_in_another_context_page(self):
  406. import cpa_oauth
  407. callback_url = "http://localhost:1455/auth/callback?code=abc&state=xyz"
  408. class _FakeContext:
  409. def __init__(self):
  410. self.pages = []
  411. class _FakeCallbackPage:
  412. def __init__(self, url):
  413. self.url = url
  414. class _FakeLocator:
  415. def __init__(self, page, selector):
  416. self.page = page
  417. self.selector = selector
  418. def count(self):
  419. if 'button[type="submit"]' in self.selector:
  420. return 1
  421. return 0
  422. def nth(self, _idx):
  423. return self
  424. def is_visible(self):
  425. return True
  426. def is_enabled(self):
  427. return True
  428. def click(self, **_kwargs):
  429. self.page.clicked.append(self.selector)
  430. self.page.context.pages.append(_FakeCallbackPage(callback_url))
  431. class _FakePage:
  432. def __init__(self):
  433. self.url = ""
  434. self.clicked = []
  435. self.context = _FakeContext()
  436. self.context.pages.append(self)
  437. def goto(self, url, **_kwargs):
  438. self.url = "https://auth.openai.com/sign-in-with-chatgpt/codex/consent"
  439. def wait_for_timeout(self, *_args, **_kwargs):
  440. pass
  441. def locator(self, selector):
  442. return _FakeLocator(self, selector)
  443. page = _FakePage()
  444. captured = cpa_oauth.approve_openai_oauth_and_capture_callback(
  445. page,
  446. "https://auth.openai.com/oauth/authorize?client_id=codex",
  447. log=lambda *_: None,
  448. timeout_sec=1,
  449. )
  450. self.assertEqual(captured, callback_url)
  451. self.assertIn('button[type="submit"]', page.clicked)
  452. def test_capture_callback_even_when_localhost_navigation_raises(self):
  453. import cpa_oauth
  454. callback_url = "http://localhost:1455/auth/callback?code=abc&state=xyz"
  455. class _FakePage:
  456. def __init__(self):
  457. self.url = ""
  458. def goto(self, *_args, **_kwargs):
  459. self.url = callback_url
  460. raise RuntimeError("net::ERR_CONNECTION_REFUSED")
  461. def wait_for_timeout(self, *_args, **_kwargs):
  462. pass
  463. def locator(self, _selector):
  464. raise AssertionError("callback 已捕获时不应继续查找按钮")
  465. captured = cpa_oauth.approve_openai_oauth_and_capture_callback(
  466. _FakePage(),
  467. "https://auth.openai.com/oauth",
  468. log=lambda *_: None,
  469. )
  470. self.assertEqual(captured, callback_url)
  471. class AuthorizeFlowTests(unittest.TestCase):
  472. def test_authorize_keeps_cpa_panel_page_and_uses_separate_oauth_page(self):
  473. import cpa_oauth
  474. class _FakeContext:
  475. def __init__(self, oauth_page):
  476. self.oauth_page = oauth_page
  477. self.new_page_calls = 0
  478. def new_page(self):
  479. self.new_page_calls += 1
  480. return self.oauth_page
  481. class _FakePage:
  482. def __init__(self, name):
  483. self.name = name
  484. self.closed = False
  485. self.context = None
  486. def close(self):
  487. self.closed = True
  488. panel_page = _FakePage("panel")
  489. oauth_page = _FakePage("oauth")
  490. panel_page.context = _FakeContext(oauth_page)
  491. def fake_approve(page, oauth_url, **kwargs):
  492. self.assertIs(page, oauth_page)
  493. self.assertEqual(oauth_url, "https://auth.openai.com/oauth/authorize")
  494. self.assertEqual(kwargs["email_hint"], "n@example.com")
  495. return "http://localhost:1455/auth/callback?code=abc&state=xyz"
  496. def fake_submit(page, **kwargs):
  497. self.assertIs(page, panel_page)
  498. self.assertEqual(kwargs["callback_url"], "http://localhost:1455/auth/callback?code=abc&state=xyz")
  499. return "Authentication successful!"
  500. with patch.object(cpa_oauth, "fetch_cpa_oauth_url", return_value="https://auth.openai.com/oauth/authorize") as fetch:
  501. with patch.object(cpa_oauth, "approve_openai_oauth_and_capture_callback", side_effect=fake_approve) as approve:
  502. with patch.object(cpa_oauth, "submit_oauth_callback_to_cpa", side_effect=fake_submit) as submit:
  503. result = cpa_oauth.authorize_codex_oauth_to_cpa(
  504. panel_page,
  505. cpa_url="https://cpa.example/management.html#/oauth",
  506. management_key="secret",
  507. email_hint="n@example.com",
  508. log=lambda *_: None,
  509. )
  510. fetch.assert_called_once()
  511. approve.assert_called_once()
  512. submit.assert_called_once()
  513. self.assertEqual(panel_page.context.new_page_calls, 1)
  514. self.assertTrue(oauth_page.closed)
  515. self.assertFalse(panel_page.closed)
  516. self.assertEqual(result["callbackUrl"], "http://localhost:1455/auth/callback?code=abc&state=xyz")
  517. if __name__ == "__main__":
  518. unittest.main()