Files
twitter-cli-cookiefile/tests/test_auth.py
jackwener d612066bef feat(auth): add TWITTER_BROWSER env var to specify browser preference
Add TWITTER_BROWSER environment variable to allow users to control
which browser's cookies are prioritized during extraction.

Example: TWITTER_BROWSER=chrome twitter feed

Supported values: arc, chrome, edge, firefox, brave.
The specified browser is moved to the front of the extraction order.

Also adds AGENTS.md developer guide for AI coding assistants.

Co-authored-by: Agassi <413855+agassiyzh@users.noreply.github.com>
2026-03-15 19:09:35 +08:00

399 lines
14 KiB
Python

from __future__ import annotations
import json
import os
import sys
from types import SimpleNamespace
import pytest
from twitter_cli import auth
def test_get_cookies_prefers_env(monkeypatch) -> None:
monkeypatch.setattr(auth, "load_from_env", lambda: {"auth_token": "env-token", "ct0": "env-csrf"})
monkeypatch.setattr(auth, "extract_from_browser", lambda: pytest.fail("should not extract from browser"))
seen = []
monkeypatch.setattr(
auth,
"verify_cookies",
lambda auth_token, ct0, cookie_string=None: seen.append((auth_token, ct0, cookie_string)) or {},
)
cookies = auth.get_cookies()
assert cookies == {"auth_token": "env-token", "ct0": "env-csrf"}
assert seen == [("env-token", "env-csrf", None)]
def test_get_cookies_reextracts_after_verify_failure(monkeypatch) -> None:
monkeypatch.setattr(auth, "load_from_env", lambda: None)
extracted = iter(
[
({"auth_token": "stale-token", "ct0": "stale-csrf", "cookie_string": "stale=1"}, []),
({"auth_token": "fresh-token", "ct0": "fresh-csrf", "cookie_string": "fresh=1"}, []),
]
)
monkeypatch.setattr(auth, "extract_from_browser", lambda: next(extracted))
calls = []
def _verify(auth_token, ct0, cookie_string=None):
calls.append((auth_token, ct0, cookie_string))
if auth_token == "stale-token":
raise RuntimeError("expired")
return {}
monkeypatch.setattr(auth, "verify_cookies", _verify)
cookies = auth.get_cookies()
assert cookies["auth_token"] == "fresh-token"
assert calls == [
("stale-token", "stale-csrf", "stale=1"),
("fresh-token", "fresh-csrf", "fresh=1"),
]
def test_load_from_env_logs_incomplete_env(monkeypatch, caplog) -> None:
monkeypatch.setenv("TWITTER_AUTH_TOKEN", "token")
monkeypatch.delenv("TWITTER_CT0", raising=False)
with caplog.at_level("DEBUG"):
cookies = auth.load_from_env()
assert cookies is None
assert "Environment cookies incomplete" in caplog.text
def test_extract_cookies_from_jar_logs_missing_required_cookies(caplog) -> None:
class Cookie:
def __init__(self, domain: str, name: str, value: str) -> None:
self.domain = domain
self.name = name
self.value = value
jar = [Cookie(".x.com", "auth_token", "token")]
with caplog.at_level("DEBUG"):
cookies = auth._extract_cookies_from_jar(jar, source="test-jar")
assert cookies is None
assert "test-jar" in caplog.text
assert "ct0=False" in caplog.text
def test_extract_from_browser_logs_warning_when_all_methods_fail(monkeypatch, caplog) -> None:
monkeypatch.setattr(auth, "_extract_in_process", lambda: (None, []))
monkeypatch.setattr(auth, "_extract_via_subprocess", lambda: (None, []))
with caplog.at_level("WARNING"):
cookies, diagnostics = auth.extract_from_browser()
assert cookies is None
assert "Twitter cookie extraction failed in both in-process and subprocess modes" in caplog.text
def test_extract_in_process_supports_arc(monkeypatch) -> None:
class Cookie:
def __init__(self, domain: str, name: str, value: str) -> None:
self.domain = domain
self.name = name
self.value = value
fake_module = SimpleNamespace(
arc=lambda: [Cookie(".x.com", "auth_token", "token"), Cookie(".x.com", "ct0", "csrf")],
chrome=lambda: pytest.fail("chrome should not be used when arc succeeds"),
edge=lambda: pytest.fail("edge should not be used when arc succeeds"),
firefox=lambda: pytest.fail("firefox should not be used when arc succeeds"),
brave=lambda: pytest.fail("brave should not be used when arc succeeds"),
)
monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module)
cookies, diagnostics = auth._extract_in_process()
assert cookies is not None
assert cookies["auth_token"] == "token"
assert cookies["ct0"] == "csrf"
def test_extract_via_subprocess_script_includes_arc(monkeypatch) -> None:
class Completed:
def __init__(self, stdout: str, stderr: str = "") -> None:
self.stdout = stdout
self.stderr = stderr
seen = {}
def _run(cmd, capture_output=True, text=True, timeout=15):
script = cmd[-1]
seen["script"] = script
return Completed(json.dumps({"error": "No Twitter cookies found", "attempts": []}))
monkeypatch.setattr(auth.subprocess, "run", _run)
cookies, diagnostics = auth._extract_via_subprocess()
assert cookies is None
assert '"arc": browser_cookie3.arc' in seen["script"]
def test_extract_via_subprocess_retries_uv_when_current_env_has_no_output(monkeypatch) -> None:
class Completed:
def __init__(self, stdout: str, stderr: str = "") -> None:
self.stdout = stdout
self.stderr = stderr
calls = []
def _run(cmd, capture_output=True, text=True, timeout=15):
calls.append(cmd)
if cmd[0] == sys.executable:
return Completed("", "")
return Completed(json.dumps({"auth_token": "token", "ct0": "csrf", "browser": "arc"}))
monkeypatch.setattr(auth.subprocess, "run", _run)
cookies, diagnostics = auth._extract_via_subprocess()
assert cookies == {"auth_token": "token", "ct0": "csrf"}
assert len(calls) == 2
assert calls[1][:5] == ["uv", "run", "--with", "browser-cookie3", "python"]
def test_verify_cookies_logs_attempt_summary_on_non_auth_failures(monkeypatch, caplog) -> None:
class Response:
def __init__(self, status_code: int, payload=None) -> None:
self.status_code = status_code
self._payload = payload or {}
def json(self):
return self._payload
class Session:
def __init__(self) -> None:
self.calls = 0
def get(self, url, headers=None, timeout=5):
self.calls += 1
if self.calls == 1:
return Response(404)
raise Exception("network")
monkeypatch.setattr("twitter_cli.client._get_cffi_session", lambda: Session())
with caplog.at_level("INFO"):
result = auth.verify_cookies("token", "csrf")
assert result == {}
assert "verify_credentials.json=404" in caplog.text
assert "settings.json=Exception" in caplog.text
def test_iter_chrome_cookie_files_default_first(monkeypatch, tmp_path) -> None:
"""Default profile should be yielded first, then Profile N sorted."""
# Create the correct platform-specific directory structure
if sys.platform == "darwin":
chrome_dir = tmp_path / "Library" / "Application Support" / "Google" / "Chrome"
elif sys.platform == "win32":
chrome_dir = tmp_path / "Google" / "Chrome" / "User Data"
else:
chrome_dir = tmp_path / ".config" / "Google" / "Chrome"
(chrome_dir / "Default").mkdir(parents=True)
(chrome_dir / "Default" / "Cookies").touch()
(chrome_dir / "Profile 2").mkdir()
(chrome_dir / "Profile 2" / "Cookies").touch()
(chrome_dir / "Profile 1").mkdir()
(chrome_dir / "Profile 1" / "Cookies").touch()
monkeypatch.delenv("TWITTER_CHROME_PROFILE", raising=False)
monkeypatch.setenv("HOME", str(tmp_path))
if sys.platform == "win32":
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
paths = auth._iter_chrome_cookie_files("chrome")
basenames = [os.path.basename(os.path.dirname(p)) for p in paths]
assert basenames[0] == "Default"
assert "Profile 1" in basenames
assert "Profile 2" in basenames
# Profile 1 should come before Profile 2
assert basenames.index("Profile 1") < basenames.index("Profile 2")
def test_iter_chrome_cookie_files_env_override(monkeypatch, tmp_path) -> None:
"""TWITTER_CHROME_PROFILE should restrict to that single profile."""
if sys.platform == "darwin":
chrome_dir = tmp_path / "Library" / "Application Support" / "Google" / "Chrome"
else:
chrome_dir = tmp_path / ".config" / "Google" / "Chrome"
(chrome_dir / "Default").mkdir(parents=True)
(chrome_dir / "Default" / "Cookies").touch()
(chrome_dir / "Profile 5").mkdir()
(chrome_dir / "Profile 5" / "Cookies").touch()
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("TWITTER_CHROME_PROFILE", "Profile 5")
paths = auth._iter_chrome_cookie_files("chrome")
assert len(paths) == 1
assert "Profile 5" in paths[0]
def test_iter_chrome_cookie_files_edge_linux_uses_microsoft_edge_path(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(auth.sys, "platform", "linux")
edge_dir = tmp_path / ".config" / "microsoft-edge"
(edge_dir / "Default").mkdir(parents=True)
(edge_dir / "Default" / "Cookies").touch()
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.delenv("TWITTER_CHROME_PROFILE", raising=False)
paths = auth._iter_chrome_cookie_files("edge")
assert len(paths) == 1
assert paths[0].endswith(".config/microsoft-edge/Default/Cookies")
def test_iter_chrome_cookie_files_edge_windows_uses_user_data(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(auth.sys, "platform", "win32")
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
monkeypatch.delenv("TWITTER_CHROME_PROFILE", raising=False)
edge_dir = tmp_path / "Microsoft" / "Edge" / "User Data" / "Default"
edge_dir.mkdir(parents=True)
(edge_dir / "Cookies").touch()
paths = auth._iter_chrome_cookie_files("edge")
assert len(paths) == 1
assert "Microsoft/Edge/User Data/Default/Cookies".replace("/", os.sep) in paths[0]
def test_extract_in_process_tries_multiple_profiles(monkeypatch, tmp_path) -> None:
"""When Default has no Twitter cookies but Profile 1 does, it should find them."""
class Cookie:
def __init__(self, domain: str, name: str, value: str) -> None:
self.domain = domain
self.name = name
self.value = value
default_cookies_path = str(tmp_path / "Default" / "Cookies")
profile1_cookies_path = str(tmp_path / "Profile 1" / "Cookies")
os.makedirs(os.path.dirname(default_cookies_path), exist_ok=True)
os.makedirs(os.path.dirname(profile1_cookies_path), exist_ok=True)
open(default_cookies_path, "w").close()
open(profile1_cookies_path, "w").close()
# Mock _iter_chrome_cookie_files to return our tmp paths
def mock_iter(browser_name):
if browser_name == "arc":
return [default_cookies_path, profile1_cookies_path]
return []
monkeypatch.setattr(auth, "_iter_chrome_cookie_files", mock_iter)
# Arc: Default returns empty jar, Profile 1 returns valid cookies
def mock_arc(cookie_file=None):
if cookie_file == profile1_cookies_path:
return [
Cookie(".x.com", "auth_token", "tok123"),
Cookie(".x.com", "ct0", "csrf456"),
]
return [] # Default — no cookies
fake_module = SimpleNamespace(
arc=mock_arc,
chrome=lambda cookie_file=None: [],
edge=lambda cookie_file=None: [],
firefox=lambda: [],
brave=lambda cookie_file=None: [],
)
monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module)
cookies, diagnostics = auth._extract_in_process()
assert cookies is not None
assert cookies["auth_token"] == "tok123"
assert cookies["ct0"] == "csrf456"
def test_diagnose_keychain_issues_detects_decryption_error(monkeypatch) -> None:
"""_diagnose_keychain_issues should detect Keychain-related error strings."""
monkeypatch.setattr("sys.platform", "darwin")
monkeypatch.delenv("SSH_CLIENT", raising=False)
monkeypatch.delenv("SSH_TTY", raising=False)
monkeypatch.delenv("SSH_CONNECTION", raising=False)
diagnostics = ["arc[Default]: Unable to get key for cookie decryption"]
hint = auth._diagnose_keychain_issues(diagnostics)
assert hint is not None
assert "Keychain" in hint
def test_diagnose_keychain_issues_ssh_hint(monkeypatch) -> None:
"""When SSH env vars are set, hint should suggest unlock-keychain."""
monkeypatch.setattr("sys.platform", "darwin")
monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 54321 22")
diagnostics = ["arc: Unable to get key for cookie decryption"]
hint = auth._diagnose_keychain_issues(diagnostics)
assert hint is not None
assert "SSH session detected" in hint
assert "security unlock-keychain" in hint
def test_diagnose_keychain_issues_returns_none_for_unrelated_errors() -> None:
"""Should return None when diagnostics don't mention Keychain."""
diagnostics = ["chrome[Default]=no-cookies", "firefox: profile not found"]
hint = auth._diagnose_keychain_issues(diagnostics)
assert hint is None
def test_get_cookies_includes_keychain_hint_in_error(monkeypatch) -> None:
"""When extraction fails with Keychain errors, error msg should contain the hint."""
monkeypatch.setattr("sys.platform", "darwin")
monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 54321 22")
monkeypatch.setattr(auth, "load_from_env", lambda: None)
monkeypatch.setattr(
auth,
"extract_from_browser",
lambda: (None, ["arc: Unable to get key for cookie decryption"]),
)
with pytest.raises(RuntimeError) as exc_info:
auth.get_cookies()
msg = str(exc_info.value)
assert "security unlock-keychain" in msg
assert "twitter -v" in msg
def test_extract_in_process_returns_diagnostics_on_failure(monkeypatch) -> None:
"""_extract_in_process should return diagnostics containing error strings."""
from types import SimpleNamespace
class BrowserError(Exception):
pass
fake_module = SimpleNamespace(
arc=lambda: (_ for _ in ()).throw(BrowserError("Unable to get key for cookie decryption")),
chrome=lambda: [],
edge=lambda: (_ for _ in ()).throw(BrowserError("Edge not found")),
firefox=lambda: (_ for _ in ()).throw(BrowserError("Firefox not found")),
brave=lambda: (_ for _ in ()).throw(BrowserError("Brave not found")),
)
monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module)
cookies, diagnostics = auth._extract_in_process()
assert cookies is None
assert any("cookie decryption" in d for d in diagnostics)