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