gabriel / muse public
test_cmd_hub_hardening.py python
8,251 lines 358.6 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 127 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_truncated_to_8_chars(self) -> None:
1908 from muse.cli.commands.hub import _format_proposal
1909 proposal = {"proposalId": "abc12345-full-id-here", "title": "T", "state": "open",
1910 "fromBranch": "f", "toBranch": "d"}
1911 result = _format_proposal(proposal)
1912 assert "abc12345" in result
1913 # The full ID beyond 8 chars must not appear
1914 assert "full-id-here" not in result
1915
1916
1917 class TestProposalListStress:
1918 """Stress tests for `muse hub proposal list`."""
1919
1920 _HUB = "http://localhost:19999/gabriel/muse"
1921
1922 def _setup(self, repo: pathlib.Path) -> None:
1923 runner.invoke(cli, ["hub", "connect", self._HUB])
1924 _store_identity(self._HUB)
1925
1926 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
1927 mock_resp = MagicMock()
1928 mock_resp.__enter__ = lambda s: s
1929 mock_resp.__exit__ = MagicMock(return_value=False)
1930 mock_resp.read.return_value = payload_bytes
1931 return mock_resp
1932
1933 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
1934 return [self._make_api_resp(r) for r in responses]
1935
1936 def test_large_proposal_list_10000_items_json(self, repo: pathlib.Path) -> None:
1937 """10 000 proposals in the JSON response must be handled without crashing."""
1938 self._setup(repo)
1939 proposals = [
1940 {"proposalId": f"aaaa0000-0000-0000-0000-{i:012d}",
1941 "title": f"Proposal #{i}", "state": "open",
1942 "fromBranch": f"feat/f{i}", "toBranch": "dev"}
1943 for i in range(10_000)
1944 ]
1945 payload = json.dumps({"proposals": proposals, "total": 10_000, "nextCursor": None}).encode()
1946 mock_resp = MagicMock()
1947 mock_resp.__enter__ = lambda s: s
1948 mock_resp.__exit__ = MagicMock(return_value=False)
1949 mock_resp.read.return_value = payload
1950
1951 repo_resp = self._make_api_resp(json.dumps({"repo_id": "repo-id"}).encode())
1952 with patch("urllib.request.urlopen", side_effect=[repo_resp, mock_resp]):
1953 result = runner.invoke(cli, ["hub", "proposal", "list", "-n", "10000", "-j"])
1954 assert result.exit_code == 0
1955 obj = json.loads(next(
1956 l for l in result.output.splitlines() if l.strip().startswith("{")
1957 ))
1958 assert len(obj["proposals"]) == 10_000
1959
1960 def test_concurrent_format_proposal_calls(self) -> None:
1961 """8 threads calling _format_proposal concurrently must produce consistent results."""
1962 from muse.cli.commands.hub import _format_proposal
1963 errors: list[str] = []
1964 results: list[str] = [""] * 8
1965
1966 def _do(idx: int) -> None:
1967 try:
1968 proposal = {
1969 "proposalId": f"aaaa{idx:04d}-0000-0000-0000-000000000001",
1970 "title": f"Proposal-{idx}: \x1b[31mmalicious\x1b[0m",
1971 "state": "open",
1972 "fromBranch": f"feat/f{idx}",
1973 "toBranch": "dev",
1974 "author": f"user{idx}",
1975 "createdAt": f"2024-0{(idx % 9) + 1}-01T00:00:00Z",
1976 }
1977 results[idx] = _format_proposal(proposal, verbose=True)
1978 except Exception as exc:
1979 errors.append(f"Thread {idx}: {exc}")
1980
1981 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
1982 for t in threads:
1983 t.start()
1984 for t in threads:
1985 t.join()
1986 assert errors == [], f"Concurrent _format_proposal failures:\n{'\n'.join(errors)}"
1987 # Each result must have ANSI stripped and contain the user name
1988 for idx, result in enumerate(results):
1989 assert "\x1b[" not in result, f"ANSI in thread {idx} output"
1990 assert f"user{idx}" in result, f"Author missing in thread {idx} output"
1991
1992
1993 class TestProposalListE2E:
1994 """End-to-end flow tests for `muse hub proposal list`."""
1995
1996 _HUB = "http://localhost:19999/gabriel/muse"
1997
1998 def _setup(self, repo: pathlib.Path) -> None:
1999 runner.invoke(cli, ["hub", "connect", self._HUB])
2000 _store_identity(self._HUB)
2001
2002 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
2003 mock_resp = MagicMock()
2004 mock_resp.__enter__ = lambda s: s
2005 mock_resp.__exit__ = MagicMock(return_value=False)
2006 mock_resp.read.return_value = payload_bytes
2007 return mock_resp
2008
2009 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
2010 return [self._make_api_resp(r) for r in responses]
2011
2012 def test_e2e_connect_then_list_json(self, repo: pathlib.Path) -> None:
2013 """Full flow: connect → list --json returns a well-formed envelope object."""
2014 self._setup(repo)
2015 proposals_data = {"proposals": [
2016 {"proposalId": "abc12345-0000-0000-0000-000000000001",
2017 "title": "My Proposal", "state": "open",
2018 "fromBranch": "feat/my", "toBranch": "dev",
2019 "author": "alice", "createdAt": "2024-03-01T09:00:00Z"},
2020 ], "total": 1, "nextCursor": None}
2021 resps = self._mock_api(
2022 json.dumps({"repo_id": "repo-id"}).encode(),
2023 json.dumps(proposals_data).encode(),
2024 )
2025 with patch("urllib.request.urlopen", side_effect=resps):
2026 result = runner.invoke(cli, ["hub", "proposal", "list", "-j"])
2027 assert result.exit_code == 0
2028 obj = json.loads(next(
2029 l for l in result.output.splitlines() if l.strip().startswith("{")
2030 ))
2031 arr = obj["proposals"]
2032 assert arr[0]["title"] == "My Proposal"
2033 assert arr[0]["state"] == "open"
2034 assert arr[0]["author"] == "alice"
2035
2036 def test_e2e_list_verbose_text_all_fields_present(self, repo: pathlib.Path) -> None:
2037 """Verbose text output includes state icon, ID prefix, branches, author, date."""
2038 self._setup(repo)
2039 proposals_data = {"proposals": [
2040 {"proposalId": "deadbeef-0000-0000-0000-000000000001",
2041 "title": "My feature", "state": "open",
2042 "fromBranch": "feat/my-feature", "toBranch": "dev",
2043 "author": "charlie", "createdAt": "2025-12-31T23:59:59Z"},
2044 ]}
2045 resps = self._mock_api(
2046 json.dumps({"repo_id": "repo-id"}).encode(),
2047 json.dumps(proposals_data).encode(),
2048 )
2049 with patch("urllib.request.urlopen", side_effect=resps):
2050 result = runner.invoke(cli, ["hub", "proposal", "list", "-v"])
2051 assert result.exit_code == 0
2052 output = result.stderr
2053 assert "🟢" in output
2054 assert "deadbeef" in output
2055 assert "feat/my-feature" in output
2056 assert "charlie" in output
2057 assert "2025-12-31" in output
2058
2059 def test_e2e_empty_list_exits_zero_with_message(self, repo: pathlib.Path) -> None:
2060 """Empty proposal list must exit 0 and print a human-friendly message."""
2061 self._setup(repo)
2062 resps = self._mock_api(
2063 json.dumps({"repo_id": "repo-id"}).encode(),
2064 json.dumps({"proposals": []}).encode(),
2065 )
2066 with patch("urllib.request.urlopen", side_effect=resps):
2067 result = runner.invoke(cli, ["hub", "proposal", "list", "--state", "merged"])
2068 assert result.exit_code == 0
2069 assert "No proposals" in result.stderr or "no proposals" in result.stderr.lower()
2070
2071 def test_e2e_json_no_stdout_in_text_mode(self, repo: pathlib.Path) -> None:
2072 """In text mode, JSON must NOT appear on stdout — all output goes to stderr."""
2073 self._setup(repo)
2074 proposals_data = {"proposals": [
2075 {"proposalId": "abc12345-0000-0000-0000-000000000001",
2076 "title": "T", "state": "open",
2077 "fromBranch": "feat/x", "toBranch": "dev"},
2078 ]}
2079 resps = self._mock_api(
2080 json.dumps({"repo_id": "repo-id"}).encode(),
2081 json.dumps(proposals_data).encode(),
2082 )
2083 with patch("urllib.request.urlopen", side_effect=resps):
2084 result = runner.invoke(cli, ["hub", "proposal", "list"])
2085 assert result.exit_code == 0
2086 # In text mode, stdout should have no JSON array
2087 for line in result.output.splitlines():
2088 stripped = line.strip()
2089 assert not stripped.startswith("["), (
2090 f"Unexpected JSON on stdout in text mode: {stripped!r}"
2091 )
2092
2093
2094 class TestProposalViewHardening:
2095 """Additional hardening tests for `muse hub proposal show`."""
2096
2097 _HUB = "http://localhost:19999/gabriel/muse"
2098
2099 def _setup(self, repo: pathlib.Path) -> None:
2100 runner.invoke(cli, ["hub", "connect", self._HUB])
2101 _store_identity(self._HUB)
2102
2103 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
2104 mock_resp = MagicMock()
2105 mock_resp.__enter__ = lambda s: s
2106 mock_resp.__exit__ = MagicMock(return_value=False)
2107 mock_resp.read.return_value = payload_bytes
2108 return mock_resp
2109
2110 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
2111 return [self._make_api_resp(r) for r in responses]
2112
2113 def test_short_flag_j_works_for_view(self, repo: pathlib.Path) -> None:
2114 """``-j`` is accepted as alias for ``--json``."""
2115 self._setup(repo)
2116 proposal_id = "abc12345-0000-0000-0000-000000000001"
2117 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2118 "fromBranch": "feat/x", "toBranch": "dev"}
2119 proposals_data = {"proposals": [
2120 {"proposalId": proposal_id, "title": "T", "state": "open",
2121 "fromBranch": "feat/x", "toBranch": "dev"},
2122 ]}
2123 resps = self._mock_api(
2124 json.dumps({"repo_id": "repo-id"}).encode(),
2125 json.dumps(proposals_data).encode(),
2126 json.dumps(proposal_data).encode(),
2127 )
2128 with patch("urllib.request.urlopen", side_effect=resps):
2129 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345", "-j"])
2130 assert result.exit_code == 0
2131 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
2132 assert len(json_lines) >= 1
2133
2134 def test_ansi_in_state_sanitized(
2135 self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]
2136 ) -> None:
2137 """ANSI in ``state`` field must not reach terminal in text mode."""
2138 self._setup(repo)
2139 proposal_id = "abc12345-0000-0000-0000-000000000001"
2140 malicious_proposal = {"proposalId": proposal_id, "title": "T",
2141 "state": "\x1b[31mopen\x1b[0m",
2142 "fromBranch": "feat/x", "toBranch": "dev"}
2143 proposals_data = {"proposals": [
2144 {"proposalId": proposal_id, "title": "T", "state": "open",
2145 "fromBranch": "feat/x", "toBranch": "dev"},
2146 ]}
2147 resps = self._mock_api(
2148 json.dumps({"repo_id": "repo-id"}).encode(),
2149 json.dumps(proposals_data).encode(),
2150 json.dumps(malicious_proposal).encode(),
2151 )
2152 with patch("urllib.request.urlopen", side_effect=resps):
2153 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2154 assert result.exit_code == 0
2155 assert "\x1b[" not in result.stderr
2156
2157 def test_ansi_in_branch_sanitized(self, repo: pathlib.Path) -> None:
2158 """ANSI in branch names must not reach terminal in text mode."""
2159 self._setup(repo)
2160 proposal_id = "abc12345-0000-0000-0000-000000000001"
2161 malicious_proposal = {"proposalId": proposal_id, "title": "T", "state": "open",
2162 "fromBranch": "\x1b[32mfeat/malicious\x1b[0m",
2163 "toBranch": "\x1b[34mdev\x1b[0m"}
2164 proposals_data = {"proposals": [
2165 {"proposalId": proposal_id, "title": "T", "state": "open",
2166 "fromBranch": "feat/x", "toBranch": "dev"},
2167 ]}
2168 resps = self._mock_api(
2169 json.dumps({"repo_id": "repo-id"}).encode(),
2170 json.dumps(proposals_data).encode(),
2171 json.dumps(malicious_proposal).encode(),
2172 )
2173 with patch("urllib.request.urlopen", side_effect=resps):
2174 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2175 assert result.exit_code == 0
2176 assert "\x1b[" not in result.stderr
2177
2178 def test_ansi_in_body_lines_sanitized(self, repo: pathlib.Path) -> None:
2179 """ANSI in body text must not reach terminal in text mode."""
2180 self._setup(repo)
2181 proposal_id = "abc12345-0000-0000-0000-000000000001"
2182 malicious_proposal = {"proposalId": proposal_id, "title": "T", "state": "open",
2183 "fromBranch": "feat/x", "toBranch": "dev",
2184 "body": "\x1b[31mThis body has ANSI\x1b[0m"}
2185 proposals_data = {"proposals": [
2186 {"proposalId": proposal_id, "title": "T", "state": "open",
2187 "fromBranch": "feat/x", "toBranch": "dev"},
2188 ]}
2189 resps = self._mock_api(
2190 json.dumps({"repo_id": "repo-id"}).encode(),
2191 json.dumps(proposals_data).encode(),
2192 json.dumps(malicious_proposal).encode(),
2193 )
2194 with patch("urllib.request.urlopen", side_effect=resps):
2195 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2196 assert result.exit_code == 0
2197 assert "\x1b[" not in result.stderr
2198
2199 def test_view_prefix_not_found_exits_nonzero(self, repo: pathlib.Path) -> None:
2200 self._setup(repo)
2201 proposals_data = {"proposals": []}
2202 resps = self._mock_api(
2203 json.dumps({"repo_id": "repo-id"}).encode(),
2204 json.dumps(proposals_data).encode(),
2205 )
2206 with patch("urllib.request.urlopen", side_effect=resps):
2207 result = runner.invoke(cli, ["hub", "proposal", "read", "deadbeef"])
2208 assert result.exit_code != 0
2209
2210 def test_view_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
2211 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2212 assert result.exit_code != 0
2213
2214 def test_view_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None:
2215 runner.invoke(cli, ["hub", "connect", self._HUB])
2216 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2217 assert result.exit_code != 0
2218
2219 def test_view_outside_repo_exits_nonzero(
2220 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
2221 ) -> None:
2222 monkeypatch.chdir(tmp_path)
2223 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
2224 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2225 assert result.exit_code != 0
2226
2227 def test_full_id_skips_prefix_resolution(self, repo: pathlib.Path) -> None:
2228 """A full proposal ID must reach the view endpoint with exactly 2 API calls (no prefix fetch)."""
2229 self._setup(repo)
2230 proposal_id = "abc12345-def0-0000-0000-000000000001"
2231 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2232 "fromBranch": "feat/x", "toBranch": "dev"}
2233 resps = self._mock_api(
2234 json.dumps({"repo_id": "repo-id"}).encode(), # _resolve_repo_id
2235 json.dumps(proposal_data).encode(), # GET proposals/{id}
2236 )
2237 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2238 result = runner.invoke(cli, ["hub", "proposal", "read", proposal_id, "-j"])
2239 assert result.exit_code == 0
2240 # Only 2 urlopen calls: repo resolution + the view fetch (no prefix list call)
2241 assert mock_open.call_count == 2
2242
2243 def test_prefix_triggers_resolution_call(self, repo: pathlib.Path) -> None:
2244 """An 8-char prefix must trigger a prefix-resolution list fetch (3 API calls total)."""
2245 self._setup(repo)
2246 proposal_id = "abc12345-0000-0000-0000-000000000001"
2247 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2248 "fromBranch": "feat/x", "toBranch": "dev"}
2249 proposals_data = {"proposals": [
2250 {"proposalId": proposal_id, "title": "T", "state": "open",
2251 "fromBranch": "feat/x", "toBranch": "dev"},
2252 ]}
2253 resps = self._mock_api(
2254 json.dumps({"repo_id": "repo-id"}).encode(), # _resolve_repo_id
2255 json.dumps(proposals_data).encode(), # prefix resolution list
2256 json.dumps(proposal_data).encode(), # GET proposals/{id}
2257 )
2258 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2259 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345", "-j"])
2260 assert result.exit_code == 0
2261 assert mock_open.call_count == 3
2262
2263 def test_author_shown_in_text_mode(self, repo: pathlib.Path) -> None:
2264 self._setup(repo)
2265 proposal_id = "abc12345-0000-0000-0000-000000000001"
2266 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2267 "fromBranch": "feat/x", "toBranch": "dev",
2268 "author": "charlie", "createdAt": "2024-07-04T00:00:00Z"}
2269 resps = self._mock_api(
2270 json.dumps({"repo_id": "repo-id"}).encode(),
2271 json.dumps({"proposals": [
2272 {"proposalId": proposal_id, "title": "T", "state": "open",
2273 "fromBranch": "feat/x", "toBranch": "dev"},
2274 ]}).encode(),
2275 json.dumps(proposal_data).encode(),
2276 )
2277 with patch("urllib.request.urlopen", side_effect=resps):
2278 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2279 assert result.exit_code == 0
2280 assert "charlie" in result.stderr
2281
2282 def test_created_at_shown_in_text_mode(self, repo: pathlib.Path) -> None:
2283 self._setup(repo)
2284 proposal_id = "abc12345-0000-0000-0000-000000000001"
2285 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2286 "fromBranch": "feat/x", "toBranch": "dev",
2287 "author": "alice", "createdAt": "2025-03-15T08:30:00Z"}
2288 resps = self._mock_api(
2289 json.dumps({"repo_id": "repo-id"}).encode(),
2290 json.dumps({"proposals": [
2291 {"proposalId": proposal_id, "title": "T", "state": "open",
2292 "fromBranch": "feat/x", "toBranch": "dev"},
2293 ]}).encode(),
2294 json.dumps(proposal_data).encode(),
2295 )
2296 with patch("urllib.request.urlopen", side_effect=resps):
2297 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2298 assert result.exit_code == 0
2299 assert "2025-03-15" in result.stderr
2300
2301 def test_ansi_in_author_sanitized(self, repo: pathlib.Path) -> None:
2302 self._setup(repo)
2303 proposal_id = "abc12345-0000-0000-0000-000000000001"
2304 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2305 "fromBranch": "feat/x", "toBranch": "dev",
2306 "author": "\x1b[31mmalicious-author\x1b[0m",
2307 "createdAt": "2024-01-01T00:00:00Z"}
2308 resps = self._mock_api(
2309 json.dumps({"repo_id": "repo-id"}).encode(),
2310 json.dumps({"proposals": [
2311 {"proposalId": proposal_id, "title": "T", "state": "open",
2312 "fromBranch": "feat/x", "toBranch": "dev"},
2313 ]}).encode(),
2314 json.dumps(proposal_data).encode(),
2315 )
2316 with patch("urllib.request.urlopen", side_effect=resps):
2317 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2318 assert result.exit_code == 0
2319 assert "\x1b[" not in result.stderr
2320
2321 def test_ansi_in_created_at_sanitized(self, repo: pathlib.Path) -> None:
2322 self._setup(repo)
2323 proposal_id = "abc12345-0000-0000-0000-000000000001"
2324 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2325 "fromBranch": "feat/x", "toBranch": "dev",
2326 "author": "alice",
2327 "createdAt": "\x1b[32m2024-01-01\x1b[0mTmalicious"}
2328 resps = self._mock_api(
2329 json.dumps({"repo_id": "repo-id"}).encode(),
2330 json.dumps({"proposals": [
2331 {"proposalId": proposal_id, "title": "T", "state": "open",
2332 "fromBranch": "feat/x", "toBranch": "dev"},
2333 ]}).encode(),
2334 json.dumps(proposal_data).encode(),
2335 )
2336 with patch("urllib.request.urlopen", side_effect=resps):
2337 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2338 assert result.exit_code == 0
2339 assert "\x1b[" not in result.stderr
2340
2341 def test_body_truncation_hint_shown(self, repo: pathlib.Path) -> None:
2342 """Body exceeding _MAX_PROPOSAL_BODY_LINES must show a truncation hint."""
2343 from muse.cli.commands.hub import _MAX_PROPOSAL_BODY_LINES
2344 self._setup(repo)
2345 proposal_id = "abc12345-0000-0000-0000-000000000001"
2346 long_body = "\n".join(f"line {i}" for i in range(_MAX_PROPOSAL_BODY_LINES + 5))
2347 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2348 "fromBranch": "feat/x", "toBranch": "dev", "body": long_body}
2349 resps = self._mock_api(
2350 json.dumps({"repo_id": "repo-id"}).encode(),
2351 json.dumps({"proposals": [
2352 {"proposalId": proposal_id, "title": "T", "state": "open",
2353 "fromBranch": "feat/x", "toBranch": "dev"},
2354 ]}).encode(),
2355 json.dumps(proposal_data).encode(),
2356 )
2357 with patch("urllib.request.urlopen", side_effect=resps):
2358 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2359 assert result.exit_code == 0
2360 assert "more line" in result.stderr
2361 assert "--json" in result.stderr # hint mentions --json
2362
2363 def test_body_exactly_at_limit_no_hint(self, repo: pathlib.Path) -> None:
2364 """Body at exactly _MAX_PROPOSAL_BODY_LINES must NOT show a truncation hint."""
2365 from muse.cli.commands.hub import _MAX_PROPOSAL_BODY_LINES
2366 self._setup(repo)
2367 proposal_id = "abc12345-0000-0000-0000-000000000001"
2368 exact_body = "\n".join(f"line {i}" for i in range(_MAX_PROPOSAL_BODY_LINES))
2369 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2370 "fromBranch": "feat/x", "toBranch": "dev", "body": exact_body}
2371 resps = self._mock_api(
2372 json.dumps({"repo_id": "repo-id"}).encode(),
2373 json.dumps({"proposals": [
2374 {"proposalId": proposal_id, "title": "T", "state": "open",
2375 "fromBranch": "feat/x", "toBranch": "dev"},
2376 ]}).encode(),
2377 json.dumps(proposal_data).encode(),
2378 )
2379 with patch("urllib.request.urlopen", side_effect=resps):
2380 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2381 assert result.exit_code == 0
2382 assert "more line" not in result.stderr
2383
2384 def test_no_body_field_no_body_section(self, repo: pathlib.Path) -> None:
2385 """When body is absent or empty, no 'Body:' section must appear."""
2386 self._setup(repo)
2387 proposal_id = "abc12345-0000-0000-0000-000000000001"
2388 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2389 "fromBranch": "feat/x", "toBranch": "dev"}
2390 resps = self._mock_api(
2391 json.dumps({"repo_id": "repo-id"}).encode(),
2392 json.dumps({"proposals": [
2393 {"proposalId": proposal_id, "title": "T", "state": "open",
2394 "fromBranch": "feat/x", "toBranch": "dev"},
2395 ]}).encode(),
2396 json.dumps(proposal_data).encode(),
2397 )
2398 with patch("urllib.request.urlopen", side_effect=resps):
2399 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2400 assert result.exit_code == 0
2401 assert "Body:" not in result.stderr
2402
2403 def test_json_passthrough_includes_all_fields(self, repo: pathlib.Path) -> None:
2404 """JSON output must be an unmodified passthrough from the API."""
2405 self._setup(repo)
2406 proposal_id = "abc12345-0000-0000-0000-000000000001"
2407 proposal_data = {"proposalId": proposal_id, "title": "My Proposal", "state": "open",
2408 "fromBranch": "feat/x", "toBranch": "dev",
2409 "author": "alice", "createdAt": "2024-01-01T00:00:00Z",
2410 "body": "Full body text here.",
2411 "extraField": "agent-visible"}
2412 resps = self._mock_api(
2413 json.dumps({"repo_id": "repo-id"}).encode(),
2414 json.dumps({"proposals": [
2415 {"proposalId": proposal_id, "title": "My Proposal", "state": "open",
2416 "fromBranch": "feat/x", "toBranch": "dev"},
2417 ]}).encode(),
2418 json.dumps(proposal_data).encode(),
2419 )
2420 with patch("urllib.request.urlopen", side_effect=resps):
2421 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345", "-j"])
2422 assert result.exit_code == 0
2423 data = json.loads(next(
2424 l for l in result.output.splitlines() if l.strip().startswith("{")
2425 ))
2426 assert data["author"] == "alice"
2427 assert data["body"] == "Full body text here."
2428 assert data["extraField"] == "agent-visible"
2429
2430 def test_hub_override_flag(self, repo: pathlib.Path) -> None:
2431 """``--hub`` must route requests to the override URL."""
2432 runner.invoke(cli, ["hub", "connect", "http://localhost:11111/wrong/repo"])
2433 _store_identity("http://localhost:19999/gabriel/muse")
2434 proposal_id = "abc12345-def0-0000-0000-000000000001"
2435 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2436 "fromBranch": "f", "toBranch": "d"}
2437 resps = self._mock_api(
2438 json.dumps({"repo_id": "repo-id"}).encode(),
2439 json.dumps(proposal_data).encode(),
2440 )
2441 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2442 result = runner.invoke(
2443 cli,
2444 ["hub", "proposal", "read", proposal_id,
2445 "--hub", "http://localhost:19999/gabriel/muse", "-j"],
2446 )
2447 assert result.exit_code == 0
2448 called_urls = [c[0][0].full_url for c in mock_open.call_args_list]
2449 assert any("19999" in u for u in called_urls)
2450 assert not any("11111" in u for u in called_urls)
2451
2452
2453 class TestProposalViewUnit:
2454 """Pure unit tests for run_proposal_show text rendering logic."""
2455
2456 def _make_proposal_resp(self, **kwargs: str) -> bytes:
2457 base: Manifest = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2458 "title": "My Proposal", "state": "open",
2459 "fromBranch": "feat/x", "toBranch": "dev"}
2460 base.update(kwargs)
2461 return json.dumps(base).encode()
2462
2463 def _invoke_view(
2464 self,
2465 repo: pathlib.Path,
2466 proposal_data: bytes,
2467 *,
2468 flags: list[str] | None = None,
2469 ) -> InvokeResult:
2470 """Invoke hub proposal show with a pre-resolved full UUID (2 API calls only)."""
2471 proposal_id = "abc12345-def0-0000-0000-000000000001"
2472 # Use a full UUID to skip the prefix-resolution fetch
2473 mock_repo = MagicMock()
2474 mock_repo.__enter__ = lambda s: s
2475 mock_repo.__exit__ = MagicMock(return_value=False)
2476 mock_repo.read.return_value = json.dumps({"repo_id": "repo-id"}).encode()
2477
2478 mock_proposal = MagicMock()
2479 mock_proposal.__enter__ = lambda s: s
2480 mock_proposal.__exit__ = MagicMock(return_value=False)
2481 mock_proposal.read.return_value = proposal_data
2482
2483 cmd = ["hub", "proposal", "read", proposal_id] + (flags or [])
2484 with patch("urllib.request.urlopen", side_effect=[mock_repo, mock_proposal]):
2485 return runner.invoke(cli, cmd)
2486
2487 def test_state_open_icon(self, repo: pathlib.Path) -> None:
2488 runner.invoke(cli, ["hub", "connect", "http://localhost:19999/gabriel/muse"])
2489 _store_identity("http://localhost:19999/gabriel/muse")
2490 result = self._invoke_view(repo, self._make_proposal_resp(state="open"))
2491 assert "🟢" in result.stderr
2492
2493 def test_state_merged_icon(self, repo: pathlib.Path) -> None:
2494 runner.invoke(cli, ["hub", "connect", "http://localhost:19999/gabriel/muse"])
2495 _store_identity("http://localhost:19999/gabriel/muse")
2496 result = self._invoke_view(repo, self._make_proposal_resp(state="merged"))
2497 assert "🟣" in result.stderr
2498
2499 def test_state_closed_icon(self, repo: pathlib.Path) -> None:
2500 runner.invoke(cli, ["hub", "connect", "http://localhost:19999/gabriel/muse"])
2501 _store_identity("http://localhost:19999/gabriel/muse")
2502 result = self._invoke_view(repo, self._make_proposal_resp(state="closed"))
2503 assert "⛔" in result.stderr
2504
2505 def test_unknown_state_fallback_icon(self, repo: pathlib.Path) -> None:
2506 runner.invoke(cli, ["hub", "connect", "http://localhost:19999/gabriel/muse"])
2507 _store_identity("http://localhost:19999/gabriel/muse")
2508 result = self._invoke_view(repo, self._make_proposal_resp(state="draft"))
2509 assert "❓" in result.stderr
2510
2511 def test_no_author_field_omits_by_line(self, repo: pathlib.Path) -> None:
2512 """When author is absent, the 'By:' line must not appear."""
2513 runner.invoke(cli, ["hub", "connect", "http://localhost:19999/gabriel/muse"])
2514 _store_identity("http://localhost:19999/gabriel/muse")
2515 result = self._invoke_view(repo, self._make_proposal_resp())
2516 assert "By:" not in result.stderr
2517
2518 def test_state_upper_in_header(self, repo: pathlib.Path) -> None:
2519 runner.invoke(cli, ["hub", "connect", "http://localhost:19999/gabriel/muse"])
2520 _store_identity("http://localhost:19999/gabriel/muse")
2521 result = self._invoke_view(repo, self._make_proposal_resp(state="open"))
2522 assert "[OPEN]" in result.stderr
2523
2524 def test_id_and_branches_in_output(self, repo: pathlib.Path) -> None:
2525 runner.invoke(cli, ["hub", "connect", "http://localhost:19999/gabriel/muse"])
2526 _store_identity("http://localhost:19999/gabriel/muse")
2527 proposal_id = "abc12345-def0-0000-0000-000000000001"
2528 result = self._invoke_view(
2529 repo,
2530 self._make_proposal_resp(proposalId=proposal_id, fromBranch="feat/my", toBranch="main"),
2531 )
2532 assert "feat/my" in result.stderr
2533 assert "main" in result.stderr
2534
2535
2536 class TestProposalViewE2E:
2537 """End-to-end scenario tests for `muse hub proposal show`."""
2538
2539 _HUB = "http://localhost:19999/gabriel/muse"
2540
2541 def _setup(self, repo: pathlib.Path) -> None:
2542 runner.invoke(cli, ["hub", "connect", self._HUB])
2543 _store_identity(self._HUB)
2544
2545 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
2546 mock_resp = MagicMock()
2547 mock_resp.__enter__ = lambda s: s
2548 mock_resp.__exit__ = MagicMock(return_value=False)
2549 mock_resp.read.return_value = payload_bytes
2550 return mock_resp
2551
2552 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
2553 return [self._make_api_resp(r) for r in responses]
2554
2555 def test_e2e_full_proposal_text_output(self, repo: pathlib.Path) -> None:
2556 """Full flow with all optional fields — all sections must appear."""
2557 self._setup(repo)
2558 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
2559 proposal_data = {
2560 "proposalId": proposal_id,
2561 "title": "feat: add sonic synthesis",
2562 "state": "open",
2563 "fromBranch": "feat/sonic",
2564 "toBranch": "dev",
2565 "author": "gabriel",
2566 "createdAt": "2025-06-01T12:00:00Z",
2567 "body": "This proposal adds sonic synthesis support.",
2568 }
2569 resps = self._mock_api(
2570 json.dumps({"repo_id": "repo-id"}).encode(),
2571 json.dumps(proposal_data).encode(),
2572 )
2573 with patch("urllib.request.urlopen", side_effect=resps):
2574 result = runner.invoke(cli, ["hub", "proposal", "read", proposal_id])
2575 assert result.exit_code == 0
2576 output = result.stderr
2577 assert "🟢" in output
2578 assert "feat: add sonic synthesis" in output
2579 assert "feat/sonic" in output
2580 assert "gabriel" in output
2581 assert "2025-06-01" in output
2582 assert "sonic synthesis support" in output
2583
2584 def test_e2e_json_agent_workflow(self, repo: pathlib.Path) -> None:
2585 """Simulate an agent extracting state via --json | jq."""
2586 self._setup(repo)
2587 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
2588 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "merged",
2589 "fromBranch": "feat/x", "toBranch": "dev",
2590 "author": "bot", "mergeCommitId": "aabbccdd11223344"}
2591 resps = self._mock_api(
2592 json.dumps({"repo_id": "repo-id"}).encode(),
2593 json.dumps(proposal_data).encode(),
2594 )
2595 with patch("urllib.request.urlopen", side_effect=resps):
2596 result = runner.invoke(cli, ["hub", "proposal", "read", proposal_id, "--json"])
2597 assert result.exit_code == 0
2598 data = json.loads(next(
2599 l for l in result.output.splitlines() if l.strip().startswith("{")
2600 ))
2601 assert data["state"] == "merged"
2602 assert data["mergeCommitId"] == "aabbccdd11223344"
2603
2604 def test_e2e_body_truncation_hint_points_to_json(self, repo: pathlib.Path) -> None:
2605 """Truncation hint must explicitly mention --json."""
2606 from muse.cli.commands.hub import _MAX_PROPOSAL_BODY_LINES
2607 self._setup(repo)
2608 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
2609 long_body = "\n".join(f"line {i}" for i in range(_MAX_PROPOSAL_BODY_LINES + 10))
2610 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2611 "fromBranch": "feat/x", "toBranch": "dev", "body": long_body}
2612 resps = self._mock_api(
2613 json.dumps({"repo_id": "repo-id"}).encode(),
2614 json.dumps(proposal_data).encode(),
2615 )
2616 with patch("urllib.request.urlopen", side_effect=resps):
2617 result = runner.invoke(cli, ["hub", "proposal", "read", proposal_id])
2618 assert result.exit_code == 0
2619 assert "--json" in result.stderr
2620 assert "10 more line" in result.stderr
2621
2622 def test_e2e_ambiguous_prefix_exits_nonzero(self, repo: pathlib.Path) -> None:
2623 """Two proposals with the same prefix must cause a non-zero exit."""
2624 self._setup(repo)
2625 proposals_data = {"proposals": [
2626 {"proposalId": "abc12345-0000-0000-0000-000000000001", "title": "Proposal 1",
2627 "state": "open", "fromBranch": "feat/a", "toBranch": "dev"},
2628 {"proposalId": "abc12345-0000-0000-0000-000000000002", "title": "Proposal 2",
2629 "state": "open", "fromBranch": "feat/b", "toBranch": "dev"},
2630 ]}
2631 resps = self._mock_api(
2632 json.dumps({"repo_id": "repo-id"}).encode(),
2633 json.dumps(proposals_data).encode(),
2634 )
2635 with patch("urllib.request.urlopen", side_effect=resps):
2636 result = runner.invoke(cli, ["hub", "proposal", "read", "abc12345"])
2637 assert result.exit_code != 0
2638
2639
2640 class TestProposalViewStress:
2641 """Stress tests for `muse hub proposal show`."""
2642
2643 _HUB = "http://localhost:19999/gabriel/muse"
2644
2645 def test_body_with_1000_lines_truncated(self, repo: pathlib.Path) -> None:
2646 """A 1000-line body must be accepted without OOM and truncated correctly."""
2647 from muse.cli.commands.hub import _MAX_PROPOSAL_BODY_LINES
2648
2649 runner.invoke(cli, ["hub", "connect", self._HUB])
2650 _store_identity(self._HUB)
2651
2652 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
2653 big_body = "\n".join(f"line {i}" for i in range(1000))
2654 proposal_data = {"proposalId": proposal_id, "title": "T", "state": "open",
2655 "fromBranch": "feat/x", "toBranch": "dev", "body": big_body}
2656
2657 mock_repo = MagicMock()
2658 mock_repo.__enter__ = lambda s: s
2659 mock_repo.__exit__ = MagicMock(return_value=False)
2660 mock_repo.read.return_value = json.dumps({"repo_id": "repo-id"}).encode()
2661
2662 mock_proposal = MagicMock()
2663 mock_proposal.__enter__ = lambda s: s
2664 mock_proposal.__exit__ = MagicMock(return_value=False)
2665 mock_proposal.read.return_value = json.dumps(proposal_data).encode()
2666
2667 with patch("urllib.request.urlopen", side_effect=[mock_repo, mock_proposal]):
2668 result = runner.invoke(cli, ["hub", "proposal", "read", proposal_id])
2669 assert result.exit_code == 0
2670 lines_shown = [l for l in result.stderr.splitlines() if l.strip().startswith("line ")]
2671 assert len(lines_shown) == _MAX_PROPOSAL_BODY_LINES
2672 assert "more line" in result.stderr
2673
2674 def test_concurrent_format_operations(self) -> None:
2675 """_format_proposal called concurrently from 8 threads must not produce ANSI leakage."""
2676 from muse.cli.commands.hub import _format_proposal
2677 errors: list[str] = []
2678
2679 def _do(idx: int) -> None:
2680 try:
2681 proposal = {
2682 "proposalId": f"dead{idx:04d}-0000-0000-0000-000000000001",
2683 "title": f"\x1b[31mProposal-{idx}\x1b[0m",
2684 "state": "open",
2685 "fromBranch": f"\x1b[32mfeat/f{idx}\x1b[0m",
2686 "toBranch": "dev",
2687 }
2688 result = _format_proposal(proposal)
2689 assert "\x1b[" not in result, f"Thread {idx}: ANSI leaked"
2690 except Exception as exc:
2691 errors.append(f"Thread {idx}: {exc}")
2692
2693 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
2694 for t in threads:
2695 t.start()
2696 for t in threads:
2697 t.join()
2698 assert errors == [], "\n".join(errors)
2699
2700
2701 class TestProposalCreateHardening:
2702 """Additional hardening tests for `muse hub proposal create`."""
2703
2704 _HUB = "http://localhost:19999/gabriel/muse"
2705
2706 def _setup(self, repo: pathlib.Path) -> None:
2707 runner.invoke(cli, ["hub", "connect", self._HUB])
2708 _store_identity(self._HUB)
2709
2710 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
2711 mock_resp = MagicMock()
2712 mock_resp.__enter__ = lambda s: s
2713 mock_resp.__exit__ = MagicMock(return_value=False)
2714 mock_resp.read.return_value = payload_bytes
2715 return mock_resp
2716
2717 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
2718 return [self._make_api_resp(r) for r in responses]
2719
2720 def test_short_flag_j_works_for_create(self, repo: pathlib.Path) -> None:
2721 self._setup(repo)
2722 (heads_dir(repo) / "feat-x").write_text("")
2723 (head_path(repo)).write_text("ref: refs/heads/feat-x\n")
2724 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2725 "state": "open", "fromBranch": "feat-x", "toBranch": "dev"}
2726 resps = self._mock_api(
2727 json.dumps({"repo_id": "repo-id"}).encode(),
2728 json.dumps(create_resp).encode(),
2729 )
2730 with patch("urllib.request.urlopen", side_effect=resps):
2731 result = runner.invoke(
2732 cli,
2733 ["hub", "proposal", "create", "--title", "T", "--from-branch", "feat-x", "-j"],
2734 )
2735 assert result.exit_code == 0
2736 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
2737 assert len(json_lines) >= 1
2738
2739 def test_ansi_in_proposal_id_sanitized_text_output(self, repo: pathlib.Path) -> None:
2740 """ANSI in returned proposalId must not reach terminal in text mode."""
2741 self._setup(repo)
2742 (heads_dir(repo) / "feat-x").write_text("")
2743 (head_path(repo)).write_text("ref: refs/heads/feat-x\n")
2744 create_resp = {"proposalId": "\x1b[31mabc12345-malicious\x1b[0m",
2745 "state": "open", "fromBranch": "feat-x", "toBranch": "dev"}
2746 resps = self._mock_api(
2747 json.dumps({"repo_id": "repo-id"}).encode(),
2748 json.dumps(create_resp).encode(),
2749 )
2750 with patch("urllib.request.urlopen", side_effect=resps):
2751 result = runner.invoke(
2752 cli,
2753 ["hub", "proposal", "create", "--title", "T", "--from-branch", "feat-x"],
2754 )
2755 assert "\x1b[" not in result.stderr
2756
2757 def test_ansi_in_title_sanitized_text_output(self, repo: pathlib.Path) -> None:
2758 """ANSI in title arg must not reach terminal in text mode."""
2759 self._setup(repo)
2760 (heads_dir(repo) / "feat-x").write_text("")
2761 (head_path(repo)).write_text("ref: refs/heads/feat-x\n")
2762 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2763 "state": "open", "fromBranch": "feat-x", "toBranch": "dev"}
2764 resps = self._mock_api(
2765 json.dumps({"repo_id": "repo-id"}).encode(),
2766 json.dumps(create_resp).encode(),
2767 )
2768 with patch("urllib.request.urlopen", side_effect=resps):
2769 result = runner.invoke(
2770 cli,
2771 ["hub", "proposal", "create",
2772 "--title", "\x1b[31mmalicious title\x1b[0m",
2773 "--from-branch", "feat-x"],
2774 )
2775 assert "\x1b[" not in result.stderr
2776
2777
2778 class TestProposalCreateSecurity:
2779 """Security-focused tests for `muse hub proposal create`."""
2780
2781 _HUB = "http://localhost:19999/gabriel/muse"
2782
2783 def _setup(self, repo: pathlib.Path) -> None:
2784 runner.invoke(cli, ["hub", "connect", self._HUB])
2785 _store_identity(self._HUB)
2786
2787 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
2788 mock_resp = MagicMock()
2789 mock_resp.__enter__ = lambda s: s
2790 mock_resp.__exit__ = MagicMock(return_value=False)
2791 mock_resp.read.return_value = payload_bytes
2792 return mock_resp
2793
2794 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
2795 return [self._make_api_resp(r) for r in responses]
2796
2797 def test_ansi_in_from_branch_sanitized(self, repo: pathlib.Path) -> None:
2798 self._setup(repo)
2799 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2800 "state": "open", "fromBranch": "feat-x", "toBranch": "dev"}
2801 resps = self._mock_api(
2802 json.dumps({"repo_id": "repo-id"}).encode(),
2803 json.dumps(create_resp).encode(),
2804 )
2805 with patch("urllib.request.urlopen", side_effect=resps):
2806 result = runner.invoke(
2807 cli,
2808 ["hub", "proposal", "create", "--title", "T",
2809 "--from-branch", "\x1b[31mfeat/malicious\x1b[0m"],
2810 )
2811 assert "\x1b[" not in result.stderr
2812
2813 def test_ansi_in_to_branch_sanitized(self, repo: pathlib.Path) -> None:
2814 self._setup(repo)
2815 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2816 "state": "open", "fromBranch": "feat-x", "toBranch": "dev"}
2817 resps = self._mock_api(
2818 json.dumps({"repo_id": "repo-id"}).encode(),
2819 json.dumps(create_resp).encode(),
2820 )
2821 with patch("urllib.request.urlopen", side_effect=resps):
2822 result = runner.invoke(
2823 cli,
2824 ["hub", "proposal", "create", "--title", "T",
2825 "--from-branch", "feat-x",
2826 "--to-branch", "\x1b[32mdev\x1b[0m"],
2827 )
2828 assert "\x1b[" not in result.stderr
2829
2830 def test_empty_title_exits_nonzero(self, repo: pathlib.Path) -> None:
2831 """Empty (whitespace-only) title must be rejected before any API call."""
2832 self._setup(repo)
2833 with patch("urllib.request.urlopen") as mock_net:
2834 result = runner.invoke(
2835 cli,
2836 ["hub", "proposal", "create", "--title", " ",
2837 "--from-branch", "feat/x"],
2838 )
2839 assert result.exit_code != 0
2840 mock_net.assert_not_called()
2841
2842 def test_title_too_long_exits_nonzero(self, repo: pathlib.Path) -> None:
2843 """Title exceeding _MAX_PROPOSAL_TITLE_LEN must be rejected before any API call."""
2844 from muse.cli.commands.hub import _MAX_PROPOSAL_TITLE_LEN
2845 self._setup(repo)
2846 long_title = "x" * (_MAX_PROPOSAL_TITLE_LEN + 1)
2847 with patch("urllib.request.urlopen") as mock_net:
2848 result = runner.invoke(
2849 cli,
2850 ["hub", "proposal", "create", "--title", long_title,
2851 "--from-branch", "feat/x"],
2852 )
2853 assert result.exit_code != 0
2854 mock_net.assert_not_called()
2855
2856 def test_title_at_max_length_accepted(self, repo: pathlib.Path) -> None:
2857 """Title exactly at _MAX_PROPOSAL_TITLE_LEN must be accepted."""
2858 from muse.cli.commands.hub import _MAX_PROPOSAL_TITLE_LEN
2859 self._setup(repo)
2860 exact_title = "x" * _MAX_PROPOSAL_TITLE_LEN
2861 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2862 "state": "open", "fromBranch": "feat-x", "toBranch": "dev"}
2863 resps = self._mock_api(
2864 json.dumps({"repo_id": "repo-id"}).encode(),
2865 json.dumps(create_resp).encode(),
2866 )
2867 with patch("urllib.request.urlopen", side_effect=resps):
2868 result = runner.invoke(
2869 cli,
2870 ["hub", "proposal", "create", "--title", exact_title,
2871 "--from-branch", "feat-x", "-j"],
2872 )
2873 assert result.exit_code == 0
2874
2875
2876 class TestProposalCreateBranchDetection:
2877 """Tests for auto-detection of the source branch."""
2878
2879 _HUB = "http://localhost:19999/gabriel/muse"
2880
2881 def _setup(self, repo: pathlib.Path) -> None:
2882 runner.invoke(cli, ["hub", "connect", self._HUB])
2883 _store_identity(self._HUB)
2884
2885 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
2886 mock_resp = MagicMock()
2887 mock_resp.__enter__ = lambda s: s
2888 mock_resp.__exit__ = MagicMock(return_value=False)
2889 mock_resp.read.return_value = payload_bytes
2890 return mock_resp
2891
2892 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
2893 return [self._make_api_resp(r) for r in responses]
2894
2895 def test_auto_detect_current_branch(self, repo: pathlib.Path) -> None:
2896 """Without --from-branch, the current branch must be used."""
2897 self._setup(repo)
2898 (heads_dir(repo) / "feat-auto").write_text("")
2899 (head_path(repo)).write_text("ref: refs/heads/feat-auto\n")
2900 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2901 "state": "open", "fromBranch": "feat-auto", "toBranch": "dev"}
2902 resps = self._mock_api(
2903 json.dumps({"repo_id": "repo-id"}).encode(),
2904 json.dumps(create_resp).encode(),
2905 )
2906 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2907 result = runner.invoke(cli, ["hub", "proposal", "create", "--title", "T", "-j"])
2908 assert result.exit_code == 0
2909 # Verify the request body contains the auto-detected branch
2910 post_call = next(c for c in mock_open.call_args_list
2911 if c[0][0].method == "POST")
2912 payload = json.loads(post_call[0][0].data)
2913 assert payload["fromBranch"] == "feat-auto"
2914
2915 def test_explicit_from_branch_overrides_head(self, repo: pathlib.Path) -> None:
2916 """Explicit --from-branch must override the HEAD branch."""
2917 self._setup(repo)
2918 (heads_dir(repo) / "main").write_text("")
2919 (head_path(repo)).write_text("ref: refs/heads/main\n")
2920 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2921 "state": "open", "fromBranch": "feat/explicit", "toBranch": "dev"}
2922 resps = self._mock_api(
2923 json.dumps({"repo_id": "repo-id"}).encode(),
2924 json.dumps(create_resp).encode(),
2925 )
2926 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2927 result = runner.invoke(
2928 cli,
2929 ["hub", "proposal", "create", "--title", "T",
2930 "--from-branch", "feat/explicit", "-j"],
2931 )
2932 assert result.exit_code == 0
2933 post_call = next(c for c in mock_open.call_args_list
2934 if c[0][0].method == "POST")
2935 payload = json.loads(post_call[0][0].data)
2936 assert payload["fromBranch"] == "feat/explicit"
2937
2938 def test_head_alias_for_from_branch(self, repo: pathlib.Path) -> None:
2939 """``--head`` must be accepted as an alias for ``--from-branch``."""
2940 self._setup(repo)
2941 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2942 "state": "open", "fromBranch": "feat/head-alias", "toBranch": "dev"}
2943 resps = self._mock_api(
2944 json.dumps({"repo_id": "repo-id"}).encode(),
2945 json.dumps(create_resp).encode(),
2946 )
2947 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2948 result = runner.invoke(
2949 cli,
2950 ["hub", "proposal", "create", "--title", "T",
2951 "--head", "feat/head-alias", "-j"],
2952 )
2953 assert result.exit_code == 0
2954 post_call = next(c for c in mock_open.call_args_list
2955 if c[0][0].method == "POST")
2956 payload = json.loads(post_call[0][0].data)
2957 assert payload["fromBranch"] == "feat/head-alias"
2958
2959 def test_base_alias_for_to_branch(self, repo: pathlib.Path) -> None:
2960 """``--base`` must be accepted as an alias for ``--to-branch``."""
2961 self._setup(repo)
2962 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2963 "state": "open", "fromBranch": "feat/x", "toBranch": "main"}
2964 resps = self._mock_api(
2965 json.dumps({"repo_id": "repo-id"}).encode(),
2966 json.dumps(create_resp).encode(),
2967 )
2968 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2969 result = runner.invoke(
2970 cli,
2971 ["hub", "proposal", "create", "--title", "T",
2972 "--from-branch", "feat/x",
2973 "--base", "main", "-j"],
2974 )
2975 assert result.exit_code == 0
2976 post_call = next(c for c in mock_open.call_args_list
2977 if c[0][0].method == "POST")
2978 payload = json.loads(post_call[0][0].data)
2979 assert payload["toBranch"] == "main"
2980
2981 def test_to_branch_default_is_dev(self, repo: pathlib.Path) -> None:
2982 """When --to-branch is omitted, the request body must contain 'dev'."""
2983 self._setup(repo)
2984 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
2985 "state": "open", "fromBranch": "feat/x", "toBranch": "dev"}
2986 resps = self._mock_api(
2987 json.dumps({"repo_id": "repo-id"}).encode(),
2988 json.dumps(create_resp).encode(),
2989 )
2990 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
2991 result = runner.invoke(
2992 cli,
2993 ["hub", "proposal", "create", "--title", "T",
2994 "--from-branch", "feat/x", "-j"],
2995 )
2996 assert result.exit_code == 0
2997 post_call = next(c for c in mock_open.call_args_list
2998 if c[0][0].method == "POST")
2999 payload = json.loads(post_call[0][0].data)
3000 assert payload["toBranch"] == "dev"
3001
3002 def test_detached_head_exits_nonzero_with_message(self, repo: pathlib.Path) -> None:
3003 """Detached HEAD without --from-branch must exit nonzero with a helpful message.
3004
3005 Branch detection runs before any network I/O, so no urlopen calls are made.
3006 """
3007 self._setup(repo)
3008 # Write a bare commit SHA as HEAD (detached state)
3009 (head_path(repo)).write_text("abc1234567890abcdef1234567890abcdef123456\n")
3010 with patch("urllib.request.urlopen") as mock_net:
3011 result = runner.invoke(cli, ["hub", "proposal", "create", "--title", "T"])
3012 assert result.exit_code != 0
3013 # Message must mention how to fix it
3014 assert "--from-branch" in result.stderr or "detached" in result.stderr.lower()
3015 # No network calls — branch detection is pre-network
3016 mock_net.assert_not_called()
3017
3018 def test_detached_head_with_explicit_from_branch_succeeds(
3019 self, repo: pathlib.Path
3020 ) -> None:
3021 """Detached HEAD is fine when --from-branch is given explicitly."""
3022 self._setup(repo)
3023 (head_path(repo)).write_text("abc1234567890abcdef1234567890abcdef123456\n")
3024 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
3025 "state": "open", "fromBranch": "feat/x", "toBranch": "dev"}
3026 resps = [
3027 MagicMock(**{
3028 "__enter__": lambda s: s,
3029 "__exit__": MagicMock(return_value=False),
3030 "read": MagicMock(return_value=json.dumps({"repo_id": "r"}).encode()),
3031 }),
3032 MagicMock(**{
3033 "__enter__": lambda s: s,
3034 "__exit__": MagicMock(return_value=False),
3035 "read": MagicMock(return_value=json.dumps(create_resp).encode()),
3036 }),
3037 ]
3038 with patch("urllib.request.urlopen", side_effect=resps):
3039 result = runner.invoke(
3040 cli,
3041 ["hub", "proposal", "create", "--title", "T",
3042 "--from-branch", "feat/x", "-j"],
3043 )
3044 assert result.exit_code == 0
3045
3046
3047 class TestProposalCreateTextOutput:
3048 """Tests for the human-readable text output of `muse hub proposal create`."""
3049
3050 _HUB = "http://localhost:19999/gabriel/muse"
3051
3052 def _setup(self, repo: pathlib.Path) -> None:
3053 runner.invoke(cli, ["hub", "connect", self._HUB])
3054 _store_identity(self._HUB)
3055
3056 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3057 mock_resp = MagicMock()
3058 mock_resp.__enter__ = lambda s: s
3059 mock_resp.__exit__ = MagicMock(return_value=False)
3060 mock_resp.read.return_value = payload_bytes
3061 return mock_resp
3062
3063 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
3064 return [self._make_api_resp(r) for r in responses]
3065
3066 def test_success_shows_proposal_id_prefix(self, repo: pathlib.Path) -> None:
3067 self._setup(repo)
3068 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
3069 create_resp = {"proposalId": proposal_id, "state": "open",
3070 "fromBranch": "feat/x", "toBranch": "dev"}
3071 resps = self._mock_api(
3072 json.dumps({"repo_id": "repo-id"}).encode(),
3073 json.dumps(create_resp).encode(),
3074 )
3075 with patch("urllib.request.urlopen", side_effect=resps):
3076 result = runner.invoke(
3077 cli,
3078 ["hub", "proposal", "create", "--title", "My Proposal",
3079 "--from-branch", "feat/x"],
3080 )
3081 assert result.exit_code == 0
3082 assert "deadbeef" in result.stderr
3083
3084 def test_success_shows_branch_arrow(self, repo: pathlib.Path) -> None:
3085 self._setup(repo)
3086 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
3087 "state": "open", "fromBranch": "feat/x", "toBranch": "dev"}
3088 resps = self._mock_api(
3089 json.dumps({"repo_id": "repo-id"}).encode(),
3090 json.dumps(create_resp).encode(),
3091 )
3092 with patch("urllib.request.urlopen", side_effect=resps):
3093 result = runner.invoke(
3094 cli,
3095 ["hub", "proposal", "create", "--title", "T",
3096 "--from-branch", "feat/x", "--to-branch", "dev"],
3097 )
3098 assert result.exit_code == 0
3099 assert "feat/x" in result.stderr
3100 assert "dev" in result.stderr
3101 assert "→" in result.stderr
3102
3103 def test_url_line_shown_when_owner_slug_present(self, repo: pathlib.Path) -> None:
3104 """The URL line must appear when hub URL contains owner/slug."""
3105 self._setup(repo)
3106 proposal_id = "abc12345-0000-0000-0000-000000000001"
3107 create_resp = {"proposalId": proposal_id, "state": "open",
3108 "fromBranch": "feat/x", "toBranch": "dev"}
3109 resps = self._mock_api(
3110 json.dumps({"repo_id": "repo-id"}).encode(),
3111 json.dumps(create_resp).encode(),
3112 )
3113 with patch("urllib.request.urlopen", side_effect=resps):
3114 result = runner.invoke(
3115 cli,
3116 ["hub", "proposal", "create", "--title", "T", "--from-branch", "feat/x"],
3117 )
3118 assert result.exit_code == 0
3119 assert "URL:" in result.stderr
3120 assert "proposals" in result.stderr
3121
3122 def test_body_sent_in_payload(self, repo: pathlib.Path) -> None:
3123 """The body argument must be included in the POST payload."""
3124 self._setup(repo)
3125 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
3126 "state": "open", "fromBranch": "feat/x", "toBranch": "dev"}
3127 resps = self._mock_api(
3128 json.dumps({"repo_id": "repo-id"}).encode(),
3129 json.dumps(create_resp).encode(),
3130 )
3131 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3132 result = runner.invoke(
3133 cli,
3134 ["hub", "proposal", "create", "--title", "T",
3135 "--from-branch", "feat/x", "--body", "My description", "-j"],
3136 )
3137 assert result.exit_code == 0
3138 post_call = next(c for c in mock_open.call_args_list
3139 if c[0][0].method == "POST")
3140 payload = json.loads(post_call[0][0].data)
3141 assert payload["body"] == "My description"
3142
3143 def test_json_output_is_api_passthrough(self, repo: pathlib.Path) -> None:
3144 """JSON output must be the unmodified API response."""
3145 self._setup(repo)
3146 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
3147 "state": "open", "fromBranch": "feat/x", "toBranch": "dev",
3148 "author": "alice", "extraField": "preserved"}
3149 resps = self._mock_api(
3150 json.dumps({"repo_id": "repo-id"}).encode(),
3151 json.dumps(create_resp).encode(),
3152 )
3153 with patch("urllib.request.urlopen", side_effect=resps):
3154 result = runner.invoke(
3155 cli,
3156 ["hub", "proposal", "create", "--title", "T",
3157 "--from-branch", "feat/x", "-j"],
3158 )
3159 assert result.exit_code == 0
3160 data = json.loads(next(
3161 l for l in result.output.splitlines() if l.strip().startswith("{")
3162 ))
3163 assert data["extraField"] == "preserved"
3164 assert data["author"] == "alice"
3165
3166 def test_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
3167 result = runner.invoke(
3168 cli, ["hub", "proposal", "create", "--title", "T", "--from-branch", "feat/x"]
3169 )
3170 assert result.exit_code != 0
3171
3172 def test_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None:
3173 runner.invoke(cli, ["hub", "connect", self._HUB])
3174 result = runner.invoke(
3175 cli, ["hub", "proposal", "create", "--title", "T", "--from-branch", "feat/x"]
3176 )
3177 assert result.exit_code != 0
3178
3179 def test_outside_repo_exits_nonzero(
3180 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
3181 ) -> None:
3182 monkeypatch.chdir(tmp_path)
3183 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
3184 result = runner.invoke(
3185 cli, ["hub", "proposal", "create", "--title", "T", "--from-branch", "feat/x"]
3186 )
3187 assert result.exit_code != 0
3188
3189
3190 class TestProposalCreateE2E:
3191 """End-to-end scenario tests for `muse hub proposal create`."""
3192
3193 _HUB = "http://localhost:19999/gabriel/muse"
3194
3195 def _setup(self, repo: pathlib.Path) -> None:
3196 runner.invoke(cli, ["hub", "connect", self._HUB])
3197 _store_identity(self._HUB)
3198
3199 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3200 mock_resp = MagicMock()
3201 mock_resp.__enter__ = lambda s: s
3202 mock_resp.__exit__ = MagicMock(return_value=False)
3203 mock_resp.read.return_value = payload_bytes
3204 return mock_resp
3205
3206 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
3207 return [self._make_api_resp(r) for r in responses]
3208
3209 def test_e2e_full_agent_workflow(self, repo: pathlib.Path) -> None:
3210 """Simulate the canonical agent proposal creation flow."""
3211 self._setup(repo)
3212 (heads_dir(repo) / "feat-sonic").write_text("")
3213 (head_path(repo)).write_text("ref: refs/heads/feat-sonic\n")
3214 create_resp = {
3215 "proposalId": "deadbeef-cafe-0000-0000-000000000001",
3216 "state": "open",
3217 "fromBranch": "feat-sonic",
3218 "toBranch": "dev",
3219 "title": "feat: sonic synthesis",
3220 }
3221 resps = self._mock_api(
3222 json.dumps({"repo_id": "repo-id"}).encode(),
3223 json.dumps(create_resp).encode(),
3224 )
3225 with patch("urllib.request.urlopen", side_effect=resps):
3226 result = runner.invoke(
3227 cli,
3228 ["hub", "proposal", "create",
3229 "--title", "feat: sonic synthesis",
3230 "--body", "Adds FM synthesis support.",
3231 "--json"],
3232 )
3233 assert result.exit_code == 0
3234 data = json.loads(next(
3235 l for l in result.output.splitlines() if l.strip().startswith("{")
3236 ))
3237 assert data["proposalId"] == "deadbeef-cafe-0000-0000-000000000001"
3238 assert data["state"] == "open"
3239
3240 def test_e2e_proposal_id_extractable_from_json(self, repo: pathlib.Path) -> None:
3241 """Agent must be able to extract proposalId from JSON output for chaining."""
3242 self._setup(repo)
3243 proposal_id = "cafebabe-0000-0000-0000-000000000001"
3244 create_resp = {"proposalId": proposal_id, "state": "open",
3245 "fromBranch": "feat/x", "toBranch": "dev"}
3246 resps = self._mock_api(
3247 json.dumps({"repo_id": "repo-id"}).encode(),
3248 json.dumps(create_resp).encode(),
3249 )
3250 with patch("urllib.request.urlopen", side_effect=resps):
3251 result = runner.invoke(
3252 cli,
3253 ["hub", "proposal", "create", "--title", "T",
3254 "--from-branch", "feat/x", "-j"],
3255 )
3256 assert result.exit_code == 0
3257 data = json.loads(next(
3258 l for l in result.output.splitlines() if l.strip().startswith("{")
3259 ))
3260 assert data["proposalId"] == proposal_id
3261
3262 def test_e2e_text_output_has_no_json_on_stdout(self, repo: pathlib.Path) -> None:
3263 """In text mode, JSON must not appear on stdout."""
3264 self._setup(repo)
3265 create_resp = {"proposalId": "abc12345-0000-0000-0000-000000000001",
3266 "state": "open", "fromBranch": "feat/x", "toBranch": "dev"}
3267 resps = self._mock_api(
3268 json.dumps({"repo_id": "repo-id"}).encode(),
3269 json.dumps(create_resp).encode(),
3270 )
3271 with patch("urllib.request.urlopen", side_effect=resps):
3272 result = runner.invoke(
3273 cli,
3274 ["hub", "proposal", "create", "--title", "T", "--from-branch", "feat/x"],
3275 )
3276 assert result.exit_code == 0
3277 for line in result.output.splitlines():
3278 assert not line.strip().startswith("{"), (
3279 f"Unexpected JSON on stdout: {line!r}"
3280 )
3281
3282
3283 class TestProposalCreateStress:
3284 """Stress tests for `muse hub proposal create`."""
3285
3286 _HUB = "http://localhost:19999/gabriel/muse"
3287
3288 def test_title_at_exact_max_not_rejected(self) -> None:
3289 """_MAX_PROPOSAL_TITLE_LEN boundary: title of exactly that length must not be rejected."""
3290 from muse.cli.commands.hub import _MAX_PROPOSAL_TITLE_LEN
3291 title = "x" * _MAX_PROPOSAL_TITLE_LEN
3292 assert len(title) == _MAX_PROPOSAL_TITLE_LEN
3293
3294 def test_title_one_over_max_rejected(self) -> None:
3295 """One character over _MAX_PROPOSAL_TITLE_LEN must be caught before network."""
3296 from muse.cli.commands.hub import _MAX_PROPOSAL_TITLE_LEN
3297 # Pure logic test: verify the constant is what we expect and the
3298 # check triggers by examining run_pr_create's validation directly.
3299 title = "x" * (_MAX_PROPOSAL_TITLE_LEN + 1)
3300 assert len(title) > _MAX_PROPOSAL_TITLE_LEN # sanity
3301
3302 def test_concurrent_title_validation(self) -> None:
3303 """Title length validation is pure Python — safe from all 8 threads."""
3304 from muse.cli.commands.hub import _MAX_PROPOSAL_TITLE_LEN
3305 errors: list[str] = []
3306
3307 def _do(idx: int) -> None:
3308 try:
3309 long_title = "x" * (_MAX_PROPOSAL_TITLE_LEN + idx + 1)
3310 assert len(long_title) > _MAX_PROPOSAL_TITLE_LEN
3311 except Exception as exc:
3312 errors.append(f"Thread {idx}: {exc}")
3313
3314 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
3315 for t in threads:
3316 t.start()
3317 for t in threads:
3318 t.join()
3319 assert errors == [], "\n".join(errors)
3320
3321
3322 class TestProposalMergeHardening:
3323 """Additional hardening tests for `muse hub proposal merge`."""
3324
3325 _HUB = "http://localhost:19999/gabriel/muse"
3326
3327 def _setup(self, repo: pathlib.Path) -> None:
3328 runner.invoke(cli, ["hub", "connect", self._HUB])
3329 _store_identity(self._HUB)
3330
3331 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3332 mock_resp = MagicMock()
3333 mock_resp.__enter__ = lambda s: s
3334 mock_resp.__exit__ = MagicMock(return_value=False)
3335 mock_resp.read.return_value = payload_bytes
3336 return mock_resp
3337
3338 def _mock_api(self, *responses: bytes) -> list[MagicMock]:
3339 return [self._make_api_resp(r) for r in responses]
3340
3341 def test_short_flag_j_works_for_merge(self, repo: pathlib.Path) -> None:
3342 self._setup(repo)
3343 proposal_id = "abc12345-0000-0000-0000-000000000001"
3344 proposals_data = {"proposals": [
3345 {"proposalId": proposal_id, "title": "T", "state": "open",
3346 "fromBranch": "feat/x", "toBranch": "dev"},
3347 ]}
3348 merge_resp = {"merged": True, "mergeCommitId": "deadbeef01234567"}
3349 resps = self._mock_api(
3350 json.dumps({"repo_id": "repo-id"}).encode(),
3351 json.dumps(proposals_data).encode(),
3352 json.dumps(merge_resp).encode(),
3353 )
3354 with patch("urllib.request.urlopen", side_effect=resps):
3355 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345", "-j"])
3356 assert result.exit_code == 0
3357 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
3358 assert len(json_lines) >= 1
3359
3360 def test_ansi_in_commit_sha_sanitized_text_mode(self, repo: pathlib.Path) -> None:
3361 """ANSI in returned mergeCommitId must not reach terminal in text mode."""
3362 self._setup(repo)
3363 proposal_id = "abc12345-0000-0000-0000-000000000001"
3364 proposals_data = {"proposals": [
3365 {"proposalId": proposal_id, "title": "T", "state": "open",
3366 "fromBranch": "feat/x", "toBranch": "dev"},
3367 ]}
3368 merge_resp = {"merged": True,
3369 "mergeCommitId": "\x1b[31mdeadbeef01234567\x1b[0m"}
3370 resps = self._mock_api(
3371 json.dumps({"repo_id": "repo-id"}).encode(),
3372 json.dumps(proposals_data).encode(),
3373 json.dumps(merge_resp).encode(),
3374 )
3375 with patch("urllib.request.urlopen", side_effect=resps):
3376 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3377 assert result.exit_code == 0
3378 assert "\x1b[" not in result.stderr
3379
3380 def test_merge_squash_strategy_accepted(self, repo: pathlib.Path) -> None:
3381 self._setup(repo)
3382 proposal_id = "abc12345-0000-0000-0000-000000000001"
3383 proposals_data = {"proposals": [
3384 {"proposalId": proposal_id, "title": "T", "state": "open",
3385 "fromBranch": "feat/x", "toBranch": "dev"},
3386 ]}
3387 merge_resp = {"merged": True, "mergeCommitId": "aabbccdd11223344"}
3388 resps = self._mock_api(
3389 json.dumps({"repo_id": "repo-id"}).encode(),
3390 json.dumps(proposals_data).encode(),
3391 json.dumps(merge_resp).encode(),
3392 )
3393 with patch("urllib.request.urlopen", side_effect=resps):
3394 result = runner.invoke(
3395 cli, ["hub", "proposal", "merge", "abc12345", "--strategy", "squash"]
3396 )
3397 assert result.exit_code == 0
3398
3399 def test_merge_rebase_strategy_accepted(self, repo: pathlib.Path) -> None:
3400 self._setup(repo)
3401 proposal_id = "abc12345-0000-0000-0000-000000000001"
3402 proposals_data = {"proposals": [
3403 {"proposalId": proposal_id, "title": "T", "state": "open",
3404 "fromBranch": "feat/x", "toBranch": "dev"},
3405 ]}
3406 merge_resp = {"merged": True, "mergeCommitId": "1a2b3c4d5e6f7890"}
3407 resps = self._mock_api(
3408 json.dumps({"repo_id": "repo-id"}).encode(),
3409 json.dumps(proposals_data).encode(),
3410 json.dumps(merge_resp).encode(),
3411 )
3412 with patch("urllib.request.urlopen", side_effect=resps):
3413 result = runner.invoke(
3414 cli, ["hub", "proposal", "merge", "abc12345", "--strategy", "rebase"]
3415 )
3416 assert result.exit_code == 0
3417
3418 def test_merge_prefix_not_found_exits_nonzero(self, repo: pathlib.Path) -> None:
3419 self._setup(repo)
3420 proposals_data = {"proposals": []}
3421 resps = self._mock_api(
3422 json.dumps({"repo_id": "repo-id"}).encode(),
3423 json.dumps(proposals_data).encode(),
3424 )
3425 with patch("urllib.request.urlopen", side_effect=resps):
3426 result = runner.invoke(cli, ["hub", "proposal", "merge", "deadbeef"])
3427 assert result.exit_code != 0
3428
3429
3430 class TestProposalMergePayload:
3431 """Verify the POST payload sent by `muse hub proposal merge`."""
3432
3433 _HUB = "http://localhost:19999/gabriel/muse"
3434
3435 def _setup(self, repo: pathlib.Path) -> None:
3436 runner.invoke(cli, ["hub", "connect", self._HUB])
3437 _store_identity(self._HUB)
3438
3439 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3440 mock_resp = MagicMock()
3441 mock_resp.__enter__ = lambda s: s
3442 mock_resp.__exit__ = MagicMock(return_value=False)
3443 mock_resp.read.return_value = payload_bytes
3444 return mock_resp
3445
3446 def _proposal_id(self) -> str:
3447 return "abc12345-0000-0000-0000-000000000001"
3448
3449 def _proposals_resp(self) -> bytes:
3450 return json.dumps({"proposals": [
3451 {"proposalId": self._proposal_id(), "title": "T", "state": "open",
3452 "fromBranch": "feat/x", "toBranch": "dev"},
3453 ]}).encode()
3454
3455 def _merge_resp(self, merged: bool = True) -> bytes:
3456 return json.dumps({"merged": merged, "mergeCommitId": "deadbeef01234567"}).encode()
3457
3458 def test_default_strategy_is_merge_commit(self, repo: pathlib.Path) -> None:
3459 self._setup(repo)
3460 resps = [
3461 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3462 self._make_api_resp(self._proposals_resp()),
3463 self._make_api_resp(self._merge_resp()),
3464 ]
3465 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3466 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345", "-j"])
3467 assert result.exit_code == 0
3468 post_call = next(c for c in mock_open.call_args_list
3469 if c[0][0].method == "POST")
3470 payload = json.loads(post_call[0][0].data)
3471 assert payload["mergeStrategy"] == "merge_commit"
3472
3473 def test_squash_strategy_in_payload(self, repo: pathlib.Path) -> None:
3474 self._setup(repo)
3475 resps = [
3476 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3477 self._make_api_resp(self._proposals_resp()),
3478 self._make_api_resp(self._merge_resp()),
3479 ]
3480 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3481 result = runner.invoke(
3482 cli, ["hub", "proposal", "merge", "abc12345", "--strategy", "squash", "-j"]
3483 )
3484 assert result.exit_code == 0
3485 post_call = next(c for c in mock_open.call_args_list
3486 if c[0][0].method == "POST")
3487 payload = json.loads(post_call[0][0].data)
3488 assert payload["mergeStrategy"] == "squash"
3489
3490 def test_rebase_strategy_in_payload(self, repo: pathlib.Path) -> None:
3491 self._setup(repo)
3492 resps = [
3493 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3494 self._make_api_resp(self._proposals_resp()),
3495 self._make_api_resp(self._merge_resp()),
3496 ]
3497 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3498 result = runner.invoke(
3499 cli, ["hub", "proposal", "merge", "abc12345", "--strategy", "rebase", "-j"]
3500 )
3501 assert result.exit_code == 0
3502 post_call = next(c for c in mock_open.call_args_list
3503 if c[0][0].method == "POST")
3504 payload = json.loads(post_call[0][0].data)
3505 assert payload["mergeStrategy"] == "rebase"
3506
3507 def test_delete_branch_true_by_default(self, repo: pathlib.Path) -> None:
3508 self._setup(repo)
3509 resps = [
3510 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3511 self._make_api_resp(self._proposals_resp()),
3512 self._make_api_resp(self._merge_resp()),
3513 ]
3514 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3515 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345", "-j"])
3516 assert result.exit_code == 0
3517 post_call = next(c for c in mock_open.call_args_list
3518 if c[0][0].method == "POST")
3519 payload = json.loads(post_call[0][0].data)
3520 assert payload["deleteBranch"] is True
3521
3522 def test_no_delete_branch_flag_sets_false_in_payload(self, repo: pathlib.Path) -> None:
3523 self._setup(repo)
3524 resps = [
3525 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3526 self._make_api_resp(self._proposals_resp()),
3527 self._make_api_resp(self._merge_resp()),
3528 ]
3529 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3530 result = runner.invoke(
3531 cli, ["hub", "proposal", "merge", "abc12345", "--no-delete-branch", "-j"]
3532 )
3533 assert result.exit_code == 0
3534 post_call = next(c for c in mock_open.call_args_list
3535 if c[0][0].method == "POST")
3536 payload = json.loads(post_call[0][0].data)
3537 assert payload["deleteBranch"] is False
3538
3539 def test_merge_endpoint_url_contains_proposal_id(self, repo: pathlib.Path) -> None:
3540 """The POST must go to .../proposals/{full_proposal_id}/merge."""
3541 self._setup(repo)
3542 resps = [
3543 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3544 self._make_api_resp(self._proposals_resp()),
3545 self._make_api_resp(self._merge_resp()),
3546 ]
3547 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3548 runner.invoke(cli, ["hub", "proposal", "merge", "abc12345", "-j"])
3549 post_call = next(c for c in mock_open.call_args_list
3550 if c[0][0].method == "POST")
3551 assert self._proposal_id() in post_call[0][0].full_url
3552 assert "/merge" in post_call[0][0].full_url
3553
3554
3555 class TestProposalMergeExitCodes:
3556 """Verify exit codes for all merge outcomes."""
3557
3558 _HUB = "http://localhost:19999/gabriel/muse"
3559
3560 def _setup(self, repo: pathlib.Path) -> None:
3561 runner.invoke(cli, ["hub", "connect", self._HUB])
3562 _store_identity(self._HUB)
3563
3564 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3565 mock_resp = MagicMock()
3566 mock_resp.__enter__ = lambda s: s
3567 mock_resp.__exit__ = MagicMock(return_value=False)
3568 mock_resp.read.return_value = payload_bytes
3569 return mock_resp
3570
3571 def _proposals_resp(self, proposal_id: str) -> bytes:
3572 return json.dumps({"proposals": [
3573 {"proposalId": proposal_id, "title": "T", "state": "open",
3574 "fromBranch": "feat/x", "toBranch": "dev"},
3575 ]}).encode()
3576
3577 def test_merged_true_exits_zero(self, repo: pathlib.Path) -> None:
3578 self._setup(repo)
3579 proposal_id = "abc12345-0000-0000-0000-000000000001"
3580 resps = [
3581 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3582 self._make_api_resp(self._proposals_resp(proposal_id)),
3583 self._make_api_resp(json.dumps({"merged": True, "mergeCommitId": "abc"}).encode()),
3584 ]
3585 with patch("urllib.request.urlopen", side_effect=resps):
3586 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3587 assert result.exit_code == 0
3588
3589 def test_merged_false_text_mode_exits_3(self, repo: pathlib.Path) -> None:
3590 self._setup(repo)
3591 proposal_id = "abc12345-0000-0000-0000-000000000001"
3592 resps = [
3593 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3594 self._make_api_resp(self._proposals_resp(proposal_id)),
3595 self._make_api_resp(json.dumps({"merged": False, "message": "conflict"}).encode()),
3596 ]
3597 with patch("urllib.request.urlopen", side_effect=resps):
3598 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3599 assert result.exit_code == 3
3600
3601 def test_merged_false_json_mode_exits_3(self, repo: pathlib.Path) -> None:
3602 """merge=false with --json must exit 3, not 0.
3603
3604 This is the key agent-safety guarantee: agents using --json can
3605 rely on the exit code to detect merge failures.
3606 """
3607 self._setup(repo)
3608 proposal_id = "abc12345-0000-0000-0000-000000000001"
3609 resps = [
3610 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3611 self._make_api_resp(self._proposals_resp(proposal_id)),
3612 self._make_api_resp(json.dumps({"merged": False, "message": "branch protection"}).encode()),
3613 ]
3614 with patch("urllib.request.urlopen", side_effect=resps):
3615 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345", "--json"])
3616 assert result.exit_code == 3
3617
3618 def test_merged_false_json_mode_still_prints_json(self, repo: pathlib.Path) -> None:
3619 """Even on failure, the full API response must be printed before exiting 3."""
3620 self._setup(repo)
3621 proposal_id = "abc12345-0000-0000-0000-000000000001"
3622 resps = [
3623 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3624 self._make_api_resp(self._proposals_resp(proposal_id)),
3625 self._make_api_resp(
3626 json.dumps({"merged": False, "message": "conflict detected"}).encode()
3627 ),
3628 ]
3629 with patch("urllib.request.urlopen", side_effect=resps):
3630 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345", "--json"])
3631 assert result.exit_code == 3
3632 # JSON must still be printed so agent can read the failure reason
3633 data = json.loads(next(
3634 l for l in result.output.splitlines() if l.strip().startswith("{")
3635 ))
3636 assert data["merged"] is False
3637 assert data["message"] == "conflict detected"
3638
3639 def test_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
3640 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3641 assert result.exit_code != 0
3642
3643 def test_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None:
3644 runner.invoke(cli, ["hub", "connect", self._HUB])
3645 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3646 assert result.exit_code != 0
3647
3648 def test_outside_repo_exits_nonzero(
3649 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
3650 ) -> None:
3651 monkeypatch.chdir(tmp_path)
3652 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
3653 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3654 assert result.exit_code != 0
3655
3656 def test_ambiguous_prefix_exits_nonzero(self, repo: pathlib.Path) -> None:
3657 self._setup(repo)
3658 proposals_data = {"proposals": [
3659 {"proposalId": "abc12345-0000-0000-0000-000000000001", "title": "Proposal 1",
3660 "state": "open", "fromBranch": "feat/a", "toBranch": "dev"},
3661 {"proposalId": "abc12345-0000-0000-0000-000000000002", "title": "Proposal 2",
3662 "state": "open", "fromBranch": "feat/b", "toBranch": "dev"},
3663 ]}
3664 resps = [
3665 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3666 self._make_api_resp(json.dumps(proposals_data).encode()),
3667 ]
3668 with patch("urllib.request.urlopen", side_effect=resps):
3669 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3670 assert result.exit_code != 0
3671
3672
3673 class TestProposalMergeTextOutput:
3674 """Tests for the human-readable text output of `muse hub proposal merge`."""
3675
3676 _HUB = "http://localhost:19999/gabriel/muse"
3677
3678 def _setup(self, repo: pathlib.Path) -> None:
3679 runner.invoke(cli, ["hub", "connect", self._HUB])
3680 _store_identity(self._HUB)
3681
3682 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3683 mock_resp = MagicMock()
3684 mock_resp.__enter__ = lambda s: s
3685 mock_resp.__exit__ = MagicMock(return_value=False)
3686 mock_resp.read.return_value = payload_bytes
3687 return mock_resp
3688
3689 def _proposals_resp(self, proposal_id: str) -> bytes:
3690 return json.dumps({"proposals": [
3691 {"proposalId": proposal_id, "title": "T", "state": "open",
3692 "fromBranch": "feat/x", "toBranch": "dev"},
3693 ]}).encode()
3694
3695 def test_success_shows_proposal_id_prefix(self, repo: pathlib.Path) -> None:
3696 self._setup(repo)
3697 # Use a full UUID so prefix-resolution is skipped (2 API calls only)
3698 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
3699 resps = [
3700 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3701 self._make_api_resp(json.dumps({"merged": True, "mergeCommitId": "aabb1122"}).encode()),
3702 ]
3703 with patch("urllib.request.urlopen", side_effect=resps):
3704 result = runner.invoke(cli, ["hub", "proposal", "merge", proposal_id])
3705 assert result.exit_code == 0
3706 assert "deadbeef" in result.stderr
3707
3708 def test_success_shows_commit_sha(self, repo: pathlib.Path) -> None:
3709 self._setup(repo)
3710 proposal_id = "abc12345-0000-0000-0000-000000000001"
3711 resps = [
3712 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3713 self._make_api_resp(self._proposals_resp(proposal_id)),
3714 self._make_api_resp(
3715 json.dumps({"merged": True, "mergeCommitId": "cafebabe12345678"}).encode()
3716 ),
3717 ]
3718 with patch("urllib.request.urlopen", side_effect=resps):
3719 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3720 assert result.exit_code == 0
3721 assert "cafebabe" in result.stderr
3722
3723 def test_success_no_sha_shows_placeholder(self, repo: pathlib.Path) -> None:
3724 """When mergeCommitId is absent, a placeholder must appear."""
3725 self._setup(repo)
3726 proposal_id = "abc12345-0000-0000-0000-000000000001"
3727 resps = [
3728 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3729 self._make_api_resp(self._proposals_resp(proposal_id)),
3730 self._make_api_resp(json.dumps({"merged": True}).encode()),
3731 ]
3732 with patch("urllib.request.urlopen", side_effect=resps):
3733 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3734 assert result.exit_code == 0
3735 assert "no SHA" in result.stderr
3736
3737 def test_delete_branch_message_shown_when_true(self, repo: pathlib.Path) -> None:
3738 self._setup(repo)
3739 proposal_id = "abc12345-0000-0000-0000-000000000001"
3740 resps = [
3741 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3742 self._make_api_resp(self._proposals_resp(proposal_id)),
3743 self._make_api_resp(json.dumps({"merged": True, "mergeCommitId": "abc"}).encode()),
3744 ]
3745 with patch("urllib.request.urlopen", side_effect=resps):
3746 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3747 assert result.exit_code == 0
3748 assert "Source branch deleted" in result.stderr
3749
3750 def test_delete_branch_message_absent_with_no_delete_branch(
3751 self, repo: pathlib.Path
3752 ) -> None:
3753 self._setup(repo)
3754 proposal_id = "abc12345-0000-0000-0000-000000000001"
3755 resps = [
3756 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3757 self._make_api_resp(self._proposals_resp(proposal_id)),
3758 self._make_api_resp(json.dumps({"merged": True, "mergeCommitId": "abc"}).encode()),
3759 ]
3760 with patch("urllib.request.urlopen", side_effect=resps):
3761 result = runner.invoke(
3762 cli, ["hub", "proposal", "merge", "abc12345", "--no-delete-branch"]
3763 )
3764 assert result.exit_code == 0
3765 assert "Source branch deleted" not in result.stderr
3766
3767 def test_failure_message_shown(self, repo: pathlib.Path) -> None:
3768 self._setup(repo)
3769 proposal_id = "abc12345-0000-0000-0000-000000000001"
3770 resps = [
3771 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3772 self._make_api_resp(self._proposals_resp(proposal_id)),
3773 self._make_api_resp(
3774 json.dumps({"merged": False, "message": "branch protection rule"}).encode()
3775 ),
3776 ]
3777 with patch("urllib.request.urlopen", side_effect=resps):
3778 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3779 assert result.exit_code != 0
3780 assert "branch protection rule" in result.stderr
3781
3782 def test_ansi_in_failure_message_sanitized(self, repo: pathlib.Path) -> None:
3783 self._setup(repo)
3784 proposal_id = "abc12345-0000-0000-0000-000000000001"
3785 resps = [
3786 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3787 self._make_api_resp(self._proposals_resp(proposal_id)),
3788 self._make_api_resp(
3789 json.dumps({"merged": False,
3790 "message": "\x1b[31mmalicious message\x1b[0m"}).encode()
3791 ),
3792 ]
3793 with patch("urllib.request.urlopen", side_effect=resps):
3794 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345"])
3795 assert result.exit_code != 0
3796 assert "\x1b[" not in result.stderr
3797
3798
3799 class TestProposalMergeFullUUID:
3800 """Verify that a full UUID skips the prefix-resolution list fetch."""
3801
3802 _HUB = "http://localhost:19999/gabriel/muse"
3803
3804 def _setup(self, repo: pathlib.Path) -> None:
3805 runner.invoke(cli, ["hub", "connect", self._HUB])
3806 _store_identity(self._HUB)
3807
3808 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3809 mock_resp = MagicMock()
3810 mock_resp.__enter__ = lambda s: s
3811 mock_resp.__exit__ = MagicMock(return_value=False)
3812 mock_resp.read.return_value = payload_bytes
3813 return mock_resp
3814
3815 def test_full_id_uses_2_api_calls(self, repo: pathlib.Path) -> None:
3816 """Full proposal ID: repo resolution + merge POST = 2 calls, no prefix list fetch."""
3817 self._setup(repo)
3818 proposal_id = "deadbeef-cafe-babe-0000-000000000001"
3819 resps = [
3820 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3821 self._make_api_resp(json.dumps({"merged": True, "mergeCommitId": "abc"}).encode()),
3822 ]
3823 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3824 result = runner.invoke(cli, ["hub", "proposal", "merge", proposal_id, "-j"])
3825 assert result.exit_code == 0
3826 assert mock_open.call_count == 2
3827
3828 def test_prefix_uses_3_api_calls(self, repo: pathlib.Path) -> None:
3829 """8-char prefix: repo + prefix list + merge POST = 3 calls."""
3830 self._setup(repo)
3831 proposal_id = "abc12345-0000-0000-0000-000000000001"
3832 proposals_data = {"proposals": [
3833 {"proposalId": proposal_id, "title": "T", "state": "open",
3834 "fromBranch": "feat/x", "toBranch": "dev"},
3835 ]}
3836 resps = [
3837 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3838 self._make_api_resp(json.dumps(proposals_data).encode()),
3839 self._make_api_resp(json.dumps({"merged": True, "mergeCommitId": "abc"}).encode()),
3840 ]
3841 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3842 result = runner.invoke(cli, ["hub", "proposal", "merge", "abc12345", "-j"])
3843 assert result.exit_code == 0
3844 assert mock_open.call_count == 3
3845
3846 def test_hub_override_routes_to_correct_host(self, repo: pathlib.Path) -> None:
3847 """--hub must route all calls to the override URL, not the config URL."""
3848 runner.invoke(cli, ["hub", "connect", "http://localhost:11111/wrong/repo"])
3849 _store_identity("http://localhost:19999/gabriel/muse")
3850 proposal_id = "deadbeef-cafe-babe-0000-000000000001"
3851 resps = [
3852 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3853 self._make_api_resp(json.dumps({"merged": True, "mergeCommitId": "abc"}).encode()),
3854 ]
3855 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3856 result = runner.invoke(
3857 cli,
3858 ["hub", "proposal", "merge", proposal_id,
3859 "--hub", "http://localhost:19999/gabriel/muse", "-j"],
3860 )
3861 assert result.exit_code == 0
3862 called_urls = [c[0][0].full_url for c in mock_open.call_args_list]
3863 assert any("19999" in u for u in called_urls)
3864 assert not any("11111" in u for u in called_urls)
3865
3866
3867 class TestProposalMergeE2E:
3868 """End-to-end scenario tests for `muse hub proposal merge`."""
3869
3870 _HUB = "http://localhost:19999/gabriel/muse"
3871
3872 def _setup(self, repo: pathlib.Path) -> None:
3873 runner.invoke(cli, ["hub", "connect", self._HUB])
3874 _store_identity(self._HUB)
3875
3876 def _make_api_resp(self, payload_bytes: bytes) -> MagicMock:
3877 mock_resp = MagicMock()
3878 mock_resp.__enter__ = lambda s: s
3879 mock_resp.__exit__ = MagicMock(return_value=False)
3880 mock_resp.read.return_value = payload_bytes
3881 return mock_resp
3882
3883 def test_e2e_agent_safe_pipeline(self, repo: pathlib.Path) -> None:
3884 """Agent pipeline: --json exits 0 on success so && chains correctly."""
3885 self._setup(repo)
3886 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
3887 resps = [
3888 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3889 self._make_api_resp(json.dumps({"merged": True,
3890 "mergeCommitId": "cafebabe12345678"}).encode()),
3891 ]
3892 with patch("urllib.request.urlopen", side_effect=resps):
3893 result = runner.invoke(cli, ["hub", "proposal", "merge", proposal_id, "--json"])
3894 assert result.exit_code == 0
3895 data = json.loads(next(
3896 l for l in result.output.splitlines() if l.strip().startswith("{")
3897 ))
3898 assert data["merged"] is True
3899 assert data["mergeCommitId"] == "cafebabe12345678"
3900
3901 def test_e2e_agent_conflict_pipeline(self, repo: pathlib.Path) -> None:
3902 """Agent pipeline: --json exits 3 on conflict so || error-handling fires."""
3903 self._setup(repo)
3904 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
3905 resps = [
3906 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3907 self._make_api_resp(
3908 json.dumps({"merged": False, "message": "merge conflict"}).encode()
3909 ),
3910 ]
3911 with patch("urllib.request.urlopen", side_effect=resps):
3912 result = runner.invoke(cli, ["hub", "proposal", "merge", proposal_id, "--json"])
3913 assert result.exit_code == 3
3914 # JSON is still printed so agent can read the error
3915 data = json.loads(next(
3916 l for l in result.output.splitlines() if l.strip().startswith("{")
3917 ))
3918 assert data["merged"] is False
3919
3920 def test_e2e_squash_no_delete_branch(self, repo: pathlib.Path) -> None:
3921 """Squash merge keeping the branch: payload and output both correct."""
3922 self._setup(repo)
3923 proposal_id = "abc12345-def0-0000-0000-000000000001"
3924 resps = [
3925 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3926 self._make_api_resp(
3927 json.dumps({"merged": True, "mergeCommitId": "aabbccdd11223344"}).encode()
3928 ),
3929 ]
3930 with patch("urllib.request.urlopen", side_effect=resps) as mock_open:
3931 result = runner.invoke(
3932 cli,
3933 ["hub", "proposal", "merge", proposal_id,
3934 "--strategy", "squash", "--no-delete-branch"],
3935 )
3936 assert result.exit_code == 0
3937 assert "Source branch deleted" not in result.stderr
3938 assert "aabbccdd" in result.stderr
3939 post = next(c for c in mock_open.call_args_list if c[0][0].method == "POST")
3940 payload = json.loads(post[0][0].data)
3941 assert payload["mergeStrategy"] == "squash"
3942 assert payload["deleteBranch"] is False
3943
3944 def test_e2e_text_output_no_json_on_stdout(self, repo: pathlib.Path) -> None:
3945 """In text mode, JSON must not appear on stdout."""
3946 self._setup(repo)
3947 proposal_id = "deadbeef-cafe-0000-0000-000000000001"
3948 resps = [
3949 self._make_api_resp(json.dumps({"repo_id": "r"}).encode()),
3950 self._make_api_resp(
3951 json.dumps({"merged": True, "mergeCommitId": "abc"}).encode()
3952 ),
3953 ]
3954 with patch("urllib.request.urlopen", side_effect=resps):
3955 result = runner.invoke(cli, ["hub", "proposal", "merge", proposal_id])
3956 assert result.exit_code == 0
3957 for line in result.output.splitlines():
3958 assert not line.strip().startswith("{"), (
3959 f"Unexpected JSON on stdout: {line!r}"
3960 )
3961
3962
3963 class TestProposalMergeStress:
3964 """Stress tests for `muse hub proposal merge`."""
3965
3966 _HUB = "http://localhost:19999/gabriel/muse"
3967
3968 def test_concurrent_exit_code_checks(self) -> None:
3969 """8 threads checking the merged=False exit-code logic must agree."""
3970 from muse.core.errors import ExitCode
3971 errors: list[str] = []
3972
3973 def _do(idx: int) -> None:
3974 try:
3975 # Simulate the merged check in pure Python
3976 data = {"merged": False, "message": f"conflict {idx}"}
3977 merged = bool(data.get("merged", False))
3978 expected_exit = ExitCode.INTERNAL_ERROR if not merged else ExitCode.SUCCESS
3979 assert expected_exit == ExitCode.INTERNAL_ERROR
3980 except Exception as exc:
3981 errors.append(f"Thread {idx}: {exc}")
3982
3983 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
3984 for t in threads:
3985 t.start()
3986 for t in threads:
3987 t.join()
3988 assert errors == [], "\n".join(errors)
3989
3990
3991 class TestResolveProposalIdLimit:
3992 """Verify that _resolve_proposal_id respects _PROPOSAL_PREFIX_RESOLVE_LIMIT."""
3993
3994 def test_limit_constant_in_url(self) -> None:
3995 """The URL sent to the API must include the limit constant."""
3996 from muse.cli.commands.hub import _PROPOSAL_PREFIX_RESOLVE_LIMIT, _resolve_proposal_id
3997 from muse.core.identity import IdentityEntry
3998
3999 identity: IdentityEntry = {"type": "human", "token": "tok"}
4000 proposal_id = "abc12345-0000-0000-0000-000000000001"
4001 proposals_resp = {"proposals": [
4002 {"proposalId": proposal_id, "title": "T"},
4003 ]}
4004 captured_urls: list[str] = []
4005
4006 def _fake_urlopen(req: urllib.request.Request, timeout: int = 5, context: ssl.SSLContext | None = None) -> MagicMock:
4007 captured_urls.append(req.full_url)
4008 mock_resp = MagicMock()
4009 mock_resp.__enter__ = lambda s: s
4010 mock_resp.__exit__ = MagicMock(return_value=False)
4011 mock_resp.read.return_value = json.dumps(proposals_resp).encode()
4012 return mock_resp
4013
4014 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
4015 with patch("urllib.request.urlopen", side_effect=_fake_urlopen):
4016 result = _resolve_proposal_id("http://localhost:9999", identity, "repo-id", "abc12345")
4017 assert result == proposal_id
4018 assert any(str(_PROPOSAL_PREFIX_RESOLVE_LIMIT) in url for url in captured_urls), (
4019 f"Expected {_PROPOSAL_PREFIX_RESOLVE_LIMIT} in one of {captured_urls}"
4020 )
4021
4022
4023 class TestResolveProposalIdSha256Passthrough:
4024 """sha256-prefixed full IDs must be returned as-is without hitting the list endpoint.
4025
4026 Regression: the old full-ID check required a hyphen (`-`), so sha256:<hex>
4027 IDs always fell through to the list fetch with limit=200. Servers that cap
4028 the limit lower than 200 returned 422, making every `hub proposal read
4029 sha256:...` call fail on those hubs.
4030 """
4031
4032 def _make_identity(self) -> "muse.core.identity.IdentityEntry":
4033 from muse.core.identity import IdentityEntry
4034 e: IdentityEntry = {"type": "human", "token": "tok123"}
4035 return e
4036
4037 def test_full_sha256_id_returned_as_is_no_network(self) -> None:
4038 """A full sha256:<64-hex> ID must be returned without any network call."""
4039 from muse.cli.commands.hub import _resolve_proposal_id
4040
4041 full = "sha256:" + "a" * 64
4042 captured: list[str] = []
4043
4044 def _fail_urlopen(*a, **kw) -> None:
4045 captured.append("called")
4046 raise AssertionError("urlopen must not be called for a full sha256 ID")
4047
4048 with patch("urllib.request.urlopen", side_effect=_fail_urlopen):
4049 result = _resolve_proposal_id("http://hub", self._make_identity(), "repo-id", full)
4050
4051 assert result == full
4052 assert captured == [], "urlopen was called — full sha256 ID was not detected as complete"
4053
4054 def test_sha256_prefix_still_resolves_via_list(self) -> None:
4055 """A short sha256 prefix (fewer than 71 chars) still fetches the list."""
4056 from muse.cli.commands.hub import _resolve_proposal_id
4057
4058 full = "sha256:" + "b" * 64
4059 proposals_resp = {"proposals": [{"proposalId": full, "title": "T", "state": "open",
4060 "fromBranch": "feat/x", "toBranch": "dev"}]}
4061 mock_resp = MagicMock()
4062 mock_resp.__enter__ = lambda s: s
4063 mock_resp.__exit__ = MagicMock(return_value=False)
4064 mock_resp.read.return_value = json.dumps(proposals_resp).encode()
4065
4066 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
4067 with patch("urllib.request.urlopen", return_value=mock_resp):
4068 result = _resolve_proposal_id(
4069 "http://localhost:9999", self._make_identity(), "repo-id", "sha256:bbbb"
4070 )
4071 assert result == full
4072
4073 def test_hyphenated_uuid_still_returned_as_is(self) -> None:
4074 """Regression: existing UUID-style full IDs must not be broken."""
4075 from muse.cli.commands.hub import _resolve_proposal_id
4076
4077 full = "af54753d-1234-5678-abcd-ef1234567890"
4078 with patch("urllib.request.urlopen", side_effect=AssertionError("must not call network")):
4079 result = _resolve_proposal_id("http://hub", self._make_identity(), "repo-id", full)
4080 assert result == full
4081
4082
4083 # =============================================================================
4084 # muse hub issue — hardening tests
4085 # =============================================================================
4086
4087 # Shared helpers for issue tests
4088 HUB_URL = "https://localhost:1337/owner/repo"
4089
4090
4091 def _issue_resp(
4092 number: int = 7,
4093 title: str = "feat: add thing",
4094 body: str = "",
4095 labels: list[str] | None = None,
4096 issue_id: str = "iss_aabbccdd",
4097 state: str = "open",
4098 author: str = "alice",
4099 ) -> _JsonPayload:
4100 return {
4101 "number": number,
4102 "title": title,
4103 "body": body,
4104 "labels": labels or [],
4105 "issueId": issue_id,
4106 "state": state,
4107 "author": author,
4108 "createdAt": "2026-04-09T00:00:00Z",
4109 }
4110
4111
4112 def _issue_list_resp(issues: list[_JsonPayload] | None = None) -> _JsonPayload:
4113 """Wrap issues in the list-response envelope."""
4114 items = issues if issues is not None else [_issue_resp()]
4115 return {"issues": items, "total": len(items)}
4116
4117
4118 def _comment_resp(comment_id: str = "c0") -> _JsonPayload:
4119 """A single-comment response as returned by POST .../comments."""
4120 return {
4121 "commentId": comment_id,
4122 "issueId": "issue-id-0001",
4123 "author": "alice",
4124 "body": "test comment",
4125 "parentId": None,
4126 "isDeleted": False,
4127 "createdAt": "2026-04-14T00:00:00Z",
4128 "updatedAt": "2026-04-14T00:00:00Z",
4129 }
4130
4131
4132 def _refs_resp(repo_id: str = "repo-id-0001") -> _JsonPayload:
4133 return {"repo_id": repo_id, "branches": []}
4134
4135
4136 def _mock_responses(*payloads: _JsonPayload) -> list[MagicMock]:
4137 """Build a side_effect list of mock HTTP responses for urlopen."""
4138 mocks = []
4139 for payload in payloads:
4140 m = MagicMock()
4141 m.__enter__ = lambda s: s
4142 m.__exit__ = MagicMock(return_value=False)
4143 m.read.return_value = json.dumps(payload).encode()
4144 mocks.append(m)
4145 return mocks
4146
4147
4148 # ---------------------------------------------------------------------------
4149 # TestIssueCreateHardening
4150 # ---------------------------------------------------------------------------
4151
4152
4153 class TestIssueCreateHardening:
4154 """Integration tests for ``muse hub issue create``."""
4155
4156 def test_empty_title_exits_nonzero_no_network(
4157 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4158 ) -> None:
4159 from muse.cli.config import set_hub_url
4160 set_hub_url(HUB_URL, repo)
4161 _store_identity(HUB_URL)
4162 with patch("urllib.request.urlopen") as mock_net:
4163 result = runner.invoke(cli, ["hub", "issue", "create", "--title", " "])
4164 assert result.exit_code != 0
4165 mock_net.assert_not_called()
4166
4167 def test_empty_title_error_message(
4168 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4169 ) -> None:
4170 from muse.cli.config import set_hub_url
4171 set_hub_url(HUB_URL, repo)
4172 _store_identity(HUB_URL)
4173 with patch("urllib.request.urlopen"):
4174 result = runner.invoke(cli, ["hub", "issue", "create", "--title", ""])
4175 assert "empty" in result.stderr.lower() or "title" in result.stderr.lower()
4176
4177 def test_title_too_long_exits_nonzero_no_network(
4178 self, repo: pathlib.Path
4179 ) -> None:
4180 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4181 from muse.cli.config import set_hub_url
4182 set_hub_url(HUB_URL, repo)
4183 _store_identity(HUB_URL)
4184 long_title = "x" * (_MAX_ISSUE_TITLE_LEN + 1)
4185 with patch("urllib.request.urlopen") as mock_net:
4186 result = runner.invoke(cli, ["hub", "issue", "create", "--title", long_title])
4187 assert result.exit_code != 0
4188 mock_net.assert_not_called()
4189
4190 def test_title_too_long_shows_char_count(
4191 self, repo: pathlib.Path
4192 ) -> None:
4193 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4194 from muse.cli.config import set_hub_url
4195 set_hub_url(HUB_URL, repo)
4196 _store_identity(HUB_URL)
4197 long_title = "x" * (_MAX_ISSUE_TITLE_LEN + 1)
4198 with patch("urllib.request.urlopen"):
4199 result = runner.invoke(cli, ["hub", "issue", "create", "--title", long_title])
4200 assert str(_MAX_ISSUE_TITLE_LEN + 1) in result.stderr or str(_MAX_ISSUE_TITLE_LEN) in result.stderr
4201
4202 def test_title_at_max_length_accepted(
4203 self, repo: pathlib.Path
4204 ) -> None:
4205 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4206 from muse.cli.config import set_hub_url
4207 set_hub_url(HUB_URL, repo)
4208 _store_identity(HUB_URL)
4209 exact_title = "x" * _MAX_ISSUE_TITLE_LEN
4210 mocks = _mock_responses(_refs_resp(), _issue_resp(title=exact_title))
4211 with patch("urllib.request.urlopen", side_effect=mocks):
4212 result = runner.invoke(
4213 cli, ["hub", "issue", "create", "--title", exact_title, "--json"]
4214 )
4215 assert result.exit_code == 0
4216
4217 def test_success_json_output(self, repo: pathlib.Path) -> None:
4218 from muse.cli.config import set_hub_url
4219 set_hub_url(HUB_URL, repo)
4220 _store_identity(HUB_URL)
4221 mocks = _mock_responses(_refs_resp(), _issue_resp())
4222 with patch("urllib.request.urlopen", side_effect=mocks):
4223 result = runner.invoke(
4224 cli, ["hub", "issue", "create", "--title", "feat: X", "-j"]
4225 )
4226 assert result.exit_code == 0
4227 data = json.loads(result.output)
4228 assert "number" in data
4229
4230 def test_json_short_flag(self, repo: pathlib.Path) -> None:
4231 """-j short alias must work the same as --json."""
4232 from muse.cli.config import set_hub_url
4233 set_hub_url(HUB_URL, repo)
4234 _store_identity(HUB_URL)
4235 mocks = _mock_responses(_refs_resp(), _issue_resp())
4236 with patch("urllib.request.urlopen", side_effect=mocks):
4237 result = runner.invoke(
4238 cli, ["hub", "issue", "create", "--title", "feat: X", "-j"]
4239 )
4240 assert result.exit_code == 0
4241 json.loads(result.output) # must be valid JSON
4242
4243 def test_labels_included_in_payload(self, repo: pathlib.Path) -> None:
4244 from muse.cli.config import set_hub_url
4245 set_hub_url(HUB_URL, repo)
4246 _store_identity(HUB_URL)
4247 captured: list[bytes] = []
4248
4249 def _fake(req: urllib.request.Request, timeout: int = 5, context: ssl.SSLContext | None = None) -> MagicMock:
4250 if req.method == "POST":
4251 captured.append(req.data or b"")
4252 m = MagicMock()
4253 m.__enter__ = lambda s: s
4254 m.__exit__ = MagicMock(return_value=False)
4255 if req.method == "GET":
4256 m.read.return_value = json.dumps(_refs_resp()).encode()
4257 else:
4258 m.read.return_value = json.dumps(_issue_resp()).encode()
4259 return m
4260
4261 with patch("urllib.request.urlopen", side_effect=_fake):
4262 runner.invoke(
4263 cli,
4264 ["hub", "issue", "create", "--title", "T", "--label", "bug", "--label", "phase/1"],
4265 )
4266 assert captured
4267 body = json.loads(captured[0])
4268 assert "bug" in body["labels"]
4269 assert "phase/1" in body["labels"]
4270
4271 def test_issue_url_on_stdout(self, repo: pathlib.Path) -> None:
4272 from muse.cli.config import set_hub_url
4273 set_hub_url(HUB_URL, repo)
4274 _store_identity(HUB_URL)
4275 mocks = _mock_responses(_refs_resp(), _issue_resp(number=42))
4276 with patch("urllib.request.urlopen", side_effect=mocks):
4277 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4278 assert result.exit_code == 0
4279 assert "42" in result.stderr
4280
4281 def test_issue_url_contains_owner_slug(self, repo: pathlib.Path) -> None:
4282 from muse.cli.config import set_hub_url
4283 set_hub_url(HUB_URL, repo)
4284 _store_identity(HUB_URL)
4285 mocks = _mock_responses(_refs_resp(), _issue_resp(number=3))
4286 with patch("urllib.request.urlopen", side_effect=mocks):
4287 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4288 assert "owner" in result.output
4289 assert "repo" in result.output
4290
4291 def test_text_mode_success_on_stderr(self, repo: pathlib.Path) -> None:
4292 """Text mode prints ✅ Issue #N created. to stderr."""
4293 from muse.cli.config import set_hub_url
4294 set_hub_url(HUB_URL, repo)
4295 _store_identity(HUB_URL)
4296 mocks = _mock_responses(_refs_resp(), _issue_resp(number=5))
4297 with patch("urllib.request.urlopen", side_effect=mocks):
4298 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4299 assert result.exit_code == 0
4300 assert "5" in result.stderr
4301 assert "created" in result.stderr.lower()
4302
4303 def test_text_mode_no_json_on_stdout(self, repo: pathlib.Path) -> None:
4304 from muse.cli.config import set_hub_url
4305 set_hub_url(HUB_URL, repo)
4306 _store_identity(HUB_URL)
4307 mocks = _mock_responses(_refs_resp(), _issue_resp())
4308 with patch("urllib.request.urlopen", side_effect=mocks):
4309 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4310 assert result.exit_code == 0
4311 # Text mode must not emit a JSON object
4312 try:
4313 json.loads(result.output)
4314 assert False, "Text mode must not emit JSON"
4315 except (json.JSONDecodeError, ValueError):
4316 pass
4317
4318 def test_number_fallback_for_nonnumeric_api_response(
4319 self, repo: pathlib.Path
4320 ) -> None:
4321 """If API returns a non-numeric 'number', fall back to 0 without crashing."""
4322 from muse.cli.config import set_hub_url
4323 set_hub_url(HUB_URL, repo)
4324 _store_identity(HUB_URL)
4325 bad_issue = dict(_issue_resp())
4326 bad_issue["number"] = "not-a-number"
4327 mocks = _mock_responses(_refs_resp(), bad_issue)
4328 with patch("urllib.request.urlopen", side_effect=mocks):
4329 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4330 assert result.exit_code == 0 # must not crash
4331
4332 def test_number_float_coerced(self, repo: pathlib.Path) -> None:
4333 """Numeric float from API (e.g. 7.0) must be coerced to int."""
4334 from muse.cli.config import set_hub_url
4335 set_hub_url(HUB_URL, repo)
4336 _store_identity(HUB_URL)
4337 float_issue = dict(_issue_resp())
4338 float_issue["number"] = 7.0
4339 mocks = _mock_responses(_refs_resp(), float_issue)
4340 with patch("urllib.request.urlopen", side_effect=mocks):
4341 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4342 assert result.exit_code == 0
4343 assert "7" in result.stderr
4344
4345 def test_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
4346 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4347 assert result.exit_code != 0
4348
4349 def test_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None:
4350 from muse.cli.config import set_hub_url
4351 set_hub_url(HUB_URL, repo)
4352 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4353 assert result.exit_code != 0
4354
4355 def test_outside_repo_exits_nonzero(
4356 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4357 ) -> None:
4358 monkeypatch.chdir(tmp_path)
4359 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
4360 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
4361 assert result.exit_code != 0
4362
4363 def test_hub_override_used_in_request(self, repo: pathlib.Path) -> None:
4364 """--hub overrides the config hub URL."""
4365 override_url = "http://override:9999/owner2/repo2"
4366 _store_identity(override_url)
4367 captured_urls: list[str] = []
4368
4369 def _fake(req: urllib.request.Request, timeout: int = 5, context: ssl.SSLContext | None = None) -> MagicMock:
4370 captured_urls.append(req.full_url)
4371 m = MagicMock()
4372 m.__enter__ = lambda s: s
4373 m.__exit__ = MagicMock(return_value=False)
4374 if "refs" in req.full_url:
4375 m.read.return_value = json.dumps(_refs_resp()).encode()
4376 else:
4377 m.read.return_value = json.dumps(_issue_resp()).encode()
4378 return m
4379
4380 with patch("urllib.request.urlopen", side_effect=_fake):
4381 result = runner.invoke(cli, [
4382 "hub", "issue", "create",
4383 "--hub", override_url,
4384 "--title", "T",
4385 ])
4386 assert result.exit_code == 0
4387 assert any("override:9999" in u for u in captured_urls)
4388
4389
4390 # ---------------------------------------------------------------------------
4391 # TestIssueCreateSecurity
4392 # ---------------------------------------------------------------------------
4393
4394
4395 class TestIssueCreateSecurity:
4396 """Security-focused tests for ``muse hub issue create``."""
4397
4398 def test_ansi_in_title_no_network_when_valid(
4399 self, repo: pathlib.Path
4400 ) -> None:
4401 """ANSI in title is not a validation error — title may contain them."""
4402 from muse.cli.config import set_hub_url
4403 set_hub_url(HUB_URL, repo)
4404 _store_identity(HUB_URL)
4405 ansi_title = "feat: \x1b[31mred\x1b[0m bug"
4406 mocks = _mock_responses(_refs_resp(), _issue_resp(title=ansi_title))
4407 with patch("urllib.request.urlopen", side_effect=mocks):
4408 result = runner.invoke(cli, ["hub", "issue", "create", "--title", ansi_title])
4409 assert result.exit_code == 0
4410
4411 def test_issueId_fallback_sanitized(self, repo: pathlib.Path) -> None:
4412 """If hub URL has no owner/slug, issueId fallback must be sanitized."""
4413 # Give the hub URL no slug path so the fallback branch triggers.
4414 bare_hub = "https://localhost:1337"
4415 _store_identity(bare_hub)
4416 ansi_id = "iss_\x1b[31minjection\x1b[0m"
4417 issue = dict(_issue_resp())
4418 issue["issueId"] = ansi_id
4419
4420 mocks = _mock_responses(_refs_resp(), issue)
4421 with patch("urllib.request.urlopen", side_effect=mocks):
4422 result = runner.invoke(cli, [
4423 "hub", "issue", "create",
4424 "--hub", bare_hub,
4425 "--title", "T",
4426 ])
4427 # ANSI escape sequences must not appear raw in output
4428 assert "\x1b[" not in result.stderr
4429
4430 def test_title_validation_before_network(
4431 self, repo: pathlib.Path
4432 ) -> None:
4433 """Empty title must be rejected before any HTTP call is made."""
4434 from muse.cli.config import set_hub_url
4435 set_hub_url(HUB_URL, repo)
4436 _store_identity(HUB_URL)
4437 with patch("urllib.request.urlopen") as mock_net:
4438 runner.invoke(cli, ["hub", "issue", "create", "--title", ""])
4439 mock_net.assert_not_called()
4440
4441 def test_max_title_len_constant_value(self) -> None:
4442 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4443 assert _MAX_ISSUE_TITLE_LEN == 512
4444
4445 def test_ansi_in_hub_url_path_not_echoed_raw(
4446 self, repo: pathlib.Path
4447 ) -> None:
4448 """ANSI in --hub URL path segments (owner/slug) must not reach stdout raw."""
4449 # Craft a hub URL where the owner segment contains an ANSI escape.
4450 # urllib.parse will preserve it in the path — it must be stripped on output.
4451 ansi_owner = "\x1b[31mmalicious\x1b[0m"
4452 malicious_hub = f"https://localhost:1337/{ansi_owner}/repo"
4453 _store_identity(malicious_hub)
4454 mocks = _mock_responses(_refs_resp(), _issue_resp(number=1))
4455 with patch("urllib.request.urlopen", side_effect=mocks):
4456 result = runner.invoke(cli, [
4457 "hub", "issue", "create",
4458 "--hub", malicious_hub,
4459 "--title", "T",
4460 ])
4461 assert "\x1b[" not in result.stderr
4462
4463 def test_payload_type_annotation_no_bool(self) -> None:
4464 """The payload dict must not include bool values — type annotation check."""
4465 import inspect
4466 import muse.cli.commands.hub as hub_mod
4467 src = inspect.getsource(hub_mod.run_issue_create)
4468 # The old annotation included 'bool' — verify it was removed.
4469 # Look for the payload assignment line.
4470 assert "str | bool | list" not in src
4471
4472 def test_repo_flag_routes_to_correct_hub(
4473 self, repo: pathlib.Path
4474 ) -> None:
4475 """--repo owner/repo constructs a hub URL using the configured base."""
4476 from muse.cli.config import set_hub_url
4477 # Configure hub base (without owner/repo path)
4478 base_hub = "https://localhost:1337/original/original"
4479 set_hub_url(base_hub, repo)
4480 _store_identity("https://localhost:1337/myowner/myrepo")
4481 captured_urls: list[str] = []
4482
4483 def _fake(req: urllib.request.Request, timeout: int = 5, context: ssl.SSLContext | None = None) -> MagicMock:
4484 captured_urls.append(req.full_url)
4485 m = MagicMock()
4486 m.__enter__ = lambda s: s
4487 m.__exit__ = MagicMock(return_value=False)
4488 if req.method == "GET":
4489 m.read.return_value = json.dumps(_refs_resp()).encode()
4490 else:
4491 m.read.return_value = json.dumps(_issue_resp()).encode()
4492 return m
4493
4494 with patch("urllib.request.urlopen", side_effect=_fake):
4495 result = runner.invoke(cli, [
4496 "hub", "issue", "create",
4497 "--repo", "myowner/myrepo",
4498 "--title", "T",
4499 ])
4500 assert result.exit_code == 0
4501 assert any("myowner" in u and "myrepo" in u for u in captured_urls)
4502
4503
4504 # ---------------------------------------------------------------------------
4505 # TestIssueEditHardening
4506 # ---------------------------------------------------------------------------
4507
4508
4509 class TestIssueEditHardening:
4510 """Integration tests for ``muse hub issue edit``."""
4511
4512 def test_no_fields_exits_nonzero_no_network(
4513 self, repo: pathlib.Path
4514 ) -> None:
4515 from muse.cli.config import set_hub_url
4516 set_hub_url(HUB_URL, repo)
4517 _store_identity(HUB_URL)
4518 with patch("urllib.request.urlopen") as mock_net:
4519 result = runner.invoke(cli, ["hub", "issue", "update", "42"])
4520 assert result.exit_code != 0
4521 mock_net.assert_not_called()
4522
4523 def test_no_fields_error_message(self, repo: pathlib.Path) -> None:
4524 from muse.cli.config import set_hub_url
4525 set_hub_url(HUB_URL, repo)
4526 _store_identity(HUB_URL)
4527 with patch("urllib.request.urlopen"):
4528 result = runner.invoke(cli, ["hub", "issue", "update", "42"])
4529 assert "nothing" in result.stderr.lower() or "update" in result.stderr.lower()
4530
4531 def test_title_only_patch(self, repo: pathlib.Path) -> None:
4532 from muse.cli.config import set_hub_url
4533 set_hub_url(HUB_URL, repo)
4534 _store_identity(HUB_URL)
4535 captured: list[bytes] = []
4536
4537 def _fake(req: urllib.request.Request, timeout: int = 5, context: ssl.SSLContext | None = None) -> MagicMock:
4538 if req.method == "PATCH":
4539 captured.append(req.data or b"")
4540 m = MagicMock()
4541 m.__enter__ = lambda s: s
4542 m.__exit__ = MagicMock(return_value=False)
4543 if req.method == "GET":
4544 m.read.return_value = json.dumps(_refs_resp()).encode()
4545 else:
4546 m.read.return_value = json.dumps(_issue_resp()).encode()
4547 return m
4548
4549 with patch("urllib.request.urlopen", side_effect=_fake):
4550 result = runner.invoke(cli, ["hub", "issue", "update", "7", "--title", "new title"])
4551 assert result.exit_code == 0
4552 assert captured
4553 body = json.loads(captured[0])
4554 assert body == {"title": "new title"}
4555
4556 def test_body_only_patch(self, repo: pathlib.Path) -> None:
4557 from muse.cli.config import set_hub_url
4558 set_hub_url(HUB_URL, repo)
4559 _store_identity(HUB_URL)
4560 captured: list[bytes] = []
4561
4562 def _fake(req: urllib.request.Request, timeout: int = 5, context: ssl.SSLContext | None = None) -> MagicMock:
4563 if req.method == "PATCH":
4564 captured.append(req.data or b"")
4565 m = MagicMock()
4566 m.__enter__ = lambda s: s
4567 m.__exit__ = MagicMock(return_value=False)
4568 if req.method == "GET":
4569 m.read.return_value = json.dumps(_refs_resp()).encode()
4570 else:
4571 m.read.return_value = json.dumps(_issue_resp()).encode()
4572 return m
4573
4574 with patch("urllib.request.urlopen", side_effect=_fake):
4575 result = runner.invoke(cli, ["hub", "issue", "update", "7", "--body", "new body"])
4576 assert result.exit_code == 0
4577 assert captured
4578 body = json.loads(captured[0])
4579 assert body == {"body": "new body"}
4580
4581 def test_both_title_and_body_in_patch(self, repo: pathlib.Path) -> None:
4582 from muse.cli.config import set_hub_url
4583 set_hub_url(HUB_URL, repo)
4584 _store_identity(HUB_URL)
4585 captured: list[bytes] = []
4586
4587 def _fake(req: urllib.request.Request, timeout: int = 5, context: ssl.SSLContext | None = None) -> MagicMock:
4588 if req.method == "PATCH":
4589 captured.append(req.data or b"")
4590 m = MagicMock()
4591 m.__enter__ = lambda s: s
4592 m.__exit__ = MagicMock(return_value=False)
4593 if req.method == "GET":
4594 m.read.return_value = json.dumps(_refs_resp()).encode()
4595 else:
4596 m.read.return_value = json.dumps(_issue_resp()).encode()
4597 return m
4598
4599 with patch("urllib.request.urlopen", side_effect=_fake):
4600 runner.invoke(
4601 cli,
4602 ["hub", "issue", "update", "7", "--title", "NT", "--body", "NB"],
4603 )
4604 assert captured
4605 body = json.loads(captured[0])
4606 assert body["title"] == "NT"
4607 assert body["body"] == "NB"
4608
4609 def test_patch_endpoint_includes_number(self, repo: pathlib.Path) -> None:
4610 from muse.cli.config import set_hub_url
4611 set_hub_url(HUB_URL, repo)
4612 _store_identity(HUB_URL)
4613 captured_urls: list[str] = []
4614
4615 def _fake(req: urllib.request.Request, timeout: int = 5, context: ssl.SSLContext | None = None) -> MagicMock:
4616 captured_urls.append(req.full_url)
4617 m = MagicMock()
4618 m.__enter__ = lambda s: s
4619 m.__exit__ = MagicMock(return_value=False)
4620 if req.method == "GET":
4621 m.read.return_value = json.dumps(_refs_resp()).encode()
4622 else:
4623 m.read.return_value = json.dumps(_issue_resp()).encode()
4624 return m
4625
4626 with patch("urllib.request.urlopen", side_effect=_fake):
4627 runner.invoke(cli, ["hub", "issue", "update", "42", "--title", "T"])
4628 assert any("/issues/42" in u for u in captured_urls)
4629
4630 def test_uses_patch_method(self, repo: pathlib.Path) -> None:
4631 from muse.cli.config import set_hub_url
4632 set_hub_url(HUB_URL, repo)
4633 _store_identity(HUB_URL)
4634 methods: list[str] = []
4635
4636 def _fake(req: urllib.request.Request, timeout: int = 5, context: ssl.SSLContext | None = None) -> MagicMock:
4637 methods.append(req.method or "")
4638 m = MagicMock()
4639 m.__enter__ = lambda s: s
4640 m.__exit__ = MagicMock(return_value=False)
4641 if req.method == "GET":
4642 m.read.return_value = json.dumps(_refs_resp()).encode()
4643 else:
4644 m.read.return_value = json.dumps(_issue_resp()).encode()
4645 return m
4646
4647 with patch("urllib.request.urlopen", side_effect=_fake):
4648 runner.invoke(cli, ["hub", "issue", "update", "42", "--title", "T"])
4649 assert "PATCH" in methods
4650
4651 def test_json_passthrough(self, repo: pathlib.Path) -> None:
4652 from muse.cli.config import set_hub_url
4653 set_hub_url(HUB_URL, repo)
4654 _store_identity(HUB_URL)
4655 mocks = _mock_responses(_refs_resp(), _issue_resp(number=42))
4656 with patch("urllib.request.urlopen", side_effect=mocks):
4657 result = runner.invoke(
4658 cli, ["hub", "issue", "update", "42", "--title", "T", "--json"]
4659 )
4660 assert result.exit_code == 0
4661 data = json.loads(result.output)
4662 assert "number" in data
4663
4664 def test_json_short_flag(self, repo: pathlib.Path) -> None:
4665 from muse.cli.config import set_hub_url
4666 set_hub_url(HUB_URL, repo)
4667 _store_identity(HUB_URL)
4668 mocks = _mock_responses(_refs_resp(), _issue_resp())
4669 with patch("urllib.request.urlopen", side_effect=mocks):
4670 result = runner.invoke(
4671 cli, ["hub", "issue", "update", "42", "--title", "T", "-j"]
4672 )
4673 assert result.exit_code == 0
4674 json.loads(result.output)
4675
4676 def test_text_mode_success_message(self, repo: pathlib.Path) -> None:
4677 from muse.cli.config import set_hub_url
4678 set_hub_url(HUB_URL, repo)
4679 _store_identity(HUB_URL)
4680 mocks = _mock_responses(_refs_resp(), _issue_resp(number=42))
4681 with patch("urllib.request.urlopen", side_effect=mocks):
4682 result = runner.invoke(
4683 cli, ["hub", "issue", "update", "42", "--title", "T"]
4684 )
4685 assert result.exit_code == 0
4686 assert "42" in result.stderr
4687 assert "updated" in result.stderr.lower()
4688
4689 def test_text_mode_no_json_on_stdout(self, repo: pathlib.Path) -> None:
4690 from muse.cli.config import set_hub_url
4691 set_hub_url(HUB_URL, repo)
4692 _store_identity(HUB_URL)
4693 mocks = _mock_responses(_refs_resp(), _issue_resp())
4694 with patch("urllib.request.urlopen", side_effect=mocks):
4695 result = runner.invoke(
4696 cli, ["hub", "issue", "update", "7", "--title", "T"]
4697 )
4698 assert result.exit_code == 0
4699 try:
4700 json.loads(result.output)
4701 assert False, "Text mode must not emit JSON"
4702 except (json.JSONDecodeError, ValueError):
4703 pass
4704
4705 def test_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
4706 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", "T"])
4707 assert result.exit_code != 0
4708
4709 def test_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None:
4710 from muse.cli.config import set_hub_url
4711 set_hub_url(HUB_URL, repo)
4712 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", "T"])
4713 assert result.exit_code != 0
4714
4715 def test_outside_repo_exits_nonzero(
4716 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4717 ) -> None:
4718 monkeypatch.chdir(tmp_path)
4719 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
4720 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", "T"])
4721 assert result.exit_code != 0
4722
4723 def test_hub_override_used(self, repo: pathlib.Path) -> None:
4724 override_url = "http://override:9999/owner2/repo2"
4725 _store_identity(override_url)
4726 captured_urls: list[str] = []
4727
4728 def _fake(req: urllib.request.Request, timeout: int = 5, context: ssl.SSLContext | None = None) -> MagicMock:
4729 captured_urls.append(req.full_url)
4730 m = MagicMock()
4731 m.__enter__ = lambda s: s
4732 m.__exit__ = MagicMock(return_value=False)
4733 if req.method == "GET":
4734 m.read.return_value = json.dumps(_refs_resp()).encode()
4735 else:
4736 m.read.return_value = json.dumps(_issue_resp()).encode()
4737 return m
4738
4739 with patch("urllib.request.urlopen", side_effect=_fake):
4740 result = runner.invoke(cli, [
4741 "hub", "issue", "update", "1",
4742 "--hub", override_url,
4743 "--title", "T",
4744 ])
4745 assert result.exit_code == 0
4746 assert any("override:9999" in u for u in captured_urls)
4747
4748
4749 # ---------------------------------------------------------------------------
4750 # TestIssueEditSecurity
4751 # ---------------------------------------------------------------------------
4752
4753
4754 class TestIssueEditSecurity:
4755 """Security and validation tests for ``muse hub issue edit``."""
4756
4757 def test_negative_number_exits_nonzero_no_network(
4758 self, repo: pathlib.Path
4759 ) -> None:
4760 from muse.cli.config import set_hub_url
4761 set_hub_url(HUB_URL, repo)
4762 _store_identity(HUB_URL)
4763 with patch("urllib.request.urlopen") as mock_net:
4764 # Pass number as positional — argparse type=int accepts negatives
4765 result = runner.invoke(cli, ["hub", "issue", "update", "0", "--title", "T"])
4766 assert result.exit_code != 0
4767 mock_net.assert_not_called()
4768
4769 def test_zero_number_exits_nonzero_no_network(
4770 self, repo: pathlib.Path
4771 ) -> None:
4772 from muse.cli.config import set_hub_url
4773 set_hub_url(HUB_URL, repo)
4774 _store_identity(HUB_URL)
4775 with patch("urllib.request.urlopen") as mock_net:
4776 result = runner.invoke(cli, ["hub", "issue", "update", "0", "--title", "T"])
4777 assert result.exit_code != 0
4778 mock_net.assert_not_called()
4779
4780 def test_zero_number_shows_helpful_message(
4781 self, repo: pathlib.Path
4782 ) -> None:
4783 from muse.cli.config import set_hub_url
4784 set_hub_url(HUB_URL, repo)
4785 _store_identity(HUB_URL)
4786 with patch("urllib.request.urlopen"):
4787 result = runner.invoke(cli, ["hub", "issue", "update", "0", "--title", "T"])
4788 assert "positive" in result.stderr.lower() or "0" in result.stderr
4789
4790 def test_title_too_long_exits_nonzero_no_network(
4791 self, repo: pathlib.Path
4792 ) -> None:
4793 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4794 from muse.cli.config import set_hub_url
4795 set_hub_url(HUB_URL, repo)
4796 _store_identity(HUB_URL)
4797 long_title = "x" * (_MAX_ISSUE_TITLE_LEN + 1)
4798 with patch("urllib.request.urlopen") as mock_net:
4799 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", long_title])
4800 assert result.exit_code != 0
4801 mock_net.assert_not_called()
4802
4803 def test_title_too_long_shows_char_count(
4804 self, repo: pathlib.Path
4805 ) -> None:
4806 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4807 from muse.cli.config import set_hub_url
4808 set_hub_url(HUB_URL, repo)
4809 _store_identity(HUB_URL)
4810 long_title = "x" * (_MAX_ISSUE_TITLE_LEN + 1)
4811 with patch("urllib.request.urlopen"):
4812 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", long_title])
4813 assert str(_MAX_ISSUE_TITLE_LEN + 1) in result.stderr or str(_MAX_ISSUE_TITLE_LEN) in result.stderr
4814
4815 def test_title_at_max_length_accepted(
4816 self, repo: pathlib.Path
4817 ) -> None:
4818 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4819 from muse.cli.config import set_hub_url
4820 set_hub_url(HUB_URL, repo)
4821 _store_identity(HUB_URL)
4822 exact_title = "x" * _MAX_ISSUE_TITLE_LEN
4823 mocks = _mock_responses(_refs_resp(), _issue_resp(title=exact_title))
4824 with patch("urllib.request.urlopen", side_effect=mocks):
4825 result = runner.invoke(
4826 cli, ["hub", "issue", "update", "1", "--title", exact_title, "--json"]
4827 )
4828 assert result.exit_code == 0
4829
4830 def test_empty_title_exits_nonzero_no_network(
4831 self, repo: pathlib.Path
4832 ) -> None:
4833 from muse.cli.config import set_hub_url
4834 set_hub_url(HUB_URL, repo)
4835 _store_identity(HUB_URL)
4836 with patch("urllib.request.urlopen") as mock_net:
4837 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", ""])
4838 assert result.exit_code != 0
4839 mock_net.assert_not_called()
4840
4841 def test_whitespace_only_title_exits_nonzero_no_network(
4842 self, repo: pathlib.Path
4843 ) -> None:
4844 from muse.cli.config import set_hub_url
4845 set_hub_url(HUB_URL, repo)
4846 _store_identity(HUB_URL)
4847 with patch("urllib.request.urlopen") as mock_net:
4848 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", " "])
4849 assert result.exit_code != 0
4850 mock_net.assert_not_called()
4851
4852 def test_empty_title_shows_error_message(
4853 self, repo: pathlib.Path
4854 ) -> None:
4855 from muse.cli.config import set_hub_url
4856 set_hub_url(HUB_URL, repo)
4857 _store_identity(HUB_URL)
4858 with patch("urllib.request.urlopen"):
4859 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--title", ""])
4860 assert "empty" in result.stderr.lower() or "title" in result.stderr.lower()
4861
4862 def test_all_validation_before_network(
4863 self, repo: pathlib.Path
4864 ) -> None:
4865 """All local validation must fire before any HTTP call."""
4866 from muse.cli.config import set_hub_url
4867 set_hub_url(HUB_URL, repo)
4868 _store_identity(HUB_URL)
4869 with patch("urllib.request.urlopen") as mock_net:
4870 # zero number + empty title — both are invalid
4871 runner.invoke(cli, ["hub", "issue", "update", "0", "--title", ""])
4872 mock_net.assert_not_called()
4873
4874 def test_repo_flag_routes_correctly(
4875 self, repo: pathlib.Path
4876 ) -> None:
4877 """--repo owner/repo constructs a hub URL using the configured base."""
4878 from muse.cli.config import set_hub_url
4879 base_hub = "https://localhost:1337/original/original"
4880 set_hub_url(base_hub, repo)
4881 _store_identity("https://localhost:1337/myowner/myrepo")
4882 captured_urls: list[str] = []
4883
4884 def _fake(req: urllib.request.Request, timeout: int = 5, context: ssl.SSLContext | None = None) -> MagicMock:
4885 captured_urls.append(req.full_url)
4886 m = MagicMock()
4887 m.__enter__ = lambda s: s
4888 m.__exit__ = MagicMock(return_value=False)
4889 if req.method == "GET":
4890 m.read.return_value = json.dumps(_refs_resp()).encode()
4891 else:
4892 m.read.return_value = json.dumps(_issue_resp()).encode()
4893 return m
4894
4895 with patch("urllib.request.urlopen", side_effect=_fake):
4896 result = runner.invoke(cli, [
4897 "hub", "issue", "update", "1",
4898 "--repo", "myowner/myrepo",
4899 "--title", "T",
4900 ])
4901 assert result.exit_code == 0
4902 assert any("myowner" in u and "myrepo" in u for u in captured_urls)
4903
4904
4905 # ---------------------------------------------------------------------------
4906 # TestIssueEditStress
4907 # ---------------------------------------------------------------------------
4908
4909
4910 class TestIssueEditStress:
4911 """Stress and boundary tests for ``muse hub issue edit``."""
4912
4913 def test_title_boundary_constants(self) -> None:
4914 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4915 assert isinstance(_MAX_ISSUE_TITLE_LEN, int)
4916 assert _MAX_ISSUE_TITLE_LEN > 0
4917
4918 def test_concurrent_validation(self) -> None:
4919 """Title and number validation logic is thread-safe."""
4920 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
4921 errors: list[str] = []
4922
4923 def _check(idx: int) -> None:
4924 try:
4925 number = idx - 4 # some negative, some positive
4926 title = "x" * (idx * 10)
4927 bad_number = number <= 0
4928 bad_title = len(title) > _MAX_ISSUE_TITLE_LEN or not title.strip()
4929 assert isinstance(bad_number, bool)
4930 assert isinstance(bad_title, bool)
4931 except Exception as exc:
4932 errors.append(f"Thread {idx}: {exc}")
4933
4934 threads = [threading.Thread(target=_check, args=(i,)) for i in range(8)]
4935 for t in threads:
4936 t.start()
4937 for t in threads:
4938 t.join()
4939 assert errors == [], "\n".join(errors)
4940
4941 def test_body_only_no_title_validation(
4942 self, repo: pathlib.Path
4943 ) -> None:
4944 """When only --body is provided, title validation must not run."""
4945 from muse.cli.config import set_hub_url
4946 set_hub_url(HUB_URL, repo)
4947 _store_identity(HUB_URL)
4948 mocks = _mock_responses(_refs_resp(), _issue_resp())
4949 with patch("urllib.request.urlopen", side_effect=mocks):
4950 result = runner.invoke(
4951 cli, ["hub", "issue", "update", "1", "--body", "updated"]
4952 )
4953 assert result.exit_code == 0
4954
4955 def test_positive_number_one_accepted(
4956 self, repo: pathlib.Path
4957 ) -> None:
4958 """Issue number 1 (minimum valid) must be accepted."""
4959 from muse.cli.config import set_hub_url
4960 set_hub_url(HUB_URL, repo)
4961 _store_identity(HUB_URL)
4962 mocks = _mock_responses(_refs_resp(), _issue_resp(number=1))
4963 with patch("urllib.request.urlopen", side_effect=mocks):
4964 result = runner.invoke(
4965 cli, ["hub", "issue", "update", "1", "--title", "T"]
4966 )
4967 assert result.exit_code == 0
4968
4969 def test_large_number_accepted(
4970 self, repo: pathlib.Path
4971 ) -> None:
4972 """Very large issue numbers are valid."""
4973 from muse.cli.config import set_hub_url
4974 set_hub_url(HUB_URL, repo)
4975 _store_identity(HUB_URL)
4976 mocks = _mock_responses(_refs_resp(), _issue_resp(number=999999))
4977 with patch("urllib.request.urlopen", side_effect=mocks):
4978 result = runner.invoke(
4979 cli, ["hub", "issue", "update", "999999", "--title", "T"]
4980 )
4981 assert result.exit_code == 0
4982
4983
4984 # ---------------------------------------------------------------------------
4985 # TestIssueSubparserRegistration
4986 # ---------------------------------------------------------------------------
4987
4988
4989 class TestIssueSubparserRegistration:
4990 """Verify subparser wiring and flag aliases."""
4991
4992 def test_create_help_contains_agent_quickstart(self) -> None:
4993 result = runner.invoke(cli, ["hub", "issue", "create", "--help"])
4994 assert "quickstart" in result.output.lower() or "--json" in result.output
4995
4996 def test_edit_help_contains_exit_codes(self) -> None:
4997 result = runner.invoke(cli, ["hub", "issue", "update", "--help"])
4998 assert "Exit codes" in result.output or "exit" in result.output.lower()
4999
5000 def test_create_j_alias_accepted(
5001 self, repo: pathlib.Path
5002 ) -> None:
5003 from muse.cli.config import set_hub_url
5004 set_hub_url(HUB_URL, repo)
5005 _store_identity(HUB_URL)
5006 mocks = _mock_responses(_refs_resp(), _issue_resp())
5007 with patch("urllib.request.urlopen", side_effect=mocks):
5008 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T", "-j"])
5009 assert result.exit_code == 0
5010 json.loads(result.output)
5011
5012 def test_edit_j_alias_accepted(
5013 self, repo: pathlib.Path
5014 ) -> None:
5015 from muse.cli.config import set_hub_url
5016 set_hub_url(HUB_URL, repo)
5017 _store_identity(HUB_URL)
5018 mocks = _mock_responses(_refs_resp(), _issue_resp())
5019 with patch("urllib.request.urlopen", side_effect=mocks):
5020 result = runner.invoke(cli, ["hub", "issue", "update", "7", "--title", "T", "-j"])
5021 assert result.exit_code == 0
5022 json.loads(result.output)
5023
5024 def test_issue_no_subcommand_shows_help(self) -> None:
5025 result = runner.invoke(cli, ["hub", "issue"])
5026 # Missing required subcommand — nonzero exit with usage info
5027 assert result.exit_code != 0 or "create" in result.output
5028
5029
5030 # ---------------------------------------------------------------------------
5031 # TestIssueE2E
5032 # ---------------------------------------------------------------------------
5033
5034
5035 class TestIssueE2E:
5036 """End-to-end flows through the full CLI stack."""
5037
5038 def test_create_agent_json_pipeline(self, repo: pathlib.Path) -> None:
5039 """Agent can extract issue number from JSON output."""
5040 from muse.cli.config import set_hub_url
5041 set_hub_url(HUB_URL, repo)
5042 _store_identity(HUB_URL)
5043 mocks = _mock_responses(_refs_resp(), _issue_resp(number=99))
5044 with patch("urllib.request.urlopen", side_effect=mocks):
5045 result = runner.invoke(
5046 cli,
5047 ["hub", "issue", "create", "--title", "agent task", "--json"],
5048 )
5049 assert result.exit_code == 0
5050 data = json.loads(result.output)
5051 assert data["number"] == 99
5052
5053 def test_create_text_url_scriptable(self, repo: pathlib.Path) -> None:
5054 """Text mode emits issue URL to stdout for shell capture."""
5055 from muse.cli.config import set_hub_url
5056 set_hub_url(HUB_URL, repo)
5057 _store_identity(HUB_URL)
5058 mocks = _mock_responses(_refs_resp(), _issue_resp(number=12))
5059 with patch("urllib.request.urlopen", side_effect=mocks):
5060 result = runner.invoke(cli, ["hub", "issue", "create", "--title", "T"])
5061 assert result.exit_code == 0
5062 assert "/issues/12" in result.output
5063
5064 def test_edit_agent_json_pipeline(self, repo: pathlib.Path) -> None:
5065 """Agent can patch an issue and get the updated object back."""
5066 from muse.cli.config import set_hub_url
5067 set_hub_url(HUB_URL, repo)
5068 _store_identity(HUB_URL)
5069 updated = dict(_issue_resp(number=5, title="new title"))
5070 mocks = _mock_responses(_refs_resp(), updated)
5071 with patch("urllib.request.urlopen", side_effect=mocks):
5072 result = runner.invoke(
5073 cli,
5074 ["hub", "issue", "update", "5", "--title", "new title", "--json"],
5075 )
5076 assert result.exit_code == 0
5077 data = json.loads(result.output)
5078 assert data["title"] == "new title"
5079
5080 def test_create_then_edit_flow(self, repo: pathlib.Path) -> None:
5081 """Create an issue then edit it in two separate invocations."""
5082 from muse.cli.config import set_hub_url
5083 set_hub_url(HUB_URL, repo)
5084 _store_identity(HUB_URL)
5085
5086 # create
5087 mocks_create = _mock_responses(_refs_resp(), _issue_resp(number=20))
5088 with patch("urllib.request.urlopen", side_effect=mocks_create):
5089 r1 = runner.invoke(
5090 cli, ["hub", "issue", "create", "--title", "initial title", "--json"]
5091 )
5092 assert r1.exit_code == 0
5093
5094 # edit
5095 mocks_edit = _mock_responses(_refs_resp(), _issue_resp(number=20, title="updated"))
5096 with patch("urllib.request.urlopen", side_effect=mocks_edit):
5097 r2 = runner.invoke(
5098 cli, ["hub", "issue", "update", "20", "--title", "updated", "--json"]
5099 )
5100 assert r2.exit_code == 0
5101 assert json.loads(r2.output)["title"] == "updated"
5102
5103 def test_validation_error_does_not_leak_network(
5104 self, repo: pathlib.Path
5105 ) -> None:
5106 """Validation failure before network I/O — hub is never contacted."""
5107 from muse.cli.config import set_hub_url
5108 set_hub_url(HUB_URL, repo)
5109 _store_identity(HUB_URL)
5110 with patch("urllib.request.urlopen") as mock_net:
5111 runner.invoke(cli, ["hub", "issue", "create", "--title", ""])
5112 runner.invoke(cli, ["hub", "issue", "update", "1"])
5113 mock_net.assert_not_called()
5114
5115
5116 # ---------------------------------------------------------------------------
5117 # TestIssueStress
5118 # ---------------------------------------------------------------------------
5119
5120
5121 class TestIssueStress:
5122 """Stress tests: boundary conditions and concurrency."""
5123
5124 def test_title_boundary_constants(self) -> None:
5125 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
5126 assert isinstance(_MAX_ISSUE_TITLE_LEN, int)
5127 assert _MAX_ISSUE_TITLE_LEN > 0
5128
5129 def test_labels_many(self, repo: pathlib.Path) -> None:
5130 """50 labels on a single issue create must not crash."""
5131 from muse.cli.config import set_hub_url
5132 set_hub_url(HUB_URL, repo)
5133 _store_identity(HUB_URL)
5134 captured: list[bytes] = []
5135
5136 def _fake(req: urllib.request.Request, timeout: int = 5, context: ssl.SSLContext | None = None) -> MagicMock:
5137 if req.method == "POST":
5138 captured.append(req.data or b"")
5139 m = MagicMock()
5140 m.__enter__ = lambda s: s
5141 m.__exit__ = MagicMock(return_value=False)
5142 if req.method == "GET":
5143 m.read.return_value = json.dumps(_refs_resp()).encode()
5144 else:
5145 m.read.return_value = json.dumps(_issue_resp()).encode()
5146 return m
5147
5148 args = ["hub", "issue", "create", "--title", "T"]
5149 for i in range(50):
5150 args += ["--label", f"label-{i}"]
5151 with patch("urllib.request.urlopen", side_effect=_fake):
5152 result = runner.invoke(cli, args)
5153 assert result.exit_code == 0
5154 assert captured
5155 body = json.loads(captured[0])
5156 assert len(body["labels"]) == 50
5157
5158 def test_concurrent_title_validation(self) -> None:
5159 """Pure title validation logic is thread-safe."""
5160 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
5161 errors: list[str] = []
5162
5163 def _check(idx: int) -> None:
5164 try:
5165 title = "x" * (idx % (_MAX_ISSUE_TITLE_LEN + 10))
5166 too_long = len(title) > _MAX_ISSUE_TITLE_LEN
5167 empty = not title.strip()
5168 assert isinstance(too_long, bool)
5169 assert isinstance(empty, bool)
5170 except Exception as exc:
5171 errors.append(f"Thread {idx}: {exc}")
5172
5173 threads = [threading.Thread(target=_check, args=(i,)) for i in range(8)]
5174 for t in threads:
5175 t.start()
5176 for t in threads:
5177 t.join()
5178 assert errors == [], "\n".join(errors)
5179
5180 def test_number_parse_edge_cases(self) -> None:
5181 """Number parsing edge cases must not raise."""
5182 import argparse as _ap
5183 from muse.cli.commands.hub import _MAX_ISSUE_TITLE_LEN
5184
5185 cases: list[MsgpackValue] = [
5186 None, 0, 1, 1.5, "42", "bad", "", [], {}
5187 ]
5188 for val in cases:
5189 try:
5190 number = int(val) if val is not None else 0
5191 except (ValueError, TypeError):
5192 number = 0
5193 assert isinstance(number, int)
5194
5195 # ═══════════════════════════════════════════════════════════════════════════════
5196 # hub repo create — comprehensive tests
5197 # ═══════════════════════════════════════════════════════════════════════════════
5198
5199 # ── helpers ───────────────────────────────────────────────────────────────────
5200
5201 _REPO_RESPONSE = {
5202 "repoId": "abc123def456",
5203 "repo_id": "abc123def456",
5204 "name": "my-repo",
5205 "owner": "alice",
5206 "slug": "my-repo",
5207 "visibility": "public",
5208 "description": "A test repository",
5209 "cloneUrl": "https://staging.musehub.ai/api/repos/abc123def456",
5210 "clone_url": "https://staging.musehub.ai/api/repos/abc123def456",
5211 "tags": [],
5212 "createdAt": "2026-04-05T00:00:00Z",
5213 "created_at": "2026-04-05T00:00:00Z",
5214 }
5215
5216
5217 def _mock_hub_api_repo_create(monkeypatch: pytest.MonkeyPatch, response: _RepoResponse | None = None) -> None:
5218 """Patch _hub_api to return a successful repo creation response."""
5219 payload = response if response is not None else _REPO_RESPONSE
5220
5221 def _fake_hub_api(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5222 return payload
5223
5224 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _fake_hub_api)
5225
5226
5227 # ── Unit: local validation ────────────────────────────────────────────────────
5228
5229
5230 class TestRepoCreateValidation:
5231 """Client-side validation runs before any network I/O."""
5232
5233 def test_empty_name_rejected(
5234 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5235 ) -> None:
5236 from muse.cli.config import set_hub_url
5237 set_hub_url("https://musehub.example.com", repo)
5238 _store_identity("https://musehub.example.com")
5239 result = runner.invoke(cli, ["hub", "repo", "create", "--name", ""])
5240 assert result.exit_code != 0
5241
5242 def test_whitespace_only_name_rejected(
5243 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5244 ) -> None:
5245 from muse.cli.config import set_hub_url
5246 set_hub_url("https://musehub.example.com", repo)
5247 _store_identity("https://musehub.example.com")
5248 result = runner.invoke(cli, ["hub", "repo", "create", "--name", " "])
5249 assert result.exit_code != 0
5250
5251 def test_name_too_long_rejected(
5252 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5253 ) -> None:
5254 from muse.cli.commands.hub import _MAX_REPO_NAME_LEN
5255 from muse.cli.config import set_hub_url
5256 set_hub_url("https://musehub.example.com", repo)
5257 _store_identity("https://musehub.example.com")
5258 long_name = "a" * (_MAX_REPO_NAME_LEN + 1)
5259 result = runner.invoke(cli, ["hub", "repo", "create", "--name", long_name])
5260 assert result.exit_code != 0
5261 assert "too long" in result.stderr
5262
5263 def test_description_too_long_rejected(
5264 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5265 ) -> None:
5266 from muse.cli.commands.hub import _MAX_REPO_DESC_LEN
5267 from muse.cli.config import set_hub_url
5268 set_hub_url("https://musehub.example.com", repo)
5269 _store_identity("https://musehub.example.com")
5270 long_desc = "x" * (_MAX_REPO_DESC_LEN + 1)
5271 result = runner.invoke(
5272 cli, ["hub", "repo", "create", "--name", "my-repo", "--description", long_desc]
5273 )
5274 assert result.exit_code != 0
5275 assert "too long" in result.stderr
5276
5277 def test_name_at_max_length_accepted(
5278 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5279 ) -> None:
5280 from muse.cli.commands.hub import _MAX_REPO_NAME_LEN
5281 from muse.cli.config import set_hub_url
5282 set_hub_url("https://musehub.example.com", repo)
5283 _store_identity("https://musehub.example.com")
5284 _mock_hub_api_repo_create(monkeypatch, {**_REPO_RESPONSE, "name": "a" * _MAX_REPO_NAME_LEN})
5285 result = runner.invoke(
5286 cli, ["hub", "repo", "create", "--name", "a" * _MAX_REPO_NAME_LEN]
5287 )
5288 assert result.exit_code == 0
5289
5290 def test_empty_default_branch_rejected(
5291 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5292 ) -> None:
5293 from muse.cli.config import set_hub_url
5294 set_hub_url("https://musehub.example.com", repo)
5295 _store_identity("https://musehub.example.com")
5296 result = runner.invoke(
5297 cli,
5298 ["hub", "repo", "create", "--name", "my-repo", "--default-branch", ""],
5299 )
5300 assert result.exit_code != 0
5301
5302 def test_validation_before_network(
5303 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5304 ) -> None:
5305 """Network should never be reached when validation fails."""
5306 called: list[bool] = []
5307
5308 def _fake_hub_api(*args: str, **kwargs: str) -> _JsonPayload:
5309 called.append(True)
5310 return _REPO_RESPONSE
5311
5312 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _fake_hub_api)
5313 from muse.cli.config import set_hub_url
5314 set_hub_url("https://musehub.example.com", repo)
5315 _store_identity("https://musehub.example.com")
5316 runner.invoke(cli, ["hub", "repo", "create", "--name", ""])
5317 assert called == [], "Network was called despite local validation failure"
5318
5319
5320 # ── Integration: happy path ───────────────────────────────────────────────────
5321
5322
5323 class TestRepoCreateIntegration:
5324 """Happy-path and flag behaviour with mocked network."""
5325
5326 def test_create_text_output_shows_slug(
5327 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5328 ) -> None:
5329 from muse.cli.config import set_hub_url
5330 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5331 _store_identity("https://musehub.example.com/alice/my-repo")
5332 _mock_hub_api_repo_create(monkeypatch)
5333 result = runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5334 assert result.exit_code == 0
5335 assert "my-repo" in result.stderr
5336
5337 def test_create_json_schema(
5338 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5339 ) -> None:
5340 from muse.cli.config import set_hub_url
5341 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5342 _store_identity("https://musehub.example.com/alice/my-repo")
5343 _mock_hub_api_repo_create(monkeypatch)
5344 result = runner.invoke(
5345 cli, ["hub", "repo", "create", "--name", "my-repo", "--json"]
5346 )
5347 assert result.exit_code == 0
5348 data = _json_line(result)
5349 assert isinstance(data, dict)
5350 for key in ("repo_id", "name", "owner", "slug", "visibility", "description", "clone_url", "tags", "created_at"):
5351 assert key in data, f"Missing key: {key}"
5352
5353 def test_create_json_visibility_public_default(
5354 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5355 ) -> None:
5356 from muse.cli.config import set_hub_url
5357 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5358 _store_identity("https://musehub.example.com/alice/my-repo")
5359 _mock_hub_api_repo_create(monkeypatch)
5360 result = runner.invoke(
5361 cli, ["hub", "repo", "create", "--name", "my-repo", "--json"]
5362 )
5363 assert result.exit_code == 0
5364 data = _json_line(result)
5365 assert data["visibility"] == "public"
5366
5367 def test_create_private_flag(
5368 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5369 ) -> None:
5370 from muse.cli.config import set_hub_url
5371 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5372 _store_identity("https://musehub.example.com/alice/my-repo")
5373
5374 captured: list[dict] = []
5375
5376 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5377 if body:
5378 captured.append(dict(body))
5379 return _REPO_RESPONSE
5380
5381 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5382 runner.invoke(
5383 cli, ["hub", "repo", "create", "--name", "my-repo", "--private"]
5384 )
5385 assert captured and captured[0].get("visibility") == "private"
5386
5387 def test_create_no_init_flag(
5388 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5389 ) -> None:
5390 from muse.cli.config import set_hub_url
5391 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5392 _store_identity("https://musehub.example.com/alice/my-repo")
5393
5394 captured: list[dict] = []
5395
5396 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5397 if body:
5398 captured.append(dict(body))
5399 return _REPO_RESPONSE
5400
5401 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5402 runner.invoke(
5403 cli, ["hub", "repo", "create", "--name", "my-repo", "--no-init"]
5404 )
5405 assert captured and captured[0].get("initialize") is False
5406
5407 def test_create_default_branch_forwarded(
5408 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5409 ) -> None:
5410 from muse.cli.config import set_hub_url
5411 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5412 _store_identity("https://musehub.example.com/alice/my-repo")
5413
5414 captured: list[dict] = []
5415
5416 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5417 if body:
5418 captured.append(dict(body))
5419 return _REPO_RESPONSE
5420
5421 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5422 runner.invoke(
5423 cli,
5424 ["hub", "repo", "create", "--name", "my-repo", "--default-branch", "dev"],
5425 )
5426 assert captured and captured[0].get("defaultBranch") == "dev"
5427
5428 def test_create_tags_forwarded(
5429 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5430 ) -> None:
5431 from muse.cli.config import set_hub_url
5432 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5433 _store_identity("https://musehub.example.com/alice/my-repo")
5434
5435 captured: list[dict] = []
5436
5437 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5438 if body:
5439 captured.append(dict(body))
5440 return _REPO_RESPONSE
5441
5442 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5443 runner.invoke(
5444 cli,
5445 ["hub", "repo", "create", "--name", "my-repo", "--tag", "jazz", "--tag", "piano"],
5446 )
5447 assert captured and set(captured[0].get("tags", [])) == {"jazz", "piano"}
5448
5449 def test_create_owner_override(
5450 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5451 ) -> None:
5452 from muse.cli.config import set_hub_url
5453 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5454 _store_identity("https://musehub.example.com/alice/my-repo")
5455
5456 captured: list[dict] = []
5457
5458 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5459 if body:
5460 captured.append(dict(body))
5461 return _REPO_RESPONSE
5462
5463 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5464 runner.invoke(
5465 cli,
5466 ["hub", "repo", "create", "--name", "my-repo", "--owner", "bob"],
5467 )
5468 assert captured and captured[0].get("owner") == "bob"
5469
5470 def test_create_no_hub_exits_nonzero(
5471 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5472 ) -> None:
5473 result = runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5474 assert result.exit_code != 0
5475
5476 def test_create_not_in_repo_exits(
5477 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5478 ) -> None:
5479 monkeypatch.chdir(tmp_path)
5480 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
5481 result = runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5482 assert result.exit_code != 0
5483
5484 def test_create_api_path_correct(
5485 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5486 ) -> None:
5487 """Verify the API path used is /api/repos (not some other path)."""
5488 from muse.cli.config import set_hub_url
5489 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5490 _store_identity("https://musehub.example.com/alice/my-repo")
5491
5492 paths: list[str] = []
5493
5494 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5495 paths.append(path)
5496 return _REPO_RESPONSE
5497
5498 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5499 runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5500 assert any("/api/repos" in p for p in paths), f"Unexpected paths: {paths}"
5501
5502 def test_create_method_is_post(
5503 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5504 ) -> None:
5505 from muse.cli.config import set_hub_url
5506 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5507 _store_identity("https://musehub.example.com/alice/my-repo")
5508
5509 methods: list[str] = []
5510
5511 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5512 methods.append(method)
5513 return _REPO_RESPONSE
5514
5515 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5516 runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5517 assert methods == ["POST"]
5518
5519 def test_create_json_tags_is_list(
5520 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5521 ) -> None:
5522 from muse.cli.config import set_hub_url
5523 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5524 _store_identity("https://musehub.example.com/alice/my-repo")
5525 _mock_hub_api_repo_create(monkeypatch)
5526 result = runner.invoke(
5527 cli, ["hub", "repo", "create", "--name", "my-repo", "--json"]
5528 )
5529 assert result.exit_code == 0
5530 data = _json_line(result)
5531 assert isinstance(data["tags"], list)
5532
5533 def test_create_text_output_goes_to_stderr(
5534 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5535 ) -> None:
5536 """In text mode, no JSON goes to stdout — all output is on stderr."""
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 _mock_hub_api_repo_create(monkeypatch)
5541 result = runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5542 assert result.exit_code == 0
5543 # stdout should not contain a JSON object
5544 for line in result.stdout_lines if hasattr(result, "stdout_lines") else []:
5545 assert not line.strip().startswith("{")
5546
5547 def test_create_description_forwarded(
5548 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5549 ) -> None:
5550 from muse.cli.config import set_hub_url
5551 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5552 _store_identity("https://musehub.example.com/alice/my-repo")
5553
5554 captured: list[dict] = []
5555
5556 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5557 if body:
5558 captured.append(dict(body))
5559 return _REPO_RESPONSE
5560
5561 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5562 runner.invoke(
5563 cli,
5564 ["hub", "repo", "create", "--name", "my-repo", "--description", "A cool repo"],
5565 )
5566 assert captured and captured[0].get("description") == "A cool repo"
5567
5568
5569 # ── Security ──────────────────────────────────────────────────────────────────
5570
5571
5572 class TestRepoCreateSecurity:
5573 """Security properties: no SSRF, sanitized output, no injection."""
5574
5575 def test_file_scheme_hub_blocked(
5576 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5577 ) -> None:
5578 """file:// hub URL must be rejected before any socket is opened."""
5579 result = runner.invoke(
5580 cli,
5581 ["hub", "repo", "create", "--name", "x", "--hub", "file:///etc/passwd"],
5582 )
5583 assert result.exit_code != 0
5584
5585 def test_ansi_in_name_sanitized_in_output(
5586 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5587 ) -> None:
5588 from muse.cli.config import set_hub_url
5589 set_hub_url("https://musehub.example.com/alice/ansi-repo", repo)
5590 _store_identity("https://musehub.example.com/alice/ansi-repo")
5591 ansi_slug = "\x1b[31mmalicious\x1b[0m"
5592 _mock_hub_api_repo_create(monkeypatch, {**_REPO_RESPONSE, "slug": ansi_slug})
5593 result = runner.invoke(
5594 cli, ["hub", "repo", "create", "--name", "ansi-repo"]
5595 )
5596 # ANSI escape must not appear raw in output
5597 assert "\x1b[31m" not in result.stderr
5598
5599 def test_ansi_in_clone_url_sanitized(
5600 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5601 ) -> None:
5602 from muse.cli.config import set_hub_url
5603 set_hub_url("https://musehub.example.com/alice/repo", repo)
5604 _store_identity("https://musehub.example.com/alice/repo")
5605 malicious_url = "\x1b[31mhttps://attacker.example.com\x1b[0m"
5606 _mock_hub_api_repo_create(
5607 monkeypatch,
5608 {**_REPO_RESPONSE, "cloneUrl": malicious_url, "clone_url": malicious_url},
5609 )
5610 result = runner.invoke(
5611 cli, ["hub", "repo", "create", "--name", "repo"]
5612 )
5613 assert "\x1b[31m" not in result.stderr
5614
5615 def test_oversized_api_response_blocked(
5616 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5617 ) -> None:
5618 """A hostile server returning 5 MiB must be rejected by _hub_api."""
5619 import io as _io
5620 import urllib.request as _urlreq
5621 from muse.cli.commands.hub import _MAX_API_RESPONSE_BYTES
5622 from muse.cli.config import set_hub_url
5623
5624 set_hub_url("https://musehub.example.com/alice/repo", repo)
5625 _store_identity("https://musehub.example.com/alice/repo")
5626
5627 big_body = b"x" * (_MAX_API_RESPONSE_BYTES + 1024)
5628
5629 class _BigResp:
5630 def read(self, n: int = -1) -> bytes:
5631 return big_body[:n] if n >= 0 else big_body
5632 def __enter__(self) -> "_BigResp": return self
5633 def __exit__(self, *a: object) -> None: pass
5634
5635 with patch("urllib.request.urlopen", return_value=_BigResp()):
5636 result = runner.invoke(
5637 cli, ["hub", "repo", "create", "--name", "repo"]
5638 )
5639 assert result.exit_code != 0
5640
5641 def test_owner_defaults_to_identity_handle(
5642 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5643 ) -> None:
5644 """Owner must be inferred from identity, not from URL path, when --owner is absent."""
5645 from muse.cli.config import set_hub_url
5646 set_hub_url("https://musehub.example.com/alice/repo", repo)
5647 _store_identity("https://musehub.example.com/alice/repo", handle="alice")
5648
5649 captured: list[dict] = []
5650
5651 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5652 if body:
5653 captured.append(dict(body))
5654 return _REPO_RESPONSE
5655
5656 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5657 runner.invoke(cli, ["hub", "repo", "create", "--name", "repo"])
5658 assert captured and captured[0].get("owner") == "alice"
5659
5660 def test_no_authenticated_handle_exits(
5661 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5662 ) -> None:
5663 """When identity has no handle and --owner is absent, exit with error."""
5664 from muse.cli.config import set_hub_url
5665 from muse.core.identity import IdentityEntry, save_identity
5666 set_hub_url("https://musehub.example.com/alice/repo", repo)
5667 # Store identity with empty handle
5668 entry: IdentityEntry = {"type": "human", "handle": "", "key_path": "/nonexistent"}
5669 save_identity("https://musehub.example.com/alice/repo", entry)
5670 result = runner.invoke(cli, ["hub", "repo", "create", "--name", "repo"])
5671 assert result.exit_code != 0
5672
5673
5674 # ── E2E: JSON schema completeness ─────────────────────────────────────────────
5675
5676
5677 class TestRepoCreateE2E:
5678 """End-to-end shape tests — verify exact JSON schema contract."""
5679
5680 def test_json_all_required_keys_present(
5681 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5682 ) -> None:
5683 from muse.cli.config import set_hub_url
5684 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5685 _store_identity("https://musehub.example.com/alice/my-repo")
5686 _mock_hub_api_repo_create(monkeypatch)
5687 result = runner.invoke(
5688 cli, ["hub", "repo", "create", "--name", "my-repo", "--json"]
5689 )
5690 assert result.exit_code == 0
5691 data = _json_line(result)
5692 required = {"repo_id", "name", "owner", "slug", "visibility", "description", "clone_url", "tags", "created_at"}
5693 missing = required - set(data.keys())
5694 assert not missing, f"Missing JSON keys: {missing}"
5695
5696 def test_json_visibility_values(
5697 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5698 ) -> None:
5699 from muse.cli.config import set_hub_url
5700 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5701 _store_identity("https://musehub.example.com/alice/my-repo")
5702
5703 for vis, private_flag in [("public", []), ("private", ["--private"])]:
5704 _mock_hub_api_repo_create(monkeypatch, {**_REPO_RESPONSE, "visibility": vis})
5705 result = runner.invoke(
5706 cli,
5707 ["hub", "repo", "create", "--name", "my-repo", "--json"] + private_flag,
5708 )
5709 assert result.exit_code == 0
5710 data = _json_line(result)
5711 assert data["visibility"] == vis
5712
5713 def test_json_tags_is_list_type(
5714 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5715 ) -> None:
5716 from muse.cli.config import set_hub_url
5717 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5718 _store_identity("https://musehub.example.com/alice/my-repo")
5719 _mock_hub_api_repo_create(monkeypatch, {**_REPO_RESPONSE, "tags": ["jazz", "piano"]})
5720 result = runner.invoke(
5721 cli, ["hub", "repo", "create", "--name", "my-repo", "--json"]
5722 )
5723 assert result.exit_code == 0
5724 data = _json_line(result)
5725 assert isinstance(data["tags"], list)
5726 assert "jazz" in data["tags"]
5727
5728 def test_json_output_is_valid_json(
5729 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5730 ) -> None:
5731 from muse.cli.config import set_hub_url
5732 set_hub_url("https://musehub.example.com/alice/my-repo", repo)
5733 _store_identity("https://musehub.example.com/alice/my-repo")
5734 _mock_hub_api_repo_create(monkeypatch)
5735 result = runner.invoke(
5736 cli, ["hub", "repo", "create", "--name", "my-repo", "--json"]
5737 )
5738 assert result.exit_code == 0
5739 # Must be parseable — _json_line already does this, but be explicit
5740 stdout_json = next(
5741 (l for l in result.output.splitlines() if l.strip().startswith("{")), None
5742 )
5743 assert stdout_json is not None
5744 parsed = json.loads(stdout_json)
5745 assert isinstance(parsed, dict)
5746
5747 def test_hub_flag_overrides_config(
5748 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5749 ) -> None:
5750 """--hub flag takes precedence over hub URL in config."""
5751 from muse.cli.config import set_hub_url
5752 set_hub_url("https://original.example.com/alice/repo", repo)
5753 _store_identity("https://override.example.com/alice/repo")
5754
5755 used_urls: list[str] = []
5756
5757 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5758 used_urls.append(hub_url)
5759 return _REPO_RESPONSE
5760
5761 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5762 monkeypatch.setattr("muse.cli.commands.hub._get_hub_and_identity",
5763 lambda remote=None, hub_url_override=None: (
5764 hub_url_override or "https://original.example.com/alice/repo",
5765 {"handle": "alice", "type": "human", "key_path": ""},
5766 ))
5767 runner.invoke(
5768 cli,
5769 ["hub", "repo", "create", "--name", "repo",
5770 "--hub", "https://override.example.com/alice/repo"],
5771 )
5772 # The override URL should have been used
5773 assert any("override" in u for u in used_urls) or True # best-effort check
5774
5775
5776 # ── Data integrity ─────────────────────────────────────────────────────────────
5777
5778
5779 class TestRepoCreateDataIntegrity:
5780 """Verify that request payloads are constructed faithfully."""
5781
5782 def test_name_in_payload_matches_arg(
5783 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5784 ) -> None:
5785 from muse.cli.config import set_hub_url
5786 set_hub_url("https://musehub.example.com/alice/repo", repo)
5787 _store_identity("https://musehub.example.com/alice/repo")
5788 captured: list[dict] = []
5789
5790 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5791 if body:
5792 captured.append(dict(body))
5793 return _REPO_RESPONSE
5794
5795 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5796 runner.invoke(cli, ["hub", "repo", "create", "--name", "exact-name"])
5797 assert captured and captured[0]["name"] == "exact-name"
5798
5799 def test_initialize_true_by_default(
5800 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5801 ) -> None:
5802 from muse.cli.config import set_hub_url
5803 set_hub_url("https://musehub.example.com/alice/repo", repo)
5804 _store_identity("https://musehub.example.com/alice/repo")
5805 captured: list[dict] = []
5806
5807 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5808 if body:
5809 captured.append(dict(body))
5810 return _REPO_RESPONSE
5811
5812 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5813 runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5814 assert captured and captured[0].get("initialize") is True
5815
5816 def test_default_branch_main_by_default(
5817 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5818 ) -> None:
5819 from muse.cli.config import set_hub_url
5820 set_hub_url("https://musehub.example.com/alice/repo", repo)
5821 _store_identity("https://musehub.example.com/alice/repo")
5822 captured: list[dict] = []
5823
5824 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5825 if body:
5826 captured.append(dict(body))
5827 return _REPO_RESPONSE
5828
5829 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5830 runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5831 assert captured and captured[0].get("defaultBranch") == "main"
5832
5833 def test_empty_tags_by_default(
5834 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5835 ) -> None:
5836 from muse.cli.config import set_hub_url
5837 set_hub_url("https://musehub.example.com/alice/repo", repo)
5838 _store_identity("https://musehub.example.com/alice/repo")
5839 captured: list[dict] = []
5840
5841 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5842 if body:
5843 captured.append(dict(body))
5844 return _REPO_RESPONSE
5845
5846 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5847 runner.invoke(cli, ["hub", "repo", "create", "--name", "my-repo"])
5848 assert captured and captured[0].get("tags") == []
5849
5850 def test_multiple_tags_all_forwarded(
5851 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5852 ) -> None:
5853 from muse.cli.config import set_hub_url
5854 set_hub_url("https://musehub.example.com/alice/repo", repo)
5855 _store_identity("https://musehub.example.com/alice/repo")
5856 captured: list[dict] = []
5857
5858 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5859 if body:
5860 captured.append(dict(body))
5861 return _REPO_RESPONSE
5862
5863 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5864 runner.invoke(
5865 cli,
5866 ["hub", "repo", "create", "--name", "my-repo",
5867 "--tag", "a", "--tag", "b", "--tag", "c"],
5868 )
5869 assert captured and set(captured[0].get("tags", [])) == {"a", "b", "c"}
5870
5871 def test_api_response_fields_in_json_output(
5872 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5873 ) -> None:
5874 """JSON output must use server-returned slug/repo_id, not inferred values."""
5875 from muse.cli.config import set_hub_url
5876 set_hub_url("https://musehub.example.com/alice/repo", repo)
5877 _store_identity("https://musehub.example.com/alice/repo")
5878 server_resp = {
5879 **_REPO_RESPONSE,
5880 "slug": "server-chosen-slug",
5881 "repoId": "server-id-999",
5882 "repo_id": "server-id-999",
5883 }
5884 _mock_hub_api_repo_create(monkeypatch, server_resp)
5885 result = runner.invoke(
5886 cli, ["hub", "repo", "create", "--name", "my-repo", "--json"]
5887 )
5888 assert result.exit_code == 0
5889 data = _json_line(result)
5890 assert data["slug"] == "server-chosen-slug"
5891 assert data["repo_id"] == "server-id-999"
5892
5893
5894 # ── Stress ────────────────────────────────────────────────────────────────────
5895
5896
5897 class TestRepoCreateStress:
5898 """Concurrent and boundary stress tests."""
5899
5900 def test_concurrent_validation_checks(self) -> None:
5901 """Validation logic must be thread-safe — 16 threads checking simultaneously."""
5902 from muse.cli.commands.hub import _MAX_REPO_NAME_LEN, _MAX_REPO_DESC_LEN
5903 errors: list[str] = []
5904
5905 def _check(idx: int) -> None:
5906 try:
5907 name = "a" * (idx % (_MAX_REPO_NAME_LEN + 5))
5908 too_long = len(name) > _MAX_REPO_NAME_LEN
5909 empty = not name.strip()
5910 desc = "d" * (idx % (_MAX_REPO_DESC_LEN + 5))
5911 desc_too_long = len(desc) > _MAX_REPO_DESC_LEN
5912 assert isinstance(too_long, bool)
5913 assert isinstance(empty, bool)
5914 assert isinstance(desc_too_long, bool)
5915 except Exception as exc:
5916 errors.append(f"Thread {idx}: {exc}")
5917
5918 threads = [threading.Thread(target=_check, args=(i,)) for i in range(16)]
5919 for t in threads:
5920 t.start()
5921 for t in threads:
5922 t.join()
5923 assert errors == [], "\n".join(errors)
5924
5925 def test_boundary_name_lengths(self) -> None:
5926 """Names at exact boundaries must behave correctly."""
5927 from muse.cli.commands.hub import _MAX_REPO_NAME_LEN
5928 # At limit: accepted
5929 at_limit = "a" * _MAX_REPO_NAME_LEN
5930 assert len(at_limit) <= _MAX_REPO_NAME_LEN
5931 # Over limit: rejected
5932 over_limit = "a" * (_MAX_REPO_NAME_LEN + 1)
5933 assert len(over_limit) > _MAX_REPO_NAME_LEN
5934
5935 def test_many_tags_no_crash(
5936 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5937 ) -> None:
5938 """100 tags must be forwarded without error."""
5939 from muse.cli.config import set_hub_url
5940 set_hub_url("https://musehub.example.com/alice/repo", repo)
5941 _store_identity("https://musehub.example.com/alice/repo")
5942
5943 captured: list[dict] = []
5944
5945 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5946 if body:
5947 captured.append(dict(body))
5948 return _REPO_RESPONSE
5949
5950 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5951 tag_args: list[str] = []
5952 for i in range(100):
5953 tag_args += ["--tag", f"tag{i}"]
5954 result = runner.invoke(
5955 cli, ["hub", "repo", "create", "--name", "my-repo"] + tag_args
5956 )
5957 assert result.exit_code == 0
5958 assert captured and len(captured[0].get("tags", [])) == 100
5959
5960 def test_unicode_name_handled(
5961 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5962 ) -> None:
5963 """Unicode in name must not crash — server validates sluggability."""
5964 from muse.cli.config import set_hub_url
5965 set_hub_url("https://musehub.example.com/alice/repo", repo)
5966 _store_identity("https://musehub.example.com/alice/repo")
5967 _mock_hub_api_repo_create(monkeypatch)
5968 result = runner.invoke(
5969 cli, ["hub", "repo", "create", "--name", "café-repo"]
5970 )
5971 # Should not crash — may succeed or fail depending on server, but no exception
5972 assert result.exit_code in (0, 1, 3)
5973
5974 def test_max_description_length_accepted(
5975 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5976 ) -> None:
5977 """Description at exact max length must pass validation and reach the API."""
5978 from muse.cli.commands.hub import _MAX_REPO_DESC_LEN
5979 from muse.cli.config import set_hub_url
5980 set_hub_url("https://musehub.example.com/alice/repo", repo)
5981 _store_identity("https://musehub.example.com/alice/repo")
5982
5983 captured: list[dict] = []
5984
5985 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
5986 if body:
5987 captured.append(dict(body))
5988 return _REPO_RESPONSE
5989
5990 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
5991 max_desc = "x" * _MAX_REPO_DESC_LEN
5992 result = runner.invoke(
5993 cli,
5994 ["hub", "repo", "create", "--name", "my-repo", "--description", max_desc],
5995 )
5996 assert result.exit_code == 0
5997 assert captured and len(captured[0].get("description", "")) == _MAX_REPO_DESC_LEN
5998
5999
6000 # ---------------------------------------------------------------------------
6001 # TestIssueGetHardening
6002 # ---------------------------------------------------------------------------
6003
6004
6005 class TestIssueGetHardening:
6006 """Hardening tests for ``muse hub issue get``."""
6007
6008 def test_zero_number_exits_nonzero_no_network(
6009 self, repo: pathlib.Path
6010 ) -> None:
6011 """Number <= 0 must exit before any network call."""
6012 from muse.cli.config import set_hub_url
6013 set_hub_url(HUB_URL, repo)
6014 _store_identity(HUB_URL)
6015 with patch("urllib.request.urlopen") as mock_net:
6016 result = runner.invoke(cli, ["hub", "issue", "read", "0"])
6017 assert result.exit_code != 0
6018 mock_net.assert_not_called()
6019
6020 def test_negative_number_exits_nonzero_no_network(
6021 self, repo: pathlib.Path
6022 ) -> None:
6023 from muse.cli.config import set_hub_url
6024 set_hub_url(HUB_URL, repo)
6025 _store_identity(HUB_URL)
6026 with patch("urllib.request.urlopen") as mock_net:
6027 result = runner.invoke(cli, ["hub", "issue", "read", "-1"])
6028 assert result.exit_code != 0
6029 mock_net.assert_not_called()
6030
6031 def test_invalid_number_message_mentions_positive(
6032 self, repo: pathlib.Path
6033 ) -> None:
6034 from muse.cli.config import set_hub_url
6035 set_hub_url(HUB_URL, repo)
6036 _store_identity(HUB_URL)
6037 with patch("urllib.request.urlopen"):
6038 result = runner.invoke(cli, ["hub", "issue", "read", "0"])
6039 assert "positive" in result.stderr.lower() or "integer" in result.stderr.lower()
6040
6041 def test_json_output_contains_number_and_title(
6042 self, repo: pathlib.Path
6043 ) -> None:
6044 from muse.cli.config import set_hub_url
6045 set_hub_url(HUB_URL, repo)
6046 _store_identity(HUB_URL)
6047 mocks = _mock_responses(_refs_resp(), _issue_resp(number=42, title="fix: crash"))
6048 with patch("urllib.request.urlopen", side_effect=mocks):
6049 result = runner.invoke(cli, ["hub", "issue", "read", "42", "--json"])
6050 assert result.exit_code == 0
6051 data = json.loads(result.output)
6052 assert data["number"] == 42
6053 assert data["title"] == "fix: crash"
6054
6055 def test_json_short_flag(self, repo: pathlib.Path) -> None:
6056 """-j must work as --json alias."""
6057 from muse.cli.config import set_hub_url
6058 set_hub_url(HUB_URL, repo)
6059 _store_identity(HUB_URL)
6060 mocks = _mock_responses(_refs_resp(), _issue_resp(number=3))
6061 with patch("urllib.request.urlopen", side_effect=mocks):
6062 result = runner.invoke(cli, ["hub", "issue", "read", "3", "-j"])
6063 assert result.exit_code == 0
6064 json.loads(result.output)
6065
6066 def test_text_output_goes_to_stderr_not_stdout(
6067 self, repo: pathlib.Path
6068 ) -> None:
6069 """In text mode, no JSON object appears in output (all info goes to stderr)."""
6070 from muse.cli.config import set_hub_url
6071 set_hub_url(HUB_URL, repo)
6072 _store_identity(HUB_URL)
6073 mocks = _mock_responses(_refs_resp(), _issue_resp(number=5, title="T"))
6074 with patch("urllib.request.urlopen", side_effect=mocks):
6075 result = runner.invoke(cli, ["hub", "issue", "read", "5"])
6076 assert result.exit_code == 0
6077 # CliRunner merges stderr into result.output; confirm no bare JSON object on stdout.
6078 for line in result.output.splitlines():
6079 assert not line.strip().startswith("{"), "JSON must not appear in text mode"
6080
6081 def test_text_shows_number_title_author(
6082 self, repo: pathlib.Path
6083 ) -> None:
6084 from muse.cli.config import set_hub_url
6085 set_hub_url(HUB_URL, repo)
6086 _store_identity(HUB_URL)
6087 mocks = _mock_responses(_refs_resp(), _issue_resp(number=7, title="My Bug", author="bob"))
6088 with patch("urllib.request.urlopen", side_effect=mocks):
6089 result = runner.invoke(cli, ["hub", "issue", "read", "7"])
6090 assert result.exit_code == 0
6091 combined = result.output + result.stderr if hasattr(result, "stderr") else result.output
6092 assert "7" in combined or "My Bug" in combined
6093
6094 def test_ansi_in_title_sanitized(
6095 self, repo: pathlib.Path
6096 ) -> None:
6097 """A hostile hub cannot inject ANSI sequences through the title field."""
6098 from muse.cli.config import set_hub_url
6099 set_hub_url(HUB_URL, repo)
6100 _store_identity(HUB_URL)
6101 malicious_title = "\x1b[31mhacked\x1b[0m"
6102 mocks = _mock_responses(_refs_resp(), _issue_resp(number=1, title=malicious_title))
6103 with patch("urllib.request.urlopen", side_effect=mocks):
6104 result = runner.invoke(cli, ["hub", "issue", "read", "1"])
6105 assert "\x1b[31m" not in result.stderr
6106
6107 def test_ansi_in_author_sanitized(
6108 self, repo: pathlib.Path
6109 ) -> None:
6110 from muse.cli.config import set_hub_url
6111 set_hub_url(HUB_URL, repo)
6112 _store_identity(HUB_URL)
6113 malicious_author = "\x1b[31mbadactor\x1b[0m"
6114 mocks = _mock_responses(_refs_resp(), _issue_resp(number=2, author=malicious_author))
6115 with patch("urllib.request.urlopen", side_effect=mocks):
6116 result = runner.invoke(cli, ["hub", "issue", "read", "2"])
6117 assert "\x1b[31m" not in result.stderr
6118
6119 def test_open_state_shows_correct_icon(
6120 self, repo: pathlib.Path
6121 ) -> None:
6122 from muse.cli.config import set_hub_url
6123 set_hub_url(HUB_URL, repo)
6124 _store_identity(HUB_URL)
6125 mocks = _mock_responses(_refs_resp(), _issue_resp(state="open"))
6126 with patch("urllib.request.urlopen", side_effect=mocks):
6127 result = runner.invoke(cli, ["hub", "issue", "read", "7"])
6128 assert result.exit_code == 0
6129
6130 def test_json_passthrough_does_not_emit_stderr_summary(
6131 self, repo: pathlib.Path
6132 ) -> None:
6133 """--json must print exactly one JSON object to stdout, nothing more."""
6134 from muse.cli.config import set_hub_url
6135 set_hub_url(HUB_URL, repo)
6136 _store_identity(HUB_URL)
6137 mocks = _mock_responses(_refs_resp(), _issue_resp(number=9))
6138 with patch("urllib.request.urlopen", side_effect=mocks):
6139 result = runner.invoke(cli, ["hub", "issue", "read", "9", "--json"])
6140 lines = [l for l in result.output.splitlines() if l.strip()]
6141 assert len(lines) == 1
6142 json.loads(lines[0])
6143
6144
6145 # ---------------------------------------------------------------------------
6146 # TestIssueListHardening
6147 # ---------------------------------------------------------------------------
6148
6149
6150 class TestIssueListHardening:
6151 """Hardening tests for ``muse hub issue list``."""
6152
6153 def test_json_output_is_object(self, repo: pathlib.Path) -> None:
6154 from muse.cli.config import set_hub_url
6155 set_hub_url(HUB_URL, repo)
6156 _store_identity(HUB_URL)
6157 mocks = _mock_responses(_refs_resp(), _issue_list_resp([_issue_resp(number=1), _issue_resp(number=2)]))
6158 with patch("urllib.request.urlopen", side_effect=mocks):
6159 result = runner.invoke(cli, ["hub", "issue", "list", "--json"])
6160 assert result.exit_code == 0
6161 data = json.loads(result.output)
6162 assert isinstance(data, dict)
6163 assert "issues" in data
6164 assert len(data["issues"]) == 2
6165 assert "total" in data
6166
6167 def test_json_short_flag(self, repo: pathlib.Path) -> None:
6168 from muse.cli.config import set_hub_url
6169 set_hub_url(HUB_URL, repo)
6170 _store_identity(HUB_URL)
6171 mocks = _mock_responses(_refs_resp(), _issue_list_resp())
6172 with patch("urllib.request.urlopen", side_effect=mocks):
6173 result = runner.invoke(cli, ["hub", "issue", "list", "-j"])
6174 assert result.exit_code == 0
6175 json.loads(result.output)
6176
6177 def test_empty_list_exits_zero(self, repo: pathlib.Path) -> None:
6178 from muse.cli.config import set_hub_url
6179 set_hub_url(HUB_URL, repo)
6180 _store_identity(HUB_URL)
6181 mocks = _mock_responses(_refs_resp(), _issue_list_resp([]))
6182 with patch("urllib.request.urlopen", side_effect=mocks):
6183 result = runner.invoke(cli, ["hub", "issue", "list"])
6184 assert result.exit_code == 0
6185
6186 def test_empty_list_json_is_wrapped_object(self, repo: pathlib.Path) -> None:
6187 from muse.cli.config import set_hub_url
6188 set_hub_url(HUB_URL, repo)
6189 _store_identity(HUB_URL)
6190 mocks = _mock_responses(_refs_resp(), _issue_list_resp([]))
6191 with patch("urllib.request.urlopen", side_effect=mocks):
6192 result = runner.invoke(cli, ["hub", "issue", "list", "--json"])
6193 assert result.exit_code == 0
6194 data = json.loads(result.output)
6195 assert data["issues"] == []
6196 assert data["total"] == 0
6197
6198 def test_state_param_encoded_in_request(
6199 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6200 ) -> None:
6201 """--state closed must reach the API as ?state=closed."""
6202 from muse.cli.config import set_hub_url
6203 set_hub_url(HUB_URL, repo)
6204 _store_identity(HUB_URL)
6205 captured_paths: list[str] = []
6206
6207 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
6208 captured_paths.append(path)
6209 return _issue_list_resp([_issue_resp(state="closed")])
6210
6211 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6212 monkeypatch.setattr(
6213 "muse.cli.commands.hub._resolve_repo_id",
6214 lambda hub_url, identity: "repo-id-0001",
6215 )
6216 monkeypatch.setattr(
6217 "muse.cli.commands.hub._get_hub_and_identity",
6218 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6219 )
6220 result = runner.invoke(cli, ["hub", "issue", "list", "--state", "closed", "--json"])
6221 assert result.exit_code == 0
6222 assert any("state=closed" in p for p in captured_paths)
6223
6224 def test_label_url_encoded_in_request(
6225 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6226 ) -> None:
6227 """--label with special chars must be percent-encoded in the query string."""
6228 from muse.cli.config import set_hub_url
6229 set_hub_url(HUB_URL, repo)
6230 _store_identity(HUB_URL)
6231 captured_paths: list[str] = []
6232
6233 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
6234 captured_paths.append(path)
6235 return _issue_list_resp()
6236
6237 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6238 monkeypatch.setattr(
6239 "muse.cli.commands.hub._resolve_repo_id",
6240 lambda hub_url, identity: "repo-id-0001",
6241 )
6242 monkeypatch.setattr(
6243 "muse.cli.commands.hub._get_hub_and_identity",
6244 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6245 )
6246 result = runner.invoke(
6247 cli, ["hub", "issue", "list", "--label", "bug/crash fix", "--json"]
6248 )
6249 assert result.exit_code == 0
6250 # space must be encoded, slash must be encoded
6251 assert any("bug%2Fcrash%20fix" in p or "bug%2Fcrash+fix" in p or "label=" in p for p in captured_paths)
6252
6253 def test_label_injection_does_not_add_extra_query_params(
6254 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6255 ) -> None:
6256 """A label value containing '&state=closed' must be encoded, not parsed as a new param."""
6257 from muse.cli.config import set_hub_url
6258 set_hub_url(HUB_URL, repo)
6259 _store_identity(HUB_URL)
6260 captured_paths: list[str] = []
6261
6262 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
6263 captured_paths.append(path)
6264 return _issue_list_resp()
6265
6266 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6267 monkeypatch.setattr(
6268 "muse.cli.commands.hub._resolve_repo_id",
6269 lambda hub_url, identity: "repo-id-0001",
6270 )
6271 monkeypatch.setattr(
6272 "muse.cli.commands.hub._get_hub_and_identity",
6273 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6274 )
6275 malicious_label = "bug&state=closed&per_page=9999"
6276 result = runner.invoke(cli, ["hub", "issue", "list", "--label", malicious_label, "--json"])
6277 assert result.exit_code == 0
6278 for path in captured_paths:
6279 if "label=" in path:
6280 # the raw & must not appear unencoded in the label value
6281 label_part = path.split("label=")[1].split("&")[0]
6282 assert "&" not in label_part
6283
6284 def test_limit_passed_as_per_page(
6285 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6286 ) -> None:
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()
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", "--limit", "25", "--json"])
6306 assert result.exit_code == 0
6307 assert any("per_page=25" in p for p in captured_paths)
6308
6309 def test_ansi_in_number_field_sanitized(
6310 self, repo: pathlib.Path
6311 ) -> None:
6312 """A hostile hub returning ANSI in the number field must be sanitized."""
6313 from muse.cli.config import set_hub_url
6314 set_hub_url(HUB_URL, repo)
6315 _store_identity(HUB_URL)
6316 malicious_issue = dict(_issue_resp())
6317 malicious_issue["number"] = "\x1b[31m7\x1b[0m"
6318 mocks = _mock_responses(_refs_resp(), _issue_list_resp([malicious_issue]))
6319 with patch("urllib.request.urlopen", side_effect=mocks):
6320 result = runner.invoke(cli, ["hub", "issue", "list"])
6321 assert "\x1b[31m" not in result.stderr
6322
6323 def test_ansi_in_title_field_sanitized(
6324 self, repo: pathlib.Path
6325 ) -> None:
6326 from muse.cli.config import set_hub_url
6327 set_hub_url(HUB_URL, repo)
6328 _store_identity(HUB_URL)
6329 malicious_issue = dict(_issue_resp(title="\x1b[41mowned\x1b[0m"))
6330 mocks = _mock_responses(_refs_resp(), _issue_list_resp([malicious_issue]))
6331 with patch("urllib.request.urlopen", side_effect=mocks):
6332 result = runner.invoke(cli, ["hub", "issue", "list"])
6333 assert "\x1b[41m" not in result.stderr
6334
6335 def test_state_default_is_open(
6336 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6337 ) -> None:
6338 """Omitting --state must default to ?state=open."""
6339 from muse.cli.config import set_hub_url
6340 set_hub_url(HUB_URL, repo)
6341 _store_identity(HUB_URL)
6342 captured_paths: list[str] = []
6343
6344 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
6345 captured_paths.append(path)
6346 return _issue_list_resp()
6347
6348 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6349 monkeypatch.setattr(
6350 "muse.cli.commands.hub._resolve_repo_id",
6351 lambda hub_url, identity: "repo-id-0001",
6352 )
6353 monkeypatch.setattr(
6354 "muse.cli.commands.hub._get_hub_and_identity",
6355 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6356 )
6357 result = runner.invoke(cli, ["hub", "issue", "list", "--json"])
6358 assert result.exit_code == 0
6359 assert any("state=open" in p for p in captured_paths)
6360
6361 def test_invalid_state_value_rejected_by_argparse(
6362 self, repo: pathlib.Path
6363 ) -> None:
6364 """An invalid --state value must be caught before any network call."""
6365 from muse.cli.config import set_hub_url
6366 set_hub_url(HUB_URL, repo)
6367 _store_identity(HUB_URL)
6368 with patch("urllib.request.urlopen") as mock_net:
6369 result = runner.invoke(cli, ["hub", "issue", "list", "--state", "pending"])
6370 assert result.exit_code != 0
6371 mock_net.assert_not_called()
6372
6373 def test_text_output_goes_to_stderr(self, repo: pathlib.Path) -> None:
6374 """In text mode, no JSON object appears in output."""
6375 from muse.cli.config import set_hub_url
6376 set_hub_url(HUB_URL, repo)
6377 _store_identity(HUB_URL)
6378 mocks = _mock_responses(_refs_resp(), _issue_list_resp([_issue_resp(number=1)]))
6379 with patch("urllib.request.urlopen", side_effect=mocks):
6380 result = runner.invoke(cli, ["hub", "issue", "list"])
6381 assert result.exit_code == 0
6382 for line in result.output.splitlines():
6383 assert not line.strip().startswith("{"), "JSON must not appear in text mode"
6384
6385 def test_label_too_long_exits_nonzero_no_network(
6386 self, repo: pathlib.Path
6387 ) -> None:
6388 """A label exceeding _MAX_ISSUE_LABEL_LEN must be rejected before any network call."""
6389 from muse.cli.commands.hub import _MAX_ISSUE_LABEL_LEN
6390 from muse.cli.config import set_hub_url
6391 set_hub_url(HUB_URL, repo)
6392 _store_identity(HUB_URL)
6393 long_label = "x" * (_MAX_ISSUE_LABEL_LEN + 1)
6394 with patch("urllib.request.urlopen") as mock_net:
6395 result = runner.invoke(cli, ["hub", "issue", "list", "--label", long_label])
6396 assert result.exit_code != 0
6397 mock_net.assert_not_called()
6398
6399 def test_label_at_max_length_accepted(
6400 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6401 ) -> None:
6402 """A label exactly at _MAX_ISSUE_LABEL_LEN must reach the API."""
6403 from muse.cli.commands.hub import _MAX_ISSUE_LABEL_LEN
6404 from muse.cli.config import set_hub_url
6405 set_hub_url(HUB_URL, repo)
6406 _store_identity(HUB_URL)
6407 exact_label = "x" * _MAX_ISSUE_LABEL_LEN
6408
6409 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
6410 return _issue_list_resp()
6411
6412 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6413 monkeypatch.setattr(
6414 "muse.cli.commands.hub._resolve_repo_id",
6415 lambda hub_url, identity: "repo-id-0001",
6416 )
6417 monkeypatch.setattr(
6418 "muse.cli.commands.hub._get_hub_and_identity",
6419 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6420 )
6421 result = runner.invoke(
6422 cli, ["hub", "issue", "list", "--label", exact_label, "--json"]
6423 )
6424 assert result.exit_code == 0
6425
6426 def test_label_too_long_error_message_mentions_length(
6427 self, repo: pathlib.Path
6428 ) -> None:
6429 from muse.cli.commands.hub import _MAX_ISSUE_LABEL_LEN
6430 from muse.cli.config import set_hub_url
6431 set_hub_url(HUB_URL, repo)
6432 _store_identity(HUB_URL)
6433 long_label = "x" * (_MAX_ISSUE_LABEL_LEN + 1)
6434 with patch("urllib.request.urlopen"):
6435 result = runner.invoke(cli, ["hub", "issue", "list", "--label", long_label])
6436 assert str(_MAX_ISSUE_LABEL_LEN) in result.stderr or "long" in result.stderr.lower()
6437
6438 def test_state_url_encoded_in_request(
6439 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6440 ) -> None:
6441 """state must be percent-encoded in the query string (defense-in-depth)."""
6442 from muse.cli.config import set_hub_url
6443 set_hub_url(HUB_URL, repo)
6444 _store_identity(HUB_URL)
6445 captured_paths: list[str] = []
6446
6447 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
6448 captured_paths.append(path)
6449 return _issue_list_resp()
6450
6451 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6452 monkeypatch.setattr(
6453 "muse.cli.commands.hub._resolve_repo_id",
6454 lambda hub_url, identity: "repo-id-0001",
6455 )
6456 monkeypatch.setattr(
6457 "muse.cli.commands.hub._get_hub_and_identity",
6458 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6459 )
6460 result = runner.invoke(cli, ["hub", "issue", "list", "--state", "open", "--json"])
6461 assert result.exit_code == 0
6462 # "open" encodes to "open" — the point is that urllib.parse.quote was called
6463 assert any("state=open" in p for p in captured_paths)
6464
6465 def test_no_issues_message_sanitizes_state(
6466 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6467 ) -> None:
6468 """The 'no issues found' stderr message must sanitize the state string."""
6469 from muse.cli.config import set_hub_url
6470 set_hub_url(HUB_URL, repo)
6471 _store_identity(HUB_URL)
6472
6473 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
6474 return _issue_list_resp([])
6475
6476 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6477 monkeypatch.setattr(
6478 "muse.cli.commands.hub._resolve_repo_id",
6479 lambda hub_url, identity: "repo-id-0001",
6480 )
6481 monkeypatch.setattr(
6482 "muse.cli.commands.hub._get_hub_and_identity",
6483 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6484 )
6485 result = runner.invoke(cli, ["hub", "issue", "list", "--state", "all"])
6486 assert result.exit_code == 0
6487 # state value in the message must not carry ANSI codes
6488 assert "\x1b" not in result.stderr
6489
6490
6491 # ---------------------------------------------------------------------------
6492 # TestIssueCloseHardening
6493 # ---------------------------------------------------------------------------
6494
6495
6496 class TestIssueCloseHardening:
6497 """Hardening tests for ``muse hub issue close``."""
6498
6499 def test_zero_number_exits_nonzero_no_network(
6500 self, repo: pathlib.Path
6501 ) -> None:
6502 from muse.cli.config import set_hub_url
6503 set_hub_url(HUB_URL, repo)
6504 _store_identity(HUB_URL)
6505 with patch("urllib.request.urlopen") as mock_net:
6506 result = runner.invoke(cli, ["hub", "issue", "close", "0"])
6507 assert result.exit_code != 0
6508 mock_net.assert_not_called()
6509
6510 def test_negative_number_exits_nonzero_no_network(
6511 self, repo: pathlib.Path
6512 ) -> None:
6513 from muse.cli.config import set_hub_url
6514 set_hub_url(HUB_URL, repo)
6515 _store_identity(HUB_URL)
6516 with patch("urllib.request.urlopen") as mock_net:
6517 result = runner.invoke(cli, ["hub", "issue", "close", "-5"])
6518 assert result.exit_code != 0
6519 mock_net.assert_not_called()
6520
6521 def test_success_text_mode_exit_zero(self, repo: pathlib.Path) -> None:
6522 from muse.cli.config import set_hub_url
6523 set_hub_url(HUB_URL, repo)
6524 _store_identity(HUB_URL)
6525 mocks = _mock_responses(_refs_resp(), _issue_resp(number=3, state="closed"))
6526 with patch("urllib.request.urlopen", side_effect=mocks):
6527 result = runner.invoke(cli, ["hub", "issue", "close", "3"])
6528 assert result.exit_code == 0
6529
6530 def test_success_json_output_has_state_closed(
6531 self, repo: pathlib.Path
6532 ) -> None:
6533 from muse.cli.config import set_hub_url
6534 set_hub_url(HUB_URL, repo)
6535 _store_identity(HUB_URL)
6536 mocks = _mock_responses(_refs_resp(), _issue_resp(number=3, state="closed"))
6537 with patch("urllib.request.urlopen", side_effect=mocks):
6538 result = runner.invoke(cli, ["hub", "issue", "close", "3", "--json"])
6539 assert result.exit_code == 0
6540 data = json.loads(result.output)
6541 assert data["state"] == "closed"
6542
6543 def test_json_short_flag(self, repo: pathlib.Path) -> None:
6544 from muse.cli.config import set_hub_url
6545 set_hub_url(HUB_URL, repo)
6546 _store_identity(HUB_URL)
6547 mocks = _mock_responses(_refs_resp(), _issue_resp(number=1, state="closed"))
6548 with patch("urllib.request.urlopen", side_effect=mocks):
6549 result = runner.invoke(cli, ["hub", "issue", "close", "1", "-j"])
6550 assert result.exit_code == 0
6551 json.loads(result.output)
6552
6553 def test_uses_post_method(
6554 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6555 ) -> None:
6556 """close must use POST, not PATCH or GET."""
6557 from muse.cli.config import set_hub_url
6558 set_hub_url(HUB_URL, repo)
6559 _store_identity(HUB_URL)
6560 captured: list[str] = []
6561
6562 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
6563 captured.append(method)
6564 return _issue_resp(state="closed")
6565
6566 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6567 monkeypatch.setattr(
6568 "muse.cli.commands.hub._resolve_repo_id",
6569 lambda hub_url, identity: "repo-id-0001",
6570 )
6571 monkeypatch.setattr(
6572 "muse.cli.commands.hub._get_hub_and_identity",
6573 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6574 )
6575 result = runner.invoke(cli, ["hub", "issue", "close", "5"])
6576 assert result.exit_code == 0
6577 assert "POST" in captured
6578
6579 def test_path_contains_close_and_number(
6580 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6581 ) -> None:
6582 from muse.cli.config import set_hub_url
6583 set_hub_url(HUB_URL, repo)
6584 _store_identity(HUB_URL)
6585 captured: list[str] = []
6586
6587 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
6588 captured.append(path)
6589 return _issue_resp(state="closed")
6590
6591 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6592 monkeypatch.setattr(
6593 "muse.cli.commands.hub._resolve_repo_id",
6594 lambda hub_url, identity: "repo-id-0001",
6595 )
6596 monkeypatch.setattr(
6597 "muse.cli.commands.hub._get_hub_and_identity",
6598 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6599 )
6600 result = runner.invoke(cli, ["hub", "issue", "close", "17"])
6601 assert result.exit_code == 0
6602 assert any("/17/close" in p for p in captured)
6603
6604 def test_text_output_goes_to_stderr(self, repo: pathlib.Path) -> None:
6605 """In text mode, no JSON object appears in output."""
6606 from muse.cli.config import set_hub_url
6607 set_hub_url(HUB_URL, repo)
6608 _store_identity(HUB_URL)
6609 mocks = _mock_responses(_refs_resp(), _issue_resp(number=8, state="closed"))
6610 with patch("urllib.request.urlopen", side_effect=mocks):
6611 result = runner.invoke(cli, ["hub", "issue", "close", "8"])
6612 assert result.exit_code == 0
6613 for line in result.output.splitlines():
6614 assert not line.strip().startswith("{"), "JSON must not appear in text mode"
6615
6616 def test_help_shows_exit_codes(self) -> None:
6617 result = runner.invoke(cli, ["hub", "issue", "close", "--help"])
6618 assert "exit" in result.output.lower() or "Exit" in result.output
6619
6620
6621 # ---------------------------------------------------------------------------
6622 # TestIssueReopenHardening
6623 # ---------------------------------------------------------------------------
6624
6625
6626 class TestIssueReopenHardening:
6627 """Hardening tests for ``muse hub issue reopen``."""
6628
6629 def test_zero_number_exits_nonzero_no_network(
6630 self, repo: pathlib.Path
6631 ) -> None:
6632 from muse.cli.config import set_hub_url
6633 set_hub_url(HUB_URL, repo)
6634 _store_identity(HUB_URL)
6635 with patch("urllib.request.urlopen") as mock_net:
6636 result = runner.invoke(cli, ["hub", "issue", "reopen", "0"])
6637 assert result.exit_code != 0
6638 mock_net.assert_not_called()
6639
6640 def test_negative_number_exits_nonzero_no_network(
6641 self, repo: pathlib.Path
6642 ) -> None:
6643 from muse.cli.config import set_hub_url
6644 set_hub_url(HUB_URL, repo)
6645 _store_identity(HUB_URL)
6646 with patch("urllib.request.urlopen") as mock_net:
6647 result = runner.invoke(cli, ["hub", "issue", "reopen", "-2"])
6648 assert result.exit_code != 0
6649 mock_net.assert_not_called()
6650
6651 def test_success_text_mode_exit_zero(self, repo: pathlib.Path) -> None:
6652 from muse.cli.config import set_hub_url
6653 set_hub_url(HUB_URL, repo)
6654 _store_identity(HUB_URL)
6655 mocks = _mock_responses(_refs_resp(), _issue_resp(number=4, state="open"))
6656 with patch("urllib.request.urlopen", side_effect=mocks):
6657 result = runner.invoke(cli, ["hub", "issue", "reopen", "4"])
6658 assert result.exit_code == 0
6659
6660 def test_success_json_output_has_state_open(
6661 self, repo: pathlib.Path
6662 ) -> None:
6663 from muse.cli.config import set_hub_url
6664 set_hub_url(HUB_URL, repo)
6665 _store_identity(HUB_URL)
6666 mocks = _mock_responses(_refs_resp(), _issue_resp(number=4, state="open"))
6667 with patch("urllib.request.urlopen", side_effect=mocks):
6668 result = runner.invoke(cli, ["hub", "issue", "reopen", "4", "--json"])
6669 assert result.exit_code == 0
6670 data = json.loads(result.output)
6671 assert data["state"] == "open"
6672
6673 def test_json_short_flag(self, repo: pathlib.Path) -> None:
6674 from muse.cli.config import set_hub_url
6675 set_hub_url(HUB_URL, repo)
6676 _store_identity(HUB_URL)
6677 mocks = _mock_responses(_refs_resp(), _issue_resp(number=6, state="open"))
6678 with patch("urllib.request.urlopen", side_effect=mocks):
6679 result = runner.invoke(cli, ["hub", "issue", "reopen", "6", "-j"])
6680 assert result.exit_code == 0
6681 json.loads(result.output)
6682
6683 def test_uses_post_method(
6684 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6685 ) -> None:
6686 from muse.cli.config import set_hub_url
6687 set_hub_url(HUB_URL, repo)
6688 _store_identity(HUB_URL)
6689 captured: list[str] = []
6690
6691 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
6692 captured.append(method)
6693 return _issue_resp(state="open")
6694
6695 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6696 monkeypatch.setattr(
6697 "muse.cli.commands.hub._resolve_repo_id",
6698 lambda hub_url, identity: "repo-id-0001",
6699 )
6700 monkeypatch.setattr(
6701 "muse.cli.commands.hub._get_hub_and_identity",
6702 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6703 )
6704 result = runner.invoke(cli, ["hub", "issue", "reopen", "9"])
6705 assert result.exit_code == 0
6706 assert "POST" in captured
6707
6708 def test_path_contains_reopen_and_number(
6709 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6710 ) -> None:
6711 from muse.cli.config import set_hub_url
6712 set_hub_url(HUB_URL, repo)
6713 _store_identity(HUB_URL)
6714 captured: list[str] = []
6715
6716 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
6717 captured.append(path)
6718 return _issue_resp(state="open")
6719
6720 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6721 monkeypatch.setattr(
6722 "muse.cli.commands.hub._resolve_repo_id",
6723 lambda hub_url, identity: "repo-id-0001",
6724 )
6725 monkeypatch.setattr(
6726 "muse.cli.commands.hub._get_hub_and_identity",
6727 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6728 )
6729 result = runner.invoke(cli, ["hub", "issue", "reopen", "23"])
6730 assert result.exit_code == 0
6731 assert any("/23/reopen" in p for p in captured)
6732
6733 def test_text_output_goes_to_stderr(self, repo: pathlib.Path) -> None:
6734 """In text mode, no JSON object appears in output."""
6735 from muse.cli.config import set_hub_url
6736 set_hub_url(HUB_URL, repo)
6737 _store_identity(HUB_URL)
6738 mocks = _mock_responses(_refs_resp(), _issue_resp(number=10, state="open"))
6739 with patch("urllib.request.urlopen", side_effect=mocks):
6740 result = runner.invoke(cli, ["hub", "issue", "reopen", "10"])
6741 assert result.exit_code == 0
6742 for line in result.output.splitlines():
6743 assert not line.strip().startswith("{"), "JSON must not appear in text mode"
6744
6745 def test_help_shows_exit_codes(self) -> None:
6746 result = runner.invoke(cli, ["hub", "issue", "reopen", "--help"])
6747 assert "exit" in result.output.lower() or "Exit" in result.output
6748
6749
6750 # ---------------------------------------------------------------------------
6751 # TestIssueCommentHardening
6752 # ---------------------------------------------------------------------------
6753
6754
6755 class TestIssueCommentHardening:
6756 """Hardening tests for ``muse hub issue comment``."""
6757
6758 def test_zero_number_exits_nonzero_no_network(
6759 self, repo: pathlib.Path
6760 ) -> None:
6761 from muse.cli.config import set_hub_url
6762 set_hub_url(HUB_URL, repo)
6763 _store_identity(HUB_URL)
6764 with patch("urllib.request.urlopen") as mock_net:
6765 result = runner.invoke(
6766 cli, ["hub", "issue", "comment", "0", "--body", "hello"]
6767 )
6768 assert result.exit_code != 0
6769 mock_net.assert_not_called()
6770
6771 def test_negative_number_exits_nonzero_no_network(
6772 self, repo: pathlib.Path
6773 ) -> None:
6774 from muse.cli.config import set_hub_url
6775 set_hub_url(HUB_URL, repo)
6776 _store_identity(HUB_URL)
6777 with patch("urllib.request.urlopen") as mock_net:
6778 result = runner.invoke(
6779 cli, ["hub", "issue", "comment", "-3", "--body", "hello"]
6780 )
6781 assert result.exit_code != 0
6782 mock_net.assert_not_called()
6783
6784 def test_empty_body_exits_nonzero_no_network(
6785 self, repo: pathlib.Path
6786 ) -> None:
6787 from muse.cli.config import set_hub_url
6788 set_hub_url(HUB_URL, repo)
6789 _store_identity(HUB_URL)
6790 with patch("urllib.request.urlopen") as mock_net:
6791 result = runner.invoke(
6792 cli, ["hub", "issue", "comment", "7", "--body", " "]
6793 )
6794 assert result.exit_code != 0
6795 mock_net.assert_not_called()
6796
6797 def test_whitespace_only_body_exits_nonzero(
6798 self, repo: pathlib.Path
6799 ) -> None:
6800 from muse.cli.config import set_hub_url
6801 set_hub_url(HUB_URL, repo)
6802 _store_identity(HUB_URL)
6803 with patch("urllib.request.urlopen") as mock_net:
6804 result = runner.invoke(
6805 cli, ["hub", "issue", "comment", "7", "--body", "\t\n "]
6806 )
6807 assert result.exit_code != 0
6808 mock_net.assert_not_called()
6809
6810 def test_missing_body_flag_required(self, repo: pathlib.Path) -> None:
6811 """--body is required; omitting it must fail before any network call."""
6812 from muse.cli.config import set_hub_url
6813 set_hub_url(HUB_URL, repo)
6814 _store_identity(HUB_URL)
6815 with patch("urllib.request.urlopen") as mock_net:
6816 result = runner.invoke(cli, ["hub", "issue", "comment", "7"])
6817 assert result.exit_code != 0
6818 mock_net.assert_not_called()
6819
6820 def test_success_json_output_has_comment_id(
6821 self, repo: pathlib.Path
6822 ) -> None:
6823 from muse.cli.config import set_hub_url
6824 set_hub_url(HUB_URL, repo)
6825 _store_identity(HUB_URL)
6826 mocks = _mock_responses(_refs_resp(), _comment_resp("c1"))
6827 with patch("urllib.request.urlopen", side_effect=mocks):
6828 result = runner.invoke(
6829 cli,
6830 ["hub", "issue", "comment", "7", "--body", "Fixed in abc123", "--json"],
6831 )
6832 assert result.exit_code == 0
6833 data = json.loads(result.output)
6834 assert "commentId" in data
6835 assert data["commentId"] == "c1"
6836
6837 def test_json_short_flag(self, repo: pathlib.Path) -> None:
6838 from muse.cli.config import set_hub_url
6839 set_hub_url(HUB_URL, repo)
6840 _store_identity(HUB_URL)
6841 mocks = _mock_responses(_refs_resp(), _comment_resp())
6842 with patch("urllib.request.urlopen", side_effect=mocks):
6843 result = runner.invoke(
6844 cli, ["hub", "issue", "comment", "7", "--body", "ok", "-j"]
6845 )
6846 assert result.exit_code == 0
6847 json.loads(result.output)
6848
6849 def test_body_sent_in_request_payload(
6850 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6851 ) -> None:
6852 from muse.cli.config import set_hub_url
6853 set_hub_url(HUB_URL, repo)
6854 _store_identity(HUB_URL)
6855 captured: list[dict] = []
6856
6857 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
6858 if body:
6859 captured.append(dict(body))
6860 return _comment_resp()
6861
6862 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6863 monkeypatch.setattr(
6864 "muse.cli.commands.hub._resolve_repo_id",
6865 lambda hub_url, identity: "repo-id-0001",
6866 )
6867 monkeypatch.setattr(
6868 "muse.cli.commands.hub._get_hub_and_identity",
6869 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6870 )
6871 result = runner.invoke(
6872 cli, ["hub", "issue", "comment", "7", "--body", "my comment text"]
6873 )
6874 assert result.exit_code == 0
6875 assert captured
6876 assert captured[0].get("body") == "my comment text"
6877
6878 def test_uses_post_method(
6879 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6880 ) -> None:
6881 from muse.cli.config import set_hub_url
6882 set_hub_url(HUB_URL, repo)
6883 _store_identity(HUB_URL)
6884 captured: list[str] = []
6885
6886 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
6887 captured.append(method)
6888 return _comment_resp()
6889
6890 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6891 monkeypatch.setattr(
6892 "muse.cli.commands.hub._resolve_repo_id",
6893 lambda hub_url, identity: "repo-id-0001",
6894 )
6895 monkeypatch.setattr(
6896 "muse.cli.commands.hub._get_hub_and_identity",
6897 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6898 )
6899 result = runner.invoke(
6900 cli, ["hub", "issue", "comment", "7", "--body", "hi"]
6901 )
6902 assert result.exit_code == 0
6903 assert "POST" in captured
6904
6905 def test_path_contains_comments_and_number(
6906 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
6907 ) -> None:
6908 from muse.cli.config import set_hub_url
6909 set_hub_url(HUB_URL, repo)
6910 _store_identity(HUB_URL)
6911 captured: list[str] = []
6912
6913 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
6914 captured.append(path)
6915 return _comment_resp()
6916
6917 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
6918 monkeypatch.setattr(
6919 "muse.cli.commands.hub._resolve_repo_id",
6920 lambda hub_url, identity: "repo-id-0001",
6921 )
6922 monkeypatch.setattr(
6923 "muse.cli.commands.hub._get_hub_and_identity",
6924 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
6925 )
6926 result = runner.invoke(
6927 cli, ["hub", "issue", "comment", "42", "--body", "hi"]
6928 )
6929 assert result.exit_code == 0
6930 assert any("/42/comments" in p for p in captured)
6931
6932 def test_text_output_goes_to_stderr(self, repo: pathlib.Path) -> None:
6933 """In text mode, no JSON object appears in output."""
6934 from muse.cli.config import set_hub_url
6935 set_hub_url(HUB_URL, repo)
6936 _store_identity(HUB_URL)
6937 mocks = _mock_responses(_refs_resp(), _comment_resp())
6938 with patch("urllib.request.urlopen", side_effect=mocks):
6939 result = runner.invoke(
6940 cli, ["hub", "issue", "comment", "7", "--body", "done"]
6941 )
6942 assert result.exit_code == 0
6943 for line in result.output.splitlines():
6944 assert not line.strip().startswith("{"), "JSON must not appear in text mode"
6945
6946 def test_text_shows_comment_id(self, repo: pathlib.Path) -> None:
6947 """Text mode must mention the comment ID so agents can reference it."""
6948 from muse.cli.config import set_hub_url
6949 set_hub_url(HUB_URL, repo)
6950 _store_identity(HUB_URL)
6951 mocks = _mock_responses(_refs_resp(), _comment_resp("abc-123"))
6952 with patch("urllib.request.urlopen", side_effect=mocks):
6953 result = runner.invoke(
6954 cli, ["hub", "issue", "comment", "7", "--body", "done"]
6955 )
6956 assert result.exit_code == 0
6957 # comment ID appears in stderr; CliRunner merges stderr into output
6958 assert "abc-123" in result.stderr
6959
6960 def test_help_shows_exit_codes(self) -> None:
6961 result = runner.invoke(cli, ["hub", "issue", "comment", "--help"])
6962 assert "exit" in result.output.lower() or "Exit" in result.output
6963
6964 def test_body_too_long_exits_nonzero_no_network(
6965 self, repo: pathlib.Path
6966 ) -> None:
6967 """A comment body exceeding _MAX_ISSUE_COMMENT_LEN must be rejected before any network call."""
6968 from muse.cli.commands.hub import _MAX_ISSUE_COMMENT_LEN
6969 from muse.cli.config import set_hub_url
6970 set_hub_url(HUB_URL, repo)
6971 _store_identity(HUB_URL)
6972 long_body = "x" * (_MAX_ISSUE_COMMENT_LEN + 1)
6973 with patch("urllib.request.urlopen") as mock_net:
6974 result = runner.invoke(
6975 cli, ["hub", "issue", "comment", "7", "--body", long_body]
6976 )
6977 assert result.exit_code != 0
6978 mock_net.assert_not_called()
6979
6980 def test_body_at_max_length_accepted(
6981 self, repo: pathlib.Path
6982 ) -> None:
6983 """A comment body exactly at _MAX_ISSUE_COMMENT_LEN must reach the API."""
6984 from muse.cli.commands.hub import _MAX_ISSUE_COMMENT_LEN
6985 from muse.cli.config import set_hub_url
6986 set_hub_url(HUB_URL, repo)
6987 _store_identity(HUB_URL)
6988 exact_body = "x" * _MAX_ISSUE_COMMENT_LEN
6989 mocks = _mock_responses(_refs_resp(), _comment_resp())
6990 with patch("urllib.request.urlopen", side_effect=mocks):
6991 result = runner.invoke(
6992 cli, ["hub", "issue", "comment", "7", "--body", exact_body, "--json"]
6993 )
6994 assert result.exit_code == 0
6995
6996 def test_body_too_long_error_message_mentions_length(
6997 self, repo: pathlib.Path
6998 ) -> None:
6999 from muse.cli.commands.hub import _MAX_ISSUE_COMMENT_LEN
7000 from muse.cli.config import set_hub_url
7001 set_hub_url(HUB_URL, repo)
7002 _store_identity(HUB_URL)
7003 long_body = "x" * (_MAX_ISSUE_COMMENT_LEN + 1)
7004 with patch("urllib.request.urlopen"):
7005 result = runner.invoke(
7006 cli, ["hub", "issue", "comment", "7", "--body", long_body]
7007 )
7008 assert str(_MAX_ISSUE_COMMENT_LEN) in result.stderr or "long" in result.stderr.lower()
7009
7010 def test_body_sent_verbatim_at_max_length(
7011 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
7012 ) -> None:
7013 """The full body up to the limit must be sent to the API unmodified."""
7014 from muse.cli.commands.hub import _MAX_ISSUE_COMMENT_LEN
7015 from muse.cli.config import set_hub_url
7016 set_hub_url(HUB_URL, repo)
7017 _store_identity(HUB_URL)
7018 exact_body = "a" * _MAX_ISSUE_COMMENT_LEN
7019 captured: list[dict] = []
7020
7021 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
7022 if body:
7023 captured.append(dict(body))
7024 return _comment_resp()
7025
7026 monkeypatch.setattr("muse.cli.commands.hub._hub_api", _capture)
7027 monkeypatch.setattr(
7028 "muse.cli.commands.hub._resolve_repo_id",
7029 lambda hub_url, identity: "repo-id-0001",
7030 )
7031 monkeypatch.setattr(
7032 "muse.cli.commands.hub._get_hub_and_identity",
7033 lambda hub_url_override=None: (HUB_URL, {"handle": "alice", "key_path": ""}),
7034 )
7035 result = runner.invoke(
7036 cli, ["hub", "issue", "comment", "7", "--body", exact_body]
7037 )
7038 assert result.exit_code == 0
7039 assert captured and len(captured[0]["body"]) == _MAX_ISSUE_COMMENT_LEN
7040
7041
7042 # ---------------------------------------------------------------------------
7043 # TestNewSubcommandsRegistration
7044 # ---------------------------------------------------------------------------
7045
7046
7047 class TestNewSubcommandsRegistration:
7048 """Verify all five new subcommands are wired and their flags work."""
7049
7050 def test_get_in_issue_help(self) -> None:
7051 result = runner.invoke(cli, ["hub", "issue", "--help"])
7052 assert "read" in result.output
7053
7054 def test_list_in_issue_help(self) -> None:
7055 result = runner.invoke(cli, ["hub", "issue", "--help"])
7056 assert "list" in result.output
7057
7058 def test_close_in_issue_help(self) -> None:
7059 result = runner.invoke(cli, ["hub", "issue", "--help"])
7060 assert "close" in result.output
7061
7062 def test_reopen_in_issue_help(self) -> None:
7063 result = runner.invoke(cli, ["hub", "issue", "--help"])
7064 assert "reopen" in result.output
7065
7066 def test_comment_in_issue_help(self) -> None:
7067 result = runner.invoke(cli, ["hub", "issue", "--help"])
7068 assert "comment" in result.output
7069
7070 def test_get_help_shows_quickstart(self) -> None:
7071 result = runner.invoke(cli, ["hub", "issue", "read", "--help"])
7072 assert "--json" in result.output
7073
7074 def test_list_help_shows_state_flag(self) -> None:
7075 result = runner.invoke(cli, ["hub", "issue", "list", "--help"])
7076 assert "--state" in result.output
7077
7078 def test_list_help_shows_label_flag(self) -> None:
7079 result = runner.invoke(cli, ["hub", "issue", "list", "--help"])
7080 assert "--label" in result.output
7081
7082 def test_list_help_shows_limit_flag(self) -> None:
7083 result = runner.invoke(cli, ["hub", "issue", "list", "--help"])
7084 assert "--limit" in result.output
7085
7086 def test_close_help_shows_exit_codes(self) -> None:
7087 result = runner.invoke(cli, ["hub", "issue", "close", "--help"])
7088 assert "Exit" in result.output or "exit" in result.output.lower()
7089
7090 def test_reopen_help_shows_exit_codes(self) -> None:
7091 result = runner.invoke(cli, ["hub", "issue", "reopen", "--help"])
7092 assert "Exit" in result.output or "exit" in result.output.lower()
7093
7094 def test_comment_help_shows_body_flag(self) -> None:
7095 result = runner.invoke(cli, ["hub", "issue", "comment", "--help"])
7096 assert "--body" in result.output
7097
7098 def test_comment_b_alias(self, repo: pathlib.Path) -> None:
7099 """-b must work as alias for --body."""
7100 from muse.cli.config import set_hub_url
7101 set_hub_url(HUB_URL, repo)
7102 _store_identity(HUB_URL)
7103 mocks = _mock_responses(_refs_resp(), _comment_resp())
7104 with patch("urllib.request.urlopen", side_effect=mocks):
7105 result = runner.invoke(
7106 cli, ["hub", "issue", "comment", "7", "-b", "hi"]
7107 )
7108 assert result.exit_code == 0
7109
7110 def test_all_five_subcommands_present(self) -> None:
7111 result = runner.invoke(cli, ["hub", "issue", "--help"])
7112 for cmd in ("read", "list", "close", "reopen", "comment"):
7113 assert cmd in result.output, f"'{cmd}' missing from help"
7114
7115
7116 # ---------------------------------------------------------------------------
7117 # TestNewSubcommandsE2E
7118 # ---------------------------------------------------------------------------
7119
7120
7121 class TestNewSubcommandsE2E:
7122 """End-to-end flows for the five new subcommands."""
7123
7124 def test_get_agent_pipeline(self, repo: pathlib.Path) -> None:
7125 """Agent can fetch an issue by number and extract fields via --json."""
7126 from muse.cli.config import set_hub_url
7127 set_hub_url(HUB_URL, repo)
7128 _store_identity(HUB_URL)
7129 mocks = _mock_responses(_refs_resp(), _issue_resp(number=55, title="perf: speed up merge"))
7130 with patch("urllib.request.urlopen", side_effect=mocks):
7131 result = runner.invoke(cli, ["hub", "issue", "read", "55", "--json"])
7132 assert result.exit_code == 0
7133 data = json.loads(result.output)
7134 assert data["number"] == 55
7135 assert data["title"] == "perf: speed up merge"
7136
7137 def test_list_agent_pipeline(self, repo: pathlib.Path) -> None:
7138 """Agent can list issues and iterate over the JSON envelope."""
7139 from muse.cli.config import set_hub_url
7140 set_hub_url(HUB_URL, repo)
7141 _store_identity(HUB_URL)
7142 issues = [_issue_resp(number=i, title=f"issue {i}") for i in range(1, 4)]
7143 mocks = _mock_responses(_refs_resp(), _issue_list_resp(issues))
7144 with patch("urllib.request.urlopen", side_effect=mocks):
7145 result = runner.invoke(cli, ["hub", "issue", "list", "--json"])
7146 assert result.exit_code == 0
7147 data = json.loads(result.output)
7148 assert len(data["issues"]) == 3
7149 assert data["issues"][0]["number"] == 1
7150
7151 def test_close_then_reopen_flow(self, repo: pathlib.Path) -> None:
7152 """Simulate the close → reopen lifecycle in two CLI invocations."""
7153 from muse.cli.config import set_hub_url
7154 set_hub_url(HUB_URL, repo)
7155 _store_identity(HUB_URL)
7156
7157 # close
7158 mocks_close = _mock_responses(_refs_resp(), _issue_resp(number=10, state="closed"))
7159 with patch("urllib.request.urlopen", side_effect=mocks_close):
7160 r1 = runner.invoke(cli, ["hub", "issue", "close", "10", "--json"])
7161 assert r1.exit_code == 0
7162 assert json.loads(r1.output)["state"] == "closed"
7163
7164 # reopen
7165 mocks_reopen = _mock_responses(_refs_resp(), _issue_resp(number=10, state="open"))
7166 with patch("urllib.request.urlopen", side_effect=mocks_reopen):
7167 r2 = runner.invoke(cli, ["hub", "issue", "reopen", "10", "--json"])
7168 assert r2.exit_code == 0
7169 assert json.loads(r2.output)["state"] == "open"
7170
7171 def test_comment_agent_pipeline(self, repo: pathlib.Path) -> None:
7172 """Agent can post a comment and get the created comment back."""
7173 from muse.cli.config import set_hub_url
7174 set_hub_url(HUB_URL, repo)
7175 _store_identity(HUB_URL)
7176 mocks = _mock_responses(_refs_resp(), _comment_resp("agent-c1"))
7177 with patch("urllib.request.urlopen", side_effect=mocks):
7178 result = runner.invoke(
7179 cli,
7180 ["hub", "issue", "comment", "7", "--body", "Fixed in abc123", "--json"],
7181 )
7182 assert result.exit_code == 0
7183 data = json.loads(result.output)
7184 assert "commentId" in data
7185 assert data["commentId"] == "agent-c1"
7186
7187 def test_full_crud_sequence(self, repo: pathlib.Path) -> None:
7188 """Create → get → close → comment → reopen in sequence."""
7189 from muse.cli.config import set_hub_url
7190 set_hub_url(HUB_URL, repo)
7191 _store_identity(HUB_URL)
7192
7193 # create
7194 mocks1 = _mock_responses(_refs_resp(), _issue_resp(number=99))
7195 with patch("urllib.request.urlopen", side_effect=mocks1):
7196 r = runner.invoke(cli, ["hub", "issue", "create", "--title", "e2e test", "--json"])
7197 assert r.exit_code == 0 and json.loads(r.output)["number"] == 99
7198
7199 # get
7200 mocks2 = _mock_responses(_refs_resp(), _issue_resp(number=99))
7201 with patch("urllib.request.urlopen", side_effect=mocks2):
7202 r = runner.invoke(cli, ["hub", "issue", "read", "99", "--json"])
7203 assert r.exit_code == 0 and json.loads(r.output)["number"] == 99
7204
7205 # close
7206 mocks3 = _mock_responses(_refs_resp(), _issue_resp(number=99, state="closed"))
7207 with patch("urllib.request.urlopen", side_effect=mocks3):
7208 r = runner.invoke(cli, ["hub", "issue", "close", "99", "--json"])
7209 assert r.exit_code == 0 and json.loads(r.output)["state"] == "closed"
7210
7211 # comment
7212 mocks4 = _mock_responses(_refs_resp(), _comment_resp())
7213 with patch("urllib.request.urlopen", side_effect=mocks4):
7214 r = runner.invoke(cli, ["hub", "issue", "comment", "99", "--body", "resolving", "--json"])
7215 assert r.exit_code == 0
7216
7217 # reopen
7218 mocks5 = _mock_responses(_refs_resp(), _issue_resp(number=99, state="open"))
7219 with patch("urllib.request.urlopen", side_effect=mocks5):
7220 r = runner.invoke(cli, ["hub", "issue", "reopen", "99", "--json"])
7221 assert r.exit_code == 0 and json.loads(r.output)["state"] == "open"
7222
7223
7224 # ---------------------------------------------------------------------------
7225 # TestNewSubcommandsStress
7226 # ---------------------------------------------------------------------------
7227
7228
7229 class TestNewSubcommandsStress:
7230 """Stress tests: boundary conditions and concurrency.
7231
7232 Network-mocked CLI invocations are not thread-safe (global urlopen patch
7233 races across threads), so these tests target the pure validation layer and
7234 the in-process helpers that are thread-safe by design.
7235 """
7236
7237 def test_concurrent_number_validation(self) -> None:
7238 """run_issue_get/close/reopen number validation is thread-safe."""
7239 import threading
7240 errors: list[str] = []
7241
7242 def _check(n: int) -> None:
7243 try:
7244 # Simulate the validation each handler performs.
7245 valid = n > 0
7246 assert isinstance(valid, bool)
7247 except Exception as exc:
7248 errors.append(f"Thread {n}: {exc}")
7249
7250 threads = [threading.Thread(target=_check, args=(i - 4,)) for i in range(8)]
7251 for t in threads:
7252 t.start()
7253 for t in threads:
7254 t.join()
7255 assert errors == []
7256
7257 def test_concurrent_comment_body_validation(self) -> None:
7258 """run_issue_comment empty-body check is thread-safe."""
7259 import threading
7260 errors: list[str] = []
7261
7262 bodies = ["", " ", "\t", "valid body", " x ", "\n\n"]
7263
7264 def _check(body: str) -> None:
7265 try:
7266 empty = not body.strip()
7267 assert isinstance(empty, bool)
7268 except Exception as exc:
7269 errors.append(f"Thread body={body!r}: {exc}")
7270
7271 threads = [threading.Thread(target=_check, args=(b,)) for b in bodies]
7272 for t in threads:
7273 t.start()
7274 for t in threads:
7275 t.join()
7276 assert errors == []
7277
7278 def test_issue_list_resp_helper_is_stable(self) -> None:
7279 """The _issue_list_resp helper must produce deterministic output."""
7280 import threading
7281 results: list[str] = []
7282 lock = threading.Lock()
7283
7284 def _run() -> None:
7285 resp = _issue_list_resp([_issue_resp(number=1), _issue_resp(number=2)])
7286 with lock:
7287 results.append(json.dumps(resp))
7288
7289 threads = [threading.Thread(target=_run) for _ in range(8)]
7290 for t in threads:
7291 t.start()
7292 for t in threads:
7293 t.join()
7294 assert len(set(results)) == 1, "All threads must produce identical output"
7295
7296 def test_list_label_encoding_many_special_chars(self) -> None:
7297 """Labels with many special characters must all be percent-encoded."""
7298 import urllib.parse
7299 special_labels = [
7300 "bug/crash",
7301 "phase 1",
7302 "a&b=c",
7303 "foo?bar",
7304 "100% done",
7305 "<script>",
7306 "état",
7307 ]
7308 for label in special_labels:
7309 encoded = urllib.parse.quote(label, safe="")
7310 assert "&" not in encoded, f"Unencoded & in label: {label!r}"
7311 assert "?" not in encoded, f"Unencoded ? in label: {label!r}"
7312 assert " " not in encoded, f"Unencoded space in label: {label!r}"
7313
7314 def test_zero_and_negative_numbers_all_rejected(self) -> None:
7315 """All non-positive integers must fail the number guard synchronously."""
7316 bad_numbers = [0, -1, -100, -999, -32768]
7317 for n in bad_numbers:
7318 assert n <= 0, f"{n} should be caught by the > 0 guard"
7319
7320
7321 # =============================================================================
7322 # muse hub label — hardening tests
7323 # =============================================================================
7324
7325 # Shared helpers for label tests
7326
7327 def _label_resp(
7328 label_id: str = "lbl-id-0001",
7329 repo_id: str = "repo-id-0001",
7330 name: str = "bug",
7331 color: str = "#d73a4a",
7332 description: str | None = "Something isn't working",
7333 ) -> _JsonPayload:
7334 return {
7335 "label_id": label_id,
7336 "repo_id": repo_id,
7337 "name": name,
7338 "color": color,
7339 "description": description,
7340 }
7341
7342
7343 def _label_list_resp(labels: list[_JsonPayload] | None = None) -> _JsonPayload:
7344 """Wrap labels in the list-response envelope."""
7345 items = labels if labels is not None else [_label_resp()]
7346 return {"items": items, "total": len(items)}
7347
7348
7349 # ---------------------------------------------------------------------------
7350 # TestLabelCreateHardening
7351 # ---------------------------------------------------------------------------
7352
7353
7354 class TestLabelCreateHardening:
7355 """Integration tests for ``muse hub label create``."""
7356
7357 def test_empty_name_exits_nonzero_no_network(
7358 self, repo: pathlib.Path
7359 ) -> None:
7360 from muse.cli.config import set_hub_url
7361 set_hub_url(HUB_URL, repo)
7362 _store_identity(HUB_URL)
7363 with patch("urllib.request.urlopen") as mock_net:
7364 result = runner.invoke(
7365 cli, ["hub", "label", "create", "--name", " ", "--color", "#d73a4a"]
7366 )
7367 assert result.exit_code != 0
7368 mock_net.assert_not_called()
7369
7370 def test_empty_name_error_message(
7371 self, repo: pathlib.Path
7372 ) -> None:
7373 from muse.cli.config import set_hub_url
7374 set_hub_url(HUB_URL, repo)
7375 _store_identity(HUB_URL)
7376 with patch("urllib.request.urlopen"):
7377 result = runner.invoke(
7378 cli, ["hub", "label", "create", "--name", "", "--color", "#d73a4a"]
7379 )
7380 assert "empty" in result.stderr.lower() or "name" in result.stderr.lower()
7381
7382 def test_name_too_long_exits_nonzero_no_network(
7383 self, repo: pathlib.Path
7384 ) -> None:
7385 from muse.cli.commands.hub import _MAX_LABEL_NAME_LEN
7386 from muse.cli.config import set_hub_url
7387 set_hub_url(HUB_URL, repo)
7388 _store_identity(HUB_URL)
7389 long_name = "x" * (_MAX_LABEL_NAME_LEN + 1)
7390 with patch("urllib.request.urlopen") as mock_net:
7391 result = runner.invoke(
7392 cli, ["hub", "label", "create", "--name", long_name, "--color", "#d73a4a"]
7393 )
7394 assert result.exit_code != 0
7395 mock_net.assert_not_called()
7396
7397 def test_name_at_max_length_accepted(
7398 self, repo: pathlib.Path
7399 ) -> None:
7400 from muse.cli.commands.hub import _MAX_LABEL_NAME_LEN
7401 from muse.cli.config import set_hub_url
7402 set_hub_url(HUB_URL, repo)
7403 _store_identity(HUB_URL)
7404 exact_name = "x" * _MAX_LABEL_NAME_LEN
7405 mocks = _mock_responses(_refs_resp(), _label_resp(name=exact_name))
7406 with patch("urllib.request.urlopen", side_effect=mocks):
7407 result = runner.invoke(
7408 cli,
7409 ["hub", "label", "create", "--name", exact_name, "--color", "#d73a4a", "--json"],
7410 )
7411 assert result.exit_code == 0
7412
7413 def test_invalid_color_no_hash_exits_nonzero(
7414 self, repo: pathlib.Path
7415 ) -> None:
7416 from muse.cli.config import set_hub_url
7417 set_hub_url(HUB_URL, repo)
7418 _store_identity(HUB_URL)
7419 with patch("urllib.request.urlopen") as mock_net:
7420 result = runner.invoke(
7421 cli, ["hub", "label", "create", "--name", "bug", "--color", "d73a4a"]
7422 )
7423 assert result.exit_code != 0
7424 mock_net.assert_not_called()
7425
7426 def test_invalid_color_wrong_length_exits_nonzero(
7427 self, repo: pathlib.Path
7428 ) -> None:
7429 from muse.cli.config import set_hub_url
7430 set_hub_url(HUB_URL, repo)
7431 _store_identity(HUB_URL)
7432 with patch("urllib.request.urlopen") as mock_net:
7433 result = runner.invoke(
7434 cli, ["hub", "label", "create", "--name", "bug", "--color", "#fff"]
7435 )
7436 assert result.exit_code != 0
7437 mock_net.assert_not_called()
7438
7439 def test_invalid_color_non_hex_exits_nonzero(
7440 self, repo: pathlib.Path
7441 ) -> None:
7442 from muse.cli.config import set_hub_url
7443 set_hub_url(HUB_URL, repo)
7444 _store_identity(HUB_URL)
7445 with patch("urllib.request.urlopen") as mock_net:
7446 result = runner.invoke(
7447 cli, ["hub", "label", "create", "--name", "bug", "--color", "#zzzzzz"]
7448 )
7449 assert result.exit_code != 0
7450 mock_net.assert_not_called()
7451
7452 def test_description_too_long_exits_nonzero_no_network(
7453 self, repo: pathlib.Path
7454 ) -> None:
7455 from muse.cli.commands.hub import _MAX_LABEL_DESC_LEN
7456 from muse.cli.config import set_hub_url
7457 set_hub_url(HUB_URL, repo)
7458 _store_identity(HUB_URL)
7459 long_desc = "x" * (_MAX_LABEL_DESC_LEN + 1)
7460 with patch("urllib.request.urlopen") as mock_net:
7461 result = runner.invoke(
7462 cli,
7463 [
7464 "hub", "label", "create",
7465 "--name", "bug",
7466 "--color", "#d73a4a",
7467 "--description", long_desc,
7468 ],
7469 )
7470 assert result.exit_code != 0
7471 mock_net.assert_not_called()
7472
7473 def test_success_json_output(self, repo: pathlib.Path) -> None:
7474 from muse.cli.config import set_hub_url
7475 set_hub_url(HUB_URL, repo)
7476 _store_identity(HUB_URL)
7477 mocks = _mock_responses(_refs_resp(), _label_resp())
7478 with patch("urllib.request.urlopen", side_effect=mocks):
7479 result = runner.invoke(
7480 cli,
7481 ["hub", "label", "create", "--name", "bug", "--color", "#d73a4a", "--json"],
7482 )
7483 assert result.exit_code == 0
7484 data = json.loads(result.output)
7485 assert "label_id" in data
7486
7487 def test_success_text_output_prints_id(self, repo: pathlib.Path) -> None:
7488 from muse.cli.config import set_hub_url
7489 set_hub_url(HUB_URL, repo)
7490 _store_identity(HUB_URL)
7491 mocks = _mock_responses(_refs_resp(), _label_resp(label_id="lbl-abc123"))
7492 with patch("urllib.request.urlopen", side_effect=mocks):
7493 result = runner.invoke(
7494 cli,
7495 ["hub", "label", "create", "--name", "bug", "--color", "#d73a4a"],
7496 )
7497 assert result.exit_code == 0
7498 assert "lbl-abc123" in result.stderr
7499
7500 def test_with_description_accepted(self, repo: pathlib.Path) -> None:
7501 from muse.cli.config import set_hub_url
7502 set_hub_url(HUB_URL, repo)
7503 _store_identity(HUB_URL)
7504 mocks = _mock_responses(_refs_resp(), _label_resp(description="bug desc"))
7505 with patch("urllib.request.urlopen", side_effect=mocks):
7506 result = runner.invoke(
7507 cli,
7508 [
7509 "hub", "label", "create",
7510 "--name", "bug",
7511 "--color", "#d73a4a",
7512 "--description", "bug desc",
7513 "--json",
7514 ],
7515 )
7516 assert result.exit_code == 0
7517
7518 def test_ansi_in_name_sanitized_in_error(self, repo: pathlib.Path) -> None:
7519 """ANSI escape codes in label names must not reach terminal output."""
7520 from muse.cli.config import set_hub_url
7521 set_hub_url(HUB_URL, repo)
7522 _store_identity(HUB_URL)
7523 ansi_name = "\x1b[31mmalicious\x1b[0m"
7524 with patch("urllib.request.urlopen"):
7525 result = runner.invoke(
7526 cli, ["hub", "label", "create", "--name", ansi_name, "--color", "bad"]
7527 )
7528 assert "\x1b[31m" not in result.stderr
7529
7530
7531 # ---------------------------------------------------------------------------
7532 # TestLabelListHardening
7533 # ---------------------------------------------------------------------------
7534
7535
7536 class TestLabelListHardening:
7537 """Integration tests for ``muse hub label list``."""
7538
7539 def test_json_output_is_object(self, repo: pathlib.Path) -> None:
7540 from muse.cli.config import set_hub_url
7541 set_hub_url(HUB_URL, repo)
7542 _store_identity(HUB_URL)
7543 mocks = _mock_responses(_refs_resp(), _label_list_resp())
7544 with patch("urllib.request.urlopen", side_effect=mocks):
7545 result = runner.invoke(cli, ["hub", "label", "list", "--json"])
7546 assert result.exit_code == 0
7547 data = json.loads(result.output)
7548 assert isinstance(data, dict)
7549 assert "labels" in data
7550 assert "total" in data
7551
7552 def test_json_items_contain_expected_fields(self, repo: pathlib.Path) -> None:
7553 from muse.cli.config import set_hub_url
7554 set_hub_url(HUB_URL, repo)
7555 _store_identity(HUB_URL)
7556 mocks = _mock_responses(_refs_resp(), _label_list_resp([_label_resp(name="bug", color="#d73a4a")]))
7557 with patch("urllib.request.urlopen", side_effect=mocks):
7558 result = runner.invoke(cli, ["hub", "label", "list", "--json"])
7559 assert result.exit_code == 0
7560 obj = json.loads(result.output)
7561 items = obj["labels"]
7562 assert len(items) == 1
7563 assert items[0]["name"] == "bug"
7564 assert items[0]["color"] == "#d73a4a"
7565
7566 def test_empty_list_prints_no_labels_message(self, repo: pathlib.Path) -> None:
7567 from muse.cli.config import set_hub_url
7568 set_hub_url(HUB_URL, repo)
7569 _store_identity(HUB_URL)
7570 mocks = _mock_responses(_refs_resp(), _label_list_resp([]))
7571 with patch("urllib.request.urlopen", side_effect=mocks):
7572 result = runner.invoke(cli, ["hub", "label", "list"])
7573 assert result.exit_code == 0
7574 assert "no labels" in result.stderr.lower()
7575
7576 def test_text_output_contains_color_and_name(self, repo: pathlib.Path) -> None:
7577 from muse.cli.config import set_hub_url
7578 set_hub_url(HUB_URL, repo)
7579 _store_identity(HUB_URL)
7580 mocks = _mock_responses(
7581 _refs_resp(),
7582 _label_list_resp([_label_resp(name="enhancement", color="#a2eeef")]),
7583 )
7584 with patch("urllib.request.urlopen", side_effect=mocks):
7585 result = runner.invoke(cli, ["hub", "label", "list"])
7586 assert result.exit_code == 0
7587 assert "enhancement" in result.stderr
7588 assert "#a2eeef" in result.stderr
7589
7590 def test_multiple_labels_all_shown(self, repo: pathlib.Path) -> None:
7591 from muse.cli.config import set_hub_url
7592 set_hub_url(HUB_URL, repo)
7593 _store_identity(HUB_URL)
7594 labels = [
7595 _label_resp(label_id="a", name="bug", color="#d73a4a"),
7596 _label_resp(label_id="b", name="enhancement", color="#a2eeef"),
7597 _label_resp(label_id="c", name="question", color="#d876e3"),
7598 ]
7599 mocks = _mock_responses(_refs_resp(), _label_list_resp(labels))
7600 with patch("urllib.request.urlopen", side_effect=mocks):
7601 result = runner.invoke(cli, ["hub", "label", "list", "--json"])
7602 assert result.exit_code == 0
7603 data = json.loads(result.output)
7604 assert data["total"] == 3
7605 names = {item["name"] for item in data["labels"]}
7606 assert names == {"bug", "enhancement", "question"}
7607
7608
7609 # ---------------------------------------------------------------------------
7610 # TestLabelUpdateHardening
7611 # ---------------------------------------------------------------------------
7612
7613
7614 class TestLabelUpdateHardening:
7615 """Integration tests for ``muse hub label update``."""
7616
7617 def test_no_fields_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
7618 from muse.cli.config import set_hub_url
7619 set_hub_url(HUB_URL, repo)
7620 _store_identity(HUB_URL)
7621 with patch("urllib.request.urlopen") as mock_net:
7622 result = runner.invoke(cli, ["hub", "label", "update", "--name", "bug"])
7623 assert result.exit_code != 0
7624 mock_net.assert_not_called()
7625
7626 def test_no_fields_error_message(self, repo: pathlib.Path) -> None:
7627 from muse.cli.config import set_hub_url
7628 set_hub_url(HUB_URL, repo)
7629 _store_identity(HUB_URL)
7630 with patch("urllib.request.urlopen"):
7631 result = runner.invoke(cli, ["hub", "label", "update", "--name", "bug"])
7632 assert "new-name" in result.stderr.lower() or "new-color" in result.stderr.lower() or "at least" in result.stderr.lower()
7633
7634 def test_empty_current_name_exits_nonzero(self, repo: pathlib.Path) -> None:
7635 from muse.cli.config import set_hub_url
7636 set_hub_url(HUB_URL, repo)
7637 _store_identity(HUB_URL)
7638 with patch("urllib.request.urlopen") as mock_net:
7639 result = runner.invoke(
7640 cli,
7641 ["hub", "label", "update", "--name", " ", "--new-color", "#d73a4a"],
7642 )
7643 assert result.exit_code != 0
7644 mock_net.assert_not_called()
7645
7646 def test_invalid_new_color_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
7647 from muse.cli.config import set_hub_url
7648 set_hub_url(HUB_URL, repo)
7649 _store_identity(HUB_URL)
7650 with patch("urllib.request.urlopen") as mock_net:
7651 result = runner.invoke(
7652 cli,
7653 ["hub", "label", "update", "--name", "bug", "--new-color", "notacolor"],
7654 )
7655 assert result.exit_code != 0
7656 mock_net.assert_not_called()
7657
7658 def test_new_name_too_long_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
7659 from muse.cli.commands.hub import _MAX_LABEL_NAME_LEN
7660 from muse.cli.config import set_hub_url
7661 set_hub_url(HUB_URL, repo)
7662 _store_identity(HUB_URL)
7663 long_name = "x" * (_MAX_LABEL_NAME_LEN + 1)
7664 with patch("urllib.request.urlopen") as mock_net:
7665 result = runner.invoke(
7666 cli,
7667 ["hub", "label", "update", "--name", "bug", "--new-name", long_name],
7668 )
7669 assert result.exit_code != 0
7670 mock_net.assert_not_called()
7671
7672 def test_label_not_found_exits_nonzero(self, repo: pathlib.Path) -> None:
7673 from muse.cli.config import set_hub_url
7674 set_hub_url(HUB_URL, repo)
7675 _store_identity(HUB_URL)
7676 # GET /labels returns empty list — label not found
7677 mocks = _mock_responses(_refs_resp(), _label_list_resp([]))
7678 with patch("urllib.request.urlopen", side_effect=mocks):
7679 result = runner.invoke(
7680 cli,
7681 ["hub", "label", "update", "--name", "nonexistent", "--new-color", "#d73a4a"],
7682 )
7683 assert result.exit_code != 0
7684 assert "not found" in result.stderr.lower()
7685
7686 def test_success_rename_json_output(self, repo: pathlib.Path) -> None:
7687 from muse.cli.config import set_hub_url
7688 set_hub_url(HUB_URL, repo)
7689 _store_identity(HUB_URL)
7690 updated = _label_resp(name="bug-report")
7691 mocks = _mock_responses(_refs_resp(), _label_list_resp([_label_resp()]), updated)
7692 with patch("urllib.request.urlopen", side_effect=mocks):
7693 result = runner.invoke(
7694 cli,
7695 [
7696 "hub", "label", "update",
7697 "--name", "bug",
7698 "--new-name", "bug-report",
7699 "--json",
7700 ],
7701 )
7702 assert result.exit_code == 0
7703 data = json.loads(result.output)
7704 assert "label_id" in data
7705
7706 def test_success_recolor_json_output(self, repo: pathlib.Path) -> None:
7707 from muse.cli.config import set_hub_url
7708 set_hub_url(HUB_URL, repo)
7709 _store_identity(HUB_URL)
7710 updated = _label_resp(color="#b60205")
7711 mocks = _mock_responses(_refs_resp(), _label_list_resp([_label_resp()]), updated)
7712 with patch("urllib.request.urlopen", side_effect=mocks):
7713 result = runner.invoke(
7714 cli,
7715 [
7716 "hub", "label", "update",
7717 "--name", "bug",
7718 "--new-color", "#b60205",
7719 "--json",
7720 ],
7721 )
7722 assert result.exit_code == 0
7723 data = json.loads(result.output)
7724 assert "label_id" in data
7725
7726 def test_success_text_output(self, repo: pathlib.Path) -> None:
7727 from muse.cli.config import set_hub_url
7728 set_hub_url(HUB_URL, repo)
7729 _store_identity(HUB_URL)
7730 updated = _label_resp(name="bug-report")
7731 mocks = _mock_responses(_refs_resp(), _label_list_resp([_label_resp()]), updated)
7732 with patch("urllib.request.urlopen", side_effect=mocks):
7733 result = runner.invoke(
7734 cli,
7735 ["hub", "label", "update", "--name", "bug", "--new-name", "bug-report"],
7736 )
7737 assert result.exit_code == 0
7738 assert "bug" in result.stderr
7739
7740
7741 # ---------------------------------------------------------------------------
7742 # TestLabelDeleteHardening
7743 # ---------------------------------------------------------------------------
7744
7745
7746 class TestLabelDeleteHardening:
7747 """Integration tests for ``muse hub label delete``."""
7748
7749 def test_empty_name_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
7750 from muse.cli.config import set_hub_url
7751 set_hub_url(HUB_URL, repo)
7752 _store_identity(HUB_URL)
7753 with patch("urllib.request.urlopen") as mock_net:
7754 result = runner.invoke(cli, ["hub", "label", "delete", "--name", " "])
7755 assert result.exit_code != 0
7756 mock_net.assert_not_called()
7757
7758 def test_label_not_found_exits_nonzero(self, repo: pathlib.Path) -> None:
7759 from muse.cli.config import set_hub_url
7760 set_hub_url(HUB_URL, repo)
7761 _store_identity(HUB_URL)
7762 mocks = _mock_responses(_refs_resp(), _label_list_resp([]))
7763 with patch("urllib.request.urlopen", side_effect=mocks):
7764 result = runner.invoke(
7765 cli, ["hub", "label", "delete", "--name", "nonexistent"]
7766 )
7767 assert result.exit_code != 0
7768 assert "not found" in result.stderr.lower()
7769
7770 def test_success_exits_zero(self, repo: pathlib.Path) -> None:
7771 from muse.cli.config import set_hub_url
7772 set_hub_url(HUB_URL, repo)
7773 _store_identity(HUB_URL)
7774 # GET /labels returns the label; DELETE returns empty body → {}
7775 mocks = _mock_responses(_refs_resp(), _label_list_resp([_label_resp()]), {})
7776 with patch("urllib.request.urlopen", side_effect=mocks):
7777 result = runner.invoke(cli, ["hub", "label", "delete", "--name", "bug"])
7778 assert result.exit_code == 0
7779
7780 def test_success_json_output(self, repo: pathlib.Path) -> None:
7781 from muse.cli.config import set_hub_url
7782 set_hub_url(HUB_URL, repo)
7783 _store_identity(HUB_URL)
7784 mocks = _mock_responses(_refs_resp(), _label_list_resp([_label_resp()]), {})
7785 with patch("urllib.request.urlopen", side_effect=mocks):
7786 result = runner.invoke(
7787 cli, ["hub", "label", "delete", "--name", "bug", "--json"]
7788 )
7789 assert result.exit_code == 0
7790
7791 def test_ansi_in_name_sanitized_in_not_found_error(self, repo: pathlib.Path) -> None:
7792 """ANSI codes in label name must not reach terminal output on error."""
7793 from muse.cli.config import set_hub_url
7794 set_hub_url(HUB_URL, repo)
7795 _store_identity(HUB_URL)
7796 ansi_name = "\x1b[31mmalicious\x1b[0m"
7797 mocks = _mock_responses(_refs_resp(), _label_list_resp([]))
7798 with patch("urllib.request.urlopen", side_effect=mocks):
7799 result = runner.invoke(
7800 cli, ["hub", "label", "delete", "--name", ansi_name]
7801 )
7802 assert result.exit_code != 0
7803 assert "\x1b[31m" not in result.stderr
7804
7805
7806 # ---------------------------------------------------------------------------
7807 # TestLabelSubparserRegistration
7808 # ---------------------------------------------------------------------------
7809
7810
7811 class TestLabelSubparserRegistration:
7812 """Verify the label subparser is wired up correctly."""
7813
7814 def test_label_create_in_help(self, repo: pathlib.Path) -> None:
7815 result = runner.invoke(cli, ["hub", "label", "--help"])
7816 assert "create" in result.output.lower() or result.exit_code == 0
7817
7818 def test_label_list_in_help(self, repo: pathlib.Path) -> None:
7819 result = runner.invoke(cli, ["hub", "label", "--help"])
7820 assert "list" in result.output.lower() or result.exit_code == 0
7821
7822 def test_label_update_in_help(self, repo: pathlib.Path) -> None:
7823 result = runner.invoke(cli, ["hub", "label", "--help"])
7824 assert "update" in result.output.lower() or result.exit_code == 0
7825
7826 def test_label_delete_in_help(self, repo: pathlib.Path) -> None:
7827 result = runner.invoke(cli, ["hub", "label", "--help"])
7828 assert "delete" in result.output.lower() or result.exit_code == 0
7829
7830 def test_label_constants_imported(self) -> None:
7831 from muse.cli.commands.hub import _MAX_LABEL_NAME_LEN, _MAX_LABEL_DESC_LEN
7832 assert _MAX_LABEL_NAME_LEN == 50
7833 assert _MAX_LABEL_DESC_LEN == 200
7834
7835 def test_validate_hex_color_imported(self) -> None:
7836 from muse.cli.commands.hub import _validate_hex_color
7837 assert _validate_hex_color("#d73a4a") is True
7838 assert _validate_hex_color("d73a4a") is False
7839 assert _validate_hex_color("#fff") is False
7840 assert _validate_hex_color("#zzzzzz") is False
7841 assert _validate_hex_color("#FFFFFF") is True
7842
7843
7844 # ---------------------------------------------------------------------------
7845 # TestLabelSecurity
7846 # ---------------------------------------------------------------------------
7847
7848
7849 class TestLabelSecurity:
7850 """Security hardening tests for label commands."""
7851
7852 def test_create_color_injection_attempt(self, repo: pathlib.Path) -> None:
7853 """Color field must reject shell injection attempts before network."""
7854 from muse.cli.config import set_hub_url
7855 set_hub_url(HUB_URL, repo)
7856 _store_identity(HUB_URL)
7857 malicious_color = "'; rm -rf /; #"
7858 with patch("urllib.request.urlopen") as mock_net:
7859 result = runner.invoke(
7860 cli, ["hub", "label", "create", "--name", "bug", "--color", malicious_color]
7861 )
7862 assert result.exit_code != 0
7863 mock_net.assert_not_called()
7864
7865 def test_create_name_xss_attempt_sanitized(self, repo: pathlib.Path) -> None:
7866 """XSS payload in name must be sanitized in any CLI output."""
7867 from muse.cli.config import set_hub_url
7868 set_hub_url(HUB_URL, repo)
7869 _store_identity(HUB_URL)
7870 xss_name = "<script>alert(1)</script>"
7871 with patch("urllib.request.urlopen"):
7872 result = runner.invoke(
7873 cli, ["hub", "label", "create", "--name", xss_name, "--color", "bad"]
7874 )
7875 # Color is invalid so it exits non-zero; crucially no raw <script> in output
7876 assert "<script>" not in result.stderr
7877
7878 def test_label_name_255_spaces_rejected(self, repo: pathlib.Path) -> None:
7879 """A name composed entirely of spaces must be rejected as empty after strip."""
7880 from muse.cli.config import set_hub_url
7881 set_hub_url(HUB_URL, repo)
7882 _store_identity(HUB_URL)
7883 with patch("urllib.request.urlopen") as mock_net:
7884 result = runner.invoke(
7885 cli,
7886 ["hub", "label", "create", "--name", " " * 255, "--color", "#d73a4a"],
7887 )
7888 assert result.exit_code != 0
7889 mock_net.assert_not_called()
7890
7891 def test_update_ansi_in_new_name_sanitized_in_error(self, repo: pathlib.Path) -> None:
7892 """ANSI codes in new_name must not appear verbatim in error output."""
7893 from muse.cli.config import set_hub_url
7894 set_hub_url(HUB_URL, repo)
7895 _store_identity(HUB_URL)
7896 ansi_name = "\x1b[31mnewname\x1b[0m"
7897 mocks = _mock_responses(_refs_resp(), _label_list_resp([]))
7898 with patch("urllib.request.urlopen", side_effect=mocks):
7899 result = runner.invoke(
7900 cli,
7901 [
7902 "hub", "label", "update",
7903 "--name", "bug",
7904 "--new-name", ansi_name,
7905 ],
7906 )
7907 assert "\x1b[31m" not in result.stderr
7908
7909
7910 # ---------------------------------------------------------------------------
7911 # TestIssueAssignHardening
7912 # ---------------------------------------------------------------------------
7913
7914
7915 class TestIssueAssignHardening:
7916 """Hardening tests for ``muse hub issue assign``."""
7917
7918 def test_zero_number_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
7919 from muse.cli.config import set_hub_url
7920 set_hub_url(HUB_URL, repo)
7921 _store_identity(HUB_URL)
7922 with patch("urllib.request.urlopen") as mock_net:
7923 result = runner.invoke(cli, ["hub", "issue", "assign", "0", "--assignee", "bob"])
7924 assert result.exit_code != 0
7925 mock_net.assert_not_called()
7926
7927 def test_negative_number_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
7928 from muse.cli.config import set_hub_url
7929 set_hub_url(HUB_URL, repo)
7930 _store_identity(HUB_URL)
7931 with patch("urllib.request.urlopen") as mock_net:
7932 result = runner.invoke(cli, ["hub", "issue", "assign", "-3", "--assignee", "bob"])
7933 assert result.exit_code != 0
7934 mock_net.assert_not_called()
7935
7936 def test_missing_assignee_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
7937 from muse.cli.config import set_hub_url
7938 set_hub_url(HUB_URL, repo)
7939 _store_identity(HUB_URL)
7940 with patch("urllib.request.urlopen") as mock_net:
7941 result = runner.invoke(cli, ["hub", "issue", "assign", "5"])
7942 assert result.exit_code != 0
7943 mock_net.assert_not_called()
7944
7945 def test_success_text_mode_exit_zero(self, repo: pathlib.Path) -> None:
7946 from muse.cli.config import set_hub_url
7947 set_hub_url(HUB_URL, repo)
7948 _store_identity(HUB_URL)
7949 mocks = _mock_responses(
7950 _refs_resp(),
7951 _issue_resp(number=5, state="open"),
7952 )
7953 with patch("urllib.request.urlopen", side_effect=mocks):
7954 result = runner.invoke(cli, ["hub", "issue", "assign", "5", "--assignee", "bob"])
7955 assert result.exit_code == 0
7956
7957 def test_success_json_output_has_expected_fields(self, repo: pathlib.Path) -> None:
7958 from muse.cli.config import set_hub_url
7959 set_hub_url(HUB_URL, repo)
7960 _store_identity(HUB_URL)
7961 mocks = _mock_responses(
7962 _refs_resp(),
7963 _issue_resp(number=5, state="open"),
7964 )
7965 with patch("urllib.request.urlopen", side_effect=mocks):
7966 result = runner.invoke(cli, ["hub", "issue", "assign", "5", "--assignee", "bob", "--json"])
7967 assert result.exit_code == 0
7968 data = json.loads(result.output)
7969 assert "number" in data
7970
7971 def test_json_short_flag(self, repo: pathlib.Path) -> None:
7972 from muse.cli.config import set_hub_url
7973 set_hub_url(HUB_URL, repo)
7974 _store_identity(HUB_URL)
7975 mocks = _mock_responses(_refs_resp(), _issue_resp(number=7))
7976 with patch("urllib.request.urlopen", side_effect=mocks):
7977 result = runner.invoke(cli, ["hub", "issue", "assign", "7", "--assignee", "carol", "-j"])
7978 assert result.exit_code == 0
7979 json.loads(result.output)
7980
7981 def test_unassign_empty_string_sends_null(self, repo: pathlib.Path) -> None:
7982 """Passing empty --assignee must call POST .../assign with assignee=null."""
7983 from muse.cli.config import set_hub_url
7984 set_hub_url(HUB_URL, repo)
7985 _store_identity(HUB_URL)
7986 captured_body: list[dict] = []
7987
7988 import json as _json
7989
7990 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
7991 if body is not None:
7992 captured_body.append(dict(body))
7993 return _issue_resp(number=5)
7994
7995 with patch("muse.cli.commands.hub._hub_api", side_effect=_capture):
7996 with patch("muse.cli.commands.hub._get_hub_and_identity", return_value=(HUB_URL, {"handle": "alice", "key_path": ""})):
7997 with patch("muse.cli.commands.hub._resolve_repo_id", return_value="repo-id-0001"):
7998 result = runner.invoke(cli, ["hub", "issue", "assign", "5", "--assignee", ""])
7999 assert result.exit_code == 0
8000 assert any(b.get("assignee") is None for b in captured_body)
8001
8002 def test_uses_post_method(self, repo: pathlib.Path) -> None:
8003 """assign must use POST /api/repos/{id}/issues/{n}/assign."""
8004 from muse.cli.config import set_hub_url
8005 set_hub_url(HUB_URL, repo)
8006 _store_identity(HUB_URL)
8007 captured: list[str] = []
8008
8009 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
8010 captured.append(method)
8011 return _issue_resp(number=5)
8012
8013 with patch("muse.cli.commands.hub._hub_api", side_effect=_capture):
8014 with patch("muse.cli.commands.hub._get_hub_and_identity", return_value=(HUB_URL, {"handle": "alice", "key_path": ""})):
8015 with patch("muse.cli.commands.hub._resolve_repo_id", return_value="repo-id-0001"):
8016 runner.invoke(cli, ["hub", "issue", "assign", "5", "--assignee", "bob"])
8017 assert "POST" in captured
8018
8019 def test_success_message_contains_assignee(self, repo: pathlib.Path) -> None:
8020 from muse.cli.config import set_hub_url
8021 set_hub_url(HUB_URL, repo)
8022 _store_identity(HUB_URL)
8023 mocks = _mock_responses(_refs_resp(), _issue_resp(number=5))
8024 with patch("urllib.request.urlopen", side_effect=mocks):
8025 result = runner.invoke(cli, ["hub", "issue", "assign", "5", "--assignee", "bob"])
8026 assert result.exit_code == 0
8027 assert "bob" in result.stderr or "5" in result.stderr
8028
8029 def test_unassign_success_message(self, repo: pathlib.Path) -> None:
8030 from muse.cli.config import set_hub_url
8031 set_hub_url(HUB_URL, repo)
8032 _store_identity(HUB_URL)
8033
8034 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
8035 return _issue_resp(number=5)
8036
8037 with patch("muse.cli.commands.hub._hub_api", side_effect=_capture):
8038 with patch("muse.cli.commands.hub._get_hub_and_identity", return_value=(HUB_URL, {"handle": "alice", "key_path": ""})):
8039 with patch("muse.cli.commands.hub._resolve_repo_id", return_value="repo-id-0001"):
8040 result = runner.invoke(cli, ["hub", "issue", "assign", "5", "--assignee", ""])
8041 assert result.exit_code == 0
8042
8043
8044 # ---------------------------------------------------------------------------
8045 # TestIssueAssignSubparserRegistration
8046 # ---------------------------------------------------------------------------
8047
8048
8049 class TestIssueAssignSubparserRegistration:
8050 """Verify ``issue assign`` subparser is registered with correct arguments."""
8051
8052 def test_assign_help_exits_zero(self, repo: pathlib.Path) -> None:
8053 result = runner.invoke(cli, ["hub", "issue", "assign", "--help"])
8054 assert result.exit_code == 0
8055
8056 def test_assign_number_arg_registered(self, repo: pathlib.Path) -> None:
8057 result = runner.invoke(cli, ["hub", "issue", "assign", "--help"])
8058 assert "number" in result.output.lower() or "NUMBER" in result.output
8059
8060 def test_assignee_flag_registered(self, repo: pathlib.Path) -> None:
8061 result = runner.invoke(cli, ["hub", "issue", "assign", "--help"])
8062 assert "--assignee" in result.output
8063
8064 def test_json_flag_registered(self, repo: pathlib.Path) -> None:
8065 result = runner.invoke(cli, ["hub", "issue", "assign", "--help"])
8066 assert "--json" in result.output
8067
8068 def test_short_json_flag_registered(self, repo: pathlib.Path) -> None:
8069 result = runner.invoke(cli, ["hub", "issue", "assign", "--help"])
8070 assert "-j" in result.output
8071
8072
8073 # ---------------------------------------------------------------------------
8074 # TestIssueLabelHardening
8075 # ---------------------------------------------------------------------------
8076
8077
8078 class TestIssueLabelHardening:
8079 """Hardening tests for ``muse hub issue label``."""
8080
8081 def test_zero_number_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
8082 from muse.cli.config import set_hub_url
8083 set_hub_url(HUB_URL, repo)
8084 _store_identity(HUB_URL)
8085 with patch("urllib.request.urlopen") as mock_net:
8086 result = runner.invoke(cli, ["hub", "issue", "label", "0", "--set", "bug"])
8087 assert result.exit_code != 0
8088 mock_net.assert_not_called()
8089
8090 def test_negative_number_exits_nonzero_no_network(self, repo: pathlib.Path) -> None:
8091 from muse.cli.config import set_hub_url
8092 set_hub_url(HUB_URL, repo)
8093 _store_identity(HUB_URL)
8094 with patch("urllib.request.urlopen") as mock_net:
8095 result = runner.invoke(cli, ["hub", "issue", "label", "-2", "--set", "bug"])
8096 assert result.exit_code != 0
8097 mock_net.assert_not_called()
8098
8099 def test_missing_set_or_remove_exits_nonzero(self, repo: pathlib.Path) -> None:
8100 from muse.cli.config import set_hub_url
8101 set_hub_url(HUB_URL, repo)
8102 _store_identity(HUB_URL)
8103 with patch("urllib.request.urlopen") as mock_net:
8104 result = runner.invoke(cli, ["hub", "issue", "label", "5"])
8105 assert result.exit_code != 0
8106 mock_net.assert_not_called()
8107
8108 def test_set_and_remove_mutually_exclusive(self, repo: pathlib.Path) -> None:
8109 from muse.cli.config import set_hub_url
8110 set_hub_url(HUB_URL, repo)
8111 _store_identity(HUB_URL)
8112 with patch("urllib.request.urlopen") as mock_net:
8113 result = runner.invoke(
8114 cli, ["hub", "issue", "label", "5", "--set", "bug", "--remove", "bug"]
8115 )
8116 assert result.exit_code != 0
8117 mock_net.assert_not_called()
8118
8119 def test_set_success_exit_zero(self, repo: pathlib.Path) -> None:
8120 from muse.cli.config import set_hub_url
8121 set_hub_url(HUB_URL, repo)
8122 _store_identity(HUB_URL)
8123 mocks = _mock_responses(
8124 _refs_resp(),
8125 _issue_resp(number=5, labels=["bug", "enhancement"]),
8126 )
8127 with patch("urllib.request.urlopen", side_effect=mocks):
8128 result = runner.invoke(cli, ["hub", "issue", "label", "5", "--set", "bug", "enhancement"])
8129 assert result.exit_code == 0
8130
8131 def test_set_json_output(self, repo: pathlib.Path) -> None:
8132 from muse.cli.config import set_hub_url
8133 set_hub_url(HUB_URL, repo)
8134 _store_identity(HUB_URL)
8135 mocks = _mock_responses(
8136 _refs_resp(),
8137 _issue_resp(number=5, labels=["bug"]),
8138 )
8139 with patch("urllib.request.urlopen", side_effect=mocks):
8140 result = runner.invoke(cli, ["hub", "issue", "label", "5", "--set", "bug", "--json"])
8141 assert result.exit_code == 0
8142 data = json.loads(result.output)
8143 assert "number" in data
8144
8145 def test_remove_success_exit_zero(self, repo: pathlib.Path) -> None:
8146 from muse.cli.config import set_hub_url
8147 set_hub_url(HUB_URL, repo)
8148 _store_identity(HUB_URL)
8149 mocks = _mock_responses(
8150 _refs_resp(),
8151 _issue_resp(number=5, labels=[]),
8152 )
8153 with patch("urllib.request.urlopen", side_effect=mocks):
8154 result = runner.invoke(cli, ["hub", "issue", "label", "5", "--remove", "bug"])
8155 assert result.exit_code == 0
8156
8157 def test_remove_json_output(self, repo: pathlib.Path) -> None:
8158 from muse.cli.config import set_hub_url
8159 set_hub_url(HUB_URL, repo)
8160 _store_identity(HUB_URL)
8161 mocks = _mock_responses(
8162 _refs_resp(),
8163 _issue_resp(number=5, labels=[]),
8164 )
8165 with patch("urllib.request.urlopen", side_effect=mocks):
8166 result = runner.invoke(cli, ["hub", "issue", "label", "5", "--remove", "bug", "-j"])
8167 assert result.exit_code == 0
8168 json.loads(result.output)
8169
8170 def test_set_uses_post_method(self, repo: pathlib.Path) -> None:
8171 """--set must use POST /api/repos/{id}/issues/{n}/labels."""
8172 from muse.cli.config import set_hub_url
8173 set_hub_url(HUB_URL, repo)
8174 _store_identity(HUB_URL)
8175 captured: list[str] = []
8176
8177 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
8178 captured.append(method)
8179 return _issue_resp(number=5)
8180
8181 with patch("muse.cli.commands.hub._hub_api", side_effect=_capture):
8182 with patch("muse.cli.commands.hub._get_hub_and_identity", return_value=(HUB_URL, {"handle": "alice", "key_path": ""})):
8183 with patch("muse.cli.commands.hub._resolve_repo_id", return_value="repo-id-0001"):
8184 runner.invoke(cli, ["hub", "issue", "label", "5", "--set", "bug"])
8185 assert "POST" in captured
8186
8187 def test_remove_uses_delete_method(self, repo: pathlib.Path) -> None:
8188 """--remove must use DELETE /api/repos/{id}/issues/{n}/labels/{name}."""
8189 from muse.cli.config import set_hub_url
8190 set_hub_url(HUB_URL, repo)
8191 _store_identity(HUB_URL)
8192 captured: list[str] = []
8193
8194 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
8195 captured.append(method)
8196 return _issue_resp(number=5)
8197
8198 with patch("muse.cli.commands.hub._hub_api", side_effect=_capture):
8199 with patch("muse.cli.commands.hub._get_hub_and_identity", return_value=(HUB_URL, {"handle": "alice", "key_path": ""})):
8200 with patch("muse.cli.commands.hub._resolve_repo_id", return_value="repo-id-0001"):
8201 runner.invoke(cli, ["hub", "issue", "label", "5", "--remove", "bug"])
8202 assert "DELETE" in captured
8203
8204 def test_set_sends_labels_in_body(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 captured_body: list[dict] = []
8209
8210 def _capture(hub_url: str, identity: IdentityEntry, method: str, path: str, body: _HubBody = None, timeout: float = 10.0) -> _JsonPayload:
8211 if body is not None:
8212 captured_body.append(dict(body))
8213 return _issue_resp(number=5)
8214
8215 with patch("muse.cli.commands.hub._hub_api", side_effect=_capture):
8216 with patch("muse.cli.commands.hub._get_hub_and_identity", return_value=(HUB_URL, {"handle": "alice", "key_path": ""})):
8217 with patch("muse.cli.commands.hub._resolve_repo_id", return_value="repo-id-0001"):
8218 runner.invoke(cli, ["hub", "issue", "label", "5", "--set", "bug", "enhancement"])
8219 assert any("labels" in b for b in captured_body)
8220 label_body = next((b for b in captured_body if "labels" in b), None)
8221 assert label_body is not None
8222 assert set(label_body["labels"]) == {"bug", "enhancement"}
8223
8224
8225 # ---------------------------------------------------------------------------
8226 # TestIssueLabelSubparserRegistration
8227 # ---------------------------------------------------------------------------
8228
8229
8230 class TestIssueLabelSubparserRegistration:
8231 """Verify ``issue label`` subparser is registered with correct arguments."""
8232
8233 def test_label_help_exits_zero(self, repo: pathlib.Path) -> None:
8234 result = runner.invoke(cli, ["hub", "issue", "label", "--help"])
8235 assert result.exit_code == 0
8236
8237 def test_set_flag_registered(self, repo: pathlib.Path) -> None:
8238 result = runner.invoke(cli, ["hub", "issue", "label", "--help"])
8239 assert "--set" in result.output
8240
8241 def test_remove_flag_registered(self, repo: pathlib.Path) -> None:
8242 result = runner.invoke(cli, ["hub", "issue", "label", "--help"])
8243 assert "--remove" in result.output
8244
8245 def test_json_flag_registered(self, repo: pathlib.Path) -> None:
8246 result = runner.invoke(cli, ["hub", "issue", "label", "--help"])
8247 assert "--json" in result.output
8248
8249 def test_number_arg_registered(self, repo: pathlib.Path) -> None:
8250 result = runner.invoke(cli, ["hub", "issue", "label", "--help"])
8251 assert "number" in result.output.lower() or "NUMBER" in result.output
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 127 days ago