2860fb5d18
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
753 lines
31 KiB
Python
753 lines
31 KiB
Python
"""Tests for the first-run setup wizard module."""
|
|
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
|
|
from lib import setup_wizard
|
|
|
|
|
|
class TestIsFirstRun:
|
|
"""Tests for is_first_run()."""
|
|
|
|
def test_first_run_when_setup_complete_not_set(self):
|
|
"""SETUP_COMPLETE not in config -> first run."""
|
|
config = {"AUTH_TOKEN": "abc", "CT0": "xyz"}
|
|
assert setup_wizard.is_first_run(config) is True
|
|
|
|
def test_first_run_when_setup_complete_is_none(self):
|
|
"""SETUP_COMPLETE=None -> first run."""
|
|
config = {"SETUP_COMPLETE": None}
|
|
assert setup_wizard.is_first_run(config) is True
|
|
|
|
def test_first_run_when_setup_complete_is_empty(self):
|
|
"""SETUP_COMPLETE="" -> first run."""
|
|
config = {"SETUP_COMPLETE": ""}
|
|
assert setup_wizard.is_first_run(config) is True
|
|
|
|
def test_not_first_run_when_setup_complete_true(self):
|
|
"""SETUP_COMPLETE=true -> not first run."""
|
|
config = {"SETUP_COMPLETE": "true"}
|
|
assert setup_wizard.is_first_run(config) is False
|
|
|
|
def test_not_first_run_when_setup_complete_any_value(self):
|
|
"""SETUP_COMPLETE set to any truthy value -> not first run."""
|
|
config = {"SETUP_COMPLETE": "yes"}
|
|
assert setup_wizard.is_first_run(config) is False
|
|
|
|
|
|
class TestRunAutoSetup:
|
|
"""Tests for run_auto_setup()."""
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source")
|
|
@patch("shutil.which")
|
|
def test_cookies_found(self, mock_which, mock_extract):
|
|
"""When cookies are found, results dict includes them."""
|
|
mock_extract.return_value = ({"auth_token": "abc", "ct0": "xyz"}, "chrome")
|
|
mock_which.return_value = "/usr/local/bin/yt-dlp"
|
|
|
|
config = {}
|
|
results = setup_wizard.run_auto_setup(config, allow_browser_cookies=True)
|
|
|
|
assert "x" in results["cookies_found"]
|
|
assert results["cookies_found"]["x"] == "chrome"
|
|
assert results["ytdlp_installed"] is True
|
|
assert results["ytdlp_action"] == "already_installed"
|
|
assert results["env_written"] is False
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source")
|
|
@patch("shutil.which")
|
|
def test_no_cookies_found(self, mock_which, mock_extract):
|
|
"""When no cookies found, results dict has empty cookies_found."""
|
|
mock_extract.return_value = None
|
|
mock_which.return_value = None
|
|
|
|
config = {}
|
|
results = setup_wizard.run_auto_setup(config)
|
|
|
|
assert results["cookies_found"] == {}
|
|
mock_extract.assert_not_called()
|
|
assert results["ytdlp_installed"] is False
|
|
assert results["ytdlp_action"] == "no_homebrew"
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source")
|
|
@patch("shutil.which")
|
|
def test_cookie_extraction_exception(self, mock_which, mock_extract):
|
|
"""Cookie extraction raising an exception is handled gracefully."""
|
|
mock_extract.side_effect = Exception("DB locked")
|
|
mock_which.return_value = None
|
|
|
|
config = {}
|
|
results = setup_wizard.run_auto_setup(config, allow_browser_cookies=True)
|
|
|
|
assert results["cookies_found"] == {}
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source")
|
|
@patch("shutil.which")
|
|
def test_multiple_sources(self, mock_which, mock_extract):
|
|
"""Multiple cookie sources can be found."""
|
|
def side_effect(browser, domain, cookie_names):
|
|
if domain == ".x.com":
|
|
return ({"auth_token": "abc", "ct0": "xyz"}, "firefox")
|
|
elif domain == ".truthsocial.com":
|
|
return ({"_session_id": "sess123"}, "firefox")
|
|
return None
|
|
|
|
mock_extract.side_effect = side_effect
|
|
mock_which.return_value = None
|
|
|
|
config = {}
|
|
results = setup_wizard.run_auto_setup(config, allow_browser_cookies=True)
|
|
|
|
assert results["cookies_found"]["x"] == "firefox"
|
|
assert results["cookies_found"]["truthsocial"] == "firefox"
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source")
|
|
@patch("shutil.which")
|
|
def test_default_scan_order_is_chromium_first(self, mock_which, mock_extract):
|
|
"""U1: with FROM_BROWSER unset, the scan tries Chrome before Safari.
|
|
|
|
Safari is the only X-cookie source needing Full Disk Access; Chrome
|
|
reads via the Keychain with no FDA. The wizard must try the Chromium
|
|
family first so the common macOS user does not hit the FDA dead-end.
|
|
"""
|
|
tried = []
|
|
|
|
def side_effect(browser, domain, cookie_names):
|
|
tried.append(browser)
|
|
return None # never find cookies, so every browser is attempted
|
|
|
|
mock_extract.side_effect = side_effect
|
|
mock_which.return_value = None
|
|
|
|
setup_wizard.run_auto_setup({}, allow_browser_cookies=True)
|
|
|
|
# Order within the first domain's attempts must be chromium-first.
|
|
assert "chrome" in tried and "safari" in tried
|
|
assert tried.index("chrome") < tried.index("safari")
|
|
assert tried.index("chrome") < tried.index("firefox")
|
|
# And it must not route through the silent-first "auto" order.
|
|
assert tried[0] == "chrome"
|
|
|
|
|
|
class TestYtdlpAutoInstall:
|
|
"""Tests for yt-dlp auto-install via Homebrew in run_auto_setup()."""
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
|
@patch("subprocess.run")
|
|
@patch("shutil.which")
|
|
def test_ytdlp_missing_brew_available_installs(self, mock_which, mock_subproc, mock_extract):
|
|
"""yt-dlp missing + brew available -> installs via brew."""
|
|
def which_side_effect(cmd):
|
|
if cmd == "yt-dlp":
|
|
return None
|
|
if cmd == "brew":
|
|
return "/opt/homebrew/bin/brew"
|
|
return None
|
|
mock_which.side_effect = which_side_effect
|
|
mock_subproc.return_value = MagicMock(returncode=0, stderr="")
|
|
|
|
results = setup_wizard.run_auto_setup({})
|
|
|
|
mock_subproc.assert_called_once_with(
|
|
["brew", "install", "yt-dlp"],
|
|
capture_output=True, text=True, timeout=120,
|
|
)
|
|
assert results["ytdlp_installed"] is True
|
|
assert results["ytdlp_action"] == "installed"
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
|
@patch("shutil.which")
|
|
def test_ytdlp_missing_brew_missing(self, mock_which, mock_extract):
|
|
"""yt-dlp missing + brew missing -> no_homebrew."""
|
|
mock_which.return_value = None
|
|
|
|
results = setup_wizard.run_auto_setup({})
|
|
|
|
assert results["ytdlp_installed"] is False
|
|
assert results["ytdlp_action"] == "no_homebrew"
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
|
@patch("shutil.which")
|
|
def test_ytdlp_already_installed(self, mock_which, mock_extract):
|
|
"""yt-dlp already installed -> already_installed."""
|
|
mock_which.return_value = "/usr/local/bin/yt-dlp"
|
|
|
|
results = setup_wizard.run_auto_setup({})
|
|
|
|
assert results["ytdlp_installed"] is True
|
|
assert results["ytdlp_action"] == "already_installed"
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
|
@patch("subprocess.run")
|
|
@patch("shutil.which")
|
|
def test_brew_install_fails(self, mock_which, mock_subproc, mock_extract):
|
|
"""brew install yt-dlp fails -> install_failed with stderr."""
|
|
def which_side_effect(cmd):
|
|
if cmd == "yt-dlp":
|
|
return None
|
|
if cmd == "brew":
|
|
return "/opt/homebrew/bin/brew"
|
|
return None
|
|
mock_which.side_effect = which_side_effect
|
|
mock_subproc.return_value = MagicMock(returncode=1, stderr="Error: something broke")
|
|
|
|
results = setup_wizard.run_auto_setup({})
|
|
|
|
assert results["ytdlp_installed"] is False
|
|
assert results["ytdlp_action"] == "install_failed"
|
|
assert "something broke" in results["ytdlp_stderr"]
|
|
|
|
|
|
class TestDiggAutoInstall:
|
|
"""Tests for digg-pp-cli auto-install via npx in run_auto_setup()."""
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
|
@patch("shutil.which")
|
|
def test_digg_already_installed(self, mock_which, mock_extract):
|
|
"""digg-pp-cli already on PATH -> already_installed, no subprocess."""
|
|
# yt-dlp missing + brew missing keeps the yt-dlp path subprocess-free;
|
|
# digg-pp-cli present short-circuits before any npx call.
|
|
def which_side_effect(cmd):
|
|
return "/Users/me/go/bin/digg-pp-cli" if cmd == "digg-pp-cli" else None
|
|
mock_which.side_effect = which_side_effect
|
|
|
|
with patch("subprocess.run") as mock_subproc:
|
|
results = setup_wizard.run_auto_setup({})
|
|
mock_subproc.assert_not_called()
|
|
|
|
assert results["digg_installed"] is True
|
|
assert results["digg_action"] == "already_installed"
|
|
|
|
# Redirect HOME/GOPATH so real ~/.local/bin or ~/go/bin digg-pp-cli on the
|
|
# dev box does not make the binary look present during absence tests.
|
|
@staticmethod
|
|
def _empty_home(tmp_path, monkeypatch):
|
|
monkeypatch.delenv("GOPATH", raising=False)
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
|
@patch("shutil.which")
|
|
def test_digg_no_npx(self, mock_which, mock_extract, tmp_path, monkeypatch):
|
|
"""digg-pp-cli missing + npx missing -> no_npx, no subprocess."""
|
|
self._empty_home(tmp_path, monkeypatch)
|
|
mock_which.return_value = None
|
|
|
|
with patch("subprocess.run") as mock_subproc:
|
|
results = setup_wizard.run_auto_setup({})
|
|
mock_subproc.assert_not_called()
|
|
|
|
assert results["digg_installed"] is False
|
|
assert results["digg_action"] == "no_npx"
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
|
@patch("subprocess.run")
|
|
@patch("shutil.which")
|
|
def test_digg_install_succeeds(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
|
|
"""npx present + install succeeds + binary verifiable -> installed."""
|
|
self._empty_home(tmp_path, monkeypatch)
|
|
# First which("digg-pp-cli") (pre-install) -> None, npx -> present,
|
|
# then post-install which("digg-pp-cli") -> resolves.
|
|
calls = {"digg": 0}
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd == "digg-pp-cli":
|
|
calls["digg"] += 1
|
|
return None if calls["digg"] == 1 else "/Users/me/go/bin/digg-pp-cli"
|
|
if cmd == "npx":
|
|
return "/opt/homebrew/bin/npx"
|
|
return None
|
|
mock_which.side_effect = which_side_effect
|
|
mock_subproc.return_value = MagicMock(returncode=0, stderr="")
|
|
|
|
results = setup_wizard.run_auto_setup({})
|
|
|
|
# The wizard now also best-effort-installs the additional default-on
|
|
# Printing Press sources (arxiv/techmeme/trustpilot), so digg is one of
|
|
# several install calls rather than the only one.
|
|
mock_subproc.assert_any_call(
|
|
["npx", "-y", setup_wizard.PRINTING_PRESS_NPM, "install", "digg", "--cli-only"],
|
|
capture_output=True, text=True, timeout=setup_wizard.DIGG_INSTALL_TIMEOUT,
|
|
)
|
|
assert results["digg_installed"] is True
|
|
assert results["digg_action"] == "installed"
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
|
@patch("subprocess.run")
|
|
@patch("shutil.which")
|
|
def test_digg_install_fails_nonzero(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
|
|
"""npx install returns non-zero -> install_failed with stderr."""
|
|
self._empty_home(tmp_path, monkeypatch)
|
|
def which_side_effect(cmd):
|
|
return "/opt/homebrew/bin/npx" if cmd == "npx" else None
|
|
mock_which.side_effect = which_side_effect
|
|
mock_subproc.return_value = MagicMock(returncode=1, stderr="npm ERR! boom")
|
|
|
|
results = setup_wizard.run_auto_setup({})
|
|
|
|
assert results["digg_installed"] is False
|
|
assert results["digg_action"] == "install_failed"
|
|
assert "boom" in results["digg_stderr"]
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
|
@patch("shutil.which")
|
|
def test_digg_prior_install_off_path(self, mock_which, mock_extract, tmp_path, monkeypatch):
|
|
"""pp-digg CLI at ~/.local/bin but not on PATH -> installed_off_path, no npx."""
|
|
self._empty_home(tmp_path, monkeypatch)
|
|
local_bin = tmp_path / ".local" / "bin"
|
|
local_bin.mkdir(parents=True)
|
|
binary = local_bin / "digg-pp-cli"
|
|
binary.write_text("#!/bin/sh\n")
|
|
binary.chmod(0o755)
|
|
mock_which.return_value = None
|
|
|
|
with patch("subprocess.run") as mock_subproc:
|
|
results = setup_wizard.run_auto_setup({})
|
|
mock_subproc.assert_not_called()
|
|
|
|
assert results["digg_installed"] is False
|
|
assert results["digg_action"] == "installed_off_path"
|
|
assert results["digg_path"] == str(binary)
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
|
@patch("subprocess.run")
|
|
@patch("shutil.which")
|
|
def test_digg_install_zero_but_not_on_path(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
|
|
"""rc=0, binary at $HOME/.local/bin but not on PATH -> installed_off_path."""
|
|
self._empty_home(tmp_path, monkeypatch)
|
|
def which_side_effect(cmd):
|
|
return "/opt/homebrew/bin/npx" if cmd == "npx" else None
|
|
mock_which.side_effect = which_side_effect
|
|
|
|
local_bin = tmp_path / ".local" / "bin"
|
|
|
|
def fake_install(*args, **kwargs):
|
|
local_bin.mkdir(parents=True, exist_ok=True)
|
|
binary = local_bin / "digg-pp-cli"
|
|
binary.write_text("#!/bin/sh\n")
|
|
binary.chmod(0o755)
|
|
return MagicMock(returncode=0, stderr="")
|
|
mock_subproc.side_effect = fake_install
|
|
|
|
results = setup_wizard.run_auto_setup({})
|
|
|
|
assert results["digg_installed"] is False
|
|
assert results["digg_action"] == "installed_off_path"
|
|
assert results["digg_path"] == str(local_bin / "digg-pp-cli")
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
|
@patch("subprocess.run")
|
|
@patch("shutil.which")
|
|
def test_digg_install_timeout_does_not_raise(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
|
|
"""subprocess raising (e.g. timeout) -> install_failed, no exception escapes."""
|
|
self._empty_home(tmp_path, monkeypatch)
|
|
def which_side_effect(cmd):
|
|
return "/opt/homebrew/bin/npx" if cmd == "npx" else None
|
|
mock_which.side_effect = which_side_effect
|
|
mock_subproc.side_effect = subprocess.TimeoutExpired(cmd="npx", timeout=300)
|
|
|
|
results = setup_wizard.run_auto_setup({})
|
|
|
|
assert results["digg_installed"] is False
|
|
assert results["digg_action"] == "install_failed"
|
|
|
|
|
|
class TestWriteSetupConfig:
|
|
"""Tests for write_setup_config()."""
|
|
|
|
def test_creates_new_env_file(self):
|
|
"""Creates .env with SETUP_COMPLETE; omits FROM_BROWSER when unspecified.
|
|
|
|
With no detected browser we must NOT pin FROM_BROWSER=auto, because
|
|
that makes every later run probe Chrome and re-trigger the macOS
|
|
Keychain prompt. Leaving it unset applies the safe Firefox/Safari
|
|
default instead.
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
env_path = Path(tmpdir) / "subdir" / ".env"
|
|
|
|
result = setup_wizard.write_setup_config(env_path)
|
|
|
|
assert result is True
|
|
assert env_path.exists()
|
|
content = env_path.read_text()
|
|
assert "SETUP_COMPLETE=true" in content
|
|
assert "FROM_BROWSER" not in content
|
|
|
|
def test_appends_to_existing_file(self):
|
|
"""Appends to existing .env without overwriting keys."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
env_path = Path(tmpdir) / ".env"
|
|
env_path.write_text("XAI_API_KEY=my-key\nAUTH_TOKEN=tok123\n")
|
|
|
|
result = setup_wizard.write_setup_config(env_path)
|
|
|
|
assert result is True
|
|
content = env_path.read_text()
|
|
# Original keys preserved
|
|
assert "XAI_API_KEY=my-key" in content
|
|
assert "AUTH_TOKEN=tok123" in content
|
|
# SETUP_COMPLETE appended; FROM_BROWSER omitted (no browser detected)
|
|
assert "SETUP_COMPLETE=true" in content
|
|
assert "FROM_BROWSER" not in content
|
|
|
|
def test_does_not_overwrite_existing_keys(self):
|
|
"""If SETUP_COMPLETE or FROM_BROWSER already exist, don't duplicate."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
env_path = Path(tmpdir) / ".env"
|
|
env_path.write_text("SETUP_COMPLETE=true\nFROM_BROWSER=firefox\n")
|
|
|
|
result = setup_wizard.write_setup_config(env_path)
|
|
|
|
assert result is True
|
|
content = env_path.read_text()
|
|
# Should only appear once
|
|
assert content.count("SETUP_COMPLETE") == 1
|
|
assert content.count("FROM_BROWSER") == 1
|
|
# Original value preserved
|
|
assert "FROM_BROWSER=firefox" in content
|
|
|
|
def test_custom_from_browser_value(self):
|
|
"""Custom from_browser value is written."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
env_path = Path(tmpdir) / ".env"
|
|
|
|
result = setup_wizard.write_setup_config(env_path, from_browser="chrome")
|
|
|
|
assert result is True
|
|
content = env_path.read_text()
|
|
assert "FROM_BROWSER=chrome" in content
|
|
|
|
def test_creates_parent_directories(self):
|
|
"""Creates parent directories if they don't exist."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
env_path = Path(tmpdir) / "a" / "b" / "c" / ".env"
|
|
|
|
result = setup_wizard.write_setup_config(env_path)
|
|
|
|
assert result is True
|
|
assert env_path.exists()
|
|
|
|
def test_handles_file_without_trailing_newline(self):
|
|
"""Appends correctly when existing file has no trailing newline."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
env_path = Path(tmpdir) / ".env"
|
|
env_path.write_text("EXISTING_KEY=value") # no trailing newline
|
|
|
|
result = setup_wizard.write_setup_config(env_path, from_browser="firefox")
|
|
|
|
assert result is True
|
|
content = env_path.read_text()
|
|
# Should have newline separator
|
|
lines = content.strip().split("\n")
|
|
assert len(lines) == 3
|
|
assert lines[0] == "EXISTING_KEY=value"
|
|
assert "SETUP_COMPLETE=true" in lines[1]
|
|
|
|
|
|
class TestWriteApiKey:
|
|
"""Tests for write_api_key() — persisting the ScrapeCreators signup key."""
|
|
|
|
def test_writes_key_with_secret_permissions(self):
|
|
"""Key is written and the file is 0o600 (owner read/write only)."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
env_path = Path(tmpdir) / "subdir" / ".env"
|
|
|
|
result = setup_wizard.write_api_key(env_path, "sc_live_abcdef123456")
|
|
|
|
assert result is True
|
|
assert env_path.exists()
|
|
assert "SCRAPECREATORS_API_KEY=sc_live_abcdef123456" in env_path.read_text()
|
|
assert (env_path.stat().st_mode & 0o777) == 0o600
|
|
|
|
def test_value_round_trips_through_env_loader(self):
|
|
"""Persisted key reloads to the exact original value."""
|
|
from lib import env as env_mod
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
env_path = Path(tmpdir) / ".env"
|
|
|
|
setup_wizard.write_api_key(env_path, "sc_live_abcdef123456")
|
|
|
|
loaded = env_mod.load_env_file(env_path)
|
|
assert loaded["SCRAPECREATORS_API_KEY"] == "sc_live_abcdef123456"
|
|
|
|
def test_idempotent_when_key_already_present(self):
|
|
"""If the key already exists, do not duplicate or overwrite it."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
env_path = Path(tmpdir) / ".env"
|
|
env_path.write_text("SCRAPECREATORS_API_KEY=existing_key\n")
|
|
|
|
result = setup_wizard.write_api_key(env_path, "sc_new_value")
|
|
|
|
assert result is True
|
|
content = env_path.read_text()
|
|
assert content.count("SCRAPECREATORS_API_KEY") == 1
|
|
assert "existing_key" in content
|
|
assert "sc_new_value" not in content
|
|
|
|
def test_appends_without_clobbering_other_keys(self):
|
|
"""Existing unrelated keys are preserved."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
env_path = Path(tmpdir) / ".env"
|
|
env_path.write_text("SETUP_COMPLETE=true\nFROM_BROWSER=firefox\n")
|
|
|
|
setup_wizard.write_api_key(env_path, "sc_key_xyz")
|
|
|
|
content = env_path.read_text()
|
|
assert "SETUP_COMPLETE=true" in content
|
|
assert "FROM_BROWSER=firefox" in content
|
|
assert "SCRAPECREATORS_API_KEY=sc_key_xyz" in content
|
|
|
|
def test_value_with_whitespace_is_quoted(self):
|
|
"""A pathological value with whitespace is quoted so it round-trips."""
|
|
from lib import env as env_mod
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
env_path = Path(tmpdir) / ".env"
|
|
|
|
setup_wizard.write_api_key(env_path, "key with space")
|
|
|
|
content = env_path.read_text()
|
|
assert 'SCRAPECREATORS_API_KEY="key with space"' in content
|
|
assert env_mod.load_env_file(env_path)["SCRAPECREATORS_API_KEY"] == "key with space"
|
|
|
|
def test_empty_key_returns_false_and_writes_nothing(self):
|
|
"""An empty api_key persists nothing and reports failure."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
env_path = Path(tmpdir) / ".env"
|
|
|
|
assert setup_wizard.write_api_key(env_path, "") is False
|
|
assert not env_path.exists()
|
|
|
|
def test_unwritable_target_returns_false(self):
|
|
"""Unwritable target dir -> False, no exception escapes."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
ro_dir = Path(tmpdir) / "ro"
|
|
ro_dir.mkdir()
|
|
ro_dir.chmod(0o500) # no write
|
|
try:
|
|
result = setup_wizard.write_api_key(ro_dir / "sub" / ".env", "sc_key")
|
|
assert result is False
|
|
finally:
|
|
ro_dir.chmod(0o700) # restore so tempdir cleanup succeeds
|
|
|
|
|
|
class TestMaskApiKey:
|
|
"""Tests for mask_api_key() — non-secret display form."""
|
|
|
|
def test_masks_long_key(self):
|
|
masked = setup_wizard.mask_api_key("sc_live_abcdef123456")
|
|
assert "abcdef" not in masked
|
|
assert masked.endswith("3456")
|
|
assert masked.startswith("sc_")
|
|
|
|
def test_short_key_collapses_to_placeholder(self):
|
|
assert setup_wizard.mask_api_key("short") == "sc_…"
|
|
|
|
def test_empty_key_collapses_to_placeholder(self):
|
|
assert setup_wizard.mask_api_key("") == "sc_…"
|
|
|
|
|
|
class TestCookieExtractionBrowsers:
|
|
"""Tests for env.cookie_extraction_browsers() — the shared browser policy."""
|
|
|
|
def test_default_disables_extraction(self):
|
|
"""FROM_BROWSER unset -> no browser-cookie reads."""
|
|
from lib import env
|
|
browsers = env.cookie_extraction_browsers({})
|
|
assert browsers == []
|
|
|
|
def test_off_disables_extraction(self):
|
|
from lib import env
|
|
assert env.cookie_extraction_browsers({"FROM_BROWSER": "off"}) == []
|
|
|
|
def test_auto_opts_into_chrome(self):
|
|
from lib import env
|
|
assert "chrome" in env.cookie_extraction_browsers({"FROM_BROWSER": "auto"})
|
|
|
|
def test_specific_browser(self):
|
|
from lib import env
|
|
assert env.cookie_extraction_browsers({"FROM_BROWSER": "chrome"}) == ["chrome"]
|
|
|
|
|
|
class TestWizardDoesNotProbeChromeByDefault:
|
|
"""Regression: first-run setup must not silently read Chrome cookies."""
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
|
@patch("shutil.which", return_value=None)
|
|
def test_default_run_never_requests_chrome(self, _mock_which, mock_extract):
|
|
setup_wizard.run_auto_setup({})
|
|
requested_browsers = {call.args[0] for call in mock_extract.call_args_list}
|
|
assert requested_browsers == set()
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
|
@patch("shutil.which", return_value=None)
|
|
def test_from_browser_auto_does_request_chrome(self, _mock_which, mock_extract):
|
|
setup_wizard.run_auto_setup({"FROM_BROWSER": "auto"}, allow_browser_cookies=True)
|
|
requested_browsers = {call.args[0] for call in mock_extract.call_args_list}
|
|
assert "chrome" in requested_browsers
|
|
|
|
@patch("lib.cookie_extract.extract_cookies_with_source")
|
|
@patch("shutil.which", return_value=None)
|
|
def test_from_browser_off_skips_extraction(self, _mock_which, mock_extract):
|
|
results = setup_wizard.run_auto_setup({"FROM_BROWSER": "off"})
|
|
mock_extract.assert_not_called()
|
|
assert results["cookies_found"] == {}
|
|
|
|
|
|
class TestGetSetupStatusText:
|
|
"""Tests for get_setup_status_text()."""
|
|
|
|
def test_with_cookies_and_ytdlp(self):
|
|
"""Status text mentions found cookies and yt-dlp."""
|
|
results = {
|
|
"cookies_found": {"x": "chrome"},
|
|
"ytdlp_installed": True,
|
|
"ytdlp_action": "already_installed",
|
|
"env_written": True,
|
|
}
|
|
text = setup_wizard.get_setup_status_text(results)
|
|
assert "X cookies found in chrome" in text
|
|
assert "yt-dlp already installed" in text
|
|
assert "Configuration saved" in text
|
|
|
|
def test_with_no_cookies_no_ytdlp(self):
|
|
"""Status text shows no cookies and suggests yt-dlp install."""
|
|
results = {
|
|
"cookies_found": {},
|
|
"ytdlp_installed": False,
|
|
"ytdlp_action": "no_homebrew",
|
|
"env_written": False,
|
|
}
|
|
text = setup_wizard.get_setup_status_text(results)
|
|
assert "No browser cookies found" in text
|
|
assert "Install Homebrew first" in text
|
|
|
|
def test_status_text_installed(self):
|
|
"""Status text for freshly installed yt-dlp."""
|
|
results = {
|
|
"cookies_found": {},
|
|
"ytdlp_installed": True,
|
|
"ytdlp_action": "installed",
|
|
"env_written": False,
|
|
}
|
|
text = setup_wizard.get_setup_status_text(results)
|
|
assert "Installed yt-dlp via Homebrew" in text
|
|
|
|
def test_status_text_install_failed(self):
|
|
"""Status text for failed yt-dlp install."""
|
|
results = {
|
|
"cookies_found": {},
|
|
"ytdlp_installed": False,
|
|
"ytdlp_action": "install_failed",
|
|
"env_written": False,
|
|
}
|
|
text = setup_wizard.get_setup_status_text(results)
|
|
assert "yt-dlp install failed" in text
|
|
assert "manually" in text
|
|
|
|
def test_status_text_digg_installed(self):
|
|
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
|
"digg_action": "installed", "env_written": False}
|
|
text = setup_wizard.get_setup_status_text(results)
|
|
assert "Installed Digg CLI" in text
|
|
|
|
def test_status_text_digg_already_installed(self):
|
|
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
|
"digg_action": "already_installed", "env_written": False}
|
|
text = setup_wizard.get_setup_status_text(results)
|
|
assert "Digg CLI already installed" in text
|
|
|
|
def test_status_text_digg_install_failed(self):
|
|
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
|
"digg_action": "install_failed", "env_written": False}
|
|
text = setup_wizard.get_setup_status_text(results)
|
|
assert "Digg CLI install failed" in text
|
|
assert "printing-press-library" in text
|
|
|
|
def test_status_text_digg_installed_off_path(self):
|
|
home = Path.home()
|
|
digg_path = str(home / ".local" / "bin" / "digg-pp-cli")
|
|
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
|
"digg_action": "installed_off_path",
|
|
"digg_path": digg_path,
|
|
"env_written": False}
|
|
text = setup_wizard.get_setup_status_text(results)
|
|
assert "not on PATH" in text
|
|
assert "$HOME/.local/bin" in text
|
|
assert "now active" not in text.lower()
|
|
|
|
def test_status_text_digg_installed_off_path_legacy_go_bin(self):
|
|
"""PATH hint names the actual install dir as $HOME-relative, not ~/.local/bin."""
|
|
home = Path.home()
|
|
digg_path = str(home / "go" / "bin" / "digg-pp-cli")
|
|
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
|
"digg_action": "installed_off_path",
|
|
"digg_path": digg_path,
|
|
"env_written": False}
|
|
text = setup_wizard.get_setup_status_text(results)
|
|
assert "$HOME/go/bin" in text
|
|
assert ".local/bin" not in text
|
|
|
|
def test_status_text_digg_installed_off_path_missing_path(self):
|
|
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
|
"digg_action": "installed_off_path",
|
|
"env_written": False}
|
|
text = setup_wizard.get_setup_status_text(results)
|
|
assert "not on PATH" in text
|
|
assert "add its install directory to PATH" in text
|
|
|
|
def test_status_text_digg_installed_off_path_empty_path(self):
|
|
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
|
"digg_action": "installed_off_path",
|
|
"digg_path": "",
|
|
"env_written": False}
|
|
text = setup_wizard.get_setup_status_text(results)
|
|
assert "add its install directory to PATH" in text
|
|
|
|
def test_digg_bin_dir_hint_windows_returns_absolute_parent(self):
|
|
home = Path.home()
|
|
digg_path = str(home / ".local" / "bin" / "digg-pp-cli")
|
|
expected = str(home / ".local" / "bin")
|
|
with patch.object(setup_wizard.os, "name", "nt"):
|
|
assert setup_wizard._digg_bin_dir_hint(digg_path) == expected
|
|
|
|
def test_status_text_digg_no_npx(self):
|
|
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
|
"digg_action": "no_npx", "env_written": False}
|
|
text = setup_wizard.get_setup_status_text(results)
|
|
assert "Digg CLI not installed" in text
|
|
|
|
def test_status_text_digg_absent_key_renders(self):
|
|
"""No digg_action key (defensive) -> no Digg line, no error."""
|
|
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
|
"env_written": False}
|
|
text = setup_wizard.get_setup_status_text(results)
|
|
assert "Digg" not in text
|
|
|
|
|
|
class TestSetupSubcommand:
|
|
"""Tests for setup subcommand detection in argument parsing."""
|
|
|
|
def test_setup_detected_as_topic(self):
|
|
"""The word 'setup' is treated as the setup subcommand."""
|
|
# Simulate what argparse produces
|
|
import argparse
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("topic", nargs="*")
|
|
args = parser.parse_args(["setup"])
|
|
topic = " ".join(args.topic) if args.topic else None
|
|
assert topic is not None
|
|
assert topic.strip().lower() == "setup"
|
|
|
|
def test_normal_topic_not_setup(self):
|
|
"""A normal topic is not confused with setup."""
|
|
import argparse
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("topic", nargs="*")
|
|
args = parser.parse_args(["AI", "video", "tools"])
|
|
topic = " ".join(args.topic) if args.topic else None
|
|
assert topic.strip().lower() != "setup"
|