"""Comprehensive tests for muse ls-remote. The command contacts a remote via HttpTransport. All tests mock that transport — no real network is required. Coverage: - Unit: _FORMAT_CHOICES, register args - Integration: JSON/text output, --json shorthand, multiple branches, empty repo, default-branch marker, URL override, format error - Security: ANSI in remote branch names / commit IDs, format error → stderr, no tracebacks on transport failures - Stress: 200 branches, 200 sequential calls """ from __future__ import annotations import json import pathlib from unittest.mock import patch from muse.core.errors import ExitCode from muse.core.paths import muse_dir from muse.core.mpack import RemoteInfo from muse.core.transport import TransportError from muse.core.types import Manifest, long_id from tests.cli_test_helper import CliRunner, InvokeResult runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _FAKE_OID = long_id("a" * 64) _FAKE_URL = "https://localhost:1337/gabriel/muse" def _init_repo(path: pathlib.Path) -> pathlib.Path: dot_muse = muse_dir(path) (dot_muse / "commits").mkdir(parents=True, exist_ok=True) (dot_muse / "snapshots").mkdir(parents=True, exist_ok=True) (dot_muse / "objects").mkdir(parents=True, exist_ok=True) (dot_muse / "refs" / "heads").mkdir(parents=True, exist_ok=True) (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") (dot_muse / "repo.json").write_text( json.dumps({"repo_id": "test-repo", "domain": "generic"}), encoding="utf-8" ) # Remote config so "local" resolves to a URL (.muse/config.toml is the canonical location) (dot_muse / "config.toml").write_text( f'[remotes.local]\nurl = "{_FAKE_URL}"\n', encoding="utf-8" ) return path def _make_remote_info( branches: Manifest | None = None, default: str = "main", ) -> RemoteInfo: return RemoteInfo( repo_id="test-repo", domain="generic", branch_heads={"main": _FAKE_OID} if branches is None else branches, default_branch=default, ) def _lr( tmp_path: pathlib.Path, *args: str, remote_info: RemoteInfo | None = None, transport_error: TransportError | None = None, ) -> InvokeResult: """Invoke ls-remote with a mocked HttpTransport.""" from muse.cli.app import main as cli repo = _init_repo(tmp_path) info = remote_info or _make_remote_info() with patch("muse.cli.commands.ls_remote.HttpTransport") as MockTransport: instance = MockTransport.return_value if transport_error is not None: instance.fetch_remote_info.side_effect = transport_error else: instance.fetch_remote_info.return_value = info return runner.invoke( cli, ["ls-remote", *args], env={"MUSE_REPO_ROOT": str(repo)}, ) # --------------------------------------------------------------------------- # Unit: schema # --------------------------------------------------------------------------- class TestSchemas: def test_json_flag_registered(self) -> None: from muse.cli.commands.ls_remote import register import argparse p = argparse.ArgumentParser() subs = p.add_subparsers() register(subs) args = p.parse_args(["ls-remote", "--json"]) assert args.json_out is True def test_remote_info_fields(self) -> None: r = _make_remote_info() assert "repo_id" in r assert "domain" in r assert "branch_heads" in r assert "default_branch" in r # --------------------------------------------------------------------------- # Integration: JSON output # --------------------------------------------------------------------------- class TestJsonOutput: def test_single_branch_json(self, tmp_path: pathlib.Path) -> None: r = _lr(tmp_path, "local", "--json") assert r.exit_code == 0 d = json.loads(r.output) assert d["repo_id"] == "test-repo" assert d["domain"] == "generic" assert "main" in d["branches"] assert d["branches"]["main"] == _FAKE_OID assert d["default_branch"] == "main" def test_json_shorthand(self, tmp_path: pathlib.Path) -> None: r = _lr(tmp_path, "local", "--json") assert r.exit_code == 0 d = json.loads(r.output) assert "branches" in d def test_multiple_branches(self, tmp_path: pathlib.Path) -> None: info = _make_remote_info( branches={"main": _FAKE_OID, "dev": "b" * 64, "feat/x": "c" * 64}, default="main", ) r = _lr(tmp_path, "local", "--json", remote_info=info) assert r.exit_code == 0 d = json.loads(r.output) assert len(d["branches"]) == 3 assert "feat/x" in d["branches"] def test_empty_branches(self, tmp_path: pathlib.Path) -> None: info = _make_remote_info(branches={}) r = _lr(tmp_path, "local", "--json", remote_info=info) assert r.exit_code == 0 d = json.loads(r.output) assert d["branches"] == {} def test_non_default_branch_flag(self, tmp_path: pathlib.Path) -> None: info = _make_remote_info( branches={"main": _FAKE_OID, "dev": "b" * 64}, default="main" ) r = _lr(tmp_path, "local", "--json", remote_info=info) assert r.exit_code == 0 d = json.loads(r.output) assert d["default_branch"] == "main" # --------------------------------------------------------------------------- # Integration: text output # --------------------------------------------------------------------------- class TestTextOutput: def test_text_format_shows_commit_and_branch(self, tmp_path: pathlib.Path) -> None: r = _lr(tmp_path, "local") assert r.exit_code == 0 assert _FAKE_OID in r.output assert "main" in r.output def test_text_format_empty_repo(self, tmp_path: pathlib.Path) -> None: info = _make_remote_info(branches={}) r = _lr(tmp_path, "local", remote_info=info) assert r.exit_code == 0 assert "(no branches)" in r.output def test_text_format_default_branch_marker(self, tmp_path: pathlib.Path) -> None: info = _make_remote_info( branches={"main": _FAKE_OID, "dev": "b" * 64}, default="main" ) r = _lr(tmp_path, "local", remote_info=info) assert r.exit_code == 0 # Default branch should have a marker (*) in text output lines = r.output.strip().split("\n") default_line = next(l for l in lines if "main" in l) assert "*" in default_line def test_text_format_non_default_no_marker(self, tmp_path: pathlib.Path) -> None: info = _make_remote_info( branches={"main": _FAKE_OID, "dev": "b" * 64}, default="main" ) r = _lr(tmp_path, "local", remote_info=info) lines = r.output.strip().split("\n") dev_line = next(l for l in lines if "dev" in l) assert "*" not in dev_line def test_text_format_sorted_output(self, tmp_path: pathlib.Path) -> None: info = _make_remote_info( branches={"zeta": _FAKE_OID, "alpha": "b" * 64, "main": "c" * 64}, ) r = _lr(tmp_path, "local", remote_info=info) lines = [l for l in r.output.strip().split("\n") if l] # Branch names should be sorted branch_names = [l.split("\t")[1].strip().rstrip(" *") for l in lines] assert branch_names == sorted(branch_names) def test_url_direct_bypass_remote_config(self, tmp_path: pathlib.Path) -> None: """Passing a URL directly instead of a remote name should work.""" r = _lr(tmp_path, _FAKE_URL) assert r.exit_code == 0 # --------------------------------------------------------------------------- # Integration: error paths # --------------------------------------------------------------------------- class TestErrors: def test_transport_error_exits_nonzero(self, tmp_path: pathlib.Path) -> None: r = _lr( tmp_path, "local", transport_error=TransportError("Connection refused", 0), ) assert r.exit_code != 0 def test_transport_error_goes_to_stderr(self, tmp_path: pathlib.Path) -> None: r = _lr( tmp_path, "local", transport_error=TransportError("404 Not Found", 404), ) assert r.exit_code != 0 # Error message goes to stderr; output must be empty assert r.stdout_bytes == b"" assert "cannot reach remote" in r.stderr.lower() or r.exit_code != 0 def test_unknown_remote_name_errors(self, tmp_path: pathlib.Path) -> None: from muse.cli.app import main as cli repo = _init_repo(tmp_path) with patch("muse.cli.commands.ls_remote.HttpTransport"): result = runner.invoke( cli, ["ls-remote", "nonexistent-remote"], env={"MUSE_REPO_ROOT": str(repo)}, ) assert result.exit_code != 0 def test_format_error_to_stderr(self, tmp_path: pathlib.Path) -> None: r = _lr(tmp_path, "local", "--format", "xml") assert r.exit_code != 0 assert r.stdout_bytes == b"" assert r.stderr # error message sent to stderr def test_no_traceback_on_transport_failure(self, tmp_path: pathlib.Path) -> None: r = _lr( tmp_path, "local", transport_error=TransportError("timed out", 0), ) assert "Traceback" not in r.output assert "Traceback" not in r.stderr def test_no_traceback_on_bad_format(self, tmp_path: pathlib.Path) -> None: r = _lr(tmp_path, "local", "--format", "bad") assert "Traceback" not in r.output assert "Traceback" not in r.stderr # --------------------------------------------------------------------------- # Security # --------------------------------------------------------------------------- class TestSecurity: def test_ansi_in_branch_name_stripped_text(self, tmp_path: pathlib.Path) -> None: """ANSI in remote-provided branch name must not leak to text output.""" ansi_branch = "\x1b[31mmalicious\x1b[0m" info = _make_remote_info(branches={ansi_branch: _FAKE_OID}) r = _lr(tmp_path, "local", "--format", "text", remote_info=info) assert "\x1b" not in r.output def test_ansi_in_commit_id_stripped_text(self, tmp_path: pathlib.Path) -> None: """ANSI in remote-provided commit ID must not leak to text output.""" ansi_oid = f"\x1b[31m{'a' * 58}\x1b[0m" info = _make_remote_info(branches={"main": ansi_oid}) r = _lr(tmp_path, "local", "--format", "text", remote_info=info) assert "\x1b" not in r.output def test_ansi_encoded_in_json(self, tmp_path: pathlib.Path) -> None: """ANSI in remote data is JSON-encoded (\\u001b), not emitted raw.""" ansi_branch = "\x1b[31mred\x1b[0m" info = _make_remote_info(branches={ansi_branch: _FAKE_OID}) r = _lr(tmp_path, "local", "--json", remote_info=info) assert r.exit_code == 0 # json.dumps encodes \x1b as \u001b — raw ESC must not appear in output assert "\x1b" not in r.output # Even after JSON decode, the branch key is recoverable as-is d = json.loads(r.output) assert ansi_branch in d["branches"] # --------------------------------------------------------------------------- # Stress # --------------------------------------------------------------------------- class TestStress: def test_200_branches(self, tmp_path: pathlib.Path) -> None: branches = {f"branch-{i:04d}": format(i, "064x") for i in range(200)} info = _make_remote_info(branches=branches, default="branch-0000") r = _lr(tmp_path, "local", "--json", remote_info=info) assert r.exit_code == 0 d = json.loads(r.output) assert len(d["branches"]) == 200 def test_200_sequential_calls(self, tmp_path: pathlib.Path) -> None: for i in range(200): r = _lr(tmp_path, "local") assert r.exit_code == 0, f"failed at iteration {i}" def test_large_branch_text_output(self, tmp_path: pathlib.Path) -> None: """200 branches in text format must not crash.""" branches = {f"br-{i:04d}": format(i, "064x") for i in range(200)} info = _make_remote_info(branches=branches, default="br-0000") r = _lr(tmp_path, "local", remote_info=info) assert r.exit_code == 0 lines = [l for l in r.output.strip().split("\n") if l] assert len(lines) == 200 # --------------------------------------------------------------------------- # Signing identity — remote_url forwarding # --------------------------------------------------------------------------- class TestSigningIdentityForwarding: """Signing identity must use the resolved remote URL, not the default hub URL. Regression test for the bug where ls-remote called get_signing_identity(root) before resolving the URL, causing it to look up the key for the repo's default hub (e.g. localhost:1337) instead of the actual target remote (e.g. staging). This produced HTTP 401 on staging even when the user was registered there. """ def test_signing_identity_receives_resolved_remote_url( self, tmp_path: pathlib.Path ) -> None: """get_signing_identity must be called with remote_url equal to the resolved URL of the named remote, not the fallback hub URL.""" from muse.cli.app import main as cli repo = _init_repo(tmp_path) with ( patch("muse.cli.commands.ls_remote.HttpTransport") as MockTransport, patch("muse.cli.commands.ls_remote.get_signing_identity") as mock_gsi, ): MockTransport.return_value.fetch_remote_info.return_value = _make_remote_info() mock_gsi.return_value = None runner.invoke( cli, ["ls-remote", "local", "--json"], env={"MUSE_REPO_ROOT": str(repo)}, ) mock_gsi.assert_called_once() _, kwargs = mock_gsi.call_args assert kwargs.get("remote_url") == _FAKE_URL, ( f"get_signing_identity must be called with remote_url={_FAKE_URL!r}; " f"got remote_url={kwargs.get('remote_url')!r}. " "Without this, ls-remote uses the wrong signing key for non-default remotes." ) def test_signing_identity_receives_url_when_passed_directly( self, tmp_path: pathlib.Path ) -> None: """When the caller passes a full URL instead of a remote name, that URL must be forwarded to get_signing_identity as remote_url.""" from muse.cli.app import main as cli direct_url = "https://staging.musehub.ai/gabriel/muse" repo = _init_repo(tmp_path) with ( patch("muse.cli.commands.ls_remote.HttpTransport") as MockTransport, patch("muse.cli.commands.ls_remote.get_signing_identity") as mock_gsi, ): MockTransport.return_value.fetch_remote_info.return_value = _make_remote_info() mock_gsi.return_value = None runner.invoke( cli, ["ls-remote", direct_url, "--json"], env={"MUSE_REPO_ROOT": str(repo)}, ) mock_gsi.assert_called_once() _, kwargs = mock_gsi.call_args assert kwargs.get("remote_url") == direct_url class TestRegisterFlags: def test_json_short_flag(self) -> None: import argparse from muse.cli.commands.ls_remote import register p = argparse.ArgumentParser() subs = p.add_subparsers() register(subs) args = p.parse_args(["ls-remote", "-j"]) assert args.json_out is True def test_json_long_flag(self) -> None: import argparse from muse.cli.commands.ls_remote import register p = argparse.ArgumentParser() subs = p.add_subparsers() register(subs) args = p.parse_args(["ls-remote", "--json"]) assert args.json_out is True def test_default_no_json(self) -> None: import argparse from muse.cli.commands.ls_remote import register p = argparse.ArgumentParser() subs = p.add_subparsers() register(subs) # Command-specific required args may differ; just check dest exists when possible try: args = p.parse_args(["ls-remote"]) assert args.json_out is False except SystemExit: pass # required positional args missing — flag default still correct