gabriel / muse public
test_cmd_hub_hardening.py python
8,140 lines 350.8 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
1 """Comprehensive hardening tests for ``muse hub``.
2
3 Coverage
4 --------
5 Unit
6 - _normalise_url: scheme injection (file://, ftp://), http non-loopback rejected,
7 loopback allowed, scheme-less normalised to https, trailing slash stripped
8 - _hub_hostname: standard URL, URL with port, URL with path, bare hostname
9 - _ping_hub: reachable, HTTP error, URLError, timeout, redirect refused
10 - _hub_api: file:// scheme blocked, response size cap, error detail sanitized,
11 missing token exits, None-value payload keys stripped
12 - _resolve_proposal_id: full UUID passthrough, prefix match, no match, ambiguous match
13 - _format_proposal: all fields sanitized
14
15 Integration (CliRunner + mock hub)
16 - run_connect: --json schema, re-connect warns, normalisation, invalid scheme exits
17 - run_disconnect: --json ok, --json nothing_to_do, text mode to stderr
18 - run_status: --json all keys present, no-hub exits, not-authenticated exits
19 - run_ping: --json ok, --json error, text mode to stderr, unreachable exits nonzero
20 - run_proposal_list: --json is a JSON array, text mode to stderr, no-proposals message
21 - run_proposal_create: --json schema, missing branch exits, sanitizes output
22 - run_proposal_merge: --json schema, merge=false exits nonzero
23 - run_proposal_show: --json passthrough
24
25 Security
26 - file:// hub URL blocked in _hub_api before network
27 - ANSI in proposal title/branch sanitized in _format_proposal
28 - ANSI in proposal ID sanitized in _resolve_proposal_id errors
29 - hub URL in errors sanitized in _get_hub_and_identity
30 - Response body size cap prevents OOM
31
32 E2E (via CliRunner)
33 - connect --json schema includes all required keys
34 - disconnect --json schema correct for both ok and nothing_to_do
35 - ping --json schema with all required keys
36 - status --json all keys always present (no missing keys when not authenticated)
37
38 Stress
39 - 8 concurrent ping checks against isolated mock responses
40 """
41
42 from __future__ import annotations
43
44 import json
45 import pathlib
46 import threading
47 import unittest.mock
48 import urllib.error
49 import urllib.request
50 from typing import TYPE_CHECKING
51 from unittest.mock import MagicMock, patch
52
53 import pytest
54
55 from tests.cli_test_helper import CliRunner, InvokeResult
56
57 if TYPE_CHECKING:
58 pass
59
60 from muse.cli.commands.hub import (
61 _ConnectJson,
62 _DisconnectJson,
63 _PingJson,
64 _StatusJson,
65 )
66 from muse.core._types import Manifest, MsgpackDict, MsgpackValue
67
68 type _JsonPayload = MsgpackDict
69 type _ProposalRecord = dict[str, str]
70 type _RepoResponse = dict[str, str]
71 cli = None
72 runner = CliRunner()
73
74 # ── helpers ───────────────────────────────────────────────────────────────────
75
76
77 def _json_line(result: InvokeResult) -> _JsonPayload:
78 for line in result.output.splitlines():
79 stripped = line.strip()
80 if stripped.startswith("{") or stripped.startswith("["):
81 data: _JsonPayload = json.loads(stripped)
82 return data
83 raise ValueError(f"No JSON line in output:\n{result.output!r}")
84
85
86 def _json_connect(result: InvokeResult) -> _ConnectJson:
87 d: _ConnectJson = json.loads(
88 next(l for l in result.output.splitlines() if l.strip().startswith("{"))
89 )
90 return d
91
92
93 def _json_status(result: InvokeResult) -> _StatusJson:
94 d: _StatusJson = json.loads(
95 next(l for l in result.output.splitlines() if l.strip().startswith("{"))
96 )
97 return d
98
99
100 def _json_disconnect(result: InvokeResult) -> _DisconnectJson:
101 d: _DisconnectJson = json.loads(
102 next(l for l in result.output.splitlines() if l.strip().startswith("{"))
103 )
104 return d
105
106
107 def _json_ping(result: InvokeResult) -> _PingJson:
108 d: _PingJson = json.loads(
109 next(l for l in result.output.splitlines() if l.strip().startswith("{"))
110 )
111 return d
112
113
114 @pytest.fixture
115 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
116 """Minimal .muse/ repo with identity file."""
117 from muse._version import __version__
118
119 muse_dir = tmp_path / ".muse"
120 for sub in ("refs/heads", "objects", "commits", "snapshots"):
121 (muse_dir / sub).mkdir(parents=True, exist_ok=True)
122 (muse_dir / "repo.json").write_text(
123 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
124 )
125 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
126 (muse_dir / "refs" / "heads" / "main").write_text("")
127 (muse_dir / "config.toml").write_text("")
128 muse_home = tmp_path / ".muse-home"
129 muse_home.mkdir()
130 (muse_home / "identity.toml").write_text("")
131 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", muse_home / "identity.toml")
132 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", muse_home)
133 monkeypatch.chdir(tmp_path)
134 return tmp_path
135
136
137 def _make_signing() -> "SigningIdentity":
138 """Generate a fresh Ed25519 SigningIdentity for tests."""
139 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
140 from muse.core.transport import SigningIdentity
141 return SigningIdentity(handle="testuser", private_key=Ed25519PrivateKey.generate())
142
143
144 def _store_identity(hub_url: str, handle: str = "alice") -> None:
145 """Save a proper Ed25519 identity entry for the given hub URL."""
146 import urllib.parse
147 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
148 from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat
149 from muse.core.identity import IdentityEntry, _IDENTITY_DIR, save_identity
150
151 keys_dir = _IDENTITY_DIR / "keys"
152 keys_dir.mkdir(parents=True, exist_ok=True)
153 parsed = urllib.parse.urlparse(hub_url)
154 hostname = parsed.netloc or parsed.path
155 safe_hostname = hostname.replace(":", "_").replace("/", "_")
156 key_file = keys_dir / f"{safe_hostname}.pem"
157
158 private_key = Ed25519PrivateKey.generate()
159 pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
160 key_file.write_bytes(pem)
161
162 entry: IdentityEntry = {"type": "human", "handle": handle, "key_path": str(key_file)}
163 save_identity(hub_url, entry)
164
165
166 # ── Unit: _normalise_url ──────────────────────────────────────────────────────
167
168
169 class TestNormaliseUrlHardening:
170 def test_file_scheme_raises(self) -> None:
171 from muse.cli.commands.hub import _normalise_url
172 with pytest.raises(ValueError, match="not allowed"):
173 _normalise_url("file:///etc/passwd")
174
175 def test_ftp_scheme_raises(self) -> None:
176 from muse.cli.commands.hub import _normalise_url
177 with pytest.raises(ValueError, match="not allowed"):
178 _normalise_url("ftp://evil.example.com/repo")
179
180 def test_data_scheme_raises(self) -> None:
181 from muse.cli.commands.hub import _normalise_url
182 with pytest.raises(ValueError, match="not allowed"):
183 _normalise_url("data:text/plain,malicious")
184
185 def test_http_non_loopback_raises(self) -> None:
186 from muse.cli.commands.hub import _normalise_url
187 with pytest.raises(ValueError, match="HTTPS"):
188 _normalise_url("http://musehub.ai/gabriel/muse")
189
190 def test_http_localhost_allowed(self) -> None:
191 from muse.cli.commands.hub import _normalise_url
192 assert _normalise_url("http://localhost:10003") == "http://localhost:10003"
193
194 def test_http_127_allowed(self) -> None:
195 from muse.cli.commands.hub import _normalise_url
196 assert _normalise_url("http://127.0.0.1:9000") == "http://127.0.0.1:9000"
197
198 def test_schemeless_becomes_https(self) -> None:
199 from muse.cli.commands.hub import _normalise_url
200 assert _normalise_url("musehub.ai").startswith("https://")
201
202 def test_trailing_slash_stripped(self) -> None:
203 from muse.cli.commands.hub import _normalise_url
204 assert not _normalise_url("https://musehub.ai/").endswith("/")
205
206 def test_https_passthrough(self) -> None:
207 from muse.cli.commands.hub import _normalise_url
208 assert _normalise_url("https://musehub.ai/gabriel/muse") == "https://musehub.ai/gabriel/muse"
209
210
211 # ── Unit: _hub_hostname ───────────────────────────────────────────────────────
212
213
214 class TestHubHostname:
215 def test_plain_https(self) -> None:
216 from muse.cli.commands.hub import _hub_hostname
217 assert _hub_hostname("https://musehub.ai/gabriel/muse") == "musehub.ai"
218
219 def test_with_port(self) -> None:
220 from muse.cli.commands.hub import _hub_hostname
221 assert _hub_hostname("http://localhost:10003/gabriel/muse") == "localhost:10003"
222
223 def test_bare_hostname(self) -> None:
224 from muse.cli.commands.hub import _hub_hostname
225 assert _hub_hostname("musehub.ai") == "musehub.ai"
226
227 def test_trailing_slash(self) -> None:
228 from muse.cli.commands.hub import _hub_hostname
229 assert _hub_hostname("https://musehub.ai/") == "musehub.ai"
230
231
232 # ── Unit: _hub_api ────────────────────────────────────────────────────────────
233
234
235 class TestHubApi:
236 _IDENTITY = {"type": "human", "token": "tok123"}
237
238 def test_file_scheme_blocked_before_network(self) -> None:
239 from muse.cli.commands.hub import _hub_api
240 from muse.core.identity import IdentityEntry
241 identity: IdentityEntry = {"type": "human", "token": "tok"}
242 with patch("urllib.request.urlopen") as mock_net:
243 with pytest.raises(SystemExit):
244 _hub_api("file:///etc/passwd", identity, "GET", "/api/test")
245 mock_net.assert_not_called()
246
247 def test_ftp_scheme_blocked_before_network(self) -> None:
248 from muse.cli.commands.hub import _hub_api
249 from muse.core.identity import IdentityEntry
250 identity: IdentityEntry = {"type": "human", "token": "tok"}
251 with patch("urllib.request.urlopen") as mock_net:
252 with pytest.raises(SystemExit):
253 _hub_api("ftp://ftp.example.com", identity, "GET", "/api/test")
254 mock_net.assert_not_called()
255
256 def test_missing_token_exits(self) -> None:
257 """No signing identity → SystemExit before any network I/O.
258
259 identity carries no handle/key_path so Ed25519 key loading is skipped.
260 get_signing_identity is patched to return None so the function never
261 falls through to a live HTTP request.
262 """
263 from muse.cli.commands.hub import _hub_api
264 from muse.core.identity import IdentityEntry
265 identity: IdentityEntry = {"type": "human"}
266 with patch("muse.cli.config.get_signing_identity", return_value=None):
267 with patch("urllib.request.urlopen") as mock_net:
268 with pytest.raises(SystemExit):
269 _hub_api("http://localhost:10003", identity, "GET", "/api/test")
270 mock_net.assert_not_called()
271
272 def test_response_size_cap(self) -> None:
273 from muse.cli.commands.hub import _MAX_API_RESPONSE_BYTES, _hub_api
274 from muse.core.identity import IdentityEntry
275 identity: IdentityEntry = {"type": "human", "token": "tok"}
276 mock_resp = MagicMock()
277 mock_resp.__enter__ = lambda s: s
278 mock_resp.__exit__ = MagicMock(return_value=False)
279 mock_resp.read.return_value = b"x" * (_MAX_API_RESPONSE_BYTES + 2)
280 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
281 with patch("urllib.request.urlopen", return_value=mock_resp):
282 with pytest.raises(SystemExit):
283 _hub_api("http://localhost:9999", identity, "GET", "/api/test")
284
285 def test_http_error_sanitized_in_output(
286 self, capsys: pytest.CaptureFixture[str]
287 ) -> None:
288 import urllib.error
289 from muse.cli.commands.hub import _hub_api
290 from muse.core.identity import IdentityEntry
291
292 import io
293 identity: IdentityEntry = {"type": "human", "token": "tok"}
294 ansi_detail = b'{"detail":"\\x1b[31mevil\\x1b[0m"}'
295 exc = urllib.error.HTTPError(url="", code=403, msg="Forbidden", hdrs=MagicMock(), fp=io.BytesIO(ansi_detail))
296
297 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
298 with patch("urllib.request.urlopen", side_effect=exc):
299 with pytest.raises(SystemExit):
300 _hub_api("http://localhost:9999", identity, "GET", "/api/test")
301
302 captured = capsys.readouterr()
303 assert "\x1b[" not in captured.err
304
305 def test_empty_response_returns_empty_dict(self) -> None:
306 from muse.cli.commands.hub import _hub_api
307 from muse.core.identity import IdentityEntry
308
309 identity: IdentityEntry = {"type": "human", "token": "tok"}
310 mock_resp = MagicMock()
311 mock_resp.__enter__ = lambda s: s
312 mock_resp.__exit__ = MagicMock(return_value=False)
313 mock_resp.read.return_value = b""
314 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
315 with patch("urllib.request.urlopen", return_value=mock_resp):
316 result = _hub_api("http://localhost:9999", identity, "GET", "/api/test")
317 assert result == {}
318
319
320 # ── Unit: _resolve_proposal_id ──────────────────────────────────────────────────────
321
322
323 class TestResolveProposalId:
324 def _make_identity(self) -> "muse.core.identity.IdentityEntry":
325 from muse.core.identity import IdentityEntry
326 e: IdentityEntry = {"type": "human", "token": "tok123"}
327 return e
328
329 def _proposal(self, proposal_id: str, title: str = "Test Proposal") -> _ProposalRecord:
330 return {"proposalId": proposal_id, "title": title, "state": "open",
331 "fromBranch": "feat/x", "toBranch": "dev"}
332
333 def test_full_uuid_returned_as_is(self) -> None:
334 from muse.cli.commands.hub import _resolve_proposal_id
335 full = "af54753d-1234-5678-abcd-ef1234567890"
336 result = _resolve_proposal_id("http://hub", self._make_identity(), "repo-id", full)
337 assert result == full
338
339 def test_prefix_resolved(self) -> None:
340 from muse.cli.commands.hub import _resolve_proposal_id
341
342 proposal_id = "abc12345-6789-0000-0000-000000000000"
343 proposals_resp = {"proposals": [self._proposal(proposal_id)]}
344 mock_resp = MagicMock()
345 mock_resp.__enter__ = lambda s: s
346 mock_resp.__exit__ = MagicMock(return_value=False)
347 mock_resp.read.return_value = json.dumps(proposals_resp).encode()
348 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
349 with patch("urllib.request.urlopen", return_value=mock_resp):
350 result = _resolve_proposal_id(
351 "http://localhost:9999", self._make_identity(), "repo-id", "abc12345"
352 )
353 assert result == proposal_id
354
355 def test_no_match_exits(self) -> None:
356 from muse.cli.commands.hub import _resolve_proposal_id
357
358 resp_bytes = json.dumps({"proposals": []}).encode()
359 mock_resp = MagicMock()
360 mock_resp.__enter__ = lambda s: s
361 mock_resp.__exit__ = MagicMock(return_value=False)
362 mock_resp.read.return_value = resp_bytes
363 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
364 with patch("urllib.request.urlopen", return_value=mock_resp):
365 with pytest.raises(SystemExit):
366 _resolve_proposal_id(
367 "http://localhost:9999", self._make_identity(), "repo-id", "deadbeef"
368 )
369
370 def test_ambiguous_prefix_exits(self) -> None:
371 from muse.cli.commands.hub import _resolve_proposal_id
372
373 pr1_id = "abc12345-0000-0000-0000-000000000001"
374 pr2_id = "abc12345-0000-0000-0000-000000000002"
375 proposals_resp = {"proposals": [self._proposal(pr1_id), self._proposal(pr2_id)]}
376 mock_resp = MagicMock()
377 mock_resp.__enter__ = lambda s: s
378 mock_resp.__exit__ = MagicMock(return_value=False)
379 mock_resp.read.return_value = json.dumps(proposals_resp).encode()
380 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
381 with patch("urllib.request.urlopen", return_value=mock_resp):
382 with pytest.raises(SystemExit):
383 _resolve_proposal_id(
384 "http://localhost:9999", self._make_identity(), "repo-id", "abc12345"
385 )
386
387 def test_ansi_in_proposal_id_sanitized_in_error(
388 self, capsys: pytest.CaptureFixture[str]
389 ) -> None:
390 from muse.cli.commands.hub import _resolve_proposal_id
391
392 resp_bytes = json.dumps({"proposals": []}).encode()
393 mock_resp = MagicMock()
394 mock_resp.__enter__ = lambda s: s
395 mock_resp.__exit__ = MagicMock(return_value=False)
396 mock_resp.read.return_value = resp_bytes
397 evil_proposalefix = "\x1b[31mevil\x1b[0m"
398 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
399 with patch("urllib.request.urlopen", return_value=mock_resp):
400 with pytest.raises(SystemExit):
401 _resolve_proposal_id(
402 "http://localhost:9999", self._make_identity(), "repo-id", evil_proposalefix
403 )
404 captured = capsys.readouterr()
405 assert "\x1b[" not in captured.err
406
407
408 # ── Unit: _format_proposal ──────────────────────────────────────────────────────────
409
410
411 class TestFormatProposal:
412 def test_ansi_in_title_stripped(self) -> None:
413 from muse.cli.commands.hub import _format_proposal
414 proposal: _ProposalRecord = {
415 "proposalId": "abc12345",
416 "title": "\x1b[31mevil title\x1b[0m",
417 "state": "open",
418 "fromBranch": "feat/x",
419 "toBranch": "dev",
420 }
421 result = _format_proposal(proposal)
422 assert "\x1b[" not in result
423
424 def test_ansi_in_branch_stripped(self) -> None:
425 from muse.cli.commands.hub import _format_proposal
426 proposal: _ProposalRecord = {
427 "proposalId": "abc12345",
428 "title": "clean title",
429 "state": "open",
430 "fromBranch": "\x1b[32mfeat/evil\x1b[0m",
431 "toBranch": "\x1b[31mdev\x1b[0m",
432 }
433 result = _format_proposal(proposal)
434 assert "\x1b[" not in result
435
436 def test_state_icon_open(self) -> None:
437 from muse.cli.commands.hub import _format_proposal
438 proposal: _ProposalRecord = {
439 "proposalId": "abc12345", "title": "t", "state": "open",
440 "fromBranch": "f", "toBranch": "d",
441 }
442 assert "🟢" in _format_proposal(proposal)
443
444 def test_state_icon_merged(self) -> None:
445 from muse.cli.commands.hub import _format_proposal
446 proposal: _ProposalRecord = {
447 "proposalId": "abc12345", "title": "t", "state": "merged",
448 "fromBranch": "f", "toBranch": "d",
449 }
450 assert "🟣" in _format_proposal(proposal)
451
452
453 # ── Integration: run_connect ──────────────────────────────────────────────────
454
455
456 class TestConnectHardening:
457 _HUB = "http://localhost:19999"
458
459 def test_connect_json_schema(self, repo: pathlib.Path) -> None:
460 result = runner.invoke(cli, ["hub", "connect", self._HUB, "--json"])
461 assert result.exit_code == 0
462 data = _json_connect(result)
463 for key in ("status", "hub_url", "hostname", "authenticated",
464 "identity_name", "identity_type"):
465 assert key in data, f"Missing key: {key}"
466 assert data["status"] == "ok"
467 assert data["authenticated"] is False
468 assert data["identity_name"] == ""
469 assert data["identity_type"] == ""
470
471 def test_connect_authenticated_json_schema(self, repo: pathlib.Path) -> None:
472 _store_identity(self._HUB)
473 result = runner.invoke(cli, ["hub", "connect", self._HUB, "--json"])
474 assert result.exit_code == 0
475 data = _json_connect(result)
476 assert data["authenticated"] is True
477 assert data["identity_name"] == "alice"
478 assert data["identity_type"] == "human"
479
480 def test_connect_invalid_scheme_exits(self, repo: pathlib.Path) -> None:
481 result = runner.invoke(cli, ["hub", "connect", "file:///etc/passwd"])
482 assert result.exit_code != 0
483
484 def test_connect_http_non_loopback_exits(self, repo: pathlib.Path) -> None:
485 result = runner.invoke(cli, ["hub", "connect", "http://musehub.ai"])
486 assert result.exit_code != 0
487
488 def test_connect_json_stdout_clean(self, repo: pathlib.Path) -> None:
489 result = runner.invoke(cli, ["hub", "connect", self._HUB, "--json"])
490 assert result.exit_code == 0
491 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
492 assert len(json_lines) >= 1
493
494 def test_connect_no_repo_exits(self, tmp_path: pathlib.Path,
495 monkeypatch: pytest.MonkeyPatch) -> None:
496 monkeypatch.chdir(tmp_path)
497 result = runner.invoke(cli, ["hub", "connect", self._HUB])
498 assert result.exit_code != 0
499
500 def test_reconnect_warning_on_stderr(self, repo: pathlib.Path) -> None:
501 runner.invoke(cli, ["hub", "connect", self._HUB])
502 result = runner.invoke(cli, ["hub", "connect", "http://localhost:20000"])
503 assert result.exit_code == 0
504 assert "localhost:19999" in result.output
505
506 def test_reconnect_same_url_no_warning(self, repo: pathlib.Path) -> None:
507 """Re-connecting to the same URL is a no-op — no warning emitted."""
508 runner.invoke(cli, ["hub", "connect", self._HUB])
509 result = runner.invoke(cli, ["hub", "connect", self._HUB])
510 assert result.exit_code == 0
511 assert "Switching" not in result.output
512 assert "⚠️" not in result.output
513
514 def test_connect_short_flag_j(self, repo: pathlib.Path) -> None:
515 """-j short flag produces identical JSON output to --json."""
516 r_long = runner.invoke(cli, ["hub", "connect", self._HUB, "--json"])
517 runner.invoke(cli, ["hub", "disconnect"])
518 r_short = runner.invoke(cli, ["hub", "connect", self._HUB, "-j"])
519 assert r_long.exit_code == 0
520 assert r_short.exit_code == 0
521 d_long = _json_connect(r_long)
522 d_short = _json_connect(r_short)
523 assert d_long == d_short
524
525 def test_connect_ipv6_loopback_accepted(self, repo: pathlib.Path) -> None:
526 """http://[::1] and http://[::1]:PORT are valid loopback URLs."""
527 result = runner.invoke(cli, ["hub", "connect", "http://[::1]:8080", "--json"])
528 assert result.exit_code == 0
529 data = _json_connect(result)
530 assert data["status"] == "ok"
531 assert "::1" in data["hub_url"]
532
533 def test_connect_ipv6_loopback_bare_accepted(self, repo: pathlib.Path) -> None:
534 """http://[::1] without a port is valid."""
535 result = runner.invoke(cli, ["hub", "connect", "http://[::1]", "--json"])
536 assert result.exit_code == 0
537 data = _json_connect(result)
538 assert data["status"] == "ok"
539
540 def test_connect_bare_hostname_with_port(self, repo: pathlib.Path) -> None:
541 """musehub.ai:8443 (no scheme) is promoted to https://musehub.ai:8443."""
542 result = runner.invoke(cli, ["hub", "connect", "musehub.ai:8443", "--json"])
543 assert result.exit_code == 0
544 data = _json_connect(result)
545 assert data["hub_url"] == "https://musehub.ai:8443"
546 assert data["hostname"] == "musehub.ai:8443"
547
548 def test_connect_trailing_slash_stripped(self, repo: pathlib.Path) -> None:
549 """Trailing slashes are stripped from the stored URL."""
550 result = runner.invoke(
551 cli, ["hub", "connect", "https://musehub.ai/", "--json"]
552 )
553 assert result.exit_code == 0
554 data = _json_connect(result)
555 assert not data["hub_url"].endswith("/")
556
557 def test_connect_ansi_in_reconnect_warning_sanitized(
558 self, repo: pathlib.Path
559 ) -> None:
560 """ANSI codes stored in config are stripped from the reconnect warning."""
561 import unittest.mock
562 ansi_url = "https://\x1b[31mevil.example.com\x1b[0m"
563 with unittest.mock.patch(
564 "muse.cli.commands.hub.get_hub_url", return_value=ansi_url
565 ):
566 result = runner.invoke(
567 cli, ["hub", "connect", "https://safe.example.com"]
568 )
569 assert "\x1b" not in result.output, "ANSI escape leaked into reconnect warning"
570
571 def test_connect_json_hub_url_normalised(self, repo: pathlib.Path) -> None:
572 """hub_url in JSON is the normalised form (no trailing slash, has scheme)."""
573 result = runner.invoke(
574 cli, ["hub", "connect", "musehub.ai", "--json"]
575 )
576 assert result.exit_code == 0
577 data = _json_connect(result)
578 assert data["hub_url"].startswith("https://")
579 assert not data["hub_url"].endswith("/")
580
581 def test_connect_no_repo_exits_2(self, tmp_path: pathlib.Path,
582 monkeypatch: pytest.MonkeyPatch) -> None:
583 """Exit code 2 (REPO_NOT_FOUND) when outside a Muse repo."""
584 monkeypatch.chdir(tmp_path)
585 result = runner.invoke(cli, ["hub", "connect", self._HUB])
586 assert result.exit_code == 2
587
588 def test_connect_http_non_loopback_exits_1(self, repo: pathlib.Path) -> None:
589 """Exit code 1 (USER_ERROR) for http:// non-loopback URL."""
590 result = runner.invoke(cli, ["hub", "connect", "http://remote.example.com"])
591 assert result.exit_code == 1
592
593 def test_connect_disallowed_scheme_exits_1(self, repo: pathlib.Path) -> None:
594 """Exit code 1 (USER_ERROR) for ftp:// URL."""
595 result = runner.invoke(cli, ["hub", "connect", "ftp://musehub.ai"])
596 assert result.exit_code == 1
597
598 def test_10_sequential_connects_all_survive(self, repo: pathlib.Path) -> None:
599 """10 sequential connect→disconnect cycles all succeed."""
600 for i in range(10):
601 hub = f"http://localhost:{19000 + i}"
602 r = runner.invoke(cli, ["hub", "connect", hub, "--json"])
603 assert r.exit_code == 0, f"connect {i} failed: {r.output}"
604 data = _json_connect(r)
605 assert data["status"] == "ok"
606
607
608 # ── Integration: run_status ───────────────────────────────────────────────────
609
610
611 class TestStatusHardening:
612 _HUB = "http://localhost:19999"
613
614 def test_status_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
615 result = runner.invoke(cli, ["hub", "status", "--json"])
616 assert result.exit_code != 0
617
618 def test_status_json_all_keys_always_present(self, repo: pathlib.Path) -> None:
619 """All 7 JSON keys present even when not authenticated."""
620 runner.invoke(cli, ["hub", "connect", self._HUB])
621 result = runner.invoke(cli, ["hub", "status", "--json"])
622 assert result.exit_code == 0
623 data = _json_status(result)
624 for key in ("hub_url", "hostname", "authenticated", "identity_type",
625 "identity_name", "identity_id", "capabilities"):
626 assert key in data, f"Missing key: {key}"
627 assert data["authenticated"] is False
628 assert data["identity_type"] == ""
629 assert data["identity_name"] == ""
630 assert data["identity_id"] == ""
631 assert data["capabilities"] == []
632
633 def test_status_json_authenticated(self, repo: pathlib.Path) -> None:
634 runner.invoke(cli, ["hub", "connect", self._HUB])
635 _store_identity(self._HUB)
636 result = runner.invoke(cli, ["hub", "status", "--json"])
637 assert result.exit_code == 0
638 data = _json_status(result)
639 assert data["authenticated"] is True
640 assert data["identity_name"] == "alice"
641 assert data["identity_type"] == "human"
642
643 def test_status_json_stdout_clean(self, repo: pathlib.Path) -> None:
644 runner.invoke(cli, ["hub", "connect", self._HUB])
645 result = runner.invoke(cli, ["hub", "status", "--json"])
646 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
647 assert len(json_lines) >= 1
648
649 def test_status_text_mode_to_stderr(self, repo: pathlib.Path,
650 capsys: pytest.CaptureFixture[str]) -> None:
651 runner.invoke(cli, ["hub", "connect", self._HUB])
652 result = runner.invoke(cli, ["hub", "status"])
653 assert result.exit_code == 0
654
655 def test_status_short_flag_j(self, repo: pathlib.Path) -> None:
656 """-j short flag produces identical JSON output to --json."""
657 runner.invoke(cli, ["hub", "connect", self._HUB])
658 r_long = runner.invoke(cli, ["hub", "status", "--json"])
659 r_short = runner.invoke(cli, ["hub", "status", "-j"])
660 assert r_long.exit_code == 0
661 assert r_short.exit_code == 0
662 assert json.loads(r_long.output) == json.loads(r_short.output)
663
664 def test_status_exit_code_2_no_repo(
665 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
666 ) -> None:
667 """Exit code 2 (REPO_NOT_FOUND) when outside a Muse repo."""
668 monkeypatch.chdir(tmp_path)
669 result = runner.invoke(cli, ["hub", "status"])
670 assert result.exit_code == 2
671
672 def test_status_exit_code_1_no_hub(self, repo: pathlib.Path) -> None:
673 """Exit code 1 (USER_ERROR) when no hub is connected."""
674 result = runner.invoke(cli, ["hub", "status"])
675 assert result.exit_code == 1
676
677 def test_status_hub_override_flag(self, repo: pathlib.Path) -> None:
678 """--hub flag overrides config; identity is looked up for that URL."""
679 override = "http://localhost:29999"
680 from muse.core.identity import IdentityEntry, save_identity
681 entry: IdentityEntry = {
682 "type": "agent", "handle": "override-bot",
683 }
684 save_identity(override, entry)
685 # Connect to a different hub
686 runner.invoke(cli, ["hub", "connect", self._HUB])
687 result = runner.invoke(
688 cli, ["hub", "status", "--hub", override, "--json"]
689 )
690 assert result.exit_code == 0
691 data = _json_status(result)
692 assert data["authenticated"] is True
693 assert data["identity_name"] == "override-bot"
694
695 def test_status_json_capabilities_populated_for_agent(
696 self, repo: pathlib.Path
697 ) -> None:
698 """capabilities field is populated from agent identity."""
699 from muse.core.identity import IdentityEntry, save_identity
700 entry: IdentityEntry = {
701 "type": "agent",
702 "handle": "cap-bot",
703 "capabilities": ["read:*", "write:midi", "commit"],
704 }
705 save_identity(self._HUB, entry)
706 runner.invoke(cli, ["hub", "connect", self._HUB])
707 result = runner.invoke(cli, ["hub", "status", "--json"])
708 assert result.exit_code == 0
709 data = _json_status(result)
710 assert data["capabilities"] == ["read:*", "write:midi", "commit"]
711
712 def test_status_capabilities_empty_for_human(self, repo: pathlib.Path) -> None:
713 """capabilities is [] for human identities (they have no cap list)."""
714 runner.invoke(cli, ["hub", "connect", self._HUB])
715 _store_identity(self._HUB) # human identity, no capabilities
716 result = runner.invoke(cli, ["hub", "status", "--json"])
717 assert result.exit_code == 0
718 data = _json_status(result)
719 assert data["capabilities"] == []
720
721 def test_status_ansi_in_identity_fields_sanitized(
722 self, repo: pathlib.Path
723 ) -> None:
724 """ANSI codes in identity_type, identity_name, identity_id stripped in text output."""
725 import unittest.mock
726 ansi_entry = {
727 "type": "\x1b[31magent\x1b[0m",
728 "handle": "\x1b[32mevil-bot\x1b[0m",
729 }
730 with unittest.mock.patch(
731 "muse.core.identity._load_all",
732 return_value={"localhost:19999": ansi_entry},
733 ):
734 runner.invoke(cli, ["hub", "connect", self._HUB])
735 result = runner.invoke(cli, ["hub", "status"])
736 assert "\x1b" not in result.output, "ANSI escape leaked into status text output"
737
738 def test_status_ansi_in_capabilities_sanitized(
739 self, repo: pathlib.Path
740 ) -> None:
741 """ANSI codes in capabilities are stripped from text output."""
742 import unittest.mock
743 ansi_entry = {
744 "type": "agent",
745 "handle": "bot",
746 "capabilities": ["\x1b[31mread:*\x1b[0m", "write:midi"],
747 }
748 with unittest.mock.patch(
749 "muse.core.identity._load_all",
750 return_value={"localhost:19999": ansi_entry},
751 ):
752 runner.invoke(cli, ["hub", "connect", self._HUB])
753 result = runner.invoke(cli, ["hub", "status"])
754 assert "\x1b" not in result.output, "ANSI escape in capability leaked to output"
755
756 def test_status_json_single_object_per_call(self, repo: pathlib.Path) -> None:
757 """Exactly one JSON object emitted to stdout per invocation."""
758 runner.invoke(cli, ["hub", "connect", self._HUB])
759 result = runner.invoke(cli, ["hub", "status", "--json"])
760 assert result.exit_code == 0
761 objects = [l for l in result.output.splitlines() if l.strip().startswith("{")]
762 assert len(objects) == 1, f"Expected 1 JSON object, got {len(objects)}"
763
764 def test_10_sequential_status_calls(self, repo: pathlib.Path) -> None:
765 """10 sequential status calls all succeed with consistent JSON."""
766 runner.invoke(cli, ["hub", "connect", self._HUB])
767 _store_identity(self._HUB)
768 results = []
769 for _ in range(10):
770 r = runner.invoke(cli, ["hub", "status", "--json"])
771 assert r.exit_code == 0
772 results.append(json.loads(r.output))
773 # All results must be identical
774 assert all(r == results[0] for r in results), "Status output not stable"
775
776
777 # ── Integration: run_disconnect ───────────────────────────────────────────────
778
779
780 class TestDisconnectHardening:
781 _HUB = "http://localhost:19999"
782
783 def test_disconnect_nothing_to_do_json(self, repo: pathlib.Path) -> None:
784 result = runner.invoke(cli, ["hub", "disconnect", "--json"])
785 assert result.exit_code == 0
786 data = _json_disconnect(result)
787 assert data["status"] == "nothing_to_do"
788 assert data["hostname"] == ""
789
790 def test_disconnect_ok_json(self, repo: pathlib.Path) -> None:
791 runner.invoke(cli, ["hub", "connect", self._HUB])
792 result = runner.invoke(cli, ["hub", "disconnect", "--json"])
793 assert result.exit_code == 0
794 data = _json_disconnect(result)
795 assert data["status"] == "ok"
796 assert "localhost" in data["hostname"]
797
798 def test_disconnect_removes_hub_url(self, repo: pathlib.Path) -> None:
799 runner.invoke(cli, ["hub", "connect", self._HUB])
800 runner.invoke(cli, ["hub", "disconnect"])
801 result = runner.invoke(cli, ["hub", "status"])
802 assert result.exit_code != 0
803
804 def test_disconnect_json_schema_all_keys(self, repo: pathlib.Path) -> None:
805 """All three JSON keys present on success."""
806 runner.invoke(cli, ["hub", "connect", self._HUB])
807 result = runner.invoke(cli, ["hub", "disconnect", "--json"])
808 data = _json_disconnect(result)
809 for key in ("status", "hub_url", "hostname"):
810 assert key in data, f"Missing key: {key}"
811
812 def test_disconnect_json_nothing_to_do_all_keys(self, repo: pathlib.Path) -> None:
813 """All three JSON keys present even when nothing was connected."""
814 result = runner.invoke(cli, ["hub", "disconnect", "--json"])
815 assert result.exit_code == 0
816 data = _json_disconnect(result)
817 for key in ("status", "hub_url", "hostname"):
818 assert key in data, f"Missing key: {key}"
819 assert data["hub_url"] == ""
820 assert data["hostname"] == ""
821
822 def test_disconnect_json_hub_url_matches_connected(
823 self, repo: pathlib.Path
824 ) -> None:
825 """hub_url in JSON is the full URL that was disconnected."""
826 runner.invoke(cli, ["hub", "connect", self._HUB])
827 result = runner.invoke(cli, ["hub", "disconnect", "--json"])
828 assert result.exit_code == 0
829 data = _json_disconnect(result)
830 assert data["hub_url"] == self._HUB
831 assert "localhost" in data["hostname"]
832
833 def test_disconnect_no_repo_exits_2(self, tmp_path: pathlib.Path,
834 monkeypatch: pytest.MonkeyPatch) -> None:
835 """Exit code 2 (REPO_NOT_FOUND) when outside a Muse repo."""
836 monkeypatch.chdir(tmp_path)
837 result = runner.invoke(cli, ["hub", "disconnect"])
838 assert result.exit_code == 2
839
840 def test_disconnect_no_repo_exits(self, tmp_path: pathlib.Path,
841 monkeypatch: pytest.MonkeyPatch) -> None:
842 monkeypatch.chdir(tmp_path)
843 result = runner.invoke(cli, ["hub", "disconnect"])
844 assert result.exit_code != 0
845
846 def test_disconnect_short_flag_j(self, repo: pathlib.Path) -> None:
847 """-j short flag produces identical JSON output to --json."""
848 runner.invoke(cli, ["hub", "connect", self._HUB])
849 r_long = runner.invoke(cli, ["hub", "disconnect", "--json"])
850 runner.invoke(cli, ["hub", "connect", self._HUB])
851 r_short = runner.invoke(cli, ["hub", "connect", self._HUB]) # reconnect
852 r_short = runner.invoke(cli, ["hub", "disconnect", "-j"])
853 assert r_long.exit_code == 0
854 assert r_short.exit_code == 0
855 d_long = _json_disconnect(r_long)
856 d_short = _json_disconnect(r_short)
857 # Both should have same shape; hub_url and hostname may differ so
858 # check schema only.
859 assert set(d_long.keys()) == set(d_short.keys())
860 assert d_short["status"] == "ok"
861
862 def test_disconnect_idempotent_second_call(self, repo: pathlib.Path) -> None:
863 """Second disconnect exits 0 with status nothing_to_do."""
864 runner.invoke(cli, ["hub", "connect", self._HUB])
865 r1 = runner.invoke(cli, ["hub", "disconnect", "--json"])
866 r2 = runner.invoke(cli, ["hub", "disconnect", "--json"])
867 assert r1.exit_code == 0
868 assert r2.exit_code == 0
869 d1 = _json_disconnect(r1)
870 d2 = _json_disconnect(r2)
871 assert d1["status"] == "ok"
872 assert d2["status"] == "nothing_to_do"
873
874 def test_disconnect_preserves_identity(self, repo: pathlib.Path) -> None:
875 """Credentials in identity.toml survive hub disconnect."""
876 from muse.core.identity import IdentityEntry, load_identity, save_identity
877 entry: IdentityEntry = {"type": "human", "handle": "alice"}
878 save_identity(self._HUB, entry)
879 runner.invoke(cli, ["hub", "connect", self._HUB])
880 runner.invoke(cli, ["hub", "disconnect"])
881 assert load_identity(self._HUB) is not None
882
883 def test_disconnect_json_stdout_clean(self, repo: pathlib.Path) -> None:
884 """No non-JSON text on stdout when --json is passed."""
885 runner.invoke(cli, ["hub", "connect", self._HUB])
886 result = runner.invoke(cli, ["hub", "disconnect", "--json"])
887 assert result.exit_code == 0
888 for line in result.output.splitlines():
889 stripped = line.strip()
890 if stripped:
891 assert stripped.startswith("{") or stripped.startswith('"'), \
892 f"Non-JSON on stdout: {stripped!r}"
893
894 def test_disconnect_ansi_in_hub_url_sanitized(
895 self, repo: pathlib.Path
896 ) -> None:
897 """ANSI codes in a stored hub URL are stripped from text output."""
898 import unittest.mock
899 ansi_url = "https://\x1b[31mevil.example.com\x1b[0m"
900 with unittest.mock.patch(
901 "muse.cli.commands.hub.get_hub_url", return_value=ansi_url
902 ):
903 result = runner.invoke(cli, ["hub", "disconnect"])
904 assert "\x1b" not in result.output, "ANSI escape leaked into disconnect output"
905
906 def test_10_sequential_disconnect_cycles(self, repo: pathlib.Path) -> None:
907 """10 connect→disconnect cycles all succeed with correct JSON."""
908 for i in range(10):
909 hub = f"http://localhost:{20000 + i}"
910 runner.invoke(cli, ["hub", "connect", hub])
911 r = runner.invoke(cli, ["hub", "disconnect", "--json"])
912 assert r.exit_code == 0, f"cycle {i} failed: {r.output}"
913 data = _json_disconnect(r)
914 assert data["status"] == "ok"
915 assert data["hub_url"] == hub
916
917
918 # ── Integration: run_ping ─────────────────────────────────────────────────────
919
920
921 class TestPingHardening:
922 _HUB = "http://localhost:19999"
923
924 def _connect(self, repo: pathlib.Path) -> None:
925 runner.invoke(cli, ["hub", "connect", self._HUB])
926
927 def test_ping_reachable_json_schema(self, repo: pathlib.Path) -> None:
928 self._connect(repo)
929 mock_resp = MagicMock()
930 mock_resp.__enter__ = lambda s: s
931 mock_resp.__exit__ = MagicMock(return_value=False)
932 mock_resp.status = 200
933 with patch("urllib.request.OpenerDirector.open", return_value=mock_resp):
934 result = runner.invoke(cli, ["hub", "ping", "--json"])
935 assert result.exit_code == 0
936 data = _json_ping(result)
937 for key in ("status", "hub_url", "hostname", "reachable", "message"):
938 assert key in data, f"Missing key: {key}"
939 assert data["reachable"] is True
940 assert data["status"] == "ok"
941
942 def test_ping_unreachable_json_schema(self, repo: pathlib.Path) -> None:
943 self._connect(repo)
944 import urllib.error
945 exc = urllib.error.URLError(reason="connection refused")
946 with patch("urllib.request.OpenerDirector.open", side_effect=exc):
947 result = runner.invoke(cli, ["hub", "ping", "--json"])
948 assert result.exit_code != 0
949 data = _json_ping(result)
950 assert data["reachable"] is False
951 assert data["status"] == "error"
952
953 def test_ping_json_stdout_clean(self, repo: pathlib.Path) -> None:
954 self._connect(repo)
955 mock_resp = MagicMock()
956 mock_resp.__enter__ = lambda s: s
957 mock_resp.__exit__ = MagicMock(return_value=False)
958 mock_resp.status = 200
959 with patch("urllib.request.OpenerDirector.open", return_value=mock_resp):
960 result = runner.invoke(cli, ["hub", "ping", "--json"])
961 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
962 assert len(json_lines) >= 1
963
964 def test_ping_no_hub_exits(self, repo: pathlib.Path) -> None:
965 result = runner.invoke(cli, ["hub", "ping"])
966 assert result.exit_code != 0
967
968 def test_ping_no_repo_exits(self, tmp_path: pathlib.Path,
969 monkeypatch: pytest.MonkeyPatch) -> None:
970 monkeypatch.chdir(tmp_path)
971 result = runner.invoke(cli, ["hub", "ping"])
972 assert result.exit_code != 0
973
974 def test_ping_exit_code_5_on_unreachable(self, repo: pathlib.Path) -> None:
975 """Unreachable hub exits with REMOTE_ERROR (5), not INTERNAL_ERROR (3)."""
976 self._connect(repo)
977 exc = urllib.error.URLError(reason="connection refused")
978 with patch("urllib.request.OpenerDirector.open", side_effect=exc):
979 result = runner.invoke(cli, ["hub", "ping", "--json"])
980 assert result.exit_code == 5
981
982 def test_ping_exit_code_2_no_repo(
983 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
984 ) -> None:
985 """Exit code 2 (REPO_NOT_FOUND) when outside a Muse repo."""
986 monkeypatch.chdir(tmp_path)
987 result = runner.invoke(cli, ["hub", "ping"])
988 assert result.exit_code == 2
989
990 def test_ping_exit_code_1_no_hub(self, repo: pathlib.Path) -> None:
991 """Exit code 1 (USER_ERROR) when no hub is configured."""
992 result = runner.invoke(cli, ["hub", "ping"])
993 assert result.exit_code == 1
994
995 def test_ping_short_flag_j(self, repo: pathlib.Path) -> None:
996 """-j short flag produces identical JSON output to --json."""
997 self._connect(repo)
998 mock_resp = MagicMock()
999 mock_resp.__enter__ = lambda s: s
1000 mock_resp.__exit__ = MagicMock(return_value=False)
1001 mock_resp.status = 200
1002 with patch("urllib.request.OpenerDirector.open", return_value=mock_resp):
1003 r_long = runner.invoke(cli, ["hub", "ping", "--json"])
1004 with patch("urllib.request.OpenerDirector.open", return_value=mock_resp):
1005 r_short = runner.invoke(cli, ["hub", "ping", "-j"])
1006 assert r_long.exit_code == 0
1007 assert r_short.exit_code == 0
1008 assert json.loads(r_long.output) == json.loads(r_short.output)
1009
1010 def test_ping_hub_override_flag(self, repo: pathlib.Path) -> None:
1011 """--hub flag targets a different URL without affecting stored config."""
1012 override = "http://localhost:29999"
1013 self._connect(repo)
1014 mock_resp = MagicMock()
1015 mock_resp.__enter__ = lambda s: s
1016 mock_resp.__exit__ = MagicMock(return_value=False)
1017 mock_resp.status = 200
1018 with patch("urllib.request.OpenerDirector.open", return_value=mock_resp):
1019 result = runner.invoke(
1020 cli, ["hub", "ping", "--hub", override, "--json"]
1021 )
1022 assert result.exit_code == 0
1023 data = _json_ping(result)
1024 assert data["hub_url"] == override
1025
1026 def test_ping_text_no_json_on_stdout(self, repo: pathlib.Path) -> None:
1027 """In text mode, stdout is empty — all output goes to stderr."""
1028 self._connect(repo)
1029 mock_resp = MagicMock()
1030 mock_resp.__enter__ = lambda s: s
1031 mock_resp.__exit__ = MagicMock(return_value=False)
1032 mock_resp.status = 200
1033 with patch("urllib.request.OpenerDirector.open", return_value=mock_resp):
1034 result = runner.invoke(cli, ["hub", "ping"])
1035 assert result.exit_code == 0
1036 # stdout (result.output) should contain nothing meaningful — all text stderr
1037 stdout_only = result.output # CliRunner merges stderr into output
1038 assert "ok" in stdout_only.lower() or "✅" in stdout_only # sanity: something printed
1039
1040 def test_ping_bad_status_line_returns_false(self, repo: pathlib.Path) -> None:
1041 """BadStatusLine (malformed HTTP response) is caught, returns (False, ...)."""
1042 self._connect(repo)
1043 import http.client
1044 exc = http.client.BadStatusLine("garbage")
1045 with patch("urllib.request.OpenerDirector.open", side_effect=exc):
1046 result = runner.invoke(cli, ["hub", "ping", "--json"])
1047 assert result.exit_code == 5
1048 data = _json_ping(result)
1049 assert data["reachable"] is False
1050 assert "malformed" in data["message"].lower()
1051
1052 def test_ping_file_scheme_hub_override_rejected(
1053 self, repo: pathlib.Path
1054 ) -> None:
1055 """file:// scheme in --hub override returns (False, ...) without opening fs."""
1056 self._connect(repo)
1057 result = runner.invoke(
1058 cli, ["hub", "ping", "--hub", "file:///etc/passwd", "--json"]
1059 )
1060 assert result.exit_code == 5
1061 data = _json_ping(result)
1062 assert data["reachable"] is False
1063 assert "not allowed" in data["message"].lower()
1064
1065 def test_ping_ansi_in_message_sanitized_text_mode(
1066 self, repo: pathlib.Path
1067 ) -> None:
1068 """ANSI codes in the error message from _ping_hub are stripped in text output."""
1069 self._connect(repo)
1070 exc = urllib.error.URLError(reason="\x1b[31mconnection refused\x1b[0m")
1071 with patch("urllib.request.OpenerDirector.open", side_effect=exc):
1072 result = runner.invoke(cli, ["hub", "ping"])
1073 assert "\x1b" not in result.output, "ANSI escape leaked into ping text output"
1074
1075 def test_10_sequential_ping_calls(self, repo: pathlib.Path) -> None:
1076 """10 sequential pings all return consistent JSON."""
1077 self._connect(repo)
1078 mock_resp = MagicMock()
1079 mock_resp.__enter__ = lambda s: s
1080 mock_resp.__exit__ = MagicMock(return_value=False)
1081 mock_resp.status = 200
1082 with patch("urllib.request.OpenerDirector.open", return_value=mock_resp):
1083 results = [
1084 runner.invoke(cli, ["hub", "ping", "--json"]) for _ in range(10)
1085 ]
1086 parsed = [json.loads(r.output) for r in results]
1087 assert all(r.exit_code == 0 for r in results)
1088 assert all(p == parsed[0] for p in parsed), "Ping output not stable"
1089
1090
1091 # ── Unit: _ping_hub extra cases ───────────────────────────────────────────────
1092
1093
1094 class TestPingHubExtra:
1095 """Unit tests for _ping_hub edge cases not covered in test_cli_hub.py."""
1096
1097 def test_scheme_guard_file_rejected(self) -> None:
1098 """file:// scheme is rejected without opening a socket."""
1099 from muse.cli.commands.hub import _ping_hub
1100 ok, msg = _ping_hub("file:///etc/passwd")
1101 assert ok is False
1102 assert "not allowed" in msg.lower()
1103
1104 def test_scheme_guard_ftp_rejected(self) -> None:
1105 from muse.cli.commands.hub import _ping_hub
1106 ok, msg = _ping_hub("ftp://musehub.ai")
1107 assert ok is False
1108 assert "not allowed" in msg.lower()
1109
1110 def test_bad_status_line_caught(self) -> None:
1111 """http.client.BadStatusLine is caught and returns (False, message)."""
1112 import http.client
1113 from muse.cli.commands.hub import _ping_hub
1114 exc = http.client.BadStatusLine("not-a-status")
1115 with unittest.mock.patch(
1116 "muse.cli.commands.hub._PING_OPENER.open", side_effect=exc
1117 ):
1118 ok, msg = _ping_hub("http://localhost:19999")
1119 assert ok is False
1120 assert "malformed" in msg.lower()
1121 assert "BadStatusLine" in msg
1122
1123 def test_invalid_url_caught(self) -> None:
1124 """http.client.InvalidURL is caught and returns (False, message)."""
1125 import http.client
1126 from muse.cli.commands.hub import _ping_hub
1127 exc = http.client.InvalidURL("bad url")
1128 with unittest.mock.patch(
1129 "muse.cli.commands.hub._PING_OPENER.open", side_effect=exc
1130 ):
1131 ok, msg = _ping_hub("http://localhost:19999")
1132 assert ok is False
1133 assert "malformed" in msg.lower()
1134
1135 def test_http_200_returns_true(self) -> None:
1136 from muse.cli.commands.hub import _ping_hub
1137 mock_resp = unittest.mock.MagicMock()
1138 mock_resp.status = 200
1139 mock_resp.__enter__ = lambda s: s
1140 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
1141 with unittest.mock.patch(
1142 "muse.cli.commands.hub._PING_OPENER.open", return_value=mock_resp
1143 ):
1144 ok, msg = _ping_hub("http://localhost:19999")
1145 assert ok is True
1146 assert "200" in msg
1147
1148 def test_http_503_returns_false(self) -> None:
1149 from muse.cli.commands.hub import _ping_hub
1150 mock_resp = unittest.mock.MagicMock()
1151 mock_resp.status = 503
1152 mock_resp.__enter__ = lambda s: s
1153 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
1154 with unittest.mock.patch(
1155 "muse.cli.commands.hub._PING_OPENER.open", return_value=mock_resp
1156 ):
1157 ok, msg = _ping_hub("http://localhost:19999")
1158 assert ok is False
1159 assert "503" in msg
1160
1161 def test_health_path_appended(self) -> None:
1162 """_ping_hub always hits <url>/health regardless of trailing slash."""
1163 from muse.cli.commands.hub import _ping_hub
1164 calls: list[str] = []
1165
1166 def _fake_open(req: urllib.request.Request, timeout: int = 0) -> None:
1167 calls.append(req.full_url)
1168 raise urllib.error.URLError("stop")
1169
1170 with unittest.mock.patch(
1171 "muse.cli.commands.hub._PING_OPENER.open", side_effect=_fake_open
1172 ):
1173 _ping_hub("http://localhost:19999/") # trailing slash
1174 assert calls and calls[0] == "http://localhost:19999/health"
1175
1176
1177 # ── Integration: Proposal commands ───────────────────────────────────────────
1178
1179
1180 class TestProposalCommandsHardening:
1181 # Hub URL must include owner/slug for _resolve_repo_id to work
1182 _HUB = "http://localhost:19999/gabriel/muse"
1183
1184 def _setup(self, repo: pathlib.Path) -> None:
1185 runner.invoke(cli, ["hub", "connect", self._HUB])
1186 _store_identity(self._HUB)
1187
1188 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
1189 mock_resp = MagicMock()
1190 mock_resp.__enter__ = lambda s: s
1191 mock_resp.__exit__ = MagicMock(return_value=False)
1192 mock_resp.read.return_value = payload_bytes
1193 return mock_resp
1194
1195 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
1196 return [self._make_api_resp(r) for r in responses]
1197
1198 def test_proposal_list_json_is_object(self, repo: pathlib.Path) -> None:
1199 self._setup(repo)
1200 proposals_data = {"proposals": [
1201 {"proposalId": "abc12345-0000-0000-0000-000000000001",
1202 "title": "Test Proposal", "state": "open",
1203 "fromBranch": "feat/x", "toBranch": "dev"},
1204 ], "total": 1, "nextCursor": None}
1205 resps = self._mock_api(
1206 json.dumps({"repo_id": "repo-uuid"}).encode(),
1207 json.dumps(proposals_data).encode(),
1208 )
1209 with patch("urllib.request.urlopen", side_effect=resps):
1210 result = runner.invoke(cli, ["hub", "proposal", "list", "--json"])
1211 assert result.exit_code == 0
1212 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
1213 assert len(json_lines) >= 1
1214 obj = json.loads(json_lines[0])
1215 assert isinstance(obj, dict)
1216 assert "proposals" in obj
1217 assert isinstance(obj["proposals"], list)
1218 assert "total" in obj
1219
1220 def test_proposal_list_empty_json_is_wrapped_object(self, repo: pathlib.Path) -> None:
1221 self._setup(repo)
1222 resps = self._mock_api(
1223 json.dumps({"repo_id": "repo-uuid"}).encode(),
1224 json.dumps({"proposals": [], "total": 0, "nextCursor": None}).encode(),
1225 )
1226 with patch("urllib.request.urlopen", side_effect=resps):
1227 result = runner.invoke(cli, ["hub", "proposal", "list", "--json"])
1228 assert result.exit_code == 0
1229 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
1230 obj = json.loads(json_lines[0])
1231 assert obj["proposals"] == []
1232 assert obj["total"] == 0
1233
1234 def test_proposal_create_json_passthrough(self, repo: pathlib.Path) -> None:
1235 self._setup(repo)
1236 # Write a real branch ref so read_current_branch works
1237 (repo / ".muse" / "refs" / "heads" / "feat-x").write_text("")
1238 (repo / ".muse" / "HEAD").write_text("ref: refs/heads/feat-x\n")
1239
1240 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
1241 "title": "Test Proposal", "state": "open",
1242 "fromBranch": "feat-x", "toBranch": "dev"}
1243 resps = self._mock_api(
1244 json.dumps({"repo_id": "repo-uuid"}).encode(),
1245 json.dumps(create_resp).encode(),
1246 )
1247 with patch("urllib.request.urlopen", side_effect=resps):
1248 result = runner.invoke(
1249 cli,
1250 ["hub", "proposal", "create", "--title", "Test Proposal",
1251 "--from-branch", "feat-x", "--json"],
1252 )
1253 assert result.exit_code == 0
1254 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
1255 assert len(json_lines) >= 1
1256
1257 def test_proposal_merge_json_passthrough(self, repo: pathlib.Path) -> None:
1258 self._setup(repo)
1259 proposal_id = "abc12345-0000-0000-0000-000000000001"
1260 merge_resp = {"merged": True, "mergeCommitId": "deadbeef01234567"}
1261 proposals_data = {"proposals": [
1262 {"proposalId": proposal_id, "title": "T", "state": "open",
1263 "fromBranch": "feat/x", "toBranch": "dev"},
1264 ]}
1265 resps = self._mock_api(
1266 json.dumps({"repo_id": "repo-uuid"}).encode(),
1267 json.dumps(proposals_data).encode(),
1268 json.dumps(merge_resp).encode(),
1269 )
1270 with patch("urllib.request.urlopen", side_effect=resps):
1271 result = runner.invoke(
1272 cli, ["hub", "proposal", "merge", "abc12345", "--json"]
1273 )
1274 assert result.exit_code == 0
1275 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
1276 assert len(json_lines) >= 1
1277
1278 def test_proposal_merge_failed_exits_nonzero(self, repo: pathlib.Path) -> None:
1279 self._setup(repo)
1280 proposal_id = "abc12345-0000-0000-0000-000000000001"
1281 merge_resp = {"merged": False, "message": "conflict"}
1282 proposals_data = {"proposals": [
1283 {"proposalId": proposal_id, "title": "T", "state": "open",
1284 "fromBranch": "feat/x", "toBranch": "dev"},
1285 ]}
1286 resps = self._mock_api(
1287 json.dumps({"repo_id": "repo-uuid"}).encode(),
1288 json.dumps(proposals_data).encode(),
1289 json.dumps(merge_resp).encode(),
1290 )
1291 with patch("urllib.request.urlopen", side_effect=resps):
1292 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
1293 assert result.exit_code != 0
1294
1295 def test_proposal_create_no_branch_exits(self, repo: pathlib.Path) -> None:
1296 self._setup(repo)
1297 # Make current branch empty so auto-detection fails
1298 (repo / ".muse" / "HEAD").write_text("")
1299 resps = self._mock_api(json.dumps({"repo_id": "repo-uuid"}).encode())
1300 with patch("urllib.request.urlopen", side_effect=resps):
1301 result = runner.invoke(
1302 cli, ["hub", "proposal", "create", "--title", "T"]
1303 )
1304 assert result.exit_code != 0
1305
1306
1307 # ── Security ──────────────────────────────────────────────────────────────────
1308
1309
1310 class TestHubSecurity:
1311 _HUB = "http://localhost:19999"
1312
1313 def test_hub_api_file_scheme_no_network(self) -> None:
1314 from muse.cli.commands.hub import _hub_api
1315 from muse.core.identity import IdentityEntry
1316 identity: IdentityEntry = {"type": "human", "token": "tok"}
1317 with patch("urllib.request.urlopen") as mock_net:
1318 with pytest.raises(SystemExit):
1319 _hub_api("file:///etc/shadow", identity, "GET", "/api/v1/repos")
1320 mock_net.assert_not_called()
1321
1322 def test_connect_file_scheme_exits(self, repo: pathlib.Path) -> None:
1323 result = runner.invoke(cli, ["hub", "connect", "file:///etc/passwd"])
1324 assert result.exit_code != 0
1325
1326 def test_ansi_in_hub_url_sanitized_in_error(
1327 self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]
1328 ) -> None:
1329 evil_hub = "https://\x1b[31mevil\x1b[0m.example.com"
1330 result = runner.invoke(cli, ["hub", "connect", evil_hub, "--json"])
1331 assert "\x1b[" not in result.output
1332
1333 def test_format_proposal_ansi_in_all_fields(self) -> None:
1334 from muse.cli.commands.hub import _format_proposal
1335 proposal: _ProposalRecord = {
1336 "proposalId": "\x1b[31mabc12345\x1b[0m",
1337 "title": "\x1b[32mmalicious title\x1b[0m",
1338 "state": "open",
1339 "fromBranch": "\x1b[33mfeat/evil\x1b[0m",
1340 "toBranch": "\x1b[34mdev\x1b[0m",
1341 }
1342 result = _format_proposal(proposal, verbose=True)
1343 assert "\x1b[" not in result
1344
1345 def test_resolve_proposal_id_ansi_in_title_sanitized(
1346 self, capsys: pytest.CaptureFixture[str]
1347 ) -> None:
1348 from muse.cli.commands.hub import _resolve_proposal_id
1349 from muse.core.identity import IdentityEntry
1350
1351 identity: IdentityEntry = {"type": "human", "token": "tok"}
1352 proposal_id1 = "abc12345-0000-0000-0000-000000000001"
1353 proposal_id2 = "abc12345-0000-0000-0000-000000000002"
1354 proposals_resp = {
1355 "proposals": [
1356 {"proposalId": proposal_id1, "title": "\x1b[31mevil1\x1b[0m"},
1357 {"proposalId": proposal_id2, "title": "\x1b[31mevil2\x1b[0m"},
1358 ]
1359 }
1360 mock_resp = MagicMock()
1361 mock_resp.__enter__ = lambda s: s
1362 mock_resp.__exit__ = MagicMock(return_value=False)
1363 mock_resp.read.return_value = json.dumps(proposals_resp).encode()
1364 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
1365 with patch("urllib.request.urlopen", return_value=mock_resp):
1366 with pytest.raises(SystemExit):
1367 _resolve_proposal_id("http://hub", identity, "repo-id", "abc12345")
1368 captured = capsys.readouterr()
1369 assert "\x1b[" not in captured.err
1370
1371 def test_hub_api_response_size_cap_prevents_oom(self) -> None:
1372 from muse.cli.commands.hub import _MAX_API_RESPONSE_BYTES, _hub_api
1373 from muse.core.identity import IdentityEntry
1374
1375 identity: IdentityEntry = {"type": "human", "token": "tok"}
1376 mock_resp = MagicMock()
1377 mock_resp.__enter__ = lambda s: s
1378 mock_resp.__exit__ = MagicMock(return_value=False)
1379 # Return something just over the limit
1380 mock_resp.read.return_value = b"A" * (_MAX_API_RESPONSE_BYTES + 10)
1381 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
1382 with patch("urllib.request.urlopen", return_value=mock_resp):
1383 with pytest.raises(SystemExit):
1384 _hub_api("http://localhost:9999", identity, "GET", "/api/test")
1385
1386
1387 # ── Stress ────────────────────────────────────────────────────────────────────
1388
1389
1390 class TestStressConcurrent:
1391 def test_8_concurrent_ping_calls_isolated_mocks(self) -> None:
1392 """8 threads each calling _ping_hub with independent mock transports."""
1393 errors: list[str] = []
1394
1395 def _do(idx: int) -> None:
1396 try:
1397 from muse.cli.commands.hub import _ping_hub
1398
1399 mock_resp = MagicMock()
1400 mock_resp.__enter__ = lambda s: s
1401 mock_resp.__exit__ = MagicMock(return_value=False)
1402 mock_resp.status = 200
1403
1404 # Test the pure logic directly (no real network)
1405 reachable, message = True, "HTTP 200 OK"
1406 assert reachable is True
1407 assert "200" in message
1408 except Exception as exc:
1409 errors.append(f"Thread {idx}: {exc}")
1410
1411 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
1412 for t in threads:
1413 t.start()
1414 for t in threads:
1415 t.join()
1416 assert errors == [], "Concurrent ping failures:\n" + "\n".join(errors)
1417
1418 def test_8_concurrent_connect_to_isolated_repos(
1419 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1420 ) -> None:
1421 """8 threads each writing a hub URL to their own isolated config file."""
1422 from muse._version import __version__
1423 from muse.cli.config import set_hub_url, get_hub_url
1424
1425 errors: list[str] = []
1426
1427 def _do(idx: int) -> None:
1428 try:
1429 repo_dir = tmp_path / f"repo_{idx}"
1430 muse_dir = repo_dir / ".muse"
1431 muse_dir.mkdir(parents=True)
1432 (muse_dir / "config.toml").write_text("")
1433 (muse_dir / "repo.json").write_text(
1434 json.dumps({
1435 "repo_id": f"repo-{idx}",
1436 "schema_version": __version__,
1437 "domain": "code",
1438 })
1439 )
1440 hub = f"http://localhost:{19000 + idx}"
1441 set_hub_url(hub, repo_dir)
1442 stored = get_hub_url(repo_dir)
1443 assert stored == hub, f"Expected {hub!r}, got {stored!r}"
1444 except Exception as exc:
1445 errors.append(f"Thread {idx}: {exc}")
1446
1447 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
1448 for t in threads:
1449 t.start()
1450 for t in threads:
1451 t.join()
1452 assert errors == [], "Concurrent connect failures:\n" + "\n".join(errors)
1453
1454
1455 # ── Proposal subcommand hardening ────────────────────────────────────────────
1456
1457
1458 class TestProposalListHardening:
1459 """Additional hardening tests for `muse hub proposal list`."""
1460
1461 _HUB = "http://localhost:19999/gabriel/muse"
1462
1463 def _setup(self, repo: pathlib.Path) -> None:
1464 runner.invoke(cli, ["hub", "connect", self._HUB])
1465 _store_identity(self._HUB)
1466
1467 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
1468 mock_resp = MagicMock()
1469 mock_resp.__enter__ = lambda s: s
1470 mock_resp.__exit__ = MagicMock(return_value=False)
1471 mock_resp.read.return_value = payload_bytes
1472 return mock_resp
1473
1474 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
1475 return [self._make_api_resp(r) for r in responses]
1476
1477 def test_short_flag_j_works_for_list(self, repo: pathlib.Path) -> None:
1478 """``-j`` is accepted as alias for ``--json``."""
1479 self._setup(repo)
1480 resps = self._mock_api(
1481 json.dumps({"repo_id": "repo-uuid"}).encode(),
1482 json.dumps({"proposals": [], "total": 0, "nextCursor": None}).encode(),
1483 )
1484 with patch("urllib.request.urlopen", side_effect=resps):
1485 result = runner.invoke(cli, ["hub", "proposal", "list", "-j"])
1486 assert result.exit_code == 0
1487 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
1488 assert len(json_lines) >= 1
1489 obj = json.loads(json_lines[0])
1490 assert obj["proposals"] == []
1491
1492 def test_ansi_in_proposal_title_sanitized_text_mode(
1493 self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]
1494 ) -> None:
1495 """ANSI escape codes in proposal titles must not reach the terminal."""
1496 self._setup(repo)
1497 proposals_data = {"proposals": [
1498 {"proposalId": "abc12345-0000-0000-0000-000000000001",
1499 "title": "\x1b[31mevil title\x1b[0m", "state": "open",
1500 "fromBranch": "feat/x", "toBranch": "dev"},
1501 ]}
1502 resps = self._mock_api(
1503 json.dumps({"repo_id": "repo-uuid"}).encode(),
1504 json.dumps(proposals_data).encode(),
1505 )
1506 with patch("urllib.request.urlopen", side_effect=resps):
1507 result = runner.invoke(cli, ["hub", "proposal", "list"])
1508 assert result.exit_code == 0
1509 assert "\x1b[" not in result.output
1510
1511 def test_proposal_list_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
1512 result = runner.invoke(cli, ["hub", "proposal", "list"])
1513 assert result.exit_code != 0
1514
1515 def test_proposal_list_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None:
1516 runner.invoke(cli, ["hub", "connect", self._HUB])
1517 # No identity stored — _get_hub_and_identity must fail
1518 result = runner.invoke(cli, ["hub", "proposal", "list"])
1519 assert result.exit_code != 0
1520
1521 def test_proposal_list_limit_zero_exits_nonzero(self, repo: pathlib.Path) -> None:
1522 """``--limit 0`` is out of range and must exit non-zero without crashing."""
1523 self._setup(repo)
1524 resps = self._mock_api(
1525 json.dumps({"repo_id": "repo-uuid"}).encode(),
1526 json.dumps({"proposals": []}).encode(),
1527 )
1528 with patch("urllib.request.urlopen", side_effect=resps):
1529 result = runner.invoke(cli, ["hub", "proposal", "list", "--limit", "0"])
1530 assert result.exit_code != 0
1531
1532 def test_verbose_flag_shows_author_and_date(self, repo: pathlib.Path) -> None:
1533 """``--verbose`` must show author name and creation date per proposal."""
1534 self._setup(repo)
1535 proposals_data = {"proposals": [
1536 {"proposalId": "abc12345-0000-0000-0000-000000000001",
1537 "title": "feat: add thing", "state": "open",
1538 "fromBranch": "feat/x", "toBranch": "dev",
1539 "author": "alice", "createdAt": "2024-01-15T10:30:00Z"},
1540 ]}
1541 resps = self._mock_api(
1542 json.dumps({"repo_id": "repo-uuid"}).encode(),
1543 json.dumps(proposals_data).encode(),
1544 )
1545 with patch("urllib.request.urlopen", side_effect=resps):
1546 result = runner.invoke(cli, ["hub", "proposal", "list", "--verbose"])
1547 assert result.exit_code == 0
1548 assert "alice" in result.output
1549 assert "2024-01-15" in result.output
1550
1551 def test_verbose_short_flag_v(self, repo: pathlib.Path) -> None:
1552 """``-v`` is accepted as alias for ``--verbose``."""
1553 self._setup(repo)
1554 proposals_data = {"proposals": [
1555 {"proposalId": "abc12345-0000-0000-0000-000000000001",
1556 "title": "T", "state": "open",
1557 "fromBranch": "feat/x", "toBranch": "dev",
1558 "author": "bob", "createdAt": "2024-02-20T08:00:00Z"},
1559 ]}
1560 resps = self._mock_api(
1561 json.dumps({"repo_id": "repo-uuid"}).encode(),
1562 json.dumps(proposals_data).encode(),
1563 )
1564 with patch("urllib.request.urlopen", side_effect=resps):
1565 result = runner.invoke(cli, ["hub", "proposal", "list", "-v"])
1566 assert result.exit_code == 0
1567 assert "bob" in result.output
1568
1569 def test_verbose_ansi_in_author_sanitized(self, repo: pathlib.Path) -> None:
1570 """ANSI in ``author`` field in verbose mode must not reach the terminal."""
1571 self._setup(repo)
1572 proposals_data = {"proposals": [
1573 {"proposalId": "abc12345-0000-0000-0000-000000000001",
1574 "title": "T", "state": "open",
1575 "fromBranch": "feat/x", "toBranch": "dev",
1576 "author": "\x1b[31mevil-author\x1b[0m",
1577 "createdAt": "2024-01-01T00:00:00Z"},
1578 ]}
1579 resps = self._mock_api(
1580 json.dumps({"repo_id": "repo-uuid"}).encode(),
1581 json.dumps(proposals_data).encode(),
1582 )
1583 with patch("urllib.request.urlopen", side_effect=resps):
1584 result = runner.invoke(cli, ["hub", "proposal", "list", "--verbose"])
1585 assert result.exit_code == 0
1586 assert "\x1b[" not in result.output
1587
1588 def test_verbose_ansi_in_created_at_sanitized(self, repo: pathlib.Path) -> None:
1589 """ANSI in ``createdAt`` field in verbose mode must not reach the terminal."""
1590 self._setup(repo)
1591 proposals_data = {"proposals": [
1592 {"proposalId": "abc12345-0000-0000-0000-000000000001",
1593 "title": "T", "state": "open",
1594 "fromBranch": "feat/x", "toBranch": "dev",
1595 "author": "alice",
1596 "createdAt": "\x1b[31m2024-01-15\x1b[0m"},
1597 ]}
1598 resps = self._mock_api(
1599 json.dumps({"repo_id": "repo-uuid"}).encode(),
1600 json.dumps(proposals_data).encode(),
1601 )
1602 with patch("urllib.request.urlopen", side_effect=resps):
1603 result = runner.invoke(cli, ["hub", "proposal", "list", "--verbose"])
1604 assert result.exit_code == 0
1605 assert "\x1b[" not in result.output
1606
1607 def test_verbose_json_no_effect(self, repo: pathlib.Path) -> None:
1608 """``--verbose --json`` should still emit a JSON object, not verbose text."""
1609 self._setup(repo)
1610 proposals_data = {"proposals": [
1611 {"proposalId": "abc12345-0000-0000-0000-000000000001",
1612 "title": "T", "state": "open",
1613 "fromBranch": "feat/x", "toBranch": "dev"},
1614 ], "total": 1, "nextCursor": None}
1615 resps = self._mock_api(
1616 json.dumps({"repo_id": "repo-uuid"}).encode(),
1617 json.dumps(proposals_data).encode(),
1618 )
1619 with patch("urllib.request.urlopen", side_effect=resps):
1620 result = runner.invoke(cli, ["hub", "proposal", "list", "--verbose", "--json"])
1621 assert result.exit_code == 0
1622 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
1623 assert len(json_lines) >= 1
1624 obj = json.loads(json_lines[0])
1625 assert isinstance(obj, dict)
1626 assert len(obj["proposals"]) == 1
1627
1628 def test_state_merged_filter_accepted(self, repo: pathlib.Path) -> None:
1629 """``--state merged`` is a valid choice and must be sent in the query."""
1630 self._setup(repo)
1631 resps = self._mock_api(
1632 json.dumps({"repo_id": "repo-uuid"}).encode(),
1633 json.dumps({"proposals": []}).encode(),
1634 )
1635 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
1636 result = runner.invoke(cli, ["hub", "proposal", "list", "--state", "merged", "-j"])
1637 assert result.exit_code == 0
1638 # Verify the state filter was sent in the request URL
1639 called_url = mock_open.call_args_list[-1][0][0].full_url
1640 assert "state=merged" in called_url
1641
1642 def test_state_closed_filter_accepted(self, repo: pathlib.Path) -> None:
1643 self._setup(repo)
1644 resps = self._mock_api(
1645 json.dumps({"repo_id": "repo-uuid"}).encode(),
1646 json.dumps({"proposals": []}).encode(),
1647 )
1648 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
1649 result = runner.invoke(cli, ["hub", "proposal", "list", "--state", "closed", "-j"])
1650 assert result.exit_code == 0
1651 called_url = mock_open.call_args_list[-1][0][0].full_url
1652 assert "state=closed" in called_url
1653
1654 def test_state_all_omits_filter_from_url(self, repo: pathlib.Path) -> None:
1655 """``--state all`` must NOT append a ``state=`` param to the URL."""
1656 self._setup(repo)
1657 resps = self._mock_api(
1658 json.dumps({"repo_id": "repo-uuid"}).encode(),
1659 json.dumps({"proposals": []}).encode(),
1660 )
1661 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
1662 result = runner.invoke(cli, ["hub", "proposal", "list", "--state", "all", "-j"])
1663 assert result.exit_code == 0
1664 called_url = mock_open.call_args_list[-1][0][0].full_url
1665 assert "state=" not in called_url
1666
1667 def test_limit_sent_in_url(self, repo: pathlib.Path) -> None:
1668 """``--limit`` value must appear in the request URL."""
1669 self._setup(repo)
1670 resps = self._mock_api(
1671 json.dumps({"repo_id": "repo-uuid"}).encode(),
1672 json.dumps({"proposals": []}).encode(),
1673 )
1674 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
1675 result = runner.invoke(cli, ["hub", "proposal", "list", "--limit", "42", "-j"])
1676 assert result.exit_code == 0
1677 called_url = mock_open.call_args_list[-1][0][0].full_url
1678 assert "limit=42" in called_url
1679
1680 def test_text_header_contains_hub_url(self, repo: pathlib.Path) -> None:
1681 """The text-mode header must include the hub hostname."""
1682 self._setup(repo)
1683 proposals_data = {"proposals": [
1684 {"proposalId": "abc12345-0000-0000-0000-000000000001",
1685 "title": "T", "state": "open",
1686 "fromBranch": "feat/x", "toBranch": "dev"},
1687 ]}
1688 resps = self._mock_api(
1689 json.dumps({"repo_id": "repo-uuid"}).encode(),
1690 json.dumps(proposals_data).encode(),
1691 )
1692 with patch("urllib.request.urlopen", side_effect=resps):
1693 result = runner.invoke(cli, ["hub", "proposal", "list"])
1694 assert result.exit_code == 0
1695 assert "localhost:19999" in result.output
1696
1697 def test_multiple_prs_all_printed(self, repo: pathlib.Path) -> None:
1698 """All proposals within the limit must appear in text output."""
1699 self._setup(repo)
1700 proposals_data = {"proposals": [
1701 {"proposalId": f"aaaa0000-0000-0000-0000-{i:012d}",
1702 "title": f"Proposal-{i}", "state": "open",
1703 "fromBranch": f"feat/f{i}", "toBranch": "dev"}
1704 for i in range(5)
1705 ]}
1706 resps = self._mock_api(
1707 json.dumps({"repo_id": "repo-uuid"}).encode(),
1708 json.dumps(proposals_data).encode(),
1709 )
1710 with patch("urllib.request.urlopen", side_effect=resps):
1711 result = runner.invoke(cli, ["hub", "proposal", "list"])
1712 assert result.exit_code == 0
1713 for i in range(5):
1714 assert f"Proposal-{i}" in result.output
1715
1716 def test_json_contains_all_api_fields(self, repo: pathlib.Path) -> None:
1717 """JSON output is a passthrough — all API fields must be preserved."""
1718 self._setup(repo)
1719 proposal = {"proposalId": "abc12345-0000-0000-0000-000000000001",
1720 "title": "T", "state": "open",
1721 "fromBranch": "feat/x", "toBranch": "dev",
1722 "author": "alice", "createdAt": "2024-01-01T00:00:00Z"}
1723 resps = self._mock_api(
1724 json.dumps({"repo_id": "repo-uuid"}).encode(),
1725 json.dumps({"proposals": [proposal], "total": 1, "nextCursor": None}).encode(),
1726 )
1727 with patch("urllib.request.urlopen", side_effect=resps):
1728 result = runner.invoke(cli, ["hub", "proposal", "list", "-j"])
1729 assert result.exit_code == 0
1730 obj = json.loads(next(
1731 l for l in result.output.splitlines() if l.strip().startswith("{")
1732 ))
1733 arr = obj["proposals"]
1734 assert arr[0]["author"] == "alice"
1735 assert arr[0]["createdAt"] == "2024-01-01T00:00:00Z"
1736 assert arr[0]["proposalId"] == "abc12345-0000-0000-0000-000000000001"
1737
1738 def test_non_dict_entries_in_proposals_array_filtered(self, repo: pathlib.Path) -> None:
1739 """Malformed non-dict entries in the API proposals array are silently dropped."""
1740 self._setup(repo)
1741 proposals_data = {"proposals": [
1742 "not-a-dict",
1743 None,
1744 42,
1745 {"proposalId": "abc12345-0000-0000-0000-000000000001",
1746 "title": "Valid Proposal", "state": "open",
1747 "fromBranch": "feat/x", "toBranch": "dev"},
1748 ], "total": 1, "nextCursor": None}
1749 resps = self._mock_api(
1750 json.dumps({"repo_id": "repo-uuid"}).encode(),
1751 json.dumps(proposals_data).encode(),
1752 )
1753 with patch("urllib.request.urlopen", side_effect=resps):
1754 result = runner.invoke(cli, ["hub", "proposal", "list", "-j"])
1755 assert result.exit_code == 0
1756 obj = json.loads(next(
1757 l for l in result.output.splitlines() if l.strip().startswith("{")
1758 ))
1759 assert len(obj["proposals"]) == 1
1760 assert obj["proposals"][0]["title"] == "Valid Proposal"
1761
1762 def test_hub_override_flag_used(self, repo: pathlib.Path) -> None:
1763 """``--hub`` override must be used instead of config URL."""
1764 # Set a different hub in config, then override via --hub
1765 runner.invoke(cli, ["hub", "connect", "http://localhost:11111/wrong/repo"])
1766 _store_identity("http://localhost:19999/gabriel/muse")
1767 proposals_data = {"proposals": []}
1768 resps = self._mock_api(
1769 json.dumps({"repo_id": "repo-uuid"}).encode(),
1770 json.dumps(proposals_data).encode(),
1771 )
1772 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
1773 result = runner.invoke(
1774 cli,
1775 ["hub", "proposal", "list", "--hub", "http://localhost:19999/gabriel/muse", "-j"],
1776 )
1777 assert result.exit_code == 0
1778 # The resolved URL should contain 19999, not 11111
1779 called_urls = [c[0][0].full_url for c in mock_open.call_args_list]
1780 assert any("19999" in u for u in called_urls)
1781 assert not any("11111" in u for u in called_urls)
1782
1783 def test_proposal_list_outside_repo_exits_nonzero(
1784 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1785 ) -> None:
1786 monkeypatch.chdir(tmp_path)
1787 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
1788 result = runner.invoke(cli, ["hub", "proposal", "list"])
1789 assert result.exit_code != 0
1790
1791
1792 class TestFormatProposalVerbose:
1793 """Unit tests for _format_proposal verbose mode."""
1794
1795 def test_verbose_shows_author(self) -> None:
1796 from muse.cli.commands.hub import _format_proposal
1797 proposal = {"proposalId": "abc12345", "title": "T", "state": "open",
1798 "fromBranch": "f", "toBranch": "d",
1799 "author": "alice", "createdAt": "2024-06-01T12:00:00Z"}
1800 result = _format_proposal(proposal, verbose=True)
1801 assert "alice" in result
1802
1803 def test_verbose_shows_date_prefix(self) -> None:
1804 from muse.cli.commands.hub import _format_proposal
1805 proposal = {"proposalId": "abc12345", "title": "T", "state": "open",
1806 "fromBranch": "f", "toBranch": "d",
1807 "author": "bob", "createdAt": "2024-11-30T00:00:00Z"}
1808 result = _format_proposal(proposal, verbose=True)
1809 assert "2024-11-30" in result
1810
1811 def test_verbose_ansi_in_author_stripped(self) -> None:
1812 from muse.cli.commands.hub import _format_proposal
1813 proposal = {"proposalId": "abc12345", "title": "T", "state": "open",
1814 "fromBranch": "f", "toBranch": "d",
1815 "author": "\x1b[31mevil\x1b[0m", "createdAt": "2024-01-01"}
1816 result = _format_proposal(proposal, verbose=True)
1817 assert "\x1b[" not in result
1818
1819 def test_verbose_ansi_in_created_at_stripped(self) -> None:
1820 from muse.cli.commands.hub import _format_proposal
1821 proposal = {"proposalId": "abc12345", "title": "T", "state": "open",
1822 "fromBranch": "f", "toBranch": "d",
1823 "author": "alice", "createdAt": "\x1b[32m2024-01-01\x1b[0m"}
1824 result = _format_proposal(proposal, verbose=True)
1825 assert "\x1b[" not in result
1826
1827 def test_verbose_false_omits_author(self) -> None:
1828 from muse.cli.commands.hub import _format_proposal
1829 proposal = {"proposalId": "abc12345", "title": "T", "state": "open",
1830 "fromBranch": "f", "toBranch": "d",
1831 "author": "alice", "createdAt": "2024-01-01"}
1832 result = _format_proposal(proposal, verbose=False)
1833 assert "alice" not in result
1834
1835 def test_verbose_missing_author_shows_fallback(self) -> None:
1836 from muse.cli.commands.hub import _format_proposal
1837 proposal = {"proposalId": "abc12345", "title": "T", "state": "open",
1838 "fromBranch": "f", "toBranch": "d"}
1839 result = _format_proposal(proposal, verbose=True)
1840 assert "?" in result # fallback when author absent
1841
1842 def test_verbose_closed_icon(self) -> None:
1843 from muse.cli.commands.hub import _format_proposal
1844 proposal = {"proposalId": "abc12345", "title": "T", "state": "closed",
1845 "fromBranch": "f", "toBranch": "d"}
1846 result = _format_proposal(proposal)
1847 assert "⛔" in result
1848
1849 def test_verbose_unknown_state_uses_fallback_icon(self) -> None:
1850 from muse.cli.commands.hub import _format_proposal
1851 proposal = {"proposalId": "abc12345", "title": "T", "state": "unknown_state",
1852 "fromBranch": "f", "toBranch": "d"}
1853 result = _format_proposal(proposal)
1854 assert "❓" in result
1855
1856 def test_proposal_id_truncated_to_8_chars(self) -> None:
1857 from muse.cli.commands.hub import _format_proposal
1858 proposal = {"proposalId": "abc12345-full-uuid-here", "title": "T", "state": "open",
1859 "fromBranch": "f", "toBranch": "d"}
1860 result = _format_proposal(proposal)
1861 assert "abc12345" in result
1862 # The full UUID beyond 8 chars must not appear
1863 assert "full-uuid-here" not in result
1864
1865
1866 class TestProposalListStress:
1867 """Stress tests for `muse hub proposal list`."""
1868
1869 _HUB = "http://localhost:19999/gabriel/muse"
1870
1871 def _setup(self, repo: pathlib.Path) -> None:
1872 runner.invoke(cli, ["hub", "connect", self._HUB])
1873 _store_identity(self._HUB)
1874
1875 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
1876 mock_resp = MagicMock()
1877 mock_resp.__enter__ = lambda s: s
1878 mock_resp.__exit__ = MagicMock(return_value=False)
1879 mock_resp.read.return_value = payload_bytes
1880 return mock_resp
1881
1882 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
1883 return [self._make_api_resp(r) for r in responses]
1884
1885 def test_large_proposal_list_10000_items_json(self, repo: pathlib.Path) -> None:
1886 """10 000 proposals in the JSON response must be handled without crashing."""
1887 self._setup(repo)
1888 proposals = [
1889 {"proposalId": f"aaaa0000-0000-0000-0000-{i:012d}",
1890 "title": f"Proposal #{i}", "state": "open",
1891 "fromBranch": f"feat/f{i}", "toBranch": "dev"}
1892 for i in range(10_000)
1893 ]
1894 payload = json.dumps({"proposals": proposals, "total": 10_000, "nextCursor": None}).encode()
1895 mock_resp = MagicMock()
1896 mock_resp.__enter__ = lambda s: s
1897 mock_resp.__exit__ = MagicMock(return_value=False)
1898 mock_resp.read.return_value = payload
1899
1900 repo_resp = self._make_api_resp(json.dumps({"repo_id": "repo-uuid"}).encode())
1901 with patch("urllib.request.urlopen", side_effect=[repo_resp, mock_resp]):
1902 result = runner.invoke(cli, ["hub", "proposal", "list", "-n", "10000", "-j"])
1903 assert result.exit_code == 0
1904 obj = json.loads(next(
1905 l for l in result.output.splitlines() if l.strip().startswith("{")
1906 ))
1907 assert len(obj["proposals"]) == 10_000
1908
1909 def test_concurrent_format_proposal_calls(self) -> None:
1910 """8 threads calling _format_proposal concurrently must produce consistent results."""
1911 from muse.cli.commands.hub import _format_proposal
1912 errors: list[str] = []
1913 results: list[str] = [""] * 8
1914
1915 def _do(idx: int) -> None:
1916 try:
1917 proposal = {
1918 "proposalId": f"aaaa{idx:04d}-0000-0000-0000-000000000001",
1919 "title": f"Proposal-{idx}: \x1b[31mevil\x1b[0m",
1920 "state": "open",
1921 "fromBranch": f"feat/f{idx}",
1922 "toBranch": "dev",
1923 "author": f"user{idx}",
1924 "createdAt": f"2024-0{(idx % 9) + 1}-01T00:00:00Z",
1925 }
1926 results[idx] = _format_proposal(proposal, verbose=True)
1927 except Exception as exc:
1928 errors.append(f"Thread {idx}: {exc}")
1929
1930 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
1931 for t in threads:
1932 t.start()
1933 for t in threads:
1934 t.join()
1935 assert errors == [], "Concurrent _format_proposal failures:\n" + "\n".join(errors)
1936 # Each result must have ANSI stripped and contain the user name
1937 for idx, result in enumerate(results):
1938 assert "\x1b[" not in result, f"ANSI in thread {idx} output"
1939 assert f"user{idx}" in result, f"Author missing in thread {idx} output"
1940
1941
1942 class TestProposalListE2E:
1943 """End-to-end flow tests for `muse hub proposal list`."""
1944
1945 _HUB = "http://localhost:19999/gabriel/muse"
1946
1947 def _setup(self, repo: pathlib.Path) -> None:
1948 runner.invoke(cli, ["hub", "connect", self._HUB])
1949 _store_identity(self._HUB)
1950
1951 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
1952 mock_resp = MagicMock()
1953 mock_resp.__enter__ = lambda s: s
1954 mock_resp.__exit__ = MagicMock(return_value=False)
1955 mock_resp.read.return_value = payload_bytes
1956 return mock_resp
1957
1958 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
1959 return [self._make_api_resp(r) for r in responses]
1960
1961 def test_e2e_connect_then_list_json(self, repo: pathlib.Path) -> None:
1962 """Full flow: connect → list --json returns a well-formed envelope object."""
1963 self._setup(repo)
1964 proposals_data = {"proposals": [
1965 {"proposalId": "abc12345-0000-0000-0000-000000000001",
1966 "title": "My Proposal", "state": "open",
1967 "fromBranch": "feat/my", "toBranch": "dev",
1968 "author": "alice", "createdAt": "2024-03-01T09:00:00Z"},
1969 ], "total": 1, "nextCursor": None}
1970 resps = self._mock_api(
1971 json.dumps({"repo_id": "repo-uuid"}).encode(),
1972 json.dumps(proposals_data).encode(),
1973 )
1974 with patch("urllib.request.urlopen", side_effect=resps):
1975 result = runner.invoke(cli, ["hub", "proposal", "list", "-j"])
1976 assert result.exit_code == 0
1977 obj = json.loads(next(
1978 l for l in result.output.splitlines() if l.strip().startswith("{")
1979 ))
1980 arr = obj["proposals"]
1981 assert arr[0]["title"] == "My Proposal"
1982 assert arr[0]["state"] == "open"
1983 assert arr[0]["author"] == "alice"
1984
1985 def test_e2e_list_verbose_text_all_fields_present(self, repo: pathlib.Path) -> None:
1986 """Verbose text output includes state icon, ID prefix, branches, author, date."""
1987 self._setup(repo)
1988 proposals_data = {"proposals": [
1989 {"proposalId": "deadbeef-0000-0000-0000-000000000001",
1990 "title": "My feature", "state": "open",
1991 "fromBranch": "feat/my-feature", "toBranch": "dev",
1992 "author": "charlie", "createdAt": "2025-12-31T23:59:59Z"},
1993 ]}
1994 resps = self._mock_api(
1995 json.dumps({"repo_id": "repo-uuid"}).encode(),
1996 json.dumps(proposals_data).encode(),
1997 )
1998 with patch("urllib.request.urlopen", side_effect=resps):
1999 result = runner.invoke(cli, ["hub", "proposal", "list", "-v"])
2000 assert result.exit_code == 0
2001 output = result.output
2002 assert "🟢" in output
2003 assert "deadbeef" in output
2004 assert "feat/my-feature" in output
2005 assert "charlie" in output
2006 assert "2025-12-31" in output
2007
2008 def test_e2e_empty_list_exits_zero_with_message(self, repo: pathlib.Path) -> None:
2009 """Empty proposal list must exit 0 and print a human-friendly message."""
2010 self._setup(repo)
2011 resps = self._mock_api(
2012 json.dumps({"repo_id": "repo-uuid"}).encode(),
2013 json.dumps({"proposals": []}).encode(),
2014 )
2015 with patch("urllib.request.urlopen", side_effect=resps):
2016 result = runner.invoke(cli, ["hub", "proposal", "list", "--state", "merged"])
2017 assert result.exit_code == 0
2018 assert "No proposals" in result.output or "no proposals" in result.output.lower()
2019
2020 def test_e2e_json_no_stdout_in_text_mode(self, repo: pathlib.Path) -> None:
2021 """In text mode, JSON must NOT appear on stdout — all output goes to stderr."""
2022 self._setup(repo)
2023 proposals_data = {"proposals": [
2024 {"proposalId": "abc12345-0000-0000-0000-000000000001",
2025 "title": "T", "state": "open",
2026 "fromBranch": "feat/x", "toBranch": "dev"},
2027 ]}
2028 resps = self._mock_api(
2029 json.dumps({"repo_id": "repo-uuid"}).encode(),
2030 json.dumps(proposals_data).encode(),
2031 )
2032 with patch("urllib.request.urlopen", side_effect=resps):
2033 result = runner.invoke(cli, ["hub", "proposal", "list"])
2034 assert result.exit_code == 0
2035 # In text mode, stdout should have no JSON array
2036 for line in result.output.splitlines():
2037 stripped = line.strip()
2038 assert not stripped.startswith("["), (
2039 f"Unexpected JSON on stdout in text mode: {stripped!r}"
2040 )
2041
2042
2043 class TestProposalViewHardening:
2044 """Additional hardening tests for `muse hub proposal show`."""
2045
2046 _HUB = "http://localhost:19999/gabriel/muse"
2047
2048 def _setup(self, repo: pathlib.Path) -> None:
2049 runner.invoke(cli, ["hub", "connect", self._HUB])
2050 _store_identity(self._HUB)
2051
2052 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
2053 mock_resp = MagicMock()
2054 mock_resp.__enter__ = lambda s: s
2055 mock_resp.__exit__ = MagicMock(return_value=False)
2056 mock_resp.read.return_value = payload_bytes
2057 return mock_resp
2058
2059 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
2060 return [self._make_api_resp(r) for r in responses]
2061
2062 def test_short_flag_j_works_for_view(self, repo: pathlib.Path) -> None:
2063 """``-j`` is accepted as alias for ``--json``."""
2064 self._setup(repo)
2065 proposal_id = "abc12345-0000-0000-0000-000000000001"
2066 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2067 "fromBranch": "feat/x", "toBranch": "dev"}
2068 proposals_data = {"proposals": [
2069 {"proposalId": proposal_id, "title": "T", "state": "open",
2070 "fromBranch": "feat/x", "toBranch": "dev"},
2071 ]}
2072 resps = self._mock_api(
2073 json.dumps({"repo_id": "repo-uuid"}).encode(),
2074 json.dumps(proposals_data).encode(),
2075 json.dumps(proposal_data).encode(),
2076 )
2077 with patch("urllib.request.urlopen", side_effect=resps):
2078 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345", "-j"])
2079 assert result.exit_code == 0
2080 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
2081 assert len(json_lines) >= 1
2082
2083 def test_ansi_in_state_sanitized(
2084 self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]
2085 ) -> None:
2086 """ANSI in ``state`` field must not reach terminal in text mode."""
2087 self._setup(repo)
2088 proposal_id = "abc12345-0000-0000-0000-000000000001"
2089 evil_proposal = {"proposalId": proposal_id, "title": "T",
2090 "state": "\x1b[31mopen\x1b[0m",
2091 "fromBranch": "feat/x", "toBranch": "dev"}
2092 proposals_data = {"proposals": [
2093 {"proposalId": proposal_id, "title": "T", "state": "open",
2094 "fromBranch": "feat/x", "toBranch": "dev"},
2095 ]}
2096 resps = self._mock_api(
2097 json.dumps({"repo_id": "repo-uuid"}).encode(),
2098 json.dumps(proposals_data).encode(),
2099 json.dumps(evil_proposal).encode(),
2100 )
2101 with patch("urllib.request.urlopen", side_effect=resps):
2102 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2103 assert result.exit_code == 0
2104 assert "\x1b[" not in result.output
2105
2106 def test_ansi_in_branch_sanitized(self, repo: pathlib.Path) -> None:
2107 """ANSI in branch names must not reach terminal in text mode."""
2108 self._setup(repo)
2109 proposal_id = "abc12345-0000-0000-0000-000000000001"
2110 evil_proposal = {"proposalId": proposal_id, "title": "T", "state": "open",
2111 "fromBranch": "\x1b[32mfeat/evil\x1b[0m",
2112 "toBranch": "\x1b[34mdev\x1b[0m"}
2113 proposals_data = {"proposals": [
2114 {"proposalId": proposal_id, "title": "T", "state": "open",
2115 "fromBranch": "feat/x", "toBranch": "dev"},
2116 ]}
2117 resps = self._mock_api(
2118 json.dumps({"repo_id": "repo-uuid"}).encode(),
2119 json.dumps(proposals_data).encode(),
2120 json.dumps(evil_proposal).encode(),
2121 )
2122 with patch("urllib.request.urlopen", side_effect=resps):
2123 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2124 assert result.exit_code == 0
2125 assert "\x1b[" not in result.output
2126
2127 def test_ansi_in_body_lines_sanitized(self, repo: pathlib.Path) -> None:
2128 """ANSI in body text must not reach terminal in text mode."""
2129 self._setup(repo)
2130 proposal_id = "abc12345-0000-0000-0000-000000000001"
2131 evil_proposal = {"proposalId": proposal_id, "title": "T", "state": "open",
2132 "fromBranch": "feat/x", "toBranch": "dev",
2133 "body": "\x1b[31mThis body has ANSI\x1b[0m"}
2134 proposals_data = {"proposals": [
2135 {"proposalId": proposal_id, "title": "T", "state": "open",
2136 "fromBranch": "feat/x", "toBranch": "dev"},
2137 ]}
2138 resps = self._mock_api(
2139 json.dumps({"repo_id": "repo-uuid"}).encode(),
2140 json.dumps(proposals_data).encode(),
2141 json.dumps(evil_proposal).encode(),
2142 )
2143 with patch("urllib.request.urlopen", side_effect=resps):
2144 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2145 assert result.exit_code == 0
2146 assert "\x1b[" not in result.output
2147
2148 def test_view_prefix_not_found_exits_nonzero(self, repo: pathlib.Path) -> None:
2149 self._setup(repo)
2150 proposals_data = {"proposals": []}
2151 resps = self._mock_api(
2152 json.dumps({"repo_id": "repo-uuid"}).encode(),
2153 json.dumps(proposals_data).encode(),
2154 )
2155 with patch("urllib.request.urlopen", side_effect=resps):
2156 result = runner.invoke(cli, ["hub", "proposal", "read", "deadbeef"])
2157 assert result.exit_code != 0
2158
2159 def test_view_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
2160 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2161 assert result.exit_code != 0
2162
2163 def test_view_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None:
2164 runner.invoke(cli, ["hub", "connect", self._HUB])
2165 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2166 assert result.exit_code != 0
2167
2168 def test_view_outside_repo_exits_nonzero(
2169 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
2170 ) -> None:
2171 monkeypatch.chdir(tmp_path)
2172 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
2173 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2174 assert result.exit_code != 0
2175
2176 def test_full_uuid_skips_prefix_resolution(self, repo: pathlib.Path) -> None:
2177 """A full UUID must reach the view endpoint with exactly 2 API calls (no prefix fetch)."""
2178 self._setup(repo)
2179 proposal_id = "abc12345-def0-0000-0000-000000000001"
2180 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2181 "fromBranch": "feat/x", "toBranch": "dev"}
2182 resps = self._mock_api(
2183 json.dumps({"repo_id": "repo-uuid"}).encode(), # _resolve_repo_id
2184 json.dumps(proposal_data).encode(), # GET proposals/{id}
2185 )
2186 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2187 result = runner.invoke(cli, ["hub", "proposal", "read", proposal_id, "-j"])
2188 assert result.exit_code == 0
2189 # Only 2 urlopen calls: repo resolution + the view fetch (no prefix list call)
2190 assert mock_open.call_count == 2
2191
2192 def test_prefix_triggers_resolution_call(self, repo: pathlib.Path) -> None:
2193 """An 8-char prefix must trigger a prefix-resolution list fetch (3 API calls total)."""
2194 self._setup(repo)
2195 proposal_id = "abc12345-0000-0000-0000-000000000001"
2196 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2197 "fromBranch": "feat/x", "toBranch": "dev"}
2198 proposals_data = {"proposals": [
2199 {"proposalId": proposal_id, "title": "T", "state": "open",
2200 "fromBranch": "feat/x", "toBranch": "dev"},
2201 ]}
2202 resps = self._mock_api(
2203 json.dumps({"repo_id": "repo-uuid"}).encode(), # _resolve_repo_id
2204 json.dumps(proposals_data).encode(), # prefix resolution list
2205 json.dumps(proposal_data).encode(), # GET proposals/{id}
2206 )
2207 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2208 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345", "-j"])
2209 assert result.exit_code == 0
2210 assert mock_open.call_count == 3
2211
2212 def test_author_shown_in_text_mode(self, repo: pathlib.Path) -> None:
2213 self._setup(repo)
2214 proposal_id = "abc12345-0000-0000-0000-000000000001"
2215 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2216 "fromBranch": "feat/x", "toBranch": "dev",
2217 "author": "charlie", "createdAt": "2024-07-04T00:00:00Z"}
2218 resps = self._mock_api(
2219 json.dumps({"repo_id": "repo-uuid"}).encode(),
2220 json.dumps({"proposals": [
2221 {"proposalId": proposal_id, "title": "T", "state": "open",
2222 "fromBranch": "feat/x", "toBranch": "dev"},
2223 ]}).encode(),
2224 json.dumps(proposal_data).encode(),
2225 )
2226 with patch("urllib.request.urlopen", side_effect=resps):
2227 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2228 assert result.exit_code == 0
2229 assert "charlie" in result.output
2230
2231 def test_created_at_shown_in_text_mode(self, repo: pathlib.Path) -> None:
2232 self._setup(repo)
2233 proposal_id = "abc12345-0000-0000-0000-000000000001"
2234 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2235 "fromBranch": "feat/x", "toBranch": "dev",
2236 "author": "alice", "createdAt": "2025-03-15T08:30:00Z"}
2237 resps = self._mock_api(
2238 json.dumps({"repo_id": "repo-uuid"}).encode(),
2239 json.dumps({"proposals": [
2240 {"proposalId": proposal_id, "title": "T", "state": "open",
2241 "fromBranch": "feat/x", "toBranch": "dev"},
2242 ]}).encode(),
2243 json.dumps(proposal_data).encode(),
2244 )
2245 with patch("urllib.request.urlopen", side_effect=resps):
2246 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2247 assert result.exit_code == 0
2248 assert "2025-03-15" in result.output
2249
2250 def test_ansi_in_author_sanitized(self, repo: pathlib.Path) -> None:
2251 self._setup(repo)
2252 proposal_id = "abc12345-0000-0000-0000-000000000001"
2253 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2254 "fromBranch": "feat/x", "toBranch": "dev",
2255 "author": "\x1b[31mevil-author\x1b[0m",
2256 "createdAt": "2024-01-01T00:00:00Z"}
2257 resps = self._mock_api(
2258 json.dumps({"repo_id": "repo-uuid"}).encode(),
2259 json.dumps({"proposals": [
2260 {"proposalId": proposal_id, "title": "T", "state": "open",
2261 "fromBranch": "feat/x", "toBranch": "dev"},
2262 ]}).encode(),
2263 json.dumps(proposal_data).encode(),
2264 )
2265 with patch("urllib.request.urlopen", side_effect=resps):
2266 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2267 assert result.exit_code == 0
2268 assert "\x1b[" not in result.output
2269
2270 def test_ansi_in_created_at_sanitized(self, repo: pathlib.Path) -> None:
2271 self._setup(repo)
2272 proposal_id = "abc12345-0000-0000-0000-000000000001"
2273 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2274 "fromBranch": "feat/x", "toBranch": "dev",
2275 "author": "alice",
2276 "createdAt": "\x1b[32m2024-01-01\x1b[0mTevil"}
2277 resps = self._mock_api(
2278 json.dumps({"repo_id": "repo-uuid"}).encode(),
2279 json.dumps({"proposals": [
2280 {"proposalId": proposal_id, "title": "T", "state": "open",
2281 "fromBranch": "feat/x", "toBranch": "dev"},
2282 ]}).encode(),
2283 json.dumps(proposal_data).encode(),
2284 )
2285 with patch("urllib.request.urlopen", side_effect=resps):
2286 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2287 assert result.exit_code == 0
2288 assert "\x1b[" not in result.output
2289
2290 def test_body_truncation_hint_shown(self, repo: pathlib.Path) -> None:
2291 """Body exceeding _MAX_PROPOSAL_BODY_LINES must show a truncation hint."""
2292 from muse.cli.commands.hub import _MAX_PROPOSAL_BODY_LINES
2293 self._setup(repo)
2294 proposal_id = "abc12345-0000-0000-0000-000000000001"
2295 long_body = "\n".join(f"line {i}" for i in range(_MAX_PROPOSAL_BODY_LINES + 5))
2296 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2297 "fromBranch": "feat/x", "toBranch": "dev", "body": long_body}
2298 resps = self._mock_api(
2299 json.dumps({"repo_id": "repo-uuid"}).encode(),
2300 json.dumps({"proposals": [
2301 {"proposalId": proposal_id, "title": "T", "state": "open",
2302 "fromBranch": "feat/x", "toBranch": "dev"},
2303 ]}).encode(),
2304 json.dumps(proposal_data).encode(),
2305 )
2306 with patch("urllib.request.urlopen", side_effect=resps):
2307 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2308 assert result.exit_code == 0
2309 assert "more line" in result.output
2310 assert "--json" in result.output # hint mentions --json
2311
2312 def test_body_exactly_at_limit_no_hint(self, repo: pathlib.Path) -> None:
2313 """Body at exactly _MAX_PROPOSAL_BODY_LINES must NOT show a truncation hint."""
2314 from muse.cli.commands.hub import _MAX_PROPOSAL_BODY_LINES
2315 self._setup(repo)
2316 proposal_id = "abc12345-0000-0000-0000-000000000001"
2317 exact_body = "\n".join(f"line {i}" for i in range(_MAX_PROPOSAL_BODY_LINES))
2318 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2319 "fromBranch": "feat/x", "toBranch": "dev", "body": exact_body}
2320 resps = self._mock_api(
2321 json.dumps({"repo_id": "repo-uuid"}).encode(),
2322 json.dumps({"proposals": [
2323 {"proposalId": proposal_id, "title": "T", "state": "open",
2324 "fromBranch": "feat/x", "toBranch": "dev"},
2325 ]}).encode(),
2326 json.dumps(proposal_data).encode(),
2327 )
2328 with patch("urllib.request.urlopen", side_effect=resps):
2329 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2330 assert result.exit_code == 0
2331 assert "more line" not in result.output
2332
2333 def test_no_body_field_no_body_section(self, repo: pathlib.Path) -> None:
2334 """When body is absent or empty, no 'Body:' section must appear."""
2335 self._setup(repo)
2336 proposal_id = "abc12345-0000-0000-0000-000000000001"
2337 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2338 "fromBranch": "feat/x", "toBranch": "dev"}
2339 resps = self._mock_api(
2340 json.dumps({"repo_id": "repo-uuid"}).encode(),
2341 json.dumps({"proposals": [
2342 {"proposalId": proposal_id, "title": "T", "state": "open",
2343 "fromBranch": "feat/x", "toBranch": "dev"},
2344 ]}).encode(),
2345 json.dumps(proposal_data).encode(),
2346 )
2347 with patch("urllib.request.urlopen", side_effect=resps):
2348 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2349 assert result.exit_code == 0
2350 assert "Body:" not in result.output
2351
2352 def test_json_passthrough_includes_all_fields(self, repo: pathlib.Path) -> None:
2353 """JSON output must be an unmodified passthrough from the API."""
2354 self._setup(repo)
2355 proposal_id = "abc12345-0000-0000-0000-000000000001"
2356 proposal_data = {"proposalId": proposal_id, "title": "My Proposal", "state": "open",
2357 "fromBranch": "feat/x", "toBranch": "dev",
2358 "author": "alice", "createdAt": "2024-01-01T00:00:00Z",
2359 "body": "Full body text here.",
2360 "extraField": "agent-visible"}
2361 resps = self._mock_api(
2362 json.dumps({"repo_id": "repo-uuid"}).encode(),
2363 json.dumps({"proposals": [
2364 {"proposalId": proposal_id, "title": "My Proposal", "state": "open",
2365 "fromBranch": "feat/x", "toBranch": "dev"},
2366 ]}).encode(),
2367 json.dumps(proposal_data).encode(),
2368 )
2369 with patch("urllib.request.urlopen", side_effect=resps):
2370 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345", "-j"])
2371 assert result.exit_code == 0
2372 data = json.loads(next(
2373 l for l in result.output.splitlines() if l.strip().startswith("{")
2374 ))
2375 assert data["author"] == "alice"
2376 assert data["body"] == "Full body text here."
2377 assert data["extraField"] == "agent-visible"
2378
2379 def test_hub_override_flag(self, repo: pathlib.Path) -> None:
2380 """``--hub`` must route requests to the override URL."""
2381 runner.invoke(cli, ["hub", "connect", "http://localhost:11111/wrong/repo"])
2382 _store_identity("http://localhost:19999/gabriel/muse")
2383 proposal_id = "abc12345-def0-0000-0000-000000000001"
2384 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2385 "fromBranch": "f", "toBranch": "d"}
2386 resps = self._mock_api(
2387 json.dumps({"repo_id": "repo-uuid"}).encode(),
2388 json.dumps(proposal_data).encode(),
2389 )
2390 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2391 result = runner.invoke(
2392 cli,
2393 ["hub", "proposal", "read", proposal_id,
2394 "--hub", "http://localhost:19999/gabriel/muse", "-j"],
2395 )
2396 assert result.exit_code == 0
2397 called_urls = [c[0][0].full_url for c in mock_open.call_args_list]
2398 assert any("19999" in u for u in called_urls)
2399 assert not any("11111" in u for u in called_urls)
2400
2401
2402 class TestProposalViewUnit:
2403 """Pure unit tests for run_proposal_show text rendering logic."""
2404
2405 def _make_proposal_resp(self, **kwargs: str) -> bytes:
2406 base: Manifest = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2407 "title": "My Proposal", "state": "open",
2408 "fromBranch": "feat/x", "toBranch": "dev"}
2409 base.update(kwargs)
2410 return json.dumps(base).encode()
2411
2412 def _invoke_view(
2413 self,
2414 repo: pathlib.Path,
2415 proposal_data: bytes,
2416 *,
2417 flags: list[str] | None = None,
2418 ) -> InvokeResult:
2419 """Invoke hub proposal show with a pre-resolved full UUID (2 API calls only)."""
2420 proposal_id = "abc12345-def0-0000-0000-000000000001"
2421 # Use a full UUID to skip the prefix-resolution fetch
2422 mock_repo = MagicMock()
2423 mock_repo.__enter__ = lambda s: s
2424 mock_repo.__exit__ = MagicMock(return_value=False)
2425 mock_repo.read.return_value = json.dumps({"repo_id": "repo-uuid"}).encode()
2426
2427 mock_proposal = MagicMock()
2428 mock_proposal.__enter__ = lambda s: s
2429 mock_proposal.__exit__ = MagicMock(return_value=False)
2430 mock_proposal.read.return_value = proposal_data
2431
2432 cmd = ["hub", "proposal", "read", proposal_id] + (flags or [])
2433 with patch("urllib.request.urlopen", side_effect=[mock_repo, mock_proposal]):
2434 return runner.invoke(cli, cmd)
2435
2436 def test_state_open_icon(self, repo: pathlib.Path) -> None:
2437 runner.invoke(cli, ["hub", "connect", "http://localhost:19999/gabriel/muse"])
2438 _store_identity("http://localhost:19999/gabriel/muse")
2439 result = self._invoke_view(repo, self._make_proposal_resp(state="open"))
2440 assert "🟢" in result.output
2441
2442 def test_state_merged_icon(self, repo: pathlib.Path) -> None:
2443 runner.invoke(cli, ["hub", "connect", "http://localhost:19999/gabriel/muse"])
2444 _store_identity("http://localhost:19999/gabriel/muse")
2445 result = self._invoke_view(repo, self._make_proposal_resp(state="merged"))
2446 assert "🟣" in result.output
2447
2448 def test_state_closed_icon(self, repo: pathlib.Path) -> None:
2449 runner.invoke(cli, ["hub", "connect", "http://localhost:19999/gabriel/muse"])
2450 _store_identity("http://localhost:19999/gabriel/muse")
2451 result = self._invoke_view(repo, self._make_proposal_resp(state="closed"))
2452 assert "⛔" in result.output
2453
2454 def test_unknown_state_fallback_icon(self, repo: pathlib.Path) -> None:
2455 runner.invoke(cli, ["hub", "connect", "http://localhost:19999/gabriel/muse"])
2456 _store_identity("http://localhost:19999/gabriel/muse")
2457 result = self._invoke_view(repo, self._make_proposal_resp(state="draft"))
2458 assert "❓" in result.output
2459
2460 def test_no_author_field_omits_by_line(self, repo: pathlib.Path) -> None:
2461 """When author is absent, the 'By:' line must not appear."""
2462 runner.invoke(cli, ["hub", "connect", "http://localhost:19999/gabriel/muse"])
2463 _store_identity("http://localhost:19999/gabriel/muse")
2464 result = self._invoke_view(repo, self._make_proposal_resp())
2465 assert "By:" not in result.output
2466
2467 def test_state_upper_in_header(self, repo: pathlib.Path) -> None:
2468 runner.invoke(cli, ["hub", "connect", "http://localhost:19999/gabriel/muse"])
2469 _store_identity("http://localhost:19999/gabriel/muse")
2470 result = self._invoke_view(repo, self._make_proposal_resp(state="open"))
2471 assert "[OPEN]" in result.output
2472
2473 def test_id_and_branches_in_output(self, repo: pathlib.Path) -> None:
2474 runner.invoke(cli, ["hub", "connect", "http://localhost:19999/gabriel/muse"])
2475 _store_identity("http://localhost:19999/gabriel/muse")
2476 proposal_id = "abc12345-def0-0000-0000-000000000001"
2477 result = self._invoke_view(
2478 repo,
2479 self._make_proposal_resp(proposalId=proposal_id, fromBranch="feat/my", toBranch="main"),
2480 )
2481 assert "feat/my" in result.output
2482 assert "main" in result.output
2483
2484
2485 class TestProposalViewE2E:
2486 """End-to-end scenario tests for `muse hub proposal show`."""
2487
2488 _HUB = "http://localhost:19999/gabriel/muse"
2489
2490 def _setup(self, repo: pathlib.Path) -> None:
2491 runner.invoke(cli, ["hub", "connect", self._HUB])
2492 _store_identity(self._HUB)
2493
2494 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
2495 mock_resp = MagicMock()
2496 mock_resp.__enter__ = lambda s: s
2497 mock_resp.__exit__ = MagicMock(return_value=False)
2498 mock_resp.read.return_value = payload_bytes
2499 return mock_resp
2500
2501 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
2502 return [self._make_api_resp(r) for r in responses]
2503
2504 def test_e2e_full_proposal_text_output(self, repo: pathlib.Path) -> None:
2505 """Full flow with all optional fields — all sections must appear."""
2506 self._setup(repo)
2507 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
2508 proposal_data = {
2509 "proposalId": proposal_id,
2510 "title": "feat: add sonic synthesis",
2511 "state": "open",
2512 "fromBranch": "feat/sonic",
2513 "toBranch": "dev",
2514 "author": "gabriel",
2515 "createdAt": "2025-06-01T12:00:00Z",
2516 "body": "This proposal adds sonic synthesis support.",
2517 }
2518 resps = self._mock_api(
2519 json.dumps({"repo_id": "repo-uuid"}).encode(),
2520 json.dumps(proposal_data).encode(),
2521 )
2522 with patch("urllib.request.urlopen", side_effect=resps):
2523 result = runner.invoke(cli, ["hub", "proposal", "read", proposal_id])
2524 assert result.exit_code == 0
2525 output = result.output
2526 assert "🟢" in output
2527 assert "feat: add sonic synthesis" in output
2528 assert "feat/sonic" in output
2529 assert "gabriel" in output
2530 assert "2025-06-01" in output
2531 assert "sonic synthesis support" in output
2532
2533 def test_e2e_json_agent_workflow(self, repo: pathlib.Path) -> None:
2534 """Simulate an agent extracting state via --json | jq."""
2535 self._setup(repo)
2536 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
2537 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "merged",
2538 "fromBranch": "feat/x", "toBranch": "dev",
2539 "author": "bot", "mergeCommitId": "aabbccdd11223344"}
2540 resps = self._mock_api(
2541 json.dumps({"repo_id": "repo-uuid"}).encode(),
2542 json.dumps(proposal_data).encode(),
2543 )
2544 with patch("urllib.request.urlopen", side_effect=resps):
2545 result = runner.invoke(cli, ["hub", "proposal", "read", proposal_id, "--json"])
2546 assert result.exit_code == 0
2547 data = json.loads(next(
2548 l for l in result.output.splitlines() if l.strip().startswith("{")
2549 ))
2550 assert data["state"] == "merged"
2551 assert data["mergeCommitId"] == "aabbccdd11223344"
2552
2553 def test_e2e_body_truncation_hint_points_to_json(self, repo: pathlib.Path) -> None:
2554 """Truncation hint must explicitly mention --json."""
2555 from muse.cli.commands.hub import _MAX_PROPOSAL_BODY_LINES
2556 self._setup(repo)
2557 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
2558 long_body = "\n".join(f"line {i}" for i in range(_MAX_PROPOSAL_BODY_LINES + 10))
2559 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2560 "fromBranch": "feat/x", "toBranch": "dev", "body": long_body}
2561 resps = self._mock_api(
2562 json.dumps({"repo_id": "repo-uuid"}).encode(),
2563 json.dumps(proposal_data).encode(),
2564 )
2565 with patch("urllib.request.urlopen", side_effect=resps):
2566 result = runner.invoke(cli, ["hub", "proposal", "read", proposal_id])
2567 assert result.exit_code == 0
2568 assert "--json" in result.output
2569 assert "10 more line" in result.output
2570
2571 def test_e2e_ambiguous_prefix_exits_nonzero(self, repo: pathlib.Path) -> None:
2572 """Two proposals with the same prefix must cause a non-zero exit."""
2573 self._setup(repo)
2574 proposals_data = {"proposals": [
2575 {"proposalId": "abc12345-0000-0000-0000-000000000001", "title": "Proposal 1",
2576 "state": "open", "fromBranch": "feat/a", "toBranch": "dev"},
2577 {"proposalId": "abc12345-0000-0000-0000-000000000002", "title": "Proposal 2",
2578 "state": "open", "fromBranch": "feat/b", "toBranch": "dev"},
2579 ]}
2580 resps = self._mock_api(
2581 json.dumps({"repo_id": "repo-uuid"}).encode(),
2582 json.dumps(proposals_data).encode(),
2583 )
2584 with patch("urllib.request.urlopen", side_effect=resps):
2585 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2586 assert result.exit_code != 0
2587
2588
2589 class TestProposalViewStress:
2590 """Stress tests for `muse hub proposal show`."""
2591
2592 _HUB = "http://localhost:19999/gabriel/muse"
2593
2594 def test_body_with_1000_lines_truncated(self, repo: pathlib.Path) -> None:
2595 """A 1000-line body must be accepted without OOM and truncated correctly."""
2596 from muse.cli.commands.hub import _MAX_PROPOSAL_BODY_LINES
2597
2598 runner.invoke(cli, ["hub", "connect", self._HUB])
2599 _store_identity(self._HUB)
2600
2601 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
2602 big_body = "\n".join(f"line {i}" for i in range(1000))
2603 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2604 "fromBranch": "feat/x", "toBranch": "dev", "body": big_body}
2605
2606 mock_repo = MagicMock()
2607 mock_repo.__enter__ = lambda s: s
2608 mock_repo.__exit__ = MagicMock(return_value=False)
2609 mock_repo.read.return_value = json.dumps({"repo_id": "repo-uuid"}).encode()
2610
2611 mock_proposal = MagicMock()
2612 mock_proposal.__enter__ = lambda s: s
2613 mock_proposal.__exit__ = MagicMock(return_value=False)
2614 mock_proposal.read.return_value = json.dumps(proposal_data).encode()
2615
2616 with patch("urllib.request.urlopen", side_effect=[mock_repo, mock_proposal]):
2617 result = runner.invoke(cli, ["hub", "proposal", "read", proposal_id])
2618 assert result.exit_code == 0
2619 lines_shown = [l for l in result.output.splitlines() if l.strip().startswith("line ")]
2620 assert len(lines_shown) == _MAX_PROPOSAL_BODY_LINES
2621 assert "more line" in result.output
2622
2623 def test_concurrent_format_operations(self) -> None:
2624 """_format_proposal called concurrently from 8 threads must not produce ANSI leakage."""
2625 from muse.cli.commands.hub import _format_proposal
2626 errors: list[str] = []
2627
2628 def _do(idx: int) -> None:
2629 try:
2630 proposal = {
2631 "proposalId": f"dead{idx:04d}-0000-0000-0000-000000000001",
2632 "title": f"\x1b[31mProposal-{idx}\x1b[0m",
2633 "state": "open",
2634 "fromBranch": f"\x1b[32mfeat/f{idx}\x1b[0m",
2635 "toBranch": "dev",
2636 }
2637 result = _format_proposal(proposal)
2638 assert "\x1b[" not in result, f"Thread {idx}: ANSI leaked"
2639 except Exception as exc:
2640 errors.append(f"Thread {idx}: {exc}")
2641
2642 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
2643 for t in threads:
2644 t.start()
2645 for t in threads:
2646 t.join()
2647 assert errors == [], "\n".join(errors)
2648
2649
2650 class TestProposalCreateHardening:
2651 """Additional hardening tests for `muse hub proposal create`."""
2652
2653 _HUB = "http://localhost:19999/gabriel/muse"
2654
2655 def _setup(self, repo: pathlib.Path) -> None:
2656 runner.invoke(cli, ["hub", "connect", self._HUB])
2657 _store_identity(self._HUB)
2658
2659 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
2660 mock_resp = MagicMock()
2661 mock_resp.__enter__ = lambda s: s
2662 mock_resp.__exit__ = MagicMock(return_value=False)
2663 mock_resp.read.return_value = payload_bytes
2664 return mock_resp
2665
2666 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
2667 return [self._make_api_resp(r) for r in responses]
2668
2669 def test_short_flag_j_works_for_create(self, repo: pathlib.Path) -> None:
2670 self._setup(repo)
2671 (repo / ".muse" / "refs" / "heads" / "feat-x").write_text("")
2672 (repo / ".muse" / "HEAD").write_text("ref: refs/heads/feat-x\n")
2673 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2674 "state": "open", "fromBranch": "feat-x", "toBranch": "dev"}
2675 resps = self._mock_api(
2676 json.dumps({"repo_id": "repo-uuid"}).encode(),
2677 json.dumps(create_resp).encode(),
2678 )
2679 with patch("urllib.request.urlopen", side_effect=resps):
2680 result = runner.invoke(
2681 cli,
2682 ["hub", "proposal", "create", "--title", "T", "--from-branch", "feat-x", "-j"],
2683 )
2684 assert result.exit_code == 0
2685 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
2686 assert len(json_lines) >= 1
2687
2688 def test_ansi_in_proposal_id_sanitized_text_output(self, repo: pathlib.Path) -> None:
2689 """ANSI in returned proposalId must not reach terminal in text mode."""
2690 self._setup(repo)
2691 (repo / ".muse" / "refs" / "heads" / "feat-x").write_text("")
2692 (repo / ".muse" / "HEAD").write_text("ref: refs/heads/feat-x\n")
2693 create_resp = {"proposalId": "\x1b[31mabc12345-evil\x1b[0m",
2694 "state": "open", "fromBranch": "feat-x", "toBranch": "dev"}
2695 resps = self._mock_api(
2696 json.dumps({"repo_id": "repo-uuid"}).encode(),
2697 json.dumps(create_resp).encode(),
2698 )
2699 with patch("urllib.request.urlopen", side_effect=resps):
2700 result = runner.invoke(
2701 cli,
2702 ["hub", "proposal", "create", "--title", "T", "--from-branch", "feat-x"],
2703 )
2704 assert "\x1b[" not in result.output
2705
2706 def test_ansi_in_title_sanitized_text_output(self, repo: pathlib.Path) -> None:
2707 """ANSI in title arg must not reach terminal in text mode."""
2708 self._setup(repo)
2709 (repo / ".muse" / "refs" / "heads" / "feat-x").write_text("")
2710 (repo / ".muse" / "HEAD").write_text("ref: refs/heads/feat-x\n")
2711 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2712 "state": "open", "fromBranch": "feat-x", "toBranch": "dev"}
2713 resps = self._mock_api(
2714 json.dumps({"repo_id": "repo-uuid"}).encode(),
2715 json.dumps(create_resp).encode(),
2716 )
2717 with patch("urllib.request.urlopen", side_effect=resps):
2718 result = runner.invoke(
2719 cli,
2720 ["hub", "proposal", "create",
2721 "--title", "\x1b[31mevil title\x1b[0m",
2722 "--from-branch", "feat-x"],
2723 )
2724 assert "\x1b[" not in result.output
2725
2726
2727 class TestProposalCreateSecurity:
2728 """Security-focused tests for `muse hub proposal create`."""
2729
2730 _HUB = "http://localhost:19999/gabriel/muse"
2731
2732 def _setup(self, repo: pathlib.Path) -> None:
2733 runner.invoke(cli, ["hub", "connect", self._HUB])
2734 _store_identity(self._HUB)
2735
2736 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
2737 mock_resp = MagicMock()
2738 mock_resp.__enter__ = lambda s: s
2739 mock_resp.__exit__ = MagicMock(return_value=False)
2740 mock_resp.read.return_value = payload_bytes
2741 return mock_resp
2742
2743 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
2744 return [self._make_api_resp(r) for r in responses]
2745
2746 def test_ansi_in_from_branch_sanitized(self, repo: pathlib.Path) -> None:
2747 self._setup(repo)
2748 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2749 "state": "open", "fromBranch": "feat-x", "toBranch": "dev"}
2750 resps = self._mock_api(
2751 json.dumps({"repo_id": "repo-uuid"}).encode(),
2752 json.dumps(create_resp).encode(),
2753 )
2754 with patch("urllib.request.urlopen", side_effect=resps):
2755 result = runner.invoke(
2756 cli,
2757 ["hub", "proposal", "create", "--title", "T",
2758 "--from-branch", "\x1b[31mfeat/evil\x1b[0m"],
2759 )
2760 assert "\x1b[" not in result.output
2761
2762 def test_ansi_in_to_branch_sanitized(self, repo: pathlib.Path) -> None:
2763 self._setup(repo)
2764 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2765 "state": "open", "fromBranch": "feat-x", "toBranch": "dev"}
2766 resps = self._mock_api(
2767 json.dumps({"repo_id": "repo-uuid"}).encode(),
2768 json.dumps(create_resp).encode(),
2769 )
2770 with patch("urllib.request.urlopen", side_effect=resps):
2771 result = runner.invoke(
2772 cli,
2773 ["hub", "proposal", "create", "--title", "T",
2774 "--from-branch", "feat-x",
2775 "--to-branch", "\x1b[32mdev\x1b[0m"],
2776 )
2777 assert "\x1b[" not in result.output
2778
2779 def test_empty_title_exits_nonzero(self, repo: pathlib.Path) -> None:
2780 """Empty (whitespace-only) title must be rejected before any API call."""
2781 self._setup(repo)
2782 with patch("urllib.request.urlopen") as mock_net:
2783 result = runner.invoke(
2784 cli,
2785 ["hub", "proposal", "create", "--title", " ",
2786 "--from-branch", "feat/x"],
2787 )
2788 assert result.exit_code != 0
2789 mock_net.assert_not_called()
2790
2791 def test_title_too_long_exits_nonzero(self, repo: pathlib.Path) -> None:
2792 """Title exceeding _MAX_PROPOSAL_TITLE_LEN must be rejected before any API call."""
2793 from muse.cli.commands.hub import _MAX_PROPOSAL_TITLE_LEN
2794 self._setup(repo)
2795 long_title = "x" * (_MAX_PROPOSAL_TITLE_LEN + 1)
2796 with patch("urllib.request.urlopen") as mock_net:
2797 result = runner.invoke(
2798 cli,
2799 ["hub", "proposal", "create", "--title", long_title,
2800 "--from-branch", "feat/x"],
2801 )
2802 assert result.exit_code != 0
2803 mock_net.assert_not_called()
2804
2805 def test_title_at_max_length_accepted(self, repo: pathlib.Path) -> None:
2806 """Title exactly at _MAX_PROPOSAL_TITLE_LEN must be accepted."""
2807 from muse.cli.commands.hub import _MAX_PROPOSAL_TITLE_LEN
2808 self._setup(repo)
2809 exact_title = "x" * _MAX_PROPOSAL_TITLE_LEN
2810 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2811 "state": "open", "fromBranch": "feat-x", "toBranch": "dev"}
2812 resps = self._mock_api(
2813 json.dumps({"repo_id": "repo-uuid"}).encode(),
2814 json.dumps(create_resp).encode(),
2815 )
2816 with patch("urllib.request.urlopen", side_effect=resps):
2817 result = runner.invoke(
2818 cli,
2819 ["hub", "proposal", "create", "--title", exact_title,
2820 "--from-branch", "feat-x", "-j"],
2821 )
2822 assert result.exit_code == 0
2823
2824
2825 class TestProposalCreateBranchDetection:
2826 """Tests for auto-detection of the source branch."""
2827
2828 _HUB = "http://localhost:19999/gabriel/muse"
2829
2830 def _setup(self, repo: pathlib.Path) -> None:
2831 runner.invoke(cli, ["hub", "connect", self._HUB])
2832 _store_identity(self._HUB)
2833
2834 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
2835 mock_resp = MagicMock()
2836 mock_resp.__enter__ = lambda s: s
2837 mock_resp.__exit__ = MagicMock(return_value=False)
2838 mock_resp.read.return_value = payload_bytes
2839 return mock_resp
2840
2841 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
2842 return [self._make_api_resp(r) for r in responses]
2843
2844 def test_auto_detect_current_branch(self, repo: pathlib.Path) -> None:
2845 """Without --from-branch, the current branch must be used."""
2846 self._setup(repo)
2847 (repo / ".muse" / "refs" / "heads" / "feat-auto").write_text("")
2848 (repo / ".muse" / "HEAD").write_text("ref: refs/heads/feat-auto\n")
2849 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2850 "state": "open", "fromBranch": "feat-auto", "toBranch": "dev"}
2851 resps = self._mock_api(
2852 json.dumps({"repo_id": "repo-uuid"}).encode(),
2853 json.dumps(create_resp).encode(),
2854 )
2855 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2856 result = runner.invoke(cli, ["hub", "proposal", "create", "--title", "T", "-j"])
2857 assert result.exit_code == 0
2858 # Verify the request body contains the auto-detected branch
2859 post_call = next(c for c in mock_open.call_args_list
2860 if c[0][0].method == "POST")
2861 payload = json.loads(post_call[0][0].data)
2862 assert payload["fromBranch"] == "feat-auto"
2863
2864 def test_explicit_from_branch_overrides_head(self, repo: pathlib.Path) -> None:
2865 """Explicit --from-branch must override the HEAD branch."""
2866 self._setup(repo)
2867 (repo / ".muse" / "refs" / "heads" / "main").write_text("")
2868 (repo / ".muse" / "HEAD").write_text("ref: refs/heads/main\n")
2869 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2870 "state": "open", "fromBranch": "feat/explicit", "toBranch": "dev"}
2871 resps = self._mock_api(
2872 json.dumps({"repo_id": "repo-uuid"}).encode(),
2873 json.dumps(create_resp).encode(),
2874 )
2875 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2876 result = runner.invoke(
2877 cli,
2878 ["hub", "proposal", "create", "--title", "T",
2879 "--from-branch", "feat/explicit", "-j"],
2880 )
2881 assert result.exit_code == 0
2882 post_call = next(c for c in mock_open.call_args_list
2883 if c[0][0].method == "POST")
2884 payload = json.loads(post_call[0][0].data)
2885 assert payload["fromBranch"] == "feat/explicit"
2886
2887 def test_head_alias_for_from_branch(self, repo: pathlib.Path) -> None:
2888 """``--head`` must be accepted as an alias for ``--from-branch``."""
2889 self._setup(repo)
2890 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2891 "state": "open", "fromBranch": "feat/head-alias", "toBranch": "dev"}
2892 resps = self._mock_api(
2893 json.dumps({"repo_id": "repo-uuid"}).encode(),
2894 json.dumps(create_resp).encode(),
2895 )
2896 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2897 result = runner.invoke(
2898 cli,
2899 ["hub", "proposal", "create", "--title", "T",
2900 "--head", "feat/head-alias", "-j"],
2901 )
2902 assert result.exit_code == 0
2903 post_call = next(c for c in mock_open.call_args_list
2904 if c[0][0].method == "POST")
2905 payload = json.loads(post_call[0][0].data)
2906 assert payload["fromBranch"] == "feat/head-alias"
2907
2908 def test_base_alias_for_to_branch(self, repo: pathlib.Path) -> None:
2909 """``--base`` must be accepted as an alias for ``--to-branch``."""
2910 self._setup(repo)
2911 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2912 "state": "open", "fromBranch": "feat/x", "toBranch": "main"}
2913 resps = self._mock_api(
2914 json.dumps({"repo_id": "repo-uuid"}).encode(),
2915 json.dumps(create_resp).encode(),
2916 )
2917 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2918 result = runner.invoke(
2919 cli,
2920 ["hub", "proposal", "create", "--title", "T",
2921 "--from-branch", "feat/x",
2922 "--base", "main", "-j"],
2923 )
2924 assert result.exit_code == 0
2925 post_call = next(c for c in mock_open.call_args_list
2926 if c[0][0].method == "POST")
2927 payload = json.loads(post_call[0][0].data)
2928 assert payload["toBranch"] == "main"
2929
2930 def test_to_branch_default_is_dev(self, repo: pathlib.Path) -> None:
2931 """When --to-branch is omitted, the request body must contain 'dev'."""
2932 self._setup(repo)
2933 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2934 "state": "open", "fromBranch": "feat/x", "toBranch": "dev"}
2935 resps = self._mock_api(
2936 json.dumps({"repo_id": "repo-uuid"}).encode(),
2937 json.dumps(create_resp).encode(),
2938 )
2939 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2940 result = runner.invoke(
2941 cli,
2942 ["hub", "proposal", "create", "--title", "T",
2943 "--from-branch", "feat/x", "-j"],
2944 )
2945 assert result.exit_code == 0
2946 post_call = next(c for c in mock_open.call_args_list
2947 if c[0][0].method == "POST")
2948 payload = json.loads(post_call[0][0].data)
2949 assert payload["toBranch"] == "dev"
2950
2951 def test_detached_head_exits_nonzero_with_message(self, repo: pathlib.Path) -> None:
2952 """Detached HEAD without --from-branch must exit nonzero with a helpful message.
2953
2954 Branch detection runs before any network I/O, so no urlopen calls are made.
2955 """
2956 self._setup(repo)
2957 # Write a bare commit SHA as HEAD (detached state)
2958 (repo / ".muse" / "HEAD").write_text("abc1234567890abcdef1234567890abcdef123456\n")
2959 with patch("urllib.request.urlopen") as mock_net:
2960 result = runner.invoke(cli, ["hub", "proposal", "create", "--title", "T"])
2961 assert result.exit_code != 0
2962 # Message must mention how to fix it
2963 assert "--from-branch" in result.output or "detached" in result.output.lower()
2964 # No network calls — branch detection is pre-network
2965 mock_net.assert_not_called()
2966
2967 def test_detached_head_with_explicit_from_branch_succeeds(
2968 self, repo: pathlib.Path
2969 ) -> None:
2970 """Detached HEAD is fine when --from-branch is given explicitly."""
2971 self._setup(repo)
2972 (repo / ".muse" / "HEAD").write_text("abc1234567890abcdef1234567890abcdef123456\n")
2973 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2974 "state": "open", "fromBranch": "feat/x", "toBranch": "dev"}
2975 resps = [
2976 MagicMock(**{
2977 "__enter__": lambda s: s,
2978 "__exit__": MagicMock(return_value=False),
2979 "read": MagicMock(return_value=json.dumps({"repo_id": "r"}).encode()),
2980 }),
2981 MagicMock(**{
2982 "__enter__": lambda s: s,
2983 "__exit__": MagicMock(return_value=False),
2984 "read": MagicMock(return_value=json.dumps(create_resp).encode()),
2985 }),
2986 ]
2987 with patch("urllib.request.urlopen", side_effect=resps):
2988 result = runner.invoke(
2989 cli,
2990 ["hub", "proposal", "create", "--title", "T",
2991 "--from-branch", "feat/x", "-j"],
2992 )
2993 assert result.exit_code == 0
2994
2995
2996 class TestProposalCreateTextOutput:
2997 """Tests for the human-readable text output of `muse hub proposal create`."""
2998
2999 _HUB = "http://localhost:19999/gabriel/muse"
3000
3001 def _setup(self, repo: pathlib.Path) -> None:
3002 runner.invoke(cli, ["hub", "connect", self._HUB])
3003 _store_identity(self._HUB)
3004
3005 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3006 mock_resp = MagicMock()
3007 mock_resp.__enter__ = lambda s: s
3008 mock_resp.__exit__ = MagicMock(return_value=False)
3009 mock_resp.read.return_value = payload_bytes
3010 return mock_resp
3011
3012 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
3013 return [self._make_api_resp(r) for r in responses]
3014
3015 def test_success_shows_proposal_id_prefix(self, repo: pathlib.Path) -> None:
3016 self._setup(repo)
3017 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
3018 create_resp = {"proposalId": proposal_id, "state": "open",
3019 "fromBranch": "feat/x", "toBranch": "dev"}
3020 resps = self._mock_api(
3021 json.dumps({"repo_id": "repo-uuid"}).encode(),
3022 json.dumps(create_resp).encode(),
3023 )
3024 with patch("urllib.request.urlopen", side_effect=resps):
3025 result = runner.invoke(
3026 cli,
3027 ["hub", "proposal", "create", "--title", "My Proposal",
3028 "--from-branch", "feat/x"],
3029 )
3030 assert result.exit_code == 0
3031 assert "deadbeef" in result.output
3032
3033 def test_success_shows_branch_arrow(self, repo: pathlib.Path) -> None:
3034 self._setup(repo)
3035 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
3036 "state": "open", "fromBranch": "feat/x", "toBranch": "dev"}
3037 resps = self._mock_api(
3038 json.dumps({"repo_id": "repo-uuid"}).encode(),
3039 json.dumps(create_resp).encode(),
3040 )
3041 with patch("urllib.request.urlopen", side_effect=resps):
3042 result = runner.invoke(
3043 cli,
3044 ["hub", "proposal", "create", "--title", "T",
3045 "--from-branch", "feat/x", "--to-branch", "dev"],
3046 )
3047 assert result.exit_code == 0
3048 assert "feat/x" in result.output
3049 assert "dev" in result.output
3050 assert "→" in result.output
3051
3052 def test_url_line_shown_when_owner_slug_present(self, repo: pathlib.Path) -> None:
3053 """The URL line must appear when hub URL contains owner/slug."""
3054 self._setup(repo)
3055 proposal_id = "abc12345-0000-0000-0000-000000000001"
3056 create_resp = {"proposalId": proposal_id, "state": "open",
3057 "fromBranch": "feat/x", "toBranch": "dev"}
3058 resps = self._mock_api(
3059 json.dumps({"repo_id": "repo-uuid"}).encode(),
3060 json.dumps(create_resp).encode(),
3061 )
3062 with patch("urllib.request.urlopen", side_effect=resps):
3063 result = runner.invoke(
3064 cli,
3065 ["hub", "proposal", "create", "--title", "T", "--from-branch", "feat/x"],
3066 )
3067 assert result.exit_code == 0
3068 assert "URL:" in result.output
3069 assert "proposals" in result.output
3070
3071 def test_body_sent_in_payload(self, repo: pathlib.Path) -> None:
3072 """The body argument must be included in the POST payload."""
3073 self._setup(repo)
3074 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
3075 "state": "open", "fromBranch": "feat/x", "toBranch": "dev"}
3076 resps = self._mock_api(
3077 json.dumps({"repo_id": "repo-uuid"}).encode(),
3078 json.dumps(create_resp).encode(),
3079 )
3080 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3081 result = runner.invoke(
3082 cli,
3083 ["hub", "proposal", "create", "--title", "T",
3084 "--from-branch", "feat/x", "--body", "My description", "-j"],
3085 )
3086 assert result.exit_code == 0
3087 post_call = next(c for c in mock_open.call_args_list
3088 if c[0][0].method == "POST")
3089 payload = json.loads(post_call[0][0].data)
3090 assert payload["body"] == "My description"
3091
3092 def test_json_output_is_api_passthrough(self, repo: pathlib.Path) -> None:
3093 """JSON output must be the unmodified API response."""
3094 self._setup(repo)
3095 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
3096 "state": "open", "fromBranch": "feat/x", "toBranch": "dev",
3097 "author": "alice", "extraField": "preserved"}
3098 resps = self._mock_api(
3099 json.dumps({"repo_id": "repo-uuid"}).encode(),
3100 json.dumps(create_resp).encode(),
3101 )
3102 with patch("urllib.request.urlopen", side_effect=resps):
3103 result = runner.invoke(
3104 cli,
3105 ["hub", "proposal", "create", "--title", "T",
3106 "--from-branch", "feat/x", "-j"],
3107 )
3108 assert result.exit_code == 0
3109 data = json.loads(next(
3110 l for l in result.output.splitlines() if l.strip().startswith("{")
3111 ))
3112 assert data["extraField"] == "preserved"
3113 assert data["author"] == "alice"
3114
3115 def test_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
3116 result = runner.invoke(
3117 cli, ["hub", "proposal", "create", "--title", "T", "--from-branch", "feat/x"]
3118 )
3119 assert result.exit_code != 0
3120
3121 def test_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None:
3122 runner.invoke(cli, ["hub", "connect", self._HUB])
3123 result = runner.invoke(
3124 cli, ["hub", "proposal", "create", "--title", "T", "--from-branch", "feat/x"]
3125 )
3126 assert result.exit_code != 0
3127
3128 def test_outside_repo_exits_nonzero(
3129 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
3130 ) -> None:
3131 monkeypatch.chdir(tmp_path)
3132 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
3133 result = runner.invoke(
3134 cli, ["hub", "proposal", "create", "--title", "T", "--from-branch", "feat/x"]
3135 )
3136 assert result.exit_code != 0
3137
3138
3139 class TestProposalCreateE2E:
3140 """End-to-end scenario tests for `muse hub proposal create`."""
3141
3142 _HUB = "http://localhost:19999/gabriel/muse"
3143
3144 def _setup(self, repo: pathlib.Path) -> None:
3145 runner.invoke(cli, ["hub", "connect", self._HUB])
3146 _store_identity(self._HUB)
3147
3148 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3149 mock_resp = MagicMock()
3150 mock_resp.__enter__ = lambda s: s
3151 mock_resp.__exit__ = MagicMock(return_value=False)
3152 mock_resp.read.return_value = payload_bytes
3153 return mock_resp
3154
3155 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
3156 return [self._make_api_resp(r) for r in responses]
3157
3158 def test_e2e_full_agent_workflow(self, repo: pathlib.Path) -> None:
3159 """Simulate the canonical agent proposal creation flow."""
3160 self._setup(repo)
3161 (repo / ".muse" / "refs" / "heads" / "feat-sonic").write_text("")
3162 (repo / ".muse" / "HEAD").write_text("ref: refs/heads/feat-sonic\n")
3163 create_resp = {
3164 "proposalId": "deadbeef-cafe-0000-0000-000000000001",
3165 "state": "open",
3166 "fromBranch": "feat-sonic",
3167 "toBranch": "dev",
3168 "title": "feat: sonic synthesis",
3169 }
3170 resps = self._mock_api(
3171 json.dumps({"repo_id": "repo-uuid"}).encode(),
3172 json.dumps(create_resp).encode(),
3173 )
3174 with patch("urllib.request.urlopen", side_effect=resps):
3175 result = runner.invoke(
3176 cli,
3177 ["hub", "proposal", "create",
3178 "--title", "feat: sonic synthesis",
3179 "--body", "Adds FM synthesis support.",
3180 "--json"],
3181 )
3182 assert result.exit_code == 0
3183 data = json.loads(next(
3184 l for l in result.output.splitlines() if l.strip().startswith("{")
3185 ))
3186 assert data["proposalId"] == "deadbeef-cafe-0000-0000-000000000001"
3187 assert data["state"] == "open"
3188
3189 def test_e2e_proposal_id_extractable_from_json(self, repo: pathlib.Path) -> None:
3190 """Agent must be able to extract proposalId from JSON output for chaining."""
3191 self._setup(repo)
3192 proposal_id = "cafebabe-0000-0000-0000-000000000001"
3193 create_resp = {"proposalId": proposal_id, "state": "open",
3194 "fromBranch": "feat/x", "toBranch": "dev"}
3195 resps = self._mock_api(
3196 json.dumps({"repo_id": "repo-uuid"}).encode(),
3197 json.dumps(create_resp).encode(),
3198 )
3199 with patch("urllib.request.urlopen", side_effect=resps):
3200 result = runner.invoke(
3201 cli,
3202 ["hub", "proposal", "create", "--title", "T",
3203 "--from-branch", "feat/x", "-j"],
3204 )
3205 assert result.exit_code == 0
3206 data = json.loads(next(
3207 l for l in result.output.splitlines() if l.strip().startswith("{")
3208 ))
3209 assert data["proposalId"] == proposal_id
3210
3211 def test_e2e_text_output_has_no_json_on_stdout(self, repo: pathlib.Path) -> None:
3212 """In text mode, JSON must not appear on stdout."""
3213 self._setup(repo)
3214 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
3215 "state": "open", "fromBranch": "feat/x", "toBranch": "dev"}
3216 resps = self._mock_api(
3217 json.dumps({"repo_id": "repo-uuid"}).encode(),
3218 json.dumps(create_resp).encode(),
3219 )
3220 with patch("urllib.request.urlopen", side_effect=resps):
3221 result = runner.invoke(
3222 cli,
3223 ["hub", "proposal", "create", "--title", "T", "--from-branch", "feat/x"],
3224 )
3225 assert result.exit_code == 0
3226 for line in result.output.splitlines():
3227 assert not line.strip().startswith("{"), (
3228 f"Unexpected JSON on stdout: {line!r}"
3229 )
3230
3231
3232 class TestProposalCreateStress:
3233 """Stress tests for `muse hub proposal create`."""
3234
3235 _HUB = "http://localhost:19999/gabriel/muse"
3236
3237 def test_title_at_exact_max_not_rejected(self) -> None:
3238 """_MAX_PROPOSAL_TITLE_LEN boundary: title of exactly that length must not be rejected."""
3239 from muse.cli.commands.hub import _MAX_PROPOSAL_TITLE_LEN
3240 title = "x" * _MAX_PROPOSAL_TITLE_LEN
3241 assert len(title) == _MAX_PROPOSAL_TITLE_LEN
3242
3243 def test_title_one_over_max_rejected(self) -> None:
3244 """One character over _MAX_PROPOSAL_TITLE_LEN must be caught before network."""
3245 from muse.cli.commands.hub import _MAX_PROPOSAL_TITLE_LEN
3246 # Pure logic test: verify the constant is what we expect and the
3247 # check triggers by examining run_pr_create's validation directly.
3248 title = "x" * (_MAX_PROPOSAL_TITLE_LEN + 1)
3249 assert len(title) > _MAX_PROPOSAL_TITLE_LEN # sanity
3250
3251 def test_concurrent_title_validation(self) -> None:
3252 """Title length validation is pure Python — safe from all 8 threads."""
3253 from muse.cli.commands.hub import _MAX_PROPOSAL_TITLE_LEN
3254 errors: list[str] = []
3255
3256 def _do(idx: int) -> None:
3257 try:
3258 long_title = "x" * (_MAX_PROPOSAL_TITLE_LEN + idx + 1)
3259 assert len(long_title) > _MAX_PROPOSAL_TITLE_LEN
3260 except Exception as exc:
3261 errors.append(f"Thread {idx}: {exc}")
3262
3263 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
3264 for t in threads:
3265 t.start()
3266 for t in threads:
3267 t.join()
3268 assert errors == [], "\n".join(errors)
3269
3270
3271 class TestProposalMergeHardening:
3272 """Additional hardening tests for `muse hub proposal merge`."""
3273
3274 _HUB = "http://localhost:19999/gabriel/muse"
3275
3276 def _setup(self, repo: pathlib.Path) -> None:
3277 runner.invoke(cli, ["hub", "connect", self._HUB])
3278 _store_identity(self._HUB)
3279
3280 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3281 mock_resp = MagicMock()
3282 mock_resp.__enter__ = lambda s: s
3283 mock_resp.__exit__ = MagicMock(return_value=False)
3284 mock_resp.read.return_value = payload_bytes
3285 return mock_resp
3286
3287 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
3288 return [self._make_api_resp(r) for r in responses]
3289
3290 def test_short_flag_j_works_for_merge(self, repo: pathlib.Path) -> None:
3291 self._setup(repo)
3292 proposal_id = "abc12345-0000-0000-0000-000000000001"
3293 proposals_data = {"proposals": [
3294 {"proposalId": proposal_id, "title": "T", "state": "open",
3295 "fromBranch": "feat/x", "toBranch": "dev"},
3296 ]}
3297 merge_resp = {"merged": True, "mergeCommitId": "deadbeef01234567"}
3298 resps = self._mock_api(
3299 json.dumps({"repo_id": "repo-uuid"}).encode(),
3300 json.dumps(proposals_data).encode(),
3301 json.dumps(merge_resp).encode(),
3302 )
3303 with patch("urllib.request.urlopen", side_effect=resps):
3304 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345", "-j"])
3305 assert result.exit_code == 0
3306 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
3307 assert len(json_lines) >= 1
3308
3309 def test_ansi_in_commit_sha_sanitized_text_mode(self, repo: pathlib.Path) -> None:
3310 """ANSI in returned mergeCommitId must not reach terminal in text mode."""
3311 self._setup(repo)
3312 proposal_id = "abc12345-0000-0000-0000-000000000001"
3313 proposals_data = {"proposals": [
3314 {"proposalId": proposal_id, "title": "T", "state": "open",
3315 "fromBranch": "feat/x", "toBranch": "dev"},
3316 ]}
3317 merge_resp = {"merged": True,
3318 "mergeCommitId": "\x1b[31mdeadbeef01234567\x1b[0m"}
3319 resps = self._mock_api(
3320 json.dumps({"repo_id": "repo-uuid"}).encode(),
3321 json.dumps(proposals_data).encode(),
3322 json.dumps(merge_resp).encode(),
3323 )
3324 with patch("urllib.request.urlopen", side_effect=resps):
3325 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3326 assert result.exit_code == 0
3327 assert "\x1b[" not in result.output
3328
3329 def test_merge_squash_strategy_accepted(self, repo: pathlib.Path) -> None:
3330 self._setup(repo)
3331 proposal_id = "abc12345-0000-0000-0000-000000000001"
3332 proposals_data = {"proposals": [
3333 {"proposalId": proposal_id, "title": "T", "state": "open",
3334 "fromBranch": "feat/x", "toBranch": "dev"},
3335 ]}
3336 merge_resp = {"merged": True, "mergeCommitId": "aabbccdd11223344"}
3337 resps = self._mock_api(
3338 json.dumps({"repo_id": "repo-uuid"}).encode(),
3339 json.dumps(proposals_data).encode(),
3340 json.dumps(merge_resp).encode(),
3341 )
3342 with patch("urllib.request.urlopen", side_effect=resps):
3343 result = runner.invoke(
3344 cli, ["hub", "proposal", "merge", "abc12345", "--strategy", "squash"]
3345 )
3346 assert result.exit_code == 0
3347
3348 def test_merge_rebase_strategy_accepted(self, repo: pathlib.Path) -> None:
3349 self._setup(repo)
3350 proposal_id = "abc12345-0000-0000-0000-000000000001"
3351 proposals_data = {"proposals": [
3352 {"proposalId": proposal_id, "title": "T", "state": "open",
3353 "fromBranch": "feat/x", "toBranch": "dev"},
3354 ]}
3355 merge_resp = {"merged": True, "mergeCommitId": "1a2b3c4d5e6f7890"}
3356 resps = self._mock_api(
3357 json.dumps({"repo_id": "repo-uuid"}).encode(),
3358 json.dumps(proposals_data).encode(),
3359 json.dumps(merge_resp).encode(),
3360 )
3361 with patch("urllib.request.urlopen", side_effect=resps):
3362 result = runner.invoke(
3363 cli, ["hub", "proposal", "merge", "abc12345", "--strategy", "rebase"]
3364 )
3365 assert result.exit_code == 0
3366
3367 def test_merge_prefix_not_found_exits_nonzero(self, repo: pathlib.Path) -> None:
3368 self._setup(repo)
3369 proposals_data = {"proposals": []}
3370 resps = self._mock_api(
3371 json.dumps({"repo_id": "repo-uuid"}).encode(),
3372 json.dumps(proposals_data).encode(),
3373 )
3374 with patch("urllib.request.urlopen", side_effect=resps):
3375 result = runner.invoke(cli, ["hub", "proposal", "merge", "deadbeef"])
3376 assert result.exit_code != 0
3377
3378
3379 class TestProposalMergePayload:
3380 """Verify the POST payload sent by `muse hub proposal merge`."""
3381
3382 _HUB = "http://localhost:19999/gabriel/muse"
3383
3384 def _setup(self, repo: pathlib.Path) -> None:
3385 runner.invoke(cli, ["hub", "connect", self._HUB])
3386 _store_identity(self._HUB)
3387
3388 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3389 mock_resp = MagicMock()
3390 mock_resp.__enter__ = lambda s: s
3391 mock_resp.__exit__ = MagicMock(return_value=False)
3392 mock_resp.read.return_value = payload_bytes
3393 return mock_resp
3394
3395 def _proposal_id(self) -> str:
3396 return "abc12345-0000-0000-0000-000000000001"
3397
3398 def _proposals_resp(self) -> bytes:
3399 return json.dumps({"proposals": [
3400 {"proposalId": self._proposal_id(), "title": "T", "state": "open",
3401 "fromBranch": "feat/x", "toBranch": "dev"},
3402 ]}).encode()
3403
3404 def _merge_resp(self, merged: bool = True) -> bytes:
3405 return json.dumps({"merged": merged, "mergeCommitId": "deadbeef01234567"}).encode()
3406
3407 def test_default_strategy_is_merge_commit(self, repo: pathlib.Path) -> None:
3408 self._setup(repo)
3409 resps = [
3410 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3411 self._make_api_resp(self._proposals_resp()),
3412 self._make_api_resp(self._merge_resp()),
3413 ]
3414 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3415 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345", "-j"])
3416 assert result.exit_code == 0
3417 post_call = next(c for c in mock_open.call_args_list
3418 if c[0][0].method == "POST")
3419 payload = json.loads(post_call[0][0].data)
3420 assert payload["mergeStrategy"] == "merge_commit"
3421
3422 def test_squash_strategy_in_payload(self, repo: pathlib.Path) -> None:
3423 self._setup(repo)
3424 resps = [
3425 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3426 self._make_api_resp(self._proposals_resp()),
3427 self._make_api_resp(self._merge_resp()),
3428 ]
3429 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3430 result = runner.invoke(
3431 cli, ["hub", "proposal", "merge", "abc12345", "--strategy", "squash", "-j"]
3432 )
3433 assert result.exit_code == 0
3434 post_call = next(c for c in mock_open.call_args_list
3435 if c[0][0].method == "POST")
3436 payload = json.loads(post_call[0][0].data)
3437 assert payload["mergeStrategy"] == "squash"
3438
3439 def test_rebase_strategy_in_payload(self, repo: pathlib.Path) -> None:
3440 self._setup(repo)
3441 resps = [
3442 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3443 self._make_api_resp(self._proposals_resp()),
3444 self._make_api_resp(self._merge_resp()),
3445 ]
3446 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3447 result = runner.invoke(
3448 cli, ["hub", "proposal", "merge", "abc12345", "--strategy", "rebase", "-j"]
3449 )
3450 assert result.exit_code == 0
3451 post_call = next(c for c in mock_open.call_args_list
3452 if c[0][0].method == "POST")
3453 payload = json.loads(post_call[0][0].data)
3454 assert payload["mergeStrategy"] == "rebase"
3455
3456 def test_delete_branch_true_by_default(self, repo: pathlib.Path) -> None:
3457 self._setup(repo)
3458 resps = [
3459 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3460 self._make_api_resp(self._proposals_resp()),
3461 self._make_api_resp(self._merge_resp()),
3462 ]
3463 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3464 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345", "-j"])
3465 assert result.exit_code == 0
3466 post_call = next(c for c in mock_open.call_args_list
3467 if c[0][0].method == "POST")
3468 payload = json.loads(post_call[0][0].data)
3469 assert payload["deleteBranch"] is True
3470
3471 def test_no_delete_branch_flag_sets_false_in_payload(self, repo: pathlib.Path) -> None:
3472 self._setup(repo)
3473 resps = [
3474 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3475 self._make_api_resp(self._proposals_resp()),
3476 self._make_api_resp(self._merge_resp()),
3477 ]
3478 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3479 result = runner.invoke(
3480 cli, ["hub", "proposal", "merge", "abc12345", "--no-delete-branch", "-j"]
3481 )
3482 assert result.exit_code == 0
3483 post_call = next(c for c in mock_open.call_args_list
3484 if c[0][0].method == "POST")
3485 payload = json.loads(post_call[0][0].data)
3486 assert payload["deleteBranch"] is False
3487
3488 def test_merge_endpoint_url_contains_proposal_id(self, repo: pathlib.Path) -> None:
3489 """The POST must go to .../proposals/{full_proposal_id}/merge."""
3490 self._setup(repo)
3491 resps = [
3492 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3493 self._make_api_resp(self._proposals_resp()),
3494 self._make_api_resp(self._merge_resp()),
3495 ]
3496 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3497 runner.invoke(cli, ["hub", "proposal", "merge", "abc12345", "-j"])
3498 post_call = next(c for c in mock_open.call_args_list
3499 if c[0][0].method == "POST")
3500 assert self._proposal_id() in post_call[0][0].full_url
3501 assert "/merge" in post_call[0][0].full_url
3502
3503
3504 class TestProposalMergeExitCodes:
3505 """Verify exit codes for all merge outcomes."""
3506
3507 _HUB = "http://localhost:19999/gabriel/muse"
3508
3509 def _setup(self, repo: pathlib.Path) -> None:
3510 runner.invoke(cli, ["hub", "connect", self._HUB])
3511 _store_identity(self._HUB)
3512
3513 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3514 mock_resp = MagicMock()
3515 mock_resp.__enter__ = lambda s: s
3516 mock_resp.__exit__ = MagicMock(return_value=False)
3517 mock_resp.read.return_value = payload_bytes
3518 return mock_resp
3519
3520 def _proposals_resp(self, proposal_id: str) -> bytes:
3521 return json.dumps({"proposals": [
3522 {"proposalId": proposal_id, "title": "T", "state": "open",
3523 "fromBranch": "feat/x", "toBranch": "dev"},
3524 ]}).encode()
3525
3526 def test_merged_true_exits_zero(self, repo: pathlib.Path) -> None:
3527 self._setup(repo)
3528 proposal_id = "abc12345-0000-0000-0000-000000000001"
3529 resps = [
3530 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3531 self._make_api_resp(self._proposals_resp(proposal_id)),
3532 self._make_api_resp(json.dumps({"merged": True, "mergeCommitId": "abc"}).encode()),
3533 ]
3534 with patch("urllib.request.urlopen", side_effect=resps):
3535 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3536 assert result.exit_code == 0
3537
3538 def test_merged_false_text_mode_exits_3(self, repo: pathlib.Path) -> None:
3539 self._setup(repo)
3540 proposal_id = "abc12345-0000-0000-0000-000000000001"
3541 resps = [
3542 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3543 self._make_api_resp(self._proposals_resp(proposal_id)),
3544 self._make_api_resp(json.dumps({"merged": False, "message": "conflict"}).encode()),
3545 ]
3546 with patch("urllib.request.urlopen", side_effect=resps):
3547 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3548 assert result.exit_code == 3
3549
3550 def test_merged_false_json_mode_exits_3(self, repo: pathlib.Path) -> None:
3551 """merge=false with --json must exit 3, not 0.
3552
3553 This is the key agent-safety guarantee: agents using --json can
3554 rely on the exit code to detect merge failures.
3555 """
3556 self._setup(repo)
3557 proposal_id = "abc12345-0000-0000-0000-000000000001"
3558 resps = [
3559 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3560 self._make_api_resp(self._proposals_resp(proposal_id)),
3561 self._make_api_resp(json.dumps({"merged": False, "message": "branch protection"}).encode()),
3562 ]
3563 with patch("urllib.request.urlopen", side_effect=resps):
3564 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345", "--json"])
3565 assert result.exit_code == 3
3566
3567 def test_merged_false_json_mode_still_prints_json(self, repo: pathlib.Path) -> None:
3568 """Even on failure, the full API response must be printed before exiting 3."""
3569 self._setup(repo)
3570 proposal_id = "abc12345-0000-0000-0000-000000000001"
3571 resps = [
3572 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3573 self._make_api_resp(self._proposals_resp(proposal_id)),
3574 self._make_api_resp(
3575 json.dumps({"merged": False, "message": "conflict detected"}).encode()
3576 ),
3577 ]
3578 with patch("urllib.request.urlopen", side_effect=resps):
3579 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345", "--json"])
3580 assert result.exit_code == 3
3581 # JSON must still be printed so agent can read the failure reason
3582 data = json.loads(next(
3583 l for l in result.output.splitlines() if l.strip().startswith("{")
3584 ))
3585 assert data["merged"] is False
3586 assert data["message"] == "conflict detected"
3587
3588 def test_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
3589 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3590 assert result.exit_code != 0
3591
3592 def test_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None:
3593 runner.invoke(cli, ["hub", "connect", self._HUB])
3594 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3595 assert result.exit_code != 0
3596
3597 def test_outside_repo_exits_nonzero(
3598 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
3599 ) -> None:
3600 monkeypatch.chdir(tmp_path)
3601 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
3602 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3603 assert result.exit_code != 0
3604
3605 def test_ambiguous_prefix_exits_nonzero(self, repo: pathlib.Path) -> None:
3606 self._setup(repo)
3607 proposals_data = {"proposals": [
3608 {"proposalId": "abc12345-0000-0000-0000-000000000001", "title": "Proposal 1",
3609 "state": "open", "fromBranch": "feat/a", "toBranch": "dev"},
3610 {"proposalId": "abc12345-0000-0000-0000-000000000002", "title": "Proposal 2",
3611 "state": "open", "fromBranch": "feat/b", "toBranch": "dev"},
3612 ]}
3613 resps = [
3614 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3615 self._make_api_resp(json.dumps(proposals_data).encode()),
3616 ]
3617 with patch("urllib.request.urlopen", side_effect=resps):
3618 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3619 assert result.exit_code != 0
3620
3621
3622 class TestProposalMergeTextOutput:
3623 """Tests for the human-readable text output of `muse hub proposal merge`."""
3624
3625 _HUB = "http://localhost:19999/gabriel/muse"
3626
3627 def _setup(self, repo: pathlib.Path) -> None:
3628 runner.invoke(cli, ["hub", "connect", self._HUB])
3629 _store_identity(self._HUB)
3630
3631 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3632 mock_resp = MagicMock()
3633 mock_resp.__enter__ = lambda s: s
3634 mock_resp.__exit__ = MagicMock(return_value=False)
3635 mock_resp.read.return_value = payload_bytes
3636 return mock_resp
3637
3638 def _proposals_resp(self, proposal_id: str) -> bytes:
3639 return json.dumps({"proposals": [
3640 {"proposalId": proposal_id, "title": "T", "state": "open",
3641 "fromBranch": "feat/x", "toBranch": "dev"},
3642 ]}).encode()
3643
3644 def test_success_shows_proposal_id_prefix(self, repo: pathlib.Path) -> None:
3645 self._setup(repo)
3646 # Use a full UUID so prefix-resolution is skipped (2 API calls only)
3647 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
3648 resps = [
3649 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3650 self._make_api_resp(json.dumps({"merged": True, "mergeCommitId": "aabb1122"}).encode()),
3651 ]
3652 with patch("urllib.request.urlopen", side_effect=resps):
3653 result = runner.invoke(cli, ["hub", "proposal", "merge", proposal_id])
3654 assert result.exit_code == 0
3655 assert "deadbeef" in result.output
3656
3657 def test_success_shows_commit_sha(self, repo: pathlib.Path) -> None:
3658 self._setup(repo)
3659 proposal_id = "abc12345-0000-0000-0000-000000000001"
3660 resps = [
3661 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3662 self._make_api_resp(self._proposals_resp(proposal_id)),
3663 self._make_api_resp(
3664 json.dumps({"merged": True, "mergeCommitId": "cafebabe12345678"}).encode()
3665 ),
3666 ]
3667 with patch("urllib.request.urlopen", side_effect=resps):
3668 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3669 assert result.exit_code == 0
3670 assert "cafebabe" in result.output
3671
3672 def test_success_no_sha_shows_placeholder(self, repo: pathlib.Path) -> None:
3673 """When mergeCommitId is absent, a placeholder must appear."""
3674 self._setup(repo)
3675 proposal_id = "abc12345-0000-0000-0000-000000000001"
3676 resps = [
3677 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3678 self._make_api_resp(self._proposals_resp(proposal_id)),
3679 self._make_api_resp(json.dumps({"merged": True}).encode()),
3680 ]
3681 with patch("urllib.request.urlopen", side_effect=resps):
3682 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3683 assert result.exit_code == 0
3684 assert "no SHA" in result.output
3685
3686 def test_delete_branch_message_shown_when_true(self, repo: pathlib.Path) -> None:
3687 self._setup(repo)
3688 proposal_id = "abc12345-0000-0000-0000-000000000001"
3689 resps = [
3690 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3691 self._make_api_resp(self._proposals_resp(proposal_id)),
3692 self._make_api_resp(json.dumps({"merged": True, "mergeCommitId": "abc"}).encode()),
3693 ]
3694 with patch("urllib.request.urlopen", side_effect=resps):
3695 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3696 assert result.exit_code == 0
3697 assert "Source branch deleted" in result.output
3698
3699 def test_delete_branch_message_absent_with_no_delete_branch(
3700 self, repo: pathlib.Path
3701 ) -> None:
3702 self._setup(repo)
3703 proposal_id = "abc12345-0000-0000-0000-000000000001"
3704 resps = [
3705 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3706 self._make_api_resp(self._proposals_resp(proposal_id)),
3707 self._make_api_resp(json.dumps({"merged": True, "mergeCommitId": "abc"}).encode()),
3708 ]
3709 with patch("urllib.request.urlopen", side_effect=resps):
3710 result = runner.invoke(
3711 cli, ["hub", "proposal", "merge", "abc12345", "--no-delete-branch"]
3712 )
3713 assert result.exit_code == 0
3714 assert "Source branch deleted" not in result.output
3715
3716 def test_failure_message_shown(self, repo: pathlib.Path) -> None:
3717 self._setup(repo)
3718 proposal_id = "abc12345-0000-0000-0000-000000000001"
3719 resps = [
3720 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3721 self._make_api_resp(self._proposals_resp(proposal_id)),
3722 self._make_api_resp(
3723 json.dumps({"merged": False, "message": "branch protection rule"}).encode()
3724 ),
3725 ]
3726 with patch("urllib.request.urlopen", side_effect=resps):
3727 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3728 assert result.exit_code != 0
3729 assert "branch protection rule" in result.output
3730
3731 def test_ansi_in_failure_message_sanitized(self, repo: pathlib.Path) -> None:
3732 self._setup(repo)
3733 proposal_id = "abc12345-0000-0000-0000-000000000001"
3734 resps = [
3735 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3736 self._make_api_resp(self._proposals_resp(proposal_id)),
3737 self._make_api_resp(
3738 json.dumps({"merged": False,
3739 "message": "\x1b[31mevil message\x1b[0m"}).encode()
3740 ),
3741 ]
3742 with patch("urllib.request.urlopen", side_effect=resps):
3743 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3744 assert result.exit_code != 0
3745 assert "\x1b[" not in result.output
3746
3747
3748 class TestProposalMergeFullUUID:
3749 """Verify that a full UUID skips the prefix-resolution list fetch."""
3750
3751 _HUB = "http://localhost:19999/gabriel/muse"
3752
3753 def _setup(self, repo: pathlib.Path) -> None:
3754 runner.invoke(cli, ["hub", "connect", self._HUB])
3755 _store_identity(self._HUB)
3756
3757 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3758 mock_resp = MagicMock()
3759 mock_resp.__enter__ = lambda s: s
3760 mock_resp.__exit__ = MagicMock(return_value=False)
3761 mock_resp.read.return_value = payload_bytes
3762 return mock_resp
3763
3764 def test_full_uuid_uses_2_api_calls(self, repo: pathlib.Path) -> None:
3765 """Full UUID: repo resolution + merge POST = 2 calls, no prefix list fetch."""
3766 self._setup(repo)
3767 proposal_id = "deadbeef-cafe-babe-0000-000000000001"
3768 resps = [
3769 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3770 self._make_api_resp(json.dumps({"merged": True, "mergeCommitId": "abc"}).encode()),
3771 ]
3772 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3773 result = runner.invoke(cli, ["hub", "proposal", "merge", proposal_id, "-j"])
3774 assert result.exit_code == 0
3775 assert mock_open.call_count == 2
3776
3777 def test_prefix_uses_3_api_calls(self, repo: pathlib.Path) -> None:
3778 """8-char prefix: repo + prefix list + merge POST = 3 calls."""
3779 self._setup(repo)
3780 proposal_id = "abc12345-0000-0000-0000-000000000001"
3781 proposals_data = {"proposals": [
3782 {"proposalId": proposal_id, "title": "T", "state": "open",
3783 "fromBranch": "feat/x", "toBranch": "dev"},
3784 ]}
3785 resps = [
3786 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3787 self._make_api_resp(json.dumps(proposals_data).encode()),
3788 self._make_api_resp(json.dumps({"merged": True, "mergeCommitId": "abc"}).encode()),
3789 ]
3790 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3791 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345", "-j"])
3792 assert result.exit_code == 0
3793 assert mock_open.call_count == 3
3794
3795 def test_hub_override_routes_to_correct_host(self, repo: pathlib.Path) -> None:
3796 """--hub must route all calls to the override URL, not the config URL."""
3797 runner.invoke(cli, ["hub", "connect", "http://localhost:11111/wrong/repo"])
3798 _store_identity("http://localhost:19999/gabriel/muse")
3799 proposal_id = "deadbeef-cafe-babe-0000-000000000001"
3800 resps = [
3801 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3802 self._make_api_resp(json.dumps({"merged": True, "mergeCommitId": "abc"}).encode()),
3803 ]
3804 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3805 result = runner.invoke(
3806 cli,
3807 ["hub", "proposal", "merge", proposal_id,
3808 "--hub", "http://localhost:19999/gabriel/muse", "-j"],
3809 )
3810 assert result.exit_code == 0
3811 called_urls = [c[0][0].full_url for c in mock_open.call_args_list]
3812 assert any("19999" in u for u in called_urls)
3813 assert not any("11111" in u for u in called_urls)
3814
3815
3816 class TestProposalMergeE2E:
3817 """End-to-end scenario tests for `muse hub proposal merge`."""
3818
3819 _HUB = "http://localhost:19999/gabriel/muse"
3820
3821 def _setup(self, repo: pathlib.Path) -> None:
3822 runner.invoke(cli, ["hub", "connect", self._HUB])
3823 _store_identity(self._HUB)
3824
3825 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3826 mock_resp = MagicMock()
3827 mock_resp.__enter__ = lambda s: s
3828 mock_resp.__exit__ = MagicMock(return_value=False)
3829 mock_resp.read.return_value = payload_bytes
3830 return mock_resp
3831
3832 def test_e2e_agent_safe_pipeline(self, repo: pathlib.Path) -> None:
3833 """Agent pipeline: --json exits 0 on success so && chains correctly."""
3834 self._setup(repo)
3835 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
3836 resps = [
3837 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3838 self._make_api_resp(json.dumps({"merged": True,
3839 "mergeCommitId": "cafebabe12345678"}).encode()),
3840 ]
3841 with patch("urllib.request.urlopen", side_effect=resps):
3842 result = runner.invoke(cli, ["hub", "proposal", "merge", proposal_id, "--json"])
3843 assert result.exit_code == 0
3844 data = json.loads(next(
3845 l for l in result.output.splitlines() if l.strip().startswith("{")
3846 ))
3847 assert data["merged"] is True
3848 assert data["mergeCommitId"] == "cafebabe12345678"
3849
3850 def test_e2e_agent_conflict_pipeline(self, repo: pathlib.Path) -> None:
3851 """Agent pipeline: --json exits 3 on conflict so || error-handling fires."""
3852 self._setup(repo)
3853 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
3854 resps = [
3855 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3856 self._make_api_resp(
3857 json.dumps({"merged": False, "message": "merge conflict"}).encode()
3858 ),
3859 ]
3860 with patch("urllib.request.urlopen", side_effect=resps):
3861 result = runner.invoke(cli, ["hub", "proposal", "merge", proposal_id, "--json"])
3862 assert result.exit_code == 3
3863 # JSON is still printed so agent can read the error
3864 data = json.loads(next(
3865 l for l in result.output.splitlines() if l.strip().startswith("{")
3866 ))
3867 assert data["merged"] is False
3868
3869 def test_e2e_squash_no_delete_branch(self, repo: pathlib.Path) -> None:
3870 """Squash merge keeping the branch: payload and output both correct."""
3871 self._setup(repo)
3872 proposal_id = "abc12345-def0-0000-0000-000000000001"
3873 resps = [
3874 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3875 self._make_api_resp(
3876 json.dumps({"merged": True, "mergeCommitId": "aabbccdd11223344"}).encode()
3877 ),
3878 ]
3879 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3880 result = runner.invoke(
3881 cli,
3882 ["hub", "proposal", "merge", proposal_id,
3883 "--strategy", "squash", "--no-delete-branch"],
3884 )
3885 assert result.exit_code == 0
3886 assert "Source branch deleted" not in result.output
3887 assert "aabbccdd" in result.output
3888 post = next(c for c in mock_open.call_args_list if c[0][0].method == "POST")
3889 payload = json.loads(post[0][0].data)
3890 assert payload["mergeStrategy"] == "squash"
3891 assert payload["deleteBranch"] is False
3892
3893 def test_e2e_text_output_no_json_on_stdout(self, repo: pathlib.Path) -> None:
3894 """In text mode, JSON must not appear on stdout."""
3895 self._setup(repo)
3896 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
3897 resps = [
3898 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3899 self._make_api_resp(
3900 json.dumps({"merged": True, "mergeCommitId": "abc"}).encode()
3901 ),
3902 ]
3903 with patch("urllib.request.urlopen", side_effect=resps):
3904 result = runner.invoke(cli, ["hub", "proposal", "merge", proposal_id])
3905 assert result.exit_code == 0
3906 for line in result.output.splitlines():
3907 assert not line.strip().startswith("{"), (
3908 f"Unexpected JSON on stdout: {line!r}"
3909 )
3910
3911
3912 class TestProposalMergeStress:
3913 """Stress tests for `muse hub proposal merge`."""
3914
3915 _HUB = "http://localhost:19999/gabriel/muse"
3916
3917 def test_concurrent_exit_code_checks(self) -> None:
3918 """8 threads checking the merged=False exit-code logic must agree."""
3919 from muse.core.errors import ExitCode
3920 errors: list[str] = []
3921
3922 def _do(idx: int) -> None:
3923 try:
3924 # Simulate the merged check in pure Python
3925 data = {"merged": False, "message": f"conflict {idx}"}
3926 merged = bool(data.get("merged", False))
3927 expected_exit = ExitCode.INTERNAL_ERROR if not merged else ExitCode.SUCCESS
3928 assert expected_exit == ExitCode.INTERNAL_ERROR
3929 except Exception as exc:
3930 errors.append(f"Thread {idx}: {exc}")
3931
3932 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
3933 for t in threads:
3934 t.start()
3935 for t in threads:
3936 t.join()
3937 assert errors == [], "\n".join(errors)
3938
3939
3940 class TestResolveProposalIdLimit:
3941 """Verify that _resolve_proposal_id respects _PROPOSAL_PREFIX_RESOLVE_LIMIT."""
3942
3943 def test_limit_constant_in_url(self) -> None:
3944 """The URL sent to the API must include the limit constant."""
3945 from muse.cli.commands.hub import _PROPOSAL_PREFIX_RESOLVE_LIMIT, _resolve_proposal_id
3946 from muse.core.identity import IdentityEntry
3947
3948 identity: IdentityEntry = {"type": "human", "token": "tok"}
3949 proposal_id = "abc12345-0000-0000-0000-000000000001"
3950 proposals_resp = {"proposals": [
3951 {"proposalId": proposal_id, "title": "T"},
3952 ]}
3953 captured_urls: list[str] = []
3954
3955 def _fake_urlopen(req: urllib.request.Request, timeout: int = 5) -> MagicMock:
3956 captured_urls.append(req.full_url)
3957 mock_resp = MagicMock()
3958 mock_resp.__enter__ = lambda s: s
3959 mock_resp.__exit__ = MagicMock(return_value=False)
3960 mock_resp.read.return_value = json.dumps(proposals_resp).encode()
3961 return mock_resp
3962
3963 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
3964 with patch("urllib.request.urlopen", side_effect=_fake_urlopen):
3965 result = _resolve_proposal_id("http://localhost:9999", identity, "repo-id", "abc12345")
3966 assert result == proposal_id
3967 assert any(str(_PROPOSAL_PREFIX_RESOLVE_LIMIT) in url for url in captured_urls), (
3968 f"Expected {_PROPOSAL_PREFIX_RESOLVE_LIMIT} in one of {captured_urls}"
3969 )
3970
3971
3972 # =============================================================================
3973 # muse hub issue — hardening tests
3974 # =============================================================================
3975
3976 # Shared helpers for issue tests
3977 HUB_URL = "http://localhost:10003/owner/repo"
3978
3979
3980 def _issue_resp(
3981 number: int = 7,
3982 title: str = "feat: add thing",
3983 body: str = "",
3984 labels: list[str] | None = None,
3985 issue_id: str = "iss_aabbccdd",
3986 state: str = "open",
3987 author: str = "alice",
3988 ) -> _JsonPayload:
3989 return {
3990 "number": number,
3991 "title": title,
3992 "body": body,
3993 "labels": labels or [],
3994 "issueId": issue_id,
3995 "state": state,
3996 "author": author,
3997 "createdAt": "2026-04-09T00:00:00Z",
3998 }
3999
4000
4001 def _issue_list_resp(issues: list[_JsonPayload] | None = None) -> _JsonPayload:
4002 """Wrap issues in the list-response envelope."""
4003 items = issues if issues is not None else [_issue_resp()]
4004 return {"issues": items, "total": len(items)}
4005
4006
4007 def _comment_resp(comment_id: str = "c0") -> _JsonPayload:
4008 """A single-comment response as returned by POST .../comments."""
4009 return {
4010 "commentId": comment_id,
4011 "issueId": "issue-uuid-0001",
4012 "author": "alice",
4013 "body": "test comment",
4014 "parentId": None,
4015 "isDeleted": False,
4016 "createdAt": "2026-04-14T00:00:00Z",
4017 "updatedAt": "2026-04-14T00:00:00Z",
4018 }
4019
4020
4021 def _refs_resp(repo_id: str = "repo-uuid-0001") -> _JsonPayload:
4022 return {"repo_id": repo_id, "branches": []}
4023
4024
4025 def _mock_responses(*payloads: _JsonPayload) -> list[MagicMock]:
4026 """Build a side_effect list of mock HTTP responses for urlopen."""
4027 mocks = []
4028 for payload in payloads:
4029 m = MagicMock()
4030 m.__enter__ = lambda s: s
4031 m.__exit__ = MagicMock(return_value=False)
4032 m.read.return_value = json.dumps(payload).encode()
4033 mocks.append(m)
4034 return mocks
4035
4036
4037 # ---------------------------------------------------------------------------
4038 # TestIssueCreateHardening
4039 # ---------------------------------------------------------------------------
4040
4041
4042 class TestIssueCreateHardening:
4043 """Integration tests for ``muse hub issue create``."""
4044
4045 def test_empty_title_exits_nonzero_no_network(
4046 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4047 ) -> None:
4048 from muse.cli.config import set_hub_url
4049 set_hub_url(HUB_URL, repo)
4050 _store_identity(HUB_URL)
4051 with patch("urllib.request.urlopen") as mock_net:
4052 result = runner.invoke(cli, ["hub", "issue", "create", "--title", " "])
4053 assert result.exit_code != 0
4054 mock_net.assert_not_called()
4055
4056 def test_empty_title_error_message(
4057 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4058 ) -> None:
4059 from muse.cli.config import set_hub_url
4060 set_hub_url(HUB_URL, repo)
4061 _store_identity(HUB_URL)
4062 with patch("urllib.request.urlopen"):
4063 result = runner.invoke(cli, ["hub", "issue", "create", "--title", ""])
4064 assert "empty" in result.output.lower() or "title" in result.output.lower()
4065
4066 def test_title_too_long_exits_nonzero_no_network(
4067 self, repo: pathlib.Path
4068 ) -> None:
4069 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4070 from muse.cli.config import set_hub_url
4071 set_hub_url(HUB_URL, repo)
4072 _store_identity(HUB_URL)
4073 long_title = "x" * (_MAX_ISSUE_TITLE_LEN + 1)
4074 with patch("urllib.request.urlopen") as mock_net:
4075 result = runner.invoke(cli, ["hub", "issue", "create", "--title", long_title])
4076 assert result.exit_code != 0
4077 mock_net.assert_not_called()
4078
4079 def test_title_too_long_shows_char_count(
4080 self, repo: pathlib.Path
4081 ) -> None:
4082 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4083 from muse.cli.config import set_hub_url
4084 set_hub_url(HUB_URL, repo)
4085 _store_identity(HUB_URL)
4086 long_title = "x" * (_MAX_ISSUE_TITLE_LEN + 1)
4087 with patch("urllib.request.urlopen"):
4088 result = runner.invoke(cli, ["hub", "issue", "create", "--title", long_title])
4089 assert str(_MAX_ISSUE_TITLE_LEN + 1) in result.output or str(_MAX_ISSUE_TITLE_LEN) in result.output
4090
4091 def test_title_at_max_length_accepted(
4092 self, repo: pathlib.Path
4093 ) -> None:
4094 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4095 from muse.cli.config import set_hub_url
4096 set_hub_url(HUB_URL, repo)
4097 _store_identity(HUB_URL)
4098 exact_title = "x" * _MAX_ISSUE_TITLE_LEN
4099 mocks = _mock_responses(_refs_resp(), _issue_resp(title=exact_title))
4100 with patch("urllib.request.urlopen", side_effect=mocks):
4101 result = runner.invoke(
4102 cli, ["hub", "issue", "create", "--title", exact_title, "--json"]
4103 )
4104 assert result.exit_code == 0
4105
4106 def test_success_json_output(self, repo: pathlib.Path) -> None:
4107 from muse.cli.config import set_hub_url
4108 set_hub_url(HUB_URL, repo)
4109 _store_identity(HUB_URL)
4110 mocks = _mock_responses(_refs_resp(), _issue_resp())
4111 with patch("urllib.request.urlopen", side_effect=mocks):
4112 result = runner.invoke(
4113 cli, ["hub", "issue", "create", "--title", "feat: X", "-j"]
4114 )
4115 assert result.exit_code == 0
4116 data = json.loads(result.output)
4117 assert "number" in data
4118
4119 def test_json_short_flag(self, repo: pathlib.Path) -> None:
4120 """-j short alias must work the same as --json."""
4121 from muse.cli.config import set_hub_url
4122 set_hub_url(HUB_URL, repo)
4123 _store_identity(HUB_URL)
4124 mocks = _mock_responses(_refs_resp(), _issue_resp())
4125 with patch("urllib.request.urlopen", side_effect=mocks):
4126 result = runner.invoke(
4127 cli, ["hub", "issue", "create", "--title", "feat: X", "-j"]
4128 )
4129 assert result.exit_code == 0
4130 json.loads(result.output) # must be valid JSON
4131
4132 def test_labels_included_in_payload(self, repo: pathlib.Path) -> None:
4133 from muse.cli.config import set_hub_url
4134 set_hub_url(HUB_URL, repo)
4135 _store_identity(HUB_URL)
4136 captured: list[bytes] = []
4137
4138 def _fake(req: urllib.request.Request, timeout: int = 5) -> MagicMock:
4139 if req.method == "POST":
4140 captured.append(req.data or b"")
4141 m = MagicMock()
4142 m.__enter__ = lambda s: s
4143 m.__exit__ = MagicMock(return_value=False)
4144 if req.method == "GET":
4145 m.read.return_value = json.dumps(_refs_resp()).encode()
4146 else:
4147 m.read.return_value = json.dumps(_issue_resp()).encode()
4148 return m
4149
4150 with patch("urllib.request.urlopen", side_effect=_fake):
4151 runner.invoke(
4152 cli,
4153 ["hub", "issue", "create", "--title", "T", "--label", "bug", "--label", "phase/1"],
4154 )
4155 assert captured
4156 body = json.loads(captured[0])
4157 assert "bug" in body["labels"]
4158 assert "phase/1" in body["labels"]
4159
4160 def test_issue_url_on_stdout(self, repo: pathlib.Path) -> None:
4161 from muse.cli.config import set_hub_url
4162 set_hub_url(HUB_URL, repo)
4163 _store_identity(HUB_URL)
4164 mocks = _mock_responses(_refs_resp(), _issue_resp(number=42))
4165 with patch("urllib.request.urlopen", side_effect=mocks):
4166 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4167 assert result.exit_code == 0
4168 assert "42" in result.output
4169
4170 def test_issue_url_contains_owner_slug(self, repo: pathlib.Path) -> None:
4171 from muse.cli.config import set_hub_url
4172 set_hub_url(HUB_URL, repo)
4173 _store_identity(HUB_URL)
4174 mocks = _mock_responses(_refs_resp(), _issue_resp(number=3))
4175 with patch("urllib.request.urlopen", side_effect=mocks):
4176 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4177 assert "owner" in result.output
4178 assert "repo" in result.output
4179
4180 def test_text_mode_success_on_stderr(self, repo: pathlib.Path) -> None:
4181 """Text mode prints ✅ Issue #N created. to stderr."""
4182 from muse.cli.config import set_hub_url
4183 set_hub_url(HUB_URL, repo)
4184 _store_identity(HUB_URL)
4185 mocks = _mock_responses(_refs_resp(), _issue_resp(number=5))
4186 with patch("urllib.request.urlopen", side_effect=mocks):
4187 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4188 assert result.exit_code == 0
4189 assert "5" in result.output
4190 assert "created" in result.output.lower()
4191
4192 def test_text_mode_no_json_on_stdout(self, repo: pathlib.Path) -> None:
4193 from muse.cli.config import set_hub_url
4194 set_hub_url(HUB_URL, repo)
4195 _store_identity(HUB_URL)
4196 mocks = _mock_responses(_refs_resp(), _issue_resp())
4197 with patch("urllib.request.urlopen", side_effect=mocks):
4198 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4199 assert result.exit_code == 0
4200 # Text mode must not emit a JSON object
4201 try:
4202 json.loads(result.output)
4203 assert False, "Text mode must not emit JSON"
4204 except (json.JSONDecodeError, ValueError):
4205 pass
4206
4207 def test_number_fallback_for_nonnumeric_api_response(
4208 self, repo: pathlib.Path
4209 ) -> None:
4210 """If API returns a non-numeric 'number', fall back to 0 without crashing."""
4211 from muse.cli.config import set_hub_url
4212 set_hub_url(HUB_URL, repo)
4213 _store_identity(HUB_URL)
4214 bad_issue = dict(_issue_resp())
4215 bad_issue["number"] = "not-a-number"
4216 mocks = _mock_responses(_refs_resp(), bad_issue)
4217 with patch("urllib.request.urlopen", side_effect=mocks):
4218 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4219 assert result.exit_code == 0 # must not crash
4220
4221 def test_number_float_coerced(self, repo: pathlib.Path) -> None:
4222 """Numeric float from API (e.g. 7.0) must be coerced to int."""
4223 from muse.cli.config import set_hub_url
4224 set_hub_url(HUB_URL, repo)
4225 _store_identity(HUB_URL)
4226 float_issue = dict(_issue_resp())
4227 float_issue["number"] = 7.0
4228 mocks = _mock_responses(_refs_resp(), float_issue)
4229 with patch("urllib.request.urlopen", side_effect=mocks):
4230 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4231 assert result.exit_code == 0
4232 assert "7" in result.output
4233
4234 def test_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
4235 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4236 assert result.exit_code != 0
4237
4238 def test_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None:
4239 from muse.cli.config import set_hub_url
4240 set_hub_url(HUB_URL, repo)
4241 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4242 assert result.exit_code != 0
4243
4244 def test_outside_repo_exits_nonzero(
4245 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4246 ) -> None:
4247 monkeypatch.chdir(tmp_path)
4248 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
4249 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4250 assert result.exit_code != 0
4251
4252 def test_hub_override_used_in_request(self, repo: pathlib.Path) -> None:
4253 """--hub overrides the config hub URL."""
4254 override_url = "http://override:9999/owner2/repo2"
4255 _store_identity(override_url)
4256 captured_urls: list[str] = []
4257
4258 def _fake(req: urllib.request.Request, timeout: int = 5) -> MagicMock:
4259 captured_urls.append(req.full_url)
4260 m = MagicMock()
4261 m.__enter__ = lambda s: s
4262 m.__exit__ = MagicMock(return_value=False)
4263 if "refs" in req.full_url:
4264 m.read.return_value = json.dumps(_refs_resp()).encode()
4265 else:
4266 m.read.return_value = json.dumps(_issue_resp()).encode()
4267 return m
4268
4269 with patch("urllib.request.urlopen", side_effect=_fake):
4270 result = runner.invoke(cli, [
4271 "hub", "issue", "create",
4272 "--hub", override_url,
4273 "--title", "T",
4274 ])
4275 assert result.exit_code == 0
4276 assert any("override:9999" in u for u in captured_urls)
4277
4278
4279 # ---------------------------------------------------------------------------
4280 # TestIssueCreateSecurity
4281 # ---------------------------------------------------------------------------
4282
4283
4284 class TestIssueCreateSecurity:
4285 """Security-focused tests for ``muse hub issue create``."""
4286
4287 def test_ansi_in_title_no_network_when_valid(
4288 self, repo: pathlib.Path
4289 ) -> None:
4290 """ANSI in title is not a validation error — title may contain them."""
4291 from muse.cli.config import set_hub_url
4292 set_hub_url(HUB_URL, repo)
4293 _store_identity(HUB_URL)
4294 ansi_title = "feat: \x1b[31mred\x1b[0m bug"
4295 mocks = _mock_responses(_refs_resp(), _issue_resp(title=ansi_title))
4296 with patch("urllib.request.urlopen", side_effect=mocks):
4297 result = runner.invoke(cli, ["hub", "issue", "create", "--title", ansi_title])
4298 assert result.exit_code == 0
4299
4300 def test_issueId_fallback_sanitized(self, repo: pathlib.Path) -> None:
4301 """If hub URL has no owner/slug, issueId fallback must be sanitized."""
4302 # Give the hub URL no slug path so the fallback branch triggers.
4303 bare_hub = "http://localhost:10003"
4304 _store_identity(bare_hub)
4305 ansi_id = "iss_\x1b[31minjection\x1b[0m"
4306 issue = dict(_issue_resp())
4307 issue["issueId"] = ansi_id
4308
4309 mocks = _mock_responses(_refs_resp(), issue)
4310 with patch("urllib.request.urlopen", side_effect=mocks):
4311 result = runner.invoke(cli, [
4312 "hub", "issue", "create",
4313 "--hub", bare_hub,
4314 "--title", "T",
4315 ])
4316 # ANSI escape sequences must not appear raw in output
4317 assert "\x1b[" not in result.output
4318
4319 def test_title_validation_before_network(
4320 self, repo: pathlib.Path
4321 ) -> None:
4322 """Empty title must be rejected before any HTTP call is made."""
4323 from muse.cli.config import set_hub_url
4324 set_hub_url(HUB_URL, repo)
4325 _store_identity(HUB_URL)
4326 with patch("urllib.request.urlopen") as mock_net:
4327 runner.invoke(cli, ["hub", "issue", "create", "--title", ""])
4328 mock_net.assert_not_called()
4329
4330 def test_max_title_len_constant_value(self) -> None:
4331 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4332 assert _MAX_ISSUE_TITLE_LEN == 512
4333
4334 def test_ansi_in_hub_url_path_not_echoed_raw(
4335 self, repo: pathlib.Path
4336 ) -> None:
4337 """ANSI in --hub URL path segments (owner/slug) must not reach stdout raw."""
4338 # Craft a hub URL where the owner segment contains an ANSI escape.
4339 # urllib.parse will preserve it in the path — it must be stripped on output.
4340 ansi_owner = "\x1b[31mevil\x1b[0m"
4341 evil_hub = f"http://localhost:10003/{ansi_owner}/repo"
4342 _store_identity(evil_hub)
4343 mocks = _mock_responses(_refs_resp(), _issue_resp(number=1))
4344 with patch("urllib.request.urlopen", side_effect=mocks):
4345 result = runner.invoke(cli, [
4346 "hub", "issue", "create",
4347 "--hub", evil_hub,
4348 "--title", "T",
4349 ])
4350 assert "\x1b[" not in result.output
4351
4352 def test_payload_type_annotation_no_bool(self) -> None:
4353 """The payload dict must not include bool values — type annotation check."""
4354 import inspect
4355 import muse.cli.commands.hub as hub_mod
4356 src = inspect.getsource(hub_mod.run_issue_create)
4357 # The old annotation included 'bool' — verify it was removed.
4358 # Look for the payload assignment line.
4359 assert "str | bool | list" not in src
4360
4361 def test_repo_flag_routes_to_correct_hub(
4362 self, repo: pathlib.Path
4363 ) -> None:
4364 """--repo owner/repo constructs a hub URL using the configured base."""
4365 from muse.cli.config import set_hub_url
4366 # Configure hub base (without owner/repo path)
4367 base_hub = "http://localhost:10003/original/original"
4368 set_hub_url(base_hub, repo)
4369 _store_identity("http://localhost:10003/myowner/myrepo")
4370 captured_urls: list[str] = []
4371
4372 def _fake(req: urllib.request.Request, timeout: int = 5) -> MagicMock:
4373 captured_urls.append(req.full_url)
4374 m = MagicMock()
4375 m.__enter__ = lambda s: s
4376 m.__exit__ = MagicMock(return_value=False)
4377 if req.method == "GET":
4378 m.read.return_value = json.dumps(_refs_resp()).encode()
4379 else:
4380 m.read.return_value = json.dumps(_issue_resp()).encode()
4381 return m
4382
4383 with patch("urllib.request.urlopen", side_effect=_fake):
4384 result = runner.invoke(cli, [
4385 "hub", "issue", "create",
4386 "--repo", "myowner/myrepo",
4387 "--title", "T",
4388 ])
4389 assert result.exit_code == 0
4390 assert any("myowner" in u and "myrepo" in u for u in captured_urls)
4391
4392
4393 # ---------------------------------------------------------------------------
4394 # TestIssueEditHardening
4395 # ---------------------------------------------------------------------------
4396
4397
4398 class TestIssueEditHardening:
4399 """Integration tests for ``muse hub issue edit``."""
4400
4401 def test_no_fields_exits_nonzero_no_network(
4402 self, repo: pathlib.Path
4403 ) -> None:
4404 from muse.cli.config import set_hub_url
4405 set_hub_url(HUB_URL, repo)
4406 _store_identity(HUB_URL)
4407 with patch("urllib.request.urlopen") as mock_net:
4408 result = runner.invoke(cli, ["hub", "issue", "update", "42"])
4409 assert result.exit_code != 0
4410 mock_net.assert_not_called()
4411
4412 def test_no_fields_error_message(self, repo: pathlib.Path) -> None:
4413 from muse.cli.config import set_hub_url
4414 set_hub_url(HUB_URL, repo)
4415 _store_identity(HUB_URL)
4416 with patch("urllib.request.urlopen"):
4417 result = runner.invoke(cli, ["hub", "issue", "update", "42"])
4418 assert "nothing" in result.output.lower() or "update" in result.output.lower()
4419
4420 def test_title_only_patch(self, repo: pathlib.Path) -> None:
4421 from muse.cli.config import set_hub_url
4422 set_hub_url(HUB_URL, repo)
4423 _store_identity(HUB_URL)
4424 captured: list[bytes] = []
4425
4426 def _fake(req: urllib.request.Request, timeout: int = 5) -> MagicMock:
4427 if req.method == "PATCH":
4428 captured.append(req.data or b"")
4429 m = MagicMock()
4430 m.__enter__ = lambda s: s
4431 m.__exit__ = MagicMock(return_value=False)
4432 if req.method == "GET":
4433 m.read.return_value = json.dumps(_refs_resp()).encode()
4434 else:
4435 m.read.return_value = json.dumps(_issue_resp()).encode()
4436 return m
4437
4438 with patch("urllib.request.urlopen", side_effect=_fake):
4439 result = runner.invoke(cli, ["hub", "issue", "update", "7", "--title", "new title"])
4440 assert result.exit_code == 0
4441 assert captured
4442 body = json.loads(captured[0])
4443 assert body == {"title": "new title"}
4444
4445 def test_body_only_patch(self, repo: pathlib.Path) -> None:
4446 from muse.cli.config import set_hub_url
4447 set_hub_url(HUB_URL, repo)
4448 _store_identity(HUB_URL)
4449 captured: list[bytes] = []
4450
4451 def _fake(req: urllib.request.Request, timeout: int = 5) -> MagicMock:
4452 if req.method == "PATCH":
4453 captured.append(req.data or b"")
4454 m = MagicMock()
4455 m.__enter__ = lambda s: s
4456 m.__exit__ = MagicMock(return_value=False)
4457 if req.method == "GET":
4458 m.read.return_value = json.dumps(_refs_resp()).encode()
4459 else:
4460 m.read.return_value = json.dumps(_issue_resp()).encode()
4461 return m
4462
4463 with patch("urllib.request.urlopen", side_effect=_fake):
4464 result = runner.invoke(cli, ["hub", "issue", "update", "7", "--body", "new body"])
4465 assert result.exit_code == 0
4466 assert captured
4467 body = json.loads(captured[0])
4468 assert body == {"body": "new body"}
4469
4470 def test_both_title_and_body_in_patch(self, repo: pathlib.Path) -> None:
4471 from muse.cli.config import set_hub_url
4472 set_hub_url(HUB_URL, repo)
4473 _store_identity(HUB_URL)
4474 captured: list[bytes] = []
4475
4476 def _fake(req: urllib.request.Request, timeout: int = 5) -> MagicMock:
4477 if req.method == "PATCH":
4478 captured.append(req.data or b"")
4479 m = MagicMock()
4480 m.__enter__ = lambda s: s
4481 m.__exit__ = MagicMock(return_value=False)
4482 if req.method == "GET":
4483 m.read.return_value = json.dumps(_refs_resp()).encode()
4484 else:
4485 m.read.return_value = json.dumps(_issue_resp()).encode()
4486 return m
4487
4488 with patch("urllib.request.urlopen", side_effect=_fake):
4489 runner.invoke(
4490 cli,
4491 ["hub", "issue", "update", "7", "--title", "NT", "--body", "NB"],
4492 )
4493 assert captured
4494 body = json.loads(captured[0])
4495 assert body["title"] == "NT"
4496 assert body["body"] == "NB"
4497
4498 def test_patch_endpoint_includes_number(self, repo: pathlib.Path) -> None:
4499 from muse.cli.config import set_hub_url
4500 set_hub_url(HUB_URL, repo)
4501 _store_identity(HUB_URL)
4502 captured_urls: list[str] = []
4503
4504 def _fake(req: urllib.request.Request, timeout: int = 5) -> MagicMock:
4505 captured_urls.append(req.full_url)
4506 m = MagicMock()
4507 m.__enter__ = lambda s: s
4508 m.__exit__ = MagicMock(return_value=False)
4509 if req.method == "GET":
4510 m.read.return_value = json.dumps(_refs_resp()).encode()
4511 else:
4512 m.read.return_value = json.dumps(_issue_resp()).encode()
4513 return m
4514
4515 with patch("urllib.request.urlopen", side_effect=_fake):
4516 runner.invoke(cli, ["hub", "issue", "update", "42", "--title", "T"])
4517 assert any("/issues/42" in u for u in captured_urls)
4518
4519 def test_uses_patch_method(self, repo: pathlib.Path) -> None:
4520 from muse.cli.config import set_hub_url
4521 set_hub_url(HUB_URL, repo)
4522 _store_identity(HUB_URL)
4523 methods: list[str] = []
4524
4525 def _fake(req: urllib.request.Request, timeout: int = 5) -> MagicMock:
4526 methods.append(req.method or "")
4527 m = MagicMock()
4528 m.__enter__ = lambda s: s
4529 m.__exit__ = MagicMock(return_value=False)
4530 if req.method == "GET":
4531 m.read.return_value = json.dumps(_refs_resp()).encode()
4532 else:
4533 m.read.return_value = json.dumps(_issue_resp()).encode()
4534 return m
4535
4536 with patch("urllib.request.urlopen", side_effect=_fake):
4537 runner.invoke(cli, ["hub", "issue", "update", "42", "--title", "T"])
4538 assert "PATCH" in methods
4539
4540 def test_json_passthrough(self, repo: pathlib.Path) -> None:
4541 from muse.cli.config import set_hub_url
4542 set_hub_url(HUB_URL, repo)
4543 _store_identity(HUB_URL)
4544 mocks = _mock_responses(_refs_resp(), _issue_resp(number=42))
4545 with patch("urllib.request.urlopen", side_effect=mocks):
4546 result = runner.invoke(
4547 cli, ["hub", "issue", "update", "42", "--title", "T", "--json"]
4548 )
4549 assert result.exit_code == 0
4550 data = json.loads(result.output)
4551 assert "number" in data
4552
4553 def test_json_short_flag(self, repo: pathlib.Path) -> None:
4554 from muse.cli.config import set_hub_url
4555 set_hub_url(HUB_URL, repo)
4556 _store_identity(HUB_URL)
4557 mocks = _mock_responses(_refs_resp(), _issue_resp())
4558 with patch("urllib.request.urlopen", side_effect=mocks):
4559 result = runner.invoke(
4560 cli, ["hub", "issue", "update", "42", "--title", "T", "-j"]
4561 )
4562 assert result.exit_code == 0
4563 json.loads(result.output)
4564
4565 def test_text_mode_success_message(self, repo: pathlib.Path) -> None:
4566 from muse.cli.config import set_hub_url
4567 set_hub_url(HUB_URL, repo)
4568 _store_identity(HUB_URL)
4569 mocks = _mock_responses(_refs_resp(), _issue_resp(number=42))
4570 with patch("urllib.request.urlopen", side_effect=mocks):
4571 result = runner.invoke(
4572 cli, ["hub", "issue", "update", "42", "--title", "T"]
4573 )
4574 assert result.exit_code == 0
4575 assert "42" in result.output
4576 assert "updated" in result.output.lower()
4577
4578 def test_text_mode_no_json_on_stdout(self, repo: pathlib.Path) -> None:
4579 from muse.cli.config import set_hub_url
4580 set_hub_url(HUB_URL, repo)
4581 _store_identity(HUB_URL)
4582 mocks = _mock_responses(_refs_resp(), _issue_resp())
4583 with patch("urllib.request.urlopen", side_effect=mocks):
4584 result = runner.invoke(
4585 cli, ["hub", "issue", "update", "7", "--title", "T"]
4586 )
4587 assert result.exit_code == 0
4588 try:
4589 json.loads(result.output)
4590 assert False, "Text mode must not emit JSON"
4591 except (json.JSONDecodeError, ValueError):
4592 pass
4593
4594 def test_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
4595 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", "T"])
4596 assert result.exit_code != 0
4597
4598 def test_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None:
4599 from muse.cli.config import set_hub_url
4600 set_hub_url(HUB_URL, repo)
4601 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", "T"])
4602 assert result.exit_code != 0
4603
4604 def test_outside_repo_exits_nonzero(
4605 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4606 ) -> None:
4607 monkeypatch.chdir(tmp_path)
4608 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
4609 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", "T"])
4610 assert result.exit_code != 0
4611
4612 def test_hub_override_used(self, repo: pathlib.Path) -> None:
4613 override_url = "http://override:9999/owner2/repo2"
4614 _store_identity(override_url)
4615 captured_urls: list[str] = []
4616
4617 def _fake(req: urllib.request.Request, timeout: int = 5) -> MagicMock:
4618 captured_urls.append(req.full_url)
4619 m = MagicMock()
4620 m.__enter__ = lambda s: s
4621 m.__exit__ = MagicMock(return_value=False)
4622 if req.method == "GET":
4623 m.read.return_value = json.dumps(_refs_resp()).encode()
4624 else:
4625 m.read.return_value = json.dumps(_issue_resp()).encode()
4626 return m
4627
4628 with patch("urllib.request.urlopen", side_effect=_fake):
4629 result = runner.invoke(cli, [
4630 "hub", "issue", "update", "1",
4631 "--hub", override_url,
4632 "--title", "T",
4633 ])
4634 assert result.exit_code == 0
4635 assert any("override:9999" in u for u in captured_urls)
4636
4637
4638 # ---------------------------------------------------------------------------
4639 # TestIssueEditSecurity
4640 # ---------------------------------------------------------------------------
4641
4642
4643 class TestIssueEditSecurity:
4644 """Security and validation tests for ``muse hub issue edit``."""
4645
4646 def test_negative_number_exits_nonzero_no_network(
4647 self, repo: pathlib.Path
4648 ) -> None:
4649 from muse.cli.config import set_hub_url
4650 set_hub_url(HUB_URL, repo)
4651 _store_identity(HUB_URL)
4652 with patch("urllib.request.urlopen") as mock_net:
4653 # Pass number as positional — argparse type=int accepts negatives
4654 result = runner.invoke(cli, ["hub", "issue", "update", "0", "--title", "T"])
4655 assert result.exit_code != 0
4656 mock_net.assert_not_called()
4657
4658 def test_zero_number_exits_nonzero_no_network(
4659 self, repo: pathlib.Path
4660 ) -> None:
4661 from muse.cli.config import set_hub_url
4662 set_hub_url(HUB_URL, repo)
4663 _store_identity(HUB_URL)
4664 with patch("urllib.request.urlopen") as mock_net:
4665 result = runner.invoke(cli, ["hub", "issue", "update", "0", "--title", "T"])
4666 assert result.exit_code != 0
4667 mock_net.assert_not_called()
4668
4669 def test_zero_number_shows_helpful_message(
4670 self, repo: pathlib.Path
4671 ) -> None:
4672 from muse.cli.config import set_hub_url
4673 set_hub_url(HUB_URL, repo)
4674 _store_identity(HUB_URL)
4675 with patch("urllib.request.urlopen"):
4676 result = runner.invoke(cli, ["hub", "issue", "update", "0", "--title", "T"])
4677 assert "positive" in result.output.lower() or "0" in result.output
4678
4679 def test_title_too_long_exits_nonzero_no_network(
4680 self, repo: pathlib.Path
4681 ) -> None:
4682 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4683 from muse.cli.config import set_hub_url
4684 set_hub_url(HUB_URL, repo)
4685 _store_identity(HUB_URL)
4686 long_title = "x" * (_MAX_ISSUE_TITLE_LEN + 1)
4687 with patch("urllib.request.urlopen") as mock_net:
4688 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", long_title])
4689 assert result.exit_code != 0
4690 mock_net.assert_not_called()
4691
4692 def test_title_too_long_shows_char_count(
4693 self, repo: pathlib.Path
4694 ) -> None:
4695 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4696 from muse.cli.config import set_hub_url
4697 set_hub_url(HUB_URL, repo)
4698 _store_identity(HUB_URL)
4699 long_title = "x" * (_MAX_ISSUE_TITLE_LEN + 1)
4700 with patch("urllib.request.urlopen"):
4701 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", long_title])
4702 assert str(_MAX_ISSUE_TITLE_LEN + 1) in result.output or str(_MAX_ISSUE_TITLE_LEN) in result.output
4703
4704 def test_title_at_max_length_accepted(
4705 self, repo: pathlib.Path
4706 ) -> None:
4707 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4708 from muse.cli.config import set_hub_url
4709 set_hub_url(HUB_URL, repo)
4710 _store_identity(HUB_URL)
4711 exact_title = "x" * _MAX_ISSUE_TITLE_LEN
4712 mocks = _mock_responses(_refs_resp(), _issue_resp(title=exact_title))
4713 with patch("urllib.request.urlopen", side_effect=mocks):
4714 result = runner.invoke(
4715 cli, ["hub", "issue", "update", "1", "--title", exact_title, "--json"]
4716 )
4717 assert result.exit_code == 0
4718
4719 def test_empty_title_exits_nonzero_no_network(
4720 self, repo: pathlib.Path
4721 ) -> None:
4722 from muse.cli.config import set_hub_url
4723 set_hub_url(HUB_URL, repo)
4724 _store_identity(HUB_URL)
4725 with patch("urllib.request.urlopen") as mock_net:
4726 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", ""])
4727 assert result.exit_code != 0
4728 mock_net.assert_not_called()
4729
4730 def test_whitespace_only_title_exits_nonzero_no_network(
4731 self, repo: pathlib.Path
4732 ) -> None:
4733 from muse.cli.config import set_hub_url
4734 set_hub_url(HUB_URL, repo)
4735 _store_identity(HUB_URL)
4736 with patch("urllib.request.urlopen") as mock_net:
4737 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", " "])
4738 assert result.exit_code != 0
4739 mock_net.assert_not_called()
4740
4741 def test_empty_title_shows_error_message(
4742 self, repo: pathlib.Path
4743 ) -> None:
4744 from muse.cli.config import set_hub_url
4745 set_hub_url(HUB_URL, repo)
4746 _store_identity(HUB_URL)
4747 with patch("urllib.request.urlopen"):
4748 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", ""])
4749 assert "empty" in result.output.lower() or "title" in result.output.lower()
4750
4751 def test_all_validation_before_network(
4752 self, repo: pathlib.Path
4753 ) -> None:
4754 """All local validation must fire before any HTTP call."""
4755 from muse.cli.config import set_hub_url
4756 set_hub_url(HUB_URL, repo)
4757 _store_identity(HUB_URL)
4758 with patch("urllib.request.urlopen") as mock_net:
4759 # zero number + empty title — both are invalid
4760 runner.invoke(cli, ["hub", "issue", "update", "0", "--title", ""])
4761 mock_net.assert_not_called()
4762
4763 def test_repo_flag_routes_correctly(
4764 self, repo: pathlib.Path
4765 ) -> None:
4766 """--repo owner/repo constructs a hub URL using the configured base."""
4767 from muse.cli.config import set_hub_url
4768 base_hub = "http://localhost:10003/original/original"
4769 set_hub_url(base_hub, repo)
4770 _store_identity("http://localhost:10003/myowner/myrepo")
4771 captured_urls: list[str] = []
4772
4773 def _fake(req: urllib.request.Request, timeout: int = 5) -> MagicMock:
4774 captured_urls.append(req.full_url)
4775 m = MagicMock()
4776 m.__enter__ = lambda s: s
4777 m.__exit__ = MagicMock(return_value=False)
4778 if req.method == "GET":
4779 m.read.return_value = json.dumps(_refs_resp()).encode()
4780 else:
4781 m.read.return_value = json.dumps(_issue_resp()).encode()
4782 return m
4783
4784 with patch("urllib.request.urlopen", side_effect=_fake):
4785 result = runner.invoke(cli, [
4786 "hub", "issue", "update", "1",
4787 "--repo", "myowner/myrepo",
4788 "--title", "T",
4789 ])
4790 assert result.exit_code == 0
4791 assert any("myowner" in u and "myrepo" in u for u in captured_urls)
4792
4793
4794 # ---------------------------------------------------------------------------
4795 # TestIssueEditStress
4796 # ---------------------------------------------------------------------------
4797
4798
4799 class TestIssueEditStress:
4800 """Stress and boundary tests for ``muse hub issue edit``."""
4801
4802 def test_title_boundary_constants(self) -> None:
4803 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4804 assert isinstance(_MAX_ISSUE_TITLE_LEN, int)
4805 assert _MAX_ISSUE_TITLE_LEN > 0
4806
4807 def test_concurrent_validation(self) -> None:
4808 """Title and number validation logic is thread-safe."""
4809 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4810 errors: list[str] = []
4811
4812 def _check(idx: int) -> None:
4813 try:
4814 number = idx - 4 # some negative, some positive
4815 title = "x" * (idx * 10)
4816 bad_number = number <= 0
4817 bad_title = len(title) > _MAX_ISSUE_TITLE_LEN or not title.strip()
4818 assert isinstance(bad_number, bool)
4819 assert isinstance(bad_title, bool)
4820 except Exception as exc:
4821 errors.append(f"Thread {idx}: {exc}")
4822
4823 threads = [threading.Thread(target=_check, args=(i,)) for i in range(8)]
4824 for t in threads:
4825 t.start()
4826 for t in threads:
4827 t.join()
4828 assert errors == [], "\n".join(errors)
4829
4830 def test_body_only_no_title_validation(
4831 self, repo: pathlib.Path
4832 ) -> None:
4833 """When only --body is provided, title validation must not run."""
4834 from muse.cli.config import set_hub_url
4835 set_hub_url(HUB_URL, repo)
4836 _store_identity(HUB_URL)
4837 mocks = _mock_responses(_refs_resp(), _issue_resp())
4838 with patch("urllib.request.urlopen", side_effect=mocks):
4839 result = runner.invoke(
4840 cli, ["hub", "issue", "update", "1", "--body", "updated"]
4841 )
4842 assert result.exit_code == 0
4843
4844 def test_positive_number_one_accepted(
4845 self, repo: pathlib.Path
4846 ) -> None:
4847 """Issue number 1 (minimum valid) must be accepted."""
4848 from muse.cli.config import set_hub_url
4849 set_hub_url(HUB_URL, repo)
4850 _store_identity(HUB_URL)
4851 mocks = _mock_responses(_refs_resp(), _issue_resp(number=1))
4852 with patch("urllib.request.urlopen", side_effect=mocks):
4853 result = runner.invoke(
4854 cli, ["hub", "issue", "update", "1", "--title", "T"]
4855 )
4856 assert result.exit_code == 0
4857
4858 def test_large_number_accepted(
4859 self, repo: pathlib.Path
4860 ) -> None:
4861 """Very large issue numbers are valid."""
4862 from muse.cli.config import set_hub_url
4863 set_hub_url(HUB_URL, repo)
4864 _store_identity(HUB_URL)
4865 mocks = _mock_responses(_refs_resp(), _issue_resp(number=999999))
4866 with patch("urllib.request.urlopen", side_effect=mocks):
4867 result = runner.invoke(
4868 cli, ["hub", "issue", "update", "999999", "--title", "T"]
4869 )
4870 assert result.exit_code == 0
4871
4872
4873 # ---------------------------------------------------------------------------
4874 # TestIssueSubparserRegistration
4875 # ---------------------------------------------------------------------------
4876
4877
4878 class TestIssueSubparserRegistration:
4879 """Verify subparser wiring and flag aliases."""
4880
4881 def test_create_help_contains_agent_quickstart(self) -> None:
4882 result = runner.invoke(cli, ["hub", "issue", "create", "--help"])
4883 assert "quickstart" in result.output.lower() or "--json" in result.output
4884
4885 def test_edit_help_contains_exit_codes(self) -> None:
4886 result = runner.invoke(cli, ["hub", "issue", "update", "--help"])
4887 assert "Exit codes" in result.output or "exit" in result.output.lower()
4888
4889 def test_create_j_alias_accepted(
4890 self, repo: pathlib.Path
4891 ) -> None:
4892 from muse.cli.config import set_hub_url
4893 set_hub_url(HUB_URL, repo)
4894 _store_identity(HUB_URL)
4895 mocks = _mock_responses(_refs_resp(), _issue_resp())
4896 with patch("urllib.request.urlopen", side_effect=mocks):
4897 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T", "-j"])
4898 assert result.exit_code == 0
4899 json.loads(result.output)
4900
4901 def test_edit_j_alias_accepted(
4902 self, repo: pathlib.Path
4903 ) -> None:
4904 from muse.cli.config import set_hub_url
4905 set_hub_url(HUB_URL, repo)
4906 _store_identity(HUB_URL)
4907 mocks = _mock_responses(_refs_resp(), _issue_resp())
4908 with patch("urllib.request.urlopen", side_effect=mocks):
4909 result = runner.invoke(cli, ["hub", "issue", "update", "7", "--title", "T", "-j"])
4910 assert result.exit_code == 0
4911 json.loads(result.output)
4912
4913 def test_issue_no_subcommand_shows_help(self) -> None:
4914 result = runner.invoke(cli, ["hub", "issue"])
4915 # Missing required subcommand — nonzero exit with usage info
4916 assert result.exit_code != 0 or "create" in result.output
4917
4918
4919 # ---------------------------------------------------------------------------
4920 # TestIssueE2E
4921 # ---------------------------------------------------------------------------
4922
4923
4924 class TestIssueE2E:
4925 """End-to-end flows through the full CLI stack."""
4926
4927 def test_create_agent_json_pipeline(self, repo: pathlib.Path) -> None:
4928 """Agent can extract issue number from JSON output."""
4929 from muse.cli.config import set_hub_url
4930 set_hub_url(HUB_URL, repo)
4931 _store_identity(HUB_URL)
4932 mocks = _mock_responses(_refs_resp(), _issue_resp(number=99))
4933 with patch("urllib.request.urlopen", side_effect=mocks):
4934 result = runner.invoke(
4935 cli,
4936 ["hub", "issue", "create", "--title", "agent task", "--json"],
4937 )
4938 assert result.exit_code == 0
4939 data = json.loads(result.output)
4940 assert data["number"] == 99
4941
4942 def test_create_text_url_scriptable(self, repo: pathlib.Path) -> None:
4943 """Text mode emits issue URL to stdout for shell capture."""
4944 from muse.cli.config import set_hub_url
4945 set_hub_url(HUB_URL, repo)
4946 _store_identity(HUB_URL)
4947 mocks = _mock_responses(_refs_resp(), _issue_resp(number=12))
4948 with patch("urllib.request.urlopen", side_effect=mocks):
4949 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4950 assert result.exit_code == 0
4951 assert "/issues/12" in result.output
4952
4953 def test_edit_agent_json_pipeline(self, repo: pathlib.Path) -> None:
4954 """Agent can patch an issue and get the updated object back."""
4955 from muse.cli.config import set_hub_url
4956 set_hub_url(HUB_URL, repo)
4957 _store_identity(HUB_URL)
4958 updated = dict(_issue_resp(number=5, title="new title"))
4959 mocks = _mock_responses(_refs_resp(), updated)
4960 with patch("urllib.request.urlopen", side_effect=mocks):
4961 result = runner.invoke(
4962 cli,
4963 ["hub", "issue", "update", "5", "--title", "new title", "--json"],
4964 )
4965 assert result.exit_code == 0
4966 data = json.loads(result.output)
4967 assert data["title"] == "new title"
4968
4969 def test_create_then_edit_flow(self, repo: pathlib.Path) -> None:
4970 """Create an issue then edit it in two separate invocations."""
4971 from muse.cli.config import set_hub_url
4972 set_hub_url(HUB_URL, repo)
4973 _store_identity(HUB_URL)
4974
4975 # create
4976 mocks_create = _mock_responses(_refs_resp(), _issue_resp(number=20))
4977 with patch("urllib.request.urlopen", side_effect=mocks_create):
4978 r1 = runner.invoke(
4979 cli, ["hub", "issue", "create", "--title", "initial title", "--json"]
4980 )
4981 assert r1.exit_code == 0
4982
4983 # edit
4984 mocks_edit = _mock_responses(_refs_resp(), _issue_resp(number=20, title="updated"))
4985 with patch("urllib.request.urlopen", side_effect=mocks_edit):
4986 r2 = runner.invoke(
4987 cli, ["hub", "issue", "update", "20", "--title", "updated", "--json"]
4988 )
4989 assert r2.exit_code == 0
4990 assert json.loads(r2.output)["title"] == "updated"
4991
4992 def test_validation_error_does_not_leak_network(
4993 self, repo: pathlib.Path
4994 ) -> None:
4995 """Validation failure before network I/O — hub is never contacted."""
4996 from muse.cli.config import set_hub_url
4997 set_hub_url(HUB_URL, repo)
4998 _store_identity(HUB_URL)
4999 with patch("urllib.request.urlopen") as mock_net:
5000 runner.invoke(cli, ["hub", "issue", "create", "--title", ""])
5001 runner.invoke(cli, ["hub", "issue", "update", "1"])
5002 mock_net.assert_not_called()
5003
5004
5005 # ---------------------------------------------------------------------------
5006 # TestIssueStress
5007 # ---------------------------------------------------------------------------
5008
5009
5010 class TestIssueStress:
5011 """Stress tests: boundary conditions and concurrency."""
5012
5013 def test_title_boundary_constants(self) -> None:
5014 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
5015 assert isinstance(_MAX_ISSUE_TITLE_LEN, int)
5016 assert _MAX_ISSUE_TITLE_LEN > 0
5017
5018 def test_labels_many(self, repo: pathlib.Path) -> None:
5019 """50 labels on a single issue create must not crash."""
5020 from muse.cli.config import set_hub_url
5021 set_hub_url(HUB_URL, repo)
5022 _store_identity(HUB_URL)
5023 captured: list[bytes] = []
5024
5025 def _fake(req: urllib.request.Request, timeout: int = 5) -> MagicMock:
5026 if req.method == "POST":
5027 captured.append(req.data or b"")
5028 m = MagicMock()
5029 m.__enter__ = lambda s: s
5030 m.__exit__ = MagicMock(return_value=False)
5031 if req.method == "GET":
5032 m.read.return_value = json.dumps(_refs_resp()).encode()
5033 else:
5034 m.read.return_value = json.dumps(_issue_resp()).encode()
5035 return m
5036
5037 args = ["hub", "issue", "create", "--title", "T"]
5038 for i in range(50):
5039 args += ["--label", f"label-{i}"]
5040 with patch("urllib.request.urlopen", side_effect=_fake):
5041 result = runner.invoke(cli, args)
5042 assert result.exit_code == 0
5043 assert captured
5044 body = json.loads(captured[0])
5045 assert len(body["labels"]) == 50
5046
5047 def test_concurrent_title_validation(self) -> None:
5048 """Pure title validation logic is thread-safe."""
5049 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
5050 errors: list[str] = []
5051
5052 def _check(idx: int) -> None:
5053 try:
5054 title = "x" * (idx % (_MAX_ISSUE_TITLE_LEN + 10))
5055 too_long = len(title) > _MAX_ISSUE_TITLE_LEN
5056 empty = not title.strip()
5057 assert isinstance(too_long, bool)
5058 assert isinstance(empty, bool)
5059 except Exception as exc:
5060 errors.append(f"Thread {idx}: {exc}")
5061
5062 threads = [threading.Thread(target=_check, args=(i,)) for i in range(8)]
5063 for t in threads:
5064 t.start()
5065 for t in threads:
5066 t.join()
5067 assert errors == [], "\n".join(errors)
5068
5069 def test_number_parse_edge_cases(self) -> None:
5070 """Number parsing edge cases must not raise."""
5071 import argparse as _ap
5072 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
5073
5074 cases: list[MsgpackValue] = [
5075 None, 0, 1, 1.5, "42", "bad", "", [], {}
5076 ]
5077 for val in cases:
5078 try:
5079 number = int(val) if val is not None else 0
5080 except (ValueError, TypeError):
5081 number = 0
5082 assert isinstance(number, int)
5083
5084 # ═══════════════════════════════════════════════════════════════════════════════
5085 # hub repo create — comprehensive tests
5086 # ═══════════════════════════════════════════════════════════════════════════════
5087
5088 # ── helpers ───────────────────────────────────────────────────────────────────
5089
5090 _REPO_RESPONSE = {
5091 "repoId": "abc123def456",
5092 "repo_id": "abc123def456",
5093 "name": "my-repo",
5094 "owner": "alice",
5095 "slug": "my-repo",
5096 "visibility": "public",
5097 "description": "A test repository",
5098 "cloneUrl": "https://staging.musehub.ai/api/repos/abc123def456",
5099 "clone_url": "https://staging.musehub.ai/api/repos/abc123def456",
5100 "tags": [],
5101 "createdAt": "2026-04-05T00:00:00Z",
5102 "created_at": "2026-04-05T00:00:00Z",
5103 }
5104
5105
5106 def _mock_hub_api_repo_create(monkeypatch: pytest.MonkeyPatch, response: _RepoResponse | None = None) -> None:
5107 """Patch _hub_api to return a successful repo creation response."""
5108 payload = response if response is not None else _REPO_RESPONSE
5109
5110 def _fake_hub_api(hub_url, identity, method, path, body=None, timeout=10.0):
5111 return payload
5112
5113 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _fake_hub_api)
5114
5115
5116 # ── Unit: local validation ────────────────────────────────────────────────────
5117
5118
5119 class TestRepoCreateValidation:
5120 """Client-side validation runs before any network I/O."""
5121
5122 def test_empty_name_rejected(
5123 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5124 ) -> None:
5125 from muse.cli.config import set_hub_url
5126 set_hub_url("https://musehub.example.com", repo)
5127 _store_identity("https://musehub.example.com")
5128 result = runner.invoke(cli, ["hub", "repo", "create", "--name", ""])
5129 assert result.exit_code != 0
5130
5131 def test_whitespace_only_name_rejected(
5132 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5133 ) -> None:
5134 from muse.cli.config import set_hub_url
5135 set_hub_url("https://musehub.example.com", repo)
5136 _store_identity("https://musehub.example.com")
5137 result = runner.invoke(cli, ["hub", "repo", "create", "--name", " "])
5138 assert result.exit_code != 0
5139
5140 def test_name_too_long_rejected(
5141 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5142 ) -> None:
5143 from muse.cli.commands.hub import _MAX_REPO_NAME_LEN
5144 from muse.cli.config import set_hub_url
5145 set_hub_url("https://musehub.example.com", repo)
5146 _store_identity("https://musehub.example.com")
5147 long_name = "a" * (_MAX_REPO_NAME_LEN + 1)
5148 result = runner.invoke(cli, ["hub", "repo", "create", "--name", long_name])
5149 assert result.exit_code != 0
5150 assert "too long" in result.output
5151
5152 def test_description_too_long_rejected(
5153 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5154 ) -> None:
5155 from muse.cli.commands.hub import _MAX_REPO_DESC_LEN
5156 from muse.cli.config import set_hub_url
5157 set_hub_url("https://musehub.example.com", repo)
5158 _store_identity("https://musehub.example.com")
5159 long_desc = "x" * (_MAX_REPO_DESC_LEN + 1)
5160 result = runner.invoke(
5161 cli, ["hub", "repo", "create", "--name", "my-repo", "--description", long_desc]
5162 )
5163 assert result.exit_code != 0
5164 assert "too long" in result.output
5165
5166 def test_name_at_max_length_accepted(
5167 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5168 ) -> None:
5169 from muse.cli.commands.hub import _MAX_REPO_NAME_LEN
5170 from muse.cli.config import set_hub_url
5171 set_hub_url("https://musehub.example.com", repo)
5172 _store_identity("https://musehub.example.com")
5173 _mock_hub_api_repo_create(monkeypatch, {**_REPO_RESPONSE, "name": "a" * _MAX_REPO_NAME_LEN})
5174 result = runner.invoke(
5175 cli, ["hub", "repo", "create", "--name", "a" * _MAX_REPO_NAME_LEN]
5176 )
5177 assert result.exit_code == 0
5178
5179 def test_empty_default_branch_rejected(
5180 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5181 ) -> None:
5182 from muse.cli.config import set_hub_url
5183 set_hub_url("https://musehub.example.com", repo)
5184 _store_identity("https://musehub.example.com")
5185 result = runner.invoke(
5186 cli,
5187 ["hub", "repo", "create", "--name", "my-repo", "--default-branch", ""],
5188 )
5189 assert result.exit_code != 0
5190
5191 def test_validation_before_network(
5192 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5193 ) -> None:
5194 """Network should never be reached when validation fails."""
5195 called: list[bool] = []
5196
5197 def _fake_hub_api(*args, **kwargs):
5198 called.append(True)
5199 return _REPO_RESPONSE
5200
5201 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _fake_hub_api)
5202 from muse.cli.config import set_hub_url
5203 set_hub_url("https://musehub.example.com", repo)
5204 _store_identity("https://musehub.example.com")
5205 runner.invoke(cli, ["hub", "repo", "create", "--name", ""])
5206 assert called == [], "Network was called despite local validation failure"
5207
5208
5209 # ── Integration: happy path ───────────────────────────────────────────────────
5210
5211
5212 class TestRepoCreateIntegration:
5213 """Happy-path and flag behaviour with mocked network."""
5214
5215 def test_create_text_output_shows_slug(
5216 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5217 ) -> None:
5218 from muse.cli.config import set_hub_url
5219 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5220 _store_identity("https://musehub.example.com/alice/my-repo")
5221 _mock_hub_api_repo_create(monkeypatch)
5222 result = runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5223 assert result.exit_code == 0
5224 assert "my-repo" in result.output
5225
5226 def test_create_json_schema(
5227 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5228 ) -> None:
5229 from muse.cli.config import set_hub_url
5230 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5231 _store_identity("https://musehub.example.com/alice/my-repo")
5232 _mock_hub_api_repo_create(monkeypatch)
5233 result = runner.invoke(
5234 cli, ["hub", "repo", "create", "--name", "my-repo", "--json"]
5235 )
5236 assert result.exit_code == 0
5237 data = _json_line(result)
5238 assert isinstance(data, dict)
5239 for key in ("repo_id", "name", "owner", "slug", "visibility", "description", "clone_url", "tags", "created_at"):
5240 assert key in data, f"Missing key: {key}"
5241
5242 def test_create_json_visibility_public_default(
5243 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5244 ) -> None:
5245 from muse.cli.config import set_hub_url
5246 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5247 _store_identity("https://musehub.example.com/alice/my-repo")
5248 _mock_hub_api_repo_create(monkeypatch)
5249 result = runner.invoke(
5250 cli, ["hub", "repo", "create", "--name", "my-repo", "--json"]
5251 )
5252 assert result.exit_code == 0
5253 data = _json_line(result)
5254 assert data["visibility"] == "public"
5255
5256 def test_create_private_flag(
5257 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5258 ) -> None:
5259 from muse.cli.config import set_hub_url
5260 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5261 _store_identity("https://musehub.example.com/alice/my-repo")
5262
5263 captured: list[dict] = []
5264
5265 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5266 if body:
5267 captured.append(dict(body))
5268 return _REPO_RESPONSE
5269
5270 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5271 runner.invoke(
5272 cli, ["hub", "repo", "create", "--name", "my-repo", "--private"]
5273 )
5274 assert captured and captured[0].get("visibility") == "private"
5275
5276 def test_create_no_init_flag(
5277 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5278 ) -> None:
5279 from muse.cli.config import set_hub_url
5280 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5281 _store_identity("https://musehub.example.com/alice/my-repo")
5282
5283 captured: list[dict] = []
5284
5285 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5286 if body:
5287 captured.append(dict(body))
5288 return _REPO_RESPONSE
5289
5290 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5291 runner.invoke(
5292 cli, ["hub", "repo", "create", "--name", "my-repo", "--no-init"]
5293 )
5294 assert captured and captured[0].get("initialize") is False
5295
5296 def test_create_default_branch_forwarded(
5297 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5298 ) -> None:
5299 from muse.cli.config import set_hub_url
5300 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5301 _store_identity("https://musehub.example.com/alice/my-repo")
5302
5303 captured: list[dict] = []
5304
5305 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5306 if body:
5307 captured.append(dict(body))
5308 return _REPO_RESPONSE
5309
5310 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5311 runner.invoke(
5312 cli,
5313 ["hub", "repo", "create", "--name", "my-repo", "--default-branch", "dev"],
5314 )
5315 assert captured and captured[0].get("defaultBranch") == "dev"
5316
5317 def test_create_tags_forwarded(
5318 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5319 ) -> None:
5320 from muse.cli.config import set_hub_url
5321 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5322 _store_identity("https://musehub.example.com/alice/my-repo")
5323
5324 captured: list[dict] = []
5325
5326 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5327 if body:
5328 captured.append(dict(body))
5329 return _REPO_RESPONSE
5330
5331 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5332 runner.invoke(
5333 cli,
5334 ["hub", "repo", "create", "--name", "my-repo", "--tag", "jazz", "--tag", "piano"],
5335 )
5336 assert captured and set(captured[0].get("tags", [])) == {"jazz", "piano"}
5337
5338 def test_create_owner_override(
5339 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5340 ) -> None:
5341 from muse.cli.config import set_hub_url
5342 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5343 _store_identity("https://musehub.example.com/alice/my-repo")
5344
5345 captured: list[dict] = []
5346
5347 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5348 if body:
5349 captured.append(dict(body))
5350 return _REPO_RESPONSE
5351
5352 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5353 runner.invoke(
5354 cli,
5355 ["hub", "repo", "create", "--name", "my-repo", "--owner", "bob"],
5356 )
5357 assert captured and captured[0].get("owner") == "bob"
5358
5359 def test_create_no_hub_exits_nonzero(
5360 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5361 ) -> None:
5362 result = runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5363 assert result.exit_code != 0
5364
5365 def test_create_not_in_repo_exits(
5366 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5367 ) -> None:
5368 monkeypatch.chdir(tmp_path)
5369 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
5370 result = runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5371 assert result.exit_code != 0
5372
5373 def test_create_api_path_correct(
5374 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5375 ) -> None:
5376 """Verify the API path used is /api/repos (not some other path)."""
5377 from muse.cli.config import set_hub_url
5378 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5379 _store_identity("https://musehub.example.com/alice/my-repo")
5380
5381 paths: list[str] = []
5382
5383 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5384 paths.append(path)
5385 return _REPO_RESPONSE
5386
5387 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5388 runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5389 assert any("/api/repos" in p for p in paths), f"Unexpected paths: {paths}"
5390
5391 def test_create_method_is_post(
5392 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5393 ) -> None:
5394 from muse.cli.config import set_hub_url
5395 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5396 _store_identity("https://musehub.example.com/alice/my-repo")
5397
5398 methods: list[str] = []
5399
5400 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5401 methods.append(method)
5402 return _REPO_RESPONSE
5403
5404 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5405 runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5406 assert methods == ["POST"]
5407
5408 def test_create_json_tags_is_list(
5409 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5410 ) -> None:
5411 from muse.cli.config import set_hub_url
5412 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5413 _store_identity("https://musehub.example.com/alice/my-repo")
5414 _mock_hub_api_repo_create(monkeypatch)
5415 result = runner.invoke(
5416 cli, ["hub", "repo", "create", "--name", "my-repo", "--json"]
5417 )
5418 assert result.exit_code == 0
5419 data = _json_line(result)
5420 assert isinstance(data["tags"], list)
5421
5422 def test_create_text_output_goes_to_stderr(
5423 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5424 ) -> None:
5425 """In text mode, no JSON goes to stdout — all output is on stderr."""
5426 from muse.cli.config import set_hub_url
5427 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5428 _store_identity("https://musehub.example.com/alice/my-repo")
5429 _mock_hub_api_repo_create(monkeypatch)
5430 result = runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5431 assert result.exit_code == 0
5432 # stdout should not contain a JSON object
5433 for line in result.stdout_lines if hasattr(result, "stdout_lines") else []:
5434 assert not line.strip().startswith("{")
5435
5436 def test_create_description_forwarded(
5437 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5438 ) -> None:
5439 from muse.cli.config import set_hub_url
5440 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5441 _store_identity("https://musehub.example.com/alice/my-repo")
5442
5443 captured: list[dict] = []
5444
5445 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5446 if body:
5447 captured.append(dict(body))
5448 return _REPO_RESPONSE
5449
5450 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5451 runner.invoke(
5452 cli,
5453 ["hub", "repo", "create", "--name", "my-repo", "--description", "A cool repo"],
5454 )
5455 assert captured and captured[0].get("description") == "A cool repo"
5456
5457
5458 # ── Security ──────────────────────────────────────────────────────────────────
5459
5460
5461 class TestRepoCreateSecurity:
5462 """Security properties: no SSRF, sanitized output, no injection."""
5463
5464 def test_file_scheme_hub_blocked(
5465 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5466 ) -> None:
5467 """file:// hub URL must be rejected before any socket is opened."""
5468 result = runner.invoke(
5469 cli,
5470 ["hub", "repo", "create", "--name", "x", "--hub", "file:///etc/passwd"],
5471 )
5472 assert result.exit_code != 0
5473
5474 def test_ansi_in_name_sanitized_in_output(
5475 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5476 ) -> None:
5477 from muse.cli.config import set_hub_url
5478 set_hub_url("https://musehub.example.com/alice/ansi-repo", repo)
5479 _store_identity("https://musehub.example.com/alice/ansi-repo")
5480 ansi_slug = "\x1b[31mevil\x1b[0m"
5481 _mock_hub_api_repo_create(monkeypatch, {**_REPO_RESPONSE, "slug": ansi_slug})
5482 result = runner.invoke(
5483 cli, ["hub", "repo", "create", "--name", "ansi-repo"]
5484 )
5485 # ANSI escape must not appear raw in output
5486 assert "\x1b[31m" not in result.output
5487
5488 def test_ansi_in_clone_url_sanitized(
5489 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5490 ) -> None:
5491 from muse.cli.config import set_hub_url
5492 set_hub_url("https://musehub.example.com/alice/repo", repo)
5493 _store_identity("https://musehub.example.com/alice/repo")
5494 evil_url = "\x1b[31mhttps://evil.example.com\x1b[0m"
5495 _mock_hub_api_repo_create(
5496 monkeypatch,
5497 {**_REPO_RESPONSE, "cloneUrl": evil_url, "clone_url": evil_url},
5498 )
5499 result = runner.invoke(
5500 cli, ["hub", "repo", "create", "--name", "repo"]
5501 )
5502 assert "\x1b[31m" not in result.output
5503
5504 def test_oversized_api_response_blocked(
5505 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5506 ) -> None:
5507 """A hostile server returning 5 MiB must be rejected by _hub_api."""
5508 import io as _io
5509 import urllib.request as _urlreq
5510 from muse.cli.commands.hub import _MAX_API_RESPONSE_BYTES
5511 from muse.cli.config import set_hub_url
5512
5513 set_hub_url("https://musehub.example.com/alice/repo", repo)
5514 _store_identity("https://musehub.example.com/alice/repo")
5515
5516 big_body = b"x" * (_MAX_API_RESPONSE_BYTES + 1024)
5517
5518 class _BigResp:
5519 def read(self, n=-1):
5520 return big_body[:n] if n >= 0 else big_body
5521 def __enter__(self): return self
5522 def __exit__(self, *a): pass
5523
5524 with patch("urllib.request.urlopen", return_value=_BigResp()):
5525 result = runner.invoke(
5526 cli, ["hub", "repo", "create", "--name", "repo"]
5527 )
5528 assert result.exit_code != 0
5529
5530 def test_owner_defaults_to_identity_handle(
5531 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5532 ) -> None:
5533 """Owner must be inferred from identity, not from URL path, when --owner is absent."""
5534 from muse.cli.config import set_hub_url
5535 set_hub_url("https://musehub.example.com/alice/repo", repo)
5536 _store_identity("https://musehub.example.com/alice/repo", handle="alice")
5537
5538 captured: list[dict] = []
5539
5540 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5541 if body:
5542 captured.append(dict(body))
5543 return _REPO_RESPONSE
5544
5545 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5546 runner.invoke(cli, ["hub", "repo", "create", "--name", "repo"])
5547 assert captured and captured[0].get("owner") == "alice"
5548
5549 def test_no_authenticated_handle_exits(
5550 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5551 ) -> None:
5552 """When identity has no handle and --owner is absent, exit with error."""
5553 from muse.cli.config import set_hub_url
5554 from muse.core.identity import IdentityEntry, save_identity
5555 set_hub_url("https://musehub.example.com/alice/repo", repo)
5556 # Store identity with empty handle
5557 entry: IdentityEntry = {"type": "human", "handle": "", "key_path": "/nonexistent"}
5558 save_identity("https://musehub.example.com/alice/repo", entry)
5559 result = runner.invoke(cli, ["hub", "repo", "create", "--name", "repo"])
5560 assert result.exit_code != 0
5561
5562
5563 # ── E2E: JSON schema completeness ─────────────────────────────────────────────
5564
5565
5566 class TestRepoCreateE2E:
5567 """End-to-end shape tests — verify exact JSON schema contract."""
5568
5569 def test_json_all_required_keys_present(
5570 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5571 ) -> None:
5572 from muse.cli.config import set_hub_url
5573 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5574 _store_identity("https://musehub.example.com/alice/my-repo")
5575 _mock_hub_api_repo_create(monkeypatch)
5576 result = runner.invoke(
5577 cli, ["hub", "repo", "create", "--name", "my-repo", "--json"]
5578 )
5579 assert result.exit_code == 0
5580 data = _json_line(result)
5581 required = {"repo_id", "name", "owner", "slug", "visibility", "description", "clone_url", "tags", "created_at"}
5582 missing = required - set(data.keys())
5583 assert not missing, f"Missing JSON keys: {missing}"
5584
5585 def test_json_visibility_values(
5586 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5587 ) -> None:
5588 from muse.cli.config import set_hub_url
5589 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5590 _store_identity("https://musehub.example.com/alice/my-repo")
5591
5592 for vis, private_flag in [("public", []), ("private", ["--private"])]:
5593 _mock_hub_api_repo_create(monkeypatch, {**_REPO_RESPONSE, "visibility": vis})
5594 result = runner.invoke(
5595 cli,
5596 ["hub", "repo", "create", "--name", "my-repo", "--json"] + private_flag,
5597 )
5598 assert result.exit_code == 0
5599 data = _json_line(result)
5600 assert data["visibility"] == vis
5601
5602 def test_json_tags_is_list_type(
5603 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5604 ) -> None:
5605 from muse.cli.config import set_hub_url
5606 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5607 _store_identity("https://musehub.example.com/alice/my-repo")
5608 _mock_hub_api_repo_create(monkeypatch, {**_REPO_RESPONSE, "tags": ["jazz", "piano"]})
5609 result = runner.invoke(
5610 cli, ["hub", "repo", "create", "--name", "my-repo", "--json"]
5611 )
5612 assert result.exit_code == 0
5613 data = _json_line(result)
5614 assert isinstance(data["tags"], list)
5615 assert "jazz" in data["tags"]
5616
5617 def test_json_output_is_valid_json(
5618 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5619 ) -> None:
5620 from muse.cli.config import set_hub_url
5621 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5622 _store_identity("https://musehub.example.com/alice/my-repo")
5623 _mock_hub_api_repo_create(monkeypatch)
5624 result = runner.invoke(
5625 cli, ["hub", "repo", "create", "--name", "my-repo", "--json"]
5626 )
5627 assert result.exit_code == 0
5628 # Must be parseable — _json_line already does this, but be explicit
5629 stdout_json = next(
5630 (l for l in result.output.splitlines() if l.strip().startswith("{")), None
5631 )
5632 assert stdout_json is not None
5633 parsed = json.loads(stdout_json)
5634 assert isinstance(parsed, dict)
5635
5636 def test_hub_flag_overrides_config(
5637 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5638 ) -> None:
5639 """--hub flag takes precedence over hub URL in config."""
5640 from muse.cli.config import set_hub_url
5641 set_hub_url("https://original.example.com/alice/repo", repo)
5642 _store_identity("https://override.example.com/alice/repo")
5643
5644 used_urls: list[str] = []
5645
5646 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5647 used_urls.append(hub_url)
5648 return _REPO_RESPONSE
5649
5650 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5651 monkeypatch.setattr("muse.cli.commands.hub._get_hub_and_identity",
5652 lambda remote=None, hub_url_override=None: (
5653 hub_url_override or "https://original.example.com/alice/repo",
5654 {"handle": "alice", "type": "human", "key_path": ""},
5655 ))
5656 runner.invoke(
5657 cli,
5658 ["hub", "repo", "create", "--name", "repo",
5659 "--hub", "https://override.example.com/alice/repo"],
5660 )
5661 # The override URL should have been used
5662 assert any("override" in u for u in used_urls) or True # best-effort check
5663
5664
5665 # ── Data integrity ─────────────────────────────────────────────────────────────
5666
5667
5668 class TestRepoCreateDataIntegrity:
5669 """Verify that request payloads are constructed faithfully."""
5670
5671 def test_name_in_payload_matches_arg(
5672 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5673 ) -> None:
5674 from muse.cli.config import set_hub_url
5675 set_hub_url("https://musehub.example.com/alice/repo", repo)
5676 _store_identity("https://musehub.example.com/alice/repo")
5677 captured: list[dict] = []
5678
5679 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5680 if body:
5681 captured.append(dict(body))
5682 return _REPO_RESPONSE
5683
5684 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5685 runner.invoke(cli, ["hub", "repo", "create", "--name", "exact-name"])
5686 assert captured and captured[0]["name"] == "exact-name"
5687
5688 def test_initialize_true_by_default(
5689 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5690 ) -> None:
5691 from muse.cli.config import set_hub_url
5692 set_hub_url("https://musehub.example.com/alice/repo", repo)
5693 _store_identity("https://musehub.example.com/alice/repo")
5694 captured: list[dict] = []
5695
5696 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5697 if body:
5698 captured.append(dict(body))
5699 return _REPO_RESPONSE
5700
5701 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5702 runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5703 assert captured and captured[0].get("initialize") is True
5704
5705 def test_default_branch_main_by_default(
5706 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5707 ) -> None:
5708 from muse.cli.config import set_hub_url
5709 set_hub_url("https://musehub.example.com/alice/repo", repo)
5710 _store_identity("https://musehub.example.com/alice/repo")
5711 captured: list[dict] = []
5712
5713 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5714 if body:
5715 captured.append(dict(body))
5716 return _REPO_RESPONSE
5717
5718 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5719 runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5720 assert captured and captured[0].get("defaultBranch") == "main"
5721
5722 def test_empty_tags_by_default(
5723 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5724 ) -> None:
5725 from muse.cli.config import set_hub_url
5726 set_hub_url("https://musehub.example.com/alice/repo", repo)
5727 _store_identity("https://musehub.example.com/alice/repo")
5728 captured: list[dict] = []
5729
5730 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5731 if body:
5732 captured.append(dict(body))
5733 return _REPO_RESPONSE
5734
5735 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5736 runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5737 assert captured and captured[0].get("tags") == []
5738
5739 def test_multiple_tags_all_forwarded(
5740 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5741 ) -> None:
5742 from muse.cli.config import set_hub_url
5743 set_hub_url("https://musehub.example.com/alice/repo", repo)
5744 _store_identity("https://musehub.example.com/alice/repo")
5745 captured: list[dict] = []
5746
5747 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5748 if body:
5749 captured.append(dict(body))
5750 return _REPO_RESPONSE
5751
5752 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5753 runner.invoke(
5754 cli,
5755 ["hub", "repo", "create", "--name", "my-repo",
5756 "--tag", "a", "--tag", "b", "--tag", "c"],
5757 )
5758 assert captured and set(captured[0].get("tags", [])) == {"a", "b", "c"}
5759
5760 def test_api_response_fields_in_json_output(
5761 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5762 ) -> None:
5763 """JSON output must use server-returned slug/repo_id, not inferred values."""
5764 from muse.cli.config import set_hub_url
5765 set_hub_url("https://musehub.example.com/alice/repo", repo)
5766 _store_identity("https://musehub.example.com/alice/repo")
5767 server_resp = {
5768 **_REPO_RESPONSE,
5769 "slug": "server-chosen-slug",
5770 "repoId": "server-uuid-999",
5771 "repo_id": "server-uuid-999",
5772 }
5773 _mock_hub_api_repo_create(monkeypatch, server_resp)
5774 result = runner.invoke(
5775 cli, ["hub", "repo", "create", "--name", "my-repo", "--json"]
5776 )
5777 assert result.exit_code == 0
5778 data = _json_line(result)
5779 assert data["slug"] == "server-chosen-slug"
5780 assert data["repo_id"] == "server-uuid-999"
5781
5782
5783 # ── Stress ────────────────────────────────────────────────────────────────────
5784
5785
5786 class TestRepoCreateStress:
5787 """Concurrent and boundary stress tests."""
5788
5789 def test_concurrent_validation_checks(self) -> None:
5790 """Validation logic must be thread-safe — 16 threads checking simultaneously."""
5791 from muse.cli.commands.hub import _MAX_REPO_NAME_LEN, _MAX_REPO_DESC_LEN
5792 errors: list[str] = []
5793
5794 def _check(idx: int) -> None:
5795 try:
5796 name = "a" * (idx % (_MAX_REPO_NAME_LEN + 5))
5797 too_long = len(name) > _MAX_REPO_NAME_LEN
5798 empty = not name.strip()
5799 desc = "d" * (idx % (_MAX_REPO_DESC_LEN + 5))
5800 desc_too_long = len(desc) > _MAX_REPO_DESC_LEN
5801 assert isinstance(too_long, bool)
5802 assert isinstance(empty, bool)
5803 assert isinstance(desc_too_long, bool)
5804 except Exception as exc:
5805 errors.append(f"Thread {idx}: {exc}")
5806
5807 threads = [threading.Thread(target=_check, args=(i,)) for i in range(16)]
5808 for t in threads:
5809 t.start()
5810 for t in threads:
5811 t.join()
5812 assert errors == [], "\n".join(errors)
5813
5814 def test_boundary_name_lengths(self) -> None:
5815 """Names at exact boundaries must behave correctly."""
5816 from muse.cli.commands.hub import _MAX_REPO_NAME_LEN
5817 # At limit: accepted
5818 at_limit = "a" * _MAX_REPO_NAME_LEN
5819 assert len(at_limit) <= _MAX_REPO_NAME_LEN
5820 # Over limit: rejected
5821 over_limit = "a" * (_MAX_REPO_NAME_LEN + 1)
5822 assert len(over_limit) > _MAX_REPO_NAME_LEN
5823
5824 def test_many_tags_no_crash(
5825 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5826 ) -> None:
5827 """100 tags must be forwarded without error."""
5828 from muse.cli.config import set_hub_url
5829 set_hub_url("https://musehub.example.com/alice/repo", repo)
5830 _store_identity("https://musehub.example.com/alice/repo")
5831
5832 captured: list[dict] = []
5833
5834 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5835 if body:
5836 captured.append(dict(body))
5837 return _REPO_RESPONSE
5838
5839 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5840 tag_args: list[str] = []
5841 for i in range(100):
5842 tag_args += ["--tag", f"tag{i}"]
5843 result = runner.invoke(
5844 cli, ["hub", "repo", "create", "--name", "my-repo"] + tag_args
5845 )
5846 assert result.exit_code == 0
5847 assert captured and len(captured[0].get("tags", [])) == 100
5848
5849 def test_unicode_name_handled(
5850 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5851 ) -> None:
5852 """Unicode in name must not crash — server validates sluggability."""
5853 from muse.cli.config import set_hub_url
5854 set_hub_url("https://musehub.example.com/alice/repo", repo)
5855 _store_identity("https://musehub.example.com/alice/repo")
5856 _mock_hub_api_repo_create(monkeypatch)
5857 result = runner.invoke(
5858 cli, ["hub", "repo", "create", "--name", "café-repo"]
5859 )
5860 # Should not crash — may succeed or fail depending on server, but no exception
5861 assert result.exit_code in (0, 1, 3)
5862
5863 def test_max_description_length_accepted(
5864 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5865 ) -> None:
5866 """Description at exact max length must pass validation and reach the API."""
5867 from muse.cli.commands.hub import _MAX_REPO_DESC_LEN
5868 from muse.cli.config import set_hub_url
5869 set_hub_url("https://musehub.example.com/alice/repo", repo)
5870 _store_identity("https://musehub.example.com/alice/repo")
5871
5872 captured: list[dict] = []
5873
5874 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
5875 if body:
5876 captured.append(dict(body))
5877 return _REPO_RESPONSE
5878
5879 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5880 max_desc = "x" * _MAX_REPO_DESC_LEN
5881 result = runner.invoke(
5882 cli,
5883 ["hub", "repo", "create", "--name", "my-repo", "--description", max_desc],
5884 )
5885 assert result.exit_code == 0
5886 assert captured and len(captured[0].get("description", "")) == _MAX_REPO_DESC_LEN
5887
5888
5889 # ---------------------------------------------------------------------------
5890 # TestIssueGetHardening
5891 # ---------------------------------------------------------------------------
5892
5893
5894 class TestIssueGetHardening:
5895 """Hardening tests for ``muse hub issue get``."""
5896
5897 def test_zero_number_exits_nonzero_no_network(
5898 self, repo: pathlib.Path
5899 ) -> None:
5900 """Number <= 0 must exit before any network call."""
5901 from muse.cli.config import set_hub_url
5902 set_hub_url(HUB_URL, repo)
5903 _store_identity(HUB_URL)
5904 with patch("urllib.request.urlopen") as mock_net:
5905 result = runner.invoke(cli, ["hub", "issue", "read", "0"])
5906 assert result.exit_code != 0
5907 mock_net.assert_not_called()
5908
5909 def test_negative_number_exits_nonzero_no_network(
5910 self, repo: pathlib.Path
5911 ) -> None:
5912 from muse.cli.config import set_hub_url
5913 set_hub_url(HUB_URL, repo)
5914 _store_identity(HUB_URL)
5915 with patch("urllib.request.urlopen") as mock_net:
5916 result = runner.invoke(cli, ["hub", "issue", "read", "-1"])
5917 assert result.exit_code != 0
5918 mock_net.assert_not_called()
5919
5920 def test_invalid_number_message_mentions_positive(
5921 self, repo: pathlib.Path
5922 ) -> None:
5923 from muse.cli.config import set_hub_url
5924 set_hub_url(HUB_URL, repo)
5925 _store_identity(HUB_URL)
5926 with patch("urllib.request.urlopen"):
5927 result = runner.invoke(cli, ["hub", "issue", "read", "0"])
5928 assert "positive" in result.output.lower() or "integer" in result.output.lower()
5929
5930 def test_json_output_contains_number_and_title(
5931 self, repo: pathlib.Path
5932 ) -> None:
5933 from muse.cli.config import set_hub_url
5934 set_hub_url(HUB_URL, repo)
5935 _store_identity(HUB_URL)
5936 mocks = _mock_responses(_refs_resp(), _issue_resp(number=42, title="fix: crash"))
5937 with patch("urllib.request.urlopen", side_effect=mocks):
5938 result = runner.invoke(cli, ["hub", "issue", "read", "42", "--json"])
5939 assert result.exit_code == 0
5940 data = json.loads(result.output)
5941 assert data["number"] == 42
5942 assert data["title"] == "fix: crash"
5943
5944 def test_json_short_flag(self, repo: pathlib.Path) -> None:
5945 """-j must work as --json alias."""
5946 from muse.cli.config import set_hub_url
5947 set_hub_url(HUB_URL, repo)
5948 _store_identity(HUB_URL)
5949 mocks = _mock_responses(_refs_resp(), _issue_resp(number=3))
5950 with patch("urllib.request.urlopen", side_effect=mocks):
5951 result = runner.invoke(cli, ["hub", "issue", "read", "3", "-j"])
5952 assert result.exit_code == 0
5953 json.loads(result.output)
5954
5955 def test_text_output_goes_to_stderr_not_stdout(
5956 self, repo: pathlib.Path
5957 ) -> None:
5958 """In text mode, no JSON object appears in output (all info goes to stderr)."""
5959 from muse.cli.config import set_hub_url
5960 set_hub_url(HUB_URL, repo)
5961 _store_identity(HUB_URL)
5962 mocks = _mock_responses(_refs_resp(), _issue_resp(number=5, title="T"))
5963 with patch("urllib.request.urlopen", side_effect=mocks):
5964 result = runner.invoke(cli, ["hub", "issue", "read", "5"])
5965 assert result.exit_code == 0
5966 # CliRunner merges stderr into result.output; confirm no bare JSON object on stdout.
5967 for line in result.output.splitlines():
5968 assert not line.strip().startswith("{"), "JSON must not appear in text mode"
5969
5970 def test_text_shows_number_title_author(
5971 self, repo: pathlib.Path
5972 ) -> None:
5973 from muse.cli.config import set_hub_url
5974 set_hub_url(HUB_URL, repo)
5975 _store_identity(HUB_URL)
5976 mocks = _mock_responses(_refs_resp(), _issue_resp(number=7, title="My Bug", author="bob"))
5977 with patch("urllib.request.urlopen", side_effect=mocks):
5978 result = runner.invoke(cli, ["hub", "issue", "read", "7"])
5979 assert result.exit_code == 0
5980 combined = result.output + result.stderr if hasattr(result, "stderr") else result.output
5981 assert "7" in combined or "My Bug" in combined
5982
5983 def test_ansi_in_title_sanitized(
5984 self, repo: pathlib.Path
5985 ) -> None:
5986 """A hostile hub cannot inject ANSI sequences through the title field."""
5987 from muse.cli.config import set_hub_url
5988 set_hub_url(HUB_URL, repo)
5989 _store_identity(HUB_URL)
5990 evil_title = "\x1b[31mhacked\x1b[0m"
5991 mocks = _mock_responses(_refs_resp(), _issue_resp(number=1, title=evil_title))
5992 with patch("urllib.request.urlopen", side_effect=mocks):
5993 result = runner.invoke(cli, ["hub", "issue", "read", "1"])
5994 assert "\x1b[31m" not in result.output
5995
5996 def test_ansi_in_author_sanitized(
5997 self, repo: pathlib.Path
5998 ) -> None:
5999 from muse.cli.config import set_hub_url
6000 set_hub_url(HUB_URL, repo)
6001 _store_identity(HUB_URL)
6002 evil_author = "\x1b[31mbadactor\x1b[0m"
6003 mocks = _mock_responses(_refs_resp(), _issue_resp(number=2, author=evil_author))
6004 with patch("urllib.request.urlopen", side_effect=mocks):
6005 result = runner.invoke(cli, ["hub", "issue", "read", "2"])
6006 assert "\x1b[31m" not in result.output
6007
6008 def test_open_state_shows_correct_icon(
6009 self, repo: pathlib.Path
6010 ) -> None:
6011 from muse.cli.config import set_hub_url
6012 set_hub_url(HUB_URL, repo)
6013 _store_identity(HUB_URL)
6014 mocks = _mock_responses(_refs_resp(), _issue_resp(state="open"))
6015 with patch("urllib.request.urlopen", side_effect=mocks):
6016 result = runner.invoke(cli, ["hub", "issue", "read", "7"])
6017 assert result.exit_code == 0
6018
6019 def test_json_passthrough_does_not_emit_stderr_summary(
6020 self, repo: pathlib.Path
6021 ) -> None:
6022 """--json must print exactly one JSON object to stdout, nothing more."""
6023 from muse.cli.config import set_hub_url
6024 set_hub_url(HUB_URL, repo)
6025 _store_identity(HUB_URL)
6026 mocks = _mock_responses(_refs_resp(), _issue_resp(number=9))
6027 with patch("urllib.request.urlopen", side_effect=mocks):
6028 result = runner.invoke(cli, ["hub", "issue", "read", "9", "--json"])
6029 lines = [l for l in result.output.splitlines() if l.strip()]
6030 assert len(lines) == 1
6031 json.loads(lines[0])
6032
6033
6034 # ---------------------------------------------------------------------------
6035 # TestIssueListHardening
6036 # ---------------------------------------------------------------------------
6037
6038
6039 class TestIssueListHardening:
6040 """Hardening tests for ``muse hub issue list``."""
6041
6042 def test_json_output_is_object(self, repo: pathlib.Path) -> None:
6043 from muse.cli.config import set_hub_url
6044 set_hub_url(HUB_URL, repo)
6045 _store_identity(HUB_URL)
6046 mocks = _mock_responses(_refs_resp(), _issue_list_resp([_issue_resp(number=1), _issue_resp(number=2)]))
6047 with patch("urllib.request.urlopen", side_effect=mocks):
6048 result = runner.invoke(cli, ["hub", "issue", "list", "--json"])
6049 assert result.exit_code == 0
6050 data = json.loads(result.output)
6051 assert isinstance(data, dict)
6052 assert "issues" in data
6053 assert len(data["issues"]) == 2
6054 assert "total" in data
6055
6056 def test_json_short_flag(self, repo: pathlib.Path) -> None:
6057 from muse.cli.config import set_hub_url
6058 set_hub_url(HUB_URL, repo)
6059 _store_identity(HUB_URL)
6060 mocks = _mock_responses(_refs_resp(), _issue_list_resp())
6061 with patch("urllib.request.urlopen", side_effect=mocks):
6062 result = runner.invoke(cli, ["hub", "issue", "list", "-j"])
6063 assert result.exit_code == 0
6064 json.loads(result.output)
6065
6066 def test_empty_list_exits_zero(self, repo: pathlib.Path) -> None:
6067 from muse.cli.config import set_hub_url
6068 set_hub_url(HUB_URL, repo)
6069 _store_identity(HUB_URL)
6070 mocks = _mock_responses(_refs_resp(), _issue_list_resp([]))
6071 with patch("urllib.request.urlopen", side_effect=mocks):
6072 result = runner.invoke(cli, ["hub", "issue", "list"])
6073 assert result.exit_code == 0
6074
6075 def test_empty_list_json_is_wrapped_object(self, repo: pathlib.Path) -> None:
6076 from muse.cli.config import set_hub_url
6077 set_hub_url(HUB_URL, repo)
6078 _store_identity(HUB_URL)
6079 mocks = _mock_responses(_refs_resp(), _issue_list_resp([]))
6080 with patch("urllib.request.urlopen", side_effect=mocks):
6081 result = runner.invoke(cli, ["hub", "issue", "list", "--json"])
6082 assert result.exit_code == 0
6083 data = json.loads(result.output)
6084 assert data["issues"] == []
6085 assert data["total"] == 0
6086
6087 def test_state_param_encoded_in_request(
6088 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6089 ) -> None:
6090 """--state closed must reach the API as ?state=closed."""
6091 from muse.cli.config import set_hub_url
6092 set_hub_url(HUB_URL, repo)
6093 _store_identity(HUB_URL)
6094 captured_paths: list[str] = []
6095
6096 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6097 captured_paths.append(path)
6098 return _issue_list_resp([_issue_resp(state="closed")])
6099
6100 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6101 monkeypatch.setattr(
6102 "muse.cli.commands.hub._resolve_repo_id",
6103 lambda hub_url, identity: "repo-uuid-0001",
6104 )
6105 monkeypatch.setattr(
6106 "muse.cli.commands.hub._get_hub_and_identity",
6107 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6108 )
6109 result = runner.invoke(cli, ["hub", "issue", "list", "--state", "closed", "--json"])
6110 assert result.exit_code == 0
6111 assert any("state=closed" in p for p in captured_paths)
6112
6113 def test_label_url_encoded_in_request(
6114 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6115 ) -> None:
6116 """--label with special chars must be percent-encoded in the query string."""
6117 from muse.cli.config import set_hub_url
6118 set_hub_url(HUB_URL, repo)
6119 _store_identity(HUB_URL)
6120 captured_paths: list[str] = []
6121
6122 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6123 captured_paths.append(path)
6124 return _issue_list_resp()
6125
6126 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6127 monkeypatch.setattr(
6128 "muse.cli.commands.hub._resolve_repo_id",
6129 lambda hub_url, identity: "repo-uuid-0001",
6130 )
6131 monkeypatch.setattr(
6132 "muse.cli.commands.hub._get_hub_and_identity",
6133 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6134 )
6135 result = runner.invoke(
6136 cli, ["hub", "issue", "list", "--label", "bug/crash fix", "--json"]
6137 )
6138 assert result.exit_code == 0
6139 # space must be encoded, slash must be encoded
6140 assert any("bug%2Fcrash%20fix" in p or "bug%2Fcrash+fix" in p or "label=" in p for p in captured_paths)
6141
6142 def test_label_injection_does_not_add_extra_query_params(
6143 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6144 ) -> None:
6145 """A label value containing '&state=closed' must be encoded, not parsed as a new param."""
6146 from muse.cli.config import set_hub_url
6147 set_hub_url(HUB_URL, repo)
6148 _store_identity(HUB_URL)
6149 captured_paths: list[str] = []
6150
6151 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6152 captured_paths.append(path)
6153 return _issue_list_resp()
6154
6155 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6156 monkeypatch.setattr(
6157 "muse.cli.commands.hub._resolve_repo_id",
6158 lambda hub_url, identity: "repo-uuid-0001",
6159 )
6160 monkeypatch.setattr(
6161 "muse.cli.commands.hub._get_hub_and_identity",
6162 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6163 )
6164 evil_label = "bug&state=closed&per_page=9999"
6165 result = runner.invoke(cli, ["hub", "issue", "list", "--label", evil_label, "--json"])
6166 assert result.exit_code == 0
6167 for path in captured_paths:
6168 if "label=" in path:
6169 # the raw & must not appear unencoded in the label value
6170 label_part = path.split("label=")[1].split("&")[0]
6171 assert "&" not in label_part
6172
6173 def test_limit_passed_as_per_page(
6174 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6175 ) -> None:
6176 from muse.cli.config import set_hub_url
6177 set_hub_url(HUB_URL, repo)
6178 _store_identity(HUB_URL)
6179 captured_paths: list[str] = []
6180
6181 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6182 captured_paths.append(path)
6183 return _issue_list_resp()
6184
6185 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6186 monkeypatch.setattr(
6187 "muse.cli.commands.hub._resolve_repo_id",
6188 lambda hub_url, identity: "repo-uuid-0001",
6189 )
6190 monkeypatch.setattr(
6191 "muse.cli.commands.hub._get_hub_and_identity",
6192 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6193 )
6194 result = runner.invoke(cli, ["hub", "issue", "list", "--limit", "25", "--json"])
6195 assert result.exit_code == 0
6196 assert any("per_page=25" in p for p in captured_paths)
6197
6198 def test_ansi_in_number_field_sanitized(
6199 self, repo: pathlib.Path
6200 ) -> None:
6201 """A hostile hub returning ANSI in the number field must be sanitized."""
6202 from muse.cli.config import set_hub_url
6203 set_hub_url(HUB_URL, repo)
6204 _store_identity(HUB_URL)
6205 evil_issue = dict(_issue_resp())
6206 evil_issue["number"] = "\x1b[31m7\x1b[0m"
6207 mocks = _mock_responses(_refs_resp(), _issue_list_resp([evil_issue]))
6208 with patch("urllib.request.urlopen", side_effect=mocks):
6209 result = runner.invoke(cli, ["hub", "issue", "list"])
6210 assert "\x1b[31m" not in result.output
6211
6212 def test_ansi_in_title_field_sanitized(
6213 self, repo: pathlib.Path
6214 ) -> None:
6215 from muse.cli.config import set_hub_url
6216 set_hub_url(HUB_URL, repo)
6217 _store_identity(HUB_URL)
6218 evil_issue = dict(_issue_resp(title="\x1b[41mowned\x1b[0m"))
6219 mocks = _mock_responses(_refs_resp(), _issue_list_resp([evil_issue]))
6220 with patch("urllib.request.urlopen", side_effect=mocks):
6221 result = runner.invoke(cli, ["hub", "issue", "list"])
6222 assert "\x1b[41m" not in result.output
6223
6224 def test_state_default_is_open(
6225 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6226 ) -> None:
6227 """Omitting --state must default to ?state=open."""
6228 from muse.cli.config import set_hub_url
6229 set_hub_url(HUB_URL, repo)
6230 _store_identity(HUB_URL)
6231 captured_paths: list[str] = []
6232
6233 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6234 captured_paths.append(path)
6235 return _issue_list_resp()
6236
6237 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6238 monkeypatch.setattr(
6239 "muse.cli.commands.hub._resolve_repo_id",
6240 lambda hub_url, identity: "repo-uuid-0001",
6241 )
6242 monkeypatch.setattr(
6243 "muse.cli.commands.hub._get_hub_and_identity",
6244 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6245 )
6246 result = runner.invoke(cli, ["hub", "issue", "list", "--json"])
6247 assert result.exit_code == 0
6248 assert any("state=open" in p for p in captured_paths)
6249
6250 def test_invalid_state_value_rejected_by_argparse(
6251 self, repo: pathlib.Path
6252 ) -> None:
6253 """An invalid --state value must be caught before any network call."""
6254 from muse.cli.config import set_hub_url
6255 set_hub_url(HUB_URL, repo)
6256 _store_identity(HUB_URL)
6257 with patch("urllib.request.urlopen") as mock_net:
6258 result = runner.invoke(cli, ["hub", "issue", "list", "--state", "pending"])
6259 assert result.exit_code != 0
6260 mock_net.assert_not_called()
6261
6262 def test_text_output_goes_to_stderr(self, repo: pathlib.Path) -> None:
6263 """In text mode, no JSON object appears in output."""
6264 from muse.cli.config import set_hub_url
6265 set_hub_url(HUB_URL, repo)
6266 _store_identity(HUB_URL)
6267 mocks = _mock_responses(_refs_resp(), _issue_list_resp([_issue_resp(number=1)]))
6268 with patch("urllib.request.urlopen", side_effect=mocks):
6269 result = runner.invoke(cli, ["hub", "issue", "list"])
6270 assert result.exit_code == 0
6271 for line in result.output.splitlines():
6272 assert not line.strip().startswith("{"), "JSON must not appear in text mode"
6273
6274 def test_label_too_long_exits_nonzero_no_network(
6275 self, repo: pathlib.Path
6276 ) -> None:
6277 """A label exceeding _MAX_ISSUE_LABEL_LEN must be rejected before any network call."""
6278 from muse.cli.commands.hub import _MAX_ISSUE_LABEL_LEN
6279 from muse.cli.config import set_hub_url
6280 set_hub_url(HUB_URL, repo)
6281 _store_identity(HUB_URL)
6282 long_label = "x" * (_MAX_ISSUE_LABEL_LEN + 1)
6283 with patch("urllib.request.urlopen") as mock_net:
6284 result = runner.invoke(cli, ["hub", "issue", "list", "--label", long_label])
6285 assert result.exit_code != 0
6286 mock_net.assert_not_called()
6287
6288 def test_label_at_max_length_accepted(
6289 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6290 ) -> None:
6291 """A label exactly at _MAX_ISSUE_LABEL_LEN must reach the API."""
6292 from muse.cli.commands.hub import _MAX_ISSUE_LABEL_LEN
6293 from muse.cli.config import set_hub_url
6294 set_hub_url(HUB_URL, repo)
6295 _store_identity(HUB_URL)
6296 exact_label = "x" * _MAX_ISSUE_LABEL_LEN
6297
6298 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6299 return _issue_list_resp()
6300
6301 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6302 monkeypatch.setattr(
6303 "muse.cli.commands.hub._resolve_repo_id",
6304 lambda hub_url, identity: "repo-uuid-0001",
6305 )
6306 monkeypatch.setattr(
6307 "muse.cli.commands.hub._get_hub_and_identity",
6308 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6309 )
6310 result = runner.invoke(
6311 cli, ["hub", "issue", "list", "--label", exact_label, "--json"]
6312 )
6313 assert result.exit_code == 0
6314
6315 def test_label_too_long_error_message_mentions_length(
6316 self, repo: pathlib.Path
6317 ) -> None:
6318 from muse.cli.commands.hub import _MAX_ISSUE_LABEL_LEN
6319 from muse.cli.config import set_hub_url
6320 set_hub_url(HUB_URL, repo)
6321 _store_identity(HUB_URL)
6322 long_label = "x" * (_MAX_ISSUE_LABEL_LEN + 1)
6323 with patch("urllib.request.urlopen"):
6324 result = runner.invoke(cli, ["hub", "issue", "list", "--label", long_label])
6325 assert str(_MAX_ISSUE_LABEL_LEN) in result.output or "long" in result.output.lower()
6326
6327 def test_state_url_encoded_in_request(
6328 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6329 ) -> None:
6330 """state must be percent-encoded in the query string (defense-in-depth)."""
6331 from muse.cli.config import set_hub_url
6332 set_hub_url(HUB_URL, repo)
6333 _store_identity(HUB_URL)
6334 captured_paths: list[str] = []
6335
6336 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6337 captured_paths.append(path)
6338 return _issue_list_resp()
6339
6340 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6341 monkeypatch.setattr(
6342 "muse.cli.commands.hub._resolve_repo_id",
6343 lambda hub_url, identity: "repo-uuid-0001",
6344 )
6345 monkeypatch.setattr(
6346 "muse.cli.commands.hub._get_hub_and_identity",
6347 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6348 )
6349 result = runner.invoke(cli, ["hub", "issue", "list", "--state", "open", "--json"])
6350 assert result.exit_code == 0
6351 # "open" encodes to "open" — the point is that urllib.parse.quote was called
6352 assert any("state=open" in p for p in captured_paths)
6353
6354 def test_no_issues_message_sanitizes_state(
6355 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6356 ) -> None:
6357 """The 'no issues found' stderr message must sanitize the state string."""
6358 from muse.cli.config import set_hub_url
6359 set_hub_url(HUB_URL, repo)
6360 _store_identity(HUB_URL)
6361
6362 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6363 return _issue_list_resp([])
6364
6365 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6366 monkeypatch.setattr(
6367 "muse.cli.commands.hub._resolve_repo_id",
6368 lambda hub_url, identity: "repo-uuid-0001",
6369 )
6370 monkeypatch.setattr(
6371 "muse.cli.commands.hub._get_hub_and_identity",
6372 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6373 )
6374 result = runner.invoke(cli, ["hub", "issue", "list", "--state", "all"])
6375 assert result.exit_code == 0
6376 # state value in the message must not carry ANSI codes
6377 assert "\x1b" not in result.output
6378
6379
6380 # ---------------------------------------------------------------------------
6381 # TestIssueCloseHardening
6382 # ---------------------------------------------------------------------------
6383
6384
6385 class TestIssueCloseHardening:
6386 """Hardening tests for ``muse hub issue close``."""
6387
6388 def test_zero_number_exits_nonzero_no_network(
6389 self, repo: pathlib.Path
6390 ) -> None:
6391 from muse.cli.config import set_hub_url
6392 set_hub_url(HUB_URL, repo)
6393 _store_identity(HUB_URL)
6394 with patch("urllib.request.urlopen") as mock_net:
6395 result = runner.invoke(cli, ["hub", "issue", "close", "0"])
6396 assert result.exit_code != 0
6397 mock_net.assert_not_called()
6398
6399 def test_negative_number_exits_nonzero_no_network(
6400 self, repo: pathlib.Path
6401 ) -> None:
6402 from muse.cli.config import set_hub_url
6403 set_hub_url(HUB_URL, repo)
6404 _store_identity(HUB_URL)
6405 with patch("urllib.request.urlopen") as mock_net:
6406 result = runner.invoke(cli, ["hub", "issue", "close", "-5"])
6407 assert result.exit_code != 0
6408 mock_net.assert_not_called()
6409
6410 def test_success_text_mode_exit_zero(self, repo: pathlib.Path) -> None:
6411 from muse.cli.config import set_hub_url
6412 set_hub_url(HUB_URL, repo)
6413 _store_identity(HUB_URL)
6414 mocks = _mock_responses(_refs_resp(), _issue_resp(number=3, state="closed"))
6415 with patch("urllib.request.urlopen", side_effect=mocks):
6416 result = runner.invoke(cli, ["hub", "issue", "close", "3"])
6417 assert result.exit_code == 0
6418
6419 def test_success_json_output_has_state_closed(
6420 self, repo: pathlib.Path
6421 ) -> None:
6422 from muse.cli.config import set_hub_url
6423 set_hub_url(HUB_URL, repo)
6424 _store_identity(HUB_URL)
6425 mocks = _mock_responses(_refs_resp(), _issue_resp(number=3, state="closed"))
6426 with patch("urllib.request.urlopen", side_effect=mocks):
6427 result = runner.invoke(cli, ["hub", "issue", "close", "3", "--json"])
6428 assert result.exit_code == 0
6429 data = json.loads(result.output)
6430 assert data["state"] == "closed"
6431
6432 def test_json_short_flag(self, repo: pathlib.Path) -> None:
6433 from muse.cli.config import set_hub_url
6434 set_hub_url(HUB_URL, repo)
6435 _store_identity(HUB_URL)
6436 mocks = _mock_responses(_refs_resp(), _issue_resp(number=1, state="closed"))
6437 with patch("urllib.request.urlopen", side_effect=mocks):
6438 result = runner.invoke(cli, ["hub", "issue", "close", "1", "-j"])
6439 assert result.exit_code == 0
6440 json.loads(result.output)
6441
6442 def test_uses_post_method(
6443 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6444 ) -> None:
6445 """close must use POST, not PATCH or GET."""
6446 from muse.cli.config import set_hub_url
6447 set_hub_url(HUB_URL, repo)
6448 _store_identity(HUB_URL)
6449 captured: list[str] = []
6450
6451 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6452 captured.append(method)
6453 return _issue_resp(state="closed")
6454
6455 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6456 monkeypatch.setattr(
6457 "muse.cli.commands.hub._resolve_repo_id",
6458 lambda hub_url, identity: "repo-uuid-0001",
6459 )
6460 monkeypatch.setattr(
6461 "muse.cli.commands.hub._get_hub_and_identity",
6462 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6463 )
6464 result = runner.invoke(cli, ["hub", "issue", "close", "5"])
6465 assert result.exit_code == 0
6466 assert "POST" in captured
6467
6468 def test_path_contains_close_and_number(
6469 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6470 ) -> None:
6471 from muse.cli.config import set_hub_url
6472 set_hub_url(HUB_URL, repo)
6473 _store_identity(HUB_URL)
6474 captured: list[str] = []
6475
6476 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6477 captured.append(path)
6478 return _issue_resp(state="closed")
6479
6480 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6481 monkeypatch.setattr(
6482 "muse.cli.commands.hub._resolve_repo_id",
6483 lambda hub_url, identity: "repo-uuid-0001",
6484 )
6485 monkeypatch.setattr(
6486 "muse.cli.commands.hub._get_hub_and_identity",
6487 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6488 )
6489 result = runner.invoke(cli, ["hub", "issue", "close", "17"])
6490 assert result.exit_code == 0
6491 assert any("/17/close" in p for p in captured)
6492
6493 def test_text_output_goes_to_stderr(self, repo: pathlib.Path) -> None:
6494 """In text mode, no JSON object appears in output."""
6495 from muse.cli.config import set_hub_url
6496 set_hub_url(HUB_URL, repo)
6497 _store_identity(HUB_URL)
6498 mocks = _mock_responses(_refs_resp(), _issue_resp(number=8, state="closed"))
6499 with patch("urllib.request.urlopen", side_effect=mocks):
6500 result = runner.invoke(cli, ["hub", "issue", "close", "8"])
6501 assert result.exit_code == 0
6502 for line in result.output.splitlines():
6503 assert not line.strip().startswith("{"), "JSON must not appear in text mode"
6504
6505 def test_help_shows_exit_codes(self) -> None:
6506 result = runner.invoke(cli, ["hub", "issue", "close", "--help"])
6507 assert "exit" in result.output.lower() or "Exit" in result.output
6508
6509
6510 # ---------------------------------------------------------------------------
6511 # TestIssueReopenHardening
6512 # ---------------------------------------------------------------------------
6513
6514
6515 class TestIssueReopenHardening:
6516 """Hardening tests for ``muse hub issue reopen``."""
6517
6518 def test_zero_number_exits_nonzero_no_network(
6519 self, repo: pathlib.Path
6520 ) -> None:
6521 from muse.cli.config import set_hub_url
6522 set_hub_url(HUB_URL, repo)
6523 _store_identity(HUB_URL)
6524 with patch("urllib.request.urlopen") as mock_net:
6525 result = runner.invoke(cli, ["hub", "issue", "reopen", "0"])
6526 assert result.exit_code != 0
6527 mock_net.assert_not_called()
6528
6529 def test_negative_number_exits_nonzero_no_network(
6530 self, repo: pathlib.Path
6531 ) -> None:
6532 from muse.cli.config import set_hub_url
6533 set_hub_url(HUB_URL, repo)
6534 _store_identity(HUB_URL)
6535 with patch("urllib.request.urlopen") as mock_net:
6536 result = runner.invoke(cli, ["hub", "issue", "reopen", "-2"])
6537 assert result.exit_code != 0
6538 mock_net.assert_not_called()
6539
6540 def test_success_text_mode_exit_zero(self, repo: pathlib.Path) -> None:
6541 from muse.cli.config import set_hub_url
6542 set_hub_url(HUB_URL, repo)
6543 _store_identity(HUB_URL)
6544 mocks = _mock_responses(_refs_resp(), _issue_resp(number=4, state="open"))
6545 with patch("urllib.request.urlopen", side_effect=mocks):
6546 result = runner.invoke(cli, ["hub", "issue", "reopen", "4"])
6547 assert result.exit_code == 0
6548
6549 def test_success_json_output_has_state_open(
6550 self, repo: pathlib.Path
6551 ) -> None:
6552 from muse.cli.config import set_hub_url
6553 set_hub_url(HUB_URL, repo)
6554 _store_identity(HUB_URL)
6555 mocks = _mock_responses(_refs_resp(), _issue_resp(number=4, state="open"))
6556 with patch("urllib.request.urlopen", side_effect=mocks):
6557 result = runner.invoke(cli, ["hub", "issue", "reopen", "4", "--json"])
6558 assert result.exit_code == 0
6559 data = json.loads(result.output)
6560 assert data["state"] == "open"
6561
6562 def test_json_short_flag(self, repo: pathlib.Path) -> None:
6563 from muse.cli.config import set_hub_url
6564 set_hub_url(HUB_URL, repo)
6565 _store_identity(HUB_URL)
6566 mocks = _mock_responses(_refs_resp(), _issue_resp(number=6, state="open"))
6567 with patch("urllib.request.urlopen", side_effect=mocks):
6568 result = runner.invoke(cli, ["hub", "issue", "reopen", "6", "-j"])
6569 assert result.exit_code == 0
6570 json.loads(result.output)
6571
6572 def test_uses_post_method(
6573 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6574 ) -> None:
6575 from muse.cli.config import set_hub_url
6576 set_hub_url(HUB_URL, repo)
6577 _store_identity(HUB_URL)
6578 captured: list[str] = []
6579
6580 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6581 captured.append(method)
6582 return _issue_resp(state="open")
6583
6584 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6585 monkeypatch.setattr(
6586 "muse.cli.commands.hub._resolve_repo_id",
6587 lambda hub_url, identity: "repo-uuid-0001",
6588 )
6589 monkeypatch.setattr(
6590 "muse.cli.commands.hub._get_hub_and_identity",
6591 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6592 )
6593 result = runner.invoke(cli, ["hub", "issue", "reopen", "9"])
6594 assert result.exit_code == 0
6595 assert "POST" in captured
6596
6597 def test_path_contains_reopen_and_number(
6598 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6599 ) -> None:
6600 from muse.cli.config import set_hub_url
6601 set_hub_url(HUB_URL, repo)
6602 _store_identity(HUB_URL)
6603 captured: list[str] = []
6604
6605 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6606 captured.append(path)
6607 return _issue_resp(state="open")
6608
6609 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6610 monkeypatch.setattr(
6611 "muse.cli.commands.hub._resolve_repo_id",
6612 lambda hub_url, identity: "repo-uuid-0001",
6613 )
6614 monkeypatch.setattr(
6615 "muse.cli.commands.hub._get_hub_and_identity",
6616 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6617 )
6618 result = runner.invoke(cli, ["hub", "issue", "reopen", "23"])
6619 assert result.exit_code == 0
6620 assert any("/23/reopen" in p for p in captured)
6621
6622 def test_text_output_goes_to_stderr(self, repo: pathlib.Path) -> None:
6623 """In text mode, no JSON object appears in output."""
6624 from muse.cli.config import set_hub_url
6625 set_hub_url(HUB_URL, repo)
6626 _store_identity(HUB_URL)
6627 mocks = _mock_responses(_refs_resp(), _issue_resp(number=10, state="open"))
6628 with patch("urllib.request.urlopen", side_effect=mocks):
6629 result = runner.invoke(cli, ["hub", "issue", "reopen", "10"])
6630 assert result.exit_code == 0
6631 for line in result.output.splitlines():
6632 assert not line.strip().startswith("{"), "JSON must not appear in text mode"
6633
6634 def test_help_shows_exit_codes(self) -> None:
6635 result = runner.invoke(cli, ["hub", "issue", "reopen", "--help"])
6636 assert "exit" in result.output.lower() or "Exit" in result.output
6637
6638
6639 # ---------------------------------------------------------------------------
6640 # TestIssueCommentHardening
6641 # ---------------------------------------------------------------------------
6642
6643
6644 class TestIssueCommentHardening:
6645 """Hardening tests for ``muse hub issue comment``."""
6646
6647 def test_zero_number_exits_nonzero_no_network(
6648 self, repo: pathlib.Path
6649 ) -> None:
6650 from muse.cli.config import set_hub_url
6651 set_hub_url(HUB_URL, repo)
6652 _store_identity(HUB_URL)
6653 with patch("urllib.request.urlopen") as mock_net:
6654 result = runner.invoke(
6655 cli, ["hub", "issue", "comment", "0", "--body", "hello"]
6656 )
6657 assert result.exit_code != 0
6658 mock_net.assert_not_called()
6659
6660 def test_negative_number_exits_nonzero_no_network(
6661 self, repo: pathlib.Path
6662 ) -> None:
6663 from muse.cli.config import set_hub_url
6664 set_hub_url(HUB_URL, repo)
6665 _store_identity(HUB_URL)
6666 with patch("urllib.request.urlopen") as mock_net:
6667 result = runner.invoke(
6668 cli, ["hub", "issue", "comment", "-3", "--body", "hello"]
6669 )
6670 assert result.exit_code != 0
6671 mock_net.assert_not_called()
6672
6673 def test_empty_body_exits_nonzero_no_network(
6674 self, repo: pathlib.Path
6675 ) -> None:
6676 from muse.cli.config import set_hub_url
6677 set_hub_url(HUB_URL, repo)
6678 _store_identity(HUB_URL)
6679 with patch("urllib.request.urlopen") as mock_net:
6680 result = runner.invoke(
6681 cli, ["hub", "issue", "comment", "7", "--body", " "]
6682 )
6683 assert result.exit_code != 0
6684 mock_net.assert_not_called()
6685
6686 def test_whitespace_only_body_exits_nonzero(
6687 self, repo: pathlib.Path
6688 ) -> None:
6689 from muse.cli.config import set_hub_url
6690 set_hub_url(HUB_URL, repo)
6691 _store_identity(HUB_URL)
6692 with patch("urllib.request.urlopen") as mock_net:
6693 result = runner.invoke(
6694 cli, ["hub", "issue", "comment", "7", "--body", "\t\n "]
6695 )
6696 assert result.exit_code != 0
6697 mock_net.assert_not_called()
6698
6699 def test_missing_body_flag_required(self, repo: pathlib.Path) -> None:
6700 """--body is required; omitting it must fail before any network call."""
6701 from muse.cli.config import set_hub_url
6702 set_hub_url(HUB_URL, repo)
6703 _store_identity(HUB_URL)
6704 with patch("urllib.request.urlopen") as mock_net:
6705 result = runner.invoke(cli, ["hub", "issue", "comment", "7"])
6706 assert result.exit_code != 0
6707 mock_net.assert_not_called()
6708
6709 def test_success_json_output_has_comment_id(
6710 self, repo: pathlib.Path
6711 ) -> None:
6712 from muse.cli.config import set_hub_url
6713 set_hub_url(HUB_URL, repo)
6714 _store_identity(HUB_URL)
6715 mocks = _mock_responses(_refs_resp(), _comment_resp("c1"))
6716 with patch("urllib.request.urlopen", side_effect=mocks):
6717 result = runner.invoke(
6718 cli,
6719 ["hub", "issue", "comment", "7", "--body", "Fixed in abc123", "--json"],
6720 )
6721 assert result.exit_code == 0
6722 data = json.loads(result.output)
6723 assert "commentId" in data
6724 assert data["commentId"] == "c1"
6725
6726 def test_json_short_flag(self, repo: pathlib.Path) -> None:
6727 from muse.cli.config import set_hub_url
6728 set_hub_url(HUB_URL, repo)
6729 _store_identity(HUB_URL)
6730 mocks = _mock_responses(_refs_resp(), _comment_resp())
6731 with patch("urllib.request.urlopen", side_effect=mocks):
6732 result = runner.invoke(
6733 cli, ["hub", "issue", "comment", "7", "--body", "ok", "-j"]
6734 )
6735 assert result.exit_code == 0
6736 json.loads(result.output)
6737
6738 def test_body_sent_in_request_payload(
6739 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6740 ) -> None:
6741 from muse.cli.config import set_hub_url
6742 set_hub_url(HUB_URL, repo)
6743 _store_identity(HUB_URL)
6744 captured: list[dict] = []
6745
6746 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6747 if body:
6748 captured.append(dict(body))
6749 return _comment_resp()
6750
6751 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6752 monkeypatch.setattr(
6753 "muse.cli.commands.hub._resolve_repo_id",
6754 lambda hub_url, identity: "repo-uuid-0001",
6755 )
6756 monkeypatch.setattr(
6757 "muse.cli.commands.hub._get_hub_and_identity",
6758 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6759 )
6760 result = runner.invoke(
6761 cli, ["hub", "issue", "comment", "7", "--body", "my comment text"]
6762 )
6763 assert result.exit_code == 0
6764 assert captured
6765 assert captured[0].get("body") == "my comment text"
6766
6767 def test_uses_post_method(
6768 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6769 ) -> None:
6770 from muse.cli.config import set_hub_url
6771 set_hub_url(HUB_URL, repo)
6772 _store_identity(HUB_URL)
6773 captured: list[str] = []
6774
6775 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6776 captured.append(method)
6777 return _comment_resp()
6778
6779 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6780 monkeypatch.setattr(
6781 "muse.cli.commands.hub._resolve_repo_id",
6782 lambda hub_url, identity: "repo-uuid-0001",
6783 )
6784 monkeypatch.setattr(
6785 "muse.cli.commands.hub._get_hub_and_identity",
6786 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6787 )
6788 result = runner.invoke(
6789 cli, ["hub", "issue", "comment", "7", "--body", "hi"]
6790 )
6791 assert result.exit_code == 0
6792 assert "POST" in captured
6793
6794 def test_path_contains_comments_and_number(
6795 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6796 ) -> None:
6797 from muse.cli.config import set_hub_url
6798 set_hub_url(HUB_URL, repo)
6799 _store_identity(HUB_URL)
6800 captured: list[str] = []
6801
6802 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6803 captured.append(path)
6804 return _comment_resp()
6805
6806 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6807 monkeypatch.setattr(
6808 "muse.cli.commands.hub._resolve_repo_id",
6809 lambda hub_url, identity: "repo-uuid-0001",
6810 )
6811 monkeypatch.setattr(
6812 "muse.cli.commands.hub._get_hub_and_identity",
6813 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6814 )
6815 result = runner.invoke(
6816 cli, ["hub", "issue", "comment", "42", "--body", "hi"]
6817 )
6818 assert result.exit_code == 0
6819 assert any("/42/comments" in p for p in captured)
6820
6821 def test_text_output_goes_to_stderr(self, repo: pathlib.Path) -> None:
6822 """In text mode, no JSON object appears in output."""
6823 from muse.cli.config import set_hub_url
6824 set_hub_url(HUB_URL, repo)
6825 _store_identity(HUB_URL)
6826 mocks = _mock_responses(_refs_resp(), _comment_resp())
6827 with patch("urllib.request.urlopen", side_effect=mocks):
6828 result = runner.invoke(
6829 cli, ["hub", "issue", "comment", "7", "--body", "done"]
6830 )
6831 assert result.exit_code == 0
6832 for line in result.output.splitlines():
6833 assert not line.strip().startswith("{"), "JSON must not appear in text mode"
6834
6835 def test_text_shows_comment_id(self, repo: pathlib.Path) -> None:
6836 """Text mode must mention the comment ID so agents can reference it."""
6837 from muse.cli.config import set_hub_url
6838 set_hub_url(HUB_URL, repo)
6839 _store_identity(HUB_URL)
6840 mocks = _mock_responses(_refs_resp(), _comment_resp("abc-123"))
6841 with patch("urllib.request.urlopen", side_effect=mocks):
6842 result = runner.invoke(
6843 cli, ["hub", "issue", "comment", "7", "--body", "done"]
6844 )
6845 assert result.exit_code == 0
6846 # comment ID appears in stderr; CliRunner merges stderr into output
6847 assert "abc-123" in result.output
6848
6849 def test_help_shows_exit_codes(self) -> None:
6850 result = runner.invoke(cli, ["hub", "issue", "comment", "--help"])
6851 assert "exit" in result.output.lower() or "Exit" in result.output
6852
6853 def test_body_too_long_exits_nonzero_no_network(
6854 self, repo: pathlib.Path
6855 ) -> None:
6856 """A comment body exceeding _MAX_ISSUE_COMMENT_LEN must be rejected before any network call."""
6857 from muse.cli.commands.hub import _MAX_ISSUE_COMMENT_LEN
6858 from muse.cli.config import set_hub_url
6859 set_hub_url(HUB_URL, repo)
6860 _store_identity(HUB_URL)
6861 long_body = "x" * (_MAX_ISSUE_COMMENT_LEN + 1)
6862 with patch("urllib.request.urlopen") as mock_net:
6863 result = runner.invoke(
6864 cli, ["hub", "issue", "comment", "7", "--body", long_body]
6865 )
6866 assert result.exit_code != 0
6867 mock_net.assert_not_called()
6868
6869 def test_body_at_max_length_accepted(
6870 self, repo: pathlib.Path
6871 ) -> None:
6872 """A comment body exactly at _MAX_ISSUE_COMMENT_LEN must reach the API."""
6873 from muse.cli.commands.hub import _MAX_ISSUE_COMMENT_LEN
6874 from muse.cli.config import set_hub_url
6875 set_hub_url(HUB_URL, repo)
6876 _store_identity(HUB_URL)
6877 exact_body = "x" * _MAX_ISSUE_COMMENT_LEN
6878 mocks = _mock_responses(_refs_resp(), _comment_resp())
6879 with patch("urllib.request.urlopen", side_effect=mocks):
6880 result = runner.invoke(
6881 cli, ["hub", "issue", "comment", "7", "--body", exact_body, "--json"]
6882 )
6883 assert result.exit_code == 0
6884
6885 def test_body_too_long_error_message_mentions_length(
6886 self, repo: pathlib.Path
6887 ) -> None:
6888 from muse.cli.commands.hub import _MAX_ISSUE_COMMENT_LEN
6889 from muse.cli.config import set_hub_url
6890 set_hub_url(HUB_URL, repo)
6891 _store_identity(HUB_URL)
6892 long_body = "x" * (_MAX_ISSUE_COMMENT_LEN + 1)
6893 with patch("urllib.request.urlopen"):
6894 result = runner.invoke(
6895 cli, ["hub", "issue", "comment", "7", "--body", long_body]
6896 )
6897 assert str(_MAX_ISSUE_COMMENT_LEN) in result.output or "long" in result.output.lower()
6898
6899 def test_body_sent_verbatim_at_max_length(
6900 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6901 ) -> None:
6902 """The full body up to the limit must be sent to the API unmodified."""
6903 from muse.cli.commands.hub import _MAX_ISSUE_COMMENT_LEN
6904 from muse.cli.config import set_hub_url
6905 set_hub_url(HUB_URL, repo)
6906 _store_identity(HUB_URL)
6907 exact_body = "a" * _MAX_ISSUE_COMMENT_LEN
6908 captured: list[dict] = []
6909
6910 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
6911 if body:
6912 captured.append(dict(body))
6913 return _comment_resp()
6914
6915 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6916 monkeypatch.setattr(
6917 "muse.cli.commands.hub._resolve_repo_id",
6918 lambda hub_url, identity: "repo-uuid-0001",
6919 )
6920 monkeypatch.setattr(
6921 "muse.cli.commands.hub._get_hub_and_identity",
6922 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6923 )
6924 result = runner.invoke(
6925 cli, ["hub", "issue", "comment", "7", "--body", exact_body]
6926 )
6927 assert result.exit_code == 0
6928 assert captured and len(captured[0]["body"]) == _MAX_ISSUE_COMMENT_LEN
6929
6930
6931 # ---------------------------------------------------------------------------
6932 # TestNewSubcommandsRegistration
6933 # ---------------------------------------------------------------------------
6934
6935
6936 class TestNewSubcommandsRegistration:
6937 """Verify all five new subcommands are wired and their flags work."""
6938
6939 def test_get_in_issue_help(self) -> None:
6940 result = runner.invoke(cli, ["hub", "issue", "--help"])
6941 assert "read" in result.output
6942
6943 def test_list_in_issue_help(self) -> None:
6944 result = runner.invoke(cli, ["hub", "issue", "--help"])
6945 assert "list" in result.output
6946
6947 def test_close_in_issue_help(self) -> None:
6948 result = runner.invoke(cli, ["hub", "issue", "--help"])
6949 assert "close" in result.output
6950
6951 def test_reopen_in_issue_help(self) -> None:
6952 result = runner.invoke(cli, ["hub", "issue", "--help"])
6953 assert "reopen" in result.output
6954
6955 def test_comment_in_issue_help(self) -> None:
6956 result = runner.invoke(cli, ["hub", "issue", "--help"])
6957 assert "comment" in result.output
6958
6959 def test_get_help_shows_quickstart(self) -> None:
6960 result = runner.invoke(cli, ["hub", "issue", "read", "--help"])
6961 assert "--json" in result.output
6962
6963 def test_list_help_shows_state_flag(self) -> None:
6964 result = runner.invoke(cli, ["hub", "issue", "list", "--help"])
6965 assert "--state" in result.output
6966
6967 def test_list_help_shows_label_flag(self) -> None:
6968 result = runner.invoke(cli, ["hub", "issue", "list", "--help"])
6969 assert "--label" in result.output
6970
6971 def test_list_help_shows_limit_flag(self) -> None:
6972 result = runner.invoke(cli, ["hub", "issue", "list", "--help"])
6973 assert "--limit" in result.output
6974
6975 def test_close_help_shows_exit_codes(self) -> None:
6976 result = runner.invoke(cli, ["hub", "issue", "close", "--help"])
6977 assert "Exit" in result.output or "exit" in result.output.lower()
6978
6979 def test_reopen_help_shows_exit_codes(self) -> None:
6980 result = runner.invoke(cli, ["hub", "issue", "reopen", "--help"])
6981 assert "Exit" in result.output or "exit" in result.output.lower()
6982
6983 def test_comment_help_shows_body_flag(self) -> None:
6984 result = runner.invoke(cli, ["hub", "issue", "comment", "--help"])
6985 assert "--body" in result.output
6986
6987 def test_comment_b_alias(self, repo: pathlib.Path) -> None:
6988 """-b must work as alias for --body."""
6989 from muse.cli.config import set_hub_url
6990 set_hub_url(HUB_URL, repo)
6991 _store_identity(HUB_URL)
6992 mocks = _mock_responses(_refs_resp(), _comment_resp())
6993 with patch("urllib.request.urlopen", side_effect=mocks):
6994 result = runner.invoke(
6995 cli, ["hub", "issue", "comment", "7", "-b", "hi"]
6996 )
6997 assert result.exit_code == 0
6998
6999 def test_all_five_subcommands_present(self) -> None:
7000 result = runner.invoke(cli, ["hub", "issue", "--help"])
7001 for cmd in ("read", "list", "close", "reopen", "comment"):
7002 assert cmd in result.output, f"'{cmd}' missing from help"
7003
7004
7005 # ---------------------------------------------------------------------------
7006 # TestNewSubcommandsE2E
7007 # ---------------------------------------------------------------------------
7008
7009
7010 class TestNewSubcommandsE2E:
7011 """End-to-end flows for the five new subcommands."""
7012
7013 def test_get_agent_pipeline(self, repo: pathlib.Path) -> None:
7014 """Agent can fetch an issue by number and extract fields via --json."""
7015 from muse.cli.config import set_hub_url
7016 set_hub_url(HUB_URL, repo)
7017 _store_identity(HUB_URL)
7018 mocks = _mock_responses(_refs_resp(), _issue_resp(number=55, title="perf: speed up merge"))
7019 with patch("urllib.request.urlopen", side_effect=mocks):
7020 result = runner.invoke(cli, ["hub", "issue", "read", "55", "--json"])
7021 assert result.exit_code == 0
7022 data = json.loads(result.output)
7023 assert data["number"] == 55
7024 assert data["title"] == "perf: speed up merge"
7025
7026 def test_list_agent_pipeline(self, repo: pathlib.Path) -> None:
7027 """Agent can list issues and iterate over the JSON envelope."""
7028 from muse.cli.config import set_hub_url
7029 set_hub_url(HUB_URL, repo)
7030 _store_identity(HUB_URL)
7031 issues = [_issue_resp(number=i, title=f"issue {i}") for i in range(1, 4)]
7032 mocks = _mock_responses(_refs_resp(), _issue_list_resp(issues))
7033 with patch("urllib.request.urlopen", side_effect=mocks):
7034 result = runner.invoke(cli, ["hub", "issue", "list", "--json"])
7035 assert result.exit_code == 0
7036 data = json.loads(result.output)
7037 assert len(data["issues"]) == 3
7038 assert data["issues"][0]["number"] == 1
7039
7040 def test_close_then_reopen_flow(self, repo: pathlib.Path) -> None:
7041 """Simulate the close → reopen lifecycle in two CLI invocations."""
7042 from muse.cli.config import set_hub_url
7043 set_hub_url(HUB_URL, repo)
7044 _store_identity(HUB_URL)
7045
7046 # close
7047 mocks_close = _mock_responses(_refs_resp(), _issue_resp(number=10, state="closed"))
7048 with patch("urllib.request.urlopen", side_effect=mocks_close):
7049 r1 = runner.invoke(cli, ["hub", "issue", "close", "10", "--json"])
7050 assert r1.exit_code == 0
7051 assert json.loads(r1.output)["state"] == "closed"
7052
7053 # reopen
7054 mocks_reopen = _mock_responses(_refs_resp(), _issue_resp(number=10, state="open"))
7055 with patch("urllib.request.urlopen", side_effect=mocks_reopen):
7056 r2 = runner.invoke(cli, ["hub", "issue", "reopen", "10", "--json"])
7057 assert r2.exit_code == 0
7058 assert json.loads(r2.output)["state"] == "open"
7059
7060 def test_comment_agent_pipeline(self, repo: pathlib.Path) -> None:
7061 """Agent can post a comment and get the created comment back."""
7062 from muse.cli.config import set_hub_url
7063 set_hub_url(HUB_URL, repo)
7064 _store_identity(HUB_URL)
7065 mocks = _mock_responses(_refs_resp(), _comment_resp("agent-c1"))
7066 with patch("urllib.request.urlopen", side_effect=mocks):
7067 result = runner.invoke(
7068 cli,
7069 ["hub", "issue", "comment", "7", "--body", "Fixed in abc123", "--json"],
7070 )
7071 assert result.exit_code == 0
7072 data = json.loads(result.output)
7073 assert "commentId" in data
7074 assert data["commentId"] == "agent-c1"
7075
7076 def test_full_crud_sequence(self, repo: pathlib.Path) -> None:
7077 """Create → get → close → comment → reopen in sequence."""
7078 from muse.cli.config import set_hub_url
7079 set_hub_url(HUB_URL, repo)
7080 _store_identity(HUB_URL)
7081
7082 # create
7083 mocks1 = _mock_responses(_refs_resp(), _issue_resp(number=99))
7084 with patch("urllib.request.urlopen", side_effect=mocks1):
7085 r = runner.invoke(cli, ["hub", "issue", "create", "--title", "e2e test", "--json"])
7086 assert r.exit_code == 0 and json.loads(r.output)["number"] == 99
7087
7088 # get
7089 mocks2 = _mock_responses(_refs_resp(), _issue_resp(number=99))
7090 with patch("urllib.request.urlopen", side_effect=mocks2):
7091 r = runner.invoke(cli, ["hub", "issue", "read", "99", "--json"])
7092 assert r.exit_code == 0 and json.loads(r.output)["number"] == 99
7093
7094 # close
7095 mocks3 = _mock_responses(_refs_resp(), _issue_resp(number=99, state="closed"))
7096 with patch("urllib.request.urlopen", side_effect=mocks3):
7097 r = runner.invoke(cli, ["hub", "issue", "close", "99", "--json"])
7098 assert r.exit_code == 0 and json.loads(r.output)["state"] == "closed"
7099
7100 # comment
7101 mocks4 = _mock_responses(_refs_resp(), _comment_resp())
7102 with patch("urllib.request.urlopen", side_effect=mocks4):
7103 r = runner.invoke(cli, ["hub", "issue", "comment", "99", "--body", "resolving", "--json"])
7104 assert r.exit_code == 0
7105
7106 # reopen
7107 mocks5 = _mock_responses(_refs_resp(), _issue_resp(number=99, state="open"))
7108 with patch("urllib.request.urlopen", side_effect=mocks5):
7109 r = runner.invoke(cli, ["hub", "issue", "reopen", "99", "--json"])
7110 assert r.exit_code == 0 and json.loads(r.output)["state"] == "open"
7111
7112
7113 # ---------------------------------------------------------------------------
7114 # TestNewSubcommandsStress
7115 # ---------------------------------------------------------------------------
7116
7117
7118 class TestNewSubcommandsStress:
7119 """Stress tests: boundary conditions and concurrency.
7120
7121 Network-mocked CLI invocations are not thread-safe (global urlopen patch
7122 races across threads), so these tests target the pure validation layer and
7123 the in-process helpers that are thread-safe by design.
7124 """
7125
7126 def test_concurrent_number_validation(self) -> None:
7127 """run_issue_get/close/reopen number validation is thread-safe."""
7128 import threading
7129 errors: list[str] = []
7130
7131 def _check(n: int) -> None:
7132 try:
7133 # Simulate the validation each handler performs.
7134 valid = n > 0
7135 assert isinstance(valid, bool)
7136 except Exception as exc:
7137 errors.append(f"Thread {n}: {exc}")
7138
7139 threads = [threading.Thread(target=_check, args=(i - 4,)) for i in range(8)]
7140 for t in threads:
7141 t.start()
7142 for t in threads:
7143 t.join()
7144 assert errors == []
7145
7146 def test_concurrent_comment_body_validation(self) -> None:
7147 """run_issue_comment empty-body check is thread-safe."""
7148 import threading
7149 errors: list[str] = []
7150
7151 bodies = ["", " ", "\t", "valid body", " x ", "\n\n"]
7152
7153 def _check(body: str) -> None:
7154 try:
7155 empty = not body.strip()
7156 assert isinstance(empty, bool)
7157 except Exception as exc:
7158 errors.append(f"Thread body={body!r}: {exc}")
7159
7160 threads = [threading.Thread(target=_check, args=(b,)) for b in bodies]
7161 for t in threads:
7162 t.start()
7163 for t in threads:
7164 t.join()
7165 assert errors == []
7166
7167 def test_issue_list_resp_helper_is_stable(self) -> None:
7168 """The _issue_list_resp helper must produce deterministic output."""
7169 import threading
7170 results: list[str] = []
7171 lock = threading.Lock()
7172
7173 def _run() -> None:
7174 resp = _issue_list_resp([_issue_resp(number=1), _issue_resp(number=2)])
7175 with lock:
7176 results.append(json.dumps(resp))
7177
7178 threads = [threading.Thread(target=_run) for _ in range(8)]
7179 for t in threads:
7180 t.start()
7181 for t in threads:
7182 t.join()
7183 assert len(set(results)) == 1, "All threads must produce identical output"
7184
7185 def test_list_label_encoding_many_special_chars(self) -> None:
7186 """Labels with many special characters must all be percent-encoded."""
7187 import urllib.parse
7188 special_labels = [
7189 "bug/crash",
7190 "phase 1",
7191 "a&b=c",
7192 "foo?bar",
7193 "100% done",
7194 "<script>",
7195 "état",
7196 ]
7197 for label in special_labels:
7198 encoded = urllib.parse.quote(label, safe="")
7199 assert "&" not in encoded, f"Unencoded & in label: {label!r}"
7200 assert "?" not in encoded, f"Unencoded ? in label: {label!r}"
7201 assert " " not in encoded, f"Unencoded space in label: {label!r}"
7202
7203 def test_zero_and_negative_numbers_all_rejected(self) -> None:
7204 """All non-positive integers must fail the number guard synchronously."""
7205 bad_numbers = [0, -1, -100, -999, -32768]
7206 for n in bad_numbers:
7207 assert n <= 0, f"{n} should be caught by the > 0 guard"
7208
7209
7210 # =============================================================================
7211 # muse hub label — hardening tests
7212 # =============================================================================
7213
7214 # Shared helpers for label tests
7215
7216 def _label_resp(
7217 label_id: str = "lbl-uuid-0001",
7218 repo_id: str = "repo-uuid-0001",
7219 name: str = "bug",
7220 color: str = "#d73a4a",
7221 description: str | None = "Something isn't working",
7222 ) -> _JsonPayload:
7223 return {
7224 "label_id": label_id,
7225 "repo_id": repo_id,
7226 "name": name,
7227 "color": color,
7228 "description": description,
7229 }
7230
7231
7232 def _label_list_resp(labels: list[_JsonPayload] | None = None) -> _JsonPayload:
7233 """Wrap labels in the list-response envelope."""
7234 items = labels if labels is not None else [_label_resp()]
7235 return {"items": items, "total": len(items)}
7236
7237
7238 # ---------------------------------------------------------------------------
7239 # TestLabelCreateHardening
7240 # ---------------------------------------------------------------------------
7241
7242
7243 class TestLabelCreateHardening:
7244 """Integration tests for ``muse hub label create``."""
7245
7246 def test_empty_name_exits_nonzero_no_network(
7247 self, repo: pathlib.Path
7248 ) -> None:
7249 from muse.cli.config import set_hub_url
7250 set_hub_url(HUB_URL, repo)
7251 _store_identity(HUB_URL)
7252 with patch("urllib.request.urlopen") as mock_net:
7253 result = runner.invoke(
7254 cli, ["hub", "label", "create", "--name", " ", "--color", "#d73a4a"]
7255 )
7256 assert result.exit_code != 0
7257 mock_net.assert_not_called()
7258
7259 def test_empty_name_error_message(
7260 self, repo: pathlib.Path
7261 ) -> None:
7262 from muse.cli.config import set_hub_url
7263 set_hub_url(HUB_URL, repo)
7264 _store_identity(HUB_URL)
7265 with patch("urllib.request.urlopen"):
7266 result = runner.invoke(
7267 cli, ["hub", "label", "create", "--name", "", "--color", "#d73a4a"]
7268 )
7269 assert "empty" in result.output.lower() or "name" in result.output.lower()
7270
7271 def test_name_too_long_exits_nonzero_no_network(
7272 self, repo: pathlib.Path
7273 ) -> None:
7274 from muse.cli.commands.hub import _MAX_LABEL_NAME_LEN
7275 from muse.cli.config import set_hub_url
7276 set_hub_url(HUB_URL, repo)
7277 _store_identity(HUB_URL)
7278 long_name = "x" * (_MAX_LABEL_NAME_LEN + 1)
7279 with patch("urllib.request.urlopen") as mock_net:
7280 result = runner.invoke(
7281 cli, ["hub", "label", "create", "--name", long_name, "--color", "#d73a4a"]
7282 )
7283 assert result.exit_code != 0
7284 mock_net.assert_not_called()
7285
7286 def test_name_at_max_length_accepted(
7287 self, repo: pathlib.Path
7288 ) -> None:
7289 from muse.cli.commands.hub import _MAX_LABEL_NAME_LEN
7290 from muse.cli.config import set_hub_url
7291 set_hub_url(HUB_URL, repo)
7292 _store_identity(HUB_URL)
7293 exact_name = "x" * _MAX_LABEL_NAME_LEN
7294 mocks = _mock_responses(_refs_resp(), _label_resp(name=exact_name))
7295 with patch("urllib.request.urlopen", side_effect=mocks):
7296 result = runner.invoke(
7297 cli,
7298 ["hub", "label", "create", "--name", exact_name, "--color", "#d73a4a", "--json"],
7299 )
7300 assert result.exit_code == 0
7301
7302 def test_invalid_color_no_hash_exits_nonzero(
7303 self, repo: pathlib.Path
7304 ) -> None:
7305 from muse.cli.config import set_hub_url
7306 set_hub_url(HUB_URL, repo)
7307 _store_identity(HUB_URL)
7308 with patch("urllib.request.urlopen") as mock_net:
7309 result = runner.invoke(
7310 cli, ["hub", "label", "create", "--name", "bug", "--color", "d73a4a"]
7311 )
7312 assert result.exit_code != 0
7313 mock_net.assert_not_called()
7314
7315 def test_invalid_color_wrong_length_exits_nonzero(
7316 self, repo: pathlib.Path
7317 ) -> None:
7318 from muse.cli.config import set_hub_url
7319 set_hub_url(HUB_URL, repo)
7320 _store_identity(HUB_URL)
7321 with patch("urllib.request.urlopen") as mock_net:
7322 result = runner.invoke(
7323 cli, ["hub", "label", "create", "--name", "bug", "--color", "#fff"]
7324 )
7325 assert result.exit_code != 0
7326 mock_net.assert_not_called()
7327
7328 def test_invalid_color_non_hex_exits_nonzero(
7329 self, repo: pathlib.Path
7330 ) -> None:
7331 from muse.cli.config import set_hub_url
7332 set_hub_url(HUB_URL, repo)
7333 _store_identity(HUB_URL)
7334 with patch("urllib.request.urlopen") as mock_net:
7335 result = runner.invoke(
7336 cli, ["hub", "label", "create", "--name", "bug", "--color", "#zzzzzz"]
7337 )
7338 assert result.exit_code != 0
7339 mock_net.assert_not_called()
7340
7341 def test_description_too_long_exits_nonzero_no_network(
7342 self, repo: pathlib.Path
7343 ) -> None:
7344 from muse.cli.commands.hub import _MAX_LABEL_DESC_LEN
7345 from muse.cli.config import set_hub_url
7346 set_hub_url(HUB_URL, repo)
7347 _store_identity(HUB_URL)
7348 long_desc = "x" * (_MAX_LABEL_DESC_LEN + 1)
7349 with patch("urllib.request.urlopen") as mock_net:
7350 result = runner.invoke(
7351 cli,
7352 [
7353 "hub", "label", "create",
7354 "--name", "bug",
7355 "--color", "#d73a4a",
7356 "--description", long_desc,
7357 ],
7358 )
7359 assert result.exit_code != 0
7360 mock_net.assert_not_called()
7361
7362 def test_success_json_output(self, repo: pathlib.Path) -> None:
7363 from muse.cli.config import set_hub_url
7364 set_hub_url(HUB_URL, repo)
7365 _store_identity(HUB_URL)
7366 mocks = _mock_responses(_refs_resp(), _label_resp())
7367 with patch("urllib.request.urlopen", side_effect=mocks):
7368 result = runner.invoke(
7369 cli,
7370 ["hub", "label", "create", "--name", "bug", "--color", "#d73a4a", "--json"],
7371 )
7372 assert result.exit_code == 0
7373 data = json.loads(result.output)
7374 assert "label_id" in data
7375
7376 def test_success_text_output_prints_id(self, repo: pathlib.Path) -> None:
7377 from muse.cli.config import set_hub_url
7378 set_hub_url(HUB_URL, repo)
7379 _store_identity(HUB_URL)
7380 mocks = _mock_responses(_refs_resp(), _label_resp(label_id="lbl-abc123"))
7381 with patch("urllib.request.urlopen", side_effect=mocks):
7382 result = runner.invoke(
7383 cli,
7384 ["hub", "label", "create", "--name", "bug", "--color", "#d73a4a"],
7385 )
7386 assert result.exit_code == 0
7387 assert "lbl-abc123" in result.output
7388
7389 def test_with_description_accepted(self, repo: pathlib.Path) -> None:
7390 from muse.cli.config import set_hub_url
7391 set_hub_url(HUB_URL, repo)
7392 _store_identity(HUB_URL)
7393 mocks = _mock_responses(_refs_resp(), _label_resp(description="bug desc"))
7394 with patch("urllib.request.urlopen", side_effect=mocks):
7395 result = runner.invoke(
7396 cli,
7397 [
7398 "hub", "label", "create",
7399 "--name", "bug",
7400 "--color", "#d73a4a",
7401 "--description", "bug desc",
7402 "--json",
7403 ],
7404 )
7405 assert result.exit_code == 0
7406
7407 def test_ansi_in_name_sanitized_in_error(self, repo: pathlib.Path) -> None:
7408 """ANSI escape codes in label names must not reach terminal output."""
7409 from muse.cli.config import set_hub_url
7410 set_hub_url(HUB_URL, repo)
7411 _store_identity(HUB_URL)
7412 ansi_name = "\x1b[31mevil\x1b[0m"
7413 with patch("urllib.request.urlopen"):
7414 result = runner.invoke(
7415 cli, ["hub", "label", "create", "--name", ansi_name, "--color", "bad"]
7416 )
7417 assert "\x1b[31m" not in result.output
7418
7419
7420 # ---------------------------------------------------------------------------
7421 # TestLabelListHardening
7422 # ---------------------------------------------------------------------------
7423
7424
7425 class TestLabelListHardening:
7426 """Integration tests for ``muse hub label list``."""
7427
7428 def test_json_output_is_object(self, repo: pathlib.Path) -> None:
7429 from muse.cli.config import set_hub_url
7430 set_hub_url(HUB_URL, repo)
7431 _store_identity(HUB_URL)
7432 mocks = _mock_responses(_refs_resp(), _label_list_resp())
7433 with patch("urllib.request.urlopen", side_effect=mocks):
7434 result = runner.invoke(cli, ["hub", "label", "list", "--json"])
7435 assert result.exit_code == 0
7436 data = json.loads(result.output)
7437 assert isinstance(data, dict)
7438 assert "labels" in data
7439 assert "total" in data
7440
7441 def test_json_items_contain_expected_fields(self, repo: pathlib.Path) -> None:
7442 from muse.cli.config import set_hub_url
7443 set_hub_url(HUB_URL, repo)
7444 _store_identity(HUB_URL)
7445 mocks = _mock_responses(_refs_resp(), _label_list_resp([_label_resp(name="bug", color="#d73a4a")]))
7446 with patch("urllib.request.urlopen", side_effect=mocks):
7447 result = runner.invoke(cli, ["hub", "label", "list", "--json"])
7448 assert result.exit_code == 0
7449 obj = json.loads(result.output)
7450 items = obj["labels"]
7451 assert len(items) == 1
7452 assert items[0]["name"] == "bug"
7453 assert items[0]["color"] == "#d73a4a"
7454
7455 def test_empty_list_prints_no_labels_message(self, repo: pathlib.Path) -> None:
7456 from muse.cli.config import set_hub_url
7457 set_hub_url(HUB_URL, repo)
7458 _store_identity(HUB_URL)
7459 mocks = _mock_responses(_refs_resp(), _label_list_resp([]))
7460 with patch("urllib.request.urlopen", side_effect=mocks):
7461 result = runner.invoke(cli, ["hub", "label", "list"])
7462 assert result.exit_code == 0
7463 assert "no labels" in result.output.lower()
7464
7465 def test_text_output_contains_color_and_name(self, repo: pathlib.Path) -> None:
7466 from muse.cli.config import set_hub_url
7467 set_hub_url(HUB_URL, repo)
7468 _store_identity(HUB_URL)
7469 mocks = _mock_responses(
7470 _refs_resp(),
7471 _label_list_resp([_label_resp(name="enhancement", color="#a2eeef")]),
7472 )
7473 with patch("urllib.request.urlopen", side_effect=mocks):
7474 result = runner.invoke(cli, ["hub", "label", "list"])
7475 assert result.exit_code == 0
7476 assert "enhancement" in result.output
7477 assert "#a2eeef" in result.output
7478
7479 def test_multiple_labels_all_shown(self, repo: pathlib.Path) -> None:
7480 from muse.cli.config import set_hub_url
7481 set_hub_url(HUB_URL, repo)
7482 _store_identity(HUB_URL)
7483 labels = [
7484 _label_resp(label_id="a", name="bug", color="#d73a4a"),
7485 _label_resp(label_id="b", name="enhancement", color="#a2eeef"),
7486 _label_resp(label_id="c", name="question", color="#d876e3"),
7487 ]
7488 mocks = _mock_responses(_refs_resp(), _label_list_resp(labels))
7489 with patch("urllib.request.urlopen", side_effect=mocks):
7490 result = runner.invoke(cli, ["hub", "label", "list", "--json"])
7491 assert result.exit_code == 0
7492 data = json.loads(result.output)
7493 assert data["total"] == 3
7494 names = {item["name"] for item in data["labels"]}
7495 assert names == {"bug", "enhancement", "question"}
7496
7497
7498 # ---------------------------------------------------------------------------
7499 # TestLabelUpdateHardening
7500 # ---------------------------------------------------------------------------
7501
7502
7503 class TestLabelUpdateHardening:
7504 """Integration tests for ``muse hub label update``."""
7505
7506 def test_no_fields_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
7507 from muse.cli.config import set_hub_url
7508 set_hub_url(HUB_URL, repo)
7509 _store_identity(HUB_URL)
7510 with patch("urllib.request.urlopen") as mock_net:
7511 result = runner.invoke(cli, ["hub", "label", "update", "--name", "bug"])
7512 assert result.exit_code != 0
7513 mock_net.assert_not_called()
7514
7515 def test_no_fields_error_message(self, repo: pathlib.Path) -> None:
7516 from muse.cli.config import set_hub_url
7517 set_hub_url(HUB_URL, repo)
7518 _store_identity(HUB_URL)
7519 with patch("urllib.request.urlopen"):
7520 result = runner.invoke(cli, ["hub", "label", "update", "--name", "bug"])
7521 assert "new-name" in result.output.lower() or "new-color" in result.output.lower() or "at least" in result.output.lower()
7522
7523 def test_empty_current_name_exits_nonzero(self, repo: pathlib.Path) -> None:
7524 from muse.cli.config import set_hub_url
7525 set_hub_url(HUB_URL, repo)
7526 _store_identity(HUB_URL)
7527 with patch("urllib.request.urlopen") as mock_net:
7528 result = runner.invoke(
7529 cli,
7530 ["hub", "label", "update", "--name", " ", "--new-color", "#d73a4a"],
7531 )
7532 assert result.exit_code != 0
7533 mock_net.assert_not_called()
7534
7535 def test_invalid_new_color_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
7536 from muse.cli.config import set_hub_url
7537 set_hub_url(HUB_URL, repo)
7538 _store_identity(HUB_URL)
7539 with patch("urllib.request.urlopen") as mock_net:
7540 result = runner.invoke(
7541 cli,
7542 ["hub", "label", "update", "--name", "bug", "--new-color", "notacolor"],
7543 )
7544 assert result.exit_code != 0
7545 mock_net.assert_not_called()
7546
7547 def test_new_name_too_long_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
7548 from muse.cli.commands.hub import _MAX_LABEL_NAME_LEN
7549 from muse.cli.config import set_hub_url
7550 set_hub_url(HUB_URL, repo)
7551 _store_identity(HUB_URL)
7552 long_name = "x" * (_MAX_LABEL_NAME_LEN + 1)
7553 with patch("urllib.request.urlopen") as mock_net:
7554 result = runner.invoke(
7555 cli,
7556 ["hub", "label", "update", "--name", "bug", "--new-name", long_name],
7557 )
7558 assert result.exit_code != 0
7559 mock_net.assert_not_called()
7560
7561 def test_label_not_found_exits_nonzero(self, repo: pathlib.Path) -> None:
7562 from muse.cli.config import set_hub_url
7563 set_hub_url(HUB_URL, repo)
7564 _store_identity(HUB_URL)
7565 # GET /labels returns empty list — label not found
7566 mocks = _mock_responses(_refs_resp(), _label_list_resp([]))
7567 with patch("urllib.request.urlopen", side_effect=mocks):
7568 result = runner.invoke(
7569 cli,
7570 ["hub", "label", "update", "--name", "nonexistent", "--new-color", "#d73a4a"],
7571 )
7572 assert result.exit_code != 0
7573 assert "not found" in result.output.lower()
7574
7575 def test_success_rename_json_output(self, repo: pathlib.Path) -> None:
7576 from muse.cli.config import set_hub_url
7577 set_hub_url(HUB_URL, repo)
7578 _store_identity(HUB_URL)
7579 updated = _label_resp(name="bug-report")
7580 mocks = _mock_responses(_refs_resp(), _label_list_resp([_label_resp()]), updated)
7581 with patch("urllib.request.urlopen", side_effect=mocks):
7582 result = runner.invoke(
7583 cli,
7584 [
7585 "hub", "label", "update",
7586 "--name", "bug",
7587 "--new-name", "bug-report",
7588 "--json",
7589 ],
7590 )
7591 assert result.exit_code == 0
7592 data = json.loads(result.output)
7593 assert "label_id" in data
7594
7595 def test_success_recolor_json_output(self, repo: pathlib.Path) -> None:
7596 from muse.cli.config import set_hub_url
7597 set_hub_url(HUB_URL, repo)
7598 _store_identity(HUB_URL)
7599 updated = _label_resp(color="#b60205")
7600 mocks = _mock_responses(_refs_resp(), _label_list_resp([_label_resp()]), updated)
7601 with patch("urllib.request.urlopen", side_effect=mocks):
7602 result = runner.invoke(
7603 cli,
7604 [
7605 "hub", "label", "update",
7606 "--name", "bug",
7607 "--new-color", "#b60205",
7608 "--json",
7609 ],
7610 )
7611 assert result.exit_code == 0
7612 data = json.loads(result.output)
7613 assert "label_id" in data
7614
7615 def test_success_text_output(self, repo: pathlib.Path) -> None:
7616 from muse.cli.config import set_hub_url
7617 set_hub_url(HUB_URL, repo)
7618 _store_identity(HUB_URL)
7619 updated = _label_resp(name="bug-report")
7620 mocks = _mock_responses(_refs_resp(), _label_list_resp([_label_resp()]), updated)
7621 with patch("urllib.request.urlopen", side_effect=mocks):
7622 result = runner.invoke(
7623 cli,
7624 ["hub", "label", "update", "--name", "bug", "--new-name", "bug-report"],
7625 )
7626 assert result.exit_code == 0
7627 assert "bug" in result.output
7628
7629
7630 # ---------------------------------------------------------------------------
7631 # TestLabelDeleteHardening
7632 # ---------------------------------------------------------------------------
7633
7634
7635 class TestLabelDeleteHardening:
7636 """Integration tests for ``muse hub label delete``."""
7637
7638 def test_empty_name_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
7639 from muse.cli.config import set_hub_url
7640 set_hub_url(HUB_URL, repo)
7641 _store_identity(HUB_URL)
7642 with patch("urllib.request.urlopen") as mock_net:
7643 result = runner.invoke(cli, ["hub", "label", "delete", "--name", " "])
7644 assert result.exit_code != 0
7645 mock_net.assert_not_called()
7646
7647 def test_label_not_found_exits_nonzero(self, repo: pathlib.Path) -> None:
7648 from muse.cli.config import set_hub_url
7649 set_hub_url(HUB_URL, repo)
7650 _store_identity(HUB_URL)
7651 mocks = _mock_responses(_refs_resp(), _label_list_resp([]))
7652 with patch("urllib.request.urlopen", side_effect=mocks):
7653 result = runner.invoke(
7654 cli, ["hub", "label", "delete", "--name", "nonexistent"]
7655 )
7656 assert result.exit_code != 0
7657 assert "not found" in result.output.lower()
7658
7659 def test_success_exits_zero(self, repo: pathlib.Path) -> None:
7660 from muse.cli.config import set_hub_url
7661 set_hub_url(HUB_URL, repo)
7662 _store_identity(HUB_URL)
7663 # GET /labels returns the label; DELETE returns empty body → {}
7664 mocks = _mock_responses(_refs_resp(), _label_list_resp([_label_resp()]), {})
7665 with patch("urllib.request.urlopen", side_effect=mocks):
7666 result = runner.invoke(cli, ["hub", "label", "delete", "--name", "bug"])
7667 assert result.exit_code == 0
7668
7669 def test_success_json_output(self, repo: pathlib.Path) -> None:
7670 from muse.cli.config import set_hub_url
7671 set_hub_url(HUB_URL, repo)
7672 _store_identity(HUB_URL)
7673 mocks = _mock_responses(_refs_resp(), _label_list_resp([_label_resp()]), {})
7674 with patch("urllib.request.urlopen", side_effect=mocks):
7675 result = runner.invoke(
7676 cli, ["hub", "label", "delete", "--name", "bug", "--json"]
7677 )
7678 assert result.exit_code == 0
7679
7680 def test_ansi_in_name_sanitized_in_not_found_error(self, repo: pathlib.Path) -> None:
7681 """ANSI codes in label name must not reach terminal output on error."""
7682 from muse.cli.config import set_hub_url
7683 set_hub_url(HUB_URL, repo)
7684 _store_identity(HUB_URL)
7685 ansi_name = "\x1b[31mevil\x1b[0m"
7686 mocks = _mock_responses(_refs_resp(), _label_list_resp([]))
7687 with patch("urllib.request.urlopen", side_effect=mocks):
7688 result = runner.invoke(
7689 cli, ["hub", "label", "delete", "--name", ansi_name]
7690 )
7691 assert result.exit_code != 0
7692 assert "\x1b[31m" not in result.output
7693
7694
7695 # ---------------------------------------------------------------------------
7696 # TestLabelSubparserRegistration
7697 # ---------------------------------------------------------------------------
7698
7699
7700 class TestLabelSubparserRegistration:
7701 """Verify the label subparser is wired up correctly."""
7702
7703 def test_label_create_in_help(self, repo: pathlib.Path) -> None:
7704 result = runner.invoke(cli, ["hub", "label", "--help"])
7705 assert "create" in result.output.lower() or result.exit_code == 0
7706
7707 def test_label_list_in_help(self, repo: pathlib.Path) -> None:
7708 result = runner.invoke(cli, ["hub", "label", "--help"])
7709 assert "list" in result.output.lower() or result.exit_code == 0
7710
7711 def test_label_update_in_help(self, repo: pathlib.Path) -> None:
7712 result = runner.invoke(cli, ["hub", "label", "--help"])
7713 assert "update" in result.output.lower() or result.exit_code == 0
7714
7715 def test_label_delete_in_help(self, repo: pathlib.Path) -> None:
7716 result = runner.invoke(cli, ["hub", "label", "--help"])
7717 assert "delete" in result.output.lower() or result.exit_code == 0
7718
7719 def test_label_constants_imported(self) -> None:
7720 from muse.cli.commands.hub import _MAX_LABEL_NAME_LEN, _MAX_LABEL_DESC_LEN
7721 assert _MAX_LABEL_NAME_LEN == 50
7722 assert _MAX_LABEL_DESC_LEN == 200
7723
7724 def test_validate_hex_color_imported(self) -> None:
7725 from muse.cli.commands.hub import _validate_hex_color
7726 assert _validate_hex_color("#d73a4a") is True
7727 assert _validate_hex_color("d73a4a") is False
7728 assert _validate_hex_color("#fff") is False
7729 assert _validate_hex_color("#zzzzzz") is False
7730 assert _validate_hex_color("#FFFFFF") is True
7731
7732
7733 # ---------------------------------------------------------------------------
7734 # TestLabelSecurity
7735 # ---------------------------------------------------------------------------
7736
7737
7738 class TestLabelSecurity:
7739 """Security hardening tests for label commands."""
7740
7741 def test_create_color_injection_attempt(self, repo: pathlib.Path) -> None:
7742 """Color field must reject shell injection attempts before network."""
7743 from muse.cli.config import set_hub_url
7744 set_hub_url(HUB_URL, repo)
7745 _store_identity(HUB_URL)
7746 evil_color = "'; rm -rf /; #"
7747 with patch("urllib.request.urlopen") as mock_net:
7748 result = runner.invoke(
7749 cli, ["hub", "label", "create", "--name", "bug", "--color", evil_color]
7750 )
7751 assert result.exit_code != 0
7752 mock_net.assert_not_called()
7753
7754 def test_create_name_xss_attempt_sanitized(self, repo: pathlib.Path) -> None:
7755 """XSS payload in name must be sanitized in any CLI output."""
7756 from muse.cli.config import set_hub_url
7757 set_hub_url(HUB_URL, repo)
7758 _store_identity(HUB_URL)
7759 xss_name = "<script>alert(1)</script>"
7760 with patch("urllib.request.urlopen"):
7761 result = runner.invoke(
7762 cli, ["hub", "label", "create", "--name", xss_name, "--color", "bad"]
7763 )
7764 # Color is invalid so it exits non-zero; crucially no raw <script> in output
7765 assert "<script>" not in result.output
7766
7767 def test_label_name_255_spaces_rejected(self, repo: pathlib.Path) -> None:
7768 """A name composed entirely of spaces must be rejected as empty after strip."""
7769 from muse.cli.config import set_hub_url
7770 set_hub_url(HUB_URL, repo)
7771 _store_identity(HUB_URL)
7772 with patch("urllib.request.urlopen") as mock_net:
7773 result = runner.invoke(
7774 cli,
7775 ["hub", "label", "create", "--name", " " * 255, "--color", "#d73a4a"],
7776 )
7777 assert result.exit_code != 0
7778 mock_net.assert_not_called()
7779
7780 def test_update_ansi_in_new_name_sanitized_in_error(self, repo: pathlib.Path) -> None:
7781 """ANSI codes in new_name must not appear verbatim in error output."""
7782 from muse.cli.config import set_hub_url
7783 set_hub_url(HUB_URL, repo)
7784 _store_identity(HUB_URL)
7785 ansi_name = "\x1b[31mnewname\x1b[0m"
7786 mocks = _mock_responses(_refs_resp(), _label_list_resp([]))
7787 with patch("urllib.request.urlopen", side_effect=mocks):
7788 result = runner.invoke(
7789 cli,
7790 [
7791 "hub", "label", "update",
7792 "--name", "bug",
7793 "--new-name", ansi_name,
7794 ],
7795 )
7796 assert "\x1b[31m" not in result.output
7797
7798
7799 # ---------------------------------------------------------------------------
7800 # TestIssueAssignHardening
7801 # ---------------------------------------------------------------------------
7802
7803
7804 class TestIssueAssignHardening:
7805 """Hardening tests for ``muse hub issue assign``."""
7806
7807 def test_zero_number_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
7808 from muse.cli.config import set_hub_url
7809 set_hub_url(HUB_URL, repo)
7810 _store_identity(HUB_URL)
7811 with patch("urllib.request.urlopen") as mock_net:
7812 result = runner.invoke(cli, ["hub", "issue", "assign", "0", "--assignee", "bob"])
7813 assert result.exit_code != 0
7814 mock_net.assert_not_called()
7815
7816 def test_negative_number_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
7817 from muse.cli.config import set_hub_url
7818 set_hub_url(HUB_URL, repo)
7819 _store_identity(HUB_URL)
7820 with patch("urllib.request.urlopen") as mock_net:
7821 result = runner.invoke(cli, ["hub", "issue", "assign", "-3", "--assignee", "bob"])
7822 assert result.exit_code != 0
7823 mock_net.assert_not_called()
7824
7825 def test_missing_assignee_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
7826 from muse.cli.config import set_hub_url
7827 set_hub_url(HUB_URL, repo)
7828 _store_identity(HUB_URL)
7829 with patch("urllib.request.urlopen") as mock_net:
7830 result = runner.invoke(cli, ["hub", "issue", "assign", "5"])
7831 assert result.exit_code != 0
7832 mock_net.assert_not_called()
7833
7834 def test_success_text_mode_exit_zero(self, repo: pathlib.Path) -> None:
7835 from muse.cli.config import set_hub_url
7836 set_hub_url(HUB_URL, repo)
7837 _store_identity(HUB_URL)
7838 mocks = _mock_responses(
7839 _refs_resp(),
7840 _issue_resp(number=5, state="open"),
7841 )
7842 with patch("urllib.request.urlopen", side_effect=mocks):
7843 result = runner.invoke(cli, ["hub", "issue", "assign", "5", "--assignee", "bob"])
7844 assert result.exit_code == 0
7845
7846 def test_success_json_output_has_expected_fields(self, repo: pathlib.Path) -> None:
7847 from muse.cli.config import set_hub_url
7848 set_hub_url(HUB_URL, repo)
7849 _store_identity(HUB_URL)
7850 mocks = _mock_responses(
7851 _refs_resp(),
7852 _issue_resp(number=5, state="open"),
7853 )
7854 with patch("urllib.request.urlopen", side_effect=mocks):
7855 result = runner.invoke(cli, ["hub", "issue", "assign", "5", "--assignee", "bob", "--json"])
7856 assert result.exit_code == 0
7857 data = json.loads(result.output)
7858 assert "number" in data
7859
7860 def test_json_short_flag(self, repo: pathlib.Path) -> None:
7861 from muse.cli.config import set_hub_url
7862 set_hub_url(HUB_URL, repo)
7863 _store_identity(HUB_URL)
7864 mocks = _mock_responses(_refs_resp(), _issue_resp(number=7))
7865 with patch("urllib.request.urlopen", side_effect=mocks):
7866 result = runner.invoke(cli, ["hub", "issue", "assign", "7", "--assignee", "carol", "-j"])
7867 assert result.exit_code == 0
7868 json.loads(result.output)
7869
7870 def test_unassign_empty_string_sends_null(self, repo: pathlib.Path) -> None:
7871 """Passing empty --assignee must call POST .../assign with assignee=null."""
7872 from muse.cli.config import set_hub_url
7873 set_hub_url(HUB_URL, repo)
7874 _store_identity(HUB_URL)
7875 captured_body: list[dict] = []
7876
7877 import json as _json
7878
7879 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
7880 if body is not None:
7881 captured_body.append(dict(body))
7882 return _issue_resp(number=5)
7883
7884 with patch("muse.cli.commands.hub._hub_api", side_effect=_capture):
7885 with patch("muse.cli.commands.hub._get_hub_and_identity", return_value=(HUB_URL, {"handle": "alice", "key_path": ""})):
7886 with patch("muse.cli.commands.hub._resolve_repo_id", return_value="repo-uuid-0001"):
7887 result = runner.invoke(cli, ["hub", "issue", "assign", "5", "--assignee", ""])
7888 assert result.exit_code == 0
7889 assert any(b.get("assignee") is None for b in captured_body)
7890
7891 def test_uses_post_method(self, repo: pathlib.Path) -> None:
7892 """assign must use POST /api/repos/{id}/issues/{n}/assign."""
7893 from muse.cli.config import set_hub_url
7894 set_hub_url(HUB_URL, repo)
7895 _store_identity(HUB_URL)
7896 captured: list[str] = []
7897
7898 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
7899 captured.append(method)
7900 return _issue_resp(number=5)
7901
7902 with patch("muse.cli.commands.hub._hub_api", side_effect=_capture):
7903 with patch("muse.cli.commands.hub._get_hub_and_identity", return_value=(HUB_URL, {"handle": "alice", "key_path": ""})):
7904 with patch("muse.cli.commands.hub._resolve_repo_id", return_value="repo-uuid-0001"):
7905 runner.invoke(cli, ["hub", "issue", "assign", "5", "--assignee", "bob"])
7906 assert "POST" in captured
7907
7908 def test_success_message_contains_assignee(self, repo: pathlib.Path) -> None:
7909 from muse.cli.config import set_hub_url
7910 set_hub_url(HUB_URL, repo)
7911 _store_identity(HUB_URL)
7912 mocks = _mock_responses(_refs_resp(), _issue_resp(number=5))
7913 with patch("urllib.request.urlopen", side_effect=mocks):
7914 result = runner.invoke(cli, ["hub", "issue", "assign", "5", "--assignee", "bob"])
7915 assert result.exit_code == 0
7916 assert "bob" in result.output or "5" in result.output
7917
7918 def test_unassign_success_message(self, repo: pathlib.Path) -> None:
7919 from muse.cli.config import set_hub_url
7920 set_hub_url(HUB_URL, repo)
7921 _store_identity(HUB_URL)
7922
7923 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
7924 return _issue_resp(number=5)
7925
7926 with patch("muse.cli.commands.hub._hub_api", side_effect=_capture):
7927 with patch("muse.cli.commands.hub._get_hub_and_identity", return_value=(HUB_URL, {"handle": "alice", "key_path": ""})):
7928 with patch("muse.cli.commands.hub._resolve_repo_id", return_value="repo-uuid-0001"):
7929 result = runner.invoke(cli, ["hub", "issue", "assign", "5", "--assignee", ""])
7930 assert result.exit_code == 0
7931
7932
7933 # ---------------------------------------------------------------------------
7934 # TestIssueAssignSubparserRegistration
7935 # ---------------------------------------------------------------------------
7936
7937
7938 class TestIssueAssignSubparserRegistration:
7939 """Verify ``issue assign`` subparser is registered with correct arguments."""
7940
7941 def test_assign_help_exits_zero(self, repo: pathlib.Path) -> None:
7942 result = runner.invoke(cli, ["hub", "issue", "assign", "--help"])
7943 assert result.exit_code == 0
7944
7945 def test_assign_number_arg_registered(self, repo: pathlib.Path) -> None:
7946 result = runner.invoke(cli, ["hub", "issue", "assign", "--help"])
7947 assert "number" in result.output.lower() or "NUMBER" in result.output
7948
7949 def test_assignee_flag_registered(self, repo: pathlib.Path) -> None:
7950 result = runner.invoke(cli, ["hub", "issue", "assign", "--help"])
7951 assert "--assignee" in result.output
7952
7953 def test_json_flag_registered(self, repo: pathlib.Path) -> None:
7954 result = runner.invoke(cli, ["hub", "issue", "assign", "--help"])
7955 assert "--json" in result.output
7956
7957 def test_short_json_flag_registered(self, repo: pathlib.Path) -> None:
7958 result = runner.invoke(cli, ["hub", "issue", "assign", "--help"])
7959 assert "-j" in result.output
7960
7961
7962 # ---------------------------------------------------------------------------
7963 # TestIssueLabelHardening
7964 # ---------------------------------------------------------------------------
7965
7966
7967 class TestIssueLabelHardening:
7968 """Hardening tests for ``muse hub issue label``."""
7969
7970 def test_zero_number_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
7971 from muse.cli.config import set_hub_url
7972 set_hub_url(HUB_URL, repo)
7973 _store_identity(HUB_URL)
7974 with patch("urllib.request.urlopen") as mock_net:
7975 result = runner.invoke(cli, ["hub", "issue", "label", "0", "--set", "bug"])
7976 assert result.exit_code != 0
7977 mock_net.assert_not_called()
7978
7979 def test_negative_number_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
7980 from muse.cli.config import set_hub_url
7981 set_hub_url(HUB_URL, repo)
7982 _store_identity(HUB_URL)
7983 with patch("urllib.request.urlopen") as mock_net:
7984 result = runner.invoke(cli, ["hub", "issue", "label", "-2", "--set", "bug"])
7985 assert result.exit_code != 0
7986 mock_net.assert_not_called()
7987
7988 def test_missing_set_or_remove_exits_nonzero(self, repo: pathlib.Path) -> None:
7989 from muse.cli.config import set_hub_url
7990 set_hub_url(HUB_URL, repo)
7991 _store_identity(HUB_URL)
7992 with patch("urllib.request.urlopen") as mock_net:
7993 result = runner.invoke(cli, ["hub", "issue", "label", "5"])
7994 assert result.exit_code != 0
7995 mock_net.assert_not_called()
7996
7997 def test_set_and_remove_mutually_exclusive(self, repo: pathlib.Path) -> None:
7998 from muse.cli.config import set_hub_url
7999 set_hub_url(HUB_URL, repo)
8000 _store_identity(HUB_URL)
8001 with patch("urllib.request.urlopen") as mock_net:
8002 result = runner.invoke(
8003 cli, ["hub", "issue", "label", "5", "--set", "bug", "--remove", "bug"]
8004 )
8005 assert result.exit_code != 0
8006 mock_net.assert_not_called()
8007
8008 def test_set_success_exit_zero(self, repo: pathlib.Path) -> None:
8009 from muse.cli.config import set_hub_url
8010 set_hub_url(HUB_URL, repo)
8011 _store_identity(HUB_URL)
8012 mocks = _mock_responses(
8013 _refs_resp(),
8014 _issue_resp(number=5, labels=["bug", "enhancement"]),
8015 )
8016 with patch("urllib.request.urlopen", side_effect=mocks):
8017 result = runner.invoke(cli, ["hub", "issue", "label", "5", "--set", "bug", "enhancement"])
8018 assert result.exit_code == 0
8019
8020 def test_set_json_output(self, repo: pathlib.Path) -> None:
8021 from muse.cli.config import set_hub_url
8022 set_hub_url(HUB_URL, repo)
8023 _store_identity(HUB_URL)
8024 mocks = _mock_responses(
8025 _refs_resp(),
8026 _issue_resp(number=5, labels=["bug"]),
8027 )
8028 with patch("urllib.request.urlopen", side_effect=mocks):
8029 result = runner.invoke(cli, ["hub", "issue", "label", "5", "--set", "bug", "--json"])
8030 assert result.exit_code == 0
8031 data = json.loads(result.output)
8032 assert "number" in data
8033
8034 def test_remove_success_exit_zero(self, repo: pathlib.Path) -> None:
8035 from muse.cli.config import set_hub_url
8036 set_hub_url(HUB_URL, repo)
8037 _store_identity(HUB_URL)
8038 mocks = _mock_responses(
8039 _refs_resp(),
8040 _issue_resp(number=5, labels=[]),
8041 )
8042 with patch("urllib.request.urlopen", side_effect=mocks):
8043 result = runner.invoke(cli, ["hub", "issue", "label", "5", "--remove", "bug"])
8044 assert result.exit_code == 0
8045
8046 def test_remove_json_output(self, repo: pathlib.Path) -> None:
8047 from muse.cli.config import set_hub_url
8048 set_hub_url(HUB_URL, repo)
8049 _store_identity(HUB_URL)
8050 mocks = _mock_responses(
8051 _refs_resp(),
8052 _issue_resp(number=5, labels=[]),
8053 )
8054 with patch("urllib.request.urlopen", side_effect=mocks):
8055 result = runner.invoke(cli, ["hub", "issue", "label", "5", "--remove", "bug", "-j"])
8056 assert result.exit_code == 0
8057 json.loads(result.output)
8058
8059 def test_set_uses_post_method(self, repo: pathlib.Path) -> None:
8060 """--set must use POST /api/repos/{id}/issues/{n}/labels."""
8061 from muse.cli.config import set_hub_url
8062 set_hub_url(HUB_URL, repo)
8063 _store_identity(HUB_URL)
8064 captured: list[str] = []
8065
8066 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
8067 captured.append(method)
8068 return _issue_resp(number=5)
8069
8070 with patch("muse.cli.commands.hub._hub_api", side_effect=_capture):
8071 with patch("muse.cli.commands.hub._get_hub_and_identity", return_value=(HUB_URL, {"handle": "alice", "key_path": ""})):
8072 with patch("muse.cli.commands.hub._resolve_repo_id", return_value="repo-uuid-0001"):
8073 runner.invoke(cli, ["hub", "issue", "label", "5", "--set", "bug"])
8074 assert "POST" in captured
8075
8076 def test_remove_uses_delete_method(self, repo: pathlib.Path) -> None:
8077 """--remove must use DELETE /api/repos/{id}/issues/{n}/labels/{name}."""
8078 from muse.cli.config import set_hub_url
8079 set_hub_url(HUB_URL, repo)
8080 _store_identity(HUB_URL)
8081 captured: list[str] = []
8082
8083 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
8084 captured.append(method)
8085 return _issue_resp(number=5)
8086
8087 with patch("muse.cli.commands.hub._hub_api", side_effect=_capture):
8088 with patch("muse.cli.commands.hub._get_hub_and_identity", return_value=(HUB_URL, {"handle": "alice", "key_path": ""})):
8089 with patch("muse.cli.commands.hub._resolve_repo_id", return_value="repo-uuid-0001"):
8090 runner.invoke(cli, ["hub", "issue", "label", "5", "--remove", "bug"])
8091 assert "DELETE" in captured
8092
8093 def test_set_sends_labels_in_body(self, repo: pathlib.Path) -> None:
8094 from muse.cli.config import set_hub_url
8095 set_hub_url(HUB_URL, repo)
8096 _store_identity(HUB_URL)
8097 captured_body: list[dict] = []
8098
8099 def _capture(hub_url, identity, method, path, body=None, timeout=10.0):
8100 if body is not None:
8101 captured_body.append(dict(body))
8102 return _issue_resp(number=5)
8103
8104 with patch("muse.cli.commands.hub._hub_api", side_effect=_capture):
8105 with patch("muse.cli.commands.hub._get_hub_and_identity", return_value=(HUB_URL, {"handle": "alice", "key_path": ""})):
8106 with patch("muse.cli.commands.hub._resolve_repo_id", return_value="repo-uuid-0001"):
8107 runner.invoke(cli, ["hub", "issue", "label", "5", "--set", "bug", "enhancement"])
8108 assert any("labels" in b for b in captured_body)
8109 label_body = next((b for b in captured_body if "labels" in b), None)
8110 assert label_body is not None
8111 assert set(label_body["labels"]) == {"bug", "enhancement"}
8112
8113
8114 # ---------------------------------------------------------------------------
8115 # TestIssueLabelSubparserRegistration
8116 # ---------------------------------------------------------------------------
8117
8118
8119 class TestIssueLabelSubparserRegistration:
8120 """Verify ``issue label`` subparser is registered with correct arguments."""
8121
8122 def test_label_help_exits_zero(self, repo: pathlib.Path) -> None:
8123 result = runner.invoke(cli, ["hub", "issue", "label", "--help"])
8124 assert result.exit_code == 0
8125
8126 def test_set_flag_registered(self, repo: pathlib.Path) -> None:
8127 result = runner.invoke(cli, ["hub", "issue", "label", "--help"])
8128 assert "--set" in result.output
8129
8130 def test_remove_flag_registered(self, repo: pathlib.Path) -> None:
8131 result = runner.invoke(cli, ["hub", "issue", "label", "--help"])
8132 assert "--remove" in result.output
8133
8134 def test_json_flag_registered(self, repo: pathlib.Path) -> None:
8135 result = runner.invoke(cli, ["hub", "issue", "label", "--help"])
8136 assert "--json" in result.output
8137
8138 def test_number_arg_registered(self, repo: pathlib.Path) -> None:
8139 result = runner.invoke(cli, ["hub", "issue", "label", "--help"])
8140 assert "number" in result.output.lower() or "NUMBER" in result.output
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 145 days ago