test_chatgpt_register.py 59 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465
  1. import json
  2. from dataclasses import dataclass
  3. from pathlib import Path
  4. from core.http_client import RequestConfig
  5. import platforms.chatgpt.register as register_module
  6. from platforms.chatgpt.http_client import OpenAIHTTPClient
  7. from platforms.chatgpt.oauth import OAuthStart
  8. from platforms.chatgpt.register import RegistrationEngine, SignupFormResult
  9. from platforms.chatgpt.token_refresh import TokenRefreshResult
  10. class DummyEmailService:
  11. def create_email(self, config=None): # type: ignore[no-untyped-def]
  12. del config
  13. return {"email": "unused@example.com"}
  14. def get_verification_code(self, **kwargs): # type: ignore[no-untyped-def]
  15. del kwargs
  16. return ""
  17. @dataclass
  18. class FakeCookieItem:
  19. name: str
  20. value: str
  21. class FakeCookies(dict[str, str]):
  22. @property
  23. def jar(self) -> list[FakeCookieItem]:
  24. return [FakeCookieItem(name=name, value=value) for name, value in self.items()]
  25. class FakeResponse:
  26. def __init__(
  27. self,
  28. status_code: int,
  29. *,
  30. url: str = "",
  31. headers: dict[str, str] | None = None,
  32. json_data: dict[str, object] | None = None,
  33. text: str = "",
  34. ) -> None:
  35. self.status_code = status_code
  36. self.url = url
  37. self.headers = headers or {}
  38. self._json_data = json_data
  39. self.text = text or (json.dumps(json_data) if json_data is not None else "")
  40. def json(self) -> dict[str, object]:
  41. if self._json_data is None:
  42. raise ValueError("json unavailable")
  43. return self._json_data
  44. def _workspace_cookie(workspace_id: str) -> str:
  45. payload = json.dumps({"workspaces": [{"id": workspace_id}]}, separators=(",", ":")).encode("utf-8")
  46. encoded = register_module.base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
  47. return f"{encoded}.sig"
  48. def _session_cookie(payload: dict[str, object]) -> str:
  49. raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
  50. encoded = register_module.base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
  51. return f"{encoded}.sig"
  52. class FakeSession:
  53. def __init__(self) -> None:
  54. self.cookies = FakeCookies({"oai-client-auth-session": _workspace_cookie("ws-123")})
  55. self.calls: list[tuple[str, str, dict[str, object]]] = []
  56. def get(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  57. self.calls.append(("GET", url, kwargs))
  58. if url.startswith("https://auth.openai.com/oauth/authorize"):
  59. self.cookies["oai-did"] = "did-123"
  60. self.cookies["login_session"] = "login-session"
  61. return FakeResponse(200, url="https://auth.openai.com/log-in")
  62. if url == "https://auth.openai.com/continue-after-password":
  63. return FakeResponse(200, url=url)
  64. raise AssertionError(f"unexpected GET: {url}")
  65. def post(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  66. self.calls.append(("POST", url, kwargs))
  67. if url.endswith("/authorize/continue"):
  68. return FakeResponse(
  69. 200,
  70. url=url,
  71. json_data={"continue_url": "/log-in/password", "page": {"type": "password"}},
  72. )
  73. if url.endswith("/password/verify"):
  74. return FakeResponse(
  75. 200,
  76. url=url,
  77. json_data={"continue_url": "/continue-after-password", "page": {"type": "consent"}},
  78. )
  79. if url.endswith("/workspace/select"):
  80. return FakeResponse(
  81. 200,
  82. url=url,
  83. json_data={
  84. "continue_url": "/organization-continue",
  85. "data": {"orgs": [{"id": "org-456", "projects": [{"id": "proj-789"}]}]},
  86. },
  87. )
  88. if url.endswith("/organization/select"):
  89. return FakeResponse(
  90. 302,
  91. url=url,
  92. headers={
  93. "Location": "http://localhost:1455/auth/callback?code=oauth-code&state=demo-state",
  94. },
  95. )
  96. raise AssertionError(f"unexpected POST: {url}")
  97. class NoWorkspaceSession:
  98. def __init__(self) -> None:
  99. self.cookies = FakeCookies({})
  100. self.calls: list[tuple[str, str, dict[str, object]]] = []
  101. def get(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  102. self.calls.append(("GET", url, kwargs))
  103. if url.endswith("/authorize/continue"):
  104. return FakeResponse(200, url=url)
  105. raise AssertionError(f"unexpected GET: {url}")
  106. class DumpWorkspaceSession(FakeSession):
  107. def __init__(self) -> None:
  108. super().__init__()
  109. self.cookies = FakeCookies(
  110. {
  111. "oai-client-auth-session": _session_cookie(
  112. {
  113. "session_id": "authsess_demo",
  114. "openai_client_id": "app_demo",
  115. "app_name_enum": "oaicli",
  116. "auth_session_logging_id": "trace-demo",
  117. }
  118. ),
  119. "auth-session-minimized-client-checksum": json.dumps({"affinity": "checksum-demo"}),
  120. }
  121. )
  122. def get(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  123. self.calls.append(("GET", url, kwargs))
  124. if url == "https://auth.openai.com/api/accounts/client_auth_session_dump":
  125. dump_body = json.dumps(
  126. {
  127. "checksum": "checksum-demo",
  128. "session_id": "authsess_demo",
  129. "client_auth_session": {
  130. "session_id": "authsess_demo",
  131. "workspaces": [{"id": "ws-dump"}],
  132. },
  133. }
  134. )
  135. return FakeResponse(
  136. 200,
  137. url=url,
  138. text=")]}',\\n" + dump_body,
  139. )
  140. return super().get(url, **kwargs)
  141. def test_openai_http_client_uses_consistent_chrome120_headers() -> None:
  142. client = OpenAIHTTPClient()
  143. assert RequestConfig().impersonate == "chrome120"
  144. assert client.default_headers["User-Agent"] == (
  145. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
  146. "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
  147. )
  148. assert client.default_headers["sec-ch-ua"] == '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"'
  149. assert client.default_headers["sec-ch-ua-mobile"] == "?0"
  150. assert client.default_headers["sec-ch-ua-platform"] == '"Windows"'
  151. def test_oauth_json_headers_include_client_hints() -> None:
  152. engine = RegistrationEngine(email_service=DummyEmailService())
  153. headers = engine._oauth_json_headers(
  154. referer="https://auth.openai.com/u/signup",
  155. device_id="did-123",
  156. )
  157. assert headers["user-agent"] == (
  158. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
  159. "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
  160. )
  161. assert headers["sec-ch-ua"] == '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"'
  162. assert headers["sec-ch-ua-mobile"] == "?0"
  163. assert headers["sec-ch-ua-platform"] == '"Windows"'
  164. def test_create_user_account_logs_continue_kind_and_page_type(monkeypatch) -> None:
  165. class CreateAccountSession:
  166. def post(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  167. del kwargs
  168. assert url.endswith("/create_account")
  169. return FakeResponse(
  170. 200,
  171. url=url,
  172. json_data={
  173. "continue_url": "https://chatgpt.com/api/auth/callback/openai?code=ac_demo&state=demo",
  174. "page": {"type": "external_url"},
  175. },
  176. )
  177. monkeypatch.setattr(
  178. register_module.register_http_module,
  179. "generate_random_user_info",
  180. lambda: {"name": "Test", "birthdate": "1990-01-01"},
  181. )
  182. engine = RegistrationEngine(email_service=DummyEmailService())
  183. engine.session = CreateAccountSession()
  184. assert engine._create_user_account() is True
  185. joined_logs = "\n".join(engine.logs)
  186. assert "page_type=external_url" in joined_logs
  187. assert "continue_kind=callback_openai" in joined_logs
  188. assert "continue_host=chatgpt.com" in joined_logs
  189. def test_login_for_token_uses_password_verify_and_workspace_flow(monkeypatch) -> None:
  190. fake_session = FakeSession()
  191. created_clients: list[object] = []
  192. submit_calls: list[dict[str, object]] = []
  193. class FakeOpenAIHTTPClient:
  194. def __init__(self, proxy_url=None): # type: ignore[no-untyped-def]
  195. self.proxy_url = proxy_url
  196. self.session = fake_session
  197. self.default_headers = {"User-Agent": "FakeAgent/1.0"}
  198. self.sentinel_calls: list[tuple[str, str]] = []
  199. created_clients.append(self)
  200. def check_sentinel(self, did: str, *, flow: str = "authorize_continue") -> str:
  201. self.sentinel_calls.append((did, flow))
  202. return f"sentinel-{flow}"
  203. class FakeOAuthManager:
  204. def start_oauth(self) -> OAuthStart:
  205. return OAuthStart(
  206. auth_url="https://auth.openai.com/oauth/authorize?client_id=demo",
  207. state="demo-state",
  208. code_verifier="demo-verifier",
  209. redirect_uri="http://localhost:1455/auth/callback",
  210. )
  211. def fake_submit_callback_url(**kwargs): # type: ignore[no-untyped-def]
  212. submit_calls.append(kwargs)
  213. return json.dumps(
  214. {
  215. "access_token": "access-token",
  216. "refresh_token": "refresh-token",
  217. "id_token": "id-token",
  218. "account_id": "acct-123",
  219. "email": "user@example.com",
  220. "expired": "2026-03-22T00:00:00Z",
  221. "last_refresh": "2026-03-21T00:00:00Z",
  222. }
  223. )
  224. monkeypatch.setattr(register_module, "OpenAIHTTPClient", FakeOpenAIHTTPClient)
  225. monkeypatch.setattr(register_module, "submit_callback_url", fake_submit_callback_url)
  226. engine = RegistrationEngine(email_service=DummyEmailService())
  227. engine.email = "user@example.com"
  228. engine.password = "pw-secret"
  229. engine.oauth_manager = FakeOAuthManager()
  230. result = engine._login_for_token()
  231. assert result is not None
  232. assert result["access_token"] == "access-token"
  233. assert result["refresh_token"] == "refresh-token"
  234. assert result["account_id"] == "acct-123"
  235. assert created_clients[-1].sentinel_calls == [ # type: ignore[attr-defined]
  236. ("did-123", "authorize_continue"),
  237. ("did-123", "password_verify"),
  238. ]
  239. authorize_call = next(
  240. call for call in fake_session.calls if call[0] == "POST" and call[1].endswith("/authorize/continue")
  241. )
  242. authorize_header = json.loads(authorize_call[2]["headers"]["openai-sentinel-token"]) # type: ignore[index]
  243. assert authorize_call[2]["json"] == {"username": {"kind": "email", "value": "user@example.com"}} # type: ignore[index]
  244. assert authorize_header["flow"] == "authorize_continue"
  245. assert authorize_header["c"] == "sentinel-authorize_continue"
  246. password_call = next(
  247. call for call in fake_session.calls if call[0] == "POST" and call[1].endswith("/password/verify")
  248. )
  249. password_header = json.loads(password_call[2]["headers"]["openai-sentinel-token"]) # type: ignore[index]
  250. assert password_call[2]["json"] == {"password": "pw-secret"} # type: ignore[index]
  251. assert password_header["flow"] == "password_verify"
  252. assert password_header["c"] == "sentinel-password_verify"
  253. workspace_call = next(
  254. call for call in fake_session.calls if call[0] == "POST" and call[1].endswith("/workspace/select")
  255. )
  256. assert workspace_call[2]["json"] == {"workspace_id": "ws-123"} # type: ignore[index]
  257. organization_call = next(
  258. call for call in fake_session.calls if call[0] == "POST" and call[1].endswith("/organization/select")
  259. )
  260. assert organization_call[2]["json"] == {"org_id": "org-456", "project_id": "proj-789"} # type: ignore[index]
  261. assert submit_calls[0]["callback_url"] == "http://localhost:1455/auth/callback?code=oauth-code&state=demo-state"
  262. assert submit_calls[0]["expected_state"] == "demo-state"
  263. assert submit_calls[0]["code_verifier"] == "demo-verifier"
  264. def test_login_for_token_uses_client_auth_session_dump_when_workspace_cookie_is_minimized(monkeypatch) -> None:
  265. fake_session = DumpWorkspaceSession()
  266. submit_calls: list[dict[str, object]] = []
  267. class FakeOpenAIHTTPClient:
  268. def __init__(self, proxy_url=None): # type: ignore[no-untyped-def]
  269. self.proxy_url = proxy_url
  270. self.session = fake_session
  271. self.default_headers = {"User-Agent": "FakeAgent/1.0"}
  272. def check_sentinel(self, did: str, *, flow: str = "authorize_continue") -> str:
  273. del did
  274. return f"sentinel-{flow}"
  275. class FakeOAuthManager:
  276. def start_oauth(self) -> OAuthStart:
  277. return OAuthStart(
  278. auth_url="https://auth.openai.com/oauth/authorize?client_id=demo",
  279. state="demo-state",
  280. code_verifier="demo-verifier",
  281. redirect_uri="http://localhost:1455/auth/callback",
  282. )
  283. def fake_submit_callback_url(**kwargs): # type: ignore[no-untyped-def]
  284. submit_calls.append(kwargs)
  285. return json.dumps(
  286. {
  287. "access_token": "access-token",
  288. "refresh_token": "refresh-token",
  289. "id_token": "id-token",
  290. "account_id": "acct-123",
  291. "email": "user@example.com",
  292. "expired": "2026-03-22T00:00:00Z",
  293. "last_refresh": "2026-03-21T00:00:00Z",
  294. }
  295. )
  296. monkeypatch.setattr(register_module, "OpenAIHTTPClient", FakeOpenAIHTTPClient)
  297. monkeypatch.setattr(register_module, "submit_callback_url", fake_submit_callback_url)
  298. engine = RegistrationEngine(email_service=DummyEmailService())
  299. engine.email = "user@example.com"
  300. engine.password = "pw-secret"
  301. engine.oauth_manager = FakeOAuthManager()
  302. result = engine._login_for_token()
  303. assert result is not None
  304. assert result["access_token"] == "access-token"
  305. assert any(url.endswith("/api/accounts/client_auth_session_dump") for _, url, _ in fake_session.calls)
  306. workspace_call = next(
  307. call for call in fake_session.calls if call[0] == "POST" and call[1].endswith("/workspace/select")
  308. )
  309. assert workspace_call[2]["json"] == {"workspace_id": "ws-dump"} # type: ignore[index]
  310. assert submit_calls[0]["callback_url"] == "http://localhost:1455/auth/callback?code=oauth-code&state=demo-state"
  311. def test_follow_redirects_with_session_extracts_localhost_callback_from_exception() -> None:
  312. class ErrorSession:
  313. def get(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  314. del url, kwargs
  315. raise RuntimeError(
  316. "connection refused for http://localhost:1455/auth/callback?code=abc123&state=state456"
  317. )
  318. engine = RegistrationEngine(email_service=DummyEmailService())
  319. callback_url = engine._follow_redirects_with_session(ErrorSession(), "https://auth.openai.com/continue")
  320. assert callback_url == "http://localhost:1455/auth/callback?code=abc123&state=state456"
  321. def test_login_for_token_retries_transient_password_verify_transport_error(monkeypatch) -> None:
  322. submit_calls: list[dict[str, object]] = []
  323. transport_failures = {"remaining": 1}
  324. class RetrySession(FakeSession):
  325. def __init__(self) -> None:
  326. super().__init__()
  327. def post(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  328. if url.endswith("/password/verify"):
  329. if transport_failures["remaining"] > 0:
  330. transport_failures["remaining"] -= 1
  331. raise RuntimeError(
  332. "Failed to perform, curl: (7) Connection closed abruptly. "
  333. "See https://curl.se/libcurl/c/libcurl-errors.html first for more details."
  334. )
  335. return super().post(url, **kwargs)
  336. session_instances: list[RetrySession] = []
  337. class FakeOpenAIHTTPClient:
  338. def __init__(self, proxy_url=None): # type: ignore[no-untyped-def]
  339. del proxy_url
  340. self.session = RetrySession()
  341. self.default_headers = {"User-Agent": "FakeAgent/1.0"}
  342. session_instances.append(self.session)
  343. def check_sentinel(self, did: str, *, flow: str = "authorize_continue") -> str:
  344. del did
  345. return f"sentinel-{flow}"
  346. def close(self) -> None:
  347. return None
  348. class FakeOAuthManager:
  349. def start_oauth(self) -> OAuthStart:
  350. return OAuthStart(
  351. auth_url="https://auth.openai.com/oauth/authorize?client_id=demo",
  352. state="demo-state",
  353. code_verifier="demo-verifier",
  354. redirect_uri="http://localhost:1455/auth/callback",
  355. )
  356. def fake_submit_callback_url(**kwargs): # type: ignore[no-untyped-def]
  357. submit_calls.append(kwargs)
  358. return json.dumps(
  359. {
  360. "access_token": "access-token",
  361. "refresh_token": "refresh-token",
  362. "id_token": "id-token",
  363. "account_id": "acct-123",
  364. "email": "user@example.com",
  365. "expired": "2026-03-22T00:00:00Z",
  366. "last_refresh": "2026-03-21T00:00:00Z",
  367. }
  368. )
  369. monkeypatch.setattr(register_module, "OpenAIHTTPClient", FakeOpenAIHTTPClient)
  370. monkeypatch.setattr(register_module, "submit_callback_url", fake_submit_callback_url)
  371. engine = RegistrationEngine(email_service=DummyEmailService())
  372. engine.email = "user@example.com"
  373. engine.password = "pw-secret"
  374. engine.oauth_manager = FakeOAuthManager()
  375. result = engine._login_for_token()
  376. assert result is not None
  377. assert result["access_token"] == "access-token"
  378. assert len(session_instances) >= 2
  379. assert any("transient transport error" in line for line in engine.logs)
  380. assert submit_calls[0]["callback_url"] == "http://localhost:1455/auth/callback?code=oauth-code&state=demo-state"
  381. def test_login_for_token_uses_session_refresh_when_callback_missing(monkeypatch) -> None:
  382. class SessionTokenSession(FakeSession):
  383. def __init__(self) -> None:
  384. super().__init__()
  385. self.cookies["__Secure-next-auth.session-token"] = "sess-123"
  386. def get(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  387. self.calls.append(("GET", url, kwargs))
  388. if url.startswith("https://auth.openai.com/oauth/authorize"):
  389. self.cookies["oai-did"] = "did-123"
  390. self.cookies["login_session"] = "login-session"
  391. return FakeResponse(200, url="https://auth.openai.com/log-in")
  392. if url == "https://auth.openai.com/organization-continue":
  393. return FakeResponse(403, url=url, text="phone required")
  394. raise AssertionError(f"unexpected GET: {url}")
  395. def post(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  396. self.calls.append(("POST", url, kwargs))
  397. if url.endswith("/authorize/continue"):
  398. return FakeResponse(
  399. 200,
  400. url=url,
  401. json_data={"continue_url": "/log-in/password", "page": {"type": "password"}},
  402. )
  403. if url.endswith("/password/verify"):
  404. return FakeResponse(
  405. 200,
  406. url=url,
  407. json_data={"continue_url": "/continue-after-password", "page": {"type": "consent"}},
  408. )
  409. if url.endswith("/workspace/select"):
  410. return FakeResponse(
  411. 200,
  412. url=url,
  413. json_data={
  414. "continue_url": "/organization-continue",
  415. "data": {"orgs": []},
  416. },
  417. )
  418. raise AssertionError(f"unexpected POST: {url}")
  419. fake_session = SessionTokenSession()
  420. class FakeOpenAIHTTPClient:
  421. def __init__(self, proxy_url=None): # type: ignore[no-untyped-def]
  422. self.proxy_url = proxy_url
  423. self.session = fake_session
  424. self.default_headers = {"User-Agent": "FakeAgent/1.0"}
  425. def check_sentinel(self, did: str, *, flow: str = "authorize_continue") -> str:
  426. del did
  427. return f"sentinel-{flow}"
  428. def close(self) -> None:
  429. return None
  430. class FakeOAuthManager:
  431. def start_oauth(self) -> OAuthStart:
  432. return OAuthStart(
  433. auth_url="https://auth.openai.com/oauth/authorize?client_id=demo",
  434. state="demo-state",
  435. code_verifier="demo-verifier",
  436. redirect_uri="http://localhost:1455/auth/callback",
  437. )
  438. class FakeTokenRefreshManager:
  439. def __init__(self, proxy_url=None): # type: ignore[no-untyped-def]
  440. del proxy_url
  441. def refresh_by_session_token(self, session_token: str): # type: ignore[no-untyped-def]
  442. assert session_token == "sess-123"
  443. return TokenRefreshResult(
  444. success=True,
  445. access_token="session-access",
  446. account_id="acct-session",
  447. email="user@example.com",
  448. session_token=session_token,
  449. )
  450. monkeypatch.setattr(register_module, "OpenAIHTTPClient", FakeOpenAIHTTPClient)
  451. monkeypatch.setattr(register_module, "TokenRefreshManager", FakeTokenRefreshManager)
  452. engine = RegistrationEngine(email_service=DummyEmailService())
  453. engine.email = "user@example.com"
  454. engine.password = "pw-secret"
  455. engine.oauth_manager = FakeOAuthManager()
  456. result = engine._login_for_token()
  457. assert result is not None
  458. assert result["access_token"] == "session-access"
  459. assert result["session_token"] == "sess-123"
  460. assert result["account_id"] == "acct-session"
  461. assert any("session token refresh succeeded" in line for line in engine.logs)
  462. def test_get_workspace_id_no_longer_calls_invalid_workspaces_api() -> None:
  463. engine = RegistrationEngine(email_service=DummyEmailService())
  464. session = NoWorkspaceSession()
  465. engine.session = session
  466. assert engine._get_workspace_id() is None
  467. assert all("/api/accounts/workspaces" not in url for _, url, _ in session.calls)
  468. def test_get_workspace_id_uses_client_auth_session_dump_when_cookie_lacks_workspaces() -> None:
  469. engine = RegistrationEngine(email_service=DummyEmailService())
  470. session = DumpWorkspaceSession()
  471. engine.session = session
  472. assert engine._get_workspace_id() == "ws-dump"
  473. assert any(url.endswith("/api/accounts/client_auth_session_dump") for _, url, _ in session.calls)
  474. def test_run_continues_token_acquisition_after_add_phone_continue_url(monkeypatch) -> None:
  475. engine = RegistrationEngine(email_service=DummyEmailService())
  476. engine.email = "user@example.com"
  477. engine.password = "pw-secret"
  478. monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "US"))
  479. monkeypatch.setattr(engine, "_create_email", lambda: True)
  480. monkeypatch.setattr(engine, "_init_session", lambda: True)
  481. monkeypatch.setattr(engine, "_start_oauth", lambda: True)
  482. monkeypatch.setattr(engine, "_get_device_id", lambda: "did-123")
  483. monkeypatch.setattr(engine, "_check_sentinel", lambda device_id: "sentinel")
  484. monkeypatch.setattr(engine, "_submit_signup_form", lambda device_id, sentinel_token: SignupFormResult(success=True))
  485. monkeypatch.setattr(engine, "_register_password", lambda: True)
  486. monkeypatch.setattr(engine, "_send_verification_code", lambda: True)
  487. monkeypatch.setattr(engine, "_get_verification_code", lambda: "123456")
  488. monkeypatch.setattr(engine, "_validate_verification_code", lambda code: True)
  489. def fake_create_user_account() -> bool:
  490. engine._create_account_continue_url = "https://auth.openai.com/add-phone"
  491. return True
  492. monkeypatch.setattr(engine, "_create_user_account", fake_create_user_account)
  493. monkeypatch.setattr(
  494. engine,
  495. "_login_for_token",
  496. lambda: {
  497. "access_token": "access-token",
  498. "refresh_token": "refresh-token",
  499. "id_token": "id-token",
  500. "account_id": "acct-123",
  501. "expired": "2026-03-23T00:00:00Z",
  502. "last_refresh": "2026-03-22T00:00:00Z",
  503. },
  504. )
  505. result = engine.run()
  506. assert result.success is True
  507. assert result.stage == "completed"
  508. assert result.metadata["post_create_gate"] == "add_phone"
  509. assert result.metadata["post_create_continue_url"] == "https://auth.openai.com/add-phone"
  510. def test_run_returns_add_phone_gate_when_token_acquisition_still_fails(monkeypatch) -> None:
  511. engine = RegistrationEngine(email_service=DummyEmailService())
  512. engine.email = "user@example.com"
  513. engine.password = "pw-secret"
  514. monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "US"))
  515. monkeypatch.setattr(engine, "_create_email", lambda: True)
  516. monkeypatch.setattr(engine, "_init_session", lambda: True)
  517. monkeypatch.setattr(engine, "_start_oauth", lambda: True)
  518. monkeypatch.setattr(engine, "_get_device_id", lambda: "did-123")
  519. monkeypatch.setattr(engine, "_check_sentinel", lambda device_id: "sentinel")
  520. monkeypatch.setattr(engine, "_submit_signup_form", lambda device_id, sentinel_token: SignupFormResult(success=True))
  521. monkeypatch.setattr(engine, "_register_password", lambda: True)
  522. monkeypatch.setattr(engine, "_send_verification_code", lambda: True)
  523. monkeypatch.setattr(engine, "_get_verification_code", lambda: "123456")
  524. monkeypatch.setattr(engine, "_validate_verification_code", lambda code: True)
  525. def fake_create_user_account() -> bool:
  526. engine._create_account_continue_url = "https://auth.openai.com/add-phone"
  527. return True
  528. monkeypatch.setattr(engine, "_create_user_account", fake_create_user_account)
  529. monkeypatch.setattr(engine, "_login_for_token", lambda: None)
  530. result = engine.run()
  531. assert result.success is False
  532. assert result.stage == "add_phone_gate"
  533. assert result.metadata["post_create_gate"] == "add_phone"
  534. assert result.metadata["post_create_continue_url"] == "https://auth.openai.com/add-phone"
  535. def test_run_rebuilds_signup_auth_context_after_invalid_auth_step(monkeypatch) -> None:
  536. engine = RegistrationEngine(email_service=DummyEmailService())
  537. engine.email = "user@example.com"
  538. engine.password = "pw-secret"
  539. counters = {"init_session": 0, "start_oauth": 0, "get_device_id": 0}
  540. signup_calls: list[tuple[str, str]] = []
  541. monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "SG"))
  542. monkeypatch.setattr(engine, "_create_email", lambda: True)
  543. def fake_init_session() -> bool:
  544. counters["init_session"] += 1
  545. return True
  546. def fake_start_oauth() -> bool:
  547. counters["start_oauth"] += 1
  548. return True
  549. def fake_get_device_id() -> str:
  550. counters["get_device_id"] += 1
  551. return f"did-{counters['get_device_id']}"
  552. monkeypatch.setattr(engine, "_init_session", fake_init_session)
  553. monkeypatch.setattr(engine, "_start_oauth", fake_start_oauth)
  554. monkeypatch.setattr(engine, "_get_device_id", fake_get_device_id)
  555. monkeypatch.setattr(engine, "_check_sentinel", lambda device_id: f"sentinel-{device_id}")
  556. def fake_submit_signup_form(device_id, sentinel_token): # type: ignore[no-untyped-def]
  557. signup_calls.append((device_id, sentinel_token))
  558. if len(signup_calls) == 1:
  559. return SignupFormResult(success=False, error_message='HTTP 400: {"error":{"code":"invalid_auth_step"}}')
  560. return SignupFormResult(success=True)
  561. monkeypatch.setattr(engine, "_submit_signup_form", fake_submit_signup_form)
  562. monkeypatch.setattr(engine, "_register_password", lambda: True)
  563. monkeypatch.setattr(engine, "_send_verification_code", lambda: True)
  564. monkeypatch.setattr(engine, "_get_verification_code", lambda: "123456")
  565. monkeypatch.setattr(engine, "_validate_verification_code", lambda code: True)
  566. monkeypatch.setattr(engine, "_create_user_account", lambda: True)
  567. monkeypatch.setattr(
  568. engine,
  569. "_try_create_account_callback_session_token",
  570. lambda continue_url: {
  571. "access_token": "access-token",
  572. "refresh_token": "refresh-token",
  573. "id_token": "id-token",
  574. "account_id": "acct-123",
  575. "expired": "2026-03-23T00:00:00Z",
  576. "last_refresh": "2026-03-22T00:00:00Z",
  577. },
  578. )
  579. result = engine.run()
  580. assert result.success is True
  581. assert counters == {"init_session": 2, "start_oauth": 2, "get_device_id": 2}
  582. assert signup_calls == [
  583. ("did-1", "sentinel-did-1"),
  584. ("did-2", "sentinel-did-2"),
  585. ]
  586. assert result.metadata["signup_auth_reset_count"] == 1
  587. assert any("signup invalid_auth_step detected" in line for line in result.logs)
  588. def test_run_retries_add_phone_oauth_once_more_before_failure(monkeypatch) -> None:
  589. engine = RegistrationEngine(email_service=DummyEmailService())
  590. engine.email = "user@example.com"
  591. engine.password = "pw-secret"
  592. monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "US"))
  593. monkeypatch.setattr(engine, "_create_email", lambda: True)
  594. monkeypatch.setattr(engine, "_init_session", lambda: True)
  595. monkeypatch.setattr(engine, "_start_oauth", lambda: True)
  596. monkeypatch.setattr(engine, "_get_device_id", lambda: "did-123")
  597. monkeypatch.setattr(engine, "_check_sentinel", lambda device_id: "sentinel")
  598. monkeypatch.setattr(engine, "_submit_signup_form", lambda device_id, sentinel_token: SignupFormResult(success=True))
  599. monkeypatch.setattr(engine, "_register_password", lambda: True)
  600. monkeypatch.setattr(engine, "_send_verification_code", lambda: True)
  601. monkeypatch.setattr(engine, "_get_verification_code", lambda: "123456")
  602. monkeypatch.setattr(engine, "_validate_verification_code", lambda code: True)
  603. def fake_create_user_account() -> bool:
  604. engine._create_account_continue_url = "https://auth.openai.com/add-phone"
  605. return True
  606. monkeypatch.setattr(engine, "_create_user_account", fake_create_user_account)
  607. engine._add_phone_oauth_max_attempts = 2
  608. attempts = {"count": 0}
  609. def fake_login_for_token() -> None:
  610. attempts["count"] += 1
  611. return None
  612. monkeypatch.setattr(engine, "_login_for_token", fake_login_for_token)
  613. result = engine.run()
  614. assert result.success is False
  615. assert result.stage == "add_phone_gate"
  616. assert attempts["count"] == 2
  617. def test_run_short_circuits_second_add_phone_oauth_retry_when_trace_is_hopeless(monkeypatch) -> None:
  618. engine = RegistrationEngine(email_service=DummyEmailService())
  619. engine.email = "user@example.com"
  620. engine.password = "pw-secret"
  621. monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "US"))
  622. monkeypatch.setattr(engine, "_create_email", lambda: True)
  623. monkeypatch.setattr(engine, "_init_session", lambda: True)
  624. monkeypatch.setattr(engine, "_start_oauth", lambda: True)
  625. monkeypatch.setattr(engine, "_get_device_id", lambda: "did-123")
  626. monkeypatch.setattr(engine, "_check_sentinel", lambda device_id: "sentinel")
  627. monkeypatch.setattr(engine, "_submit_signup_form", lambda device_id, sentinel_token: SignupFormResult(success=True))
  628. monkeypatch.setattr(engine, "_register_password", lambda: True)
  629. monkeypatch.setattr(engine, "_send_verification_code", lambda: True)
  630. monkeypatch.setattr(engine, "_get_verification_code", lambda: "123456")
  631. monkeypatch.setattr(engine, "_validate_verification_code", lambda code: True)
  632. def fake_create_user_account() -> bool:
  633. engine._create_account_continue_url = "https://auth.openai.com/add-phone"
  634. return True
  635. monkeypatch.setattr(engine, "_create_user_account", fake_create_user_account)
  636. engine._add_phone_oauth_max_attempts = 2
  637. attempts = {"count": 0}
  638. def fake_login_for_token() -> None:
  639. attempts["count"] += 1
  640. engine._set_add_phone_trace(
  641. auth_session_workspace_count=0,
  642. direct_session_keys=["WARNING_BANNER"],
  643. )
  644. engine._append_add_phone_attempt(
  645. {
  646. "attempt": attempts["count"],
  647. "final_continue_url": "https://auth.openai.com/add-phone",
  648. "final_page_type": "add_phone",
  649. "callback_found": False,
  650. "session_token_found": False,
  651. }
  652. )
  653. return None
  654. monkeypatch.setattr(engine, "_login_for_token", fake_login_for_token)
  655. result = engine.run()
  656. assert result.success is False
  657. assert result.stage == "add_phone_gate"
  658. assert attempts["count"] == 1
  659. def test_run_records_add_phone_trace_artifact_and_deferred_retry_context(monkeypatch, tmp_path: Path) -> None:
  660. engine = RegistrationEngine(email_service=DummyEmailService(), proxy_url="socks5://127.0.0.1:17891")
  661. engine.email = "user@example.com"
  662. engine.password = "pw-secret"
  663. monkeypatch.setattr(register_module, "STATE_DIR", tmp_path)
  664. monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "US"))
  665. monkeypatch.setattr(engine, "_create_email", lambda: True)
  666. monkeypatch.setattr(engine, "_init_session", lambda: True)
  667. monkeypatch.setattr(engine, "_start_oauth", lambda: True)
  668. monkeypatch.setattr(engine, "_get_device_id", lambda: "did-123")
  669. monkeypatch.setattr(engine, "_check_sentinel", lambda device_id: "sentinel")
  670. monkeypatch.setattr(engine, "_submit_signup_form", lambda device_id, sentinel_token: SignupFormResult(success=True))
  671. monkeypatch.setattr(engine, "_register_password", lambda: True)
  672. monkeypatch.setattr(engine, "_send_verification_code", lambda: True)
  673. monkeypatch.setattr(engine, "_get_verification_code", lambda: "123456")
  674. monkeypatch.setattr(engine, "_validate_verification_code", lambda code: True)
  675. def fake_create_user_account() -> bool:
  676. engine._create_account_continue_url = "https://auth.openai.com/add-phone"
  677. engine._last_create_account_http_status = 200
  678. return True
  679. monkeypatch.setattr(engine, "_create_user_account", fake_create_user_account)
  680. monkeypatch.setattr(engine, "_try_direct_session_token", lambda: None)
  681. monkeypatch.setattr(engine, "_login_for_token", lambda: None)
  682. result = engine.run()
  683. assert result.success is False
  684. assert result.stage == "add_phone_gate"
  685. trace_path = Path(str(result.metadata["add_phone_trace_path"]))
  686. assert trace_path.exists()
  687. trace_payload = json.loads(trace_path.read_text(encoding="utf-8"))
  688. assert trace_payload["reason"] == "hard_add_phone_gate"
  689. assert trace_payload["email"] == "user@example.com"
  690. assert trace_payload["post_create_continue_url"] == "https://auth.openai.com/add-phone"
  691. deferred = result.metadata["deferred_credentials"]
  692. assert deferred["registration_proxy_url"] == "socks5://127.0.0.1:17891"
  693. assert deferred["registration_fingerprint_profile"] == "chrome120_win"
  694. def test_capture_add_phone_html_collects_modulepreload_urls(monkeypatch, tmp_path: Path) -> None:
  695. engine = RegistrationEngine(email_service=DummyEmailService())
  696. engine.email = "user@example.com"
  697. monkeypatch.setattr(register_module, "STATE_DIR", tmp_path)
  698. path = engine._capture_add_phone_html(
  699. label="fresh-login-attempt-1",
  700. url="https://auth.openai.com/add-phone",
  701. html=(
  702. '<html><head>'
  703. '<link rel="modulepreload" href="https://auth-cdn.oaistatic.com/assets/entry.client.js"/>'
  704. '<script src="https://auth-cdn.oaistatic.com/assets/runtime.js"></script>'
  705. "</head></html>"
  706. ),
  707. )
  708. assert Path(path).exists()
  709. artifact = engine._add_phone_trace_context["html_artifacts"][0]
  710. assert artifact["script_urls"] == [
  711. "https://auth-cdn.oaistatic.com/assets/entry.client.js",
  712. "https://auth-cdn.oaistatic.com/assets/runtime.js",
  713. ]
  714. def test_run_uses_direct_session_token_before_fresh_login_for_add_phone(monkeypatch) -> None:
  715. engine = RegistrationEngine(email_service=DummyEmailService())
  716. engine.email = "user@example.com"
  717. engine.password = "pw-secret"
  718. monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "US"))
  719. monkeypatch.setattr(engine, "_create_email", lambda: True)
  720. monkeypatch.setattr(engine, "_init_session", lambda: True)
  721. monkeypatch.setattr(engine, "_start_oauth", lambda: True)
  722. monkeypatch.setattr(engine, "_get_device_id", lambda: "did-123")
  723. monkeypatch.setattr(engine, "_check_sentinel", lambda device_id: "sentinel")
  724. monkeypatch.setattr(engine, "_submit_signup_form", lambda device_id, sentinel_token: SignupFormResult(success=True))
  725. monkeypatch.setattr(engine, "_register_password", lambda: True)
  726. monkeypatch.setattr(engine, "_send_verification_code", lambda: True)
  727. monkeypatch.setattr(engine, "_get_verification_code", lambda: "123456")
  728. monkeypatch.setattr(engine, "_validate_verification_code", lambda code: True)
  729. def fake_create_user_account() -> bool:
  730. engine._create_account_continue_url = "https://auth.openai.com/add-phone"
  731. return True
  732. monkeypatch.setattr(engine, "_create_user_account", fake_create_user_account)
  733. monkeypatch.setattr(
  734. engine,
  735. "_try_direct_session_token",
  736. lambda: {
  737. "access_token": "direct-access-token",
  738. "refresh_token": "direct-refresh-token",
  739. "id_token": "",
  740. "account_id": "acct-direct",
  741. "expired": "2026-03-30T00:00:00Z",
  742. "last_refresh": "2026-03-29T20:00:00Z",
  743. },
  744. )
  745. monkeypatch.setattr(
  746. engine,
  747. "_login_for_token",
  748. lambda: (_ for _ in ()).throw(AssertionError("fresh login fallback should not run when direct session token works")),
  749. )
  750. result = engine.run()
  751. assert result.success is True
  752. assert result.stage == "completed"
  753. assert result.account_id == "acct-direct"
  754. assert result.access_token == "direct-access-token"
  755. assert result.metadata["post_create_gate"] == "add_phone"
  756. def test_run_uses_create_account_callback_session_before_workspace_or_fresh_login(monkeypatch) -> None:
  757. class CallbackSession:
  758. def __init__(self) -> None:
  759. self.cookies = FakeCookies({})
  760. self.calls: list[tuple[str, str, dict[str, object]]] = []
  761. def get(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  762. self.calls.append(("GET", url, kwargs))
  763. if url.startswith("https://chatgpt.com/api/auth/callback/openai?code="):
  764. return FakeResponse(302, url=url, headers={"Location": "https://chatgpt.com/"})
  765. if url == "https://chatgpt.com/api/auth/session":
  766. return FakeResponse(
  767. 200,
  768. url=url,
  769. json_data={
  770. "accessToken": "header.payload.sig",
  771. "user": {"email": "user@example.com"},
  772. "expires": "2026-03-30T00:00:00Z",
  773. },
  774. )
  775. raise AssertionError(f"unexpected GET: {url}")
  776. engine = RegistrationEngine(email_service=DummyEmailService())
  777. engine.email = "user@example.com"
  778. engine.password = "pw-secret"
  779. engine.session = CallbackSession()
  780. monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "US"))
  781. monkeypatch.setattr(engine, "_create_email", lambda: True)
  782. monkeypatch.setattr(engine, "_init_session", lambda: True)
  783. monkeypatch.setattr(engine, "_start_oauth", lambda: True)
  784. monkeypatch.setattr(engine, "_get_device_id", lambda: "did-123")
  785. monkeypatch.setattr(engine, "_check_sentinel", lambda device_id: "sentinel")
  786. monkeypatch.setattr(engine, "_submit_signup_form", lambda device_id, sentinel_token: SignupFormResult(success=True))
  787. monkeypatch.setattr(engine, "_register_password", lambda: True)
  788. monkeypatch.setattr(engine, "_send_verification_code", lambda: True)
  789. monkeypatch.setattr(engine, "_get_verification_code", lambda: "123456")
  790. monkeypatch.setattr(engine, "_validate_verification_code", lambda code: True)
  791. def fake_create_user_account() -> bool:
  792. engine._create_account_continue_url = (
  793. "https://chatgpt.com/api/auth/callback/openai?code=oauth-code&state=demo-state"
  794. )
  795. return True
  796. monkeypatch.setattr(engine, "_create_user_account", fake_create_user_account)
  797. monkeypatch.setattr(
  798. engine,
  799. "_get_workspace_id",
  800. lambda: (_ for _ in ()).throw(AssertionError("workspace flow should not run when create_account already returned callback")),
  801. )
  802. monkeypatch.setattr(
  803. engine,
  804. "_login_for_token",
  805. lambda: (_ for _ in ()).throw(AssertionError("fresh login fallback should not run when callback session path works")),
  806. )
  807. monkeypatch.setattr(
  808. engine,
  809. "_parse_session_jwt",
  810. lambda access_token, session_data: {
  811. "access_token": access_token,
  812. "refresh_token": "",
  813. "id_token": "",
  814. "account_id": "acct-callback",
  815. "email": "user@example.com",
  816. "expired": "2026-03-30T00:00:00Z",
  817. "last_refresh": "2026-03-29T20:00:00Z",
  818. "source": "create_account_callback_session",
  819. },
  820. )
  821. result = engine.run()
  822. assert result.success is True
  823. assert result.stage == "completed"
  824. assert result.account_id == "acct-callback"
  825. assert result.access_token == "header.payload.sig"
  826. assert result.metadata["post_create_continue_url"] == (
  827. "https://chatgpt.com/api/auth/callback/openai?code=oauth-code&state=demo-state"
  828. )
  829. assert [url for method, url, _ in engine.session.calls if method == "GET"][-2:] == [
  830. "https://chatgpt.com/api/auth/callback/openai?code=oauth-code&state=demo-state",
  831. "https://chatgpt.com/api/auth/session",
  832. ]
  833. def test_run_does_not_retry_non_add_phone_oauth_failure(monkeypatch) -> None:
  834. engine = RegistrationEngine(email_service=DummyEmailService())
  835. engine.email = "user@example.com"
  836. engine.password = "pw-secret"
  837. monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "US"))
  838. monkeypatch.setattr(engine, "_create_email", lambda: True)
  839. monkeypatch.setattr(engine, "_init_session", lambda: True)
  840. monkeypatch.setattr(engine, "_start_oauth", lambda: True)
  841. monkeypatch.setattr(engine, "_get_device_id", lambda: "did-123")
  842. monkeypatch.setattr(engine, "_check_sentinel", lambda device_id: "sentinel")
  843. monkeypatch.setattr(engine, "_submit_signup_form", lambda device_id, sentinel_token: SignupFormResult(success=True))
  844. monkeypatch.setattr(engine, "_register_password", lambda: True)
  845. monkeypatch.setattr(engine, "_send_verification_code", lambda: True)
  846. monkeypatch.setattr(engine, "_get_verification_code", lambda: "123456")
  847. monkeypatch.setattr(engine, "_validate_verification_code", lambda code: True)
  848. monkeypatch.setattr(engine, "_create_user_account", lambda: True)
  849. engine._add_phone_oauth_max_attempts = 2
  850. attempts = {"count": 0}
  851. def fake_login_for_token() -> None:
  852. attempts["count"] += 1
  853. return None
  854. monkeypatch.setattr(engine, "_login_for_token", fake_login_for_token)
  855. result = engine.run()
  856. assert result.success is False
  857. assert result.stage == "token_acquisition"
  858. assert attempts["count"] == 1
  859. def test_login_for_token_uses_configured_add_phone_oauth_otp_timeout(monkeypatch) -> None:
  860. class OAuthOtpSession(FakeSession):
  861. def post(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  862. self.calls.append(("POST", url, kwargs))
  863. if url.endswith("/authorize/continue"):
  864. return FakeResponse(
  865. 200,
  866. url=url,
  867. json_data={"continue_url": "/log-in/password", "page": {"type": "password"}},
  868. )
  869. if url.endswith("/password/verify"):
  870. return FakeResponse(
  871. 200,
  872. url=url,
  873. json_data={
  874. "continue_url": "https://auth.openai.com/email-verification",
  875. "page": {"type": register_module.OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]},
  876. },
  877. )
  878. raise AssertionError(f"unexpected POST: {url}")
  879. fake_session = OAuthOtpSession()
  880. class FakeOpenAIHTTPClient:
  881. def __init__(self, proxy_url=None): # type: ignore[no-untyped-def]
  882. self.proxy_url = proxy_url
  883. self.session = fake_session
  884. self.default_headers = {"User-Agent": "FakeAgent/1.0"}
  885. def check_sentinel(self, did: str, *, flow: str = "authorize_continue") -> str:
  886. del did, flow
  887. return "sentinel"
  888. class FakeOAuthManager:
  889. def start_oauth(self) -> OAuthStart:
  890. return OAuthStart(
  891. auth_url="https://auth.openai.com/oauth/authorize?client_id=demo",
  892. state="demo-state",
  893. code_verifier="demo-verifier",
  894. redirect_uri="http://localhost:1455/auth/callback",
  895. )
  896. observed: dict[str, object] = {}
  897. monkeypatch.setattr(register_module, "OpenAIHTTPClient", FakeOpenAIHTTPClient)
  898. monkeypatch.setenv("ZHUCE6_ADD_PHONE_OAUTH_OTP_TIMEOUT_SECONDS", "90")
  899. engine = RegistrationEngine(email_service=DummyEmailService())
  900. engine.email = "user@example.com"
  901. engine.password = "pw-secret"
  902. engine.oauth_manager = FakeOAuthManager()
  903. def fake_wait_for_mailbox_code(*, before_ids=None, timeout=0, keyword="", not_before_timestamp=None): # type: ignore[no-untyped-def]
  904. observed["before_ids"] = before_ids
  905. observed["timeout"] = timeout
  906. observed["keyword"] = keyword
  907. observed["not_before_timestamp"] = not_before_timestamp
  908. return ""
  909. monkeypatch.setattr(engine, "_wait_for_mailbox_code", fake_wait_for_mailbox_code)
  910. result = engine._login_for_token()
  911. assert result is None
  912. assert not any(call[0] == "GET" and call[1].endswith("/api/accounts/email-otp/send") for call in fake_session.calls)
  913. assert observed["timeout"] == 90
  914. assert observed["keyword"] == "openai"
  915. assert observed["not_before_timestamp"] is None
  916. def test_build_sentinel_header_prefers_client_pow_payload() -> None:
  917. class FakePowClient:
  918. def build_sentinel_header(self, *, device_id: str, flow: str, token: str = "") -> str:
  919. return json.dumps(
  920. {
  921. "p": "gAAAAABpowtoken",
  922. "t": "",
  923. "c": token,
  924. "id": device_id,
  925. "flow": flow,
  926. },
  927. separators=(",", ":"),
  928. )
  929. engine = RegistrationEngine(email_service=DummyEmailService())
  930. header = json.loads(
  931. engine._build_sentinel_header(
  932. "sentinel-authorize_continue",
  933. "did-123",
  934. "authorize_continue",
  935. client=FakePowClient(),
  936. )
  937. )
  938. assert header["p"] == "gAAAAABpowtoken"
  939. assert header["c"] == "sentinel-authorize_continue"
  940. assert header["flow"] == "authorize_continue"
  941. def test_build_sentinel_header_falls_back_when_client_has_no_pow_helper() -> None:
  942. engine = RegistrationEngine(email_service=DummyEmailService())
  943. header = json.loads(engine._build_sentinel_header("sentinel-basic", "did-123", "authorize_continue"))
  944. assert header["p"] == ""
  945. assert header["t"] == ""
  946. assert header["c"] == "sentinel-basic"
  947. def test_create_email_discards_duplicate_mailboxes() -> None:
  948. class DuplicateEmailService:
  949. def __init__(self) -> None:
  950. self.calls = 0
  951. def create_email(self, config=None): # type: ignore[no-untyped-def]
  952. del config
  953. self.calls += 1
  954. if self.calls == 1:
  955. return {"email": "dup@example.com"}
  956. return {"email": "fresh@example.com"}
  957. def get_verification_code(self, **kwargs): # type: ignore[no-untyped-def]
  958. del kwargs
  959. return ""
  960. class FakeDedupeStore:
  961. def __init__(self) -> None:
  962. self.reserved: list[str] = []
  963. def reserve(self, email: str) -> bool:
  964. if email == "dup@example.com":
  965. return False
  966. self.reserved.append(email)
  967. return True
  968. def release(self, email: str) -> None:
  969. del email
  970. def mark(self, email: str, *, reason: str) -> None:
  971. del email, reason
  972. service = DuplicateEmailService()
  973. engine = RegistrationEngine(
  974. email_service=service,
  975. mailbox_dedupe_store=FakeDedupeStore(),
  976. create_email_max_attempts=2,
  977. )
  978. assert engine._create_email() is True
  979. assert engine.email == "fresh@example.com"
  980. assert service.calls == 2
  981. assert any("duplicate mailbox discarded" in line for line in engine.logs)
  982. def test_run_marks_user_already_exists_mailbox(monkeypatch) -> None:
  983. class FakeDedupeStore:
  984. def __init__(self) -> None:
  985. self.marked: list[tuple[str, str]] = []
  986. self.released: list[str] = []
  987. def reserve(self, email: str) -> bool:
  988. return True
  989. def release(self, email: str) -> None:
  990. self.released.append(email)
  991. def mark(self, email: str, *, reason: str) -> None:
  992. self.marked.append((email, reason))
  993. dedupe_store = FakeDedupeStore()
  994. engine = RegistrationEngine(email_service=DummyEmailService(), mailbox_dedupe_store=dedupe_store)
  995. engine.email = "dup@example.com"
  996. engine._reserved_email = "dup@example.com"
  997. monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "SG"))
  998. monkeypatch.setattr(engine, "_create_email", lambda: True)
  999. monkeypatch.setattr(engine, "_init_session", lambda: True)
  1000. monkeypatch.setattr(engine, "_start_oauth", lambda: True)
  1001. monkeypatch.setattr(engine, "_get_device_id", lambda: "did-123")
  1002. monkeypatch.setattr(engine, "_check_sentinel", lambda device_id: "sentinel")
  1003. monkeypatch.setattr(engine, "_submit_signup_form", lambda device_id, sentinel_token: SignupFormResult(success=True))
  1004. monkeypatch.setattr(engine, "_register_password", lambda: True)
  1005. monkeypatch.setattr(engine, "_send_verification_code", lambda: True)
  1006. monkeypatch.setattr(engine, "_get_verification_code", lambda: "123456")
  1007. monkeypatch.setattr(engine, "_validate_verification_code", lambda code: True)
  1008. def fake_create_user_account() -> bool:
  1009. engine._last_create_account_error_code = "user_already_exists"
  1010. engine._last_create_account_error_message = "An account already exists for this email address."
  1011. return False
  1012. monkeypatch.setattr(engine, "_create_user_account", fake_create_user_account)
  1013. result = engine.run()
  1014. assert result.success is False
  1015. assert result.stage == "create_account"
  1016. assert dedupe_store.marked == [("dup@example.com", "user_already_exists")]
  1017. assert dedupe_store.released == ["dup@example.com"]
  1018. def test_create_user_account_classifies_registration_disallowed() -> None:
  1019. class CreateAccountSession:
  1020. def post(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  1021. del kwargs
  1022. assert url.endswith("/create_account")
  1023. return FakeResponse(
  1024. 400,
  1025. json_data={
  1026. "error": {
  1027. "message": "Sorry, we cannot create your account with the given information.",
  1028. "code": "registration_disallowed",
  1029. }
  1030. },
  1031. )
  1032. engine = RegistrationEngine(email_service=DummyEmailService())
  1033. engine.email = "demo@nova.example.test"
  1034. engine.session = CreateAccountSession()
  1035. success = engine._create_user_account()
  1036. result = engine._result(success=False, stage="create_account", error_message="create account failed")
  1037. assert success is False
  1038. assert result.metadata["email_domain"] == "nova.example.test"
  1039. assert result.metadata["create_account_http_status"] == 400
  1040. assert result.metadata["create_account_error_code"] == "registration_disallowed"
  1041. assert "cannot create your account" in result.metadata["create_account_error_message"]
  1042. def test_create_user_account_classifies_unsupported_email() -> None:
  1043. class UnsupportedEmailSession:
  1044. def post(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  1045. del kwargs
  1046. assert url.endswith("/create_account")
  1047. return FakeResponse(
  1048. 400,
  1049. json_data={
  1050. "error": {
  1051. "message": "The email address is not supported.",
  1052. "code": "unsupported_email",
  1053. }
  1054. },
  1055. )
  1056. engine = RegistrationEngine(email_service=DummyEmailService())
  1057. engine.email = "demo@blacklisted.example.test"
  1058. engine.session = UnsupportedEmailSession()
  1059. success = engine._create_user_account()
  1060. result = engine._result(success=False, stage="create_account", error_message="create account failed")
  1061. assert success is False
  1062. assert result.metadata["email_domain"] == "blacklisted.example.test"
  1063. assert result.metadata["create_account_http_status"] == 400
  1064. assert result.metadata["create_account_error_code"] == "unsupported_email"
  1065. assert "not supported" in result.metadata["create_account_error_message"]
  1066. def test_get_verification_code_prefers_mailbox_context_and_logs_wait_diagnostics(monkeypatch) -> None:
  1067. class FakeMailbox:
  1068. def __init__(self) -> None:
  1069. self.last_wait_diagnostics = {
  1070. "first_message_seen_at": 103.0,
  1071. "matched_message_at": 104.0,
  1072. "poll_count": 2,
  1073. "message_scan_count": 3,
  1074. }
  1075. self.calls = []
  1076. def wait_for_code(self, account, *, keyword='', timeout=120, before_ids=None): # type: ignore[no-untyped-def]
  1077. self.calls.append({"account": account, "keyword": keyword, "timeout": timeout, "before_ids": before_ids})
  1078. return '123456'
  1079. fake_mailbox = FakeMailbox()
  1080. fake_account = object()
  1081. class FakeEmailService(DummyEmailService):
  1082. def __init__(self) -> None:
  1083. self.mailbox = fake_mailbox
  1084. self._account = fake_account
  1085. times = iter([105.0, 106.0])
  1086. monkeypatch.setattr(register_module.time, 'time', lambda: next(times))
  1087. monkeypatch.setenv('ZHUCE6_WAIT_OTP_TIMEOUT_SECONDS', '180')
  1088. engine = RegistrationEngine(email_service=FakeEmailService())
  1089. engine.email = 'demo@example.com'
  1090. engine._otp_sent_at = 100.0
  1091. engine._signup_otp_before_ids = {'old-1'}
  1092. code = engine._get_verification_code()
  1093. assert code == '123456'
  1094. assert fake_mailbox.calls[0]['before_ids'] == {'old-1'}
  1095. assert fake_mailbox.calls[0]['timeout'] == 180
  1096. assert any('waiting for verification code via mailbox' in line for line in engine.logs)
  1097. assert any('otp mailbox diagnostics' in line for line in engine.logs)
  1098. def test_get_verification_code_records_no_message_timeout_metadata(monkeypatch) -> None:
  1099. class FakeMailbox:
  1100. def __init__(self) -> None:
  1101. self.last_wait_diagnostics = {
  1102. "first_message_seen_at": None,
  1103. "matched_message_at": None,
  1104. "poll_count": 12,
  1105. "message_scan_count": 0,
  1106. }
  1107. def wait_for_code(self, account, *, keyword='', timeout=120, before_ids=None): # type: ignore[no-untyped-def]
  1108. del account, keyword, timeout, before_ids
  1109. return ''
  1110. fake_mailbox = FakeMailbox()
  1111. fake_account = object()
  1112. class FakeEmailService(DummyEmailService):
  1113. def __init__(self) -> None:
  1114. self.mailbox = fake_mailbox
  1115. self._account = fake_account
  1116. times = iter([100.0, 101.0, 102.0])
  1117. monkeypatch.setattr(register_module.time, 'time', lambda: next(times))
  1118. engine = RegistrationEngine(email_service=FakeEmailService())
  1119. engine.email = 'demo@example.com'
  1120. engine._otp_sent_at = 99.0
  1121. code = engine._get_verification_code()
  1122. assert code is None
  1123. metadata = engine._metadata()
  1124. assert metadata["otp_wait_failure_reason"] == "mailbox_timeout_no_message"
  1125. assert metadata["otp_mailbox_message_scan_count"] == 0
  1126. class _OtpRetrySession:
  1127. def __init__(self, *, method: str, response: FakeResponse | None = None, exc: Exception | None = None) -> None:
  1128. self.cookies = FakeCookies({"oai-client-auth-session": _workspace_cookie("ws-123")})
  1129. self._method = method
  1130. self._response = response
  1131. self._exc = exc
  1132. self.calls: list[tuple[str, str, dict[str, object]]] = []
  1133. def get(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  1134. self.calls.append(("GET", url, kwargs))
  1135. if self._method != "GET":
  1136. raise AssertionError(f"unexpected GET: {url}")
  1137. if self._exc is not None:
  1138. raise self._exc
  1139. assert self._response is not None
  1140. return self._response
  1141. def post(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  1142. self.calls.append(("POST", url, kwargs))
  1143. if self._method != "POST":
  1144. raise AssertionError(f"unexpected POST: {url}")
  1145. if self._exc is not None:
  1146. raise self._exc
  1147. assert self._response is not None
  1148. return self._response
  1149. class _OtpRetryHTTPClient:
  1150. def __init__(self, sessions): # type: ignore[no-untyped-def]
  1151. self._sessions = list(sessions)
  1152. self._index = 0
  1153. self.default_headers = {"User-Agent": "FakeAgent/1.0"}
  1154. @property
  1155. def session(self): # type: ignore[no-untyped-def]
  1156. return self._sessions[self._index]
  1157. def close(self) -> None:
  1158. if self._index < len(self._sessions) - 1:
  1159. self._index += 1
  1160. def test_send_verification_code_retries_transient_transport_error(monkeypatch) -> None:
  1161. monkeypatch.setattr("platforms.chatgpt.register_http.time.sleep", lambda _: None)
  1162. timeout_exc = RuntimeError(
  1163. "Failed to perform, curl: (28) Operation timed out after 30000 milliseconds with 0 bytes received."
  1164. )
  1165. sessions = [
  1166. _OtpRetrySession(method="GET", exc=timeout_exc),
  1167. _OtpRetrySession(method="GET", response=FakeResponse(200, url="https://auth.openai.com/api/accounts/email-otp/send")),
  1168. ]
  1169. engine = RegistrationEngine(email_service=DummyEmailService())
  1170. engine.http_client = _OtpRetryHTTPClient(sessions)
  1171. engine.session = engine.http_client.session
  1172. engine.email = "user@example.com"
  1173. assert engine._send_verification_code() is True
  1174. assert engine.http_client._index == 1
  1175. assert any("send otp: transient transport error" in line for line in engine.logs)
  1176. assert any("send otp status: 200" in line for line in engine.logs)
  1177. def test_validate_verification_code_retries_transient_transport_error(monkeypatch) -> None:
  1178. monkeypatch.setattr("platforms.chatgpt.register_http.time.sleep", lambda _: None)
  1179. timeout_exc = RuntimeError(
  1180. "Failed to perform, curl: (28) Operation timed out after 30000 milliseconds with 0 bytes received."
  1181. )
  1182. sessions = [
  1183. _OtpRetrySession(method="POST", exc=timeout_exc),
  1184. _OtpRetrySession(method="POST", response=FakeResponse(200, url="https://auth.openai.com/api/accounts/email-otp/validate")),
  1185. ]
  1186. engine = RegistrationEngine(email_service=DummyEmailService())
  1187. engine.http_client = _OtpRetryHTTPClient(sessions)
  1188. engine.session = engine.http_client.session
  1189. assert engine._validate_verification_code("123456") is True
  1190. assert engine.http_client._index == 1
  1191. assert any("validate otp: transient transport error" in line for line in engine.logs)
  1192. assert any("validate otp status: 200" in line for line in engine.logs)
  1193. def test_registration_engine_uses_updated_add_phone_recovery_defaults(monkeypatch) -> None:
  1194. monkeypatch.delenv("ZHUCE6_ADD_PHONE_OAUTH_MAX_ATTEMPTS", raising=False)
  1195. monkeypatch.delenv("ZHUCE6_POST_CREATE_LOGIN_DELAY_SECONDS", raising=False)
  1196. engine = RegistrationEngine(DummyEmailService())
  1197. assert engine._add_phone_oauth_max_attempts == 2
  1198. assert engine._post_create_login_delay_seconds == 8