返回 last30days-skill
test_setup_openclaw.py
根目录 / tests / test_setup_openclaw.py
1 """Tests for OpenClaw setup and device auth functions."""
2
3 import io
4 import json
5 import sys
6 import time
7 from contextlib import redirect_stdout
8 from pathlib import Path
9 from unittest.mock import patch, MagicMock, call
10
11 import pytest
12
13 import last30days as cli
14 from lib import setup_wizard
15
16
17 class TestRunOpenclawSetup:
18 """Tests for run_openclaw_setup()."""
19
20 @staticmethod
21 def _patch_digg_noop(func):
22 """Keep OpenClaw setup tests free of real digg-pp-cli / npx side effects."""
23 return patch(
24 "lib.setup_wizard._install_digg_cli",
25 return_value=(False, "no_npx", "", ""),
26 )(func)
27
28 @_patch_digg_noop
29 @patch("shutil.which")
30 def test_all_tools_present_no_keys(self, mock_which, _mock_digg):
31 """All CLI tools found, no API keys configured."""
32 mock_which.side_effect = lambda cmd: f"/usr/bin/{cmd}"
33 config = {}
34
35 result = setup_wizard.run_openclaw_setup(config)
36
37 assert result["yt_dlp"] is True
38 assert result["node"] is True
39 assert result["python3"] is True
40 assert all(v is False for v in result["keys"].values())
41 assert result["x_method"] is None
42 assert result["digg_cli"] is False
43 assert result["digg_action"] == "no_npx"
44
45 @_patch_digg_noop
46 @patch("shutil.which")
47 def test_missing_tools(self, mock_which, _mock_digg):
48 """Some CLI tools missing."""
49 def which_side(cmd):
50 if cmd == "node":
51 return None
52 return f"/usr/bin/{cmd}"
53 mock_which.side_effect = which_side
54 config = {}
55
56 result = setup_wizard.run_openclaw_setup(config)
57
58 assert result["yt_dlp"] is True
59 assert result["node"] is False
60 assert result["python3"] is True
61
62 @_patch_digg_noop
63 @patch("shutil.which")
64 def test_keys_detected(self, mock_which, _mock_digg):
65 """API keys in config are reported as present."""
66 mock_which.return_value = None
67 config = {
68 "XAI_API_KEY": "xai-abc123",
69 "BRAVE_API_KEY": "brav-xyz",
70 "SCRAPECREATORS_API_KEY": "", # empty = falsy
71 }
72
73 result = setup_wizard.run_openclaw_setup(config)
74
75 assert result["keys"]["xai"] is True
76 assert result["keys"]["brave"] is True
77 assert result["keys"]["scrapecreators"] is False
78
79 def test_openclaw_metadata_keeps_scrapecreators_optional(self):
80 """OpenClaw metadata should not hard-require the ScrapeCreators key."""
81 skill_md = Path(__file__).parent.parent / "skills" / "last30days" / "SKILL.md"
82 text = skill_md.read_text(encoding="utf-8")
83 assert "SCRAPECREATORS_API_KEY" in text
84 expected = (
85 "requires:\n"
86 " env: []\n"
87 " optionalEnv:\n"
88 " - SCRAPECREATORS_API_KEY"
89 )
90 assert expected in text
91
92 @_patch_digg_noop
93 @patch("shutil.which")
94 def test_x_method_xai(self, mock_which, _mock_digg):
95 """x_method is 'xai' when XAI_API_KEY is set."""
96 mock_which.return_value = None
97 config = {"XAI_API_KEY": "xai-key"}
98
99 result = setup_wizard.run_openclaw_setup(config)
100
101 assert result["x_method"] == "xai"
102
103 @_patch_digg_noop
104 @patch("shutil.which")
105 def test_x_method_cookies(self, mock_which, _mock_digg):
106 """x_method is 'cookies' when AUTH_TOKEN + CT0 are set."""
107 mock_which.return_value = None
108 config = {"AUTH_TOKEN": "tok", "CT0": "ct0val"}
109
110 result = setup_wizard.run_openclaw_setup(config)
111
112 assert result["x_method"] == "cookies"
113
114 @_patch_digg_noop
115 @patch("shutil.which")
116 def test_x_method_xai_over_cookies(self, mock_which, _mock_digg):
117 """XAI takes priority over cookies for x_method."""
118 mock_which.return_value = None
119 config = {"XAI_API_KEY": "xai-key", "AUTH_TOKEN": "tok", "CT0": "ct0val"}
120
121 result = setup_wizard.run_openclaw_setup(config)
122
123 assert result["x_method"] == "xai"
124
125 @_patch_digg_noop
126 @patch("shutil.which")
127 def test_x_method_null_when_nothing(self, mock_which, _mock_digg):
128 """x_method is None when no X access configured."""
129 mock_which.return_value = None
130 config = {}
131
132 result = setup_wizard.run_openclaw_setup(config)
133
134 assert result["x_method"] is None
135
136 @_patch_digg_noop
137 @patch("shutil.which")
138 def test_output_is_json_serializable(self, mock_which, _mock_digg):
139 """Result can be serialized to JSON without errors."""
140 mock_which.return_value = "/usr/bin/something"
141 config = {"XAI_API_KEY": "k", "OPENAI_API_KEY": "ok"}
142
143 result = setup_wizard.run_openclaw_setup(config)
144 serialized = json.dumps(result)
145 parsed = json.loads(serialized)
146
147 assert parsed["yt_dlp"] is True
148 assert parsed["keys"]["xai"] is True
149
150 @patch("lib.setup_wizard._install_digg_cli")
151 @patch("shutil.which")
152 def test_digg_cli_on_path(self, mock_which, mock_digg_install):
153 """OpenClaw JSON reports digg_cli when PATH resolves digg-pp-cli."""
154 mock_which.side_effect = lambda cmd: f"/usr/bin/{cmd}"
155 mock_digg_install.return_value = (True, "already_installed", "", "")
156
157 result = setup_wizard.run_openclaw_setup({})
158
159 assert result["digg_cli"] is True
160 assert result["digg_action"] == "already_installed"
161 assert "digg_path" not in result
162
163 @patch("lib.setup_wizard._install_digg_cli")
164 @patch("shutil.which")
165 def test_digg_cli_off_path(self, mock_which, mock_digg_install):
166 """OpenClaw JSON surfaces off-PATH installs from prior pp-digg setup."""
167 mock_which.return_value = None
168 mock_digg_install.return_value = (
169 False,
170 "installed_off_path",
171 "",
172 "/Users/me/.local/bin/digg-pp-cli",
173 )
174
175 result = setup_wizard.run_openclaw_setup({})
176
177 assert result["digg_cli"] is False
178 assert result["digg_action"] == "installed_off_path"
179 assert result["digg_path"] == "/Users/me/.local/bin/digg-pp-cli"
180
181
182 class TestRunDeviceAuth:
183 """Tests for run_device_auth()."""
184
185 @patch("lib.setup_wizard.urlopen")
186 def test_success(self, mock_urlopen):
187 """Successful device code request returns tuple."""
188 resp_data = {
189 "device_code": "dc-123",
190 "user_code": "ABCD-1234",
191 "verification_uri": "https://github.com/login/device",
192 "interval": 5,
193 }
194 mock_resp = MagicMock()
195 mock_resp.read.return_value = json.dumps(resp_data).encode()
196 mock_resp.__enter__ = lambda s: s
197 mock_resp.__exit__ = MagicMock(return_value=False)
198 mock_urlopen.return_value = mock_resp
199
200 result = setup_wizard.run_device_auth()
201
202 assert result is not None
203 device_code, user_code, verification_uri, interval = result
204 assert device_code == "dc-123"
205 assert user_code == "ABCD-1234"
206 assert verification_uri == "https://github.com/login/device"
207 assert interval == 5
208
209 @patch("lib.setup_wizard.urlopen")
210 def test_http_error_returns_none(self, mock_urlopen):
211 """HTTP error during code request returns None."""
212 from urllib.error import HTTPError
213 mock_urlopen.side_effect = HTTPError(
214 "https://example.com", 500, "Server Error", {}, None
215 )
216
217 result = setup_wizard.run_device_auth()
218 assert result is None
219
220 @patch("lib.setup_wizard.urlopen")
221 def test_missing_device_code_returns_none(self, mock_urlopen):
222 """Incomplete response (no device_code) returns None."""
223 resp_data = {"user_code": "ABCD-1234"}
224 mock_resp = MagicMock()
225 mock_resp.read.return_value = json.dumps(resp_data).encode()
226 mock_resp.__enter__ = lambda s: s
227 mock_resp.__exit__ = MagicMock(return_value=False)
228 mock_urlopen.return_value = mock_resp
229
230 result = setup_wizard.run_device_auth()
231 assert result is None
232
233
234 class TestPollDeviceAuth:
235 """Tests for poll_device_auth()."""
236
237 @patch("lib.setup_wizard.time")
238 @patch("lib.setup_wizard.urlopen")
239 def test_success_on_second_poll(self, mock_urlopen, mock_time):
240 """Returns access_token after initial pending then success."""
241 # First call: time check (within deadline), second: after sleep, etc.
242 mock_time.time = MagicMock(side_effect=[0, 0, 0, 0])
243 mock_time.sleep = MagicMock()
244
245 pending_resp = MagicMock()
246 pending_resp.read.return_value = json.dumps({"error": "authorization_pending"}).encode()
247 pending_resp.__enter__ = lambda s: s
248 pending_resp.__exit__ = MagicMock(return_value=False)
249
250 success_resp = MagicMock()
251 success_resp.read.return_value = json.dumps({"access_token": "gho_abc123"}).encode()
252 success_resp.__enter__ = lambda s: s
253 success_resp.__exit__ = MagicMock(return_value=False)
254
255 mock_urlopen.side_effect = [pending_resp, success_resp]
256
257 result = setup_wizard.poll_device_auth("dc-123", interval=1, timeout=300)
258 assert result == "gho_abc123"
259
260 @patch("lib.setup_wizard.time")
261 @patch("lib.setup_wizard.urlopen")
262 def test_timeout_returns_none(self, mock_urlopen, mock_time):
263 """Returns None when timeout is exceeded."""
264 # poll_device_auth captures started_at once, derives deadline + last_reminder
265 # from it, then checks time.time() in the while-loop. Two values: started_at,
266 # then a value past the deadline so the loop exits immediately.
267 mock_time.time = MagicMock(side_effect=[0, 301])
268 mock_time.sleep = MagicMock()
269
270 result = setup_wizard.poll_device_auth("dc-123", interval=5, timeout=300)
271 assert result is None
272
273 @patch("lib.setup_wizard.time")
274 @patch("lib.setup_wizard.urlopen")
275 def test_expired_token_returns_none(self, mock_urlopen, mock_time):
276 """Returns None on expired_token error."""
277 # Loop terminates via urlopen response, not the clock — pin time to 0
278 # so the deadline check stays a non-event regardless of call count.
279 mock_time.time = MagicMock(return_value=0)
280 mock_time.sleep = MagicMock()
281
282 expired_resp = MagicMock()
283 expired_resp.read.return_value = json.dumps({"error": "expired_token"}).encode()
284 expired_resp.__enter__ = lambda s: s
285 expired_resp.__exit__ = MagicMock(return_value=False)
286
287 mock_urlopen.return_value = expired_resp
288
289 result = setup_wizard.poll_device_auth("dc-123", interval=1, timeout=300)
290 assert result is None
291
292 @patch("lib.setup_wizard.time")
293 @patch("lib.setup_wizard.urlopen")
294 def test_http_400_continues_polling(self, mock_urlopen, mock_time):
295 """HTTP 400 during polling continues (authorization pending)."""
296 from urllib.error import HTTPError
297
298 mock_time.time = MagicMock(return_value=0)
299 mock_time.sleep = MagicMock()
300
301 success_resp = MagicMock()
302 success_resp.read.return_value = json.dumps({"access_token": "gho_ok"}).encode()
303 success_resp.__enter__ = lambda s: s
304 success_resp.__exit__ = MagicMock(return_value=False)
305
306 mock_urlopen.side_effect = [
307 HTTPError("url", 400, "Bad Request", {}, None),
308 success_resp,
309 ]
310
311 result = setup_wizard.poll_device_auth("dc-123", interval=1, timeout=300)
312 assert result == "gho_ok"
313
314
315 class TestFetchApiKey:
316 """Tests for fetch_api_key()."""
317
318 @patch("lib.setup_wizard.urlopen")
319 def test_success(self, mock_urlopen):
320 """Returns ok+api_key from profile response."""
321 resp_data = {"api_key": "sc-key-abc123", "username": "testuser"}
322 mock_resp = MagicMock()
323 mock_resp.read.return_value = json.dumps(resp_data).encode()
324 mock_resp.__enter__ = lambda s: s
325 mock_resp.__exit__ = MagicMock(return_value=False)
326 mock_urlopen.return_value = mock_resp
327
328 result = setup_wizard.fetch_api_key("gho_token")
329 assert result == {"ok": True, "api_key": "sc-key-abc123"}
330
331 @patch("lib.setup_wizard.urlopen")
332 def test_no_api_key_in_response(self, mock_urlopen):
333 """Returns no_api_key when api_key is not in the response."""
334 resp_data = {"username": "testuser"}
335 mock_resp = MagicMock()
336 mock_resp.read.return_value = json.dumps(resp_data).encode()
337 mock_resp.__enter__ = lambda s: s
338 mock_resp.__exit__ = MagicMock(return_value=False)
339 mock_urlopen.return_value = mock_resp
340
341 result = setup_wizard.fetch_api_key("gho_token")
342 assert result == {"ok": False, "reason": "no_api_key"}
343
344 @patch("lib.setup_wizard.urlopen")
345 def test_http_error_returns_http_error(self, mock_urlopen):
346 """4xx HTTP error is reason=http_error, not no_api_key."""
347 from urllib.error import HTTPError
348 mock_urlopen.side_effect = HTTPError(
349 "https://example.com", 401, "Unauthorized", {}, None
350 )
351
352 result = setup_wizard.fetch_api_key("bad_token")
353 assert result["ok"] is False
354 assert result["reason"] == "http_error"
355 assert result["http_status"] == 401
356
357 @patch("lib.setup_wizard.time.sleep")
358 @patch("lib.setup_wizard.urlopen")
359 def test_http_500_is_upstream_error_with_retries(self, mock_urlopen, mock_sleep):
360 """5xx is upstream_error after bounded retries; not no_api_key (#882)."""
361 from urllib.error import HTTPError
362 from io import BytesIO
363
364 def _http_500(_req, timeout=15):
365 raise HTTPError(
366 "https://api.scrapecreators.com/v1/github/device/profile",
367 500,
368 "Internal Server Error",
369 {},
370 BytesIO(b'{"message":"internal"}'),
371 )
372
373 mock_urlopen.side_effect = _http_500
374
375 result = setup_wizard.fetch_api_key("gho_token")
376 assert result["ok"] is False
377 assert result["reason"] == "upstream_error"
378 assert result["http_status"] == 500
379 assert "500" in result["detail"]
380 assert mock_urlopen.call_count == setup_wizard._PROFILE_FETCH_ATTEMPTS
381 assert mock_sleep.call_count == setup_wizard._PROFILE_FETCH_ATTEMPTS - 1
382
383
384 class TestRunFullDeviceAuth:
385 """Tests for run_full_device_auth()."""
386
387 @patch("lib.setup_wizard.fetch_api_key")
388 @patch("lib.setup_wizard.poll_device_auth")
389 @patch("lib.setup_wizard.run_device_auth")
390 @patch("webbrowser.open")
391 def test_happy_path(self, mock_browser, mock_start, mock_poll, mock_fetch):
392 """Full flow succeeds: start -> poll -> fetch -> return api_key."""
393 mock_start.return_value = ("dev123", "ABCD-1234", "https://example.com/device", 5)
394 mock_poll.return_value = "access_tok"
395 mock_fetch.return_value = {"ok": True, "api_key": "sc_live_abc123"}
396
397 result = setup_wizard.run_full_device_auth(timeout=10)
398
399 assert result["status"] == "success"
400 assert result["api_key"] == "sc_live_abc123"
401 assert result["user_code"] == "ABCD-1234"
402 mock_browser.assert_called_once_with("https://example.com/device")
403
404 @patch("lib.setup_wizard.run_device_auth")
405 def test_start_fails(self, mock_start):
406 """Device code request fails -> error status."""
407 mock_start.return_value = None
408
409 result = setup_wizard.run_full_device_auth()
410
411 assert result["status"] == "error"
412 assert "Failed to start" in result["message"]
413
414 @patch("lib.setup_wizard.poll_device_auth")
415 @patch("lib.setup_wizard.run_device_auth")
416 @patch("webbrowser.open")
417 def test_poll_timeout(self, mock_browser, mock_start, mock_poll):
418 """Poll times out -> timeout status with user_code."""
419 mock_start.return_value = ("dev123", "WXYZ-5678", "https://example.com/device", 5)
420 mock_poll.return_value = None
421
422 result = setup_wizard.run_full_device_auth(timeout=10)
423
424 assert result["status"] == "timeout"
425 assert result["user_code"] == "WXYZ-5678"
426
427 @patch("lib.setup_wizard.fetch_api_key")
428 @patch("lib.setup_wizard.poll_device_auth")
429 @patch("lib.setup_wizard.run_device_auth")
430 @patch("webbrowser.open")
431 def test_fetch_fails_after_auth(self, mock_browser, mock_start, mock_poll, mock_fetch):
432 """Auth succeeds but profile has no api_key -> already-linked error message."""
433 mock_start.return_value = ("dev123", "CODE-1111", "https://example.com/device", 5)
434 mock_poll.return_value = "access_tok"
435 mock_fetch.return_value = {"ok": False, "reason": "no_api_key"}
436
437 result = setup_wizard.run_full_device_auth(timeout=10)
438
439 assert result["status"] == "error"
440 assert result["message"] == "Authorized but failed to fetch API key"
441 assert result["reason"] == "no_api_key"
442
443 @patch("lib.setup_wizard.fetch_api_key")
444 @patch("lib.setup_wizard.poll_device_auth")
445 @patch("lib.setup_wizard.run_device_auth")
446 @patch("webbrowser.open")
447 def test_fetch_upstream_500_after_auth(self, mock_browser, mock_start, mock_poll, mock_fetch):
448 """Auth succeeds but /profile 5xx -> distinct upstream message (#882)."""
449 mock_start.return_value = ("dev123", "CODE-1111", "https://example.com/device", 5)
450 mock_poll.return_value = "access_tok"
451 mock_fetch.return_value = {
452 "ok": False,
453 "reason": "upstream_error",
454 "http_status": 500,
455 "detail": "HTTP Error 500: Internal Server Error",
456 }
457
458 result = setup_wizard.run_full_device_auth(timeout=10)
459
460 assert result["status"] == "error"
461 assert result["reason"] == "upstream_error"
462 assert "ScrapeCreators profile failed" in result["message"]
463 assert "500" in result["message"]
464 assert result["message"] != "Authorized but failed to fetch API key"
465
466 @patch("lib.setup_wizard.run_device_auth")
467 @patch("webbrowser.open")
468 def test_browser_open_fails_gracefully(self, mock_browser, mock_start):
469 """webbrowser.open raises -> flow continues without crashing."""
470 mock_start.return_value = ("dev123", "CODE-2222", "https://example.com/device", 5)
471 mock_browser.side_effect = Exception("no display")
472
473 with patch("lib.setup_wizard.poll_device_auth", return_value=None):
474 result = setup_wizard.run_full_device_auth(timeout=1)
475
476 # Should not crash, just timeout
477 assert result["status"] == "timeout"
478
479 @patch("lib.setup_wizard.run_device_auth")
480 @patch("webbrowser.open")
481 def test_no_verification_uri_skips_browser(self, mock_browser, mock_start):
482 """Empty verification_uri -> browser not opened."""
483 mock_start.return_value = ("dev123", "CODE-3333", "", 5)
484
485 with patch("lib.setup_wizard.poll_device_auth", return_value=None):
486 setup_wizard.run_full_device_auth(timeout=1)
487
488 mock_browser.assert_not_called()
489
490
491 class TestClipboardDeviceAuth:
492 """Tests for clipboard-first behavior in run_full_device_auth()."""
493
494 @patch("lib.setup_wizard.run_device_auth")
495 @patch("lib.setup_wizard.poll_device_auth", return_value=None)
496 @patch("webbrowser.open")
497 @patch("subprocess.run")
498 def test_pbcopy_called_on_macos(self, mock_subproc, mock_browser, mock_poll, mock_start):
499 """On macOS, pbcopy is called with the user code before browser opens."""
500 mock_start.return_value = ("dev123", "CLIP-CODE", "https://github.com/login/device", 5)
501
502 with patch("sys.platform", "darwin"):
503 setup_wizard.run_full_device_auth(timeout=1)
504
505 mock_subproc.assert_called_once()
506 call_args = mock_subproc.call_args
507 assert call_args[0][0] == ["pbcopy"]
508 assert call_args[1]["input"] == b"CLIP-CODE"
509
510 @patch("lib.setup_wizard.run_device_auth")
511 @patch("lib.setup_wizard.poll_device_auth", return_value=None)
512 @patch("webbrowser.open")
513 @patch("subprocess.run")
514 def test_no_pbcopy_on_linux(self, mock_subproc, mock_browser, mock_poll, mock_start):
515 """On Linux, subprocess.run (pbcopy) is not called."""
516 mock_start.return_value = ("dev123", "CLIP-CODE", "https://github.com/login/device", 5)
517
518 with patch("sys.platform", "linux"):
519 setup_wizard.run_full_device_auth(timeout=1)
520
521 mock_subproc.assert_not_called()
522
523 @patch("lib.setup_wizard.run_device_auth")
524 @patch("lib.setup_wizard.poll_device_auth", return_value=None)
525 @patch("webbrowser.open")
526 @patch("subprocess.run", side_effect=Exception("pbcopy not found"))
527 def test_pbcopy_failure_continues(self, mock_subproc, mock_browser, mock_poll, mock_start):
528 """pbcopy failing -> flow continues, browser still opens."""
529 mock_start.return_value = ("dev123", "CLIP-CODE", "https://github.com/login/device", 5)
530
531 with patch("sys.platform", "darwin"):
532 result = setup_wizard.run_full_device_auth(timeout=1)
533
534 # Should not crash, browser still called
535 mock_browser.assert_called_once()
536 assert result["status"] == "timeout"
537
538
539 class TestRunGithubAuth:
540 """Tests for run_github_auth() — device flow only."""
541
542 @patch("lib.setup_wizard.run_full_device_auth")
543 def test_goes_to_device_flow(self, mock_device):
544 """Setup never forwards a local gh PAT to ScrapeCreators."""
545 mock_device.return_value = {
546 "status": "success", "method": "device",
547 "api_key": "sc_live_deviceOnly",
548 }
549
550 result = setup_wizard.run_github_auth(timeout=10)
551
552 assert result["status"] == "success"
553 assert result["method"] == "device"
554 mock_device.assert_called_once_with(timeout=10)
555
556 @patch("lib.setup_wizard.run_full_device_auth")
557 @patch("subprocess.run", side_effect=AssertionError("must not read gh auth token"))
558 def test_does_not_shell_out_for_gh_token(self, mock_subproc, mock_device):
559 mock_device.return_value = {"status": "timeout", "user_code": "ABCD-1234"}
560 result = setup_wizard.run_github_auth(timeout=1)
561 assert result["status"] == "timeout"
562 mock_subproc.assert_not_called()
563
564
565 class TestSetupGithubCliWiring:
566 """Tests for the `setup --github` CLI branch: persist + mask the key."""
567
568 def _run_setup_github(self, tmp_path, monkeypatch):
569 """Invoke `setup --github` in-process, return (parsed_json, env_path)."""
570 env_path = tmp_path / ".env"
571 monkeypatch.setattr(cli.env, "CONFIG_FILE", env_path)
572 monkeypatch.setattr(sys, "argv", ["last30days", "setup", "--github"])
573 buf = io.StringIO()
574 with redirect_stdout(buf):
575 rc = cli.main()
576 assert rc == 0
577 return json.loads(buf.getvalue()), env_path
578
579 @patch("lib.setup_wizard.run_github_auth")
580 def test_success_persists_and_masks(self, mock_auth, tmp_path, monkeypatch):
581 """Success -> key written to .env, stdout JSON masked, persisted true."""
582 mock_auth.return_value = {
583 "status": "success", "method": "device",
584 "api_key": "sc_live_supersecret9999", "user_code": "ABCD-1234",
585 }
586
587 payload, env_path = self._run_setup_github(tmp_path, monkeypatch)
588
589 # Key persisted to disk with the real value
590 assert "SCRAPECREATORS_API_KEY=sc_live_supersecret9999" in env_path.read_text()
591 # JSON reports persistence and the raw secret never appears in stdout
592 assert payload["persisted"] is True
593 assert payload["status"] == "success"
594 assert payload["api_key"] != "sc_live_supersecret9999"
595 assert "supersecret9999" not in json.dumps(payload)
596 # Useful non-secret fields survive
597 assert payload["user_code"] == "ABCD-1234"
598
599 @patch("lib.setup_wizard.run_github_auth")
600 def test_timeout_persists_nothing(self, mock_auth, tmp_path, monkeypatch):
601 """Timeout -> no key on disk, persisted false."""
602 mock_auth.return_value = {"status": "timeout", "user_code": "WXYZ-5678"}
603
604 payload, env_path = self._run_setup_github(tmp_path, monkeypatch)
605
606 assert payload["persisted"] is False
607 assert not env_path.exists()
608 assert payload["status"] == "timeout"
609
609 lines PYTHON