gabriel / muse public
test_mist_cli.py python
747 lines 29.0 KB
Raw
sha256:f1f585ee9ca4e1ada936668c1b14f42f961a1fa78a2c033b643595f9c1bf9ac7 fixes for proposal flow Human patch 1 day 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 # M2 — run_create --sign path
668 def test_M2_run_create_sign_no_import_error(
669 self,
670 tmp_path: pathlib.Path,
671 monkeypatch: pytest.MonkeyPatch,
672 ) -> None:
673 """run_create with --sign must not crash with ImportError or TypeError.
674
675 Bugs before fix:
676 - load_identity() called without hub_url (TypeError)
677 - load_signing_identity imported from transport (ImportError — deleted)
678
679 The fix: replace the key_path/load_signing_identity block with
680 get_signing_identity() + sign_bytes(signing.private_key, content).
681 """
682 import unittest.mock as mock
683 from muse.core.transport import SigningIdentity
684 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
685
686 # Create a real file to sign
687 src = tmp_path / "song.py"
688 src.write_bytes(b"def play(): pass\n")
689
690 fake_key = Ed25519PrivateKey.generate()
691 fake_signing = SigningIdentity(handle="alice", private_key=fake_key)
692
693 with (
694 mock.patch("muse.cli.commands.mist.get_signing_identity", return_value=fake_signing, create=True),
695 ):
696 code, out, err = invoke_mist(["create", str(src), "--sign", "--json"])
697
698 # Must not crash; signed field may be True or False but no internal exception.
699 assert code == 0, f"Expected exit 0, got {code}:\n{err}"
700 data = json.loads(out)
701 assert "signed" in data
702
703 # M3 — run_raw signing path
704 def test_M3_run_raw_no_import_error_when_identity_present(
705 self,
706 tmp_path: pathlib.Path,
707 monkeypatch: pytest.MonkeyPatch,
708 ) -> None:
709 """run_raw must not crash with ImportError from the deleted load_signing_identity.
710
711 Bugs before fix:
712 - load_identity() called without hub_url inside _require_hub (TypeError)
713 - load_signing_identity imported from transport (ImportError — deleted)
714
715 The fix: replace the key_path/load_signing_identity block with
716 get_signing_identity(remote_url=server_root) + build_msign_header.
717 """
718 import argparse
719 import unittest.mock as mock
720 from muse.core.transport import SigningIdentity
721 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
722
723 fake_key = Ed25519PrivateKey.generate()
724 fake_signing = SigningIdentity(handle="alice", private_key=fake_key)
725 fake_identity = {"type": "human", "handle": "alice", "hd_path": "m/0'"}
726
727 # Fake raw bytes response (e.g. MIDI content)
728 fake_bytes = b"MThd\x00\x00\x00\x06"
729 fake_http_resp = mock.MagicMock()
730 fake_http_resp.__enter__ = lambda s: s
731 fake_http_resp.__exit__ = mock.MagicMock(return_value=False)
732 fake_http_resp.read.return_value = fake_bytes
733 fake_http_resp.headers = {"Content-Type": "audio/midi"}
734
735 with (
736 mock.patch("muse.cli.commands.mist.get_signing_identity", return_value=fake_signing, create=True),
737 mock.patch("muse.cli.commands.mist._require_hub", return_value=("https://musehub.ai", fake_identity)),
738 mock.patch("urllib.request.urlopen", return_value=fake_http_resp),
739 ):
740 from muse.cli.commands.mist import run_raw
741 args = argparse.Namespace(mist_id="alice/abc123", output=None, hub="https://musehub.ai")
742 try:
743 run_raw(args)
744 except SystemExit:
745 pass
746 except ImportError as e:
747 raise AssertionError(f"run_raw raised ImportError: {e}") from e
File History 3 commits
sha256:f1f585ee9ca4e1ada936668c1b14f42f961a1fa78a2c033b643595f9c1bf9ac7 fixes for proposal flow Human patch 1 day ago
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f chore: merge main — carry all urllib/typing/test fixes from dev Sonnet 4.6 minor 8 days ago
sha256:0bea7600d1eee83e87950be49933b1006fa9dc2c71e7c4ee748d324f61138156 chore: bump version to 0.2.0rc11; fix typing audit violatio… Sonnet 4.6 minor 8 days ago