74a387b093
Adds the macOS Keychain as the lowest-priority credential source on Darwin.
Items stored as generic passwords with service name "last30days-<KEY>" for
the current user are picked up automatically by get_config() — file env
and process env still win on collision.
No new config knob: behavior is strictly additive. On non-Darwin (or when
the `security` binary is missing) the loader is a no-op, so Linux/Windows
behavior is unchanged.
Priority (highest wins):
1. Environment variables
2. .claude/last30days.env (per-project)
3. ~/.config/last30days/.env (global)
4. macOS Keychain items prefixed last30days- (new)
Includes:
- lib/env.py: KEYCHAIN_SERVICE_PREFIX constant, _load_keychain helper
(platform-gated, shutil.which-gated, subprocess-error tolerant),
wiring into get_config before get_openai_auth so OPENAI_API_KEY can
come from Keychain too, _CONFIG_SOURCE reports "keychain" when no
file source is present.
- scripts/setup-keychain.sh: bash helper with interactive set,
--list, --delete, --replace modes. Uses `security add-generic-password`.
- tests/test_env_keychain.py: 12 tests covering platform gate,
missing-binary gate, success path, whitespace stripping, subprocess
errors swallowed, get_config precedence, and an OPENAI_AUTH wiring
regression test.
- tests/test_env_cookies.py: existing integration test mocks the new
_load_keychain hook so it stays hermetic on Darwin developer
machines that have real keychain entries.
- README.md: new "macOS Keychain (optional)" subsection under
"Bring your own keys" documenting setup-keychain.sh and the manual
`security add-generic-password` invocation.
Tested on macOS with a populated keychain and against the existing pytest
suite — CI-tracked tests (test_plugin_contract.py, test_version_consistency.py)
plus all env-touching tests pass. Pre-existing unrelated failures in
test_store.py / test_watchlist_commands.py / test_setup_openclaw.py /
test_footer_nudge_suppression.py are untouched.
137 lines
5.3 KiB
Python
137 lines
5.3 KiB
Python
"""Tests for browser cookie extraction integration in env.py."""
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "skills" / "last30days" / "scripts"))
|
|
|
|
from lib.env import extract_browser_credentials, COOKIE_DOMAINS
|
|
|
|
|
|
def _base_config(**overrides):
|
|
"""Return a minimal config dict with common defaults."""
|
|
cfg = {
|
|
"AUTH_TOKEN": None,
|
|
"CT0": None,
|
|
"TRUTHSOCIAL_TOKEN": None,
|
|
"FROM_BROWSER": None,
|
|
"SETUP_COMPLETE": None,
|
|
}
|
|
cfg.update(overrides)
|
|
return cfg
|
|
|
|
|
|
class TestExtractBrowserCredentials:
|
|
"""Unit tests for extract_browser_credentials()."""
|
|
|
|
@patch("lib.cookie_extract.extract_cookies")
|
|
def test_auto_populates_credentials(self, mock_extract):
|
|
mock_extract.return_value = {"auth_token": "tok123", "ct0": "ct0val"}
|
|
config = _base_config(FROM_BROWSER="auto")
|
|
result = extract_browser_credentials(config)
|
|
assert result["AUTH_TOKEN"] == "tok123"
|
|
assert result["CT0"] == "ct0val"
|
|
# auto mode tries firefox first, then safari, then chrome
|
|
mock_extract.assert_any_call("firefox", ".x.com", ["auth_token", "ct0"])
|
|
|
|
@patch("lib.cookie_extract.extract_cookies")
|
|
def test_explicit_auth_token_skips_x_extraction(self, mock_extract):
|
|
mock_extract.return_value = None
|
|
config = _base_config(
|
|
AUTH_TOKEN="explicit_token", CT0="explicit_ct0",
|
|
FROM_BROWSER="auto",
|
|
)
|
|
result = extract_browser_credentials(config)
|
|
assert "AUTH_TOKEN" not in result
|
|
assert "CT0" not in result
|
|
for call in mock_extract.call_args_list:
|
|
assert call[0][1] != ".x.com"
|
|
|
|
@patch("lib.cookie_extract.extract_cookies")
|
|
def test_from_browser_off_skips_all(self, mock_extract):
|
|
config = _base_config(FROM_BROWSER="off")
|
|
result = extract_browser_credentials(config)
|
|
assert result == {}
|
|
mock_extract.assert_not_called()
|
|
|
|
@patch("lib.cookie_extract.extract_cookies")
|
|
def test_no_from_browser_defaults_to_silent(self, mock_extract):
|
|
"""Default (no FROM_BROWSER): tries Firefox and Safari only, skips Chrome."""
|
|
mock_extract.return_value = None
|
|
config = _base_config()
|
|
result = extract_browser_credentials(config)
|
|
assert result == {}
|
|
# Should try firefox and safari but NOT chrome
|
|
browser_args = [call[0][0] for call in mock_extract.call_args_list]
|
|
assert "firefox" in browser_args
|
|
assert "safari" in browser_args
|
|
assert "chrome" not in browser_args
|
|
|
|
@patch("lib.cookie_extract.extract_cookies")
|
|
def test_from_browser_firefox_only(self, mock_extract):
|
|
mock_extract.return_value = {"auth_token": "ff_tok", "ct0": "ff_ct0"}
|
|
config = _base_config(FROM_BROWSER="firefox")
|
|
result = extract_browser_credentials(config)
|
|
assert result["AUTH_TOKEN"] == "ff_tok"
|
|
for call in mock_extract.call_args_list:
|
|
assert call[0][0] == "firefox"
|
|
|
|
@patch("lib.cookie_extract.extract_cookies")
|
|
def test_extraction_returns_none_config_unchanged(self, mock_extract):
|
|
mock_extract.return_value = None
|
|
config = _base_config(FROM_BROWSER="auto")
|
|
result = extract_browser_credentials(config)
|
|
assert "AUTH_TOKEN" not in result
|
|
assert "CT0" not in result
|
|
|
|
@patch("lib.cookie_extract.extract_cookies")
|
|
def test_extraction_raises_exception_caught(self, mock_extract):
|
|
mock_extract.side_effect = RuntimeError("database locked")
|
|
config = _base_config(FROM_BROWSER="auto")
|
|
result = extract_browser_credentials(config)
|
|
assert "AUTH_TOKEN" not in result
|
|
assert "CT0" not in result
|
|
|
|
@patch("lib.cookie_extract.extract_cookies")
|
|
def test_partial_credentials_only_fills_missing(self, mock_extract):
|
|
mock_extract.return_value = {"auth_token": "cookie_tok", "ct0": "cookie_ct0"}
|
|
config = _base_config(
|
|
AUTH_TOKEN="explicit", CT0=None,
|
|
FROM_BROWSER="auto",
|
|
)
|
|
result = extract_browser_credentials(config)
|
|
assert "AUTH_TOKEN" not in result
|
|
assert result["CT0"] == "cookie_ct0"
|
|
|
|
|
|
class TestGetConfigCookieIntegration:
|
|
"""Integration test: get_config() calls extract_browser_credentials."""
|
|
|
|
@patch("lib.cookie_extract.extract_cookies")
|
|
@patch("lib.env._find_project_env", return_value=None)
|
|
@patch("lib.env.load_env_file", return_value={})
|
|
@patch("lib.env._load_keychain", return_value={})
|
|
@patch("lib.env.get_openai_auth")
|
|
def test_get_config_injects_cookies(
|
|
self, mock_openai, mock_keychain, mock_load, mock_proj, mock_extract
|
|
):
|
|
from lib.env import get_config, OpenAIAuth
|
|
mock_openai.return_value = OpenAIAuth(
|
|
token=None, source="none", status="missing",
|
|
account_id=None, codex_auth_file="/fake",
|
|
)
|
|
mock_extract.return_value = {"auth_token": "browser_tok", "ct0": "browser_ct0"}
|
|
env_patch = {
|
|
"SETUP_COMPLETE": "true",
|
|
"FROM_BROWSER": "auto",
|
|
"LAST30DAYS_CONFIG_DIR": "",
|
|
}
|
|
with patch.dict(os.environ, env_patch, clear=False):
|
|
config = get_config()
|
|
assert config["AUTH_TOKEN"] == "browser_tok"
|
|
assert config["CT0"] == "browser_ct0"
|