455 lines
16 KiB
Python
455 lines
16 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_load_from_cookie_file_parses_netscape_cookie_dump(tmp_path) -> None:
|
|
cookie_file = tmp_path / "cookies.txt"
|
|
cookie_file.write_text(
|
|
"# Netscape HTTP Cookie File\n"
|
|
".x.com\tTRUE\t/\tTRUE\t0\tguest_id\tv1%3A123\n"
|
|
".x.com\tTRUE\t/\tTRUE\t0\tct0\tcsrf-token\n"
|
|
".x.com\tTRUE\t/\tTRUE\t0\tauth_token\tauth-token\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
cookies = auth.load_from_cookie_file(str(cookie_file))
|
|
|
|
assert cookies is not None
|
|
assert cookies["auth_token"] == "auth-token"
|
|
assert cookies["ct0"] == "csrf-token"
|
|
assert "guest_id=v1%3A123" in cookies["cookie_string"]
|
|
|
|
|
|
def test_get_cookies_uses_cookie_file_before_browser(monkeypatch) -> None:
|
|
monkeypatch.setattr(auth, "load_from_env", lambda: None)
|
|
monkeypatch.setenv("TWITTER_COOKIE_FILE", "/tmp/cookies.txt")
|
|
monkeypatch.setattr(
|
|
auth,
|
|
"load_from_cookie_file",
|
|
lambda path: {"auth_token": "file-token", "ct0": "file-csrf", "cookie_string": "a=1"},
|
|
)
|
|
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"] == "file-token"
|
|
assert seen == [("file-token", "file-csrf", "a=1")]
|
|
|
|
|
|
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_windows_hint(monkeypatch) -> None:
|
|
"""On Windows, hint should mention DPAPI and environment variable workaround."""
|
|
monkeypatch.setattr("sys.platform", "win32")
|
|
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
|
monkeypatch.delenv("SSH_TTY", raising=False)
|
|
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
|
|
|
diagnostics = ["chrome: Unable to get key for cookie decryption"]
|
|
hint = auth._diagnose_keychain_issues(diagnostics)
|
|
|
|
assert hint is not None
|
|
assert "DPAPI" in hint
|
|
assert "TWITTER_AUTH_TOKEN" in hint
|
|
assert "shadowcopy" 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)
|