| 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 |