返回 last30days-skill
test_github_device_auth.py
根目录 / tests / test_github_device_auth.py
1 """Tests for the ScrapeCreators GitHub device-auth flow.
2
3 Covers the U4/U5/U6 fixes: the device code is surfaced on stdout as an early
4 structured line, a non-device-shaped user_code is never copied/labeled/emitted,
5 and an already-registered account short-circuits without a fresh device dance.
6
7 All hermetic — the network, clipboard, and browser are patched.
8 """
9
10 import io
11 import json
12 from contextlib import redirect_stdout
13 from unittest.mock import MagicMock, patch
14
15 from lib import setup_wizard
16
17
18 class TestUserCodeValidation:
19 """U5: user_code must match the XXXX-XXXX device-code shape."""
20
21 @patch("lib.setup_wizard.subprocess.run")
22 @patch("lib.setup_wizard.run_device_auth")
23 @patch("lib.setup_wizard._existing_scrapecreators_key", return_value=None)
24 def test_malformed_user_code_is_rejected(
25 self, _mock_existing, mock_run_device, mock_subprocess_run
26 ):
27 # mock_subprocess_run patches setup_wizard.subprocess.run; the only such
28 # call in run_full_device_auth is the pbcopy of the code.
29 # A key-shaped value (no dash, 28 chars) must never be treated as a code.
30 mock_run_device.return_value = (
31 "dev-code-123",
32 "m08LboBUJpRz82AMyuCWP9sqwnk2",
33 "https://github.com/login/device",
34 5,
35 )
36 out = io.StringIO()
37 with redirect_stdout(out):
38 result = setup_wizard.run_full_device_auth(timeout=1)
39
40 assert result["status"] == "error"
41 # Not copied to the clipboard (no pbcopy subprocess call)...
42 mock_subprocess_run.assert_not_called()
43 # ...and never emitted as a device_code_ready line.
44 assert "device_code_ready" not in out.getvalue()
45 assert "m08LboBUJpRz82AMyuCWP9sqwnk2" not in out.getvalue()
46
47
48 class TestDeviceCodeReadyEmission:
49 """U4: a validated code is emitted to stdout before polling."""
50
51 @patch("webbrowser.open")
52 @patch("lib.setup_wizard.subprocess.run")
53 @patch("lib.setup_wizard.poll_device_auth")
54 @patch("lib.setup_wizard.run_device_auth")
55 @patch("lib.setup_wizard._existing_scrapecreators_key", return_value=None)
56 def test_valid_code_emitted_to_stdout(
57 self, _mock_existing, mock_run_device, mock_poll, mock_pbcopy, mock_browser
58 ):
59 mock_run_device.return_value = (
60 "dev-code-123",
61 "819B-F71B",
62 "https://github.com/login/device",
63 5,
64 )
65 mock_poll.return_value = None # simulate timeout, no token
66
67 out = io.StringIO()
68 with redirect_stdout(out):
69 result = setup_wizard.run_full_device_auth(timeout=1)
70
71 lines = [ln for ln in out.getvalue().splitlines() if ln.strip()]
72 ready = [json.loads(ln) for ln in lines if "device_code_ready" in ln]
73 assert ready, "expected a device_code_ready stdout line"
74 assert ready[0]["user_code"] == "819B-F71B"
75 assert ready[0]["verification_uri"] == "https://github.com/login/device"
76 # The final status is still returned (timeout here).
77 assert result["status"] == "timeout"
78
79
80 class TestAlreadyRegistered:
81 """An existing key short-circuits run_github_start without the device dance."""
82
83 @patch("lib.setup_wizard.run_device_auth")
84 @patch("lib.setup_wizard._existing_scrapecreators_key")
85 def test_existing_key_short_circuits_start(self, mock_existing, mock_device):
86 mock_existing.return_value = "sc_live_realkey1234567890"
87
88 result = setup_wizard.run_github_start()
89
90 assert result["status"] == "already_registered"
91 assert result["persisted"] is True
92 mock_device.assert_not_called() # no /code submit, no browser
93
94 @patch("webbrowser.open")
95 @patch("lib.setup_wizard.subprocess.run")
96 @patch("lib.setup_wizard.run_device_auth")
97 @patch("lib.setup_wizard._existing_scrapecreators_key")
98 def test_no_existing_key_starts_device_flow(
99 self, mock_existing, mock_device, mock_pbcopy, mock_browser
100 ):
101 mock_existing.return_value = None
102 mock_device.return_value = ("dev-code", "819B-F71B", "https://github.com/login/device", 5)
103
104 out = io.StringIO()
105 with redirect_stdout(out):
106 result = setup_wizard.run_github_start()
107
108 assert result["status"] == "awaiting_authorization"
109 assert result["user_code"] == "819B-F71B"
110 mock_device.assert_called_once()
111 # The code is printed to stdout as a plain human line (the whole point).
112 assert "819B-F71B" in out.getvalue()
113
114 @patch("lib.setup_wizard._device_handle_path")
115 @patch("lib.setup_wizard.fetch_api_key")
116 @patch("lib.setup_wizard.poll_device_auth")
117 def test_poll_reads_handle_and_returns_key(self, mock_poll, mock_fetch, mock_handle, tmp_path):
118 import json as _json
119 handle = tmp_path / "h.json"
120 handle.write_text(_json.dumps({"device_code": "dc", "interval": 1, "user_code": "819B-F71B"}))
121 mock_handle.return_value = handle
122 mock_poll.return_value = "access-token"
123 mock_fetch.return_value = "sc_polled_key"
124
125 result = setup_wizard.run_github_poll(timeout=1)
126
127 assert result["status"] == "success"
128 assert result["api_key"] == "sc_polled_key"
129 assert not handle.exists() # handle cleaned up
130
131 @patch("lib.setup_wizard._device_handle_path")
132 def test_poll_without_handle_errors_cleanly(self, mock_handle, tmp_path):
133 mock_handle.return_value = tmp_path / "missing.json"
134 result = setup_wizard.run_github_poll(timeout=1)
135 assert result["status"] == "error"
136 assert "github-start" in result["message"]
137
138 @patch("lib.setup_wizard._device_handle_path")
139 @patch("lib.setup_wizard.fetch_api_key")
140 @patch("lib.setup_wizard.poll_device_auth")
141 def test_poll_passes_real_clipboard_state(self, mock_poll, mock_fetch, mock_handle, tmp_path):
142 """clipboard_ok is read from the handle, not hardcoded True, so the poll
143 reminder never falsely claims the code is on the clipboard."""
144 import json as _json
145 handle = tmp_path / "h.json"
146 handle.write_text(
147 _json.dumps({"device_code": "dc", "interval": 1, "user_code": "819B-F71B", "clipboard_ok": False})
148 )
149 mock_handle.return_value = handle
150 mock_poll.return_value = "tok"
151 mock_fetch.return_value = "sc_k"
152
153 setup_wizard.run_github_poll(timeout=1)
154
155 assert mock_poll.call_args.kwargs["clipboard_ok"] is False
156
157 @patch("lib.setup_wizard._device_handle_path")
158 @patch("lib.setup_wizard.fetch_api_key")
159 @patch("lib.setup_wizard.poll_device_auth")
160 @patch("lib.setup_wizard._start_device_flow")
161 def test_oneshot_uses_in_memory_handle_when_file_write_fails(
162 self, mock_start, mock_poll, mock_fetch, mock_handle, tmp_path
163 ):
164 """run_full_device_auth hands the handle to poll in-memory, so a broken
165 handle-file path can't strand the one-shot."""
166 mock_handle.return_value = tmp_path / "does-not-exist" / "h.json" # unwritable/unreadable
167 mock_start.return_value = (
168 {"status": "awaiting_authorization", "user_code": "819B-F71B"},
169 {"device_code": "dc", "interval": 1, "user_code": "819B-F71B", "clipboard_ok": True},
170 )
171 mock_poll.return_value = "tok"
172 mock_fetch.return_value = "sc_oneshot_key"
173
174 result = setup_wizard.run_full_device_auth(timeout=1)
175
176 assert result["status"] == "success"
177 assert result["api_key"] == "sc_oneshot_key"
178 mock_poll.assert_called_once() # reached poll without a readable handle file
179
180 def test_already_registered_key_is_masked_before_output(self):
181 # Defense-in-depth: the mask helper must not echo the raw key.
182 masked = setup_wizard.mask_api_key("sc_live_realkey1234567890")
183 assert "realkey" not in masked
184 assert masked != "sc_live_realkey1234567890"
185
186
187 class TestFetchApiKeyLogging:
188 """U4: the /profile no-key path logs field NAMES only, never values."""
189
190 @patch("lib.setup_wizard.logger")
191 @patch("lib.setup_wizard.urlopen")
192 def test_no_key_logs_field_names_not_values(self, mock_urlopen, mock_logger):
193 # An already-linked account: /profile parses but carries no api_key,
194 # and could carry a secret under another field (here, "token").
195 body = json.dumps({"linked": True, "token": "sc_secret_value_xyz"}).encode()
196 resp = MagicMock()
197 resp.read.return_value = body
198 resp.__enter__.return_value = resp
199 mock_urlopen.return_value = resp
200
201 result = setup_wizard.fetch_api_key("gh_access_token")
202
203 assert result is None
204 # The warning must include the field names but never the secret value.
205 logged = " ".join(str(c) for c in mock_logger.warning.call_args_list)
206 assert "linked" in logged and "token" in logged
207 assert "sc_secret_value_xyz" not in logged
208
208 lines PYTHON