返回 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 os
14 import re
15 import shlex
16 import subprocess
17 from pathlib import Path
18 from unittest import mock
19
20 import pytest
21
22 from lib import env
23
24 SETUP_KEYCHAIN_SH = Path(__file__).resolve().parents[1] / "skills" / "last30days" / "scripts" / "setup-keychain.sh"
25
26
27 def _plaintext_presence_checks(script: str) -> list[str]:
28 # Join shell continuations before tokenizing so a split -w cannot hide.
29 logical = re.sub(r"\\\r?\n", " ", script)
30 offenders = []
31 for line in logical.splitlines():
32 code = " ".join(shlex.split(line, comments=True))
33 if re.search(r"find-generic-password\b.*\s-[wg](?=\s|[;>|&]|$)", code):
34 offenders.append(line)
35 return offenders
36
37
38 def test_setup_presence_checks_do_not_request_plaintext():
39 assert not _plaintext_presence_checks(SETUP_KEYCHAIN_SH.read_text(encoding="utf-8"))
40
41
42 def test_presence_guard_detects_multiline_password_flags():
43 script = (
44 "security find-generic-password \\\n"
45 ' -a "$USER" \\\n'
46 " -w >/dev/null"
47 )
48 assert _plaintext_presence_checks(script)
49 assert not _plaintext_presence_checks('# security find-generic-password -w\n')
50
51
52 @pytest.mark.parametrize("existed,replace,value,summary", [
53 (True, False, "", "added=0 replaced=0 skipped=1"),
54 (False, False, "DUMMY-VALUE\n", "added=1 replaced=0 skipped=0"),
55 (True, True, "DUMMY-VALUE\n", "added=0 replaced=1 skipped=0"),
56 ])
57 @pytest.mark.skipif(os.name == "nt", reason="POSIX shell fixture")
58 def test_setup_presence_and_counters_use_stub_security(tmp_path, existed, replace, value, summary):
59 stub = tmp_path / "security"
60 stub.write_text(
61 '#!/bin/sh\n'
62 'printf "%s\\n" "$1" >> "$STUB_LOG"\n'
63 'case "$1" in\n'
64 ' find-generic-password) exit "$STUB_STATUS" ;;\n'
65 ' add-generic-password) exit 0 ;;\n'
66 ' *) exit 99 ;;\n'
67 'esac\n', encoding="utf-8",
68 )
69 stub.chmod(0o755)
70 log_path = tmp_path / "calls"
71 command = ["/bin/bash", str(SETUP_KEYCHAIN_SH)]
72 if replace:
73 command.append("--replace")
74 command.append("OPENAI_API_KEY")
75 result = subprocess.run(
76 command, input=value, text=True, capture_output=True, timeout=5,
77 env={"PATH": str(tmp_path), "USER": "fixture-user", "OSTYPE": "darwin",
78 "STUB_LOG": str(log_path), "STUB_STATUS": "0" if existed else "44"},
79 )
80 assert result.returncode == 0, result.stderr
81 assert summary in result.stdout
82 assert "DUMMY-VALUE" not in result.stdout + result.stderr
83 calls = log_path.read_text().splitlines()
84 assert calls == ["find-generic-password"] + ([] if existed and not replace else ["add-generic-password"])
85
86 # ---------------------------------------------------------------------------
87 # _load_keychain unit tests
88 # ---------------------------------------------------------------------------
89
90
91 def test_load_keychain_returns_empty_on_non_darwin():
92 with mock.patch("platform.system", return_value="Linux"):
93 assert env._load_keychain(["XAI_API_KEY"]) == {}
94
95
96 def test_load_keychain_returns_empty_when_security_missing():
97 with mock.patch("platform.system", return_value="Darwin"), \
98 mock.patch("shutil.which", return_value=None):
99 assert env._load_keychain(["XAI_API_KEY"]) == {}
100
101
102 def _run_result(returncode: int, stdout: str = "") -> subprocess.CompletedProcess:
103 return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr="")
104
105
106 def test_load_keychain_returns_empty_when_disable_switch_set(monkeypatch):
107 """The opt-out must win on Darwin with `security` present and a key stored.
108
109 Deliberately mocked past the platform/binary early-returns above: if the
110 switch were dropped, neither of those would cover this case and this goes red.
111 """
112 monkeypatch.setenv(env.KEYCHAIN_DISABLE_ENV, "1")
113 with mock.patch("platform.system", return_value="Darwin"), \
114 mock.patch("shutil.which", return_value="/usr/bin/security"), \
115 mock.patch("subprocess.run", return_value=_run_result(0, "should-not-be-read")) as run:
116 assert env._load_keychain(["XAI_API_KEY"]) == {}
117 # Proves the opt-out short-circuits BEFORE any lookup, not merely that the
118 # returned dict came back empty.
119 run.assert_not_called()
120
121
122 def test_load_keychain_reads_key_when_disable_switch_absent(monkeypatch):
123 """Counterexample for the test above: same mocks, switch off -> the key IS read.
124
125 Without this pair, `_load_keychain(...) == {}` could pass for the wrong
126 reason and nobody would notice.
127 """
128 monkeypatch.delenv(env.KEYCHAIN_DISABLE_ENV, raising=False)
129 with mock.patch("platform.system", return_value="Darwin"), \
130 mock.patch("shutil.which", return_value="/usr/bin/security"), \
131 mock.patch("subprocess.run", return_value=_run_result(0, "xai-secret")):
132 assert env._load_keychain(["XAI_API_KEY"]) == {"XAI_API_KEY": "xai-secret"}
133
134
135 def test_load_keychain_disable_switch_ignores_falsy_values(monkeypatch):
136 """`LAST30DAYS_SKIP_KEYCHAIN=0` / empty must NOT disable the source."""
137 for falsy in ("0", "", "false", "no"):
138 monkeypatch.setenv(env.KEYCHAIN_DISABLE_ENV, falsy)
139 with mock.patch("platform.system", return_value="Darwin"), \
140 mock.patch("shutil.which", return_value="/usr/bin/security"), \
141 mock.patch("subprocess.run", return_value=_run_result(0, "xai-secret")):
142 assert env._load_keychain(["XAI_API_KEY"]) == {"XAI_API_KEY": "xai-secret"}, falsy
143
144
145 def test_parse_keychain_aliases_accepts_string_and_object_forms():
146 raw = (
147 '{"XAI_API_KEY":"existing-xai-api-key",'
148 '"BRAVE_API_KEY":{"account":"keychain-user","service":"existing-brave-api-key"}}'
149 )
150 assert env._parse_keychain_aliases(raw) == {
151 "XAI_API_KEY": [{"service": "existing-xai-api-key", "account": ""}],
152 "BRAVE_API_KEY": [{"service": "existing-brave-api-key", "account": "keychain-user"}],
153 }
154
155
156 def test_parse_keychain_aliases_accepts_ordered_fallback_list():
157 raw = '{"XAI_API_KEY":[{"service":"primary-xai"},{"account":"keychain-user","service":"fallback-xai"}]}'
158 assert env._parse_keychain_aliases(raw) == {
159 "XAI_API_KEY": [
160 {"service": "primary-xai", "account": ""},
161 {"service": "fallback-xai", "account": "keychain-user"},
162 ],
163 }
164
165
166 def test_parse_keychain_aliases_warns_on_invalid_json_and_ignores_unknown_keys(capsys):
167 assert env._parse_keychain_aliases("not json") == {}
168 warning = capsys.readouterr().err
169 assert "LAST30DAYS_KEYCHAIN_ALIASES is not valid JSON" in warning
170 assert "canonical lookups enabled" in warning
171
172 assert env._parse_keychain_aliases('{"NOT_A_KEY":"secret-service"}') == {}
173 assert capsys.readouterr().err == ""
174
175
176 def test_load_keychain_loads_present_keys_skips_missing():
177 def fake_run(cmd, **kwargs):
178 service = cmd[cmd.index("-s") + 1]
179 if service == "last30days-XAI_API_KEY":
180 return _run_result(0, "xai-abc\n")
181 if service == "last30days-BRAVE_API_KEY":
182 return _run_result(0, "brv-xyz\n")
183 return _run_result(44) # security's "not found" exit code
184
185 with mock.patch("platform.system", return_value="Darwin"), \
186 mock.patch("shutil.which", return_value="/usr/bin/security"), \
187 mock.patch("subprocess.run", side_effect=fake_run):
188 result = env._load_keychain(["XAI_API_KEY", "BRAVE_API_KEY", "OPENAI_API_KEY"])
189
190 assert result == {"XAI_API_KEY": "xai-abc", "BRAVE_API_KEY": "brv-xyz"}
191
192
193 def test_load_keychain_uses_alias_when_canonical_missing():
194 calls = []
195
196 def fake_run(cmd, **kwargs):
197 account = cmd[cmd.index("-a") + 1]
198 service = cmd[cmd.index("-s") + 1]
199 calls.append((account, service))
200 if account == "keychain-user" and service == "existing-xai-api-key":
201 return _run_result(0, "xai-alias\n")
202 return _run_result(44)
203
204 aliases = {"XAI_API_KEY": [{"account": "keychain-user", "service": "existing-xai-api-key"}]}
205 with mock.patch("platform.system", return_value="Darwin"), \
206 mock.patch("shutil.which", return_value="/usr/bin/security"), \
207 mock.patch.dict("os.environ", {"USER": "mortimer"}, clear=False), \
208 mock.patch("subprocess.run", side_effect=fake_run):
209 result = env._load_keychain(["XAI_API_KEY"], aliases)
210
211 assert result == {"XAI_API_KEY": "xai-alias"}
212 assert calls == [
213 ("mortimer", "last30days-XAI_API_KEY"),
214 ("keychain-user", "existing-xai-api-key"),
215 ]
216
217
218 def test_load_keychain_canonical_wins_over_alias():
219 def fake_run(cmd, **kwargs):
220 service = cmd[cmd.index("-s") + 1]
221 if service == "last30days-XAI_API_KEY":
222 return _run_result(0, "xai-canonical\n")
223 if service == "existing-xai-api-key":
224 return _run_result(0, "xai-alias\n")
225 return _run_result(44)
226
227 aliases = {"XAI_API_KEY": [{"account": "keychain-user", "service": "existing-xai-api-key"}]}
228 with mock.patch("platform.system", return_value="Darwin"), \
229 mock.patch("shutil.which", return_value="/usr/bin/security"), \
230 mock.patch("subprocess.run", side_effect=fake_run):
231 result = env._load_keychain(["XAI_API_KEY"], aliases)
232
233 assert result == {"XAI_API_KEY": "xai-canonical"}
234
235
236 def test_load_keychain_strips_whitespace_and_newlines():
237 with mock.patch("platform.system", return_value="Darwin"), \
238 mock.patch("shutil.which", return_value="/usr/bin/security"), \
239 mock.patch("subprocess.run", return_value=_run_result(0, " hello-key \n")):
240 result = env._load_keychain(["FOO"])
241 assert result == {"FOO": "hello-key"}
242
243
244 def test_load_keychain_swallows_subprocess_errors():
245 def fake_run(cmd, **kwargs):
246 raise subprocess.TimeoutExpired(cmd=cmd, timeout=5)
247
248 with mock.patch("platform.system", return_value="Darwin"), \
249 mock.patch("shutil.which", return_value="/usr/bin/security"), \
250 mock.patch("subprocess.run", side_effect=fake_run):
251 assert env._load_keychain(["XAI_API_KEY"]) == {}
252
253
254 def test_load_keychain_swallows_oserror():
255 with mock.patch("platform.system", return_value="Darwin"), \
256 mock.patch("shutil.which", return_value="/usr/bin/security"), \
257 mock.patch("subprocess.run", side_effect=OSError("boom")):
258 assert env._load_keychain(["XAI_API_KEY"]) == {}
259
260
261 def test_load_keychain_skips_empty_stdout():
262 with mock.patch("platform.system", return_value="Darwin"), \
263 mock.patch("shutil.which", return_value="/usr/bin/security"), \
264 mock.patch("subprocess.run", return_value=_run_result(0, "")):
265 assert env._load_keychain(["XAI_API_KEY"]) == {}
266
267 # ---------------------------------------------------------------------------
268 # get_config integration tests
269 # ---------------------------------------------------------------------------
270
271 @pytest.fixture
272 def clean_env(monkeypatch, tmp_path):
273 """Hide every key get_config might touch and point CONFIG_FILE at a
274 non-existent path so no real user config bleeds in."""
275 for var in [
276 "OPENAI_API_KEY", "XAI_API_KEY", "BRAVE_API_KEY", "AUTH_TOKEN", "CT0",
277 "SCRAPECREATORS_API_KEY", "APIFY_API_TOKEN", "BSKY_HANDLE",
278 "BSKY_APP_PASSWORD", "TRUTHSOCIAL_TOKEN", "EXA_API_KEY",
279 "SERPER_API_KEY", "OPENROUTER_API_KEY", "PERPLEXITY_API_KEY", "PARALLEL_API_KEY",
280 "XQUIK_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY",
281 "GOOGLE_GENAI_API_KEY", "INCLUDE_SOURCES", "FROM_BROWSER",
282 ]:
283 monkeypatch.delenv(var, raising=False)
284 monkeypatch.setattr(env, "CONFIG_FILE", tmp_path / "does-not-exist.env")
285 monkeypatch.chdir(tmp_path) # no project .env in this tree either
286 # Neutralize the pass(1) source so these tests don't pick up a real pass
287 # store on the host running them (tests that exercise pass override this).
288 monkeypatch.setattr(env, "_load_pass", lambda *a, **k: {})
289
290
291 def test_get_config_reports_keychain_source(clean_env):
292 with mock.patch.object(env, "_load_keychain", return_value={"XAI_API_KEY": "xai-from-kc"}):
293 cfg = env.get_config()
294 assert cfg["_CONFIG_SOURCE"] == "keychain"
295 assert cfg["XAI_API_KEY"] == "xai-from-kc"
296
297
298 def test_get_config_env_var_overrides_keychain(clean_env, monkeypatch):
299 monkeypatch.setenv("XAI_API_KEY", "xai-from-env")
300 with mock.patch.object(env, "_load_keychain", return_value={"XAI_API_KEY": "xai-from-kc"}):
301 cfg = env.get_config()
302 assert cfg["XAI_API_KEY"] == "xai-from-env"
303
304
305 def test_get_config_reports_env_only_when_keychain_empty(clean_env):
306 with mock.patch.object(env, "_load_keychain", return_value={}):
307 cfg = env.get_config()
308 assert cfg["_CONFIG_SOURCE"] == "env_only"
309
310
311 def test_get_config_global_file_outranks_keychain(clean_env, tmp_path, monkeypatch):
312 cfg_file = tmp_path / "global.env"
313 cfg_file.write_text("XAI_API_KEY=xai-from-file\n")
314 monkeypatch.setattr(env, "CONFIG_FILE", cfg_file)
315 with mock.patch.object(env, "_load_keychain", return_value={"XAI_API_KEY": "xai-from-kc"}):
316 cfg = env.get_config()
317 assert cfg["XAI_API_KEY"] == "xai-from-file"
318 assert cfg["_CONFIG_SOURCE"].startswith("global:")
319
320
321 def test_get_config_passes_aliases_from_global_file(clean_env, tmp_path, monkeypatch):
322 cfg_file = tmp_path / "global.env"
323 cfg_file.write_text(
324 'LAST30DAYS_KEYCHAIN_ALIASES={"XAI_API_KEY":{"account":"keychain-user","service":"existing-xai-api-key"}}\n'
325 )
326 monkeypatch.setattr(env, "CONFIG_FILE", cfg_file)
327
328 def fake_load_keychain(keys, aliases=None):
329 assert aliases == {
330 "XAI_API_KEY": [{"account": "keychain-user", "service": "existing-xai-api-key"}],
331 }
332 return {"XAI_API_KEY": "xai-from-alias"}
333
334 with mock.patch.object(env, "_load_keychain", side_effect=fake_load_keychain):
335 cfg = env.get_config()
336
337 assert cfg["XAI_API_KEY"] == "xai-from-alias"
338 assert cfg["LAST30DAYS_KEYCHAIN_ALIASES"].startswith('{"XAI_API_KEY"')
339
340
341 def test_get_config_passes_aliases_from_process_env(clean_env, monkeypatch):
342 monkeypatch.setenv(
343 "LAST30DAYS_KEYCHAIN_ALIASES",
344 '{"XAI_API_KEY":{"account":"keychain-user","service":"existing-xai-api-key"}}',
345 )
346
347 def fake_load_keychain(keys, aliases=None):
348 assert aliases == {
349 "XAI_API_KEY": [{"account": "keychain-user", "service": "existing-xai-api-key"}],
350 }
351 return {"XAI_API_KEY": "xai-from-env-alias"}
352
353 with mock.patch.object(env, "_load_keychain", side_effect=fake_load_keychain):
354 cfg = env.get_config()
355
356 assert cfg["XAI_API_KEY"] == "xai-from-env-alias"
357 assert cfg["LAST30DAYS_KEYCHAIN_ALIASES"].startswith('{"XAI_API_KEY"')
358
359
360 def test_get_config_openai_key_can_come_from_keychain(clean_env):
361 """OPENAI_API_KEY must be visible to get_openai_auth via the keychain
362 merge — wiring regression test."""
363 with mock.patch.object(env, "_load_keychain", return_value={"OPENAI_API_KEY": "sk-from-kc"}):
364 cfg = env.get_config()
365 assert cfg["OPENAI_API_KEY"] == "sk-from-kc"
366 assert cfg["OPENAI_AUTH_SOURCE"] == "api_key"
367
368 # ---------------------------------------------------------------------------
369 # Drift guard: lib/env.py KEYCHAIN_KEYS and setup-keychain.sh ALL_KEYS must
370 # stay in lockstep. A mismatch means users storing a key via the helper script
371 # wouldn't see it picked up by the loader, or vice versa.
372 # ---------------------------------------------------------------------------
373
374
375 def _parse_all_keys_from_shell(script: Path) -> list[str]:
376 text = script.read_text(encoding="utf-8")
377 match = re.search(r"ALL_KEYS=\(\s*(.*?)\s*\)", text, re.DOTALL)
378 if not match:
379 raise AssertionError(f"ALL_KEYS=( ... ) array not found in {script}")
380 body = match.group(1)
381 # Strip shell comments and split on whitespace
382 body = re.sub(r"#[^\n]*", "", body)
383 return [tok for tok in body.split() if tok]
384
385
386 def test_keychain_keys_match_setup_script():
387 shell_keys = _parse_all_keys_from_shell(SETUP_KEYCHAIN_SH)
388 python_keys = list(env.KEYCHAIN_KEYS)
389 assert shell_keys == python_keys, (
390 "lib/env.py::KEYCHAIN_KEYS and scripts/setup-keychain.sh::ALL_KEYS "
391 f"have drifted.\n python: {python_keys}\n shell: {shell_keys}"
392 )
393
394
395 def test_x_bearer_token_is_a_keychain_key():
396 """U6/R17: the X API bearer is loadable from the Keychain and listed by
397 the setup-keychain.sh helper (parity is enforced above)."""
398 assert "X_BEARER_TOKEN" in env.KEYCHAIN_KEYS
399 assert _parse_all_keys_from_shell(SETUP_KEYCHAIN_SH).count("X_BEARER_TOKEN") == 1
400
400 lines PYTHON