返回 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 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 == "sc-key-abc123"
330
331 @patch("lib.setup_wizard.urlopen")
332 def test_no_api_key_in_response(self, mock_urlopen):
333 """Returns None 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 is None
343
344 @patch("lib.setup_wizard.urlopen")
345 def test_http_error_returns_none(self, mock_urlopen):
346 """HTTP error returns None."""
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 is None
354
355
356 class TestRunFullDeviceAuth:
357 """Tests for run_full_device_auth()."""
358
359 @patch("lib.setup_wizard.fetch_api_key")
360 @patch("lib.setup_wizard.poll_device_auth")
361 @patch("lib.setup_wizard.run_device_auth")
362 @patch("webbrowser.open")
363 def test_happy_path(self, mock_browser, mock_start, mock_poll, mock_fetch):
364 """Full flow succeeds: start -> poll -> fetch -> return api_key."""
365 mock_start.return_value = ("dev123", "ABCD-1234", "https://example.com/device", 5)
366 mock_poll.return_value = "access_tok"
367 mock_fetch.return_value = "sc_live_abc123"
368
369 result = setup_wizard.run_full_device_auth(timeout=10)
370
371 assert result["status"] == "success"
372 assert result["api_key"] == "sc_live_abc123"
373 assert result["user_code"] == "ABCD-1234"
374 mock_browser.assert_called_once_with("https://example.com/device")
375
376 @patch("lib.setup_wizard.run_device_auth")
377 def test_start_fails(self, mock_start):
378 """Device code request fails -> error status."""
379 mock_start.return_value = None
380
381 result = setup_wizard.run_full_device_auth()
382
383 assert result["status"] == "error"
384 assert "Failed to start" in result["message"]
385
386 @patch("lib.setup_wizard.poll_device_auth")
387 @patch("lib.setup_wizard.run_device_auth")
388 @patch("webbrowser.open")
389 def test_poll_timeout(self, mock_browser, mock_start, mock_poll):
390 """Poll times out -> timeout status with user_code."""
391 mock_start.return_value = ("dev123", "WXYZ-5678", "https://example.com/device", 5)
392 mock_poll.return_value = None
393
394 result = setup_wizard.run_full_device_auth(timeout=10)
395
396 assert result["status"] == "timeout"
397 assert result["user_code"] == "WXYZ-5678"
398
399 @patch("lib.setup_wizard.fetch_api_key")
400 @patch("lib.setup_wizard.poll_device_auth")
401 @patch("lib.setup_wizard.run_device_auth")
402 @patch("webbrowser.open")
403 def test_fetch_fails_after_auth(self, mock_browser, mock_start, mock_poll, mock_fetch):
404 """Auth succeeds but profile fetch fails -> error status."""
405 mock_start.return_value = ("dev123", "CODE-1111", "https://example.com/device", 5)
406 mock_poll.return_value = "access_tok"
407 mock_fetch.return_value = None
408
409 result = setup_wizard.run_full_device_auth(timeout=10)
410
411 assert result["status"] == "error"
412 assert "failed to fetch" in result["message"].lower()
413
414 @patch("lib.setup_wizard.run_device_auth")
415 @patch("webbrowser.open")
416 def test_browser_open_fails_gracefully(self, mock_browser, mock_start):
417 """webbrowser.open raises -> flow continues without crashing."""
418 mock_start.return_value = ("dev123", "CODE-2222", "https://example.com/device", 5)
419 mock_browser.side_effect = Exception("no display")
420
421 with patch("lib.setup_wizard.poll_device_auth", return_value=None):
422 result = setup_wizard.run_full_device_auth(timeout=1)
423
424 # Should not crash, just timeout
425 assert result["status"] == "timeout"
426
427 @patch("lib.setup_wizard.run_device_auth")
428 @patch("webbrowser.open")
429 def test_no_verification_uri_skips_browser(self, mock_browser, mock_start):
430 """Empty verification_uri -> browser not opened."""
431 mock_start.return_value = ("dev123", "CODE-3333", "", 5)
432
433 with patch("lib.setup_wizard.poll_device_auth", return_value=None):
434 setup_wizard.run_full_device_auth(timeout=1)
435
436 mock_browser.assert_not_called()
437
438
439 class TestClipboardDeviceAuth:
440 """Tests for clipboard-first behavior in run_full_device_auth()."""
441
442 @patch("lib.setup_wizard.run_device_auth")
443 @patch("lib.setup_wizard.poll_device_auth", return_value=None)
444 @patch("webbrowser.open")
445 @patch("subprocess.run")
446 def test_pbcopy_called_on_macos(self, mock_subproc, mock_browser, mock_poll, mock_start):
447 """On macOS, pbcopy is called with the user code before browser opens."""
448 mock_start.return_value = ("dev123", "CLIP-CODE", "https://github.com/login/device", 5)
449
450 with patch("sys.platform", "darwin"):
451 setup_wizard.run_full_device_auth(timeout=1)
452
453 mock_subproc.assert_called_once()
454 call_args = mock_subproc.call_args
455 assert call_args[0][0] == ["pbcopy"]
456 assert call_args[1]["input"] == b"CLIP-CODE"
457
458 @patch("lib.setup_wizard.run_device_auth")
459 @patch("lib.setup_wizard.poll_device_auth", return_value=None)
460 @patch("webbrowser.open")
461 @patch("subprocess.run")
462 def test_no_pbcopy_on_linux(self, mock_subproc, mock_browser, mock_poll, mock_start):
463 """On Linux, subprocess.run (pbcopy) is not called."""
464 mock_start.return_value = ("dev123", "CLIP-CODE", "https://github.com/login/device", 5)
465
466 with patch("sys.platform", "linux"):
467 setup_wizard.run_full_device_auth(timeout=1)
468
469 mock_subproc.assert_not_called()
470
471 @patch("lib.setup_wizard.run_device_auth")
472 @patch("lib.setup_wizard.poll_device_auth", return_value=None)
473 @patch("webbrowser.open")
474 @patch("subprocess.run", side_effect=Exception("pbcopy not found"))
475 def test_pbcopy_failure_continues(self, mock_subproc, mock_browser, mock_poll, mock_start):
476 """pbcopy failing -> flow continues, browser still opens."""
477 mock_start.return_value = ("dev123", "CLIP-CODE", "https://github.com/login/device", 5)
478
479 with patch("sys.platform", "darwin"):
480 result = setup_wizard.run_full_device_auth(timeout=1)
481
482 # Should not crash, browser still called
483 mock_browser.assert_called_once()
484 assert result["status"] == "timeout"
485
486
487 class TestRunGithubAuth:
488 """Tests for run_github_auth() — device flow only."""
489
490 @patch("lib.setup_wizard.run_full_device_auth")
491 def test_goes_to_device_flow(self, mock_device):
492 """Setup never forwards a local gh PAT to ScrapeCreators."""
493 mock_device.return_value = {
494 "status": "success", "method": "device",
495 "api_key": "sc_live_deviceOnly",
496 }
497
498 result = setup_wizard.run_github_auth(timeout=10)
499
500 assert result["status"] == "success"
501 assert result["method"] == "device"
502 mock_device.assert_called_once_with(timeout=10)
503
504 @patch("lib.setup_wizard.run_full_device_auth")
505 @patch("subprocess.run", side_effect=AssertionError("must not read gh auth token"))
506 def test_does_not_shell_out_for_gh_token(self, mock_subproc, mock_device):
507 mock_device.return_value = {"status": "timeout", "user_code": "ABCD-1234"}
508 result = setup_wizard.run_github_auth(timeout=1)
509 assert result["status"] == "timeout"
510 mock_subproc.assert_not_called()
511
512
513 class TestSetupGithubCliWiring:
514 """Tests for the `setup --github` CLI branch: persist + mask the key."""
515
516 def _run_setup_github(self, tmp_path, monkeypatch):
517 """Invoke `setup --github` in-process, return (parsed_json, env_path)."""
518 env_path = tmp_path / ".env"
519 monkeypatch.setattr(cli.env, "CONFIG_FILE", env_path)
520 monkeypatch.setattr(sys, "argv", ["last30days", "setup", "--github"])
521 buf = io.StringIO()
522 with redirect_stdout(buf):
523 rc = cli.main()
524 assert rc == 0
525 return json.loads(buf.getvalue()), env_path
526
527 @patch("lib.setup_wizard.run_github_auth")
528 def test_success_persists_and_masks(self, mock_auth, tmp_path, monkeypatch):
529 """Success -> key written to .env, stdout JSON masked, persisted true."""
530 mock_auth.return_value = {
531 "status": "success", "method": "device",
532 "api_key": "sc_live_supersecret9999", "user_code": "ABCD-1234",
533 }
534
535 payload, env_path = self._run_setup_github(tmp_path, monkeypatch)
536
537 # Key persisted to disk with the real value
538 assert "SCRAPECREATORS_API_KEY=sc_live_supersecret9999" in env_path.read_text()
539 # JSON reports persistence and the raw secret never appears in stdout
540 assert payload["persisted"] is True
541 assert payload["status"] == "success"
542 assert payload["api_key"] != "sc_live_supersecret9999"
543 assert "supersecret9999" not in json.dumps(payload)
544 # Useful non-secret fields survive
545 assert payload["user_code"] == "ABCD-1234"
546
547 @patch("lib.setup_wizard.run_github_auth")
548 def test_timeout_persists_nothing(self, mock_auth, tmp_path, monkeypatch):
549 """Timeout -> no key on disk, persisted false."""
550 mock_auth.return_value = {"status": "timeout", "user_code": "WXYZ-5678"}
551
552 payload, env_path = self._run_setup_github(tmp_path, monkeypatch)
553
554 assert payload["persisted"] is False
555 assert not env_path.exists()
556 assert payload["status"] == "timeout"
557
557 lines PYTHON