gabriel / muse public
test_mist_cli.py python
985 lines 39.2 KB
Raw
sha256:7cc8abb049561fa05083f7bdf6b66bd5cf0466506a38600bc92ac3e6f4cf7b3a fix: muse mist push crashed unconditionally with AttributeE… Sonnet 5 patch 11 hours ago
1 """Tests for the ``muse mist`` CLI command — Phase 2.
2
3 Test tiers covered
4 ------------------
5 Tier 1 — Shape / API surface
6 ``muse mist`` is registered; all 7 subcommands present in --help;
7 all run_* functions importable; docstrings present.
8
9 Tier 2 — Round-trip (local, no MuseHub)
10 ``muse mist create`` reads a file, computes mist_id, returns JSON;
11 round-trip with multiple artifact types (Python, MIDI magic bytes, JSON).
12
13 Tier 3 — Edge cases
14 Empty file; file at exactly 10 MiB limit; unknown extension.
15 create with all optional flags set.
16
17 Tier 5 — Data integrity
18 mist_id in create output matches compute_mist_id of file content;
19 artifact_type matches detect_artifact_type;
20 symbol_anchors non-empty for Python, empty for binary;
21 validate_tag rejects invalid tags.
22
23 Tier 6 — Performance
24 create on a 1 MiB file under 200 ms.
25
26 Tier 7 — Security
27 create rejects filenames with path traversal, null bytes, ANSI escapes;
28 create rejects content > 10 MiB;
29 create rejects invalid visibility;
30 create rejects > 10 tags;
31 _validate_tag rejects XSS, null bytes, HTML specials.
32
33 Tier 8 — Docstrings
34 All public symbols in mist.py carry non-empty docstrings.
35 """
36
37 from __future__ import annotations
38
39 import json
40 import os
41 import pathlib
42 import sys
43 import time
44 import types
45
46 import pytest
47
48
49 # ---------------------------------------------------------------------------
50 # Helpers
51 # ---------------------------------------------------------------------------
52
53
54 def invoke_mist(args: list[str]) -> tuple[int, str, str]:
55 """Run ``muse mist <args>`` in-process and capture stdout/stderr.
56
57 Returns:
58 A tuple of (exit_code, stdout, stderr).
59 """
60 from io import StringIO
61
62 old_stdout, old_stderr = sys.stdout, sys.stderr
63 sys.stdout = out = StringIO()
64 sys.stderr = err = StringIO()
65 exit_code = 0
66 try:
67 from muse.cli.app import main
68
69 main(["mist"] + args)
70 except SystemExit as exc:
71 exit_code = int(exc.code) if exc.code is not None else 0
72 finally:
73 sys.stdout = old_stdout
74 sys.stderr = old_stderr
75 return exit_code, out.getvalue(), err.getvalue()
76
77
78 @pytest.fixture()
79 def python_file(tmp_path: pathlib.Path) -> pathlib.Path:
80 """A valid Python source file for mist create tests."""
81 f = tmp_path / "compute.py"
82 f.write_bytes(
83 b"def compute(x: int) -> int:\n"
84 b' """Double the input."""\n'
85 b" return x * 2\n"
86 )
87 return f
88
89
90 @pytest.fixture()
91 def midi_file(tmp_path: pathlib.Path) -> pathlib.Path:
92 """A minimal MIDI file (MThd magic bytes) for mist create tests."""
93 f = tmp_path / "motif.mid"
94 # Minimal MIDI header: MThd + header length (6) + format (1) + tracks (1) + division
95 f.write_bytes(b"MThd\x00\x00\x00\x06\x00\x01\x00\x01\x01\xe0")
96 return f
97
98
99 @pytest.fixture()
100 def empty_file(tmp_path: pathlib.Path) -> pathlib.Path:
101 """An empty file."""
102 f = tmp_path / "empty.txt"
103 f.write_bytes(b"")
104 return f
105
106
107 @pytest.fixture()
108 def large_file(tmp_path: pathlib.Path) -> pathlib.Path:
109 """A 1 MiB file for performance tests."""
110 f = tmp_path / "large.py"
111 f.write_bytes(b"# comment\n" * 104858) # ~1 MiB
112 return f
113
114
115 # ---------------------------------------------------------------------------
116 # Tier 1 — Shape / API surface
117 # ---------------------------------------------------------------------------
118
119
120 class TestMistCliShape:
121 """Verify muse mist is registered and all subcommands are present."""
122
123 SUBCOMMANDS = ("create", "list", "read", "fork", "push", "embed", "delete")
124 RUN_FUNCS = ("run_create", "run_list", "run_read", "run_fork", "run_push", "run_embed", "run_delete")
125
126 def test_mist_help_exits_0(self) -> None:
127 code, out, _ = invoke_mist(["--help"])
128 assert code == 0
129 assert "mist" in out.lower()
130
131 def test_all_subcommands_in_help(self) -> None:
132 code, out, _ = invoke_mist(["--help"])
133 assert code == 0
134 for sub in self.SUBCOMMANDS:
135 assert sub in out, f"Subcommand {sub!r} missing from muse mist --help"
136
137 @pytest.mark.parametrize("sub", SUBCOMMANDS)
138 def test_subcommand_help_exits_0(self, sub: str) -> None:
139 code, out, _ = invoke_mist([sub, "--help"])
140 assert code == 0
141 assert sub in out.lower()
142
143 @pytest.mark.parametrize("func_name", RUN_FUNCS)
144 def test_run_functions_importable(self, func_name: str) -> None:
145 import muse.cli.commands.mist as mod
146
147 assert hasattr(mod, func_name), f"{func_name} missing from muse.cli.commands.mist"
148 assert callable(getattr(mod, func_name))
149
150 def test_register_function_importable(self) -> None:
151 from muse.cli.commands.mist import register
152
153 assert callable(register)
154
155 def test_validate_tag_importable(self) -> None:
156 from muse.cli.commands.mist import _validate_tag
157
158 assert callable(_validate_tag)
159
160 def test_mist_registered_in_app(self) -> None:
161 """muse mist appears in the top-level command list."""
162 code, out, _ = invoke_mist(["--help"])
163 # Just verify we can invoke the mist namespace (exit 0 from --help)
164 assert code == 0
165
166 def test_create_json_flag_present(self) -> None:
167 code, out, _ = invoke_mist(["create", "--help"])
168 assert "--json" in out
169
170 def test_create_push_flag_present(self) -> None:
171 code, out, _ = invoke_mist(["create", "--help"])
172 assert "--push" in out
173
174 def test_create_sign_flag_present(self) -> None:
175 code, out, _ = invoke_mist(["create", "--help"])
176 assert "--sign" in out
177
178 def test_create_visibility_flag_present(self) -> None:
179 code, out, _ = invoke_mist(["create", "--help"])
180 assert "--visibility" in out
181
182
183 # ---------------------------------------------------------------------------
184 # Tier 2 — Round-trip (local, no MuseHub)
185 # ---------------------------------------------------------------------------
186
187
188 class TestMistCreateRoundTrip:
189 """End-to-end round-trip for muse mist create (no MuseHub required)."""
190
191 def test_create_python_file_exits_0(self, python_file: pathlib.Path) -> None:
192 code, out, err = invoke_mist(["create", str(python_file), "--json"])
193 assert code == 0, f"Unexpected exit {code}: stderr={err}"
194
195 def test_create_python_file_returns_json(self, python_file: pathlib.Path) -> None:
196 _, out, _ = invoke_mist(["create", str(python_file), "--json"])
197 data = json.loads(out)
198 assert "mist_id" in data
199 assert "artifact_type" in data
200 assert "filename" in data
201 assert "size_bytes" in data
202
203 def test_create_python_mist_id_is_12_chars(self, python_file: pathlib.Path) -> None:
204 _, out, _ = invoke_mist(["create", str(python_file), "--json"])
205 data = json.loads(out)
206 assert len(data["mist_id"]) == 12
207
208 def test_create_python_artifact_type_is_code(self, python_file: pathlib.Path) -> None:
209 _, out, _ = invoke_mist(["create", str(python_file), "--json"])
210 data = json.loads(out)
211 assert data["artifact_type"] == "code"
212 assert data["language"] == "python"
213
214 def test_create_python_symbol_anchors_non_empty(self, python_file: pathlib.Path) -> None:
215 _, out, _ = invoke_mist(["create", str(python_file), "--json"])
216 data = json.loads(out)
217 assert isinstance(data["symbol_anchors"], list)
218 assert len(data["symbol_anchors"]) > 0
219
220 def test_create_midi_file_exits_0(self, midi_file: pathlib.Path) -> None:
221 code, out, err = invoke_mist(["create", str(midi_file), "--json"])
222 assert code == 0, f"Unexpected exit {code}: stderr={err}"
223
224 def test_create_midi_artifact_type_is_midi(self, midi_file: pathlib.Path) -> None:
225 _, out, _ = invoke_mist(["create", str(midi_file), "--json"])
226 data = json.loads(out)
227 assert data["artifact_type"] == "midi"
228
229 def test_create_midi_symbol_anchors_empty(self, midi_file: pathlib.Path) -> None:
230 _, out, _ = invoke_mist(["create", str(midi_file), "--json"])
231 data = json.loads(out)
232 assert data["symbol_anchors"] == []
233
234 def test_create_size_bytes_correct(self, python_file: pathlib.Path) -> None:
235 expected = python_file.read_bytes()
236 _, out, _ = invoke_mist(["create", str(python_file), "--json"])
237 data = json.loads(out)
238 assert data["size_bytes"] == len(expected)
239
240 def test_create_filename_correct(self, python_file: pathlib.Path) -> None:
241 _, out, _ = invoke_mist(["create", str(python_file), "--json"])
242 data = json.loads(out)
243 assert data["filename"] == python_file.name
244
245 def test_create_no_url_without_push(self, python_file: pathlib.Path) -> None:
246 _, out, _ = invoke_mist(["create", str(python_file), "--json"])
247 data = json.loads(out)
248 assert data["url"] == ""
249
250 def test_create_human_readable_output(self, python_file: pathlib.Path) -> None:
251 code, out, _ = invoke_mist(["create", str(python_file)])
252 assert code == 0
253 assert "✅" in out
254 assert "Mist created" in out
255
256 def test_create_with_title_flag(self, python_file: pathlib.Path) -> None:
257 """--title flag is accepted and does not crash."""
258 code, _, _ = invoke_mist(["create", str(python_file), "--title", "My test mist"])
259 assert code == 0
260
261 def test_create_with_description_flag(self, python_file: pathlib.Path) -> None:
262 code, _, _ = invoke_mist(["create", str(python_file), "--description", "A test."])
263 assert code == 0
264
265 def test_create_with_tag_flag(self, python_file: pathlib.Path) -> None:
266 code, _, _ = invoke_mist(["create", str(python_file), "--tag", "security", "--tag", "utils"])
267 assert code == 0
268
269 def test_create_with_agent_flags(self, python_file: pathlib.Path) -> None:
270 code, out, _ = invoke_mist([
271 "create", str(python_file),
272 "--agent-id", "cccode-v3",
273 "--model-id", "claude-sonnet-4-6",
274 "--json",
275 ])
276 assert code == 0
277 data = json.loads(out)
278 assert data["agent_id"] == "cccode-v3"
279 assert data["model_id"] == "claude-sonnet-4-6"
280
281 def test_create_with_secret_visibility(self, python_file: pathlib.Path) -> None:
282 code, _, _ = invoke_mist(["create", str(python_file), "--visibility", "secret"])
283 assert code == 0
284
285
286 # ---------------------------------------------------------------------------
287 # Tier 3 — Edge cases
288 # ---------------------------------------------------------------------------
289
290
291 class TestMistCreateEdgeCases:
292 """Boundary and unusual-input tests for muse mist create."""
293
294 def test_create_empty_file_exits_0(self, empty_file: pathlib.Path) -> None:
295 code, out, err = invoke_mist(["create", str(empty_file), "--json"])
296 assert code == 0, f"stderr: {err}"
297 data = json.loads(out)
298 assert data["size_bytes"] == 0
299 assert len(data["mist_id"]) == 12
300
301 def test_create_empty_file_symbol_anchors_empty(self, empty_file: pathlib.Path) -> None:
302 _, out, _ = invoke_mist(["create", str(empty_file), "--json"])
303 data = json.loads(out)
304 assert data["symbol_anchors"] == []
305
306 def test_create_unknown_extension(self, tmp_path: pathlib.Path) -> None:
307 f = tmp_path / "data.xyzzy"
308 f.write_bytes(b"\xde\xad\xbe\xef" * 10)
309 code, out, _ = invoke_mist(["create", str(f), "--json"])
310 assert code == 0
311 data = json.loads(out)
312 assert data["artifact_type"] == "unknown"
313
314 def test_create_json_file_abi(self, tmp_path: pathlib.Path) -> None:
315 f = tmp_path / "contract.json"
316 f.write_bytes(json.dumps([{"type": "function", "name": "transfer"}]).encode())
317 _, out, _ = invoke_mist(["create", str(f), "--json"])
318 data = json.loads(out)
319 assert data["artifact_type"] == "abi"
320
321 def test_create_json_file_schema(self, tmp_path: pathlib.Path) -> None:
322 f = tmp_path / "schema.json"
323 f.write_bytes(json.dumps({"$schema": "http://json-schema.org/draft-07/schema#"}).encode())
324 _, out, _ = invoke_mist(["create", str(f), "--json"])
325 data = json.loads(out)
326 assert data["artifact_type"] == "json_schema"
327
328 def test_create_markdown_file(self, tmp_path: pathlib.Path) -> None:
329 f = tmp_path / "README.md"
330 f.write_bytes(b"# Hello\n\nWorld\n")
331 _, out, _ = invoke_mist(["create", str(f), "--json"])
332 data = json.loads(out)
333 assert data["artifact_type"] == "code"
334 assert data["language"] == "markdown"
335
336 def test_create_solidity_file(self, tmp_path: pathlib.Path) -> None:
337 f = tmp_path / "Token.sol"
338 f.write_bytes(b"// SPDX-License-Identifier: MIT\ncontract Token {}\n")
339 _, out, _ = invoke_mist(["create", str(f), "--json"])
340 data = json.loads(out)
341 assert data["artifact_type"] == "code"
342 assert data["language"] == "solidity"
343
344 def test_create_ten_tags_accepted(self, python_file: pathlib.Path) -> None:
345 tags = [f"--tag tag{i}" for i in range(10)]
346 flat: list[str] = ["create", str(python_file)]
347 for i in range(10):
348 flat += ["--tag", f"tag{i}"]
349 code, _, _ = invoke_mist(flat)
350 assert code == 0
351
352 def test_create_different_content_different_mist_id(self, tmp_path: pathlib.Path) -> None:
353 f1 = tmp_path / "a.py"
354 f1.write_bytes(b"x = 1")
355 f2 = tmp_path / "b.py"
356 f2.write_bytes(b"x = 2")
357 _, out1, _ = invoke_mist(["create", str(f1), "--json"])
358 _, out2, _ = invoke_mist(["create", str(f2), "--json"])
359 id1 = json.loads(out1)["mist_id"]
360 id2 = json.loads(out2)["mist_id"]
361 assert id1 != id2
362
363 def test_create_same_content_same_mist_id(self, tmp_path: pathlib.Path) -> None:
364 """Content-addressed: same bytes always yield the same mist_id."""
365 content = b"def stable(): return True\n"
366 f1 = tmp_path / "f1.py"
367 f1.write_bytes(content)
368 f2 = tmp_path / "f2.py"
369 f2.write_bytes(content)
370 _, out1, _ = invoke_mist(["create", str(f1), "--json"])
371 _, out2, _ = invoke_mist(["create", str(f2), "--json"])
372 assert json.loads(out1)["mist_id"] == json.loads(out2)["mist_id"]
373
374
375 # ---------------------------------------------------------------------------
376 # Tier 5 — Data integrity
377 # ---------------------------------------------------------------------------
378
379
380 class TestMistCliDataIntegrity:
381 """Verify correctness of computed fields in CLI output."""
382
383 def test_mist_id_matches_compute_mist_id(self, python_file: pathlib.Path) -> None:
384 from muse.plugins.mist.plugin import compute_mist_id
385
386 content = python_file.read_bytes()
387 expected_id = compute_mist_id(content)
388 _, out, _ = invoke_mist(["create", str(python_file), "--json"])
389 data = json.loads(out)
390 assert data["mist_id"] == expected_id
391
392 def test_artifact_type_matches_detect(self, python_file: pathlib.Path) -> None:
393 from muse.plugins.mist.plugin import detect_artifact_type
394
395 content = python_file.read_bytes()
396 expected = detect_artifact_type(python_file.name, content)
397 _, out, _ = invoke_mist(["create", str(python_file), "--json"])
398 data = json.loads(out)
399 assert data["artifact_type"] == expected["artifact_type"]
400 assert data["language"] == expected["language"]
401
402 def test_size_bytes_matches_actual(self, python_file: pathlib.Path) -> None:
403 content = python_file.read_bytes()
404 _, out, _ = invoke_mist(["create", str(python_file), "--json"])
405 data = json.loads(out)
406 assert data["size_bytes"] == len(content)
407
408 def test_signed_false_without_sign_flag(self, python_file: pathlib.Path) -> None:
409 _, out, _ = invoke_mist(["create", str(python_file), "--json"])
410 data = json.loads(out)
411 assert data["signed"] is False
412
413 def test_symbol_anchors_list_type(self, python_file: pathlib.Path) -> None:
414 _, out, _ = invoke_mist(["create", str(python_file), "--json"])
415 data = json.loads(out)
416 assert isinstance(data["symbol_anchors"], list)
417
418 def test_symbol_anchors_contain_function_name(self, python_file: pathlib.Path) -> None:
419 _, out, _ = invoke_mist(["create", str(python_file), "--json"])
420 data = json.loads(out)
421 anchors = data["symbol_anchors"]
422 assert any("compute" in a for a in anchors), f"No 'compute' in {anchors}"
423
424 def test_mist_id_base58_only(self, python_file: pathlib.Path) -> None:
425 from muse.plugins.mist.plugin import _BASE58_ALPHABET
426
427 _, out, _ = invoke_mist(["create", str(python_file), "--json"])
428 mist_id = json.loads(out)["mist_id"]
429 for ch in mist_id:
430 assert ch in _BASE58_ALPHABET, f"Non-base58 char {ch!r} in mist_id"
431
432 def test_validate_tag_rejects_xss(self) -> None:
433 from muse.cli.commands.mist import _validate_tag
434
435 with pytest.raises(ValueError, match="HTML"):
436 _validate_tag("<script>alert(1)</script>")
437
438 def test_validate_tag_rejects_null_byte(self) -> None:
439 from muse.cli.commands.mist import _validate_tag
440
441 with pytest.raises(ValueError, match="null byte"):
442 _validate_tag("tag\x00")
443
444 def test_validate_tag_rejects_too_long(self) -> None:
445 from muse.cli.commands.mist import _validate_tag
446
447 with pytest.raises(ValueError, match="limit"):
448 _validate_tag("t" * 65)
449
450 def test_validate_tag_rejects_control_char(self) -> None:
451 from muse.cli.commands.mist import _validate_tag
452
453 with pytest.raises(ValueError, match="control char"):
454 _validate_tag("tag\x01")
455
456 def test_validate_tag_accepts_normal_tag(self) -> None:
457 from muse.cli.commands.mist import _validate_tag
458
459 _validate_tag("security") # must not raise
460 _validate_tag("erc-8004")
461 _validate_tag("midi-motif")
462
463
464 # ---------------------------------------------------------------------------
465 # Tier 6 — Performance
466 # ---------------------------------------------------------------------------
467
468
469 class TestMistCliPerformance:
470 """Timing constraints for the CLI's hot paths."""
471
472 def test_create_1mb_file_under_200ms(self, large_file: pathlib.Path) -> None:
473 start = time.perf_counter()
474 code, out, err = invoke_mist(["create", str(large_file), "--json"])
475 elapsed = time.perf_counter() - start
476 assert code == 0, f"stderr: {err}"
477 assert elapsed < 0.200, f"create took {elapsed:.3f}s on 1 MiB file"
478
479 def test_create_deterministic_ids_10_calls_under_500ms(self, python_file: pathlib.Path) -> None:
480 start = time.perf_counter()
481 ids = set()
482 for _ in range(10):
483 _, out, _ = invoke_mist(["create", str(python_file), "--json"])
484 ids.add(json.loads(out)["mist_id"])
485 elapsed = time.perf_counter() - start
486 assert len(ids) == 1, "Same file should always yield same mist_id"
487 assert elapsed < 0.500, f"10 create calls took {elapsed:.3f}s"
488
489
490 # ---------------------------------------------------------------------------
491 # Tier 7 — Security
492 # ---------------------------------------------------------------------------
493
494
495 class TestMistCliSecurity:
496 """Input sanitisation and rejection of malicious inputs."""
497
498 @pytest.mark.parametrize("filename,expected_exit", [
499 ("../../../etc/passwd", 1),
500 ("file\x00hidden.py", 1),
501 ("..", 1),
502 ("a" * 256, 1),
503 ])
504 def test_invalid_filename_rejected(
505 self,
506 tmp_path: pathlib.Path,
507 filename: str,
508 expected_exit: int,
509 ) -> None:
510 """validate_mist_filename rejects attack vectors before creating."""
511 # We can't easily test invalid filenames via CLI since the filesystem
512 # won't let us create files with these names. Test the validator directly.
513 from muse.plugins.mist.plugin import validate_mist_filename
514
515 with pytest.raises(ValueError):
516 validate_mist_filename(filename)
517
518 def test_content_over_10mb_rejected(self, tmp_path: pathlib.Path) -> None:
519 f = tmp_path / "huge.py"
520 f.write_bytes(b"x" * (10 * 1024 * 1024 + 1))
521 code, _, err = invoke_mist(["create", str(f), "--json"])
522 assert code != 0
523 assert "10 MiB" in err or "limit" in err.lower()
524
525 def test_invalid_visibility_rejected(self, python_file: pathlib.Path) -> None:
526 code, _, err = invoke_mist(["create", str(python_file), "--visibility", "everyone"])
527 assert code != 0
528 assert "visibility" in err.lower() or "invalid" in err.lower()
529
530 def test_too_many_tags_rejected(self, python_file: pathlib.Path) -> None:
531 flat: list[str] = ["create", str(python_file)]
532 for i in range(11):
533 flat += ["--tag", f"tag{i}"]
534 code, _, err = invoke_mist(flat)
535 assert code != 0
536 assert "tag" in err.lower()
537
538 def test_file_not_found_exits_nonzero(self) -> None:
539 code, _, err = invoke_mist(["create", "/nonexistent/path/file.py", "--json"])
540 assert code != 0
541 assert "not found" in err.lower() or "no such" in err.lower()
542
543 def test_mist_id_not_sequential_for_sequential_content(self) -> None:
544 from muse.plugins.mist.plugin import compute_mist_id
545
546 ids = [compute_mist_id(bytes([i])) for i in range(10)]
547 # IDs should not be lexicographically sequential
548 assert ids != sorted(ids), "Mist IDs should not be predictably ordered"
549
550 def test_xss_tag_rejected_via_validate_tag(self) -> None:
551 from muse.cli.commands.mist import _validate_tag
552
553 for payload in ("<img onerror=alert(1)>", "<script>", '"inject"', "' OR 1=1"):
554 if any(c in payload for c in "<>\"'&"):
555 with pytest.raises(ValueError):
556 _validate_tag(payload)
557
558 def test_mist_id_collision_resistance_1000_payloads(self) -> None:
559 from muse.plugins.mist.plugin import compute_mist_id
560
561 ids = {compute_mist_id(f"payload-{i:04d}".encode()) for i in range(1000)}
562 assert len(ids) == 1000, "Expected no collisions across 1000 distinct payloads"
563
564
565 # ---------------------------------------------------------------------------
566 # Tier 8 — Docstrings
567 # ---------------------------------------------------------------------------
568
569
570 class TestMistCliDocstrings:
571 """Every public symbol in mist.py must carry a non-empty docstring."""
572
573 PUBLIC_FUNCS = (
574 "run_create",
575 "run_list",
576 "run_read",
577 "run_fork",
578 "run_push",
579 "run_embed",
580 "run_delete",
581 "register",
582 "_validate_tag",
583 "_require_hub",
584 "_hub_api",
585 "_get_hub_url",
586 )
587
588 def test_module_docstring(self) -> None:
589 import muse.cli.commands.mist as mod
590
591 assert mod.__doc__ and len(mod.__doc__.strip()) > 20
592
593 @pytest.mark.parametrize("func_name", PUBLIC_FUNCS)
594 def test_function_has_docstring(self, func_name: str) -> None:
595 import muse.cli.commands.mist as mod
596
597 fn = getattr(mod, func_name, None)
598 assert fn is not None, f"{func_name} not found in module"
599 assert fn.__doc__ and len(fn.__doc__.strip()) > 0, (
600 f"{func_name} is missing a docstring"
601 )
602
603 def test_register_docstring_lists_all_subcommands(self) -> None:
604 from muse.cli.commands.mist import register
605
606 doc = register.__doc__ or ""
607 for sub in ("create", "list", "read", "fork", "push", "embed", "delete"):
608 assert sub in doc, f"register() docstring missing subcommand: {sub}"
609
610
611 # ---------------------------------------------------------------------------
612 # PEM-load site migration — TDD tests (M1–M3)
613 #
614 # These tests confirm that the three PEM-load sites in mist.py have been
615 # migrated away from the deleted `load_signing_identity` function to the
616 # current `get_signing_identity` + `build_msign_header` path.
617 #
618 # Before the fix: ImportError — "cannot import name 'load_signing_identity'
619 # from 'muse.core.transport'" crashes all three call sites.
620 # After the fix: no ImportError; signing identity resolved via keychain.
621 # ---------------------------------------------------------------------------
622
623
624 class TestMistPemLoadSites:
625 """M-series: PEM-load site migration tests for mist.py."""
626
627 # M1 — _hub_api signing path
628 def test_M1_hub_api_no_import_error_when_signing_identity_present(
629 self,
630 tmp_path: pathlib.Path,
631 monkeypatch: pytest.MonkeyPatch,
632 ) -> None:
633 """_hub_api must not crash with ImportError from the deleted load_signing_identity.
634
635 The fix: replace the key_path/load_signing_identity block with
636 get_signing_identity(remote_url=server_root) + build_msign_header.
637 """
638 import io
639 import json as _json
640 import unittest.mock as mock
641 from muse.core.transport import SigningIdentity
642 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
643
644 fake_key = Ed25519PrivateKey.generate()
645 fake_signing = SigningIdentity(handle="alice", private_key=fake_key)
646
647 # Fake JSON response via _urllib_do (transport now uses urllib)
648 fake_response_data = _json.dumps({"ok": True}).encode()
649
650 from muse.core.identity import IdentityEntry
651 identity: IdentityEntry = {"type": "human", "handle": "alice"}
652
653 with (
654 mock.patch("muse.cli.config.get_signing_identity", return_value=fake_signing),
655 mock.patch("muse.core.transport._urllib_do", return_value=fake_response_data),
656 mock.patch("muse.core.hub_trust.check_and_pin"),
657 ):
658 from muse.cli.commands.mist import _hub_api
659 result = _hub_api(
660 "https://musehub.ai",
661 identity,
662 "GET",
663 "/api/test",
664 )
665 assert result == {"ok": True}
666
667 # M1b — _hub_api must pass through a top-level JSON array unchanged
668 def test_M1b_hub_api_passes_through_list_response(
669 self,
670 tmp_path: pathlib.Path,
671 monkeypatch: pytest.MonkeyPatch,
672 ) -> None:
673 """musehub#213: GET /api/mists/{id}/forks returns a top-level JSON
674 array. _hub_api (via HttpTransport.hub_json) must return it as-is,
675 not silently coerce it to {}."""
676 import json as _json
677 import unittest.mock as mock
678
679 from muse.core.identity import IdentityEntry
680
681 fake_forks = [
682 {"mistId": "Hq4kz66XdNnR", "owner": "gabriel", "forkParentId": "Es8ujVEEAd2c"},
683 ]
684 fake_response_data = _json.dumps(fake_forks).encode()
685 identity: IdentityEntry = {"type": "human", "handle": "alice"}
686
687 with (
688 mock.patch("muse.cli.config.get_signing_identity", return_value=None),
689 mock.patch("muse.core.transport._urllib_do", return_value=fake_response_data),
690 mock.patch("muse.core.hub_trust.check_and_pin"),
691 ):
692 from muse.cli.commands.mist import _hub_api
693 result = _hub_api(
694 "https://musehub.ai",
695 identity,
696 "GET",
697 "/api/mists/gabriel/Es8ujVEEAd2c/forks",
698 )
699 assert result == fake_forks
700
701 # M1c — run_forks --json renders the real array, not an empty object
702 def test_M1c_run_forks_json_renders_real_array(
703 self,
704 tmp_path: pathlib.Path,
705 monkeypatch: pytest.MonkeyPatch,
706 capsys: pytest.CaptureFixture[str],
707 ) -> None:
708 """musehub#213 end-to-end: `muse mist forks ... --json` must print
709 the real fork list, not `{}`, when the server returns a genuine
710 top-level JSON array."""
711 import argparse
712 import json as _json
713 import unittest.mock as mock
714
715 fake_forks = [
716 {"mistId": "Hq4kz66XdNnR", "owner": "gabriel", "forkParentId": "Es8ujVEEAd2c"},
717 ]
718
719 from muse.cli.commands.mist import run_forks
720
721 args = argparse.Namespace(
722 mist_id="gabriel/Es8ujVEEAd2c",
723 limit=20,
724 hub=None,
725 json_output=True,
726 )
727 with (
728 mock.patch(
729 "muse.cli.commands.mist._require_hub",
730 return_value=("https://musehub.ai", {"type": "human", "handle": "alice"}),
731 ),
732 mock.patch("muse.cli.commands.mist._hub_api", return_value=fake_forks),
733 ):
734 run_forks(args)
735 out = capsys.readouterr().out
736 assert _json.loads(out.strip()) == fake_forks
737
738 # M1d — mist push delegates to push.py with a complete, drift-proof namespace
739 def test_M1d_run_push_does_not_crash_on_missing_namespace_attrs(
740 self,
741 tmp_path: pathlib.Path,
742 monkeypatch: pytest.MonkeyPatch,
743 ) -> None:
744 """musehub#212: `muse mist push` built a bare SimpleNamespace with
745 only 4 fields (remote, branch, force, json_output) and handed it to
746 push.py's run(), which reads args.json_out (not json_output),
747 args.branch_pos, and args.set_upstream_flag directly -- none ever
748 set. Reproduces the documented manual workflow end-to-end exactly
749 as the ticket describes it; must not raise AttributeError."""
750 from unittest.mock import patch
751 from tests.test_cmd_push_hardening import _FakeTransport
752 from muse.cli.app import main as cli_main
753
754 monkeypatch.chdir(tmp_path)
755 cli_main(["init", "--domain", "mist"])
756 (tmp_path / "motif.txt").write_text("a simple melodic motif\n")
757 cli_main(["commit", "-m", "add motif", "--sign"])
758
759 t = _FakeTransport()
760 with (
761 patch("muse.cli.commands.push.get_remote", return_value="https://hub.example.com/r"),
762 patch("muse.cli.commands.push.get_signing_identity", return_value=None),
763 patch("muse.cli.commands.push.make_transport", return_value=t),
764 ):
765 exit_code, out, err = invoke_mist(["push", "--remote", "local", "--branch", "main"])
766 assert exit_code == 0, f"mist push crashed: {err}"
767 assert "AttributeError" not in err
768
769 # M2 — run_create --sign path
770 def test_M2_run_create_sign_no_import_error(
771 self,
772 tmp_path: pathlib.Path,
773 monkeypatch: pytest.MonkeyPatch,
774 ) -> None:
775 """run_create with --sign must not crash with ImportError or TypeError.
776
777 Bugs before fix:
778 - load_identity() called without hub_url (TypeError)
779 - load_signing_identity imported from transport (ImportError — deleted)
780
781 The fix: replace the key_path/load_signing_identity block with
782 get_signing_identity() + sign_bytes(signing.private_key, content).
783 """
784 import unittest.mock as mock
785 from muse.core.transport import SigningIdentity
786 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
787
788 # Create a real file to sign
789 src = tmp_path / "song.py"
790 src.write_bytes(b"def play(): pass\n")
791
792 fake_key = Ed25519PrivateKey.generate()
793 fake_signing = SigningIdentity(handle="alice", private_key=fake_key)
794
795 with (
796 mock.patch("muse.cli.commands.mist.get_signing_identity", return_value=fake_signing, create=True),
797 ):
798 code, out, err = invoke_mist(["create", str(src), "--sign", "--json"])
799
800 # Must not crash; signed field may be True or False but no internal exception.
801 assert code == 0, f"Expected exit 0, got {code}:\n{err}"
802 data = json.loads(out)
803 assert "signed" in data
804
805 # M3 — run_raw signing path
806 def test_M3_run_raw_no_import_error_when_identity_present(
807 self,
808 tmp_path: pathlib.Path,
809 monkeypatch: pytest.MonkeyPatch,
810 ) -> None:
811 """run_raw must not crash with ImportError from the deleted load_signing_identity.
812
813 Bugs before fix:
814 - load_identity() called without hub_url inside _require_hub (TypeError)
815 - load_signing_identity imported from transport (ImportError — deleted)
816
817 The fix: replace the key_path/load_signing_identity block with
818 get_signing_identity(remote_url=server_root) + build_msign_header.
819 """
820 import argparse
821 import unittest.mock as mock
822 from muse.core.transport import SigningIdentity
823 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
824
825 fake_key = Ed25519PrivateKey.generate()
826 fake_signing = SigningIdentity(handle="alice", private_key=fake_key)
827 fake_identity = {"type": "human", "handle": "alice", "hd_path": "m/0'"}
828
829 # Fake raw bytes response (e.g. MIDI content)
830 fake_bytes = b"MThd\x00\x00\x00\x06"
831 fake_http_resp = mock.MagicMock()
832 fake_http_resp.__enter__ = lambda s: s
833 fake_http_resp.__exit__ = mock.MagicMock(return_value=False)
834 fake_http_resp.read.return_value = fake_bytes
835 fake_http_resp.headers = {"Content-Type": "audio/midi"}
836
837 with (
838 mock.patch("muse.cli.commands.mist.get_signing_identity", return_value=fake_signing, create=True),
839 mock.patch("muse.cli.commands.mist._require_hub", return_value=("https://musehub.ai", fake_identity)),
840 mock.patch("urllib.request.urlopen", return_value=fake_http_resp),
841 ):
842 from muse.cli.commands.mist import run_raw
843 args = argparse.Namespace(mist_id="alice/abc123", output=None, hub="https://musehub.ai")
844 try:
845 run_raw(args)
846 except SystemExit:
847 pass
848 except ImportError as e:
849 raise AssertionError(f"run_raw raised ImportError: {e}") from e
850
851
852 class TestMistSignHubContext:
853 """MISTSIGN — ``--sign`` must thread hub context through, not silently no-op.
854
855 Regression coverage for issue #75: ``run_create``'s signing block called
856 ``get_signing_identity()`` with zero arguments, so it could only resolve an
857 identity when the caller's cwd happened to have a repo-local hub config —
858 the ``--hub`` flag (already used correctly for ``--push``) was never
859 threaded into the signing path at all. Unlike ``test_M2_run_create_sign_
860 no_import_error`` (which mocks ``get_signing_identity`` unconditionally and
861 so never observes what arguments the real call site passes), these tests
862 exercise the real resolution path or assert on the actual call arguments.
863 """
864
865 def test_MISTSIGN_01_sign_succeeds_via_hub_flag_outside_repo_config(
866 self,
867 monkeypatch: pytest.MonkeyPatch,
868 tmp_path: pathlib.Path,
869 ) -> None:
870 """--sign --hub <url> must sign even when cwd has no local hub config.
871
872 Before the fix: ``get_signing_identity()`` (no ``remote_url``) can only
873 resolve via a repo-local ``.muse/config.toml`` — from a bare directory
874 it returns ``None`` and signing silently no-ops. After the fix,
875 ``--hub`` is threaded through, so signing succeeds regardless of cwd.
876 """
877 from muse.core.types import long_id
878 from tests.cli_test_helper import CliRunner
879
880 runner = CliRunner()
881 hub = "https://staging.musehub.ai"
882
883 # Real, keychain-backed identity for this hub (the autouse
884 # _isolate_muse_home fixture already isolates ~/.muse and the OS
885 # keychain, so this never touches gabriel's real identity). keygen
886 # alone saves handle="" — register is what sets a real handle, and
887 # resolve_signing_identity requires a non-empty handle, so both must
888 # run (register's network calls are mocked, matching
889 # test_cmd_auth_phase5.py's _run_keygen_and_register pattern).
890 result = runner.invoke(None, ["auth", "keygen", "--hub", hub])
891 assert result.exit_code == 0, f"keygen failed: {result.output}"
892
893 monkeypatch.setattr(
894 "muse.cli.commands.auth._post_challenge",
895 lambda *a, **kw: {"challenge_token": "ab" * 32, "is_new_key": True},
896 )
897 monkeypatch.setattr(
898 "muse.cli.commands.auth._post_verify",
899 lambda *a, **kw: {
900 "handle": "gabriel",
901 "identity_id": long_id("a" * 64),
902 "is_new_identity": True,
903 },
904 )
905 result = runner.invoke(None, ["auth", "register", "--hub", hub, "--handle", "gabriel"])
906 assert result.exit_code == 0, f"register failed: {result.output}"
907
908 # A bare directory with NO .muse/config.toml — the exact condition
909 # that triggered the bug live.
910 workdir = tmp_path / "bare-dir"
911 workdir.mkdir()
912 src = workdir / "song.py"
913 src.write_bytes(b"def play(): pass\n")
914 monkeypatch.chdir(workdir)
915
916 code, out, err = invoke_mist(["create", str(src), "--sign", "--hub", hub, "--json"])
917 assert code == 0, f"Expected exit 0, got {code}:\n{err}"
918 data = json.loads(out)
919 assert data["signed"] is True, f"Expected signed=True, got {data}. stderr={err}"
920
921 def test_MISTSIGN_02_get_signing_identity_called_with_resolved_hub(
922 self,
923 monkeypatch: pytest.MonkeyPatch,
924 tmp_path: pathlib.Path,
925 ) -> None:
926 """get_signing_identity must be called with remote_url=<resolved hub>.
927
928 Asserts the actual call arguments, not just that signing eventually
929 succeeds — this is what ``test_M2_...`` should have asserted and
930 didn't, which is exactly how this bug shipped unnoticed. Covers both
931 --sign-only and --sign+--push, which must resolve to the same hub_url.
932 """
933 import unittest.mock as mock
934 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
935 from muse.core.transport import SigningIdentity
936
937 hub = "https://staging.musehub.ai"
938 fake_key = Ed25519PrivateKey.generate()
939 fake_signing = SigningIdentity(handle="alice", private_key=fake_key)
940
941 workdir = tmp_path / "bare-dir"
942 workdir.mkdir()
943 src = workdir / "song.py"
944 src.write_bytes(b"def play(): pass\n")
945 monkeypatch.chdir(workdir)
946
947 # Patch at the source module — mist.py's signing block does a local
948 # `from muse.cli.config import get_signing_identity` at call time, so
949 # patching muse.cli.commands.mist.get_signing_identity (as test_M2
950 # does) has no effect on what actually gets called.
951 with mock.patch(
952 "muse.cli.config.get_signing_identity", return_value=fake_signing
953 ) as mocked:
954 code, out, err = invoke_mist(["create", str(src), "--sign", "--hub", hub, "--json"])
955
956 assert code == 0, f"Expected exit 0, got {code}:\n{err}"
957 mocked.assert_called_once()
958 _, kwargs = mocked.call_args
959 assert kwargs.get("remote_url") == hub, (
960 f"Expected get_signing_identity(remote_url={hub!r}), got call: {mocked.call_args}"
961 )
962
963 def test_MISTSIGN_03_no_hub_context_warns_instead_of_silent(
964 self,
965 monkeypatch: pytest.MonkeyPatch,
966 tmp_path: pathlib.Path,
967 ) -> None:
968 """--sign with no --hub, no repo-local config, and no --push must warn.
969
970 Today this case silently returns signed=false with no explanation.
971 After the fix, the CLI must print a clear, actionable warning to
972 stderr so the caller knows *why* signing didn't happen.
973 """
974 workdir = tmp_path / "bare-dir"
975 workdir.mkdir()
976 src = workdir / "song.py"
977 src.write_bytes(b"def play(): pass\n")
978 monkeypatch.chdir(workdir)
979
980 code, out, err = invoke_mist(["create", str(src), "--sign", "--json"])
981 assert code == 0, f"Expected exit 0, got {code}:\n{err}"
982 data = json.loads(out)
983 assert data["signed"] is False
984 assert err.strip() != "", "Expected a warning on stderr, got silence"
985 assert "sign" in err.lower(), f"Expected a signing-related warning, got: {err!r}"
File History 1 commit
sha256:7cc8abb049561fa05083f7bdf6b66bd5cf0466506a38600bc92ac3e6f4cf7b3a fix: muse mist push crashed unconditionally with AttributeE… Sonnet 5 patch 11 hours ago