chore: import upstream snapshot with attribution
Security / Dependency review (push) Has been skipped
Scorecard / Scorecard analysis (push) Failing after 0s
Validate / eval (push) Failing after 0s
Security / Dependency audit (push) Failing after 1s
Security / Secret scan (push) Failing after 1s
Validate / tests (push) Failing after 0s
Validate / mcp-tests (push) Failing after 1s
GitHub Actions Security Analysis with zizmor 🌈 / zizmor (push) Failing after 1s
Security / SAST scan (push) Failing after 13m52s
Security / Dependency review (push) Has been skipped
Scorecard / Scorecard analysis (push) Failing after 0s
Validate / eval (push) Failing after 0s
Security / Dependency audit (push) Failing after 1s
Security / Secret scan (push) Failing after 1s
Validate / tests (push) Failing after 0s
Validate / mcp-tests (push) Failing after 1s
GitHub Actions Security Analysis with zizmor 🌈 / zizmor (push) Failing after 1s
Security / SAST scan (push) Failing after 13m52s
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
"""Tests for the ScrapeCreators GitHub device-auth flow.
|
||||
|
||||
Covers the U4/U5/U6 fixes: the device code is surfaced on stdout as an early
|
||||
structured line, a non-device-shaped user_code is never copied/labeled/emitted,
|
||||
and an already-registered account short-circuits without a fresh device dance.
|
||||
|
||||
All hermetic — the network, clipboard, and browser are patched.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
from contextlib import redirect_stdout
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from lib import setup_wizard
|
||||
|
||||
|
||||
class TestUserCodeValidation:
|
||||
"""U5: user_code must match the XXXX-XXXX device-code shape."""
|
||||
|
||||
@patch("lib.setup_wizard.subprocess.run")
|
||||
@patch("lib.setup_wizard.run_device_auth")
|
||||
@patch("lib.setup_wizard._existing_scrapecreators_key", return_value=None)
|
||||
def test_malformed_user_code_is_rejected(
|
||||
self, _mock_existing, mock_run_device, mock_subprocess_run
|
||||
):
|
||||
# mock_subprocess_run patches setup_wizard.subprocess.run; the only such
|
||||
# call in run_full_device_auth is the pbcopy of the code.
|
||||
# A key-shaped value (no dash, 28 chars) must never be treated as a code.
|
||||
mock_run_device.return_value = (
|
||||
"dev-code-123",
|
||||
"m08LboBUJpRz82AMyuCWP9sqwnk2",
|
||||
"https://github.com/login/device",
|
||||
5,
|
||||
)
|
||||
out = io.StringIO()
|
||||
with redirect_stdout(out):
|
||||
result = setup_wizard.run_full_device_auth(timeout=1)
|
||||
|
||||
assert result["status"] == "error"
|
||||
# Not copied to the clipboard (no pbcopy subprocess call)...
|
||||
mock_subprocess_run.assert_not_called()
|
||||
# ...and never emitted as a device_code_ready line.
|
||||
assert "device_code_ready" not in out.getvalue()
|
||||
assert "m08LboBUJpRz82AMyuCWP9sqwnk2" not in out.getvalue()
|
||||
|
||||
|
||||
class TestDeviceCodeReadyEmission:
|
||||
"""U4: a validated code is emitted to stdout before polling."""
|
||||
|
||||
@patch("webbrowser.open")
|
||||
@patch("lib.setup_wizard.subprocess.run")
|
||||
@patch("lib.setup_wizard.poll_device_auth")
|
||||
@patch("lib.setup_wizard.run_device_auth")
|
||||
@patch("lib.setup_wizard._existing_scrapecreators_key", return_value=None)
|
||||
def test_valid_code_emitted_to_stdout(
|
||||
self, _mock_existing, mock_run_device, mock_poll, mock_pbcopy, mock_browser
|
||||
):
|
||||
mock_run_device.return_value = (
|
||||
"dev-code-123",
|
||||
"819B-F71B",
|
||||
"https://github.com/login/device",
|
||||
5,
|
||||
)
|
||||
mock_poll.return_value = None # simulate timeout, no token
|
||||
|
||||
out = io.StringIO()
|
||||
with redirect_stdout(out):
|
||||
result = setup_wizard.run_full_device_auth(timeout=1)
|
||||
|
||||
lines = [ln for ln in out.getvalue().splitlines() if ln.strip()]
|
||||
ready = [json.loads(ln) for ln in lines if "device_code_ready" in ln]
|
||||
assert ready, "expected a device_code_ready stdout line"
|
||||
assert ready[0]["user_code"] == "819B-F71B"
|
||||
assert ready[0]["verification_uri"] == "https://github.com/login/device"
|
||||
# The final status is still returned (timeout here).
|
||||
assert result["status"] == "timeout"
|
||||
|
||||
|
||||
class TestAlreadyRegistered:
|
||||
"""An existing key short-circuits run_github_start without the device dance."""
|
||||
|
||||
@patch("lib.setup_wizard.run_device_auth")
|
||||
@patch("lib.setup_wizard._existing_scrapecreators_key")
|
||||
def test_existing_key_short_circuits_start(self, mock_existing, mock_device):
|
||||
mock_existing.return_value = "sc_live_realkey1234567890"
|
||||
|
||||
result = setup_wizard.run_github_start()
|
||||
|
||||
assert result["status"] == "already_registered"
|
||||
assert result["persisted"] is True
|
||||
mock_device.assert_not_called() # no /code submit, no browser
|
||||
|
||||
@patch("webbrowser.open")
|
||||
@patch("lib.setup_wizard.subprocess.run")
|
||||
@patch("lib.setup_wizard.run_device_auth")
|
||||
@patch("lib.setup_wizard._existing_scrapecreators_key")
|
||||
def test_no_existing_key_starts_device_flow(
|
||||
self, mock_existing, mock_device, mock_pbcopy, mock_browser
|
||||
):
|
||||
mock_existing.return_value = None
|
||||
mock_device.return_value = ("dev-code", "819B-F71B", "https://github.com/login/device", 5)
|
||||
|
||||
out = io.StringIO()
|
||||
with redirect_stdout(out):
|
||||
result = setup_wizard.run_github_start()
|
||||
|
||||
assert result["status"] == "awaiting_authorization"
|
||||
assert result["user_code"] == "819B-F71B"
|
||||
mock_device.assert_called_once()
|
||||
# The code is printed to stdout as a plain human line (the whole point).
|
||||
assert "819B-F71B" in out.getvalue()
|
||||
|
||||
@patch("lib.setup_wizard._device_handle_path")
|
||||
@patch("lib.setup_wizard.fetch_api_key")
|
||||
@patch("lib.setup_wizard.poll_device_auth")
|
||||
def test_poll_reads_handle_and_returns_key(self, mock_poll, mock_fetch, mock_handle, tmp_path):
|
||||
import json as _json
|
||||
handle = tmp_path / "h.json"
|
||||
handle.write_text(_json.dumps({"device_code": "dc", "interval": 1, "user_code": "819B-F71B"}))
|
||||
mock_handle.return_value = handle
|
||||
mock_poll.return_value = "access-token"
|
||||
mock_fetch.return_value = "sc_polled_key"
|
||||
|
||||
result = setup_wizard.run_github_poll(timeout=1)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["api_key"] == "sc_polled_key"
|
||||
assert not handle.exists() # handle cleaned up
|
||||
|
||||
@patch("lib.setup_wizard._device_handle_path")
|
||||
def test_poll_without_handle_errors_cleanly(self, mock_handle, tmp_path):
|
||||
mock_handle.return_value = tmp_path / "missing.json"
|
||||
result = setup_wizard.run_github_poll(timeout=1)
|
||||
assert result["status"] == "error"
|
||||
assert "github-start" in result["message"]
|
||||
|
||||
@patch("lib.setup_wizard._device_handle_path")
|
||||
@patch("lib.setup_wizard.fetch_api_key")
|
||||
@patch("lib.setup_wizard.poll_device_auth")
|
||||
def test_poll_passes_real_clipboard_state(self, mock_poll, mock_fetch, mock_handle, tmp_path):
|
||||
"""clipboard_ok is read from the handle, not hardcoded True, so the poll
|
||||
reminder never falsely claims the code is on the clipboard."""
|
||||
import json as _json
|
||||
handle = tmp_path / "h.json"
|
||||
handle.write_text(
|
||||
_json.dumps({"device_code": "dc", "interval": 1, "user_code": "819B-F71B", "clipboard_ok": False})
|
||||
)
|
||||
mock_handle.return_value = handle
|
||||
mock_poll.return_value = "tok"
|
||||
mock_fetch.return_value = "sc_k"
|
||||
|
||||
setup_wizard.run_github_poll(timeout=1)
|
||||
|
||||
assert mock_poll.call_args.kwargs["clipboard_ok"] is False
|
||||
|
||||
@patch("lib.setup_wizard._device_handle_path")
|
||||
@patch("lib.setup_wizard.fetch_api_key")
|
||||
@patch("lib.setup_wizard.poll_device_auth")
|
||||
@patch("lib.setup_wizard._start_device_flow")
|
||||
def test_oneshot_uses_in_memory_handle_when_file_write_fails(
|
||||
self, mock_start, mock_poll, mock_fetch, mock_handle, tmp_path
|
||||
):
|
||||
"""run_full_device_auth hands the handle to poll in-memory, so a broken
|
||||
handle-file path can't strand the one-shot."""
|
||||
mock_handle.return_value = tmp_path / "does-not-exist" / "h.json" # unwritable/unreadable
|
||||
mock_start.return_value = (
|
||||
{"status": "awaiting_authorization", "user_code": "819B-F71B"},
|
||||
{"device_code": "dc", "interval": 1, "user_code": "819B-F71B", "clipboard_ok": True},
|
||||
)
|
||||
mock_poll.return_value = "tok"
|
||||
mock_fetch.return_value = "sc_oneshot_key"
|
||||
|
||||
result = setup_wizard.run_full_device_auth(timeout=1)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["api_key"] == "sc_oneshot_key"
|
||||
mock_poll.assert_called_once() # reached poll without a readable handle file
|
||||
|
||||
def test_already_registered_key_is_masked_before_output(self):
|
||||
# Defense-in-depth: the mask helper must not echo the raw key.
|
||||
masked = setup_wizard.mask_api_key("sc_live_realkey1234567890")
|
||||
assert "realkey" not in masked
|
||||
assert masked != "sc_live_realkey1234567890"
|
||||
|
||||
|
||||
class TestFetchApiKeyLogging:
|
||||
"""U4: the /profile no-key path logs field NAMES only, never values."""
|
||||
|
||||
@patch("lib.setup_wizard.logger")
|
||||
@patch("lib.setup_wizard.urlopen")
|
||||
def test_no_key_logs_field_names_not_values(self, mock_urlopen, mock_logger):
|
||||
# An already-linked account: /profile parses but carries no api_key,
|
||||
# and could carry a secret under another field (here, "token").
|
||||
body = json.dumps({"linked": True, "token": "sc_secret_value_xyz"}).encode()
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = body
|
||||
resp.__enter__.return_value = resp
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
result = setup_wizard.fetch_api_key("gh_access_token")
|
||||
|
||||
assert result is None
|
||||
# The warning must include the field names but never the secret value.
|
||||
logged = " ".join(str(c) for c in mock_logger.warning.call_args_list)
|
||||
assert "linked" in logged and "token" in logged
|
||||
assert "sc_secret_value_xyz" not in logged
|
||||
Reference in New Issue
Block a user