gabriel / muse public
test_cmd_config.py python
398 lines 17.8 KB
Raw
sha256:51116ec824246acde6abf729e6ba854c223dc5173eff31a645520208023b0652 refactor(bridge): comprehensive spec sweep — close all issu… Sonnet 4.6 minor ⚠ breaking 28 days ago
1 """Comprehensive tests for ``muse config`` — show / get / set.
2
3 Coverage:
4 - Unit: get_config_value, set_config_value, config_as_dict
5 - Integration: CLI round-trips for show, get, set
6 - E2E: full set→get→show workflow
7 - Security: blocked namespaces, TOML injection, malformed keys
8 - Format: --json / --format json output
9 """
10
11 from __future__ import annotations
12
13 import json
14 import pathlib
15
16 import pytest
17 from muse.core.types import fake_id
18 from muse.core.paths import config_toml_path, muse_dir
19 from tests.cli_test_helper import CliRunner
20
21 cli = None # argparse migration — CliRunner ignores this arg
22
23 runner = CliRunner()
24
25
26 # ---------------------------------------------------------------------------
27 # Helpers
28 # ---------------------------------------------------------------------------
29
30
31 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
32 """Initialise a minimal .muse repo and return (root, repo_id)."""
33 repo_id = fake_id("repo")
34 dot_muse = muse_dir(tmp_path)
35 dot_muse.mkdir()
36 (dot_muse / "repo.json").write_text(
37 json.dumps({"repo_id": repo_id, "domain": "midi",
38 "default_branch": "main",
39 "created_at": "2026-01-01T00:00:00+00:00"})
40 )
41 (dot_muse / "HEAD").write_text("ref: refs/heads/main")
42 (dot_muse / "refs" / "heads").mkdir(parents=True)
43 (dot_muse / "snapshots").mkdir()
44 (dot_muse / "commits").mkdir()
45 (dot_muse / "objects").mkdir()
46 return tmp_path, repo_id
47
48
49 def _env(root: pathlib.Path) -> Manifest:
50 return {"MUSE_REPO_ROOT": str(root)}
51
52
53 # ---------------------------------------------------------------------------
54 # Parser flag tests
55 # ---------------------------------------------------------------------------
56
57
58 class TestRegisterFlags:
59 def _parse(self, *args: str) -> "argparse.Namespace":
60 import argparse
61 from muse.cli.commands.config_cmd import register
62 p = argparse.ArgumentParser()
63 sub = p.add_subparsers()
64 register(sub)
65 return p.parse_args(["config", *args])
66
67 def test_get_default_json_out_is_false(self) -> None:
68 ns = self._parse("get", "hub.url")
69 assert ns.json_out is False
70
71 def test_get_json_flag_sets_json_out(self) -> None:
72 ns = self._parse("get", "hub.url", "--json")
73 assert ns.json_out is True
74
75 def test_get_j_shorthand_sets_json_out(self) -> None:
76 ns = self._parse("get", "hub.url", "-j")
77 assert ns.json_out is True
78
79 def test_set_default_json_out_is_false(self) -> None:
80 ns = self._parse("set", "hub.url", "https://musehub.ai")
81 assert ns.json_out is False
82
83 def test_set_json_flag_sets_json_out(self) -> None:
84 ns = self._parse("set", "hub.url", "https://musehub.ai", "--json")
85 assert ns.json_out is True
86
87 def test_read_default_json_out_is_false(self) -> None:
88 ns = self._parse("read")
89 assert ns.json_out is False
90
91 def test_read_json_flag_sets_json_out(self) -> None:
92 ns = self._parse("read", "--json")
93 assert ns.json_out is True
94
95 def test_read_j_shorthand_sets_json_out(self) -> None:
96 ns = self._parse("read", "-j")
97 assert ns.json_out is True
98
99
100 # ---------------------------------------------------------------------------
101 # Unit tests — config helpers
102 # ---------------------------------------------------------------------------
103
104
105 class TestConfigValueHelpers:
106 def test_set_user_handle_raises(self, tmp_path: pathlib.Path) -> None:
107 root, _ = _init_repo(tmp_path)
108 from muse.cli.config import set_config_value
109 with pytest.raises(ValueError):
110 set_config_value("user.handle", "Alice", root)
111
112 def test_set_user_email_raises(self, tmp_path: pathlib.Path) -> None:
113 root, _ = _init_repo(tmp_path)
114 from muse.cli.config import set_config_value
115 with pytest.raises(ValueError):
116 set_config_value("user.email", "[email protected]", root)
117
118 def test_set_user_type_raises(self, tmp_path: pathlib.Path) -> None:
119 root, _ = _init_repo(tmp_path)
120 from muse.cli.config import set_config_value
121 with pytest.raises(ValueError):
122 set_config_value("user.type", "agent", root)
123
124 def test_set_and_get_domain_key(self, tmp_path: pathlib.Path) -> None:
125 root, _ = _init_repo(tmp_path)
126 from muse.cli.config import get_config_value, set_config_value
127 set_config_value("domain.ticks_per_beat", "480", root)
128 assert get_config_value("domain.ticks_per_beat", root) == "480"
129
130 def test_get_missing_hub_key_returns_none(self, tmp_path: pathlib.Path) -> None:
131 root, _ = _init_repo(tmp_path)
132 from muse.cli.config import get_config_value
133 assert get_config_value("hub.url", root) is None
134
135 def test_get_unknown_namespace_returns_none(self, tmp_path: pathlib.Path) -> None:
136 root, _ = _init_repo(tmp_path)
137 from muse.cli.config import get_config_value
138 assert get_config_value("unknown.key", root) is None
139
140 def test_set_blocked_auth_raises(self, tmp_path: pathlib.Path) -> None:
141 root, _ = _init_repo(tmp_path)
142 from muse.cli.config import set_config_value
143 with pytest.raises(ValueError, match="muse auth keygen"):
144 set_config_value("auth.anything", "secret", root)
145
146 def test_set_blocked_remotes_raises(self, tmp_path: pathlib.Path) -> None:
147 root, _ = _init_repo(tmp_path)
148 from muse.cli.config import set_config_value
149 with pytest.raises(ValueError, match="muse remote"):
150 set_config_value("remotes.origin", "https://x.com", root)
151
152 def test_set_unknown_namespace_raises(self, tmp_path: pathlib.Path) -> None:
153 root, _ = _init_repo(tmp_path)
154 from muse.cli.config import set_config_value
155 with pytest.raises(ValueError):
156 set_config_value("invalid.key", "value", root)
157
158 def test_set_malformed_key_raises(self, tmp_path: pathlib.Path) -> None:
159 root, _ = _init_repo(tmp_path)
160 from muse.cli.config import set_config_value
161 with pytest.raises(ValueError):
162 set_config_value("no-dot-key", "value", root)
163
164 def test_config_as_dict_no_user_section(self, tmp_path: pathlib.Path) -> None:
165 root, _ = _init_repo(tmp_path)
166 from muse.cli.config import config_as_dict, set_hub_url
167 set_hub_url("https://musehub.ai", root)
168 d = config_as_dict(root)
169 assert "user" not in d
170 assert d.get("hub", {}).get("url") == "https://musehub.ai"
171
172 def test_config_as_dict_empty_repo(self, tmp_path: pathlib.Path) -> None:
173 root, _ = _init_repo(tmp_path)
174 from muse.cli.config import config_as_dict
175 d = config_as_dict(root)
176 assert isinstance(d, dict)
177
178 def test_set_hub_url_requires_https(self, tmp_path: pathlib.Path) -> None:
179 root, _ = _init_repo(tmp_path)
180 from muse.cli.config import set_config_value
181 with pytest.raises(ValueError, match="HTTPS"):
182 set_config_value("hub.url", "http://insecure.example.com", root)
183
184
185 # ---------------------------------------------------------------------------
186 # Integration tests — CLI commands
187 # ---------------------------------------------------------------------------
188
189
190 class TestConfigCLI:
191 def test_read_empty_config(self, tmp_path: pathlib.Path) -> None:
192 root, _ = _init_repo(tmp_path)
193 result = runner.invoke(cli, ["config", "read"], env=_env(root), catch_exceptions=False)
194 assert result.exit_code == 0
195
196 def test_read_json_empty(self, tmp_path: pathlib.Path) -> None:
197 root, _ = _init_repo(tmp_path)
198 result = runner.invoke(cli, ["config", "read", "--json"], env=_env(root), catch_exceptions=False)
199 assert result.exit_code == 0
200 data = json.loads(result.output)
201 assert isinstance(data, dict)
202
203 def test_read_format_json(self, tmp_path: pathlib.Path) -> None:
204 root, _ = _init_repo(tmp_path)
205 result = runner.invoke(cli, ["config", "read", "--json"], env=_env(root), catch_exceptions=False)
206 assert result.exit_code == 0
207 data = json.loads(result.output)
208 assert isinstance(data, dict)
209
210 def test_set_user_handle_blocked(self, tmp_path: pathlib.Path) -> None:
211 root, _ = _init_repo(tmp_path)
212 result = runner.invoke(cli, ["config", "set", "user.handle", "Alice"], env=_env(root))
213 assert result.exit_code != 0
214
215 def test_set_then_get_hub_url(self, tmp_path: pathlib.Path) -> None:
216 root, _ = _init_repo(tmp_path)
217 runner.invoke(cli, ["config", "set", "hub.url", "https://musehub.ai"], env=_env(root), catch_exceptions=False)
218 result = runner.invoke(cli, ["config", "get", "hub.url"], env=_env(root), catch_exceptions=False)
219 assert result.exit_code == 0
220 assert "musehub.ai" in result.output
221
222 def test_get_unset_key_fails(self, tmp_path: pathlib.Path) -> None:
223 root, _ = _init_repo(tmp_path)
224 result = runner.invoke(cli, ["config", "get", "hub.url"], env=_env(root))
225 assert result.exit_code != 0
226
227 def test_set_domain_key(self, tmp_path: pathlib.Path) -> None:
228 root, _ = _init_repo(tmp_path)
229 result = runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "480"],
230 env=_env(root), catch_exceptions=False)
231 assert result.exit_code == 0
232
233 def test_get_domain_key_after_set(self, tmp_path: pathlib.Path) -> None:
234 root, _ = _init_repo(tmp_path)
235 runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "960"], env=_env(root))
236 result = runner.invoke(cli, ["config", "get", "domain.ticks_per_beat"], env=_env(root), catch_exceptions=False)
237 assert result.exit_code == 0
238 assert "960" in result.output
239
240 def test_set_blocked_auth_fails(self, tmp_path: pathlib.Path) -> None:
241 root, _ = _init_repo(tmp_path)
242 result = runner.invoke(cli, ["config", "set", "auth.anything", "secret"], env=_env(root))
243 assert result.exit_code != 0
244
245 def test_set_blocked_remotes_fails(self, tmp_path: pathlib.Path) -> None:
246 root, _ = _init_repo(tmp_path)
247 result = runner.invoke(cli, ["config", "set", "remotes.origin", "https://x.com"], env=_env(root))
248 assert result.exit_code != 0
249
250 def test_set_http_hub_url_fails(self, tmp_path: pathlib.Path) -> None:
251 root, _ = _init_repo(tmp_path)
252 result = runner.invoke(cli, ["config", "set", "hub.url", "http://insecure.example.com"], env=_env(root))
253 assert result.exit_code != 0
254
255 def test_set_https_hub_url_succeeds(self, tmp_path: pathlib.Path) -> None:
256 root, _ = _init_repo(tmp_path)
257 result = runner.invoke(cli, ["config", "set", "hub.url", "https://musehub.ai"],
258 env=_env(root), catch_exceptions=False)
259 assert result.exit_code == 0
260
261 def test_read_after_set_includes_value(self, tmp_path: pathlib.Path) -> None:
262 root, _ = _init_repo(tmp_path)
263 runner.invoke(cli, ["config", "set", "domain.label", "Dave"], env=_env(root))
264 result = runner.invoke(cli, ["config", "read"], env=_env(root), catch_exceptions=False)
265 assert result.exit_code == 0
266 assert "Dave" in result.output
267
268 def test_read_json_after_set(self, tmp_path: pathlib.Path) -> None:
269 root, _ = _init_repo(tmp_path)
270 runner.invoke(cli, ["config", "set", "hub.url", "https://musehub.ai"], env=_env(root))
271 runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "480"], env=_env(root))
272 result = runner.invoke(cli, ["config", "read", "--json"], env=_env(root), catch_exceptions=False)
273 assert result.exit_code == 0
274 envelope = json.loads(result.output)
275 data = envelope.get("config", envelope)
276 assert data.get("hub", {}).get("url") == "https://musehub.ai"
277 assert data.get("domain", {}).get("ticks_per_beat") == "480"
278
279 def test_multiple_sets_accumulate(self, tmp_path: pathlib.Path) -> None:
280 root, _ = _init_repo(tmp_path)
281 runner.invoke(cli, ["config", "set", "hub.url", "https://musehub.ai"], env=_env(root))
282 runner.invoke(cli, ["config", "set", "domain.sample_rate", "44100"], env=_env(root))
283 runner.invoke(cli, ["config", "set", "domain.key", "val"], env=_env(root))
284 result = runner.invoke(cli, ["config", "read", "--json"], env=_env(root), catch_exceptions=False)
285 envelope = json.loads(result.output)
286 data = envelope.get("config", envelope)
287 assert data["hub"]["url"] == "https://musehub.ai"
288 assert data["domain"]["sample_rate"] == "44100"
289 assert data["domain"]["key"] == "val"
290
291 def test_set_overwrites_previous_value(self, tmp_path: pathlib.Path) -> None:
292 root, _ = _init_repo(tmp_path)
293 runner.invoke(cli, ["config", "set", "domain.label", "Old"], env=_env(root))
294 runner.invoke(cli, ["config", "set", "domain.label", "New"], env=_env(root))
295 result = runner.invoke(cli, ["config", "get", "domain.label"], env=_env(root), catch_exceptions=False)
296 assert result.exit_code == 0
297 assert "New" in result.output
298
299 def test_read_format_unknown_fails(self, tmp_path: pathlib.Path) -> None:
300 root, _ = _init_repo(tmp_path)
301 result = runner.invoke(cli, ["config", "read", "--format", "xml"], env=_env(root))
302 assert result.exit_code != 0
303
304
305 # ---------------------------------------------------------------------------
306 # E2E tests
307 # ---------------------------------------------------------------------------
308
309
310 class TestConfigE2E:
311 def test_full_agent_config_workflow(self, tmp_path: pathlib.Path) -> None:
312 """Agent sets hub and domain config, then reads it back as JSON."""
313 root, _ = _init_repo(tmp_path)
314 runner.invoke(cli, ["config", "set", "hub.url", "https://musehub.ai"], env=_env(root))
315 runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "960"], env=_env(root))
316
317 result = runner.invoke(cli, ["config", "read", "--json"], env=_env(root), catch_exceptions=False)
318 assert result.exit_code == 0
319 envelope = json.loads(result.output)
320 data = envelope.get("config", envelope)
321 assert data["hub"]["url"] == "https://musehub.ai"
322 assert data["domain"]["ticks_per_beat"] == "960"
323 assert "user" not in data
324
325 def test_config_persists_across_invocations(self, tmp_path: pathlib.Path) -> None:
326 """Config written in one invocation is readable in a subsequent one."""
327 root, _ = _init_repo(tmp_path)
328 runner.invoke(cli, ["config", "set", "domain.label", "persistent"], env=_env(root))
329 result = runner.invoke(cli, ["config", "get", "domain.label"], env=_env(root), catch_exceptions=False)
330 assert "persistent" in result.output
331
332
333 # ---------------------------------------------------------------------------
334 # Security tests
335 # ---------------------------------------------------------------------------
336
337
338 class TestConfigSecurity:
339 def test_toml_injection_in_domain_value_stored_safely(self, tmp_path: pathlib.Path) -> None:
340 """TOML injection chars in a domain value do not break the config file."""
341 root, _ = _init_repo(tmp_path)
342 injection = 'Alice"\n[injected]\nkey = "value'
343 result = runner.invoke(cli, ["config", "set", "domain.label", injection], env=_env(root))
344 # Should either fail safely or store the value escaped
345 if result.exit_code == 0:
346 runner.invoke(cli, ["config", "get", "domain.label"], env=_env(root))
347 # If stored, round-trip must be stable — no config file corruption
348 show_result = runner.invoke(cli, ["config", "read", "--json"], env=_env(root))
349 assert show_result.exit_code == 0
350 data = json.loads(show_result.output)
351 assert isinstance(data, dict)
352
353 def test_no_credentials_in_json_output(self, tmp_path: pathlib.Path) -> None:
354 """config read --json never leaks credentials even if they somehow end up in config.toml."""
355 root, _ = _init_repo(tmp_path)
356 # Manually inject a fake token into config.toml
357 config_toml_path(root).write_text('[auth]\ntoken = "super-secret"\n', encoding="utf-8")
358 result = runner.invoke(cli, ["config", "read", "--json"], env=_env(root), catch_exceptions=False)
359 assert result.exit_code == 0
360 assert "super-secret" not in result.output
361
362 def test_set_user_type_is_blocked(self, tmp_path: pathlib.Path) -> None:
363 """user.type writes are blocked — identity.toml owns this namespace."""
364 root, _ = _init_repo(tmp_path)
365 result = runner.invoke(cli, ["config", "set", "user.type", "robot"], env=_env(root))
366 assert result.exit_code != 0
367
368
369 # ---------------------------------------------------------------------------
370 # Stress tests
371 # ---------------------------------------------------------------------------
372
373
374 class TestConfigStress:
375 def test_many_domain_keys(self, tmp_path: pathlib.Path) -> None:
376 """Setting 50 domain keys all survive a JSON round-trip."""
377 root, _ = _init_repo(tmp_path)
378 keys = {f"domain.key_{i}": str(i) for i in range(50)}
379 for k, v in keys.items():
380 r = runner.invoke(cli, ["config", "set", k, v], env=_env(root))
381 assert r.exit_code == 0
382
383 result = runner.invoke(cli, ["config", "read", "--json"], env=_env(root), catch_exceptions=False)
384 assert result.exit_code == 0
385 envelope = json.loads(result.output)
386 data = envelope.get("config", envelope)
387 domain = data.get("domain", {})
388 for i in range(50):
389 assert domain.get(f"key_{i}") == str(i)
390
391 def test_overwrite_domain_key_many_times(self, tmp_path: pathlib.Path) -> None:
392 """Repeated writes to the same key keep only the latest value."""
393 root, _ = _init_repo(tmp_path)
394 for i in range(20):
395 runner.invoke(cli, ["config", "set", "domain.counter", str(i)], env=_env(root))
396 result = runner.invoke(cli, ["config", "get", "domain.counter"], env=_env(root), catch_exceptions=False)
397 assert result.exit_code == 0
398 assert "19" in result.output
File History 1 commit
sha256:51116ec824246acde6abf729e6ba854c223dc5173eff31a645520208023b0652 refactor(bridge): comprehensive spec sweep — close all issu… Sonnet 4.6 minor 28 days ago