feat: consent-driven first-run onboarding (cookies + ScrapeCreators signup) (#660)
* feat(setup): persist ScrapeCreators API key on signup success The GitHub device-auth signup (setup --github / --device-auth) returned the ScrapeCreators API key as JSON to stdout but nothing persisted it, so a successful signup never actually configured the paid sources. - Add setup_wizard.write_api_key(): secret-safe (0o600), idempotent, reuses _open_secret_append + _format_env_value (same path as write_setup_config), and never clobbers an existing key. - Add setup_wizard.mask_api_key(): prefix + last-4 display form. - Wire both into the CLI --github/--device-auth branch: on status==success, persist the key, set results['persisted'], and mask api_key in stdout so the secret never lands in the host model's captured Bash output. Covers plan U2. * feat(skill): consent-driven first-run onboarding in Step 0 The wizard fired but ran silently: the model invoked bare `setup`, which extracts cookies + installs tools + writes SETUP_COMPLETE with zero interaction. No consent before reading browser cookies, no macOS Full Disk Access remediation, and the ScrapeCreators GitHub signup was never offered. Rewrite Step 0 as an ordered, consent-first sequence the model drives in chat (the Python subprocess can't prompt): 1. Welcome 2. Ask cookie consent BEFORE reading; on decline run with FROM_BROWSER=off (skip reads, still install yt-dlp + Digg) 3. macOS Full Disk Access remediation on permission-denied + one retry 4. Offer the ScrapeCreators GitHub signup every first run, consent before launching the browser (setup --github) 5. Confirm active sources and proceed Remove the misleading 'follow the wizard's prompts end-to-end' line and add a named onboarding contract documenting why consent is conversational. Copy avoids a hard credit count (grant is server-side). Adds tests/test_onboarding_contract.py (7 contract assertions). Covers plan U1. * docs: document consent-driven first-run onboarding - CONFIGURATION.md: new 'First-run onboarding' section covering the three consent points (cookies, Full Disk Access, ScrapeCreators GitHub signup) and automatic key persistence. - AGENTS.md: extend the optional-sources rule to note onboarding is consent-driven and model-led, and that setup --github persists the key. - CHANGELOG.md: Unreleased entry (Added + Fixed) following #659. Covers plan U3. --------- Co-authored-by: Fredy Montero <fredymontero@Fredys-MacBook-Pro-2.local>
This commit is contained in:
@@ -42,6 +42,7 @@ Python 3.12+ required. Use `uv` for the env; the venv lives at `.venv/`.
|
||||
- Git remote: origin = public (`mvanhorn/last30days-skill`)
|
||||
- Every `lib/*.py` call to `log.source_log(...)` must pass `tty_only=False`. The default is `True`, which silently drops every line when stderr isn't a TTY (Claude Code, Codex, CI, captured output) — turning source observability into invisible failure. Enforced by `tests/test_source_log_visibility.py`.
|
||||
- **CLI-gated optional sources** (Digg via `digg-pp-cli`, YouTube via `yt-dlp`) activate only when `shutil.which` resolves the binary on the **agent subprocess PATH** — not merely when the file exists on disk. First-run setup installs Digg through `@mvanhorn/printing-press-library` (default `$HOME/.local/bin`); Hermes/OpenClaw gateways often need that directory on PATH. Setup must distinguish PATH-visible installs from off-PATH binaries and must not claim "now active" unless the engine gate would pass. See `docs/solutions/integration-issues/digg-cli-agent-path-setup-wizard.md`.
|
||||
- **First-run onboarding is consent-driven and model-led.** The setup subprocess does only mechanical work (cookie reads, tool installs, GitHub device-auth) — it cannot prompt, so consent lives in `SKILL.md` Step 0: the model asks before reading cookies, surfaces the macOS Full Disk Access fix on permission-denied, and offers the ScrapeCreators GitHub signup on every first run. A successful `setup --github` persists `SCRAPECREATORS_API_KEY` automatically (via `setup_wizard.write_api_key`, 0o600) and masks the key in stdout. Do not collapse Step 0 back into a bare silent `setup` call — the consent prompts are the feature.
|
||||
|
||||
## Security hygiene
|
||||
- Never commit real API keys, browser cookies, auth tokens, app passwords, access tokens, or `.env` contents.
|
||||
|
||||
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Consent-driven first-run onboarding.** Step 0 now drives an in-chat consent flow instead of a silent `setup` run: the model asks before reading browser cookies (decline runs with `FROM_BROWSER=off` — still installs yt-dlp + Digg), surfaces the macOS Full Disk Access fix when a cookie read is permission-denied, and offers the ScrapeCreators GitHub signup on every first run. A successful `setup --github` now **persists `SCRAPECREATORS_API_KEY` automatically** (`setup_wizard.write_api_key`, 0o600) and masks the key in stdout so the secret never lands in the host model's captured output. Follows the first-run gate fix (#659).
|
||||
|
||||
### Fixed
|
||||
- **First-run setup no longer runs silently.** The prior Step 0 told the model to run `setup` and "follow the wizard's prompts end-to-end", but the wizard has no prompts — so onboarding extracted cookies, installed tools, and wrote `SETUP_COMPLETE` with zero interaction and never offered the ScrapeCreators signup. Reproduced 2026-06-22 (Fredy Montero, fresh macOS).
|
||||
|
||||
## [3.8.0] - 2026-06-21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -49,6 +49,18 @@ The footer line `📎 Raw results saved to ${LAST30DAYS_MEMORY_DIR:-$HOME/Docume
|
||||
|
||||
---
|
||||
|
||||
## First-run onboarding
|
||||
|
||||
On the very first `/last30days` run (no `~/.config/last30days/.env`, or `SETUP_COMPLETE` not set), the skill runs a consent-driven onboarding the model drives in chat. It has three consent points:
|
||||
|
||||
1. **Browser cookies** - the model asks before reading anything. On yes it extracts Firefox/Safari cookies (never Chrome, to avoid a macOS Keychain prompt) to unlock X/Twitter and other logged-in sources, and installs yt-dlp + the keyless Digg CLI. On no it runs setup with `FROM_BROWSER=off` (skips all cookie reads, still installs the tools).
|
||||
2. **Full Disk Access (macOS)** - if a cookie read is permission-denied, the model surfaces the System Settings > Privacy & Security > Full Disk Access fix and offers one retry.
|
||||
3. **ScrapeCreators GitHub signup** - offered on every first run. On consent it runs `setup --github`, which opens a browser for GitHub device-auth and, on success, **persists `SCRAPECREATORS_API_KEY` automatically** (0o600, masked in output) so TikTok, Instagram, Threads, Pinterest, X, and YouTube comments/transcripts activate on the next run. Decline anytime; you can run it later by asking to set up ScrapeCreators.
|
||||
|
||||
Re-run onboarding by deleting `~/.config/last30days/.env`. The mechanical work lives in `scripts/lib/setup_wizard.py`; the consent conversation is specified in `skills/last30days/SKILL.md` Step 0.
|
||||
|
||||
---
|
||||
|
||||
## API keys (`.env`)
|
||||
|
||||
The skill reads keys from a `.env` file. Two locations are supported, in priority order:
|
||||
|
||||
@@ -363,18 +363,32 @@ The engine reads `LAST30DAYS_MEMORY_DIR` from either the process env or `~/.conf
|
||||
|
||||
## Step 0: First-Run Setup Wizard
|
||||
|
||||
Before proceeding to Step 1, handle first-run setup.
|
||||
Before proceeding to Step 1, handle first-run setup. **You are the conversational driver.** The Python setup script does only mechanical work (cookie reads, tool installs, the GitHub device-auth flow) - it CANNOT prompt the user, because it runs as a non-interactive subprocess. So consent happens HERE, in chat: you ask, the user answers, and you gate each subprocess call on the answer. Do NOT just run `setup` and report the result - that is the silent-onboarding regression this section exists to prevent.
|
||||
|
||||
**First-run detection (silent, no commands, no output to user):**
|
||||
- If `~/.config/last30days/.env` does NOT exist, this is a first run.
|
||||
- If the file exists and contains `SETUP_COMPLETE=true`, skip Step 0 entirely and go to Step 1 (CRITICAL: Parse User Intent below). Do NOT announce that setup is complete. The user does not need a status message on every run.
|
||||
|
||||
**If this IS a first run:**
|
||||
- Run `python3 skills/last30days/scripts/last30days.py setup` (relative to the skill root) to launch the setup wizard.
|
||||
- Follow the wizard's prompts end-to-end. The wizard handles platform detection (OpenClaw vs Claude Code), auto vs manual setup, browser cookie extraction, ScrapeCreators opt-in, a best-effort auto-install of the free, keyless Digg CLI (`digg-pp-cli` via `@mvanhorn/printing-press-library install digg --cli-only` — Digg activates only when the binary is on the **agent subprocess PATH**, typically `$HOME/.local/bin`; setup reports honestly if the CLI is installed off-PATH; recommend-only if `npx` is unavailable), and the initial topic picker.
|
||||
- After the wizard writes `SETUP_COMPLETE=true` to `~/.config/last30days/.env`, proceed to research.
|
||||
**Named onboarding contract (2026-06-22, silent-wizard regression - Fredy Montero run):** the prior version of this step said "Run `setup` ... follow the wizard's prompts end-to-end." But `run_auto_setup()` has NO prompts - it extracts cookies, installs yt-dlp + Digg, and writes `SETUP_COMPLETE` with zero interaction. So the model ran the silent path, never asked consent before reading browser cookies, never surfaced the macOS Full Disk Access fix, and never offered the ScrapeCreators GitHub signup that unlocks TikTok/Instagram/X/Threads. The fix is the ordered, consent-first sequence below. Do not "simplify" it back to a bare `setup` call - the consent prompts are the feature.
|
||||
|
||||
The setup wizard lives as a Python module so it works across all hosts (Claude Code, Codex, Cursor, etc.) and the common-case (already set up) path through this file stays short.
|
||||
**If this IS a first run, run this onboarding sequence in order. Each numbered step is a turn: present it, then wait for the user where it says to wait.**
|
||||
|
||||
**1. Welcome.** One short branded line, e.g.: `Welcome to /last30days - let me get you set up (about 30 seconds).`
|
||||
|
||||
**2. Cookie consent (ask BEFORE reading anything).** Tell the user you'd like to read their browser cookies and what it unlocks, then ask. Example: `I can read your browser cookies (Firefox/Safari) to unlock X/Twitter and other logged-in sources. Want me to? (yes / no)` **Wait for the answer.**
|
||||
- On **yes** → run `python3 skills/last30days/scripts/last30days.py setup` (relative to the skill root). This extracts cookies (Firefox/Safari by default - never Chrome, to avoid a Keychain prompt) and best-effort installs yt-dlp (YouTube) and the free, keyless Digg CLI (`digg-pp-cli` via `@mvanhorn/printing-press-library install digg --cli-only`; Digg activates only when the binary is on the **agent subprocess PATH**, typically `$HOME/.local/bin`; setup reports honestly if installed off-PATH; recommend-only if `npx` is unavailable).
|
||||
- On **no** → run the same command with cookie reads disabled for that invocation: `FROM_BROWSER=off python3 skills/last30days/scripts/last30days.py setup`. This skips all cookie extraction but STILL installs yt-dlp and Digg, and still writes `SETUP_COMPLETE`. Do not attempt any cookie read after a no.
|
||||
|
||||
**3. Full Disk Access remediation (macOS only).** After the `setup` run, inspect its stderr. If it contains `Permission denied reading Cookies.binarycookies` and the platform is macOS, the OS blocked the read - surface the fix instead of swallowing it: `macOS blocked the cookie read. To enable X/Twitter: System Settings > Privacy & Security > Full Disk Access > enable your terminal (or the Claude app), then I can retry.` Offer ONE retry of step 2's `setup` command. If the user skips, continue.
|
||||
|
||||
**4. ScrapeCreators signup offer (every first run, consent BEFORE launching the browser).** Always offer this. Explain it grants free credits that unlock TikTok, Instagram, Threads, Pinterest, X, and YouTube comments/transcripts, and that it opens a GitHub authorization page in the browser. Do NOT hard-code a specific credit count - say "free credits" (the exact grant is set server-side). Ask, e.g.: `Want to unlock TikTok, Instagram, X and more? I can sign you up for ScrapeCreators with GitHub (free credits) - it opens a browser to authorize. (yes / no)` **Wait for the answer.**
|
||||
- On **yes** → run `python3 skills/last30days/scripts/last30days.py setup --github`. Tell the user a browser window will open and to authorize with the code shown. On success the engine persists the key automatically and returns JSON with `"persisted": true` and a MASKED `api_key` (the raw key never appears - do not ask for or echo it). Confirm the paid sources are now active.
|
||||
- On **timeout / denied** → tell the user it didn't complete and offer to retry or skip.
|
||||
- On **no** → note they can run it anytime later by asking to set up ScrapeCreators, then continue.
|
||||
|
||||
**5. Complete.** Once `SETUP_COMPLETE=true` is written, briefly confirm which sources are now active (read the `setup --github` JSON `persisted` field, or re-run `--diagnose`) and proceed to research.
|
||||
|
||||
The setup wizard lives as a Python module so its mechanical work runs across all hosts (Claude Code, Codex, Cursor, etc.) while you drive the consent conversation above. The common-case (already set up) path through this file stays short.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -673,12 +673,20 @@ def main() -> int:
|
||||
results = setup_wizard.run_openclaw_setup(config)
|
||||
print(json.dumps(results))
|
||||
return 0
|
||||
if "--github" in extra_argv:
|
||||
results = setup_wizard.run_github_auth()
|
||||
print(json.dumps(results))
|
||||
return 0
|
||||
if "--device-auth" in extra_argv:
|
||||
results = setup_wizard.run_full_device_auth()
|
||||
if "--github" in extra_argv or "--device-auth" in extra_argv:
|
||||
if "--github" in extra_argv:
|
||||
results = setup_wizard.run_github_auth()
|
||||
else:
|
||||
results = setup_wizard.run_full_device_auth()
|
||||
# Persist the returned key so the paid sources activate on the next
|
||||
# run, and mask it in stdout so the secret never lands in the host
|
||||
# model's captured Bash output.
|
||||
api_key = results.get("api_key")
|
||||
if results.get("status") == "success" and api_key:
|
||||
results["persisted"] = setup_wizard.write_api_key(env.CONFIG_FILE, api_key)
|
||||
results["api_key"] = setup_wizard.mask_api_key(api_key)
|
||||
else:
|
||||
results["persisted"] = False
|
||||
print(json.dumps(results))
|
||||
return 0
|
||||
sys.stderr.write("Running auto-setup...\n")
|
||||
|
||||
@@ -330,6 +330,63 @@ def write_setup_config(env_path: Path, from_browser: str | None = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def write_api_key(env_path: Path, api_key: str, key_name: str = "SCRAPECREATORS_API_KEY") -> bool:
|
||||
"""Append an API key to the .env file as a 0o600 secret.
|
||||
|
||||
Reuses the same secret-safe write path as ``write_setup_config`` so the
|
||||
value lands with restrictive permissions and round-trips through
|
||||
``env.load_env_file``. Idempotent: if ``key_name`` is already present in
|
||||
the file, nothing is written and the existing value is preserved (we never
|
||||
clobber a key the user may have set by hand).
|
||||
|
||||
Args:
|
||||
env_path: Path to the .env file (e.g. ~/.config/last30days/.env).
|
||||
api_key: The raw key value to persist.
|
||||
key_name: The env var name to write (default SCRAPECREATORS_API_KEY).
|
||||
|
||||
Returns:
|
||||
True if the key was written or already present, False on error or when
|
||||
``api_key`` is empty.
|
||||
"""
|
||||
if not api_key:
|
||||
return False
|
||||
try:
|
||||
env_path = Path(env_path)
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
existing_content = ""
|
||||
if env_path.exists():
|
||||
existing_content = env_path.read_text(encoding="utf-8")
|
||||
for line in existing_content.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("#") and "=" in stripped:
|
||||
if stripped.split("=", 1)[0].strip() == key_name:
|
||||
return True # Already configured; do not duplicate
|
||||
|
||||
line = f"{key_name}={_format_env_value(api_key)}\n"
|
||||
with _open_secret_append(env_path) as f:
|
||||
if existing_content and not existing_content.endswith("\n"):
|
||||
f.write("\n")
|
||||
f.write(line)
|
||||
|
||||
return True
|
||||
|
||||
except OSError as exc:
|
||||
logger.error("Failed to write API key to %s: %s", env_path, exc)
|
||||
return False
|
||||
|
||||
|
||||
def mask_api_key(api_key: str) -> str:
|
||||
"""Return a non-secret display form of an API key (prefix + last 4).
|
||||
|
||||
Used so the key never appears verbatim in stdout the host model captures.
|
||||
Short or empty keys collapse to a fixed placeholder.
|
||||
"""
|
||||
if not api_key or len(api_key) <= 8:
|
||||
return "sc_…"
|
||||
return f"{api_key[:3]}…{api_key[-4:]}"
|
||||
|
||||
|
||||
def get_setup_status_text(results: Dict[str, Any]) -> str:
|
||||
"""Return a human-readable summary of auto-setup results.
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Contract tests for the consent-driven first-run onboarding in SKILL.md.
|
||||
|
||||
These assert the structural guarantees of Step 0: consent is requested before
|
||||
any cookie read, the decline and Full Disk Access branches are documented, the
|
||||
ScrapeCreators signup is gated on a consent question, and the old silent-wizard
|
||||
instruction is gone. They read SKILL.md as text (the model's runtime contract),
|
||||
matching tests/test_runtime_preflight_contract.py.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SKILL_MD = ROOT / "skills" / "last30days" / "SKILL.md"
|
||||
|
||||
|
||||
class TestOnboardingContract(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.text = SKILL_MD.read_text(encoding="utf-8")
|
||||
# Scope assertions to the Step 0 section so generic substrings (e.g.
|
||||
# "setup") elsewhere in the file do not satisfy ordering checks.
|
||||
start = self.text.index("## Step 0: First-Run Setup Wizard")
|
||||
end = self.text.index("## CRITICAL: Parse User Intent", start)
|
||||
self.step0 = self.text[start:end]
|
||||
|
||||
def test_cookie_consent_requested_before_setup_invocation(self):
|
||||
"""The cookie-consent question must appear before the first `setup` run."""
|
||||
consent_idx = self.step0.find("Cookie consent")
|
||||
setup_idx = self.step0.find("last30days.py setup")
|
||||
self.assertGreater(consent_idx, -1, "no Cookie consent step found")
|
||||
self.assertGreater(setup_idx, -1, "no setup invocation found")
|
||||
self.assertLess(
|
||||
consent_idx, setup_idx,
|
||||
"cookie consent must be requested before the setup command",
|
||||
)
|
||||
|
||||
def test_decline_branch_uses_from_browser_off(self):
|
||||
"""Declining cookies must route to FROM_BROWSER=off (skip reads, keep installs)."""
|
||||
self.assertIn("FROM_BROWSER=off", self.step0)
|
||||
|
||||
def test_full_disk_access_remediation_present(self):
|
||||
"""The macOS permission-denied remediation must be documented."""
|
||||
self.assertIn("Permission denied reading Cookies.binarycookies", self.step0)
|
||||
self.assertIn("Full Disk Access", self.step0)
|
||||
|
||||
def test_scrapecreators_signup_gated_on_consent(self):
|
||||
"""The signup runs `setup --github` and is offered after a consent question."""
|
||||
self.assertIn("setup --github", self.step0)
|
||||
offer_idx = self.step0.find("ScrapeCreators signup offer")
|
||||
github_idx = self.step0.find("setup --github")
|
||||
self.assertGreater(offer_idx, -1, "no ScrapeCreators signup offer step")
|
||||
self.assertLess(
|
||||
offer_idx, github_idx,
|
||||
"the signup offer/consent must precede the --github invocation",
|
||||
)
|
||||
|
||||
def test_signup_does_not_hardcode_credit_count(self):
|
||||
"""Onboarding copy must not assert an unverified credit number."""
|
||||
self.assertNotIn("1000 free credit", self.step0)
|
||||
self.assertNotIn("1000 credits", self.step0)
|
||||
|
||||
def test_old_silent_wizard_instruction_removed(self):
|
||||
"""The misleading 'follow the wizard's prompts' line must be gone."""
|
||||
self.assertNotIn("Follow the wizard's prompts end-to-end", self.text)
|
||||
|
||||
def test_consent_is_conversational_contract_documented(self):
|
||||
"""The named onboarding contract explains why consent is in-chat."""
|
||||
self.assertIn("Named onboarding contract", self.step0)
|
||||
self.assertIn("non-interactive subprocess", self.step0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,12 +1,16 @@
|
||||
"""Tests for OpenClaw setup and device auth functions."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
|
||||
import pytest
|
||||
|
||||
import last30days as cli
|
||||
from lib import setup_wizard
|
||||
|
||||
|
||||
@@ -504,3 +508,49 @@ class TestRunGithubAuth:
|
||||
result = setup_wizard.run_github_auth(timeout=1)
|
||||
assert result["status"] == "timeout"
|
||||
mock_subproc.assert_not_called()
|
||||
|
||||
|
||||
class TestSetupGithubCliWiring:
|
||||
"""Tests for the `setup --github` CLI branch: persist + mask the key."""
|
||||
|
||||
def _run_setup_github(self, tmp_path, monkeypatch):
|
||||
"""Invoke `setup --github` in-process, return (parsed_json, env_path)."""
|
||||
env_path = tmp_path / ".env"
|
||||
monkeypatch.setattr(cli.env, "CONFIG_FILE", env_path)
|
||||
monkeypatch.setattr(sys, "argv", ["last30days", "setup", "--github"])
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
rc = cli.main()
|
||||
assert rc == 0
|
||||
return json.loads(buf.getvalue()), env_path
|
||||
|
||||
@patch("lib.setup_wizard.run_github_auth")
|
||||
def test_success_persists_and_masks(self, mock_auth, tmp_path, monkeypatch):
|
||||
"""Success -> key written to .env, stdout JSON masked, persisted true."""
|
||||
mock_auth.return_value = {
|
||||
"status": "success", "method": "device",
|
||||
"api_key": "sc_live_supersecret9999", "user_code": "ABCD-1234",
|
||||
}
|
||||
|
||||
payload, env_path = self._run_setup_github(tmp_path, monkeypatch)
|
||||
|
||||
# Key persisted to disk with the real value
|
||||
assert "SCRAPECREATORS_API_KEY=sc_live_supersecret9999" in env_path.read_text()
|
||||
# JSON reports persistence and the raw secret never appears in stdout
|
||||
assert payload["persisted"] is True
|
||||
assert payload["status"] == "success"
|
||||
assert payload["api_key"] != "sc_live_supersecret9999"
|
||||
assert "supersecret9999" not in json.dumps(payload)
|
||||
# Useful non-secret fields survive
|
||||
assert payload["user_code"] == "ABCD-1234"
|
||||
|
||||
@patch("lib.setup_wizard.run_github_auth")
|
||||
def test_timeout_persists_nothing(self, mock_auth, tmp_path, monkeypatch):
|
||||
"""Timeout -> no key on disk, persisted false."""
|
||||
mock_auth.return_value = {"status": "timeout", "user_code": "WXYZ-5678"}
|
||||
|
||||
payload, env_path = self._run_setup_github(tmp_path, monkeypatch)
|
||||
|
||||
assert payload["persisted"] is False
|
||||
assert not env_path.exists()
|
||||
assert payload["status"] == "timeout"
|
||||
|
||||
@@ -417,6 +417,108 @@ class TestWriteSetupConfig:
|
||||
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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user