gabriel / muse public
test_mist_cli.py python
883 lines 35.1 KB
Raw
sha256:f1f585ee9ca4e1ada936668c1b14f42f961a1fa78a2c033b643595f9c1bf9ac7 fixes for proposal flow Human patch 40 days 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
748
749
750 class TestMistSignHubContext:
751 """MISTSIGN — ``--sign`` must thread hub context through, not silently no-op.
752
753 Regression coverage for issue #75: ``run_create``'s signing block called
754 ``get_signing_identity()`` with zero arguments, so it could only resolve an
755 identity when the caller's cwd happened to have a repo-local hub config —
756 the ``--hub`` flag (already used correctly for ``--push``) was never
757 threaded into the signing path at all. Unlike ``test_M2_run_create_sign_
758 no_import_error`` (which mocks ``get_signing_identity`` unconditionally and
759 so never observes what arguments the real call site passes), these tests
760 exercise the real resolution path or assert on the actual call arguments.
761 """
762
763 def test_MISTSIGN_01_sign_succeeds_via_hub_flag_outside_repo_config(
764 self,
765 monkeypatch: pytest.MonkeyPatch,
766 tmp_path: pathlib.Path,
767 ) -> None:
768 """--sign --hub <url> must sign even when cwd has no local hub config.
769
770 Before the fix: ``get_signing_identity()`` (no ``remote_url``) can only
771 resolve via a repo-local ``.muse/config.toml`` — from a bare directory
772 it returns ``None`` and signing silently no-ops. After the fix,
773 ``--hub`` is threaded through, so signing succeeds regardless of cwd.
774 """
775 from muse.core.types import long_id
776 from tests.cli_test_helper import CliRunner
777
778 runner = CliRunner()
779 hub = "https://staging.musehub.ai"
780
781 # Real, keychain-backed identity for this hub (the autouse
782 # _isolate_muse_home fixture already isolates ~/.muse and the OS
783 # keychain, so this never touches gabriel's real identity). keygen
784 # alone saves handle="" — register is what sets a real handle, and
785 # resolve_signing_identity requires a non-empty handle, so both must
786 # run (register's network calls are mocked, matching
787 # test_cmd_auth_phase5.py's _run_keygen_and_register pattern).
788 result = runner.invoke(None, ["auth", "keygen", "--hub", hub])
789 assert result.exit_code == 0, f"keygen failed: {result.output}"
790
791 monkeypatch.setattr(
792 "muse.cli.commands.auth._post_challenge",
793 lambda *a, **kw: {"challenge_token": "ab" * 32, "is_new_key": True},
794 )
795 monkeypatch.setattr(
796 "muse.cli.commands.auth._post_verify",
797 lambda *a, **kw: {
798 "handle": "gabriel",
799 "identity_id": long_id("a" * 64),
800 "is_new_identity": True,
801 },
802 )
803 result = runner.invoke(None, ["auth", "register", "--hub", hub, "--handle", "gabriel"])
804 assert result.exit_code == 0, f"register failed: {result.output}"
805
806 # A bare directory with NO .muse/config.toml — the exact condition
807 # that triggered the bug live.
808 workdir = tmp_path / "bare-dir"
809 workdir.mkdir()
810 src = workdir / "song.py"
811 src.write_bytes(b"def play(): pass\n")
812 monkeypatch.chdir(workdir)
813
814 code, out, err = invoke_mist(["create", str(src), "--sign", "--hub", hub, "--json"])
815 assert code == 0, f"Expected exit 0, got {code}:\n{err}"
816 data = json.loads(out)
817 assert data["signed"] is True, f"Expected signed=True, got {data}. stderr={err}"
818
819 def test_MISTSIGN_02_get_signing_identity_called_with_resolved_hub(
820 self,
821 monkeypatch: pytest.MonkeyPatch,
822 tmp_path: pathlib.Path,
823 ) -> None:
824 """get_signing_identity must be called with remote_url=<resolved hub>.
825
826 Asserts the actual call arguments, not just that signing eventually
827 succeeds — this is what ``test_M2_...`` should have asserted and
828 didn't, which is exactly how this bug shipped unnoticed. Covers both
829 --sign-only and --sign+--push, which must resolve to the same hub_url.
830 """
831 import unittest.mock as mock
832 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
833 from muse.core.transport import SigningIdentity
834
835 hub = "https://staging.musehub.ai"
836 fake_key = Ed25519PrivateKey.generate()
837 fake_signing = SigningIdentity(handle="alice", private_key=fake_key)
838
839 workdir = tmp_path / "bare-dir"
840 workdir.mkdir()
841 src = workdir / "song.py"
842 src.write_bytes(b"def play(): pass\n")
843 monkeypatch.chdir(workdir)
844
845 # Patch at the source module — mist.py's signing block does a local
846 # `from muse.cli.config import get_signing_identity` at call time, so
847 # patching muse.cli.commands.mist.get_signing_identity (as test_M2
848 # does) has no effect on what actually gets called.
849 with mock.patch(
850 "muse.cli.config.get_signing_identity", return_value=fake_signing
851 ) as mocked:
852 code, out, err = invoke_mist(["create", str(src), "--sign", "--hub", hub, "--json"])
853
854 assert code == 0, f"Expected exit 0, got {code}:\n{err}"
855 mocked.assert_called_once()
856 _, kwargs = mocked.call_args
857 assert kwargs.get("remote_url") == hub, (
858 f"Expected get_signing_identity(remote_url={hub!r}), got call: {mocked.call_args}"
859 )
860
861 def test_MISTSIGN_03_no_hub_context_warns_instead_of_silent(
862 self,
863 monkeypatch: pytest.MonkeyPatch,
864 tmp_path: pathlib.Path,
865 ) -> None:
866 """--sign with no --hub, no repo-local config, and no --push must warn.
867
868 Today this case silently returns signed=false with no explanation.
869 After the fix, the CLI must print a clear, actionable warning to
870 stderr so the caller knows *why* signing didn't happen.
871 """
872 workdir = tmp_path / "bare-dir"
873 workdir.mkdir()
874 src = workdir / "song.py"
875 src.write_bytes(b"def play(): pass\n")
876 monkeypatch.chdir(workdir)
877
878 code, out, err = invoke_mist(["create", str(src), "--sign", "--json"])
879 assert code == 0, f"Expected exit 0, got {code}:\n{err}"
880 data = json.loads(out)
881 assert data["signed"] is False
882 assert err.strip() != "", "Expected a warning on stderr, got silence"
883 assert "sign" in err.lower(), f"Expected a signing-related warning, got: {err!r}"
File History 3 commits
sha256:f1f585ee9ca4e1ada936668c1b14f42f961a1fa78a2c033b643595f9c1bf9ac7 fixes for proposal flow Human patch 40 days ago
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f chore: merge main — carry all urllib/typing/test fixes from dev Sonnet 4.6 minor 47 days ago
sha256:0bea7600d1eee83e87950be49933b1006fa9dc2c71e7c4ee748d324f61138156 chore: bump version to 0.2.0rc11; fix typing audit violatio… Sonnet 4.6 minor 47 days ago