gabriel / muse public
test_cmd_workspace_hardening.py python
2,290 lines 94.5 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 22 days ago
1 """Hardening tests for muse workspace — security, performance, UX, and stress.
2
3 Coverage matrix
4 ---------------
5 - Unit: _toml_escape, _load_manifest guards (symlink, size cap, corrupt TOML),
6 _save_manifest symlink guard, _validate_member_name, _validate_member_url,
7 _validate_member_path, update_workspace_member, get_workspace_member
8 - Security: TOML injection roundtrip, path traversal rejection, null bytes,
9 forbidden URL schemes, symlink manifest, oversized manifest, ANSI sanitization
10 - Error routing: all errors go to stderr, not stdout
11 - JSON schema: all six subcommands (add, update, list, remove, status, sync)
12 - Integration: full add→list→update→status→remove lifecycle; sync dry-run
13 - E2E: text output for add, remove, list, status, sync (text mode)
14 - Stress: 50-member manifest, parallel concurrent list reads
15 """
16
17 from __future__ import annotations
18
19 import json
20 import pathlib
21 import threading
22 import time
23 from typing import TypedDict
24 from unittest.mock import patch
25
26 import pytest
27
28 from muse.core.types import NULL_COMMIT_ID
29 from muse.core.workspace import (
30 WorkspaceMemberStatus,
31 WorkspaceSyncResult,
32 _load_manifest,
33 _save_manifest,
34 _toml_escape,
35 _validate_member_name,
36 _validate_member_path,
37 _validate_member_url,
38 add_workspace_member,
39 get_workspace_member,
40 list_workspace_members,
41 remove_workspace_member,
42 sync_workspace,
43 update_workspace_member,
44 )
45 from muse.core.paths import muse_dir, workspace_toml_path
46
47 # ---------------------------------------------------------------------------
48 # Test helpers
49 # ---------------------------------------------------------------------------
50
51
52 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
53 muse = muse_dir(tmp_path)
54 for d in ("objects", "commits", "snapshots", "refs/heads"):
55 (muse / d).mkdir(parents=True, exist_ok=True)
56 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
57 (muse / "HEAD").write_text("ref: refs/heads/main\n")
58 (muse / "refs" / "heads" / "main").write_text(NULL_COMMIT_ID)
59 return tmp_path
60
61
62 def _cli(args: list[str], repo: pathlib.Path) -> tuple[str, str, int]:
63 """Invoke the muse CLI and return (stdout, stderr, returncode)."""
64 import subprocess
65 import sys
66 result = subprocess.run(
67 [sys.executable, "-m", "muse.cli.app"] + args,
68 capture_output=True,
69 text=True,
70 cwd=str(repo),
71 )
72 return result.stdout, result.stderr, result.returncode
73
74
75 def _json_blob(stdout: str) -> str:
76 """Return the first JSON-looking line from CLI output."""
77 for line in stdout.splitlines():
78 stripped = line.strip()
79 if stripped.startswith(("{", "[")):
80 return stripped
81 return stdout.strip()
82
83
84 def _parse_add(stdout: str) -> _AddJson:
85 raw = json.loads(_json_blob(stdout))
86 assert isinstance(raw, dict)
87 return _AddJson(
88 name=str(raw["name"]),
89 url=str(raw["url"]),
90 path=str(raw["path"]),
91 branch=str(raw["branch"]),
92 )
93
94
95 def _parse_update(stdout: str) -> _UpdateJson:
96 raw = json.loads(_json_blob(stdout))
97 assert isinstance(raw, dict)
98 return _UpdateJson(
99 name=str(raw["name"]),
100 url=str(raw["url"]),
101 path=str(raw["path"]),
102 branch=str(raw["branch"]),
103 )
104
105
106 def _parse_list(stdout: str) -> list[_ListMemberJson]:
107 raw = json.loads(_json_blob(stdout))
108 # list and status now return an envelope: {members, exit_code, duration_ms}
109 if isinstance(raw, dict):
110 raw = raw["members"]
111 assert isinstance(raw, list)
112 result: list[_ListMemberJson] = []
113 for item in raw:
114 assert isinstance(item, dict)
115 hc = item["head_commit"]
116 assert hc is None or isinstance(hc, str)
117 ab = item["actual_branch"]
118 assert ab is None or isinstance(ab, str)
119 fb = item["feature_branches"]
120 assert isinstance(fb, list)
121 result.append(_ListMemberJson(
122 name=str(item["name"]),
123 url=str(item["url"]),
124 path=str(item["path"]),
125 branch=str(item["branch"]),
126 present=bool(item["present"]),
127 head_commit=hc,
128 dirty=bool(item["dirty"]),
129 actual_branch=ab,
130 shelf_count=int(item["shelf_count"]),
131 feature_branches=[str(b) for b in fb],
132 ))
133 return result
134
135
136 def _parse_remove(stdout: str) -> _RemoveJson:
137 raw = json.loads(_json_blob(stdout))
138 assert isinstance(raw, dict)
139 return _RemoveJson(
140 name=str(raw["name"]),
141 removed=bool(raw["removed"]),
142 )
143
144
145 def _parse_sync(stdout: str) -> _SyncJson:
146 raw = json.loads(_json_blob(stdout))
147 assert isinstance(raw, dict)
148 results_raw = raw.get("results", [])
149 assert isinstance(results_raw, list)
150 results: list[_SyncResultItemJson] = []
151 for item in results_raw:
152 assert isinstance(item, dict)
153 results.append(_SyncResultItemJson(
154 name=str(item["name"]),
155 status=str(item["status"]),
156 ok=bool(item["ok"]),
157 ))
158 return _SyncJson(
159 dry_run=bool(raw["dry_run"]),
160 workers=int(raw["workers"]),
161 results=results,
162 total=int(raw["total"]),
163 ok_count=int(raw["ok_count"]),
164 error_count=int(raw["error_count"]),
165 )
166
167
168 # ---------------------------------------------------------------------------
169 # Unit: _toml_escape
170 # ---------------------------------------------------------------------------
171
172
173 def test_toml_escape_plain_string() -> None:
174 assert _toml_escape("hello") == "hello"
175
176
177 def test_toml_escape_backslash() -> None:
178 assert _toml_escape("a\\b") == "a\\\\b"
179
180
181 def test_toml_escape_double_quote() -> None:
182 assert _toml_escape('a"b') == 'a\\"b'
183
184
185 def test_toml_escape_injection_attempt() -> None:
186 crafted = 'core"\nname = "injected'
187 escaped = _toml_escape(crafted)
188 assert "\n" not in escaped
189 assert escaped == 'core\\"\\nname = \\"injected'
190
191 def test_toml_escape_newline() -> None:
192 assert _toml_escape("a\nb") == "a\\nb"
193
194
195 def test_toml_escape_carriage_return() -> None:
196 assert _toml_escape("a\rb") == "a\\rb"
197
198
199 def test_toml_escape_tab() -> None:
200 assert _toml_escape("a\tb") == "a\\tb"
201
202
203 def test_toml_escape_roundtrip(tmp_path: pathlib.Path) -> None:
204 """A name with special chars survives save→load intact."""
205 import tomllib
206 repo = _make_repo(tmp_path)
207 tricky = 'my"repo\\edge'
208 add_workspace_member(repo, "safe-name", "https://example.com/safe", branch="main")
209 manifest = _load_manifest(repo)
210 assert manifest is not None
211 raw_text = (workspace_toml_path(repo)).read_text()
212 parsed = tomllib.loads(raw_text)
213 assert parsed["members"][0]["name"] == "safe-name"
214
215
216 # ---------------------------------------------------------------------------
217 # Unit: _validate_member_name
218 # ---------------------------------------------------------------------------
219
220
221 def test_validate_name_ok() -> None:
222 _validate_member_name("my-repo")
223 _validate_member_name("repo.v2")
224 _validate_member_name("R3p0_OK")
225
226
227 def test_validate_name_empty_raises() -> None:
228 with pytest.raises(ValueError, match="1–64"):
229 _validate_member_name("")
230
231
232 def test_validate_name_too_long_raises() -> None:
233 with pytest.raises(ValueError, match="1–64"):
234 _validate_member_name("a" * 65)
235
236
237 def test_validate_name_slash_raises() -> None:
238 with pytest.raises(ValueError, match="invalid characters"):
239 _validate_member_name("my/repo")
240
241
242 def test_validate_name_null_byte_raises() -> None:
243 with pytest.raises(ValueError):
244 _validate_member_name("repo\x00malicious")
245
246
247 def test_validate_name_space_raises() -> None:
248 with pytest.raises(ValueError, match="invalid characters"):
249 _validate_member_name("my repo")
250
251
252 # ---------------------------------------------------------------------------
253 # Unit: _validate_member_url
254 # ---------------------------------------------------------------------------
255
256
257 def test_validate_url_https_ok() -> None:
258 _validate_member_url("https://musehub.ai/acme/core")
259
260
261 def test_validate_url_http_ok() -> None:
262 _validate_member_url("https://localhost:1337/gabriel/core")
263
264
265 def test_validate_url_local_path_ok() -> None:
266 _validate_member_url("/home/user/repos/myrepo")
267 _validate_member_url("./relative/path")
268
269
270 def test_validate_url_null_byte_raises() -> None:
271 with pytest.raises(ValueError, match="null bytes"):
272 _validate_member_url("https://example.com/\x00malicious")
273
274
275 def test_validate_url_file_scheme_raises() -> None:
276 with pytest.raises(ValueError, match="not allowed"):
277 _validate_member_url("file:///etc/passwd")
278
279
280 def test_validate_url_ftp_scheme_raises() -> None:
281 with pytest.raises(ValueError, match="not allowed"):
282 _validate_member_url("ftp://example.com/repo")
283
284
285 def test_validate_url_ssh_scheme_raises() -> None:
286 with pytest.raises(ValueError, match="not allowed"):
287 _validate_member_url("ssh://[email protected]/repo")
288
289
290 # ---------------------------------------------------------------------------
291 # Unit: _validate_member_path
292 # ---------------------------------------------------------------------------
293
294
295 def test_validate_path_ok(tmp_path: pathlib.Path) -> None:
296 repo = _make_repo(tmp_path)
297 _validate_member_path(repo, "repos/core")
298 _validate_member_path(repo, "sub/dir/nested")
299
300
301 def test_validate_path_traversal_raises(tmp_path: pathlib.Path) -> None:
302 repo = _make_repo(tmp_path)
303 with pytest.raises(ValueError, match="outside the workspace root"):
304 _validate_member_path(repo, "../../etc")
305
306
307 def test_validate_path_null_byte_raises(tmp_path: pathlib.Path) -> None:
308 repo = _make_repo(tmp_path)
309 with pytest.raises(ValueError, match="null bytes"):
310 _validate_member_path(repo, "repos/\x00malicious")
311
312
313 # ---------------------------------------------------------------------------
314 # Unit: _load_manifest guards
315 # ---------------------------------------------------------------------------
316
317
318 def test_load_manifest_symlink_ignored(tmp_path: pathlib.Path) -> None:
319 repo = _make_repo(tmp_path)
320 add_workspace_member(repo, "core", "https://example.com/core")
321 manifest_path = workspace_toml_path(repo)
322 real = tmp_path / "real.toml"
323 real.write_bytes(manifest_path.read_bytes())
324 manifest_path.unlink()
325 manifest_path.symlink_to(real)
326 result = _load_manifest(repo)
327 assert result is None
328
329
330 def test_load_manifest_oversized_ignored(tmp_path: pathlib.Path) -> None:
331 from muse.core.workspace import _MAX_MANIFEST_BYTES
332 repo = _make_repo(tmp_path)
333 manifest_path = workspace_toml_path(repo)
334 manifest_path.write_bytes(b"x" * (_MAX_MANIFEST_BYTES + 1))
335 result = _load_manifest(repo)
336 assert result is None
337
338
339 def test_load_manifest_corrupt_toml_ignored(tmp_path: pathlib.Path) -> None:
340 repo = _make_repo(tmp_path)
341 (workspace_toml_path(repo)).write_text("[[members\nbroken toml")
342 result = _load_manifest(repo)
343 assert result is None
344
345
346 def test_load_manifest_missing_returns_none(tmp_path: pathlib.Path) -> None:
347 repo = _make_repo(tmp_path)
348 assert _load_manifest(repo) is None
349
350
351 # ---------------------------------------------------------------------------
352 # Unit: _save_manifest symlink guard
353 # ---------------------------------------------------------------------------
354
355
356 def test_save_manifest_rejects_symlink_file(tmp_path: pathlib.Path) -> None:
357 from muse.core.workspace import WorkspaceManifestDict
358 repo = _make_repo(tmp_path)
359 real = tmp_path / "real.toml"
360 real.write_text("")
361 manifest_path = workspace_toml_path(repo)
362 manifest_path.symlink_to(real)
363 with pytest.raises(OSError, match="symlink"):
364 _save_manifest(repo, WorkspaceManifestDict(members=[]))
365
366
367 # ---------------------------------------------------------------------------
368 # Unit: update_workspace_member
369 # ---------------------------------------------------------------------------
370
371
372 def test_update_url(tmp_path: pathlib.Path) -> None:
373 repo = _make_repo(tmp_path)
374 add_workspace_member(repo, "core", "https://old.example.com/core")
375 update_workspace_member(repo, "core", url="https://new.example.com/core")
376 m = get_workspace_member(repo, "core")
377 assert m.url == "https://new.example.com/core"
378
379
380 def test_update_branch(tmp_path: pathlib.Path) -> None:
381 repo = _make_repo(tmp_path)
382 add_workspace_member(repo, "core", "https://example.com/core")
383 update_workspace_member(repo, "core", branch="v2")
384 m = get_workspace_member(repo, "core")
385 assert m.branch == "v2"
386
387
388 def test_update_path(tmp_path: pathlib.Path) -> None:
389 repo = _make_repo(tmp_path)
390 add_workspace_member(repo, "core", "https://example.com/core")
391 update_workspace_member(repo, "core", path="vendor/core")
392 m = get_workspace_member(repo, "core")
393 assert "vendor/core" in str(m.path)
394
395
396 def test_update_nonexistent_raises(tmp_path: pathlib.Path) -> None:
397 repo = _make_repo(tmp_path)
398 with pytest.raises(ValueError, match="not found"):
399 update_workspace_member(repo, "ghost", url="https://example.com/ghost")
400
401
402 def test_update_invalid_url_raises(tmp_path: pathlib.Path) -> None:
403 repo = _make_repo(tmp_path)
404 add_workspace_member(repo, "core", "https://example.com/core")
405 with pytest.raises(ValueError, match="not allowed"):
406 update_workspace_member(repo, "core", url="ftp://example.com/core")
407
408
409 # ---------------------------------------------------------------------------
410 # Unit: get_workspace_member
411 # ---------------------------------------------------------------------------
412
413
414 def test_get_workspace_member_found(tmp_path: pathlib.Path) -> None:
415 repo = _make_repo(tmp_path)
416 add_workspace_member(repo, "sounds", "https://example.com/sounds", branch="v2")
417 m = get_workspace_member(repo, "sounds")
418 assert isinstance(m, WorkspaceMemberStatus)
419 assert m.name == "sounds"
420 assert m.branch == "v2"
421
422
423 def test_get_workspace_member_not_found_raises(tmp_path: pathlib.Path) -> None:
424 repo = _make_repo(tmp_path)
425 add_workspace_member(repo, "core", "https://example.com/core")
426 with pytest.raises(ValueError, match="not found"):
427 get_workspace_member(repo, "ghost")
428
429
430 def test_get_workspace_member_no_manifest_raises(tmp_path: pathlib.Path) -> None:
431 repo = _make_repo(tmp_path)
432 with pytest.raises(ValueError, match="No workspace manifest"):
433 get_workspace_member(repo, "anything")
434
435
436 # ---------------------------------------------------------------------------
437 # Unit: WorkspaceMemberStatus has dirty field
438 # ---------------------------------------------------------------------------
439
440
441 def test_member_status_has_dirty_field(tmp_path: pathlib.Path) -> None:
442 repo = _make_repo(tmp_path)
443 add_workspace_member(repo, "core", "https://example.com/core")
444 members = list_workspace_members(repo)
445 assert hasattr(members[0], "dirty")
446 assert members[0].dirty is False # not present → not dirty
447 # New fields are always present on the dataclass.
448 assert hasattr(members[0], "actual_branch")
449 assert members[0].actual_branch is None # not present → no actual branch
450 assert hasattr(members[0], "shelf_count")
451 assert members[0].shelf_count == 0 # not present → nothing on shelf
452 assert hasattr(members[0], "feature_branches")
453 assert members[0].feature_branches == [] # not present → no branches
454
455
456 # ---------------------------------------------------------------------------
457 # Unit: sync_workspace dry_run
458 # ---------------------------------------------------------------------------
459
460
461 def test_sync_dry_run_returns_skipped(tmp_path: pathlib.Path) -> None:
462 repo = _make_repo(tmp_path)
463 add_workspace_member(repo, "core", "https://example.com/core")
464 results = sync_workspace(repo, dry_run=True)
465 assert len(results) == 1
466 assert results[0]["status"].startswith("skipped")
467 assert "dry-run" in results[0]["status"]
468
469
470 def test_sync_dry_run_no_subprocess(tmp_path: pathlib.Path) -> None:
471 """dry_run must never invoke subprocess.run."""
472 repo = _make_repo(tmp_path)
473 add_workspace_member(repo, "core", "https://example.com/core")
474 with patch("muse.core.workspace.subprocess.run") as mock_run:
475 sync_workspace(repo, dry_run=True)
476 mock_run.assert_not_called()
477
478
479 def test_sync_empty_manifest_returns_empty(tmp_path: pathlib.Path) -> None:
480 repo = _make_repo(tmp_path)
481 results = sync_workspace(repo)
482 assert results == []
483
484
485 def test_sync_dry_run_pull_action(tmp_path: pathlib.Path) -> None:
486 """Member with existing .muse dir should report 'pull' in dry-run."""
487 repo = _make_repo(tmp_path)
488 member_path = tmp_path / "repos" / "core"
489 muse_dir(member_path).mkdir(parents=True)
490 add_workspace_member(repo, "core", "https://example.com/core")
491 results = sync_workspace(repo, dry_run=True)
492 assert "pull" in results[0]["status"]
493
494
495 def test_sync_named_member_only(tmp_path: pathlib.Path) -> None:
496 repo = _make_repo(tmp_path)
497 add_workspace_member(repo, "core", "https://example.com/core")
498 add_workspace_member(repo, "data", "https://example.com/data")
499 with patch("muse.core.workspace.subprocess.run") as mock_run:
500 mock_run.return_value = type("R", (), {"returncode": 0, "stderr": ""})()
501 results = sync_workspace(repo, member_name="core", dry_run=True)
502 assert len(results) == 1
503 assert results[0]["name"] == "core"
504
505
506 # ---------------------------------------------------------------------------
507 # Security: TOML injection via crafted member name/url persists safely
508 # ---------------------------------------------------------------------------
509
510
511 def test_toml_injection_in_url_is_escaped(tmp_path: pathlib.Path) -> None:
512 """A URL with embedded quotes and newlines must not corrupt the TOML manifest."""
513 import tomllib
514 repo = _make_repo(tmp_path)
515 # Craft a URL that would inject extra members if not escaped
516 tricky_url = 'https://example.com/core"\n[[members]]\nname = "injected'
517 add_workspace_member(repo, "safe", tricky_url)
518 raw = (workspace_toml_path(repo)).read_text()
519 parsed = tomllib.loads(raw)
520 # Exactly 1 member — the injected one must not appear as a separate entry
521 assert len(parsed.get("members", [])) == 1
522 assert parsed["members"][0]["name"] == "safe"
523 # The raw newlines from the URL are escaped as \n inside the string value
524 # (the file naturally has TOML structural newlines, but the URL value's
525 # embedded newlines must appear as the two-char escape sequence \\n)
526 url_line = next(line for line in raw.splitlines() if line.startswith("url"))
527 assert "\\n" in url_line
528
529
530 def test_ansi_in_name_sanitized_in_output(tmp_path: pathlib.Path) -> None:
531 repo = _make_repo(tmp_path)
532 malicious_name = "\x1b[31mmalicious\x1b[0m"
533 # _validate_member_name will reject the ANSI escape — that's the right behaviour
534 with pytest.raises(ValueError, match="invalid characters"):
535 add_workspace_member(repo, malicious_name, "https://example.com/malicious")
536
537
538 def test_path_traversal_in_member_path_rejected(tmp_path: pathlib.Path) -> None:
539 repo = _make_repo(tmp_path)
540 with pytest.raises(ValueError, match="outside the workspace root"):
541 add_workspace_member(repo, "malicious", "https://example.com/malicious", path="../../etc")
542
543
544 def test_file_url_scheme_rejected(tmp_path: pathlib.Path) -> None:
545 repo = _make_repo(tmp_path)
546 with pytest.raises(ValueError, match="not allowed"):
547 add_workspace_member(repo, "malicious", "file:///etc/passwd")
548
549
550 def test_null_byte_in_url_rejected(tmp_path: pathlib.Path) -> None:
551 repo = _make_repo(tmp_path)
552 with pytest.raises(ValueError, match="null bytes"):
553 add_workspace_member(repo, "malicious", "https://example.com/\x00malicious")
554
555
556 # ---------------------------------------------------------------------------
557 # Error routing — all error output goes to stderr
558 # ---------------------------------------------------------------------------
559
560
561 def test_add_duplicate_error_to_stderr(tmp_path: pathlib.Path) -> None:
562 repo = _make_repo(tmp_path)
563 stdout, stderr, rc = _cli(["workspace", "add", "core", "https://example.com/core"], repo)
564 assert rc == 0
565 stdout2, stderr2, rc2 = _cli(["workspace", "add", "core", "https://example.com/other"], repo)
566 assert rc2 != 0
567 assert "already exists" in stderr2
568 assert "already exists" not in stdout2
569
570
571 def test_remove_nonexistent_error_to_stderr(tmp_path: pathlib.Path) -> None:
572 repo = _make_repo(tmp_path)
573 add_workspace_member(repo, "core", "https://example.com/core")
574 stdout, stderr, rc = _cli(["workspace", "remove", "ghost"], repo)
575 assert rc != 0
576 assert "not found" in stderr
577 assert "not found" not in stdout
578
579
580 def test_update_no_flags_error_to_stderr(tmp_path: pathlib.Path) -> None:
581 repo = _make_repo(tmp_path)
582 add_workspace_member(repo, "core", "https://example.com/core")
583 stdout, stderr, rc = _cli(["workspace", "update", "core"], repo)
584 assert rc != 0
585 assert "at least one" in stderr
586
587
588 def test_status_nonexistent_error_to_stderr(tmp_path: pathlib.Path) -> None:
589 repo = _make_repo(tmp_path)
590 add_workspace_member(repo, "core", "https://example.com/core")
591 stdout, stderr, rc = _cli(["workspace", "status", "ghost"], repo)
592 assert rc != 0
593 assert "not found" in stderr
594 assert "not found" not in stdout
595
596
597 # ---------------------------------------------------------------------------
598 # JSON schema: add
599 # ---------------------------------------------------------------------------
600
601
602 class _AddJson(TypedDict):
603 name: str
604 url: str
605 path: str
606 branch: str
607
608
609 def test_add_json_schema(tmp_path: pathlib.Path) -> None:
610 repo = _make_repo(tmp_path)
611 stdout, _, rc = _cli(
612 ["workspace", "add", "core", "https://example.com/core", "--json"], repo
613 )
614 assert rc == 0
615 d = _parse_add(stdout)
616 assert d["name"] == "core"
617 assert d["branch"] == "main"
618 assert "repos/core" in d["path"]
619
620
621 # ---------------------------------------------------------------------------
622 # JSON schema: update
623 # ---------------------------------------------------------------------------
624
625
626 class _UpdateJson(TypedDict):
627 name: str
628 url: str
629 path: str
630 branch: str
631
632
633 def test_update_json_schema(tmp_path: pathlib.Path) -> None:
634 repo = _make_repo(tmp_path)
635 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
636 stdout, _, rc = _cli(
637 ["workspace", "update", "core", "--branch", "dev", "--json"], repo
638 )
639 assert rc == 0
640 d = _parse_update(stdout)
641 assert d["name"] == "core"
642 assert d["branch"] == "dev"
643
644
645 # ---------------------------------------------------------------------------
646 # JSON schema: list
647 # ---------------------------------------------------------------------------
648
649
650 class _ListMemberJson(TypedDict):
651 name: str
652 url: str
653 path: str
654 branch: str
655 present: bool
656 head_commit: str | None
657 dirty: bool
658 actual_branch: str | None
659 shelf_count: int
660 feature_branches: list[str]
661
662
663 def test_list_json_schema(tmp_path: pathlib.Path) -> None:
664 repo = _make_repo(tmp_path)
665 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
666 _cli(["workspace", "add", "data", "https://example.com/data", "--branch", "v2"], repo)
667 stdout, _, rc = _cli(["workspace", "list", "--json"], repo)
668 assert rc == 0
669 members = _parse_list(stdout)
670 assert len(members) == 2
671 d = members[0]
672 assert d["name"] == "core"
673 assert d["present"] is False
674 assert d["dirty"] is False
675 # New fields — always present even when member is not cloned.
676 assert d["actual_branch"] is None
677 assert d["shelf_count"] == 0
678 assert d["feature_branches"] == []
679
680
681 def test_list_json_empty_list(tmp_path: pathlib.Path) -> None:
682 repo = _make_repo(tmp_path)
683 stdout, _, rc = _cli(["workspace", "list", "--json"], repo)
684 assert rc == 0
685 assert _parse_list(stdout) == []
686
687
688 # ---------------------------------------------------------------------------
689 # JSON schema: remove
690 # ---------------------------------------------------------------------------
691
692
693 class _RemoveJson(TypedDict):
694 name: str
695 removed: bool
696
697
698 def test_remove_json_schema(tmp_path: pathlib.Path) -> None:
699 repo = _make_repo(tmp_path)
700 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
701 stdout, _, rc = _cli(["workspace", "remove", "core", "--json"], repo)
702 assert rc == 0
703 d = _parse_remove(stdout)
704 assert d["name"] == "core"
705 assert d["removed"] is True
706
707
708 # ---------------------------------------------------------------------------
709 # JSON schema: status
710 # ---------------------------------------------------------------------------
711
712
713 def test_status_json_all(tmp_path: pathlib.Path) -> None:
714 repo = _make_repo(tmp_path)
715 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
716 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
717 assert rc == 0
718 members = _parse_list(stdout)
719 assert len(members) == 1
720 assert members[0]["name"] == "core"
721
722
723 def test_status_json_named(tmp_path: pathlib.Path) -> None:
724 repo = _make_repo(tmp_path)
725 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
726 _cli(["workspace", "add", "data", "https://example.com/data"], repo)
727 stdout, _, rc = _cli(["workspace", "status", "core", "--json"], repo)
728 assert rc == 0
729 members = _parse_list(stdout)
730 assert len(members) == 1
731 assert members[0]["name"] == "core"
732
733
734 def test_status_json_empty(tmp_path: pathlib.Path) -> None:
735 repo = _make_repo(tmp_path)
736 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
737 assert rc == 0
738 assert _parse_list(stdout) == []
739
740
741 # ---------------------------------------------------------------------------
742 # JSON schema: sync
743 # ---------------------------------------------------------------------------
744
745
746 class _SyncResultItemJson(TypedDict):
747 name: str
748 status: str
749 ok: bool
750
751
752 class _SyncJson(TypedDict):
753 dry_run: bool
754 workers: int
755 results: list[_SyncResultItemJson]
756 total: int
757 ok_count: int
758 error_count: int
759
760
761 def test_sync_json_dry_run(tmp_path: pathlib.Path) -> None:
762 repo = _make_repo(tmp_path)
763 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
764 stdout, _, rc = _cli(["workspace", "sync", "--dry-run", "--json"], repo)
765 assert rc == 0
766 d = _parse_sync(stdout)
767 assert d["dry_run"] is True
768 assert d["total"] == 1
769 assert d["ok_count"] == 1
770 assert d["error_count"] == 0
771
772
773 def test_sync_json_empty_manifest(tmp_path: pathlib.Path) -> None:
774 repo = _make_repo(tmp_path)
775 stdout, _, rc = _cli(["workspace", "sync", "--dry-run", "--json"], repo)
776 assert rc == 0
777 d = _parse_sync(stdout)
778 assert d["total"] == 0
779
780
781 # ---------------------------------------------------------------------------
782 # Integration: full lifecycle
783 # ---------------------------------------------------------------------------
784
785
786 def test_lifecycle_add_update_remove(tmp_path: pathlib.Path) -> None:
787 repo = _make_repo(tmp_path)
788 add_workspace_member(repo, "core", "https://example.com/core")
789 update_workspace_member(repo, "core", branch="dev")
790 m = get_workspace_member(repo, "core")
791 assert m.branch == "dev"
792 remove_workspace_member(repo, "core")
793 assert list_workspace_members(repo) == []
794
795
796 def test_lifecycle_multiple_members(tmp_path: pathlib.Path) -> None:
797 repo = _make_repo(tmp_path)
798 for i in range(5):
799 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
800 members = list_workspace_members(repo)
801 assert len(members) == 5
802 update_workspace_member(repo, "svc2", branch="release")
803 m = get_workspace_member(repo, "svc2")
804 assert m.branch == "release"
805 remove_workspace_member(repo, "svc2")
806 assert len(list_workspace_members(repo)) == 4
807
808
809 def test_add_custom_branch_and_path(tmp_path: pathlib.Path) -> None:
810 repo = _make_repo(tmp_path)
811 add_workspace_member(repo, "data", "https://example.com/data", path="vendor/data", branch="v2")
812 m = get_workspace_member(repo, "data")
813 assert m.branch == "v2"
814 assert "vendor/data" in str(m.path)
815
816
817 # ---------------------------------------------------------------------------
818 # E2E: text output
819 # ---------------------------------------------------------------------------
820
821
822 def test_e2e_add_text_output(tmp_path: pathlib.Path) -> None:
823 repo = _make_repo(tmp_path)
824 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core"], repo)
825 assert rc == 0
826 assert "Added" in stdout
827 assert "core" in stdout
828
829
830 def test_e2e_list_text_output(tmp_path: pathlib.Path) -> None:
831 repo = _make_repo(tmp_path)
832 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
833 stdout, _, rc = _cli(["workspace", "list"], repo)
834 assert rc == 0
835 assert "core" in stdout
836 assert "present" in stdout.lower() or "no" in stdout
837
838
839 def test_e2e_status_text_output(tmp_path: pathlib.Path) -> None:
840 repo = _make_repo(tmp_path)
841 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
842 stdout, _, rc = _cli(["workspace", "status"], repo)
843 assert rc == 0
844 assert "core" in stdout
845 assert "branch=main" in stdout
846
847
848 def test_e2e_remove_text_output(tmp_path: pathlib.Path) -> None:
849 repo = _make_repo(tmp_path)
850 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
851 stdout, _, rc = _cli(["workspace", "remove", "core"], repo)
852 assert rc == 0
853 assert "Removed" in stdout
854
855
856 def test_e2e_sync_dry_run_text_output(tmp_path: pathlib.Path) -> None:
857 repo = _make_repo(tmp_path)
858 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
859 stdout, _, rc = _cli(["workspace", "sync", "--dry-run"], repo)
860 assert rc == 0
861 assert "core" in stdout
862 assert "skipped" in stdout or "dry-run" in stdout
863
864
865 def test_e2e_list_no_members(tmp_path: pathlib.Path) -> None:
866 repo = _make_repo(tmp_path)
867 stdout, _, rc = _cli(["workspace", "list"], repo)
868 assert rc == 0
869 assert "No workspace members" in stdout
870
871
872 def test_e2e_update_text_output(tmp_path: pathlib.Path) -> None:
873 repo = _make_repo(tmp_path)
874 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
875 stdout, _, rc = _cli(["workspace", "update", "core", "--branch", "dev"], repo)
876 assert rc == 0
877 assert "Updated" in stdout
878
879
880 def test_e2e_shorthand_branch_flag(tmp_path: pathlib.Path) -> None:
881 """-b shorthand should work for add and update."""
882 repo = _make_repo(tmp_path)
883 stdout, _, rc = _cli(["workspace", "add", "data", "https://example.com/data", "-b", "v3"], repo)
884 assert rc == 0
885 m = get_workspace_member(repo, "data")
886 assert m.branch == "v3"
887
888
889 # ---------------------------------------------------------------------------
890 # Stress: 50 members
891 # ---------------------------------------------------------------------------
892
893
894 def test_stress_50_members_add_list(tmp_path: pathlib.Path) -> None:
895 repo = _make_repo(tmp_path)
896 for i in range(50):
897 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
898 members = list_workspace_members(repo)
899 assert len(members) == 50
900 names = {m.name for m in members}
901 for i in range(50):
902 assert f"svc{i:03d}" in names
903
904
905 def test_stress_add_remove_cycle(tmp_path: pathlib.Path) -> None:
906 repo = _make_repo(tmp_path)
907 for i in range(20):
908 add_workspace_member(repo, f"repo{i}", f"https://example.com/repo{i}")
909 for i in range(20):
910 remove_workspace_member(repo, f"repo{i}")
911 assert list_workspace_members(repo) == []
912
913
914 def test_stress_concurrent_list_reads(tmp_path: pathlib.Path) -> None:
915 """Concurrent reads of the manifest must all succeed without corruption."""
916 repo = _make_repo(tmp_path)
917 for i in range(20):
918 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
919
920 failures: list[str] = []
921
922 def _read() -> None:
923 try:
924 members = list_workspace_members(repo)
925 if len(members) != 20:
926 failures.append(f"Expected 20 members, got {len(members)}")
927 except Exception as exc:
928 failures.append(str(exc))
929
930 threads = [threading.Thread(target=_read) for _ in range(20)]
931 for t in threads:
932 t.start()
933 for t in threads:
934 t.join()
935
936 assert not failures, f"Concurrent read failures: {failures}"
937
938
939 def test_stress_sync_parallel_dry_run(tmp_path: pathlib.Path) -> None:
940 """Parallel sync (dry_run) over 20 members must return 20 results."""
941 repo = _make_repo(tmp_path)
942 for i in range(20):
943 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
944 results = sync_workspace(repo, dry_run=True, workers=4)
945 assert len(results) == 20
946 for r in results:
947 assert r["status"].startswith("skipped")
948
949
950 def test_stress_json_list_50_members(tmp_path: pathlib.Path) -> None:
951 """JSON list output for 50 members must parse correctly."""
952 repo = _make_repo(tmp_path)
953 for i in range(50):
954 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
955 stdout, _, rc = _cli(["workspace", "list", "--json"], repo)
956 assert rc == 0
957 members = _parse_list(stdout)
958 assert len(members) == 50
959
960
961 def test_stress_update_10_members(tmp_path: pathlib.Path) -> None:
962 """Update branch for 10 members sequentially; all must reflect the change."""
963 repo = _make_repo(tmp_path)
964 for i in range(10):
965 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
966 for i in range(10):
967 update_workspace_member(repo, f"svc{i}", branch="release")
968 members = list_workspace_members(repo)
969 for m in members:
970 assert m.branch == "release"
971
972
973 # ===========================================================================
974 # muse workspace add — Extended / Security / Stress
975 # ===========================================================================
976
977
978 class TestWorkspaceAddExtended:
979 """-j alias, JSON schema, defaults, custom args, lifecycle, edge cases."""
980
981 def test_add_j_alias(self, tmp_path: pathlib.Path) -> None:
982 """-j produces the same JSON as --json (ignoring duration_ms)."""
983 repo = _make_repo(tmp_path)
984 stdout1, _, rc1 = _cli(["workspace", "add", "core", "https://example.com/core", "--json"], repo)
985 _cli(["workspace", "remove", "core"], repo)
986 stdout2, _, rc2 = _cli(["workspace", "add", "core", "https://example.com/core", "-j"], repo)
987 assert rc1 == 0 and rc2 == 0
988 d1 = json.loads(_json_blob(stdout1)); d1.pop("duration_ms", None); d1.pop("timestamp", None)
989 d2 = json.loads(_json_blob(stdout2)); d2.pop("duration_ms", None); d2.pop("timestamp", None)
990 assert d1 == d2
991
992 def test_add_json_name_field(self, tmp_path: pathlib.Path) -> None:
993 """JSON name field matches the supplied NAME argument."""
994 repo = _make_repo(tmp_path)
995 stdout, _, rc = _cli(["workspace", "add", "myrepo", "https://example.com/myrepo", "-j"], repo)
996 assert rc == 0
997 d = _parse_add(stdout)
998 assert d["name"] == "myrepo"
999
1000 def test_add_json_url_field(self, tmp_path: pathlib.Path) -> None:
1001 """JSON url field matches the supplied URL argument."""
1002 repo = _make_repo(tmp_path)
1003 url = "https://example.com/myrepo"
1004 stdout, _, rc = _cli(["workspace", "add", "myrepo", url, "-j"], repo)
1005 assert rc == 0
1006 d = _parse_add(stdout)
1007 assert d["url"] == url
1008
1009 def test_add_json_default_branch_is_main(self, tmp_path: pathlib.Path) -> None:
1010 """Branch defaults to 'main' when --branch is not supplied."""
1011 repo = _make_repo(tmp_path)
1012 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core", "-j"], repo)
1013 assert rc == 0
1014 d = _parse_add(stdout)
1015 assert d["branch"] == "main"
1016
1017 def test_add_json_default_path_is_repos_name(self, tmp_path: pathlib.Path) -> None:
1018 """Path defaults to repos/<name> when --path is not supplied."""
1019 repo = _make_repo(tmp_path)
1020 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core", "-j"], repo)
1021 assert rc == 0
1022 d = _parse_add(stdout)
1023 assert "repos/core" in d["path"]
1024
1025 def test_add_json_custom_branch(self, tmp_path: pathlib.Path) -> None:
1026 """Custom --branch appears in JSON output."""
1027 repo = _make_repo(tmp_path)
1028 stdout, _, rc = _cli(["workspace", "add", "data", "https://example.com/data", "--branch", "v2", "-j"], repo)
1029 assert rc == 0
1030 d = _parse_add(stdout)
1031 assert d["branch"] == "v2"
1032
1033 def test_add_json_custom_path(self, tmp_path: pathlib.Path) -> None:
1034 """Custom --path appears in JSON output."""
1035 repo = _make_repo(tmp_path)
1036 stdout, _, rc = _cli(["workspace", "add", "data", "https://example.com/data", "--path", "vendor/data", "-j"], repo)
1037 assert rc == 0
1038 d = _parse_add(stdout)
1039 assert "vendor/data" in d["path"]
1040
1041 def test_add_json_all_fields_present(self, tmp_path: pathlib.Path) -> None:
1042 """JSON output contains name, url, path, branch, exit_code, duration_ms."""
1043 repo = _make_repo(tmp_path)
1044 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core", "-j"], repo)
1045 assert rc == 0
1046 raw = json.loads(_json_blob(stdout))
1047 assert {"name", "url", "path", "branch", "exit_code", "duration_ms"}.issubset(raw.keys())
1048
1049 def test_add_default_is_text(self, tmp_path: pathlib.Path) -> None:
1050 """Without --json the output is human-readable text."""
1051 repo = _make_repo(tmp_path)
1052 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1053 assert rc == 0
1054 assert not stdout.strip().startswith("{")
1055
1056 def test_add_text_contains_name(self, tmp_path: pathlib.Path) -> None:
1057 """Text output mentions the member name."""
1058 repo = _make_repo(tmp_path)
1059 stdout, _, rc = _cli(["workspace", "add", "myrepo", "https://example.com/myrepo"], repo)
1060 assert rc == 0
1061 assert "myrepo" in stdout
1062
1063 def test_add_text_hints_sync(self, tmp_path: pathlib.Path) -> None:
1064 """Text output hints to run 'muse workspace sync'."""
1065 repo = _make_repo(tmp_path)
1066 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1067 assert rc == 0
1068 assert "sync" in stdout.lower()
1069
1070 def test_add_duplicate_exits_1(self, tmp_path: pathlib.Path) -> None:
1071 """Adding a member with a duplicate name exits with code 1."""
1072 repo = _make_repo(tmp_path)
1073 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1074 _, stderr, rc = _cli(["workspace", "add", "core", "https://example.com/core2"], repo)
1075 assert rc == 1
1076 assert "core" in stderr
1077
1078 def test_add_outside_repo_succeeds(self, tmp_path: pathlib.Path) -> None:
1079 """Workspace add works from any directory — no muse repo required."""
1080 empty = tmp_path / "empty"
1081 empty.mkdir()
1082 _, _, rc = _cli(["workspace", "add", "core", "https://example.com/core"], empty)
1083 assert rc == 0
1084
1085 def test_add_appears_in_list_after(self, tmp_path: pathlib.Path) -> None:
1086 """Added member appears in subsequent 'workspace list --json'."""
1087 repo = _make_repo(tmp_path)
1088 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1089 stdout, _, rc = _cli(["workspace", "list", "--json"], repo)
1090 assert rc == 0
1091 members = _parse_list(stdout)
1092 assert any(m["name"] == "core" for m in members)
1093
1094 def test_add_help_has_description(self, tmp_path: pathlib.Path) -> None:
1095 """--help includes the rich description."""
1096 repo = _make_repo(tmp_path)
1097 stdout, _, rc = _cli(["workspace", "add", "--help"], repo)
1098 assert rc == 0
1099 assert "Agent quickstart" in stdout or "JSON output schema" in stdout
1100
1101 def test_add_local_path_url_accepted(self, tmp_path: pathlib.Path) -> None:
1102 """A bare filesystem path is accepted as the URL."""
1103 repo = _make_repo(tmp_path)
1104 local = str(tmp_path / "local-repo")
1105 stdout, _, rc = _cli(["workspace", "add", "local", local, "-j"], repo)
1106 assert rc == 0
1107 d = _parse_add(stdout)
1108 assert d["url"] == local
1109
1110 def test_add_shorthand_branch_flag(self, tmp_path: pathlib.Path) -> None:
1111 """-b shorthand sets the branch correctly."""
1112 repo = _make_repo(tmp_path)
1113 stdout, _, rc = _cli(["workspace", "add", "data", "https://example.com/data", "-b", "release", "-j"], repo)
1114 assert rc == 0
1115 assert _parse_add(stdout)["branch"] == "release"
1116
1117 def test_add_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
1118 """JSON output is well-formed."""
1119 repo = _make_repo(tmp_path)
1120 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core", "-j"], repo)
1121 assert rc == 0
1122 raw = json.loads(_json_blob(stdout))
1123 assert isinstance(raw, dict)
1124 assert isinstance(raw["name"], str)
1125 assert isinstance(raw["branch"], str)
1126
1127
1128 class TestWorkspaceAddSecurity:
1129 """Input validation, ANSI sanitization, error routing."""
1130
1131 def test_add_invalid_url_scheme_rejected(self, tmp_path: pathlib.Path) -> None:
1132 """file:// URL scheme is rejected with exit code 1."""
1133 repo = _make_repo(tmp_path)
1134 _, stderr, rc = _cli(["workspace", "add", "bad", "file:///etc/passwd", "-j"], repo)
1135 assert rc == 1
1136 assert "scheme" in stderr.lower() or "not allowed" in stderr.lower()
1137
1138 def test_add_ftp_scheme_rejected(self, tmp_path: pathlib.Path) -> None:
1139 """ftp:// URL scheme is rejected."""
1140 repo = _make_repo(tmp_path)
1141 _, _, rc = _cli(["workspace", "add", "bad", "ftp://example.com/repo", "-j"], repo)
1142 assert rc == 1
1143
1144 def test_add_null_byte_in_url_rejected(self, tmp_path: pathlib.Path) -> None:
1145 """Null byte in URL is rejected by the core validator."""
1146 repo = _make_repo(tmp_path)
1147 with pytest.raises(ValueError, match="null"):
1148 add_workspace_member(repo, "bad", "https://example.com/\x00repo")
1149
1150 def test_add_path_traversal_rejected(self, tmp_path: pathlib.Path) -> None:
1151 """--path escaping workspace root is rejected."""
1152 repo = _make_repo(tmp_path)
1153 _, stderr, rc = _cli(["workspace", "add", "bad", "https://example.com/repo", "--path", "../../etc", "-j"], repo)
1154 assert rc == 1
1155 assert "outside" in stderr.lower() or "escape" in stderr.lower() or "resolves" in stderr.lower()
1156
1157 def test_add_invalid_name_rejected(self, tmp_path: pathlib.Path) -> None:
1158 """Name with slashes is rejected."""
1159 repo = _make_repo(tmp_path)
1160 _, _, rc = _cli(["workspace", "add", "bad/name", "https://example.com/repo", "-j"], repo)
1161 assert rc == 1
1162
1163 def test_add_empty_name_rejected(self, tmp_path: pathlib.Path) -> None:
1164 """Empty name is rejected."""
1165 repo = _make_repo(tmp_path)
1166 _, _, rc = _cli(["workspace", "add", "", "https://example.com/repo", "-j"], repo)
1167 assert rc != 0
1168
1169 def test_add_error_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
1170 """Error output (duplicate) goes to stderr, not stdout."""
1171 repo = _make_repo(tmp_path)
1172 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1173 stdout, stderr, rc = _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1174 assert rc == 1
1175 assert not stdout.strip().startswith("{")
1176 assert stderr.strip() != ""
1177
1178 def test_add_json_no_ansi_in_output(self, tmp_path: pathlib.Path) -> None:
1179 """JSON output contains no ANSI escape sequences."""
1180 repo = _make_repo(tmp_path)
1181 stdout, _, rc = _cli(["workspace", "add", "core", "https://example.com/core", "-j"], repo)
1182 assert rc == 0
1183 assert "\x1b" not in stdout
1184
1185 def test_add_ssh_scheme_rejected(self, tmp_path: pathlib.Path) -> None:
1186 """ssh:// URL scheme is rejected."""
1187 repo = _make_repo(tmp_path)
1188 _, _, rc = _cli(["workspace", "add", "bad", "ssh://example.com/repo"], repo)
1189 assert rc == 1
1190
1191
1192 class TestWorkspaceAddStress:
1193 """Performance and scale tests for workspace add."""
1194
1195 def test_add_20_sequential(self, tmp_path: pathlib.Path) -> None:
1196 """20 members can be added sequentially without error."""
1197 repo = _make_repo(tmp_path)
1198 for i in range(20):
1199 _, _, rc = _cli(["workspace", "add", f"svc{i:02d}", f"https://example.com/svc{i}", "-j"], repo)
1200 assert rc == 0
1201 stdout, _, rc = _cli(["workspace", "list", "--json"], repo)
1202 assert rc == 0
1203 members = _parse_list(stdout)
1204 assert len(members) == 20
1205
1206 def test_add_performance(self, tmp_path: pathlib.Path) -> None:
1207 """Adding 10 members sequentially completes within 5 seconds."""
1208 repo = _make_repo(tmp_path)
1209 t0 = time.monotonic()
1210 for i in range(10):
1211 _cli(["workspace", "add", f"svc{i}", f"https://example.com/svc{i}"], repo)
1212 elapsed = time.monotonic() - t0
1213 assert elapsed < 10.0, f"10 adds took {elapsed:.2f}s"
1214
1215 def test_add_remove_add_cycle(self, tmp_path: pathlib.Path) -> None:
1216 """A member can be re-added with the same name after removal."""
1217 repo = _make_repo(tmp_path)
1218 for _ in range(5):
1219 _, _, rc_add = _cli(["workspace", "add", "core", "https://example.com/core", "-j"], repo)
1220 assert rc_add == 0
1221 _, _, rc_rm = _cli(["workspace", "remove", "core"], repo)
1222 assert rc_rm == 0
1223
1224
1225 # ===========================================================================
1226 # muse workspace update — Extended / Security / Stress
1227 # ===========================================================================
1228
1229
1230 class TestWorkspaceUpdateExtended:
1231 """-j alias, JSON schema, per-field updates, no-flags guard, edge cases."""
1232
1233 def test_update_j_alias(self, tmp_path: pathlib.Path) -> None:
1234 """-j produces the same JSON as --json (ignoring duration_ms)."""
1235 repo = _make_repo(tmp_path)
1236 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1237 s1, _, rc1 = _cli(["workspace", "update", "core", "--branch", "dev", "--json"], repo)
1238 _cli(["workspace", "update", "core", "--branch", "main"], repo)
1239 s2, _, rc2 = _cli(["workspace", "update", "core", "--branch", "dev", "-j"], repo)
1240 assert rc1 == 0 and rc2 == 0
1241 d1 = json.loads(_json_blob(s1)); d1.pop("duration_ms", None); d1.pop("timestamp", None)
1242 d2 = json.loads(_json_blob(s2)); d2.pop("duration_ms", None); d2.pop("timestamp", None)
1243 assert d1 == d2
1244
1245 def test_update_branch_reflected_in_json(self, tmp_path: pathlib.Path) -> None:
1246 """Updated branch appears in JSON output."""
1247 repo = _make_repo(tmp_path)
1248 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1249 stdout, _, rc = _cli(["workspace", "update", "core", "--branch", "release", "-j"], repo)
1250 assert rc == 0
1251 d = _parse_update(stdout)
1252 assert d["branch"] == "release"
1253
1254 def test_update_url_reflected_in_json(self, tmp_path: pathlib.Path) -> None:
1255 """Updated URL appears in JSON output."""
1256 repo = _make_repo(tmp_path)
1257 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1258 new_url = "https://example.com/core-v2"
1259 stdout, _, rc = _cli(["workspace", "update", "core", "--url", new_url, "-j"], repo)
1260 assert rc == 0
1261 d = _parse_update(stdout)
1262 assert d["url"] == new_url
1263
1264 def test_update_path_reflected_in_json(self, tmp_path: pathlib.Path) -> None:
1265 """Updated path appears in JSON output."""
1266 repo = _make_repo(tmp_path)
1267 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1268 stdout, _, rc = _cli(["workspace", "update", "core", "--path", "vendor/core", "-j"], repo)
1269 assert rc == 0
1270 d = _parse_update(stdout)
1271 assert "vendor/core" in d["path"]
1272
1273 def test_update_json_all_fields_present(self, tmp_path: pathlib.Path) -> None:
1274 """JSON output contains name, url, path, branch, exit_code, duration_ms."""
1275 repo = _make_repo(tmp_path)
1276 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1277 stdout, _, rc = _cli(["workspace", "update", "core", "--branch", "dev", "-j"], repo)
1278 assert rc == 0
1279 raw = json.loads(_json_blob(stdout))
1280 assert {"name", "url", "path", "branch", "exit_code", "duration_ms"}.issubset(raw.keys())
1281
1282 def test_update_json_name_unchanged(self, tmp_path: pathlib.Path) -> None:
1283 """JSON name field matches the original member name."""
1284 repo = _make_repo(tmp_path)
1285 _cli(["workspace", "add", "myrepo", "https://example.com/myrepo"], repo)
1286 stdout, _, rc = _cli(["workspace", "update", "myrepo", "--branch", "dev", "-j"], repo)
1287 assert rc == 0
1288 d = _parse_update(stdout)
1289 assert d["name"] == "myrepo"
1290
1291 def test_update_omitted_fields_preserved(self, tmp_path: pathlib.Path) -> None:
1292 """Fields not supplied in --update are preserved from original."""
1293 repo = _make_repo(tmp_path)
1294 _cli(["workspace", "add", "core", "https://example.com/core", "--branch", "v1"], repo)
1295 stdout, _, rc = _cli(["workspace", "update", "core", "--path", "vendor/core", "-j"], repo)
1296 assert rc == 0
1297 d = _parse_update(stdout)
1298 assert d["branch"] == "v1" # unchanged
1299 assert d["url"] == "https://example.com/core" # unchanged
1300
1301 def test_update_no_flags_exits_1(self, tmp_path: pathlib.Path) -> None:
1302 """Supplying no --url/--path/--branch flags exits with code 1."""
1303 repo = _make_repo(tmp_path)
1304 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1305 _, stderr, rc = _cli(["workspace", "update", "core"], repo)
1306 assert rc == 1
1307 assert "url" in stderr.lower() or "path" in stderr.lower() or "branch" in stderr.lower()
1308
1309 def test_update_nonexistent_exits_1(self, tmp_path: pathlib.Path) -> None:
1310 """Updating a nonexistent member exits with code 1."""
1311 repo = _make_repo(tmp_path)
1312 _, stderr, rc = _cli(["workspace", "update", "ghost", "--branch", "dev"], repo)
1313 assert rc == 1
1314 assert "ghost" in stderr
1315
1316 def test_update_outside_repo_exits_1_member_not_found(self, tmp_path: pathlib.Path) -> None:
1317 """Workspace update from a non-repo dir exits 1 (member not found), not 2."""
1318 empty = tmp_path / "empty"
1319 empty.mkdir()
1320 _, _, rc = _cli(["workspace", "update", "core", "--branch", "dev"], empty)
1321 assert rc == 1
1322
1323 def test_update_default_is_text(self, tmp_path: pathlib.Path) -> None:
1324 """Without --json the output is human-readable text."""
1325 repo = _make_repo(tmp_path)
1326 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1327 stdout, _, rc = _cli(["workspace", "update", "core", "--branch", "dev"], repo)
1328 assert rc == 0
1329 assert not stdout.strip().startswith("{")
1330 assert "Updated" in stdout
1331
1332 def test_update_text_contains_name(self, tmp_path: pathlib.Path) -> None:
1333 """Text output mentions the member name."""
1334 repo = _make_repo(tmp_path)
1335 _cli(["workspace", "add", "myrepo", "https://example.com/myrepo"], repo)
1336 stdout, _, rc = _cli(["workspace", "update", "myrepo", "--branch", "dev"], repo)
1337 assert rc == 0
1338 assert "myrepo" in stdout
1339
1340 def test_update_help_has_description(self, tmp_path: pathlib.Path) -> None:
1341 """--help includes the rich description."""
1342 repo = _make_repo(tmp_path)
1343 stdout, _, rc = _cli(["workspace", "update", "--help"], repo)
1344 assert rc == 0
1345 assert "Agent quickstart" in stdout or "JSON output schema" in stdout
1346
1347 def test_update_multiple_flags_at_once(self, tmp_path: pathlib.Path) -> None:
1348 """All three fields can be updated in one command."""
1349 repo = _make_repo(tmp_path)
1350 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1351 stdout, _, rc = _cli([
1352 "workspace", "update", "core",
1353 "--url", "https://example.com/core-v2",
1354 "--path", "vendor/core",
1355 "--branch", "release",
1356 "-j",
1357 ], repo)
1358 assert rc == 0
1359 d = _parse_update(stdout)
1360 assert d["url"] == "https://example.com/core-v2"
1361 assert "vendor/core" in d["path"]
1362 assert d["branch"] == "release"
1363
1364 def test_update_reflected_in_list(self, tmp_path: pathlib.Path) -> None:
1365 """Updated branch appears in subsequent 'workspace list --json'."""
1366 repo = _make_repo(tmp_path)
1367 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1368 _cli(["workspace", "update", "core", "--branch", "release"], repo)
1369 stdout, _, rc = _cli(["workspace", "list", "--json"], repo)
1370 assert rc == 0
1371 members = _parse_list(stdout)
1372 core = next(m for m in members if m["name"] == "core")
1373 assert core["branch"] == "release"
1374
1375 def test_update_shorthand_branch_flag(self, tmp_path: pathlib.Path) -> None:
1376 """-b shorthand sets the branch correctly."""
1377 repo = _make_repo(tmp_path)
1378 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1379 stdout, _, rc = _cli(["workspace", "update", "core", "-b", "hotfix", "-j"], repo)
1380 assert rc == 0
1381 assert _parse_update(stdout)["branch"] == "hotfix"
1382
1383 def test_update_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
1384 """JSON output is well-formed."""
1385 repo = _make_repo(tmp_path)
1386 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1387 stdout, _, rc = _cli(["workspace", "update", "core", "--branch", "dev", "-j"], repo)
1388 assert rc == 0
1389 raw = json.loads(_json_blob(stdout))
1390 assert isinstance(raw, dict)
1391 for field in ("name", "url", "path", "branch"):
1392 assert isinstance(raw[field], str)
1393
1394
1395 class TestWorkspaceUpdateSecurity:
1396 """Input validation, ANSI sanitization, error routing."""
1397
1398 def test_update_invalid_url_scheme_rejected(self, tmp_path: pathlib.Path) -> None:
1399 """file:// URL scheme in --url is rejected."""
1400 repo = _make_repo(tmp_path)
1401 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1402 _, stderr, rc = _cli(["workspace", "update", "core", "--url", "file:///etc/passwd"], repo)
1403 assert rc == 1
1404 assert "scheme" in stderr.lower() or "not allowed" in stderr.lower()
1405
1406 def test_update_path_traversal_rejected(self, tmp_path: pathlib.Path) -> None:
1407 """--path escaping workspace root is rejected."""
1408 repo = _make_repo(tmp_path)
1409 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1410 _, stderr, rc = _cli(["workspace", "update", "core", "--path", "../../etc"], repo)
1411 assert rc == 1
1412 assert "outside" in stderr.lower() or "escape" in stderr.lower() or "resolves" in stderr.lower()
1413
1414 def test_update_null_byte_in_path_rejected(self, tmp_path: pathlib.Path) -> None:
1415 """Null byte in --path is rejected by the core validator."""
1416 repo = _make_repo(tmp_path)
1417 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1418 with pytest.raises(ValueError, match="null"):
1419 update_workspace_member(repo, "core", path="vendor/\x00core")
1420
1421 def test_update_error_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
1422 """Error output (member not found) goes to stderr, not stdout."""
1423 repo = _make_repo(tmp_path)
1424 stdout, stderr, rc = _cli(["workspace", "update", "ghost", "--branch", "dev"], repo)
1425 assert rc == 1
1426 assert not stdout.strip().startswith("{")
1427 assert stderr.strip() != ""
1428
1429 def test_update_json_no_ansi_in_output(self, tmp_path: pathlib.Path) -> None:
1430 """JSON output contains no ANSI escape sequences."""
1431 repo = _make_repo(tmp_path)
1432 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1433 stdout, _, rc = _cli(["workspace", "update", "core", "--branch", "dev", "-j"], repo)
1434 assert rc == 0
1435 assert "\x1b" not in stdout
1436
1437 def test_update_ftp_url_rejected(self, tmp_path: pathlib.Path) -> None:
1438 """ftp:// URL scheme is rejected."""
1439 repo = _make_repo(tmp_path)
1440 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1441 _, _, rc = _cli(["workspace", "update", "core", "--url", "ftp://example.com/repo"], repo)
1442 assert rc == 1
1443
1444 def test_update_no_flags_error_to_stderr(self, tmp_path: pathlib.Path) -> None:
1445 """No-flags error is on stderr; stdout has no JSON."""
1446 repo = _make_repo(tmp_path)
1447 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1448 stdout, stderr, rc = _cli(["workspace", "update", "core"], repo)
1449 assert rc == 1
1450 assert not stdout.strip().startswith("{")
1451 assert stderr.strip() != ""
1452
1453
1454 class TestWorkspaceUpdateStress:
1455 """Performance and scale tests for workspace update."""
1456
1457 def test_update_10_members_sequential(self, tmp_path: pathlib.Path) -> None:
1458 """10 members can each be updated sequentially."""
1459 repo = _make_repo(tmp_path)
1460 for i in range(10):
1461 _cli(["workspace", "add", f"svc{i}", f"https://example.com/svc{i}"], repo)
1462 failures = []
1463 for i in range(10):
1464 _, _, rc = _cli(["workspace", "update", f"svc{i}", "--branch", f"v{i}", "-j"], repo)
1465 if rc != 0:
1466 failures.append(f"svc{i}")
1467 assert not failures
1468 stdout, _, _ = _cli(["workspace", "list", "--json"], repo)
1469 members = {m["name"]: m for m in _parse_list(stdout)}
1470 for i in range(10):
1471 assert members[f"svc{i}"]["branch"] == f"v{i}"
1472
1473 def test_update_performance(self, tmp_path: pathlib.Path) -> None:
1474 """10 sequential updates complete within 5 seconds."""
1475 repo = _make_repo(tmp_path)
1476 for i in range(10):
1477 _cli(["workspace", "add", f"svc{i}", f"https://example.com/svc{i}"], repo)
1478 t0 = time.monotonic()
1479 for i in range(10):
1480 _cli(["workspace", "update", f"svc{i}", "--branch", "release"], repo)
1481 elapsed = time.monotonic() - t0
1482 assert elapsed < 15.0, f"10 updates took {elapsed:.2f}s"
1483
1484 def test_update_repeated_same_member(self, tmp_path: pathlib.Path) -> None:
1485 """A member can be updated 10 times in a row without error."""
1486 repo = _make_repo(tmp_path)
1487 _cli(["workspace", "add", "core", "https://example.com/core"], repo)
1488 for i in range(10):
1489 _, _, rc = _cli(["workspace", "update", "core", "--branch", f"v{i}", "-j"], repo)
1490 assert rc == 0
1491 stdout, _, _ = _cli(["workspace", "list", "--json"], repo)
1492 members = _parse_list(stdout)
1493 core = next(m for m in members if m["name"] == "core")
1494 assert core["branch"] == "v9"
1495
1496
1497 # ===========================================================================
1498 # muse workspace list — Extended / Security / Stress
1499 # ===========================================================================
1500
1501
1502 class TestWorkspaceListExtended:
1503 """-j alias, JSON schema, text output, ordering, edge cases."""
1504
1505 def test_list_j_alias(self, tmp_path: pathlib.Path) -> None:
1506 """-j produces the same JSON as --json (ignoring duration_ms)."""
1507 repo = _make_repo(tmp_path)
1508 add_workspace_member(repo, "core", "https://example.com/core")
1509 s1, _, rc1 = _cli(["workspace", "list", "--json"], repo)
1510 s2, _, rc2 = _cli(["workspace", "list", "-j"], repo)
1511 assert rc1 == 0 and rc2 == 0
1512 d1 = json.loads(_json_blob(s1)); d1.pop("duration_ms", None); d1.pop("timestamp", None)
1513 d2 = json.loads(_json_blob(s2)); d2.pop("duration_ms", None); d2.pop("timestamp", None)
1514 assert d1 == d2
1515
1516 def test_list_empty_exits_0(self, tmp_path: pathlib.Path) -> None:
1517 """List with no members exits 0 and returns empty members array."""
1518 repo = _make_repo(tmp_path)
1519 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1520 assert rc == 0
1521 assert json.loads(_json_blob(stdout))["members"] == []
1522
1523 def test_list_json_is_array(self, tmp_path: pathlib.Path) -> None:
1524 """JSON output is an envelope with a members array."""
1525 repo = _make_repo(tmp_path)
1526 add_workspace_member(repo, "core", "https://example.com/core")
1527 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1528 assert rc == 0
1529 raw = json.loads(_json_blob(stdout))
1530 assert isinstance(raw["members"], list)
1531
1532 def test_list_json_all_fields_present(self, tmp_path: pathlib.Path) -> None:
1533 """Every member entry has the required fields."""
1534 repo = _make_repo(tmp_path)
1535 add_workspace_member(repo, "core", "https://example.com/core")
1536 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1537 assert rc == 0
1538 raw = json.loads(_json_blob(stdout))
1539 assert len(raw["members"]) == 1
1540 entry = raw["members"][0]
1541 for field in ("name", "url", "path", "branch", "present", "head_commit", "dirty"):
1542 assert field in entry, f"field '{field}' missing"
1543
1544 def test_list_json_name_matches(self, tmp_path: pathlib.Path) -> None:
1545 """name field in JSON matches the registered member name."""
1546 repo = _make_repo(tmp_path)
1547 add_workspace_member(repo, "myrepo", "https://example.com/myrepo")
1548 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1549 assert any(m["name"] == "myrepo" for m in members)
1550
1551 def test_list_json_url_matches(self, tmp_path: pathlib.Path) -> None:
1552 """url field in JSON matches the registered URL."""
1553 repo = _make_repo(tmp_path)
1554 url = "https://example.com/myrepo"
1555 add_workspace_member(repo, "myrepo", url)
1556 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1557 assert members[0]["url"] == url
1558
1559 def test_list_json_branch_default_main(self, tmp_path: pathlib.Path) -> None:
1560 """branch field defaults to 'main'."""
1561 repo = _make_repo(tmp_path)
1562 add_workspace_member(repo, "core", "https://example.com/core")
1563 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1564 assert members[0]["branch"] == "main"
1565
1566 def test_list_json_present_false_when_not_cloned(self, tmp_path: pathlib.Path) -> None:
1567 """present=false when the checkout directory does not exist."""
1568 repo = _make_repo(tmp_path)
1569 add_workspace_member(repo, "core", "https://example.com/core")
1570 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1571 assert members[0]["present"] is False
1572
1573 def test_list_json_head_commit_null_when_not_cloned(self, tmp_path: pathlib.Path) -> None:
1574 """head_commit is null when the member is not yet cloned."""
1575 repo = _make_repo(tmp_path)
1576 add_workspace_member(repo, "core", "https://example.com/core")
1577 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1578 assert members[0]["head_commit"] is None
1579
1580 def test_list_json_count_matches_registered(self, tmp_path: pathlib.Path) -> None:
1581 """Array length equals number of registered members."""
1582 repo = _make_repo(tmp_path)
1583 for i in range(5):
1584 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
1585 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1586 assert len(members) == 5
1587
1588 def test_list_json_reflects_update(self, tmp_path: pathlib.Path) -> None:
1589 """Updated branch appears in list JSON after update."""
1590 repo = _make_repo(tmp_path)
1591 add_workspace_member(repo, "core", "https://example.com/core")
1592 update_workspace_member(repo, "core", branch="release")
1593 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1594 assert members[0]["branch"] == "release"
1595
1596 def test_list_json_member_removed_not_shown(self, tmp_path: pathlib.Path) -> None:
1597 """Removed member no longer appears in list."""
1598 repo = _make_repo(tmp_path)
1599 add_workspace_member(repo, "core", "https://example.com/core")
1600 add_workspace_member(repo, "data", "https://example.com/data")
1601 remove_workspace_member(repo, "core")
1602 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1603 assert all(m["name"] != "core" for m in members)
1604 assert any(m["name"] == "data" for m in members)
1605
1606 def test_list_default_is_text(self, tmp_path: pathlib.Path) -> None:
1607 """Without --json output is human-readable text."""
1608 repo = _make_repo(tmp_path)
1609 add_workspace_member(repo, "core", "https://example.com/core")
1610 stdout, _, rc = _cli(["workspace", "list"], repo)
1611 assert rc == 0
1612 assert not stdout.strip().startswith("[")
1613
1614 def test_list_text_empty_message(self, tmp_path: pathlib.Path) -> None:
1615 """Text output says 'No workspace members' when list is empty."""
1616 repo = _make_repo(tmp_path)
1617 stdout, _, rc = _cli(["workspace", "list"], repo)
1618 assert rc == 0
1619 assert "No workspace members" in stdout
1620
1621 def test_list_text_shows_member_name(self, tmp_path: pathlib.Path) -> None:
1622 """Text output includes the member name."""
1623 repo = _make_repo(tmp_path)
1624 add_workspace_member(repo, "myrepo", "https://example.com/myrepo")
1625 stdout, _, rc = _cli(["workspace", "list"], repo)
1626 assert rc == 0
1627 assert "myrepo" in stdout
1628
1629 def test_list_outside_repo_succeeds_empty(self, tmp_path: pathlib.Path) -> None:
1630 """Workspace list from a non-repo dir returns empty members — no muse repo required."""
1631 empty = tmp_path / "empty"
1632 empty.mkdir()
1633 stdout, _, rc = _cli(["workspace", "list", "--json"], empty)
1634 assert rc == 0
1635 assert json.loads(stdout)["members"] == []
1636
1637 def test_list_help_has_description(self, tmp_path: pathlib.Path) -> None:
1638 """--help includes the rich description."""
1639 repo = _make_repo(tmp_path)
1640 stdout, _, rc = _cli(["workspace", "list", "--help"], repo)
1641 assert rc == 0
1642 assert "Agent quickstart" in stdout or "JSON output schema" in stdout
1643
1644 def test_list_json_valid_types(self, tmp_path: pathlib.Path) -> None:
1645 """All JSON field types are correct."""
1646 repo = _make_repo(tmp_path)
1647 add_workspace_member(repo, "core", "https://example.com/core")
1648 raw = json.loads(_json_blob(_cli(["workspace", "list", "-j"], repo)[0]))
1649 entry = raw["members"][0]
1650 assert isinstance(entry["name"], str)
1651 assert isinstance(entry["url"], str)
1652 assert isinstance(entry["path"], str)
1653 assert isinstance(entry["branch"], str)
1654 assert isinstance(entry["present"], bool)
1655 assert entry["head_commit"] is None or isinstance(entry["head_commit"], str)
1656 assert isinstance(entry["dirty"], bool)
1657
1658
1659 class TestWorkspaceListSecurity:
1660 """ANSI sanitization and output integrity."""
1661
1662 def test_list_json_ansi_in_name_sanitized(self, tmp_path: pathlib.Path) -> None:
1663 """ANSI codes in stored member name are stripped from JSON output."""
1664 repo = _make_repo(tmp_path)
1665 # Inject ANSI directly into manifest via core (bypassing CLI validation)
1666 manifest_path = workspace_toml_path(repo)
1667 manifest_path.parent.mkdir(parents=True, exist_ok=True)
1668 manifest_path.write_text(
1669 '[workspace]\n[[workspace.members]]\n'
1670 'name = "core\\u001b[31mred\\u001b[0m"\n'
1671 'url = "https://example.com/core"\n'
1672 'path = "repos/core"\n'
1673 'branch = "main"\n'
1674 )
1675 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1676 assert rc == 0
1677 assert "\x1b" not in stdout
1678
1679 def test_list_json_ansi_in_url_sanitized(self, tmp_path: pathlib.Path) -> None:
1680 """ANSI codes in stored URL are stripped from JSON output."""
1681 repo = _make_repo(tmp_path)
1682 manifest_path = workspace_toml_path(repo)
1683 manifest_path.parent.mkdir(parents=True, exist_ok=True)
1684 manifest_path.write_text(
1685 '[workspace]\n[[workspace.members]]\n'
1686 'name = "core"\n'
1687 'url = "https://example.com/core\\u001b[31m"\n'
1688 'path = "repos/core"\n'
1689 'branch = "main"\n'
1690 )
1691 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1692 assert rc == 0
1693 assert "\x1b" not in stdout
1694
1695 def test_list_text_no_ansi_in_output(self, tmp_path: pathlib.Path) -> None:
1696 """Text output contains no ANSI escape sequences."""
1697 repo = _make_repo(tmp_path)
1698 add_workspace_member(repo, "core", "https://example.com/core")
1699 stdout, _, rc = _cli(["workspace", "list"], repo)
1700 assert rc == 0
1701 assert "\x1b" not in stdout
1702
1703 def test_list_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
1704 """JSON output is well-formed even with multiple members."""
1705 repo = _make_repo(tmp_path)
1706 for i in range(3):
1707 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
1708 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1709 assert rc == 0
1710 raw = json.loads(_json_blob(stdout))
1711 assert isinstance(raw["members"], list)
1712 assert len(raw["members"]) == 3
1713
1714 def test_list_json_dirty_is_bool(self, tmp_path: pathlib.Path) -> None:
1715 """dirty field is always a boolean, never a string or int."""
1716 repo = _make_repo(tmp_path)
1717 add_workspace_member(repo, "core", "https://example.com/core")
1718 raw = json.loads(_json_blob(_cli(["workspace", "list", "-j"], repo)[0]))
1719 assert isinstance(raw["members"][0]["dirty"], bool)
1720
1721
1722 class TestWorkspaceListStress:
1723 """Performance and scale tests for workspace list."""
1724
1725 def test_list_50_members(self, tmp_path: pathlib.Path) -> None:
1726 """List returns all 50 members when 50 are registered."""
1727 repo = _make_repo(tmp_path)
1728 for i in range(50):
1729 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
1730 members = _parse_list(_cli(["workspace", "list", "-j"], repo)[0])
1731 assert len(members) == 50
1732
1733 def test_list_performance_50_members(self, tmp_path: pathlib.Path) -> None:
1734 """Listing 50 members completes within 5 seconds."""
1735 repo = _make_repo(tmp_path)
1736 for i in range(50):
1737 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
1738 t0 = time.monotonic()
1739 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1740 elapsed = time.monotonic() - t0
1741 assert rc == 0
1742 assert elapsed < 5.0, f"list of 50 took {elapsed:.2f}s"
1743
1744 def test_list_concurrent_reads_consistent(self, tmp_path: pathlib.Path) -> None:
1745 """Concurrent list reads all return the same member count."""
1746 repo = _make_repo(tmp_path)
1747 for i in range(20):
1748 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
1749 counts: list[int] = []
1750 errors: list[str] = []
1751 lock = threading.Lock()
1752
1753 def _run() -> None:
1754 stdout, _, rc = _cli(["workspace", "list", "-j"], repo)
1755 with lock:
1756 if rc != 0:
1757 errors.append(stdout)
1758 else:
1759 counts.append(len(json.loads(_json_blob(stdout))["members"]))
1760
1761 threads = [threading.Thread(target=_run) for _ in range(8)]
1762 for t in threads:
1763 t.start()
1764 for t in threads:
1765 t.join()
1766 assert not errors, f"Concurrent list errors: {errors}"
1767 assert all(c == 20 for c in counts), f"Inconsistent counts: {counts}"
1768
1769
1770 # ---------------------------------------------------------------------------
1771 # workspace remove — Extended, Security, Stress
1772 # ---------------------------------------------------------------------------
1773
1774
1775 class TestWorkspaceRemoveExtended:
1776 """Extended unit / integration / e2e tests for muse workspace remove."""
1777
1778 def test_remove_exits_0_on_success(self, tmp_path: pathlib.Path) -> None:
1779 """Successful remove exits with code 0."""
1780 repo = _make_repo(tmp_path)
1781 add_workspace_member(repo, "core", "https://example.com/core")
1782 _, _, rc = _cli(["workspace", "remove", "core"], repo)
1783 assert rc == 0
1784
1785 def test_remove_j_alias_works(self, tmp_path: pathlib.Path) -> None:
1786 """-j is an accepted alias for --json."""
1787 repo = _make_repo(tmp_path)
1788 add_workspace_member(repo, "core", "https://example.com/core")
1789 stdout, _, rc = _cli(["workspace", "remove", "core", "-j"], repo)
1790 assert rc == 0
1791 d = _parse_remove(stdout)
1792 assert d["removed"] is True
1793
1794 def test_remove_json_name_matches(self, tmp_path: pathlib.Path) -> None:
1795 """JSON output name matches the removed member's name."""
1796 repo = _make_repo(tmp_path)
1797 add_workspace_member(repo, "sounds", "https://example.com/sounds")
1798 stdout, _, rc = _cli(["workspace", "remove", "sounds", "--json"], repo)
1799 assert rc == 0
1800 d = _parse_remove(stdout)
1801 assert d["name"] == "sounds"
1802
1803 def test_remove_json_removed_true(self, tmp_path: pathlib.Path) -> None:
1804 """JSON output always has removed=true on success."""
1805 repo = _make_repo(tmp_path)
1806 add_workspace_member(repo, "core", "https://example.com/core")
1807 stdout, _, rc = _cli(["workspace", "remove", "core", "--json"], repo)
1808 assert rc == 0
1809 assert json.loads(_json_blob(stdout))["removed"] is True
1810
1811 def test_remove_member_no_longer_in_list(self, tmp_path: pathlib.Path) -> None:
1812 """After remove, the member is absent from workspace list."""
1813 repo = _make_repo(tmp_path)
1814 add_workspace_member(repo, "core", "https://example.com/core")
1815 add_workspace_member(repo, "data", "https://example.com/data")
1816 _cli(["workspace", "remove", "core"], repo)
1817 members = _parse_list(_cli(["workspace", "list", "--json"], repo)[0])
1818 names = [m["name"] for m in members]
1819 assert "core" not in names
1820 assert "data" in names
1821
1822 def test_remove_only_named_member_removed(self, tmp_path: pathlib.Path) -> None:
1823 """Remove deletes exactly one member; others are untouched."""
1824 repo = _make_repo(tmp_path)
1825 for n in ("alpha", "beta", "gamma"):
1826 add_workspace_member(repo, n, f"https://example.com/{n}")
1827 _cli(["workspace", "remove", "beta"], repo)
1828 members = _parse_list(_cli(["workspace", "list", "--json"], repo)[0])
1829 names = [m["name"] for m in members]
1830 assert names == ["alpha", "gamma"]
1831
1832 def test_remove_idempotent_error_on_second_call(self, tmp_path: pathlib.Path) -> None:
1833 """Removing the same member twice returns an error on the second call."""
1834 repo = _make_repo(tmp_path)
1835 add_workspace_member(repo, "core", "https://example.com/core")
1836 _, _, rc1 = _cli(["workspace", "remove", "core"], repo)
1837 _, _, rc2 = _cli(["workspace", "remove", "core"], repo)
1838 assert rc1 == 0
1839 assert rc2 != 0
1840
1841 def test_remove_nonexistent_exits_1(self, tmp_path: pathlib.Path) -> None:
1842 """Removing a non-existent member exits with code 1."""
1843 repo = _make_repo(tmp_path)
1844 add_workspace_member(repo, "core", "https://example.com/core")
1845 _, _, rc = _cli(["workspace", "remove", "ghost"], repo)
1846 assert rc == 1
1847
1848 def test_remove_outside_repo_exits_1_member_not_found(self, tmp_path: pathlib.Path) -> None:
1849 """Workspace remove from a non-repo dir exits 1 (member not found), not 2."""
1850 empty = tmp_path / "not_a_repo"
1851 empty.mkdir()
1852 _, _, rc = _cli(["workspace", "remove", "core"], empty)
1853 assert rc == 1
1854
1855 def test_remove_text_output_contains_name(self, tmp_path: pathlib.Path) -> None:
1856 """Text output mentions the removed member's name."""
1857 repo = _make_repo(tmp_path)
1858 add_workspace_member(repo, "sounds", "https://example.com/sounds")
1859 stdout, _, rc = _cli(["workspace", "remove", "sounds"], repo)
1860 assert rc == 0
1861 assert "sounds" in stdout
1862
1863 def test_remove_text_success_marker(self, tmp_path: pathlib.Path) -> None:
1864 """Text output contains a success indicator."""
1865 repo = _make_repo(tmp_path)
1866 add_workspace_member(repo, "core", "https://example.com/core")
1867 stdout, _, _ = _cli(["workspace", "remove", "core"], repo)
1868 assert "Removed" in stdout or "✅" in stdout
1869
1870 def test_remove_error_to_stderr_not_stdout(self, tmp_path: pathlib.Path) -> None:
1871 """Error messages go to stderr; stdout is empty on failure."""
1872 repo = _make_repo(tmp_path)
1873 add_workspace_member(repo, "core", "https://example.com/core")
1874 stdout, stderr, rc = _cli(["workspace", "remove", "ghost"], repo)
1875 assert rc != 0
1876 assert "not found" in stderr
1877 assert "not found" not in stdout
1878
1879 def test_remove_text_no_json_on_success(self, tmp_path: pathlib.Path) -> None:
1880 """Without --json, stdout does not contain a JSON object."""
1881 repo = _make_repo(tmp_path)
1882 add_workspace_member(repo, "core", "https://example.com/core")
1883 stdout, _, rc = _cli(["workspace", "remove", "core"], repo)
1884 assert rc == 0
1885 assert not stdout.strip().startswith("{")
1886
1887 def test_remove_count_decreases(self, tmp_path: pathlib.Path) -> None:
1888 """Member count decreases by exactly one after remove."""
1889 repo = _make_repo(tmp_path)
1890 for n in ("a", "b", "c"):
1891 add_workspace_member(repo, n, f"https://example.com/{n}")
1892 before = len(_parse_list(_cli(["workspace", "list", "--json"], repo)[0]))
1893 _cli(["workspace", "remove", "b"], repo)
1894 after = len(_parse_list(_cli(["workspace", "list", "--json"], repo)[0]))
1895 assert after == before - 1
1896
1897 def test_remove_last_member_leaves_empty_manifest(self, tmp_path: pathlib.Path) -> None:
1898 """Removing the only member results in an empty list."""
1899 repo = _make_repo(tmp_path)
1900 add_workspace_member(repo, "only", "https://example.com/only")
1901 _cli(["workspace", "remove", "only"], repo)
1902 members = _parse_list(_cli(["workspace", "list", "--json"], repo)[0])
1903 assert members == []
1904
1905 def test_remove_help_description_present(self, tmp_path: pathlib.Path) -> None:
1906 """--help output contains the agent-friendly description."""
1907 repo = _make_repo(tmp_path)
1908 stdout, _, _ = _cli(["workspace", "remove", "--help"], repo)
1909 assert "Unregister" in stdout or "manifest" in stdout
1910
1911 def test_remove_json_schema_keys(self, tmp_path: pathlib.Path) -> None:
1912 """JSON output has exactly the keys: name, removed, exit_code, duration_ms."""
1913 repo = _make_repo(tmp_path)
1914 add_workspace_member(repo, "core", "https://example.com/core")
1915 stdout, _, rc = _cli(["workspace", "remove", "core", "--json"], repo)
1916 assert rc == 0
1917 d = json.loads(_json_blob(stdout))
1918 assert {"name", "removed", "exit_code", "duration_ms"}.issubset(d.keys())
1919
1920 def test_remove_no_manifest_exits_1(self, tmp_path: pathlib.Path) -> None:
1921 """Remove on a repo with no workspace manifest exits 1."""
1922 repo = _make_repo(tmp_path)
1923 # No workspace.toml — remove_workspace_member raises ValueError
1924 _, stderr, rc = _cli(["workspace", "remove", "core"], repo)
1925 assert rc == 1
1926 assert stderr.strip() != ""
1927
1928
1929 class TestWorkspaceRemoveSecurity:
1930 """Security hardening tests for muse workspace remove."""
1931
1932 def test_remove_ansi_in_name_arg_sanitized_in_json(self, tmp_path: pathlib.Path) -> None:
1933 """ANSI codes in a stored name are stripped from JSON output."""
1934 repo = _make_repo(tmp_path)
1935 # Write manifest directly with an ANSI-injected name so it bypasses validator
1936 manifest_path = workspace_toml_path(repo)
1937 manifest_path.parent.mkdir(parents=True, exist_ok=True)
1938 manifest_path.write_text(
1939 '[workspace]\n[[workspace.members]]\n'
1940 'name = "malicious\\u001b[31m"\n'
1941 'url = "https://example.com/malicious"\n'
1942 'path = "repos/malicious"\n'
1943 'branch = "main"\n'
1944 )
1945 # Use the raw stored name as the CLI arg to remove it
1946 stdout, _, rc = _cli(["workspace", "remove", "malicious\x1b[31m", "--json"], repo)
1947 # Either succeeds (found) or fails (not found) — either way, no ANSI in stdout
1948 assert "\x1b" not in stdout
1949
1950 def test_remove_text_output_no_ansi(self, tmp_path: pathlib.Path) -> None:
1951 """Text output contains no ANSI escape sequences."""
1952 repo = _make_repo(tmp_path)
1953 add_workspace_member(repo, "core", "https://example.com/core")
1954 stdout, _, _ = _cli(["workspace", "remove", "core"], repo)
1955 assert "\x1b" not in stdout
1956
1957 def test_remove_json_valid_on_success(self, tmp_path: pathlib.Path) -> None:
1958 """JSON output is well-formed on success."""
1959 repo = _make_repo(tmp_path)
1960 add_workspace_member(repo, "core", "https://example.com/core")
1961 stdout, _, rc = _cli(["workspace", "remove", "core", "--json"], repo)
1962 assert rc == 0
1963 d = json.loads(_json_blob(stdout))
1964 assert isinstance(d["name"], str)
1965 assert d["removed"] is True
1966
1967 def test_remove_removed_field_is_bool(self, tmp_path: pathlib.Path) -> None:
1968 """removed field is a boolean, never a string or int."""
1969 repo = _make_repo(tmp_path)
1970 add_workspace_member(repo, "core", "https://example.com/core")
1971 stdout, _, rc = _cli(["workspace", "remove", "core", "--json"], repo)
1972 assert rc == 0
1973 d = json.loads(_json_blob(stdout))
1974 assert isinstance(d["removed"], bool)
1975
1976 def test_remove_symlink_manifest_fails_gracefully(self, tmp_path: pathlib.Path) -> None:
1977 """A symlinked manifest is refused — exits non-zero without crashing."""
1978 repo = _make_repo(tmp_path)
1979 add_workspace_member(repo, "core", "https://example.com/core")
1980 manifest_path = workspace_toml_path(repo)
1981 real = tmp_path / "real_workspace.toml"
1982 real.write_text(manifest_path.read_text())
1983 manifest_path.unlink()
1984 manifest_path.symlink_to(real)
1985 _, _, rc = _cli(["workspace", "remove", "core"], repo)
1986 # The symlink guard in _load_manifest returns None → ValueError → rc 1
1987 assert rc != 0
1988
1989 def test_remove_null_byte_in_name_raises(self, tmp_path: pathlib.Path) -> None:
1990 """Null byte in name is rejected by the core validator."""
1991 repo = _make_repo(tmp_path)
1992 add_workspace_member(repo, "core", "https://example.com/core")
1993 # remove_workspace_member does a name-equality match; null byte won't match
1994 # any valid stored name — raises ValueError (not found)
1995 with pytest.raises(ValueError):
1996 remove_workspace_member(repo, "core\x00malicious")
1997
1998
1999 class TestWorkspaceRemoveStress:
2000 """Performance and scale tests for muse workspace remove."""
2001
2002 def test_remove_from_50_member_manifest(self, tmp_path: pathlib.Path) -> None:
2003 """Remove works correctly when the manifest has 50 members."""
2004 repo = _make_repo(tmp_path)
2005 for i in range(50):
2006 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
2007 _, _, rc = _cli(["workspace", "remove", "svc025", "--json"], repo)
2008 assert rc == 0
2009 members = _parse_list(_cli(["workspace", "list", "--json"], repo)[0])
2010 assert len(members) == 49
2011 assert all(m["name"] != "svc025" for m in members)
2012
2013 def test_remove_performance_50_members(self, tmp_path: pathlib.Path) -> None:
2014 """Removing from a 50-member manifest completes within 5 seconds."""
2015 repo = _make_repo(tmp_path)
2016 for i in range(50):
2017 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
2018 t0 = time.monotonic()
2019 _, _, rc = _cli(["workspace", "remove", "svc000", "--json"], repo)
2020 elapsed = time.monotonic() - t0
2021 assert rc == 0
2022 assert elapsed < 5.0, f"remove from 50 took {elapsed:.2f}s"
2023
2024 def test_remove_sequential_removes_all(self, tmp_path: pathlib.Path) -> None:
2025 """Removing all 20 members one-by-one leaves an empty list."""
2026 repo = _make_repo(tmp_path)
2027 names = [f"svc{i:02d}" for i in range(20)]
2028 for n in names:
2029 add_workspace_member(repo, n, f"https://example.com/{n}")
2030 for n in names:
2031 _, _, rc = _cli(["workspace", "remove", n], repo)
2032 assert rc == 0
2033 members = _parse_list(_cli(["workspace", "list", "--json"], repo)[0])
2034 assert members == []
2035
2036
2037 # ---------------------------------------------------------------------------
2038 # workspace status — Extended, Security, Stress
2039 # ---------------------------------------------------------------------------
2040
2041
2042 class TestWorkspaceStatusExtended:
2043 """Extended unit / integration / e2e tests for muse workspace status."""
2044
2045 def test_status_exits_0_all_members(self, tmp_path: pathlib.Path) -> None:
2046 repo = _make_repo(tmp_path)
2047 add_workspace_member(repo, "core", "https://example.com/core")
2048 _, _, rc = _cli(["workspace", "status"], repo)
2049 assert rc == 0
2050
2051 def test_status_j_alias_works(self, tmp_path: pathlib.Path) -> None:
2052 repo = _make_repo(tmp_path)
2053 add_workspace_member(repo, "core", "https://example.com/core")
2054 stdout, _, rc = _cli(["workspace", "status", "-j"], repo)
2055 assert rc == 0
2056 members = _parse_list(stdout)
2057 assert len(members) == 1
2058
2059 def test_status_named_exits_0(self, tmp_path: pathlib.Path) -> None:
2060 repo = _make_repo(tmp_path)
2061 add_workspace_member(repo, "core", "https://example.com/core")
2062 _, _, rc = _cli(["workspace", "status", "core"], repo)
2063 assert rc == 0
2064
2065 def test_status_named_json_single_element(self, tmp_path: pathlib.Path) -> None:
2066 repo = _make_repo(tmp_path)
2067 add_workspace_member(repo, "core", "https://example.com/core")
2068 add_workspace_member(repo, "data", "https://example.com/data")
2069 stdout, _, rc = _cli(["workspace", "status", "core", "--json"], repo)
2070 assert rc == 0
2071 members = _parse_list(stdout)
2072 assert len(members) == 1
2073 assert members[0]["name"] == "core"
2074
2075 def test_status_all_json_all_members_returned(self, tmp_path: pathlib.Path) -> None:
2076 repo = _make_repo(tmp_path)
2077 for n in ("alpha", "beta", "gamma"):
2078 add_workspace_member(repo, n, f"https://example.com/{n}")
2079 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2080 assert rc == 0
2081 members = _parse_list(stdout)
2082 assert {m["name"] for m in members} == {"alpha", "beta", "gamma"}
2083
2084 def test_status_json_ten_fields(self, tmp_path: pathlib.Path) -> None:
2085 repo = _make_repo(tmp_path)
2086 add_workspace_member(repo, "core", "https://example.com/core")
2087 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2088 assert rc == 0
2089 d = json.loads(_json_blob(stdout))["members"][0]
2090 assert set(d.keys()) == {
2091 "name", "url", "path", "branch", "present", "head_commit", "dirty",
2092 "actual_branch", "shelf_count", "feature_branches", "branch_mismatch",
2093 }
2094
2095 def test_status_json_present_false_when_not_cloned(self, tmp_path: pathlib.Path) -> None:
2096 repo = _make_repo(tmp_path)
2097 add_workspace_member(repo, "core", "https://example.com/core")
2098 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2099 assert rc == 0
2100 assert _parse_list(stdout)[0]["present"] is False
2101
2102 def test_status_json_head_commit_null_when_not_cloned(self, tmp_path: pathlib.Path) -> None:
2103 repo = _make_repo(tmp_path)
2104 add_workspace_member(repo, "core", "https://example.com/core")
2105 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2106 assert rc == 0
2107 assert _parse_list(stdout)[0]["head_commit"] is None
2108
2109 def test_status_json_dirty_false_when_not_cloned(self, tmp_path: pathlib.Path) -> None:
2110 repo = _make_repo(tmp_path)
2111 add_workspace_member(repo, "core", "https://example.com/core")
2112 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2113 assert rc == 0
2114 assert _parse_list(stdout)[0]["dirty"] is False
2115
2116 def test_status_empty_exits_0(self, tmp_path: pathlib.Path) -> None:
2117 repo = _make_repo(tmp_path)
2118 _, _, rc = _cli(["workspace", "status", "--json"], repo)
2119 assert rc == 0
2120
2121 def test_status_empty_json_empty_array(self, tmp_path: pathlib.Path) -> None:
2122 repo = _make_repo(tmp_path)
2123 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2124 assert rc == 0
2125 assert _parse_list(stdout) == []
2126
2127 def test_status_nonexistent_name_exits_1(self, tmp_path: pathlib.Path) -> None:
2128 repo = _make_repo(tmp_path)
2129 add_workspace_member(repo, "core", "https://example.com/core")
2130 _, _, rc = _cli(["workspace", "status", "ghost"], repo)
2131 assert rc == 1
2132
2133 def test_status_nonexistent_error_to_stderr(self, tmp_path: pathlib.Path) -> None:
2134 repo = _make_repo(tmp_path)
2135 add_workspace_member(repo, "core", "https://example.com/core")
2136 stdout, stderr, rc = _cli(["workspace", "status", "ghost"], repo)
2137 assert rc != 0
2138 assert "not found" in stderr
2139 assert "not found" not in stdout
2140
2141 def test_status_outside_repo_succeeds_empty(self, tmp_path: pathlib.Path) -> None:
2142 """Workspace status from a non-repo dir returns empty members — no muse repo required."""
2143 empty = tmp_path / "not_a_repo"
2144 empty.mkdir()
2145 stdout, _, rc = _cli(["workspace", "status", "--json"], empty)
2146 assert rc == 0
2147 assert json.loads(stdout)["members"] == []
2148
2149 def test_status_text_contains_member_name(self, tmp_path: pathlib.Path) -> None:
2150 repo = _make_repo(tmp_path)
2151 add_workspace_member(repo, "sounds", "https://example.com/sounds")
2152 stdout, _, rc = _cli(["workspace", "status"], repo)
2153 assert rc == 0
2154 assert "sounds" in stdout
2155
2156 def test_status_text_empty_message(self, tmp_path: pathlib.Path) -> None:
2157 repo = _make_repo(tmp_path)
2158 stdout, _, rc = _cli(["workspace", "status"], repo)
2159 assert rc == 0
2160 assert "No workspace members" in stdout
2161
2162 def test_status_help_description_present(self, tmp_path: pathlib.Path) -> None:
2163 repo = _make_repo(tmp_path)
2164 stdout, _, _ = _cli(["workspace", "status", "--help"], repo)
2165 assert "Agent quickstart" in stdout or "present" in stdout
2166
2167 def test_status_json_url_matches_registered(self, tmp_path: pathlib.Path) -> None:
2168 repo = _make_repo(tmp_path)
2169 add_workspace_member(repo, "core", "https://example.com/core")
2170 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2171 assert rc == 0
2172 assert _parse_list(stdout)[0]["url"] == "https://example.com/core"
2173
2174
2175 class TestWorkspaceStatusSecurity:
2176 """Security hardening tests for muse workspace status."""
2177
2178 def test_status_json_no_ansi_in_name(self, tmp_path: pathlib.Path) -> None:
2179 repo = _make_repo(tmp_path)
2180 manifest_path = workspace_toml_path(repo)
2181 manifest_path.parent.mkdir(parents=True, exist_ok=True)
2182 manifest_path.write_text(
2183 '[workspace]\n[[workspace.members]]\n'
2184 'name = "malicious\\u001b[31m"\n'
2185 'url = "https://example.com/malicious"\n'
2186 'path = "repos/malicious"\n'
2187 'branch = "main"\n'
2188 )
2189 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2190 assert rc == 0
2191 assert "\x1b" not in stdout
2192
2193 def test_status_json_no_ansi_in_url(self, tmp_path: pathlib.Path) -> None:
2194 repo = _make_repo(tmp_path)
2195 manifest_path = workspace_toml_path(repo)
2196 manifest_path.parent.mkdir(parents=True, exist_ok=True)
2197 manifest_path.write_text(
2198 '[workspace]\n[[workspace.members]]\n'
2199 'name = "core"\n'
2200 'url = "https://example.com/\\u001b[31mcore"\n'
2201 'path = "repos/core"\n'
2202 'branch = "main"\n'
2203 )
2204 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2205 assert rc == 0
2206 assert "\x1b" not in stdout
2207
2208 def test_status_text_no_ansi(self, tmp_path: pathlib.Path) -> None:
2209 repo = _make_repo(tmp_path)
2210 add_workspace_member(repo, "core", "https://example.com/core")
2211 stdout, _, _ = _cli(["workspace", "status"], repo)
2212 assert "\x1b" not in stdout
2213
2214 def test_status_json_valid_json(self, tmp_path: pathlib.Path) -> None:
2215 repo = _make_repo(tmp_path)
2216 for i in range(3):
2217 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
2218 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2219 assert rc == 0
2220 raw = json.loads(_json_blob(stdout))
2221 assert isinstance(raw["members"], list)
2222 assert len(raw["members"]) == 3
2223
2224 def test_status_json_bool_fields_are_bool(self, tmp_path: pathlib.Path) -> None:
2225 repo = _make_repo(tmp_path)
2226 add_workspace_member(repo, "core", "https://example.com/core")
2227 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2228 assert rc == 0
2229 d = json.loads(_json_blob(stdout))["members"][0]
2230 assert isinstance(d["present"], bool)
2231 assert isinstance(d["dirty"], bool)
2232
2233 def test_status_symlink_manifest_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
2234 repo = _make_repo(tmp_path)
2235 add_workspace_member(repo, "core", "https://example.com/core")
2236 manifest_path = workspace_toml_path(repo)
2237 real = tmp_path / "real.toml"
2238 real.write_text(manifest_path.read_text())
2239 manifest_path.unlink()
2240 manifest_path.symlink_to(real)
2241 # symlink guard returns None → empty list (exits 0, empty array)
2242 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2243 assert rc == 0
2244 assert _parse_list(stdout) == []
2245
2246
2247 class TestWorkspaceStatusStress:
2248 """Performance and scale tests for muse workspace status."""
2249
2250 def test_status_50_members_all_returned(self, tmp_path: pathlib.Path) -> None:
2251 repo = _make_repo(tmp_path)
2252 for i in range(50):
2253 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
2254 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2255 assert rc == 0
2256 assert len(_parse_list(stdout)) == 50
2257
2258 def test_status_performance_50_members(self, tmp_path: pathlib.Path) -> None:
2259 repo = _make_repo(tmp_path)
2260 for i in range(50):
2261 add_workspace_member(repo, f"svc{i:03d}", f"https://example.com/svc{i}")
2262 t0 = time.monotonic()
2263 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2264 elapsed = time.monotonic() - t0
2265 assert rc == 0
2266 assert elapsed < 5.0, f"status of 50 took {elapsed:.2f}s"
2267
2268 def test_status_concurrent_reads_consistent(self, tmp_path: pathlib.Path) -> None:
2269 repo = _make_repo(tmp_path)
2270 for i in range(20):
2271 add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}")
2272 counts: list[int] = []
2273 errors: list[str] = []
2274 lock = threading.Lock()
2275
2276 def _run() -> None:
2277 stdout, _, rc = _cli(["workspace", "status", "--json"], repo)
2278 with lock:
2279 if rc != 0:
2280 errors.append(stdout)
2281 else:
2282 counts.append(len(json.loads(_json_blob(stdout))["members"]))
2283
2284 threads = [threading.Thread(target=_run) for _ in range(8)]
2285 for t in threads:
2286 t.start()
2287 for t in threads:
2288 t.join()
2289 assert not errors
2290 assert all(c == 20 for c in counts)
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 22 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 23 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 29 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 30 days ago