返回 last30days-skill
test_env_keychain.py
根目录 / tests / test_env_keychain.py
1 """Tests for macOS Keychain credential source in lib/env.py.
2
3 Covers:
4 - non-Darwin returns {}
5 - missing `security` binary returns {}
6 - successful lookups return parsed key/value pairs
7 - subprocess timeout / OSError are swallowed
8 - get_config merges keychain at lowest priority and labels _CONFIG_SOURCE
9 """
10
11 from __future__ import annotations
12
13 import re
14 import subprocess
15 from pathlib import Path
16 from unittest import mock
17
18 import pytest
19
20 from lib import env
21
22 SETUP_KEYCHAIN_SH = Path(__file__).resolve().parents[1] / "skills" / "last30days" / "scripts" / "setup-keychain.sh"
23
24 # ---------------------------------------------------------------------------
25 # _load_keychain unit tests
26 # ---------------------------------------------------------------------------
27
28
29 def test_load_keychain_returns_empty_on_non_darwin():
30 with mock.patch("platform.system", return_value="Linux"):
31 assert env._load_keychain(["XAI_API_KEY"]) == {}
32
33
34 def test_load_keychain_returns_empty_when_security_missing():
35 with mock.patch("platform.system", return_value="Darwin"), \
36 mock.patch("shutil.which", return_value=None):
37 assert env._load_keychain(["XAI_API_KEY"]) == {}
38
39
40 def _run_result(returncode: int, stdout: str = "") -> subprocess.CompletedProcess:
41 return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr="")
42
43
44 def test_parse_keychain_aliases_accepts_string_and_object_forms():
45 raw = (
46 '{"XAI_API_KEY":"existing-xai-api-key",'
47 '"BRAVE_API_KEY":{"account":"keychain-user","service":"existing-brave-api-key"}}'
48 )
49 assert env._parse_keychain_aliases(raw) == {
50 "XAI_API_KEY": [{"service": "existing-xai-api-key", "account": ""}],
51 "BRAVE_API_KEY": [{"service": "existing-brave-api-key", "account": "keychain-user"}],
52 }
53
54
55 def test_parse_keychain_aliases_accepts_ordered_fallback_list():
56 raw = '{"XAI_API_KEY":[{"service":"primary-xai"},{"account":"keychain-user","service":"fallback-xai"}]}'
57 assert env._parse_keychain_aliases(raw) == {
58 "XAI_API_KEY": [
59 {"service": "primary-xai", "account": ""},
60 {"service": "fallback-xai", "account": "keychain-user"},
61 ],
62 }
63
64
65 def test_parse_keychain_aliases_warns_on_invalid_json_and_ignores_unknown_keys(capsys):
66 assert env._parse_keychain_aliases("not json") == {}
67 warning = capsys.readouterr().err
68 assert "LAST30DAYS_KEYCHAIN_ALIASES is not valid JSON" in warning
69 assert "canonical lookups enabled" in warning
70
71 assert env._parse_keychain_aliases('{"NOT_A_KEY":"secret-service"}') == {}
72 assert capsys.readouterr().err == ""
73
74
75 def test_load_keychain_loads_present_keys_skips_missing():
76 def fake_run(cmd, **kwargs):
77 service = cmd[cmd.index("-s") + 1]
78 if service == "last30days-XAI_API_KEY":
79 return _run_result(0, "xai-abc\n")
80 if service == "last30days-BRAVE_API_KEY":
81 return _run_result(0, "brv-xyz\n")
82 return _run_result(44) # security's "not found" exit code
83
84 with mock.patch("platform.system", return_value="Darwin"), \
85 mock.patch("shutil.which", return_value="/usr/bin/security"), \
86 mock.patch("subprocess.run", side_effect=fake_run):
87 result = env._load_keychain(["XAI_API_KEY", "BRAVE_API_KEY", "OPENAI_API_KEY"])
88
89 assert result == {"XAI_API_KEY": "xai-abc", "BRAVE_API_KEY": "brv-xyz"}
90
91
92 def test_load_keychain_uses_alias_when_canonical_missing():
93 calls = []
94
95 def fake_run(cmd, **kwargs):
96 account = cmd[cmd.index("-a") + 1]
97 service = cmd[cmd.index("-s") + 1]
98 calls.append((account, service))
99 if account == "keychain-user" and service == "existing-xai-api-key":
100 return _run_result(0, "xai-alias\n")
101 return _run_result(44)
102
103 aliases = {"XAI_API_KEY": [{"account": "keychain-user", "service": "existing-xai-api-key"}]}
104 with mock.patch("platform.system", return_value="Darwin"), \
105 mock.patch("shutil.which", return_value="/usr/bin/security"), \
106 mock.patch.dict("os.environ", {"USER": "mortimer"}, clear=False), \
107 mock.patch("subprocess.run", side_effect=fake_run):
108 result = env._load_keychain(["XAI_API_KEY"], aliases)
109
110 assert result == {"XAI_API_KEY": "xai-alias"}
111 assert calls == [
112 ("mortimer", "last30days-XAI_API_KEY"),
113 ("keychain-user", "existing-xai-api-key"),
114 ]
115
116
117 def test_load_keychain_canonical_wins_over_alias():
118 def fake_run(cmd, **kwargs):
119 service = cmd[cmd.index("-s") + 1]
120 if service == "last30days-XAI_API_KEY":
121 return _run_result(0, "xai-canonical\n")
122 if service == "existing-xai-api-key":
123 return _run_result(0, "xai-alias\n")
124 return _run_result(44)
125
126 aliases = {"XAI_API_KEY": [{"account": "keychain-user", "service": "existing-xai-api-key"}]}
127 with mock.patch("platform.system", return_value="Darwin"), \
128 mock.patch("shutil.which", return_value="/usr/bin/security"), \
129 mock.patch("subprocess.run", side_effect=fake_run):
130 result = env._load_keychain(["XAI_API_KEY"], aliases)
131
132 assert result == {"XAI_API_KEY": "xai-canonical"}
133
134
135 def test_load_keychain_strips_whitespace_and_newlines():
136 with mock.patch("platform.system", return_value="Darwin"), \
137 mock.patch("shutil.which", return_value="/usr/bin/security"), \
138 mock.patch("subprocess.run", return_value=_run_result(0, " hello-key \n")):
139 result = env._load_keychain(["FOO"])
140 assert result == {"FOO": "hello-key"}
141
142
143 def test_load_keychain_swallows_subprocess_errors():
144 def fake_run(cmd, **kwargs):
145 raise subprocess.TimeoutExpired(cmd=cmd, timeout=5)
146
147 with mock.patch("platform.system", return_value="Darwin"), \
148 mock.patch("shutil.which", return_value="/usr/bin/security"), \
149 mock.patch("subprocess.run", side_effect=fake_run):
150 assert env._load_keychain(["XAI_API_KEY"]) == {}
151
152
153 def test_load_keychain_swallows_oserror():
154 with mock.patch("platform.system", return_value="Darwin"), \
155 mock.patch("shutil.which", return_value="/usr/bin/security"), \
156 mock.patch("subprocess.run", side_effect=OSError("boom")):
157 assert env._load_keychain(["XAI_API_KEY"]) == {}
158
159
160 def test_load_keychain_skips_empty_stdout():
161 with mock.patch("platform.system", return_value="Darwin"), \
162 mock.patch("shutil.which", return_value="/usr/bin/security"), \
163 mock.patch("subprocess.run", return_value=_run_result(0, "")):
164 assert env._load_keychain(["XAI_API_KEY"]) == {}
165
166 # ---------------------------------------------------------------------------
167 # get_config integration tests
168 # ---------------------------------------------------------------------------
169
170 @pytest.fixture
171 def clean_env(monkeypatch, tmp_path):
172 """Hide every key get_config might touch and point CONFIG_FILE at a
173 non-existent path so no real user config bleeds in."""
174 for var in [
175 "OPENAI_API_KEY", "XAI_API_KEY", "BRAVE_API_KEY", "AUTH_TOKEN", "CT0",
176 "SCRAPECREATORS_API_KEY", "APIFY_API_TOKEN", "BSKY_HANDLE",
177 "BSKY_APP_PASSWORD", "TRUTHSOCIAL_TOKEN", "EXA_API_KEY",
178 "SERPER_API_KEY", "OPENROUTER_API_KEY", "PERPLEXITY_API_KEY", "PARALLEL_API_KEY",
179 "XQUIK_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY",
180 "GOOGLE_GENAI_API_KEY", "INCLUDE_SOURCES", "FROM_BROWSER",
181 ]:
182 monkeypatch.delenv(var, raising=False)
183 monkeypatch.setattr(env, "CONFIG_FILE", tmp_path / "does-not-exist.env")
184 monkeypatch.chdir(tmp_path) # no project .env in this tree either
185 # Neutralize the pass(1) source so these tests don't pick up a real pass
186 # store on the host running them (tests that exercise pass override this).
187 monkeypatch.setattr(env, "_load_pass", lambda *a, **k: {})
188
189
190 def test_get_config_reports_keychain_source(clean_env):
191 with mock.patch.object(env, "_load_keychain", return_value={"XAI_API_KEY": "xai-from-kc"}):
192 cfg = env.get_config()
193 assert cfg["_CONFIG_SOURCE"] == "keychain"
194 assert cfg["XAI_API_KEY"] == "xai-from-kc"
195
196
197 def test_get_config_env_var_overrides_keychain(clean_env, monkeypatch):
198 monkeypatch.setenv("XAI_API_KEY", "xai-from-env")
199 with mock.patch.object(env, "_load_keychain", return_value={"XAI_API_KEY": "xai-from-kc"}):
200 cfg = env.get_config()
201 assert cfg["XAI_API_KEY"] == "xai-from-env"
202
203
204 def test_get_config_reports_env_only_when_keychain_empty(clean_env):
205 with mock.patch.object(env, "_load_keychain", return_value={}):
206 cfg = env.get_config()
207 assert cfg["_CONFIG_SOURCE"] == "env_only"
208
209
210 def test_get_config_global_file_outranks_keychain(clean_env, tmp_path, monkeypatch):
211 cfg_file = tmp_path / "global.env"
212 cfg_file.write_text("XAI_API_KEY=xai-from-file\n")
213 monkeypatch.setattr(env, "CONFIG_FILE", cfg_file)
214 with mock.patch.object(env, "_load_keychain", return_value={"XAI_API_KEY": "xai-from-kc"}):
215 cfg = env.get_config()
216 assert cfg["XAI_API_KEY"] == "xai-from-file"
217 assert cfg["_CONFIG_SOURCE"].startswith("global:")
218
219
220 def test_get_config_passes_aliases_from_global_file(clean_env, tmp_path, monkeypatch):
221 cfg_file = tmp_path / "global.env"
222 cfg_file.write_text(
223 'LAST30DAYS_KEYCHAIN_ALIASES={"XAI_API_KEY":{"account":"keychain-user","service":"existing-xai-api-key"}}\n'
224 )
225 monkeypatch.setattr(env, "CONFIG_FILE", cfg_file)
226
227 def fake_load_keychain(keys, aliases=None):
228 assert aliases == {
229 "XAI_API_KEY": [{"account": "keychain-user", "service": "existing-xai-api-key"}],
230 }
231 return {"XAI_API_KEY": "xai-from-alias"}
232
233 with mock.patch.object(env, "_load_keychain", side_effect=fake_load_keychain):
234 cfg = env.get_config()
235
236 assert cfg["XAI_API_KEY"] == "xai-from-alias"
237 assert cfg["LAST30DAYS_KEYCHAIN_ALIASES"].startswith('{"XAI_API_KEY"')
238
239
240 def test_get_config_passes_aliases_from_process_env(clean_env, monkeypatch):
241 monkeypatch.setenv(
242 "LAST30DAYS_KEYCHAIN_ALIASES",
243 '{"XAI_API_KEY":{"account":"keychain-user","service":"existing-xai-api-key"}}',
244 )
245
246 def fake_load_keychain(keys, aliases=None):
247 assert aliases == {
248 "XAI_API_KEY": [{"account": "keychain-user", "service": "existing-xai-api-key"}],
249 }
250 return {"XAI_API_KEY": "xai-from-env-alias"}
251
252 with mock.patch.object(env, "_load_keychain", side_effect=fake_load_keychain):
253 cfg = env.get_config()
254
255 assert cfg["XAI_API_KEY"] == "xai-from-env-alias"
256 assert cfg["LAST30DAYS_KEYCHAIN_ALIASES"].startswith('{"XAI_API_KEY"')
257
258
259 def test_get_config_openai_key_can_come_from_keychain(clean_env):
260 """OPENAI_API_KEY must be visible to get_openai_auth via the keychain
261 merge — wiring regression test."""
262 with mock.patch.object(env, "_load_keychain", return_value={"OPENAI_API_KEY": "sk-from-kc"}):
263 cfg = env.get_config()
264 assert cfg["OPENAI_API_KEY"] == "sk-from-kc"
265 assert cfg["OPENAI_AUTH_SOURCE"] == "api_key"
266
267 # ---------------------------------------------------------------------------
268 # Drift guard: lib/env.py KEYCHAIN_KEYS and setup-keychain.sh ALL_KEYS must
269 # stay in lockstep. A mismatch means users storing a key via the helper script
270 # wouldn't see it picked up by the loader, or vice versa.
271 # ---------------------------------------------------------------------------
272
273
274 def _parse_all_keys_from_shell(script: Path) -> list[str]:
275 text = script.read_text(encoding="utf-8")
276 match = re.search(r"ALL_KEYS=\(\s*(.*?)\s*\)", text, re.DOTALL)
277 if not match:
278 raise AssertionError(f"ALL_KEYS=( ... ) array not found in {script}")
279 body = match.group(1)
280 # Strip shell comments and split on whitespace
281 body = re.sub(r"#[^\n]*", "", body)
282 return [tok for tok in body.split() if tok]
283
284
285 def test_keychain_keys_match_setup_script():
286 shell_keys = _parse_all_keys_from_shell(SETUP_KEYCHAIN_SH)
287 python_keys = list(env.KEYCHAIN_KEYS)
288 assert shell_keys == python_keys, (
289 "lib/env.py::KEYCHAIN_KEYS and scripts/setup-keychain.sh::ALL_KEYS "
290 f"have drifted.\n python: {python_keys}\n shell: {shell_keys}"
291 )
292
292 lines PYTHON