Files
last30days-skill/tests/test_env_cookies.py
Damien Stevens 74a387b093 feat(env): macOS Keychain credential source
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.
2026-05-16 09:01:28 -04:00

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"