返回 last30days-skill
test_env_unsubstituted_template.py
根目录 / tests / test_env_unsubstituted_template.py
1 """Unsubstituted `${user_config.*}` placeholders must read as unconfigured.
2
3 A Claude Desktop extension writes the literal placeholder for every field the
4 user has not filled in. The value is non-empty, so before this fix every
5 presence check downstream read it as a real credential: doctor reported the
6 source healthy, preflight returned ready, and the backend sent the placeholder
7 upstream and surfaced the vendor's auth error instead of falling back.
8 """
9
10 from __future__ import annotations
11
12 from unittest import mock
13
14 from lib import env
15
16
17 TEMPLATE = "${user_config.scrapecreators_api_key}"
18
19 _KEYS = (
20 "SCRAPECREATORS_API_KEY",
21 "SCRAPE_CREATORS_API_KEY",
22 "GEMINI_API_KEY",
23 "OPENAI_API_KEY",
24 "GITHUB_TOKEN",
25 "LAST30DAYS_MEMORY_DIR",
26 "LAST30DAYS_YT_PLAYER_CLIENT",
27 )
28
29
30 def _isolate(monkeypatch, tmp_path):
31 """Point config loading at an empty world so only the test's env applies."""
32 monkeypatch.setattr(env, "CONFIG_FILE", tmp_path / "does-not-exist.env")
33 monkeypatch.setattr(env, "_find_project_env", lambda: None)
34 monkeypatch.setattr(env, "_load_keychain", lambda *a, **k: {})
35 monkeypatch.setattr(env, "_load_pass", lambda *a, **k: {})
36 monkeypatch.chdir(tmp_path)
37 for key in _KEYS:
38 monkeypatch.delenv(key, raising=False)
39 # Drop any ambient SETUP_COMPLETE so a truthy value on the machine running
40 # the suite cannot leak into the config under test.
41 monkeypatch.delenv("SETUP_COMPLETE", raising=False)
42
43
44 def _isolate_with_env_file(monkeypatch, tmp_path, contents):
45 """Like ``_isolate``, but the global .env holds real values."""
46 _isolate(monkeypatch, tmp_path)
47 config_file = tmp_path / ".env"
48 config_file.write_text(contents, encoding="utf-8")
49 config_file.chmod(0o600)
50 monkeypatch.setattr(env, "CONFIG_FILE", config_file)
51
52
53 def test_placeholder_falls_through_to_a_real_lower_priority_credential(monkeypatch, tmp_path):
54 # The host's placeholder occupies the highest-priority source. It must read
55 # as absent, not as an empty override, or the credential the user already
56 # configured in .env (or Keychain, or pass) is silently discarded and the
57 # run degrades despite having a valid key.
58 _isolate_with_env_file(
59 monkeypatch, tmp_path, "SCRAPECREATORS_API_KEY=sc_real_from_dotenv\n"
60 )
61 monkeypatch.setenv("SCRAPECREATORS_API_KEY", TEMPLATE)
62
63 config = env.get_config()
64
65 assert config["SCRAPECREATORS_API_KEY"] == "sc_real_from_dotenv"
66 # A credential that resolved is configured, so it is not reported as unset.
67 assert "SCRAPECREATORS_API_KEY" not in config[env.TEMPLATE_CONFIG_KEYS]
68 # The placeholder itself still leaves the process environment.
69 assert env.read_secret_env("SCRAPECREATORS_API_KEY") is None
70
71
72 def test_a_restored_key_list_is_still_rotated_to_a_single_key(monkeypatch, tmp_path):
73 # The rotation runs before the sweep, so a list restored from a lower-priority
74 # source must be rotated again - otherwise the backend receives "k1,k2" as one
75 # credential and authentication fails despite valid fallback keys existing.
76 _isolate_with_env_file(
77 monkeypatch, tmp_path, "SCRAPECREATORS_API_KEY=sc_one,sc_two\n"
78 )
79 monkeypatch.setenv("SCRAPECREATORS_API_KEY", TEMPLATE)
80
81 config = env.get_config()
82
83 assert config["SCRAPECREATORS_API_KEY"] in {"sc_one", "sc_two"}
84 assert "," not in config["SCRAPECREATORS_API_KEY"]
85
86
87 def test_a_lower_priority_placeholder_is_not_a_fallback(monkeypatch, tmp_path):
88 # A placeholder in .env is no more a credential than one in the environment.
89 _isolate_with_env_file(
90 monkeypatch, tmp_path, f"SCRAPECREATORS_API_KEY={TEMPLATE}\n"
91 )
92 monkeypatch.setenv("SCRAPECREATORS_API_KEY", TEMPLATE)
93
94 config = env.get_config()
95
96 assert config["SCRAPECREATORS_API_KEY"] == ""
97 assert "SCRAPECREATORS_API_KEY" in config[env.TEMPLATE_CONFIG_KEYS]
98
99
100 def test_templated_openai_key_clears_the_derived_auth_record(monkeypatch, tmp_path):
101 _isolate(monkeypatch, tmp_path)
102 monkeypatch.setenv("OPENAI_API_KEY", "${user_config.openai_api_key}")
103
104 config = env.get_config()
105
106 assert config["OPENAI_API_KEY"] == ""
107 assert config["OPENAI_AUTH_STATUS"] == env.AUTH_STATUS_MISSING
108 assert config["OPENAI_AUTH_SOURCE"] == env.AUTH_SOURCE_NONE
109 assert "OPENAI_API_KEY" in config[env.TEMPLATE_CONFIG_KEYS]
110
111
112 def test_whole_value_template_is_emptied_and_recorded(monkeypatch, tmp_path):
113 _isolate(monkeypatch, tmp_path)
114 monkeypatch.setenv("SCRAPECREATORS_API_KEY", TEMPLATE)
115
116 config = env.get_config()
117
118 assert config["SCRAPECREATORS_API_KEY"] == ""
119 assert "SCRAPECREATORS_API_KEY" in config[env.TEMPLATE_CONFIG_KEYS]
120
121
122 def test_template_is_removed_from_the_process_environment(monkeypatch, tmp_path):
123 _isolate(monkeypatch, tmp_path)
124 monkeypatch.setenv("GITHUB_TOKEN", "${user_config.github_token}")
125
126 env.get_config()
127
128 # doctor's GitHub record and the GitHub backend read this name straight
129 # from os.environ, bypassing the config dict entirely.
130 assert env.read_secret_env("GITHUB_TOKEN") is None
131
132
133 def test_real_credential_is_untouched_and_stays_in_the_environment(monkeypatch, tmp_path):
134 _isolate(monkeypatch, tmp_path)
135 monkeypatch.setenv("SCRAPECREATORS_API_KEY", "sc_abc123")
136
137 config = env.get_config()
138
139 assert config["SCRAPECREATORS_API_KEY"] == "sc_abc123"
140 assert config[env.TEMPLATE_CONFIG_KEYS] == []
141 assert env.read_secret_env("SCRAPECREATORS_API_KEY") == "sc_abc123"
142
143
144 def test_placeholder_alongside_other_text_is_kept(monkeypatch, tmp_path):
145 _isolate(monkeypatch, tmp_path)
146 monkeypatch.setenv("SCRAPECREATORS_API_KEY", "prefix-${user_config.x}-suffix")
147
148 config = env.get_config()
149
150 assert config["SCRAPECREATORS_API_KEY"] == "prefix-${user_config.x}-suffix"
151 assert config[env.TEMPLATE_CONFIG_KEYS] == []
152
153
154 def test_shell_default_syntax_is_not_a_template(monkeypatch, tmp_path):
155 # SKILL.md itself ships this shape as a .env example.
156 _isolate(monkeypatch, tmp_path)
157 shell_default = "${LAST30DAYS_MEMORY_DIR:-$HOME/Documents/Last30Days}"
158 monkeypatch.setenv("LAST30DAYS_MEMORY_DIR", shell_default)
159
160 config = env.get_config()
161
162 assert config["LAST30DAYS_MEMORY_DIR"] == shell_default
163 assert config[env.TEMPLATE_CONFIG_KEYS] == []
164
165
166 def test_key_assembled_before_the_registered_key_loop_is_covered(monkeypatch, tmp_path):
167 # OPENAI_API_KEY is set from get_openai_auth() before the `keys` loop.
168 _isolate(monkeypatch, tmp_path)
169 monkeypatch.setenv("OPENAI_API_KEY", "${user_config.openai_api_key}")
170
171 config = env.get_config()
172
173 assert config["OPENAI_API_KEY"] == ""
174 assert "OPENAI_API_KEY" in config[env.TEMPLATE_CONFIG_KEYS]
175
176
177 def test_templated_legacy_spelling_does_not_repopulate_the_canonical_key(
178 monkeypatch, tmp_path
179 ):
180 _isolate(monkeypatch, tmp_path)
181 monkeypatch.setenv("SCRAPE_CREATORS_API_KEY", "${user_config.scrapecreators_api_key}")
182
183 config = env.get_config()
184
185 assert config["SCRAPECREATORS_API_KEY"] == ""
186
187
188 def test_templated_credential_reads_as_absent_to_diagnose(monkeypatch, tmp_path):
189 _isolate(monkeypatch, tmp_path)
190 monkeypatch.setenv("GEMINI_API_KEY", "${user_config.gemini_api_key}")
191
192 config = env.get_config()
193 with mock.patch("lib.bird_x.get_bird_status", return_value={
194 "installed": False, "authenticated": False, "username": None,
195 "can_install": False,
196 }), mock.patch("lib.bird_x.set_credentials", lambda *a, **k: None), \
197 mock.patch("lib.grok_x.has_stored_auth", return_value=False), \
198 mock.patch("lib.xurl_x.has_stored_auth", return_value=False):
199 from lib import pipeline
200
201 diag = pipeline.diagnose(config, None, safe=True)
202
203 assert diag["providers"]["google"] is False
204
205
206 def test_key_the_earlier_export_loop_pushed_out_is_cleared_too(monkeypatch, tmp_path):
207 # The YT knob export loop runs before the sweep and passes this key through
208 # on `is not None`, so the sweep has to clear what that loop just exported.
209 _isolate(monkeypatch, tmp_path)
210 monkeypatch.setenv("LAST30DAYS_YT_PLAYER_CLIENT", "${user_config.player_client}")
211
212 config = env.get_config()
213
214 assert config["LAST30DAYS_YT_PLAYER_CLIENT"] == ""
215 assert env.read_secret_env("LAST30DAYS_YT_PLAYER_CLIENT") is None
216
217
218 def test_no_templates_leaves_an_empty_record(monkeypatch, tmp_path):
219 _isolate(monkeypatch, tmp_path)
220
221 config = env.get_config()
222
223 assert config[env.TEMPLATE_CONFIG_KEYS] == []
224
225
226 def test_is_unsubstituted_template_matches_only_the_whole_placeholder():
227 assert env.is_unsubstituted_template(TEMPLATE) is True
228 assert env.is_unsubstituted_template(" ${user_config.x} ") is True
229 assert env.is_unsubstituted_template("${user_config.x} ") is True
230 assert env.is_unsubstituted_template("${user_config.x}-tail") is False
231 assert env.is_unsubstituted_template("${LAST30DAYS_MEMORY_DIR:-x}") is False
232 assert env.is_unsubstituted_template("") is False
233 assert env.is_unsubstituted_template(None) is False
234 assert env.is_unsubstituted_template(1234) is False
235
236
237 def test_shell_default_in_the_extension_namespace_is_not_a_template():
238 # `${user_config.x:-default}` is shell-default syntax, not an unexpanded
239 # placeholder: the field name is not the bare identifier the manifest emits.
240 assert env.is_unsubstituted_template("${user_config.x:-default}") is False
241 assert env.is_unsubstituted_template("${user_config.}") is False
242 assert env.is_unsubstituted_template("${user_config.a}${user_config.b}") is False
243 assert env.is_unsubstituted_template("${user_config.x y}") is False
244
244 lines PYTHON