"""Tests for `muse auth` CLI commands — whoami, logout. The identity store is redirected to a temporary directory per test so these tests never touch ~/.muse/identity.toml. Network calls are not made — auth commands read/write the local identity store only. """ from __future__ import annotations import json import pathlib import pytest from tests.cli_test_helper import CliRunner from muse._version import __version__ cli = None # argparse migration — CliRunner ignores this arg from muse.cli.config import get_hub_url, set_hub_url from muse.core.identity import ( IdentityEntry, get_identity_path, list_all_identities, load_identity, save_identity, ) from muse.core.paths import muse_dir runner = CliRunner() # --------------------------------------------------------------------------- # Fixture: minimal repo + isolated identity store # --------------------------------------------------------------------------- @pytest.fixture() def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: """Minimal .muse/ repo with a pre-configured hub URL. The identity store is redirected to *tmp_path* so tests never touch the real ``~/.muse/identity.toml``. """ dot_muse = muse_dir(tmp_path) (dot_muse / "refs" / "heads").mkdir(parents=True) (dot_muse / "objects").mkdir() (dot_muse / "commits").mkdir() (dot_muse / "snapshots").mkdir() (dot_muse / "repo.json").write_text( json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "midi"}) ) (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") (dot_muse / "config.toml").write_text( '[hub]\nurl = "https://musehub.ai"\n' ) monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) monkeypatch.chdir(tmp_path) # Isolate the identity store. fake_dir = tmp_path / "home" / ".muse" fake_dir.mkdir(parents=True) fake_file = fake_dir / "identity.toml" monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir) monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_file) return tmp_path # --------------------------------------------------------------------------- # muse auth whoami # --------------------------------------------------------------------------- class TestAuthWhoami: def _store_entry(self, hub: str = "https://musehub.ai") -> None: entry: IdentityEntry = { "type": "human", "handle": "Alice", "algorithm": "ed25519", "fingerprint": "abc123fingerprint", "hd_path": "m/1075233755'/0'/0'/0'/0'/0'", } save_identity(hub, entry) def test_whoami_shows_hub(self, repo: pathlib.Path) -> None: self._store_entry() result = runner.invoke(cli, ["auth", "whoami"]) assert result.exit_code == 0 assert "musehub.ai" in result.stderr def test_whoami_shows_type(self, repo: pathlib.Path) -> None: self._store_entry() result = runner.invoke(cli, ["auth", "whoami"]) assert "human" in result.stderr def test_whoami_shows_handle(self, repo: pathlib.Path) -> None: self._store_entry() result = runner.invoke(cli, ["auth", "whoami"]) assert "Alice" in result.stderr def test_whoami_json_output(self, repo: pathlib.Path) -> None: self._store_entry() result = runner.invoke(cli, ["auth", "whoami", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["type"] == "human" assert data["handle"] == "Alice" assert isinstance(data.get("key_set"), bool) def test_whoami_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["auth", "whoami"]) assert result.exit_code != 0 def test_whoami_hub_option_selects_specific_hub(self, repo: pathlib.Path) -> None: save_identity("https://staging.musehub.ai", {"type": "agent", "handle": "bot"}) result = runner.invoke(cli, ["auth", "whoami", "--hub", "https://staging.musehub.ai"]) assert result.exit_code == 0 assert "staging.musehub.ai" in result.stderr def test_whoami_all_lists_all_hubs(self, repo: pathlib.Path) -> None: self._store_entry("https://hub1.example.com") self._store_entry("https://hub2.example.com") result = runner.invoke(cli, ["auth", "whoami", "--all"]) assert result.exit_code == 0 assert "hub1.example.com" in result.stderr assert "hub2.example.com" in result.stderr def test_whoami_all_no_identities_exits_nonzero(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["auth", "whoami", "--all"]) assert result.exit_code != 0 def test_whoami_capabilities_shown(self, repo: pathlib.Path) -> None: entry: IdentityEntry = { "type": "agent", "handle": "worker", "capabilities": ["read:*", "write:midi"], } save_identity("https://musehub.ai", entry) result = runner.invoke(cli, ["auth", "whoami"]) assert "read:*" in result.stderr or "write:midi" in result.stderr def test_whoami_key_set_is_bool(self, repo: pathlib.Path) -> None: self._store_entry() result = runner.invoke(cli, ["auth", "whoami", "--json"]) assert result.exit_code == 0 raw = result.output assert '"key_set": true' in raw or '"key_set":true' in raw assert '"key_set": "true"' not in raw def test_whoami_all_json_is_single_array(self, repo: pathlib.Path) -> None: """--all --json must emit one JSON envelope with an identities array.""" save_identity("https://hub-a.example.com", {"type": "human", "handle": "a"}) save_identity("https://hub-b.example.com", {"type": "agent", "handle": "b"}) result = runner.invoke(cli, ["auth", "whoami", "--all", "--json"]) assert result.exit_code == 0 parsed = json.loads(result.output) # would raise if multiple top-level values identities = parsed["identities"] assert isinstance(identities, list) assert len(identities) == 2 def test_whoami_ansi_in_fingerprint_stripped(self, repo: pathlib.Path) -> None: """ANSI escape sequences in stored fingerprint must not appear in text output.""" import unittest.mock malicious_entry: IdentityEntry = { "type": "human", "handle": "alice", "fingerprint": "\x1b[31mmalicious-fp\x1b[0m", } with unittest.mock.patch("muse.core.identity._load_all", return_value={"musehub.ai": malicious_entry}): result = runner.invoke(cli, ["auth", "whoami"]) assert result.exit_code == 0 assert "\x1b" not in result.output def test_whoami_short_flags_accepted(self, repo: pathlib.Path) -> None: """-j and -a short flags work.""" self._store_entry() result = runner.invoke(cli, ["auth", "whoami", "-j"]) assert result.exit_code == 0 json.loads(result.output) save_identity("https://hub-x.example.com", {"type": "agent", "handle": "bot"}) result2 = runner.invoke(cli, ["auth", "whoami", "-a"]) assert result2.exit_code == 0 assert "musehub.ai" in result2.stderr or "hub-x.example.com" in result2.stderr # --------------------------------------------------------------------------- # muse auth whoami — global config fallback (regression: "no url provided") # --------------------------------------------------------------------------- class TestWhoamiGlobalConfigFallback: """Regression tests for the bug where `muse auth whoami` (no --hub) fails with "No hub URL provided" even when ~/.muse/config.toml has [hub] url set. Root cause: get_hub_url() only checked /.muse/config.toml and never fell back to the global ~/.muse/config.toml. """ def test_whoami_reads_hub_from_global_config( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """whoami without --hub succeeds when ~/.muse/config.toml has [hub] url.""" # Simulate running from a directory with NO repo .muse/ outside_dir = tmp_path / "not-a-repo" outside_dir.mkdir() monkeypatch.chdir(outside_dir) # Global config at ~/.muse/config.toml with a hub URL fake_home = tmp_path / "home" fake_muse = fake_home / ".muse" fake_muse.mkdir(parents=True) (fake_muse / "config.toml").write_text('[hub]\nurl = "https://staging.musehub.ai"\n') # Redirect global config and identity store to our fake home monkeypatch.setattr("muse.cli.config._GLOBAL_CONFIG_FILE", fake_muse / "config.toml") identity_file = fake_muse / "identity.toml" monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_muse) monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", identity_file) # Store identity for the hub URL that the global config references entry: IdentityEntry = { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "fingerprint": "abc123", } save_identity("https://staging.musehub.ai", entry) result = runner.invoke(cli, ["auth", "whoami"]) assert result.exit_code == 0, f"Expected exit 0, got {result.exit_code}:\n{result.stderr}" assert "staging.musehub.ai" in result.stderr def test_whoami_global_config_fallback_not_used_when_repo_config_present( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """Repo-local .muse/config.toml takes precedence over the global config.""" # Repo with its own hub config repo_dir = tmp_path / "my-repo" dot_muse = muse_dir(repo_dir) (dot_muse / "refs" / "heads").mkdir(parents=True) (dot_muse / "config.toml").write_text('[hub]\nurl = "https://musehub.ai"\n') monkeypatch.chdir(repo_dir) # Global config pointing at a DIFFERENT hub fake_home = tmp_path / "home" fake_muse = fake_home / ".muse" fake_muse.mkdir(parents=True) (fake_muse / "config.toml").write_text('[hub]\nurl = "https://staging.musehub.ai"\n') monkeypatch.setattr("muse.cli.config._GLOBAL_CONFIG_FILE", fake_muse / "config.toml") identity_file = fake_muse / "identity.toml" monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_muse) monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", identity_file) entry: IdentityEntry = {"type": "human", "handle": "gabriel"} save_identity("https://musehub.ai", entry) result = runner.invoke(cli, ["auth", "whoami"]) assert result.exit_code == 0 assert "musehub.ai" in result.stderr # --------------------------------------------------------------------------- # muse auth logout # --------------------------------------------------------------------------- class TestAuthLogout: def _store(self, hub: str = "https://musehub.ai") -> None: entry: IdentityEntry = {"type": "human", "handle": "alice"} save_identity(hub, entry) def test_logout_removes_identity(self, repo: pathlib.Path) -> None: self._store() result = runner.invoke(cli, ["auth", "logout"]) assert result.exit_code == 0 assert load_identity("https://musehub.ai") is None def test_logout_shows_success_message(self, repo: pathlib.Path) -> None: self._store() result = runner.invoke(cli, ["auth", "logout"]) assert "musehub.ai" in result.stderr or "Logged out" in result.stderr def test_logout_nothing_to_do_does_not_fail(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["auth", "logout"]) assert result.exit_code == 0 assert "nothing" in result.stderr.lower() or "nothing to do" in result.stderr.lower() def test_logout_hub_option_removes_specific_hub(self, repo: pathlib.Path) -> None: self._store("https://hub1.example.com") self._store("https://hub2.example.com") result = runner.invoke(cli, ["auth", "logout", "--hub", "https://hub1.example.com"]) assert result.exit_code == 0 assert load_identity("https://hub1.example.com") is None assert load_identity("https://hub2.example.com") is not None def test_logout_all_removes_all_identities(self, repo: pathlib.Path) -> None: self._store("https://hub1.example.com") self._store("https://hub2.example.com") result = runner.invoke(cli, ["auth", "logout", "--all"]) assert result.exit_code == 0 assert not list_all_identities() def test_logout_all_reports_count(self, repo: pathlib.Path) -> None: self._store("https://hub1.example.com") self._store("https://hub2.example.com") result = runner.invoke(cli, ["auth", "logout", "--all"]) assert "2" in result.stderr def test_logout_all_no_identities_succeeds(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["auth", "logout", "--all"]) assert result.exit_code == 0 def test_logout_fails_without_hub_source( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """With no hub in config and no --hub flag, logout should fail.""" dot_muse = muse_dir(tmp_path) dot_muse.mkdir() (dot_muse / "config.toml").write_text("") (dot_muse / "repo.json").write_text( json.dumps({"repo_id": "r", "schema_version": __version__, "domain": "midi"}) ) (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) monkeypatch.chdir(tmp_path) fake_dir = tmp_path / "home" / ".muse" fake_dir.mkdir(parents=True) monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir) monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_dir / "identity.toml") result = runner.invoke(cli, ["auth", "logout"]) assert result.exit_code != 0