gabriel / muse public
test_cmd_domains_hardening.py python
1,985 lines 82.0 KB
Raw
1 """Comprehensive hardening tests for ``muse domains``.
2
3 Covers:
4 Unit tests:
5 - _validate_domain_name: valid, path-traversal, reserved, bad chars
6 - _validate_publish_url: http/https OK, file/ftp/data rejected
7 - _active_domain: no root, missing repo.json, explicit domain, missing key
8 - _build_entry: schema present, schema absent, active flag boolean
9 - _post_json: correct wire format, response size cap, non-object JSON
10 - _check_method / _run_validate_plugin: all checks pass, missing method
11
12 Integration tests:
13 - run (dashboard): text output, --json, --new validation + success
14 - run_info: known domain, unknown domain, --json schema
15 - run_use: no repo, unknown domain, known domain switches repo.json, --json
16 - run_validate: all-pass, missing method, --json, multi-domain
17
18 Security tests:
19 - Path traversal in --new rejected
20 - ANSI in domain name sanitized in dashboard
21 - file:// publish hub rejected
22 - Unsanitized server response never echoed raw
23
24 E2E tests (full CLI invocation via CliRunner):
25 - muse domains --json emits boolean 'active' field
26 - muse domains --json includes module_path
27 - muse domains info code --json schema present
28 - muse domains validate --json ok
29 - muse domains use code --json inside repo
30 - muse domains --new myplug creates directory
31
32 Stress tests:
33 - 8 concurrent validate calls on isolated registry snapshots
34 - 8 concurrent _active_domain reads on isolated tmp dirs
35 """
36 from __future__ import annotations
37
38 import argparse
39 import http.client
40 import json
41 import pathlib
42 import shutil
43 import threading
44 import urllib.error
45 import urllib.request
46 from contextlib import ExitStack
47 from typing import TYPE_CHECKING
48 from unittest.mock import MagicMock, patch
49
50 import pytest
51
52 from muse.cli.commands.domains import (
53 _DomainEntryJson,
54 _PublishResponse,
55 _ScaffoldJson,
56 _UseJson,
57 _ValidateJson,
58 )
59 from muse.domain import (
60 DriftReport,
61 LiveState,
62 MergeResult,
63 StateSnapshot,
64 StateDelta,
65 SnapshotManifest,
66 )
67 from muse.core.schema import DomainSchema
68 from muse.core.paths import muse_dir, repo_json_path
69 from tests.cli_test_helper import CliRunner, InvokeResult
70
71 if TYPE_CHECKING:
72 from muse.cli.commands.domains import _PublishPayload, _Capabilities, _DimensionDef
73 from muse.core.transport import SigningIdentity
74
75 cli = None # argparse migration — CliRunner ignores this argument
76
77 runner = CliRunner()
78
79 # ---------------------------------------------------------------------------
80 # JSON helpers — each returns a specific TypedDict to satisfy typing_audit
81 # ---------------------------------------------------------------------------
82
83
84 def _first_json_blob(result: InvokeResult) -> str:
85 """Return the first complete JSON blob string from result.output."""
86 output = result.output
87 depth = 0
88 start: int | None = None
89 for i, ch in enumerate(output):
90 if ch in "{[":
91 if start is None:
92 start = i
93 depth += 1
94 elif ch in "}]":
95 depth -= 1
96 if depth == 0 and start is not None:
97 return output[start : i + 1]
98 raise AssertionError(f"No JSON found in output:\n{output!r}")
99
100
101 def _parse_domains_list(result: InvokeResult) -> list[_DomainEntryJson]:
102 """Parse a JSON array of domain entries from result."""
103 parsed = json.loads(_first_json_blob(result))
104 if isinstance(parsed, dict) and "domains" in parsed:
105 parsed = parsed["domains"]
106 assert isinstance(parsed, list)
107 return parsed
108
109
110 def _parse_domain_entry(result: InvokeResult) -> _DomainEntryJson:
111 """Parse a single domain entry JSON dict from result."""
112 parsed: _DomainEntryJson = json.loads(_first_json_blob(result))
113 assert isinstance(parsed, dict)
114 return parsed
115
116
117 def _parse_scaffold(result: InvokeResult) -> _ScaffoldJson:
118 parsed: _ScaffoldJson = json.loads(_first_json_blob(result))
119 assert isinstance(parsed, dict)
120 return parsed
121
122
123 def _parse_use(result: InvokeResult) -> _UseJson:
124 parsed: _UseJson = json.loads(_first_json_blob(result))
125 assert isinstance(parsed, dict)
126 return parsed
127
128
129 def _parse_validate(result: InvokeResult) -> _ValidateJson:
130 parsed: _ValidateJson = json.loads(_first_json_blob(result))
131 assert isinstance(parsed, dict)
132 return parsed
133
134
135 def _parse_validate_list(result: InvokeResult) -> list[_ValidateJson]:
136 parsed = json.loads(_first_json_blob(result))
137 if isinstance(parsed, dict) and "results" in parsed:
138 parsed = parsed["results"]
139 assert isinstance(parsed, list)
140 return parsed
141
142
143 def _parse_publish(result: InvokeResult) -> _PublishResponse:
144 parsed: _PublishResponse = json.loads(_first_json_blob(result))
145 assert isinstance(parsed, dict)
146 return parsed
147
148
149 # ---------------------------------------------------------------------------
150 # Repository fixture
151 # ---------------------------------------------------------------------------
152
153
154 def _init_repo(tmp_path: pathlib.Path, domain: str = "code") -> pathlib.Path:
155 """Create a minimal .muse repo structure under tmp_path."""
156 muse = muse_dir(tmp_path)
157 muse.mkdir(parents=True)
158 repo_json = {
159 "repo_id": "test-repo",
160 "schema_version": "0.1.5",
161 "created_at": "2026-01-01T00:00:00+00:00",
162 "domain": domain,
163 }
164 (muse / "repo.json").write_text(json.dumps(repo_json), encoding="utf-8")
165 return tmp_path
166
167
168 # ---------------------------------------------------------------------------
169 # Unit — _validate_domain_name
170 # ---------------------------------------------------------------------------
171
172
173 class TestValidateDomainName:
174 def _call(self, name: str) -> int:
175 from muse.cli.commands.domains import _validate_domain_name
176
177 with pytest.raises(SystemExit) as exc_info:
178 _validate_domain_name(name)
179 code = exc_info.value.code
180 assert isinstance(code, int)
181 return code
182
183 def test_valid_lowercase(self) -> None:
184 from muse.cli.commands.domains import _validate_domain_name
185
186 _validate_domain_name("genomics") # must not raise
187
188 def test_valid_with_hyphen(self) -> None:
189 from muse.cli.commands.domains import _validate_domain_name
190
191 _validate_domain_name("spatial-3d")
192
193 def test_valid_with_underscore(self) -> None:
194 from muse.cli.commands.domains import _validate_domain_name
195
196 _validate_domain_name("my_domain")
197
198 def test_path_traversal_dotdot_rejected(self) -> None:
199 assert self._call("../traversal") == 1
200
201 def test_path_traversal_nested_rejected(self) -> None:
202 assert self._call("../../etc/passwd") == 1
203
204 def test_uppercase_rejected(self) -> None:
205 assert self._call("Genomics") == 1
206
207 def test_starts_with_digit_rejected(self) -> None:
208 assert self._call("3d-scenes") == 1
209
210 def test_slash_rejected(self) -> None:
211 assert self._call("malicious/path") == 1
212
213 def test_null_byte_rejected(self) -> None:
214 assert self._call("malicious\x00name") == 1
215
216 def test_space_rejected(self) -> None:
217 assert self._call("my domain") == 1
218
219 def test_reserved_scaffold_rejected(self) -> None:
220 assert self._call("scaffold") == 1
221
222 def test_max_length_64_accepted(self) -> None:
223 from muse.cli.commands.domains import _validate_domain_name
224
225 _validate_domain_name("a" * 64)
226
227 def test_max_length_65_rejected(self) -> None:
228 assert self._call("a" * 65) == 1
229
230 def test_empty_rejected(self) -> None:
231 assert self._call("") == 1
232
233
234 # ---------------------------------------------------------------------------
235 # Unit — _validate_publish_url
236 # ---------------------------------------------------------------------------
237
238
239 class TestValidatePublishUrl:
240 def _call(self, url: str) -> int:
241 from muse.cli.commands.domains import _validate_publish_url
242
243 with pytest.raises(SystemExit) as exc_info:
244 _validate_publish_url(url)
245 code = exc_info.value.code
246 assert isinstance(code, int)
247 return code
248
249 def test_https_ok(self) -> None:
250 from muse.cli.commands.domains import _validate_publish_url
251
252 _validate_publish_url("https://musehub.ai") # must not raise
253
254 def test_http_ok(self) -> None:
255 from muse.cli.commands.domains import _validate_publish_url
256
257 _validate_publish_url("https://localhost:1337")
258
259 def test_file_scheme_rejected(self) -> None:
260 assert self._call("file:///etc/passwd") == 1
261
262 def test_ftp_scheme_rejected(self) -> None:
263 assert self._call("ftp://attacker.example.com") == 1
264
265 def test_data_uri_rejected(self) -> None:
266 assert self._call("data:text/plain,malicious") == 1
267
268 def test_empty_scheme_rejected(self) -> None:
269 assert self._call("://malicious") == 1
270
271
272 # ---------------------------------------------------------------------------
273 # Unit — _active_domain
274 # ---------------------------------------------------------------------------
275
276
277 class TestActiveDomain:
278 def test_none_root_returns_none(self) -> None:
279 from muse.cli.commands.domains import _active_domain
280
281 assert _active_domain(None) is None
282
283 def test_missing_repo_json_returns_none(self, tmp_path: pathlib.Path) -> None:
284 from muse.cli.commands.domains import _active_domain
285
286 muse_dir(tmp_path).mkdir()
287 assert _active_domain(tmp_path) is None
288
289 def test_explicit_domain_returned(self, tmp_path: pathlib.Path) -> None:
290 from muse.cli.commands.domains import _active_domain
291
292 _init_repo(tmp_path, domain="code")
293 assert _active_domain(tmp_path) == "code"
294
295 def test_missing_domain_key_returns_default(self, tmp_path: pathlib.Path) -> None:
296 from muse.cli.commands.domains import _active_domain, _DEFAULT_DOMAIN
297
298 muse = muse_dir(tmp_path)
299 muse.mkdir()
300 (muse / "repo.json").write_text('{"repo_id": "x"}', encoding="utf-8")
301 assert _active_domain(tmp_path) == _DEFAULT_DOMAIN
302
303 def test_corrupt_json_returns_none(self, tmp_path: pathlib.Path) -> None:
304 from muse.cli.commands.domains import _active_domain
305
306 muse = muse_dir(tmp_path)
307 muse.mkdir()
308 (muse / "repo.json").write_text("NOT JSON", encoding="utf-8")
309 assert _active_domain(tmp_path) is None
310
311 def test_no_midi_fallback(self, tmp_path: pathlib.Path) -> None:
312 """The old 'midi' fallback is gone — default is _DEFAULT_DOMAIN ('code')."""
313 from muse.cli.commands.domains import _active_domain
314
315 muse = muse_dir(tmp_path)
316 muse.mkdir()
317 (muse / "repo.json").write_text('{"domain": ""}', encoding="utf-8")
318 result = _active_domain(tmp_path)
319 assert result != "midi"
320
321
322 # ---------------------------------------------------------------------------
323 # Unit — _build_entry
324 # ---------------------------------------------------------------------------
325
326
327 class TestBuildEntry:
328 def test_schema_present(self) -> None:
329 from muse.cli.commands.domains import _build_entry
330 from muse.plugins.registry import _REGISTRY
331
332 plugin = _REGISTRY["code"]
333 entry = _build_entry("code", plugin, "code")
334 assert entry["domain"] == "code"
335 assert entry["active"] is True
336 assert isinstance(entry["active"], bool)
337 assert "schema" in entry
338 schema = entry["schema"]
339 assert "schema_version" in schema
340 assert "dimensions" in schema
341
342 def test_schema_absent_when_not_implemented(self) -> None:
343 from muse.cli.commands.domains import _build_entry
344
345 mock_plugin = MagicMock()
346 mock_plugin.schema.side_effect = NotImplementedError
347 mock_plugin.__class__ = type("FakeDomain", (), {})
348 entry = _build_entry("fake", mock_plugin, None)
349 assert "schema" not in entry
350 assert entry["active"] is False
351
352 def test_module_path_included(self) -> None:
353 from muse.cli.commands.domains import _build_entry
354 from muse.plugins.registry import _REGISTRY
355
356 plugin = _REGISTRY["scaffold"]
357 entry = _build_entry("scaffold", plugin, None)
358 assert entry["module_path"] == "plugins/scaffold/plugin.py"
359
360 def test_active_false_for_inactive(self) -> None:
361 from muse.cli.commands.domains import _build_entry
362 from muse.plugins.registry import _REGISTRY
363
364 plugin = _REGISTRY["code"]
365 entry = _build_entry("code", plugin, "scaffold")
366 assert entry["active"] is False
367
368 def test_capabilities_list_of_strings(self) -> None:
369 from muse.cli.commands.domains import _build_entry
370 from muse.plugins.registry import _REGISTRY
371
372 plugin = _REGISTRY["code"]
373 entry = _build_entry("code", plugin, None)
374 caps = entry["capabilities"]
375 assert isinstance(caps, list)
376 assert all(isinstance(c, str) for c in caps)
377 assert "Typed Deltas" in caps
378
379
380 # ---------------------------------------------------------------------------
381 # Unit — _post_json
382 # ---------------------------------------------------------------------------
383
384
385 def _make_caps_payload() -> "_PublishPayload":
386 from muse.cli.commands.domains import _PublishPayload, _Capabilities, _DimensionDef
387
388 caps = _Capabilities(
389 dimensions=[_DimensionDef(name="notes", description="Note events")],
390 artifact_types=["mid"],
391 merge_semantics="three_way",
392 supported_commands=["commit"],
393 )
394 return _PublishPayload(
395 author_slug="user",
396 slug="music",
397 display_name="Music",
398 description="Desc",
399 capabilities=caps,
400 viewer_type="midi",
401 version="0.1.0",
402 )
403
404
405 def _make_signing() -> "SigningIdentity":
406 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
407 from muse.core.transport import SigningIdentity
408 return SigningIdentity(handle="testuser", private_key=Ed25519PrivateKey.generate())
409
410
411 class TestPostJson:
412 def test_correct_method_and_headers(self) -> None:
413 from muse.cli.commands.domains import _post_json
414
415 captured: list[urllib.request.Request] = []
416
417 def fake_urlopen(req: urllib.request.Request, timeout: float) -> MagicMock:
418 captured.append(req)
419 resp = MagicMock()
420 resp.read = MagicMock(return_value=b'{"domain_id":"1","scoped_id":"@u/s","manifest_hash":"abc"}')
421 resp.__enter__ = lambda s: s
422 resp.__exit__ = MagicMock(return_value=False)
423 return resp
424
425 with patch("urllib.request.urlopen", fake_urlopen):
426 result = _post_json("https://musehub.ai/api/v1/domains", _make_caps_payload(), _make_signing())
427
428 assert len(captured) == 1
429 req = captured[0]
430 assert req.get_method() == "POST"
431 assert req.get_header("Content-type") == "application/json"
432 assert req.get_header("Authorization").startswith("MSign ")
433 assert result["scoped_id"] == "@u/s"
434
435 def test_response_size_cap_applied(self) -> None:
436 """resp.read() is called with _MAX_RESPONSE_BYTES, not unlimited."""
437 from muse.cli.commands.domains import _post_json, _MAX_RESPONSE_BYTES
438
439 read_args: list[int] = []
440
441 def fake_urlopen(req: urllib.request.Request, timeout: float) -> MagicMock:
442 resp = MagicMock()
443
444 def _read(n: int) -> bytes:
445 read_args.append(n)
446 return b'{"domain_id":"1","scoped_id":"@u/s","manifest_hash":""}'
447
448 resp.read = _read
449 resp.__enter__ = lambda s: s
450 resp.__exit__ = MagicMock(return_value=False)
451 return resp
452
453 with patch("urllib.request.urlopen", fake_urlopen):
454 _post_json("https://musehub.ai/api/v1/domains", _make_caps_payload(), _make_signing())
455
456 assert read_args == [_MAX_RESPONSE_BYTES]
457
458 def test_non_object_json_raises_value_error(self) -> None:
459 from muse.cli.commands.domains import _post_json
460
461 def fake_urlopen(req: urllib.request.Request, timeout: float) -> MagicMock:
462 resp = MagicMock()
463 resp.read = MagicMock(return_value=b"[1,2,3]")
464 resp.__enter__ = lambda s: s
465 resp.__exit__ = MagicMock(return_value=False)
466 return resp
467
468 with patch("urllib.request.urlopen", fake_urlopen):
469 with pytest.raises(ValueError, match="Expected JSON object"):
470 _post_json("https://musehub.ai/api/v1/domains", _make_caps_payload(), _make_signing())
471
472 def test_missing_keys_normalised_to_empty_string(self) -> None:
473 from muse.cli.commands.domains import _post_json
474
475 def fake_urlopen(req: urllib.request.Request, timeout: float) -> MagicMock:
476 resp = MagicMock()
477 resp.read = MagicMock(return_value=b"{}")
478 resp.__enter__ = lambda s: s
479 resp.__exit__ = MagicMock(return_value=False)
480 return resp
481
482 with patch("urllib.request.urlopen", fake_urlopen):
483 result = _post_json("https://musehub.ai/api/v1/domains", _make_caps_payload(), _make_signing())
484
485 assert result["domain_id"] == ""
486 assert result["scoped_id"] == ""
487 assert result["manifest_hash"] == ""
488
489
490 # ---------------------------------------------------------------------------
491 # Unit — _run_validate_plugin
492 # ---------------------------------------------------------------------------
493
494
495 class _MinimalPlugin:
496 """Stub implementing all required MuseDomainPlugin methods; schema raises NotImplementedError."""
497
498 def snapshot(self, live_state: LiveState) -> StateSnapshot:
499 raise NotImplementedError
500
501 def diff(
502 self,
503 base: StateSnapshot,
504 target: StateSnapshot,
505 *,
506 repo_root: pathlib.Path | None = None,
507 ) -> StateDelta:
508 raise NotImplementedError
509
510 def merge(
511 self,
512 base: StateSnapshot,
513 left: StateSnapshot,
514 right: StateSnapshot,
515 *,
516 repo_root: pathlib.Path | None = None,
517 ) -> MergeResult:
518 raise NotImplementedError
519
520 def drift(self, committed: StateSnapshot, live: LiveState) -> DriftReport:
521 raise NotImplementedError
522
523 def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState:
524 raise NotImplementedError
525
526 def schema(self) -> DomainSchema:
527 raise NotImplementedError
528
529
530 class TestRunValidatePlugin:
531 def test_all_checks_pass_for_code_plugin(self) -> None:
532 from muse.cli.commands.domains import _run_validate_plugin
533 from muse.plugins.registry import _REGISTRY
534
535 result = _run_validate_plugin("code", _REGISTRY["code"], None)
536 assert result["ok"] is True
537 assert result["domain"] == "code"
538 names = [c["name"] for c in result["checks"]]
539 assert "has_method:snapshot" in names
540 assert "schema()" in names
541
542 def test_schema_not_implemented_flagged(self) -> None:
543 """A plugin that has all methods but raises NotImplementedError for schema()
544 must have the schema check fail while all method checks pass."""
545 from muse.cli.commands.domains import _run_validate_plugin
546
547 result = _run_validate_plugin("min", _MinimalPlugin(), None)
548 schema_check = next(c for c in result["checks"] if c["name"] == "schema()")
549 assert schema_check["ok"] is False
550 # All protocol method checks should pass since methods exist and are callable
551 method_checks = [c for c in result["checks"] if c["name"].startswith("has_method:")]
552 assert all(c["ok"] for c in method_checks)
553
554 def test_scaffold_plugin_all_pass(self) -> None:
555 from muse.cli.commands.domains import _run_validate_plugin
556 from muse.plugins.registry import _REGISTRY
557
558 result = _run_validate_plugin("scaffold", _REGISTRY["scaffold"], None)
559 assert result["ok"] is True, [c for c in result["checks"] if not c["ok"]]
560
561
562 # ---------------------------------------------------------------------------
563 # Integration — muse domains (dashboard + --json)
564 # ---------------------------------------------------------------------------
565
566
567 class TestDomainsDashboard:
568 def test_default_text_output_has_registered_domains(self) -> None:
569 result = runner.invoke(cli, ["domains"])
570 assert result.exit_code == 0
571 assert "Registered domains:" in result.output
572
573 def test_json_flag_emits_list(self) -> None:
574 result = runner.invoke(cli, ["domains", "--json"])
575 assert result.exit_code == 0
576 domains = _parse_domains_list(result)
577 assert len(domains) >= 1
578
579 def test_json_active_field_is_boolean(self) -> None:
580 result = runner.invoke(cli, ["domains", "--json"])
581 assert result.exit_code == 0
582 for entry in _parse_domains_list(result):
583 assert isinstance(entry.get("active"), bool), (
584 f"'active' must be bool, got {type(entry.get('active')).__name__}"
585 )
586
587 def test_json_module_path_present(self) -> None:
588 result = runner.invoke(cli, ["domains", "--json"])
589 assert result.exit_code == 0
590 for entry in _parse_domains_list(result):
591 assert "module_path" in entry, f"missing 'module_path' in {entry}"
592
593 def test_json_schema_present_for_code(self) -> None:
594 result = runner.invoke(cli, ["domains", "--json"])
595 assert result.exit_code == 0
596 code_entry = next(e for e in _parse_domains_list(result) if e.get("domain") == "code")
597 assert "schema" in code_entry
598 schema = code_entry["schema"]
599 assert "dimensions" in schema
600
601 def test_json_no_string_true_false(self) -> None:
602 """Agents must never see 'active': 'true' — only 'active': true."""
603 result = runner.invoke(cli, ["domains", "--json"])
604 raw = result.output
605 assert '"active": "true"' not in raw
606 assert '"active": "false"' not in raw
607
608 def test_text_has_muse_domains_new_hint(self) -> None:
609 result = runner.invoke(cli, ["domains"])
610 assert "muse domains --new" in result.output
611
612 def test_text_has_info_hint(self) -> None:
613 result = runner.invoke(cli, ["domains"])
614 assert "muse domains info" in result.output
615
616 def test_text_has_validate_hint(self) -> None:
617 result = runner.invoke(cli, ["domains"])
618 assert "muse domains validate" in result.output
619
620
621 # ---------------------------------------------------------------------------
622 # Integration — muse domains --new
623 # ---------------------------------------------------------------------------
624
625
626 class TestDomainsNew:
627 def test_valid_name_creates_directory(self) -> None:
628 plugins_dir = pathlib.Path(__file__).parents[1] / "muse" / "plugins"
629 dest = plugins_dir / "testdomain9"
630 try:
631 result = runner.invoke(cli, ["domains", "--new", "testdomain9"])
632 assert result.exit_code == 0, result.output
633 assert dest.exists()
634 assert (dest / "plugin.py").exists()
635 finally:
636 if dest.exists():
637 shutil.rmtree(str(dest))
638
639 def test_valid_name_json_flag_emits_scaffold_json(self) -> None:
640 plugins_dir = pathlib.Path(__file__).parents[1] / "muse" / "plugins"
641 dest = plugins_dir / "testdomain10"
642 try:
643 result = runner.invoke(cli, ["domains", "--new", "testdomain10", "--json"])
644 assert result.exit_code == 0, result.output
645 data = _parse_scaffold(result)
646 assert data["name"] == "testdomain10"
647 assert data["status"] == "ok"
648 assert "class_name" in data
649 assert "path" in data
650 finally:
651 if dest.exists():
652 shutil.rmtree(str(dest))
653
654 def test_path_traversal_rejected(self) -> None:
655 result = runner.invoke(cli, ["domains", "--new", "../traversal"])
656 assert result.exit_code != 0
657
658 def test_uppercase_name_rejected(self) -> None:
659 result = runner.invoke(cli, ["domains", "--new", "Genomics"])
660 assert result.exit_code != 0
661
662 def test_scaffold_reserved_rejected(self) -> None:
663 result = runner.invoke(cli, ["domains", "--new", "scaffold"])
664 assert result.exit_code != 0
665
666 def test_duplicate_name_rejected(self) -> None:
667 result = runner.invoke(cli, ["domains", "--new", "code"])
668 assert result.exit_code != 0
669
670 def test_no_pycache_in_created_directory(self) -> None:
671 plugins_dir = pathlib.Path(__file__).parents[1] / "muse" / "plugins"
672 dest = plugins_dir / "testdomain11"
673 try:
674 result = runner.invoke(cli, ["domains", "--new", "testdomain11"])
675 assert result.exit_code == 0
676 pycache = dest / "__pycache__"
677 assert not pycache.exists(), "__pycache__ must not be copied"
678 finally:
679 if dest.exists():
680 shutil.rmtree(str(dest))
681
682 def test_class_name_substituted(self) -> None:
683 plugins_dir = pathlib.Path(__file__).parents[1] / "muse" / "plugins"
684 dest = plugins_dir / "testdomain12"
685 try:
686 runner.invoke(cli, ["domains", "--new", "testdomain12"])
687 plugin_src = (dest / "plugin.py").read_text(encoding="utf-8")
688 assert "Testdomain12Plugin" in plugin_src
689 assert "ScaffoldPlugin" not in plugin_src
690 finally:
691 if dest.exists():
692 shutil.rmtree(str(dest))
693
694
695 # ---------------------------------------------------------------------------
696 # Integration — muse domains info
697 # ---------------------------------------------------------------------------
698
699
700 class TestDomainsInfo:
701 def test_known_domain_exits_zero(self) -> None:
702 result = runner.invoke(cli, ["domains", "info", "code"])
703 assert result.exit_code == 0
704
705 def test_unknown_domain_exits_nonzero(self) -> None:
706 result = runner.invoke(cli, ["domains", "info", "doesnotexist"])
707 assert result.exit_code != 0
708
709 def test_json_schema_has_required_keys(self) -> None:
710 result = runner.invoke(cli, ["domains", "info", "code", "--json"])
711 assert result.exit_code == 0
712 data = _parse_domain_entry(result)
713 assert data["domain"] == "code"
714 assert isinstance(data["active"], bool)
715 assert "capabilities" in data
716 assert "module_path" in data
717
718 def test_scaffold_has_crdt_capability(self) -> None:
719 result = runner.invoke(cli, ["domains", "info", "scaffold", "--json"])
720 assert result.exit_code == 0
721 data = _parse_domain_entry(result)
722 assert "CRDT" in data["capabilities"]
723
724 def test_text_output_includes_module_path(self) -> None:
725 result = runner.invoke(cli, ["domains", "info", "code"])
726 assert "Module:" in result.output
727 assert "plugins/code/plugin.py" in result.output
728
729 def test_error_on_unknown_domain(self) -> None:
730 result = runner.invoke(cli, ["domains", "info", "ghost"])
731 assert result.exit_code != 0
732 assert "not registered" in result.stderr
733
734
735 # ---------------------------------------------------------------------------
736 # Integration — muse domains use
737 # ---------------------------------------------------------------------------
738
739
740 class TestDomainsUse:
741 def test_no_repo_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
742 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
743 result = runner.invoke(cli, ["domains", "use", "code"])
744 assert result.exit_code != 0
745
746 def test_unknown_domain_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
747 _init_repo(tmp_path)
748 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
749 result = runner.invoke(cli, ["domains", "use", "doesnotexist"])
750 assert result.exit_code != 0
751
752 def test_known_domain_switches_repo_json(self, tmp_path: pathlib.Path) -> None:
753 _init_repo(tmp_path, domain="scaffold")
754 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
755 result = runner.invoke(cli, ["domains", "use", "code"])
756 assert result.exit_code == 0, result.output
757 data: Manifest = json.loads((repo_json_path(tmp_path)).read_text(encoding="utf-8"))
758 assert data["domain"] == "code"
759
760 def test_switch_is_idempotent(self, tmp_path: pathlib.Path) -> None:
761 _init_repo(tmp_path, domain="code")
762 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
763 result = runner.invoke(cli, ["domains", "use", "code"])
764 assert result.exit_code == 0
765
766 def test_json_output_has_required_keys(self, tmp_path: pathlib.Path) -> None:
767 _init_repo(tmp_path, domain="scaffold")
768 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
769 result = runner.invoke(cli, ["domains", "use", "code", "--json"])
770 assert result.exit_code == 0, result.output
771 data = _parse_use(result)
772 assert data["domain"] == "code"
773 assert data["status"] == "switched"
774 assert "repo" in data
775
776 def test_existing_repo_fields_preserved(self, tmp_path: pathlib.Path) -> None:
777 _init_repo(tmp_path, domain="scaffold")
778 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
779 runner.invoke(cli, ["domains", "use", "code"])
780 data: Manifest = json.loads((repo_json_path(tmp_path)).read_text(encoding="utf-8"))
781 assert "repo_id" in data
782 assert "schema_version" in data
783
784
785 # ---------------------------------------------------------------------------
786 # Integration — muse domains validate
787 # ---------------------------------------------------------------------------
788
789
790 class TestDomainsValidate:
791 def test_code_plugin_passes(self) -> None:
792 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
793 result = runner.invoke(cli, ["domains", "validate", "code"])
794 assert result.exit_code == 0
795
796 def test_scaffold_plugin_passes(self) -> None:
797 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
798 result = runner.invoke(cli, ["domains", "validate", "scaffold"])
799 assert result.exit_code == 0
800
801 def test_unknown_domain_exits_nonzero(self) -> None:
802 result = runner.invoke(cli, ["domains", "validate", "ghost"])
803 assert result.exit_code != 0
804
805 def test_json_ok_field_is_boolean(self) -> None:
806 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
807 result = runner.invoke(cli, ["domains", "validate", "code", "--json"])
808 assert result.exit_code == 0
809 data = _parse_validate(result)
810 assert isinstance(data["ok"], bool)
811 assert data["ok"] is True
812
813 def test_json_checks_list_present(self) -> None:
814 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
815 result = runner.invoke(cli, ["domains", "validate", "scaffold", "--json"])
816 data = _parse_validate(result)
817 assert isinstance(data["checks"], list)
818 assert len(data["checks"]) >= 5
819
820 def test_broken_plugin_exits_nonzero(self) -> None:
821 from muse.plugins.registry import _REGISTRY
822
823 mock_plugin = MagicMock(spec=[]) # no methods at all
824 with patch.dict(_REGISTRY, {"broken": mock_plugin}):
825 result = runner.invoke(cli, ["domains", "validate", "broken"])
826 assert result.exit_code != 0
827
828 def test_no_name_validates_active_repo_domain(self, tmp_path: pathlib.Path) -> None:
829 _init_repo(tmp_path, domain="code")
830 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
831 result = runner.invoke(cli, ["domains", "validate"])
832 assert result.exit_code == 0
833 assert "code" in result.output
834
835 def test_no_name_no_repo_validates_all(self) -> None:
836 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
837 result = runner.invoke(cli, ["domains", "validate", "--json"])
838 assert result.exit_code == 0
839 from muse.plugins.registry import _REGISTRY
840 if len(_REGISTRY) > 1:
841 _parse_validate_list(result)
842 else:
843 _parse_validate(result)
844
845
846 # ---------------------------------------------------------------------------
847 # Security
848 # ---------------------------------------------------------------------------
849
850
851 class TestDomainsSecurity:
852 def test_ansi_in_domain_name_sanitized_in_dashboard(self) -> None:
853 """A registry entry with ANSI in its name must not bleed into output."""
854 from muse.plugins.registry import _REGISTRY
855 from muse.plugins.scaffold.plugin import ScaffoldPlugin
856
857 malicious_name = "\x1b[31mmalicious\x1b[0m"
858 mock = ScaffoldPlugin()
859 with patch.dict(_REGISTRY, {malicious_name: mock}):
860 result = runner.invoke(cli, ["domains"])
861 assert "\x1b[31m" not in result.output
862
863 def test_file_scheme_publish_hub_rejected(self, tmp_path: pathlib.Path) -> None:
864 """file:// hub URL must be blocked before any network call."""
865 urlopen_called = False
866
867 def _urlopen(req: urllib.request.Request, timeout: float) -> MagicMock:
868 nonlocal urlopen_called
869 urlopen_called = True
870 raise AssertionError("urlopen must not be called with file:// URL")
871
872 _init_repo(tmp_path, domain="code")
873
874 with ExitStack() as stack:
875 stack.enter_context(patch("urllib.request.urlopen", _urlopen))
876 stack.enter_context(
877 patch("muse.cli.commands.domains.find_repo_root", return_value=tmp_path)
878 )
879 stack.enter_context(
880 patch("muse.cli.commands.domains.get_signing_identity", return_value="tok")
881 )
882 result = runner.invoke(cli, [
883 "domains", "publish",
884 "--author", "user", "--slug", "music",
885 "--name", "Music", "--description", "Desc",
886 "--viewer-type", "midi",
887 "--hub", "file:///etc/passwd",
888 "--capabilities", '{"dimensions":[],"artifact_types":[],"merge_semantics":"three_way","supported_commands":[]}',
889 ])
890
891 assert result.exit_code != 0
892 assert not urlopen_called
893
894 def test_server_ansi_in_scoped_id_sanitized(self, tmp_path: pathlib.Path) -> None:
895 """Server-returned scoped_id with ANSI (unicode-escaped in JSON) must not appear raw."""
896 _init_repo(tmp_path, domain="code")
897 # JSON uses \u001b for the ESC character — valid JSON but contains ANSI.
898 malicious_response = b'{"domain_id":"1","scoped_id":"\\u001b[31mhacked\\u001b[0m","manifest_hash":""}'
899
900 def fake_urlopen(req: urllib.request.Request, timeout: float) -> MagicMock:
901 resp = MagicMock()
902 resp.read = MagicMock(return_value=malicious_response)
903 resp.__enter__ = lambda s: s
904 resp.__exit__ = MagicMock(return_value=False)
905 return resp
906
907 with ExitStack() as stack:
908 stack.enter_context(patch("urllib.request.urlopen", fake_urlopen))
909 stack.enter_context(
910 patch("muse.cli.commands.domains.find_repo_root", return_value=tmp_path)
911 )
912 stack.enter_context(
913 patch("muse.cli.commands.domains.get_signing_identity", return_value=_make_signing())
914 )
915 result = runner.invoke(cli, [
916 "domains", "publish",
917 "--author", "user", "--slug", "music",
918 "--name", "Music", "--description", "Desc",
919 "--viewer-type", "midi",
920 "--capabilities", '{"dimensions":[],"artifact_types":[],"merge_semantics":"three_way","supported_commands":[]}',
921 ])
922
923 assert result.exit_code == 0
924 assert "\x1b[31m" not in result.output
925
926 def test_path_traversal_new_rejected_without_touching_fs(self) -> None:
927 malicious = "../" * 5 + "malicious"
928 malicious_path = pathlib.Path(__file__).parents[1] / "muse" / "plugins" / malicious
929 result = runner.invoke(cli, ["domains", "--new", malicious])
930 assert result.exit_code != 0
931 assert not malicious_path.exists()
932
933 def test_null_byte_name_rejected(self) -> None:
934 result = runner.invoke(cli, ["domains", "--new", "malicious\x00name"])
935 assert result.exit_code != 0
936
937
938 # ---------------------------------------------------------------------------
939 # E2E — verify the muse CLI entry point wires domains correctly
940 # ---------------------------------------------------------------------------
941
942
943 class TestDomainsE2E:
944 def test_domains_help_exits_zero(self) -> None:
945 result = runner.invoke(cli, ["domains", "--help"])
946 assert result.exit_code == 0
947 assert "domains" in result.output.lower()
948
949 def test_domains_info_help(self) -> None:
950 result = runner.invoke(cli, ["domains", "info", "--help"])
951 assert result.exit_code == 0
952
953 def test_domains_use_help(self) -> None:
954 result = runner.invoke(cli, ["domains", "use", "--help"])
955 assert result.exit_code == 0
956
957 def test_domains_validate_help(self) -> None:
958 result = runner.invoke(cli, ["domains", "validate", "--help"])
959 assert result.exit_code == 0
960
961 def test_domains_publish_help(self) -> None:
962 result = runner.invoke(cli, ["domains", "publish", "--help"])
963 assert result.exit_code == 0
964
965 def test_domains_json_is_valid_json(self) -> None:
966 result = runner.invoke(cli, ["domains", "--json"])
967 assert result.exit_code == 0
968 domains = _parse_domains_list(result)
969 assert len(domains) >= 1
970
971 def test_info_json_is_valid_json(self) -> None:
972 result = runner.invoke(cli, ["domains", "info", "code", "--json"])
973 assert result.exit_code == 0
974 _parse_domain_entry(result) # must not raise
975
976 def test_validate_json_is_valid_json(self) -> None:
977 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
978 result = runner.invoke(cli, ["domains", "validate", "code", "--json"])
979 assert result.exit_code == 0
980 _parse_validate(result)
981
982 def test_use_requires_name_arg(self) -> None:
983 result = runner.invoke(cli, ["domains", "use"])
984 assert result.exit_code != 0
985
986
987 # ---------------------------------------------------------------------------
988 # Publish E2E — happy path and error paths
989 # ---------------------------------------------------------------------------
990
991
992 class TestPublishE2E:
993 _CAPS = json.dumps({
994 "dimensions": [{"name": "notes", "description": "Note events"}],
995 "artifact_types": ["mid"],
996 "merge_semantics": "three_way",
997 "supported_commands": ["commit", "diff"],
998 })
999
1000 _BASE_ARGS = [
1001 "domains", "publish",
1002 "--author", "user", "--slug", "music",
1003 "--name", "Music", "--description", "Desc",
1004 "--viewer-type", "midi",
1005 "--capabilities", _CAPS,
1006 ]
1007
1008 def test_successful_publish_text_output(self, tmp_path: pathlib.Path) -> None:
1009 _init_repo(tmp_path, domain="code")
1010 fake_resp = _PublishResponse(domain_id="1", scoped_id="@user/music", manifest_hash="abc")
1011
1012 with ExitStack() as stack:
1013 stack.enter_context(
1014 patch("muse.cli.commands.domains.find_repo_root", return_value=tmp_path)
1015 )
1016 stack.enter_context(
1017 patch("muse.cli.commands.domains.get_signing_identity", return_value="tok")
1018 )
1019 stack.enter_context(
1020 patch("muse.cli.commands.domains._post_json", return_value=fake_resp)
1021 )
1022 result = runner.invoke(cli, self._BASE_ARGS)
1023
1024 assert result.exit_code == 0, result.output
1025 assert "@user/music" in result.output
1026
1027 def test_successful_publish_json_output(self, tmp_path: pathlib.Path) -> None:
1028 _init_repo(tmp_path, domain="code")
1029 fake_resp = _PublishResponse(domain_id="1", scoped_id="@user/music", manifest_hash="abc")
1030
1031 with ExitStack() as stack:
1032 stack.enter_context(
1033 patch("muse.cli.commands.domains.find_repo_root", return_value=tmp_path)
1034 )
1035 stack.enter_context(
1036 patch("muse.cli.commands.domains.get_signing_identity", return_value="tok")
1037 )
1038 stack.enter_context(
1039 patch("muse.cli.commands.domains._post_json", return_value=fake_resp)
1040 )
1041 result = runner.invoke(cli, [*self._BASE_ARGS, "--json"])
1042
1043 assert result.exit_code == 0
1044 data = _parse_publish(result)
1045 assert data["scoped_id"] == "@user/music"
1046
1047 def test_no_token_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
1048 _init_repo(tmp_path, domain="code")
1049 with ExitStack() as stack:
1050 stack.enter_context(
1051 patch("muse.cli.commands.domains.find_repo_root", return_value=tmp_path)
1052 )
1053 stack.enter_context(
1054 patch("muse.cli.commands.domains.get_signing_identity", return_value=None)
1055 )
1056 result = runner.invoke(cli, self._BASE_ARGS)
1057
1058 assert result.exit_code != 0
1059
1060 def test_http_409_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
1061 _init_repo(tmp_path, domain="code")
1062 exc = urllib.error.HTTPError(
1063 url="", code=409, msg="Conflict",
1064 hdrs=http.client.HTTPMessage(), fp=None,
1065 )
1066
1067 with ExitStack() as stack:
1068 stack.enter_context(
1069 patch("muse.cli.commands.domains.find_repo_root", return_value=tmp_path)
1070 )
1071 stack.enter_context(
1072 patch("muse.cli.commands.domains.get_signing_identity", return_value="tok")
1073 )
1074 stack.enter_context(
1075 patch("muse.cli.commands.domains._post_json", side_effect=exc)
1076 )
1077 result = runner.invoke(cli, self._BASE_ARGS)
1078
1079 assert result.exit_code != 0
1080
1081 def test_http_401_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
1082 _init_repo(tmp_path, domain="code")
1083 exc = urllib.error.HTTPError(
1084 url="", code=401, msg="Unauthorized",
1085 hdrs=http.client.HTTPMessage(), fp=None,
1086 )
1087
1088 with ExitStack() as stack:
1089 stack.enter_context(
1090 patch("muse.cli.commands.domains.find_repo_root", return_value=tmp_path)
1091 )
1092 stack.enter_context(
1093 patch("muse.cli.commands.domains.get_signing_identity", return_value="tok")
1094 )
1095 stack.enter_context(
1096 patch("muse.cli.commands.domains._post_json", side_effect=exc)
1097 )
1098 result = runner.invoke(cli, self._BASE_ARGS)
1099
1100 assert result.exit_code != 0
1101
1102 def test_url_error_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
1103 _init_repo(tmp_path, domain="code")
1104 exc = urllib.error.URLError("connection refused")
1105
1106 with ExitStack() as stack:
1107 stack.enter_context(
1108 patch("muse.cli.commands.domains.find_repo_root", return_value=tmp_path)
1109 )
1110 stack.enter_context(
1111 patch("muse.cli.commands.domains.get_signing_identity", return_value="tok")
1112 )
1113 stack.enter_context(
1114 patch("muse.cli.commands.domains._post_json", side_effect=exc)
1115 )
1116 result = runner.invoke(cli, self._BASE_ARGS)
1117
1118 assert result.exit_code != 0
1119
1120
1121 # ---------------------------------------------------------------------------
1122 # Stress tests
1123 # ---------------------------------------------------------------------------
1124
1125
1126 class TestStress:
1127 def test_8_concurrent_validate_calls_isolated(self) -> None:
1128 """Concurrent validate calls on independent plugin instances must not interfere."""
1129 from muse.cli.commands.domains import _run_validate_plugin
1130 from muse.plugins.registry import _REGISTRY
1131
1132 errors: list[str] = []
1133
1134 def _validate(idx: int) -> None:
1135 try:
1136 plugin = _REGISTRY["code"]
1137 result = _run_validate_plugin("code", plugin, None)
1138 assert result["ok"] is True, f"Thread {idx}: validate failed"
1139 except Exception as exc:
1140 errors.append(f"Thread {idx}: {exc}")
1141
1142 threads = [threading.Thread(target=_validate, args=(i,)) for i in range(8)]
1143 for t in threads:
1144 t.start()
1145 for t in threads:
1146 t.join()
1147 assert not errors, f"Concurrent validate failures: {errors}"
1148
1149 def test_8_concurrent_active_domain_reads(self, tmp_path: pathlib.Path) -> None:
1150 """Concurrent _active_domain reads on isolated dirs must all return correctly."""
1151 from muse.cli.commands.domains import _active_domain
1152
1153 roots: list[pathlib.Path] = []
1154 for i in range(8):
1155 rd = tmp_path / f"repo{i}"
1156 _init_repo(rd, domain="code")
1157 roots.append(rd)
1158
1159 errors: list[str] = []
1160
1161 def _read(idx: int) -> None:
1162 try:
1163 result = _active_domain(roots[idx])
1164 assert result == "code", f"Thread {idx}: expected 'code', got {result!r}"
1165 except Exception as exc:
1166 errors.append(f"Thread {idx}: {exc}")
1167
1168 threads = [threading.Thread(target=_read, args=(i,)) for i in range(8)]
1169 for t in threads:
1170 t.start()
1171 for t in threads:
1172 t.join()
1173 assert not errors, f"Concurrent read failures: {errors}"
1174
1175 def test_8_concurrent_build_entry_calls(self) -> None:
1176 """_build_entry must be re-entrant (no shared mutable state)."""
1177 from muse.cli.commands.domains import _build_entry
1178 from muse.plugins.registry import _REGISTRY
1179
1180 errors: list[str] = []
1181
1182 def _build(idx: int) -> None:
1183 try:
1184 plugin = _REGISTRY["scaffold"]
1185 entry = _build_entry("scaffold", plugin, "code" if idx % 2 == 0 else "scaffold")
1186 assert entry["domain"] == "scaffold"
1187 assert isinstance(entry["active"], bool)
1188 except Exception as exc:
1189 errors.append(f"Thread {idx}: {exc}")
1190
1191 threads = [threading.Thread(target=_build, args=(i,)) for i in range(8)]
1192 for t in threads:
1193 t.start()
1194 for t in threads:
1195 t.join()
1196 assert not errors, f"Concurrent build_entry failures: {errors}"
1197
1198
1199 # ---------------------------------------------------------------------------
1200 # Extended — muse domains info (deeper coverage)
1201 # ---------------------------------------------------------------------------
1202
1203
1204 class TestDomainsInfoExtended:
1205 def test_j_alias_works(self) -> None:
1206 result = runner.invoke(cli, ["domains", "info", "code", "-j"])
1207 assert result.exit_code == 0
1208 data = json.loads(result.output.strip())
1209 assert data["domain"] == "code"
1210
1211 def test_help_mentions_json_flag(self) -> None:
1212 result = runner.invoke(cli, ["domains", "info", "--help"])
1213 assert "--json" in result.output or "-j" in result.output
1214
1215 def test_help_shows_exit_codes(self) -> None:
1216 result = runner.invoke(cli, ["domains", "info", "--help"])
1217 assert "Exit code" in result.output or "exit code" in result.output
1218
1219 def test_json_is_compact_single_line(self) -> None:
1220 result = runner.invoke(cli, ["domains", "info", "code", "--json"])
1221 assert result.exit_code == 0
1222 lines = [l for l in result.output.splitlines() if l.strip()]
1223 assert len(lines) == 1, f"Expected 1 non-empty line, got {len(lines)}"
1224
1225 def test_json_parses_cleanly(self) -> None:
1226 result = runner.invoke(cli, ["domains", "info", "code", "--json"])
1227 data = json.loads(result.output.strip())
1228 assert isinstance(data, dict)
1229
1230 def test_json_domain_field_matches_arg(self) -> None:
1231 result = runner.invoke(cli, ["domains", "info", "scaffold", "--json"])
1232 assert result.exit_code == 0
1233 data = json.loads(result.output.strip())
1234 assert data["domain"] == "scaffold"
1235
1236 def test_json_active_is_bool(self) -> None:
1237 result = runner.invoke(cli, ["domains", "info", "code", "--json"])
1238 data = json.loads(result.output.strip())
1239 assert isinstance(data["active"], bool)
1240
1241 def test_json_capabilities_is_list(self) -> None:
1242 result = runner.invoke(cli, ["domains", "info", "code", "--json"])
1243 data = json.loads(result.output.strip())
1244 assert isinstance(data["capabilities"], list)
1245 assert len(data["capabilities"]) >= 1
1246
1247 def test_json_module_path_is_string(self) -> None:
1248 result = runner.invoke(cli, ["domains", "info", "code", "--json"])
1249 data = json.loads(result.output.strip())
1250 assert isinstance(data["module_path"], str)
1251 assert len(data["module_path"]) > 0
1252
1253 def test_json_code_has_typed_deltas(self) -> None:
1254 result = runner.invoke(cli, ["domains", "info", "code", "--json"])
1255 data = json.loads(result.output.strip())
1256 assert "Typed Deltas" in data["capabilities"]
1257
1258 def test_json_code_schema_present(self) -> None:
1259 result = runner.invoke(cli, ["domains", "info", "code", "--json"])
1260 data = json.loads(result.output.strip())
1261 assert "schema" in data
1262
1263 def test_json_schema_has_required_keys(self) -> None:
1264 result = runner.invoke(cli, ["domains", "info", "code", "--json"])
1265 data = json.loads(result.output.strip())
1266 schema = data["schema"]
1267 for key in ("schema_version", "merge_mode", "description", "dimensions"):
1268 assert key in schema, f"Missing schema key: {key}"
1269
1270 def test_json_schema_dimensions_is_list(self) -> None:
1271 result = runner.invoke(cli, ["domains", "info", "code", "--json"])
1272 data = json.loads(result.output.strip())
1273 assert isinstance(data["schema"]["dimensions"], list)
1274 assert len(data["schema"]["dimensions"]) >= 1
1275
1276 def test_json_schema_dimension_has_name_and_description(self) -> None:
1277 result = runner.invoke(cli, ["domains", "info", "code", "--json"])
1278 data = json.loads(result.output.strip())
1279 dim = data["schema"]["dimensions"][0]
1280 assert "name" in dim
1281 assert "description" in dim
1282
1283 def test_text_shows_module_path(self) -> None:
1284 result = runner.invoke(cli, ["domains", "info", "code"])
1285 assert "Module:" in result.output
1286
1287 def test_text_shows_capabilities(self) -> None:
1288 result = runner.invoke(cli, ["domains", "info", "code"])
1289 assert "Capabilities:" in result.output
1290 assert "Typed Deltas" in result.output
1291
1292 def test_unknown_domain_error_mentions_name(self) -> None:
1293 result = runner.invoke(cli, ["domains", "info", "nonexistent_domain_xyz"])
1294 assert result.exit_code == 1
1295 assert "nonexistent_domain_xyz" in result.stderr or "not registered" in result.stderr
1296
1297 def test_unknown_domain_lists_known_domains(self) -> None:
1298 result = runner.invoke(cli, ["domains", "info", "nope"])
1299 assert result.exit_code == 1
1300 # Should mention at least one known domain (e.g. "code")
1301 assert "code" in result.stderr
1302
1303
1304 # ---------------------------------------------------------------------------
1305 # Security — muse domains info
1306 # ---------------------------------------------------------------------------
1307
1308
1309 class TestDomainsInfoSecurity:
1310 def test_ansi_in_domain_name_stripped_error(self) -> None:
1311 """ANSI in the unknown domain name is stripped from the error message."""
1312 result = runner.invoke(cli, ["domains", "info", "\x1b[31mmalicious\x1b[0m"])
1313 assert result.exit_code == 1
1314 assert "\x1b[" not in result.output
1315
1316 def test_ansi_in_module_path_stripped_text(self) -> None:
1317 """module_path with ANSI injected by a plugin is sanitized in text output."""
1318 from unittest.mock import patch as _patch
1319 malicious_entry = {
1320 "domain": "code",
1321 "module_path": "plugins/\x1b[31mmalicious\x1b[0m/plugin.py",
1322 "capabilities": ["Typed Deltas"],
1323 "active": False,
1324 }
1325 with _patch("muse.cli.commands.domains._build_entry", return_value=malicious_entry):
1326 result = runner.invoke(cli, ["domains", "info", "code"])
1327 assert result.exit_code == 0
1328 assert "\x1b[" not in result.output
1329
1330 def test_ansi_in_schema_description_stripped_text(self) -> None:
1331 """ANSI in schema description from a plugin is sanitized in text output."""
1332 from unittest.mock import patch as _patch, MagicMock
1333 malicious_entry = {
1334 "domain": "code",
1335 "module_path": "plugins/code/plugin.py",
1336 "capabilities": ["Typed Deltas", "Domain Schema"],
1337 "active": False,
1338 "schema": {
1339 "schema_version": "1.0",
1340 "merge_mode": "structured",
1341 "description": "Good domain \x1b[31mmalicious\x1b[0m",
1342 "dimensions": [{"name": "symbols", "description": "sym"}],
1343 },
1344 }
1345 with _patch("muse.cli.commands.domains._build_entry", return_value=malicious_entry):
1346 result = runner.invoke(cli, ["domains", "info", "code"])
1347 assert result.exit_code == 0
1348 assert "\x1b[" not in result.output
1349
1350 def test_ansi_in_dimension_name_stripped_text(self) -> None:
1351 """ANSI in a dimension name from a plugin schema is sanitized in text output."""
1352 from unittest.mock import patch as _patch
1353 malicious_entry = {
1354 "domain": "code",
1355 "module_path": "plugins/code/plugin.py",
1356 "capabilities": ["Typed Deltas", "Domain Schema"],
1357 "active": False,
1358 "schema": {
1359 "schema_version": "1.0",
1360 "merge_mode": "structured",
1361 "description": "desc",
1362 "dimensions": [{"name": "\x1b[31mmalicious\x1b[0m", "description": "bad"}],
1363 },
1364 }
1365 with _patch("muse.cli.commands.domains._build_entry", return_value=malicious_entry):
1366 result = runner.invoke(cli, ["domains", "info", "code"])
1367 assert result.exit_code == 0
1368 assert "\x1b[" not in result.output
1369
1370 def test_json_stdout_only_no_stray_text(self) -> None:
1371 """In JSON mode, stdout must be parseable JSON with no surrounding noise."""
1372 result = runner.invoke(cli, ["domains", "info", "code", "--json"])
1373 assert result.exit_code == 0
1374 json.loads(result.output.strip())
1375
1376 def test_unknown_domain_no_traceback(self) -> None:
1377 result = runner.invoke(cli, ["domains", "info", "no_such_domain_xyz"])
1378 assert result.exit_code == 1
1379 assert "Traceback" not in result.output
1380
1381
1382 # ---------------------------------------------------------------------------
1383 # Stress — muse domains info
1384 # ---------------------------------------------------------------------------
1385
1386
1387 class TestDomainsInfoStress:
1388 def test_50_sequential_info_calls(self) -> None:
1389 """50 sequential info invocations on 'code' all exit 0."""
1390 for i in range(50):
1391 result = runner.invoke(cli, ["domains", "info", "code", "--json"])
1392 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
1393
1394 def test_all_registered_domains_info_exits_zero(self) -> None:
1395 """Every domain currently in the registry responds to info with exit 0."""
1396 from muse.plugins.registry import _REGISTRY
1397 for name in sorted(_REGISTRY):
1398 result = runner.invoke(cli, ["domains", "info", name, "--json"])
1399 assert result.exit_code == 0, f"domains info {name!r} failed: {result.output}"
1400
1401 def test_concurrent_info_calls_no_shared_state(self) -> None:
1402 """8 threads each call domains info in isolation — no race conditions."""
1403 import threading
1404 errors: list[str] = []
1405 from muse.cli.commands.domains import _build_entry, _active_domain, _find_repo_root
1406 from muse.plugins.registry import _REGISTRY
1407
1408 def worker(idx: int) -> None:
1409 try:
1410 plugin = _REGISTRY.get("code")
1411 if plugin is None:
1412 errors.append(f"Thread {idx}: code plugin not found")
1413 return
1414 active = _active_domain(_find_repo_root())
1415 entry = _build_entry("code", plugin, active)
1416 if entry["domain"] != "code":
1417 errors.append(f"Thread {idx}: wrong domain {entry['domain']!r}")
1418 if not isinstance(entry["active"], bool):
1419 errors.append(f"Thread {idx}: active is not bool")
1420 except Exception as exc:
1421 errors.append(f"Thread {idx}: {exc}")
1422
1423 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
1424 for t in threads:
1425 t.start()
1426 for t in threads:
1427 t.join()
1428 assert not errors, f"Concurrent failures: {errors}"
1429
1430
1431 # ---------------------------------------------------------------------------
1432 # Extended — muse domains use (deeper coverage)
1433 # ---------------------------------------------------------------------------
1434
1435
1436 class TestDomainsUseExtended:
1437 def test_j_alias_works(self, tmp_path: pathlib.Path) -> None:
1438 _init_repo(tmp_path, domain="scaffold")
1439 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1440 result = runner.invoke(cli, ["domains", "use", "code", "-j"])
1441 assert result.exit_code == 0, result.output
1442 data = json.loads(result.output.strip())
1443 assert data["domain"] == "code"
1444
1445 def test_help_mentions_json_flag(self) -> None:
1446 result = runner.invoke(cli, ["domains", "use", "--help"])
1447 assert "--json" in result.output or "-j" in result.output
1448
1449 def test_help_shows_exit_codes(self) -> None:
1450 result = runner.invoke(cli, ["domains", "use", "--help"])
1451 assert "Exit code" in result.output or "exit code" in result.output
1452
1453 def test_json_is_compact_single_line(self, tmp_path: pathlib.Path) -> None:
1454 _init_repo(tmp_path, domain="scaffold")
1455 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1456 result = runner.invoke(cli, ["domains", "use", "code", "--json"])
1457 assert result.exit_code == 0, result.output
1458 lines = [l for l in result.output.splitlines() if l.strip()]
1459 assert len(lines) == 1, f"Expected 1 non-empty line, got {len(lines)}"
1460
1461 def test_json_parses_cleanly(self, tmp_path: pathlib.Path) -> None:
1462 _init_repo(tmp_path)
1463 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1464 result = runner.invoke(cli, ["domains", "use", "code", "--json"])
1465 data = json.loads(result.output.strip())
1466 assert isinstance(data, dict)
1467
1468 def test_json_domain_matches_arg(self, tmp_path: pathlib.Path) -> None:
1469 _init_repo(tmp_path, domain="code")
1470 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1471 result = runner.invoke(cli, ["domains", "use", "scaffold", "--json"])
1472 data = json.loads(result.output.strip())
1473 assert data["domain"] == "scaffold"
1474
1475 def test_json_status_is_switched(self, tmp_path: pathlib.Path) -> None:
1476 _init_repo(tmp_path)
1477 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1478 result = runner.invoke(cli, ["domains", "use", "code", "--json"])
1479 data = json.loads(result.output.strip())
1480 assert data["status"] == "switched"
1481
1482 def test_json_repo_ends_with_muse(self, tmp_path: pathlib.Path) -> None:
1483 _init_repo(tmp_path)
1484 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1485 result = runner.invoke(cli, ["domains", "use", "code", "--json"])
1486 data = json.loads(result.output.strip())
1487 assert data["repo"].endswith(".muse")
1488
1489 def test_repo_json_domain_actually_updated(self, tmp_path: pathlib.Path) -> None:
1490 _init_repo(tmp_path, domain="scaffold")
1491 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1492 runner.invoke(cli, ["domains", "use", "code"])
1493 written = json.loads((repo_json_path(tmp_path)).read_text())
1494 assert written["domain"] == "code"
1495
1496 def test_existing_fields_preserved(self, tmp_path: pathlib.Path) -> None:
1497 _init_repo(tmp_path, domain="scaffold")
1498 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1499 runner.invoke(cli, ["domains", "use", "code"])
1500 written = json.loads((repo_json_path(tmp_path)).read_text())
1501 assert "repo_id" in written
1502 assert "schema_version" in written
1503
1504 def test_idempotent_same_domain_exits_zero(self, tmp_path: pathlib.Path) -> None:
1505 _init_repo(tmp_path, domain="code")
1506 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1507 result = runner.invoke(cli, ["domains", "use", "code"])
1508 assert result.exit_code == 0
1509
1510 def test_switch_code_to_scaffold(self, tmp_path: pathlib.Path) -> None:
1511 _init_repo(tmp_path, domain="code")
1512 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1513 result = runner.invoke(cli, ["domains", "use", "scaffold"])
1514 assert result.exit_code == 0
1515 written = json.loads((repo_json_path(tmp_path)).read_text())
1516 assert written["domain"] == "scaffold"
1517
1518 def test_text_success_mentions_domain_name(self, tmp_path: pathlib.Path) -> None:
1519 _init_repo(tmp_path)
1520 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1521 result = runner.invoke(cli, ["domains", "use", "code"])
1522 assert "code" in result.output
1523
1524 def test_text_success_mentions_repo_path(self, tmp_path: pathlib.Path) -> None:
1525 _init_repo(tmp_path)
1526 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1527 result = runner.invoke(cli, ["domains", "use", "code"])
1528 assert "Repo:" in result.output or ".muse" in result.output
1529
1530 def test_unknown_domain_lists_known(self, tmp_path: pathlib.Path) -> None:
1531 _init_repo(tmp_path)
1532 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1533 result = runner.invoke(cli, ["domains", "use", "no_such_domain"])
1534 assert result.exit_code == 1
1535 assert "code" in result.stderr # known domains listed
1536
1537 def test_no_repo_exits_1(self) -> None:
1538 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
1539 result = runner.invoke(cli, ["domains", "use", "code"])
1540 assert result.exit_code == 1
1541
1542 def test_corrupt_repo_json_exits_1(self, tmp_path: pathlib.Path) -> None:
1543 _init_repo(tmp_path)
1544 (repo_json_path(tmp_path)).write_text("{{bad json}}", encoding="utf-8")
1545 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1546 result = runner.invoke(cli, ["domains", "use", "code"])
1547 assert result.exit_code == 1
1548
1549 def test_extra_repo_json_fields_preserved(self, tmp_path: pathlib.Path) -> None:
1550 """Custom extra fields in repo.json survive a domain switch."""
1551 muse = muse_dir(tmp_path)
1552 muse.mkdir(parents=True, exist_ok=True)
1553 (muse / "repo.json").write_text(
1554 json.dumps({
1555 "repo_id": "my-repo",
1556 "schema_version": "1.0",
1557 "domain": "scaffold",
1558 "custom_field": "keep_me",
1559 }),
1560 encoding="utf-8",
1561 )
1562 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1563 result = runner.invoke(cli, ["domains", "use", "code"])
1564 assert result.exit_code == 0
1565 written = json.loads((repo_json_path(tmp_path)).read_text())
1566 assert written["custom_field"] == "keep_me"
1567 assert written["domain"] == "code"
1568
1569
1570 # ---------------------------------------------------------------------------
1571 # Security — muse domains use
1572 # ---------------------------------------------------------------------------
1573
1574
1575 class TestDomainsUseSecurity:
1576 def test_ansi_in_unknown_domain_name_stripped(self, tmp_path: pathlib.Path) -> None:
1577 _init_repo(tmp_path)
1578 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1579 result = runner.invoke(cli, ["domains", "use", "\x1b[31mmalicious\x1b[0m"])
1580 assert result.exit_code == 1
1581 assert "\x1b[" not in result.output
1582
1583 def test_unregistered_domain_never_written(self, tmp_path: pathlib.Path) -> None:
1584 """An unregistered domain name is rejected before repo.json is touched."""
1585 _init_repo(tmp_path, domain="code")
1586 original = (repo_json_path(tmp_path)).read_text()
1587 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1588 result = runner.invoke(cli, ["domains", "use", "malicious_domain"])
1589 assert result.exit_code == 1
1590 # repo.json must be unchanged
1591 assert (repo_json_path(tmp_path)).read_text() == original
1592
1593 def test_domain_name_sanitized_in_success_text(self, tmp_path: pathlib.Path) -> None:
1594 """Domain name in success text is sanitized (no raw ANSI from env)."""
1595 _init_repo(tmp_path)
1596 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1597 result = runner.invoke(cli, ["domains", "use", "code"])
1598 assert result.exit_code == 0
1599 assert "\x1b[" not in result.output
1600
1601 def test_repo_path_sanitized_in_success_text(self, tmp_path: pathlib.Path) -> None:
1602 """Repo path in success text is sanitized."""
1603 _init_repo(tmp_path)
1604 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1605 result = runner.invoke(cli, ["domains", "use", "code"])
1606 assert result.exit_code == 0
1607 assert "\x1b[" not in result.output
1608
1609 def test_json_stdout_only_no_stray_text(self, tmp_path: pathlib.Path) -> None:
1610 """In JSON mode, stdout must be valid JSON and nothing else."""
1611 _init_repo(tmp_path)
1612 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1613 result = runner.invoke(cli, ["domains", "use", "code", "--json"])
1614 assert result.exit_code == 0
1615 json.loads(result.output.strip())
1616
1617 def test_no_traceback_on_corrupt_repo_json(self, tmp_path: pathlib.Path) -> None:
1618 _init_repo(tmp_path)
1619 (repo_json_path(tmp_path)).write_text("{bad}", encoding="utf-8")
1620 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1621 result = runner.invoke(cli, ["domains", "use", "code"])
1622 assert result.exit_code == 1
1623 assert "Traceback" not in result.output
1624
1625
1626 # ---------------------------------------------------------------------------
1627 # Stress — muse domains use
1628 # ---------------------------------------------------------------------------
1629
1630
1631 class TestDomainsUseStress:
1632 def test_50_sequential_use_calls_all_succeed(self, tmp_path: pathlib.Path) -> None:
1633 _init_repo(tmp_path, domain="code")
1634 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1635 for i in range(50):
1636 result = runner.invoke(cli, ["domains", "use", "code", "--json"])
1637 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
1638
1639 def test_alternate_domains_100_times(self, tmp_path: pathlib.Path) -> None:
1640 """Switch between code and scaffold 100 times — file integrity preserved."""
1641 _init_repo(tmp_path, domain="code")
1642 domains = ["code", "scaffold"]
1643 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1644 for i in range(100):
1645 target = domains[i % 2]
1646 result = runner.invoke(cli, ["domains", "use", target])
1647 assert result.exit_code == 0, f"Switch {i} to {target!r} failed"
1648 written = json.loads((repo_json_path(tmp_path)).read_text())
1649 assert written["domain"] in domains
1650
1651 def test_concurrent_use_isolated_repos(self, tmp_path: pathlib.Path) -> None:
1652 """8 threads each switch domain on their own isolated repo."""
1653 import argparse
1654 import threading
1655
1656 from muse.cli.commands.domains import run_use
1657
1658 errors: list[str] = []
1659
1660 def worker(idx: int) -> None:
1661 repo = tmp_path / f"repo_{idx}"
1662 _init_repo(repo, domain="scaffold")
1663 try:
1664 args = argparse.Namespace(use_name="code", json_out=True)
1665 with patch("muse.cli.commands.domains._find_repo_root", return_value=repo):
1666 run_use(args)
1667 written = json.loads((repo_json_path(repo)).read_text())
1668 if written["domain"] != "code":
1669 errors.append(f"Thread {idx}: domain is {written['domain']!r}")
1670 except SystemExit as exc:
1671 errors.append(f"Thread {idx}: exit {exc.code}")
1672 except Exception as exc:
1673 errors.append(f"Thread {idx}: {exc}")
1674
1675 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
1676 for t in threads:
1677 t.start()
1678 for t in threads:
1679 t.join()
1680 assert not errors, f"Concurrent failures: {errors}"
1681
1682
1683 # ---------------------------------------------------------------------------
1684 # Extended — muse domains validate
1685 # ---------------------------------------------------------------------------
1686
1687
1688 class TestDomainsValidateExtended:
1689 def test_j_alias_works(self) -> None:
1690 """-j is equivalent to --json."""
1691 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
1692 result = runner.invoke(cli, ["domains", "validate", "code", "-j"])
1693 assert result.exit_code == 0
1694 data = json.loads(result.output.strip())
1695 assert data["domain"] == "code"
1696
1697 def test_help_flag(self) -> None:
1698 result = runner.invoke(cli, ["domains", "validate", "--help"])
1699 assert result.exit_code == 0
1700 assert "validate" in result.output.lower()
1701
1702 def test_json_compact_no_indent(self) -> None:
1703 """JSON output must be a single line (compact, no indent=2)."""
1704 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
1705 result = runner.invoke(cli, ["domains", "validate", "code", "-j"])
1706 assert result.exit_code == 0
1707 lines = [l for l in result.output.splitlines() if l.strip()]
1708 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
1709
1710 def test_json_fields_present(self) -> None:
1711 """JSON object contains domain, ok, checks."""
1712 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
1713 result = runner.invoke(cli, ["domains", "validate", "code", "-j"])
1714 data = json.loads(result.output.strip())
1715 assert set(data.keys()) >= {"domain", "ok", "checks"}
1716
1717 def test_json_domain_matches_arg(self) -> None:
1718 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
1719 result = runner.invoke(cli, ["domains", "validate", "scaffold", "-j"])
1720 data = json.loads(result.output.strip())
1721 assert data["domain"] == "scaffold"
1722
1723 def test_json_checks_are_objects(self) -> None:
1724 """Each entry in checks has name, ok, detail."""
1725 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
1726 result = runner.invoke(cli, ["domains", "validate", "code", "-j"])
1727 data = json.loads(result.output.strip())
1728 for c in data["checks"]:
1729 assert "name" in c
1730 assert "ok" in c
1731 assert "detail" in c
1732
1733 def test_text_output_shows_checkmarks(self) -> None:
1734 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
1735 result = runner.invoke(cli, ["domains", "validate", "code"])
1736 assert "✅" in result.output or "✓" in result.output
1737
1738 def test_text_output_shows_domain_name(self) -> None:
1739 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
1740 result = runner.invoke(cli, ["domains", "validate", "scaffold"])
1741 assert "scaffold" in result.output
1742
1743 def test_no_name_in_repo_validates_active_domain(self, tmp_path: pathlib.Path) -> None:
1744 """When inside a repo with domain=code, validate with no name validates code."""
1745 _init_repo(tmp_path, domain="code")
1746 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1747 result = runner.invoke(cli, ["domains", "validate", "-j"])
1748 assert result.exit_code == 0
1749 data = json.loads(result.output.strip())
1750 assert data["domain"] == "code"
1751
1752 def test_no_name_no_repo_validates_all_json(self) -> None:
1753 """With no name and no repo, all registered domains are validated."""
1754 from muse.plugins.registry import _REGISTRY
1755 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
1756 result = runner.invoke(cli, ["domains", "validate", "-j"])
1757 assert result.exit_code == 0
1758 if len(_REGISTRY) == 1:
1759 data = json.loads(result.output.strip())
1760 assert "domain" in data
1761 else:
1762 data = json.loads(result.output.strip())
1763 if isinstance(data, dict) and "results" in data:
1764 data = data["results"]
1765 assert isinstance(data, list)
1766 assert len(data) == len(_REGISTRY)
1767
1768 def test_unregistered_domain_exit1(self) -> None:
1769 result = runner.invoke(cli, ["domains", "validate", "does-not-exist"])
1770 assert result.exit_code == 1
1771
1772 def test_broken_plugin_ok_false(self) -> None:
1773 """A plugin with no required methods emits ok=false."""
1774 from muse.plugins.registry import _REGISTRY
1775 from unittest.mock import MagicMock
1776 mock = MagicMock(spec=[])
1777 with patch.dict(_REGISTRY, {"broken2": mock}):
1778 result = runner.invoke(cli, ["domains", "validate", "broken2", "-j"])
1779 assert result.exit_code == 1
1780 data = json.loads(result.output.strip())
1781 assert data["ok"] is False
1782
1783 def test_broken_plugin_checks_have_failed_entries(self) -> None:
1784 from muse.plugins.registry import _REGISTRY
1785 from unittest.mock import MagicMock
1786 mock = MagicMock(spec=[])
1787 with patch.dict(_REGISTRY, {"broken3": mock}):
1788 result = runner.invoke(cli, ["domains", "validate", "broken3", "-j"])
1789 data = json.loads(result.output.strip())
1790 failed = [c for c in data["checks"] if not c["ok"]]
1791 assert len(failed) >= 1
1792
1793 def test_multi_domain_json_is_list(self) -> None:
1794 """When validating all (no name, no repo) with >=2 domains, output is a list."""
1795 from muse.plugins.registry import _REGISTRY
1796 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
1797 result = runner.invoke(cli, ["domains", "validate", "-j"])
1798 assert result.exit_code == 0
1799 if len(_REGISTRY) >= 2:
1800 data = json.loads(result.output.strip())
1801 if isinstance(data, dict) and "results" in data:
1802 data = data["results"]
1803 assert isinstance(data, list)
1804
1805 def test_active_domain_not_in_registry_falls_back_to_all(self, tmp_path: pathlib.Path) -> None:
1806 """Active domain removed from registry → falls back to validating all."""
1807 _init_repo(tmp_path, domain="ghost")
1808 with patch("muse.cli.commands.domains._find_repo_root", return_value=tmp_path):
1809 result = runner.invoke(cli, ["domains", "validate", "-j"])
1810 assert result.exit_code == 0
1811 # should validate registry domains, not error on missing ghost
1812
1813 def test_help_shows_agent_quickstart(self) -> None:
1814 result = runner.invoke(cli, ["domains", "validate", "--help"])
1815 assert result.exit_code == 0
1816 assert "Agent quickstart" in result.output
1817
1818 def test_help_shows_json_schema(self) -> None:
1819 result = runner.invoke(cli, ["domains", "validate", "--help"])
1820 assert "JSON output schema" in result.output
1821
1822 def test_help_shows_exit_codes(self) -> None:
1823 result = runner.invoke(cli, ["domains", "validate", "--help"])
1824 assert "Exit codes" in result.output
1825
1826
1827 # ---------------------------------------------------------------------------
1828 # Security — muse domains validate
1829 # ---------------------------------------------------------------------------
1830
1831
1832 class TestDomainsValidateSecurity:
1833 def test_ansi_in_domain_name_stripped_in_error(self) -> None:
1834 """ANSI in unknown domain name must not bleed into stderr."""
1835 result = runner.invoke(cli, ["domains", "validate", "\x1b[31mmalicious\x1b[0m"])
1836 assert result.exit_code == 1
1837 assert "\x1b" not in result.output
1838
1839 def test_unregistered_domain_never_checked(self) -> None:
1840 """An unregistered name exits before any check logic runs."""
1841 result = runner.invoke(cli, ["domains", "validate", "notareal_domain_xyz"])
1842 assert result.exit_code == 1
1843 assert "not registered" in result.stderr
1844
1845 def test_json_output_only_on_stdout(self, capsys: pytest.CaptureFixture) -> None:
1846 """JSON is emitted to stdout; errors go to stderr only."""
1847 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
1848 result = runner.invoke(cli, ["domains", "validate", "code", "-j"])
1849 assert result.exit_code == 0
1850 json.loads(result.output.strip()) # must parse cleanly
1851
1852 def test_ansi_in_check_detail_sanitized_text_mode(self) -> None:
1853 """Plugin with no schema attr exercises AttributeError detail path; output has no ANSI."""
1854 from muse.plugins.registry import _REGISTRY
1855 from unittest.mock import MagicMock
1856 # spec omits schema → _run_validate_plugin catches AttributeError, writes detail string
1857 mock = MagicMock(spec=["snapshot", "diff", "merge", "drift", "apply"])
1858 with patch.dict(_REGISTRY, {"ansi_test": mock}):
1859 result = runner.invoke(cli, ["domains", "validate", "ansi_test"])
1860 assert "\x1b" not in result.output
1861
1862 def test_no_traceback_on_unknown_domain(self) -> None:
1863 result = runner.invoke(cli, ["domains", "validate", "totally_unknown_domain"])
1864 assert "Traceback" not in result.output
1865
1866 def test_multi_domain_validate_all_domains_present_in_output(self) -> None:
1867 """When validating all, every registered domain name appears in output."""
1868 from muse.plugins.registry import _REGISTRY
1869 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
1870 result = runner.invoke(cli, ["domains", "validate", "-j"])
1871 assert result.exit_code == 0
1872 output = result.output
1873 for domain_name in _REGISTRY:
1874 assert domain_name in output, f"Missing domain {domain_name!r} in output"
1875
1876
1877 # ---------------------------------------------------------------------------
1878 # Stress — muse domains validate
1879 # ---------------------------------------------------------------------------
1880
1881
1882 class TestDomainsValidateStress:
1883 def test_50_sequential_validate_calls(self) -> None:
1884 """50 sequential validate calls all succeed."""
1885 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
1886 for i in range(50):
1887 result = runner.invoke(cli, ["domains", "validate", "code", "-j"])
1888 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
1889
1890 def test_alternate_code_scaffold_100_times(self) -> None:
1891 """Alternate between validating code and scaffold 100 times."""
1892 domains = ["code", "scaffold"]
1893 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
1894 for i in range(100):
1895 target = domains[i % 2]
1896 result = runner.invoke(cli, ["domains", "validate", target, "-j"])
1897 assert result.exit_code == 0, f"Step {i}: {result.output}"
1898 data = json.loads(result.output.strip())
1899 assert data["domain"] == target
1900
1901 def test_concurrent_validate_8_threads(self) -> None:
1902 """8 threads each validate code concurrently using core function directly."""
1903 import argparse
1904 import threading
1905
1906 from muse.cli.commands.domains import run_validate
1907
1908 errors: list[str] = []
1909
1910 def worker(idx: int) -> None:
1911 args = argparse.Namespace(validate_name="code", json_out=True)
1912 try:
1913 with patch("muse.cli.commands.domains._find_repo_root", return_value=None):
1914 run_validate(args)
1915 except SystemExit as exc:
1916 if exc.code != 0:
1917 errors.append(f"Thread {idx}: exit {exc.code}")
1918 except Exception as exc:
1919 errors.append(f"Thread {idx}: {exc}")
1920
1921 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
1922 for t in threads:
1923 t.start()
1924 for t in threads:
1925 t.join()
1926 assert not errors, f"Concurrent failures: {errors}"
1927
1928
1929 # ---------------------------------------------------------------------------
1930 # TestRegisterFlags — --json / -j normalized on every domains subparser
1931 # ---------------------------------------------------------------------------
1932
1933
1934 class TestRegisterFlags:
1935 """Every domains subparser must register --json with -j shorthand."""
1936
1937 def _make_parser(self) -> argparse.ArgumentParser:
1938 from muse.cli.commands.domains import register
1939 root = argparse.ArgumentParser()
1940 subs = root.add_subparsers()
1941 register(subs)
1942 return root
1943
1944 def test_domains_j_alias_exits_zero(self) -> None:
1945 result = runner.invoke(cli, ["domains", "-j"])
1946 assert result.exit_code == 0, result.output
1947
1948 def test_domains_j_alias_valid_json(self) -> None:
1949 result = runner.invoke(cli, ["domains", "-j"])
1950 json.loads(result.output) # must not raise
1951
1952 def test_domains_j_alias_has_domains_key(self) -> None:
1953 result = runner.invoke(cli, ["domains", "-j"])
1954 assert "domains" in json.loads(result.output)
1955
1956 def test_domains_json_out_default_false(self) -> None:
1957 p = self._make_parser()
1958 ns = p.parse_args(["domains"])
1959 assert ns.json_out is False
1960
1961 def test_domains_json_out_true_with_json_flag(self) -> None:
1962 p = self._make_parser()
1963 ns = p.parse_args(["domains", "--json"])
1964 assert ns.json_out is True
1965
1966 def test_domains_json_out_true_with_j_flag(self) -> None:
1967 p = self._make_parser()
1968 ns = p.parse_args(["domains", "-j"])
1969 assert ns.json_out is True
1970
1971 def test_validate_j_alias_exits_zero(self) -> None:
1972 result = runner.invoke(cli, ["domains", "validate", "-j"])
1973 assert result.exit_code == 0, result.output
1974
1975 def test_validate_j_alias_valid_json(self) -> None:
1976 result = runner.invoke(cli, ["domains", "validate", "-j"])
1977 json.loads(result.output) # must not raise
1978
1979 def test_info_j_alias_exits_zero(self) -> None:
1980 result = runner.invoke(cli, ["domains", "info", "code", "-j"])
1981 assert result.exit_code == 0, result.output
1982
1983 def test_info_j_alias_valid_json(self) -> None:
1984 result = runner.invoke(cli, ["domains", "info", "code", "-j"])
1985 json.loads(result.output) # must not raise
File History 1 commit