From 82f97be58f74976b482f259b543e150514fdb39b Mon Sep 17 00:00:00 2001 From: chongheado <285811102+chongheado@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:53:35 -0700 Subject: [PATCH] feat: environment-variable auth; stop reading browser unless needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reddit (2026) now blocks anonymous .json access behind a browser JS anti-bot challenge, and self-service OAuth app registration is closed, so a browser-established cookie is required. This lets users supply that cookie themselves — WITHOUT the tool reading their browser profile files — via environment variables, and demotes browser extraction to a last resort. Credential chain is now: env → saved file → browser (browser kept, last). Environment variables (read fresh each run, never persisted to disk): - RDT_COOKIE : full raw Cookie header (most robust; whole session set) - RDT_SESSION : reddit_session value (convenience) - RDT_MODHASH : optional modhash (enables write actions) Public/read-only commands also no longer silently extract browser cookies (optional_auth passes allow_browser=False; anonymous runs don't browser-refresh on session expiry), so setting RDT_COOKIE — or nothing — never touches the browser for public browsing. - auth.py: parse_cookie_header(), extract_env_credential(), get_credential(allow_browser=...) chain (env-first, browser-last) - session.py: anonymous token_v2 cookie counts as read-capable - commands: optional_auth() never extracts browser; status/login/ require_auth messages point users to RDT_COOKIE - README: env-var auth section + step-by-step "get your cookie" guide - tests: test_env_credential.py, test_public_no_browser.py, conftest env isolation --- README.md | 56 ++++++++++- rdt_cli/auth.py | 81 +++++++++++++++- rdt_cli/commands/_common.py | 17 +++- rdt_cli/commands/auth.py | 5 +- rdt_cli/constants.py | 5 + rdt_cli/session.py | 3 +- tests/conftest.py | 16 ++++ tests/test_env_credential.py | 142 ++++++++++++++++++++++++++++ tests/test_public_no_browser.py | 163 ++++++++++++++++++++++++++++++++ 9 files changed, 473 insertions(+), 15 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_env_credential.py create mode 100644 tests/test_public_no_browser.py diff --git a/README.md b/README.md index 4373ace..979a674 100644 --- a/README.md +++ b/README.md @@ -124,12 +124,55 @@ rdt comment 3 "Great post!" # Comment on result #3 ## Authentication -rdt-cli supports browser cookie extraction to authenticate with Reddit: +rdt-cli resolves credentials in priority order: -1. **Saved cookies** — loads from `~/.config/rdt-cli/credential.json` -2. **Browser cookies** — auto-detects installed browsers and extracts cookies (supports Chrome, Firefox, Edge, Brave) +1. **Environment variables** — `RDT_COOKIE` / `RDT_SESSION` (see below); never touches your browser +2. **Saved cookies** — loads from `~/.config/rdt-cli/credential.json` +3. **Browser cookies** — *last resort*: auto-detects installed browsers and extracts cookies (Chrome, Firefox, Edge, Brave) -`rdt login` automatically tries all installed browsers and uses the first one with valid cookies. +`rdt login` tries the saved credential, then falls back to browser extraction. + +> **Public commands run anonymously.** Read-only commands (`popular`, `all`, `sub`, `sub-info`, `user`, `user-posts`, `user-comments`, `search`, `export`, `read`, `show`) never read your browser's cookie database. They use a saved credential if one already exists, otherwise they run unauthenticated. Run `rdt login` once to browse these authenticated (e.g., for NSFW visibility or personalized ranking). Only commands that require an account (`feed`, `saved`, `upvoted`, `upvote`, `save`, `subscribe`, `comment`) fall back to browser cookie extraction. + +### Cookies via environment variables (no browser access) + +If you'd rather rdt-cli **not read your browser's cookie database**, supply cookies yourself. These take priority over the saved file and browser extraction, and are read fresh each run (never written to disk): + +| Variable | Description | +|---|---| +| `RDT_COOKIE` | A full raw Cookie header (`name=value; name2=value2; …`) — most robust, carries the whole session set | +| `RDT_SESSION` | Just the `reddit_session` cookie value (convenience) | +| `RDT_MODHASH` | Optional `modhash`; enables write actions (vote/save/comment) | + +**Getting your cookie manually** — Reddit now requires a browser-established session even for "public" reads (a plain HTTP client is blocked by Reddit's anti-bot), so copy the cookie your browser already has: + +1. Open **reddit.com** in your browser — **logged in** (recommended: `reddit_session` lasts weeks and enables write actions) or logged out (anonymous, read-only; the anonymous token expires in ~a day). +2. Open **DevTools** — `F12` or `⌥⌘I` (macOS) — and select the **Network** tab. +3. Load `https://www.reddit.com/r/popular.json` (or reload any reddit page). +4. Click the top request → **Headers** → **Request Headers** → select the whole **`cookie:`** value and copy it. + - *Alternative:* **Application/Storage → Cookies → `https://www.reddit.com`** lists cookies individually; the ones that matter are `reddit_session` (logged in) or `token_v2` / `loid` / `edgebucket` (anonymous). + +**Setting the env var** — use **single quotes** (the value contains `;`, `=`, `%`): + +```bash +# Recommended: the whole cookie header (zsh — macOS default) +export RDT_COOKIE='loid=...; edgebucket=...; token_v2=...; reddit_session=...' +echo "export RDT_COOKIE='...'" >> ~/.zshrc && source ~/.zshrc # persist +# bash: append to ~/.bashrc instead of ~/.zshrc + +# Or, if you only have the session cookie: +export RDT_SESSION='' +export RDT_MODHASH='' # optional — enables voting/commenting +``` + +Verify: + +```bash +rdt status # → ✅ Authenticated (source: env) +rdt popular -n 5 # → posts +``` + +> The logged-in cookie is an account credential — treat it like a password; don't commit or share it (`~/.zshrc` stores it in plaintext). ### Cookie TTL @@ -149,6 +192,9 @@ After any listing command such as `feed`, `popular`, `all`, `sub`, or `search`, | Variable | Default | Description | |----------|---------|-------------| | `OUTPUT` | `auto` | Output format: `json`, `yaml`, `rich`, or `auto` (→ YAML when non-TTY) | +| `RDT_COOKIE` | — | Raw Cookie header for auth (highest priority, never persisted) — see [Authentication](#authentication) | +| `RDT_SESSION` | — | `reddit_session` cookie value (convenience) | +| `RDT_MODHASH` | — | `modhash` value; enables write actions | ## Rate Limiting & Anti-Detection @@ -368,6 +414,8 @@ rdt-cli 支持浏览器 Cookie 提取来认证 Reddit: Cookie 保存后有效期 **7 天**,超时后自动尝试从浏览器刷新。 +> **公开命令匿名运行。** 只读命令(`popular`、`all`、`sub`、`sub-info`、`user`、`user-posts`、`user-comments`、`search`、`export`、`read`、`show`)不会读取浏览器 Cookie 数据库:若已保存凭据则使用之,否则以匿名方式运行。如需登录态浏览(例如查看 NSFW 或个性化排序),先执行一次 `rdt login`。只有需要账号的命令(`feed`、`saved`、`upvoted`、`upvote`、`save`、`subscribe`、`comment`)才会回退到浏览器 Cookie 提取。 + ## 常见问题 - `No Reddit cookies found` — 请先在任意浏览器打开 https://www.reddit.com/ 并登录,然后执行 `rdt login` diff --git a/rdt_cli/auth.py b/rdt_cli/auth.py index 9573e95..7568c84 100644 --- a/rdt_cli/auth.py +++ b/rdt_cli/auth.py @@ -10,12 +10,20 @@ import json import logging +import os import shutil import subprocess import time from typing import Any -from .constants import CONFIG_DIR, CREDENTIAL_FILE, REQUIRED_COOKIES +from .constants import ( + CONFIG_DIR, + CREDENTIAL_FILE, + ENV_COOKIE, + ENV_MODHASH, + ENV_SESSION, + REQUIRED_COOKIES, +) logger = logging.getLogger(__name__) @@ -120,6 +128,56 @@ def clear_credential() -> None: CREDENTIAL_FILE.unlink() +# ── Environment variable credential ───────────────────────────────── + + +def parse_cookie_header(header: str) -> dict[str, str]: + """Parse a raw Cookie header ("a=1; b=2") into a name→value dict. + + Splits each pair on the first '=' only (cookie values such as JWTs may + contain '='). Blank segments and nameless pairs are skipped. + """ + cookies: dict[str, str] = {} + for part in header.split(";"): + name, sep, value = part.strip().partition("=") + name = name.strip() + if not sep or not name: + continue + cookies[name] = value.strip() + return cookies + + +def extract_env_credential() -> Credential | None: + """Build a credential from environment variables (no browser access). + + - RDT_COOKIE : full raw Cookie header (most robust — carries the whole set) + - RDT_SESSION : reddit_session cookie value (convenience; overlays RDT_COOKIE) + - RDT_MODHASH : modhash (enables write actions) + + Returned in-memory only; env credentials are never written to disk. + """ + raw = os.getenv(ENV_COOKIE) + session = os.getenv(ENV_SESSION) + modhash = os.getenv(ENV_MODHASH) + + cookies: dict[str, str] = {} + if raw: + cookies.update(parse_cookie_header(raw)) + if session: + cookies["reddit_session"] = session.strip() + if modhash: + cookies["modhash"] = modhash.strip() + + if not cookies: + return None + + return Credential( + cookies=cookies, + source="env", + modhash=modhash.strip() if modhash else None, + ) + + # ── Browser cookie extraction ─────────────────────────────────────── @@ -194,12 +252,25 @@ def _extract_direct() -> Credential | None: # ── Credential chain ──────────────────────────────────────────────── -def get_credential() -> Credential | None: - """Try saved → browser → return None.""" - cred = load_credential() +def get_credential(*, allow_browser: bool = True) -> Credential | None: + """Resolve a credential: env → saved → browser → None. + + Priority order: + 1. Environment variables (RDT_COOKIE / RDT_SESSION) — non-invasive, highest + priority, read fresh each run. + 2. Saved credential file. + 3. Browser cookie extraction — invasive (reads the browser's cookie database) + and slow, so it is the last resort and is gated behind ``allow_browser``. + Public/optional endpoints pass ``allow_browser=False`` to avoid the browser. + """ + cred = extract_env_credential() if cred: return cred - cred = extract_browser_credential() + cred = load_credential() if cred: return cred + if allow_browser: + cred = extract_browser_credential() + if cred: + return cred return None diff --git a/rdt_cli/commands/_common.py b/rdt_cli/commands/_common.py index bab4f33..13be520 100644 --- a/rdt_cli/commands/_common.py +++ b/rdt_cli/commands/_common.py @@ -183,14 +183,22 @@ def require_auth() -> Credential: """Get credential or exit with error.""" cred = get_credential() if not cred: - console.print("[yellow]⚠️ Not logged in[/yellow]. Use [bold]rdt login[/bold] to authenticate") + console.print( + "[yellow]⚠️ Not authenticated[/yellow]. Set [bold]RDT_COOKIE[/bold] " + "(see README) or run [bold]rdt login[/bold]" + ) sys.exit(1) return cred def optional_auth() -> Credential | None: - """Get credential if available, or None (for public endpoints).""" - return get_credential() + """Get a credential if one is already available, else None (for public endpoints). + + Never triggers browser cookie extraction — public commands should not read + the browser's cookie database as a side effect. Run `rdt login` once to save + a credential if you want authenticated public browsing. + """ + return get_credential(allow_browser=False) def get_client(credential: Credential | None = None) -> RedditClient: @@ -204,6 +212,9 @@ def run_client_action(credential: Credential | None, action: Callable[[RedditCli with get_client(credential) as client: return action(client) except SessionExpiredError: + # An anonymous run should never fall back to invasive browser extraction. + if credential is None: + raise from ..auth import extract_browser_credential fresh = extract_browser_credential() diff --git a/rdt_cli/commands/auth.py b/rdt_cli/commands/auth.py index 9ef4a07..ad30419 100644 --- a/rdt_cli/commands/auth.py +++ b/rdt_cli/commands/auth.py @@ -30,7 +30,8 @@ def login() -> None: console.print(f"[green]✅ Login successful![/green] ({len(cred.cookies)} cookies extracted)") else: console.print("[red]❌ No Reddit cookies found.[/red]") - console.print(" [dim]Please login to reddit.com in your browser first, then retry.[/dim]") + console.print(" [dim]Set [bold]RDT_COOKIE[/bold] from your browser's cookie header (see README),[/dim]") + console.print(" [dim]or log into reddit.com in your browser, then retry.[/dim]") @click.command() @@ -92,7 +93,7 @@ def status(as_json: bool, as_yaml: bool) -> None: console.print("[yellow]⚠️ Not authenticated[/yellow]") if info["error"]: console.print(f" [dim]{info['error']}[/dim]") - console.print(" [dim]Use 'rdt login' to extract cookies from your browser[/dim]") + console.print(" [dim]Set RDT_COOKIE (see README) or run 'rdt login' to use browser cookies[/dim]") @click.command() diff --git a/rdt_cli/constants.py b/rdt_cli/constants.py index 055064b..462ecd1 100644 --- a/rdt_cli/constants.py +++ b/rdt_cli/constants.py @@ -6,6 +6,11 @@ CONFIG_DIR = Path.home() / ".config" / "rdt-cli" CREDENTIAL_FILE = CONFIG_DIR / "credential.json" +# ── Environment variables (non-browser credential source) ─────────── +ENV_COOKIE = "RDT_COOKIE" # raw Cookie header: "a=1; b=2; ..." +ENV_SESSION = "RDT_SESSION" # convenience: reddit_session cookie value +ENV_MODHASH = "RDT_MODHASH" # optional: modhash (enables write actions) + # ── Base URL ──────────────────────────────────────────────────────── BASE_URL = "https://www.reddit.com" OAUTH_URL = "https://oauth.reddit.com" diff --git a/rdt_cli/session.py b/rdt_cli/session.py index 8cda2c9..40ef560 100644 --- a/rdt_cli/session.py +++ b/rdt_cli/session.py @@ -53,7 +53,8 @@ def can_write(self) -> bool: def refresh_capabilities(self) -> None: capabilities: set[str] = set() - if self.cookies.get("reddit_session"): + # reddit_session → logged-in read; token_v2 → anonymous session read. + if self.cookies.get("reddit_session") or self.cookies.get("token_v2"): capabilities.add("read") inferred_modhash = self.modhash or _cookie_value(self.cookies, "modhash", "csrf_token") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c4c0030 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +"""Shared test fixtures.""" + +from __future__ import annotations + +import pytest + +# Environment variables that influence credential resolution. Cleared for every +# test so a developer's own shell (e.g. an exported RDT_COOKIE) can't leak in and +# make credential tests flaky. +_RDT_ENV_VARS = ("RDT_COOKIE", "RDT_SESSION", "RDT_MODHASH") + + +@pytest.fixture(autouse=True) +def _clear_rdt_env(monkeypatch): + for var in _RDT_ENV_VARS: + monkeypatch.delenv(var, raising=False) diff --git a/tests/test_env_credential.py b/tests/test_env_credential.py new file mode 100644 index 0000000..9c3a278 --- /dev/null +++ b/tests/test_env_credential.py @@ -0,0 +1,142 @@ +"""Environment-variable credential source. + +Users who don't want the tool to read their browser's cookie database can supply +cookies directly via env vars: + + RDT_COOKIE — a full raw Cookie header ("a=1; b=2; ...") [most robust] + RDT_SESSION — just the reddit_session cookie value [convenience] + RDT_MODHASH — optional modhash (enables write actions) + +Env credentials take priority over the saved file and over browser extraction, +and are used in-memory only (never written to disk). +""" + +from __future__ import annotations + +from rdt_cli import auth +from rdt_cli.auth import Credential + +# ── parse_cookie_header ───────────────────────────────────────────── + + +class TestParseCookieHeader: + def test_basic_pairs(self): + assert auth.parse_cookie_header("a=1; b=2") == {"a": "1", "b": "2"} + + def test_strips_whitespace_and_skips_blanks(self): + assert auth.parse_cookie_header(" a = 1 ;; b=2 ; ") == {"a": "1", "b": "2"} + + def test_value_may_contain_equals(self): + # JWT/base64 values can contain '=' — split on the first '=' only. + assert auth.parse_cookie_header("token_v2=ab.cd==; x=1") == {"token_v2": "ab.cd==", "x": "1"} + + def test_skips_pairs_without_name(self): + assert auth.parse_cookie_header("=novalue; good=1") == {"good": "1"} + + def test_empty_returns_empty_dict(self): + assert auth.parse_cookie_header("") == {} + + +# ── extract_env_credential ────────────────────────────────────────── + + +class TestExtractEnvCredential: + def test_none_when_unset(self): + assert auth.extract_env_credential() is None + + def test_rdt_cookie_raw_header(self, monkeypatch): + monkeypatch.setenv("RDT_COOKIE", "reddit_session=abc; token_v2=xyz") + cred = auth.extract_env_credential() + assert cred is not None + assert cred.cookies == {"reddit_session": "abc", "token_v2": "xyz"} + assert cred.source == "env" + + def test_rdt_session_convenience(self, monkeypatch): + monkeypatch.setenv("RDT_SESSION", "sessval") + cred = auth.extract_env_credential() + assert cred is not None + assert cred.cookies["reddit_session"] == "sessval" + + def test_rdt_session_with_modhash_enables_write(self, monkeypatch): + monkeypatch.setenv("RDT_SESSION", "sessval") + monkeypatch.setenv("RDT_MODHASH", "mh123") + cred = auth.extract_env_credential() + assert cred.cookies["reddit_session"] == "sessval" + assert cred.cookies["modhash"] == "mh123" + + def test_rdt_cookie_takes_precedence_and_session_overlays(self, monkeypatch): + # RDT_COOKIE supplies the full set; RDT_SESSION overrides that one key. + monkeypatch.setenv("RDT_COOKIE", "reddit_session=old; token_v2=xyz") + monkeypatch.setenv("RDT_SESSION", "new") + cred = auth.extract_env_credential() + assert cred.cookies["reddit_session"] == "new" + assert cred.cookies["token_v2"] == "xyz" + + def test_env_credential_is_not_persisted(self, tmp_path, monkeypatch): + monkeypatch.setattr(auth, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(auth, "CREDENTIAL_FILE", tmp_path / "credential.json") + monkeypatch.setenv("RDT_SESSION", "sessval") + + auth.extract_env_credential() + + assert not (tmp_path / "credential.json").exists() + + +# ── get_credential chain: env → saved → browser ───────────────────── + + +class TestCredentialChainPriority: + def test_env_beats_saved_file(self, monkeypatch): + monkeypatch.setenv("RDT_SESSION", "from_env") + monkeypatch.setattr(auth, "load_credential", lambda: Credential(cookies={"reddit_session": "from_file"})) + + cred = auth.get_credential() + assert cred is not None + assert cred.cookies["reddit_session"] == "from_env" + assert cred.source == "env" + + def test_env_used_even_when_browser_disabled(self, monkeypatch): + # Public commands pass allow_browser=False but should still honor env. + monkeypatch.setenv("RDT_SESSION", "from_env") + monkeypatch.setattr(auth, "load_credential", lambda: None) + + cred = auth.get_credential(allow_browser=False) + assert cred is not None and cred.cookies["reddit_session"] == "from_env" + + def test_browser_is_last_resort(self, monkeypatch): + # No env, no saved → browser extraction runs (kept, but last). + monkeypatch.setattr(auth, "load_credential", lambda: None) + called = {"n": 0} + + def spy(): + called["n"] += 1 + return Credential(cookies={"reddit_session": "from_browser"}, source="browser:chrome") + + monkeypatch.setattr(auth, "extract_browser_credential", spy) + + cred = auth.get_credential() + assert cred.source == "browser:chrome" + assert called["n"] == 1 + + def test_env_short_circuits_before_browser(self, monkeypatch): + monkeypatch.setenv("RDT_SESSION", "from_env") + monkeypatch.setattr(auth, "load_credential", lambda: None) + + def boom(): + raise AssertionError("browser extraction must not run when env is set") + + monkeypatch.setattr(auth, "extract_browser_credential", boom) + + assert auth.get_credential().source == "env" + + +# ── capability: anonymous (token_v2) cookies count as read ────────── + + +class TestAnonymousCapability: + def test_token_v2_only_is_read_capable(self): + from rdt_cli.session import SessionState + + state = SessionState.from_credential(Credential(cookies={"token_v2": "anon"}, source="env")) + assert state.is_authenticated is True # "read" capability + assert state.can_write is False diff --git a/tests/test_public_no_browser.py b/tests/test_public_no_browser.py new file mode 100644 index 0000000..136c52a --- /dev/null +++ b/tests/test_public_no_browser.py @@ -0,0 +1,163 @@ +"""Public commands must not trigger invasive browser cookie extraction. + +Reading the browser's cookie database (via browser-cookie3 in a subprocess) is +slow and invasive. It should only happen for commands that explicitly require +authentication (``require_auth``) or the explicit ``rdt login`` flow — never as +a side effect of a public read command such as popular/all/sub/search/read. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from rdt_cli import auth +from rdt_cli.auth import Credential +from rdt_cli.cli import cli +from rdt_cli.commands import _common +from rdt_cli.exceptions import SessionExpiredError + +runner = CliRunner() + + +# ── optional_auth() never touches the browser ─────────────────────── + + +class TestOptionalAuthNeverTouchesBrowser: + def test_no_browser_extraction_when_no_saved_credential(self, monkeypatch): + monkeypatch.setattr(auth, "load_credential", lambda: None) + calls = {"n": 0} + + def spy(): + calls["n"] += 1 + return None + + monkeypatch.setattr(auth, "extract_browser_credential", spy) + + result = _common.optional_auth() + + assert result is None + assert calls["n"] == 0, "optional_auth must not extract browser cookies" + + def test_returns_saved_credential_without_browser(self, monkeypatch): + cred = Credential(cookies={"reddit_session": "abc"}) + monkeypatch.setattr(auth, "load_credential", lambda: cred) + + def boom(): + raise AssertionError("browser extraction must not run for optional auth") + + monkeypatch.setattr(auth, "extract_browser_credential", boom) + + assert _common.optional_auth() is cred + + +# ── get_credential(allow_browser=...) gate ────────────────────────── + + +class TestGetCredentialAllowBrowser: + def test_allow_browser_false_skips_browser(self, monkeypatch): + monkeypatch.setattr(auth, "load_credential", lambda: None) + calls = {"n": 0} + + def spy(): + calls["n"] += 1 + return None + + monkeypatch.setattr(auth, "extract_browser_credential", spy) + + assert auth.get_credential(allow_browser=False) is None + assert calls["n"] == 0 + + def test_default_still_allows_browser(self, monkeypatch): + # The require_auth path must be unchanged: browser extraction still runs. + cred = Credential(cookies={"reddit_session": "abc"}, source="browser:chrome") + monkeypatch.setattr(auth, "load_credential", lambda: None) + calls = {"n": 0} + + def spy(): + calls["n"] += 1 + return cred + + monkeypatch.setattr(auth, "extract_browser_credential", spy) + + assert auth.get_credential() is cred + assert calls["n"] == 1 + + +# ── run_client_action retry must not browser-extract when anonymous ─ + + +class TestRunClientActionAnonymous: + def test_anonymous_session_expiry_does_not_extract_browser(self, monkeypatch): + calls = {"n": 0} + + def spy(): + calls["n"] += 1 + return None + + monkeypatch.setattr(auth, "extract_browser_credential", spy) + + def action(_client): + raise SessionExpiredError() + + with pytest.raises(SessionExpiredError): + _common.run_client_action(None, action) + + assert calls["n"] == 0, "anonymous run must not fall back to browser extraction" + + def test_authenticated_session_expiry_still_refreshes_via_browser(self, monkeypatch): + fresh = Credential(cookies={"reddit_session": "new"}) + calls = {"n": 0} + + def spy(): + calls["n"] += 1 + return fresh + + monkeypatch.setattr(auth, "extract_browser_credential", spy) + + attempts = {"n": 0} + + def action(_client): + attempts["n"] += 1 + if attempts["n"] == 1: + raise SessionExpiredError() + return "ok" + + result = _common.run_client_action(Credential(cookies={"reddit_session": "old"}), action) + + assert result == "ok" + assert calls["n"] == 1 + + +# ── end-to-end: a public command does not extract browser cookies ─── + + +class TestPublicCommandNoBrowser: + def _mock_listing(self): + return { + "data": { + "children": [ + {"data": {"id": "abc", "title": "T", "subreddit": "s", "author": "a", + "score": 1, "num_comments": 0, "created_utc": 1700000000}} + ], + "after": None, + } + } + + def test_popular_does_not_extract_browser(self, monkeypatch): + monkeypatch.setattr(auth, "load_credential", lambda: None) + calls = {"n": 0} + + def spy(): + calls["n"] += 1 + return None + + monkeypatch.setattr(auth, "extract_browser_credential", spy) + + with patch("rdt_cli.client.RedditClient.get_popular", return_value=self._mock_listing()): + result = runner.invoke(cli, ["popular", "-n", "1", "--json"]) + + assert result.exit_code == 0, result.output + assert calls["n"] == 0, "`rdt popular` must not read browser cookies"