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