test_cli_auth.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | """Tests for `muse auth` CLI commands — whoami, logout. |
| 2 | |
| 3 | The identity store is redirected to a temporary directory per test so these |
| 4 | tests never touch ~/.muse/identity.toml. Network calls are not made — auth |
| 5 | commands read/write the local identity store only. |
| 6 | """ |
| 7 | |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import json |
| 11 | import pathlib |
| 12 | |
| 13 | import pytest |
| 14 | from tests.cli_test_helper import CliRunner |
| 15 | |
| 16 | from muse._version import __version__ |
| 17 | cli = None # argparse migration — CliRunner ignores this arg |
| 18 | from muse.cli.config import get_hub_url, set_hub_url |
| 19 | from muse.core.identity import ( |
| 20 | IdentityEntry, |
| 21 | get_identity_path, |
| 22 | list_all_identities, |
| 23 | load_identity, |
| 24 | save_identity, |
| 25 | ) |
| 26 | from muse.core.paths import muse_dir |
| 27 | |
| 28 | runner = CliRunner() |
| 29 | |
| 30 | |
| 31 | # --------------------------------------------------------------------------- |
| 32 | # Fixture: minimal repo + isolated identity store |
| 33 | # --------------------------------------------------------------------------- |
| 34 | |
| 35 | |
| 36 | @pytest.fixture() |
| 37 | def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: |
| 38 | """Minimal .muse/ repo with a pre-configured hub URL. |
| 39 | |
| 40 | The identity store is redirected to *tmp_path* so tests never touch |
| 41 | the real ``~/.muse/identity.toml``. |
| 42 | """ |
| 43 | dot_muse = muse_dir(tmp_path) |
| 44 | (dot_muse / "refs" / "heads").mkdir(parents=True) |
| 45 | (dot_muse / "objects").mkdir() |
| 46 | (dot_muse / "commits").mkdir() |
| 47 | (dot_muse / "snapshots").mkdir() |
| 48 | (dot_muse / "repo.json").write_text( |
| 49 | json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "midi"}) |
| 50 | ) |
| 51 | (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") |
| 52 | (dot_muse / "config.toml").write_text( |
| 53 | '[hub]\nurl = "https://musehub.ai"\n' |
| 54 | ) |
| 55 | monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) |
| 56 | monkeypatch.chdir(tmp_path) |
| 57 | |
| 58 | # Isolate the identity store. |
| 59 | fake_dir = tmp_path / "home" / ".muse" |
| 60 | fake_dir.mkdir(parents=True) |
| 61 | fake_file = fake_dir / "identity.toml" |
| 62 | monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir) |
| 63 | monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_file) |
| 64 | return tmp_path |
| 65 | |
| 66 | |
| 67 | # --------------------------------------------------------------------------- |
| 68 | # muse auth whoami |
| 69 | # --------------------------------------------------------------------------- |
| 70 | |
| 71 | |
| 72 | class TestAuthWhoami: |
| 73 | def _store_entry(self, hub: str = "https://musehub.ai") -> None: |
| 74 | entry: IdentityEntry = { |
| 75 | "type": "human", |
| 76 | "handle": "Alice", |
| 77 | "algorithm": "ed25519", |
| 78 | "fingerprint": "abc123fingerprint", |
| 79 | "hd_path": "m/1075233755'/0'/0'/0'/0'/0'", |
| 80 | } |
| 81 | save_identity(hub, entry) |
| 82 | |
| 83 | def test_whoami_shows_hub(self, repo: pathlib.Path) -> None: |
| 84 | self._store_entry() |
| 85 | result = runner.invoke(cli, ["auth", "whoami"]) |
| 86 | assert result.exit_code == 0 |
| 87 | assert "musehub.ai" in result.stderr |
| 88 | |
| 89 | def test_whoami_shows_type(self, repo: pathlib.Path) -> None: |
| 90 | self._store_entry() |
| 91 | result = runner.invoke(cli, ["auth", "whoami"]) |
| 92 | assert "human" in result.stderr |
| 93 | |
| 94 | def test_whoami_shows_handle(self, repo: pathlib.Path) -> None: |
| 95 | self._store_entry() |
| 96 | result = runner.invoke(cli, ["auth", "whoami"]) |
| 97 | assert "Alice" in result.stderr |
| 98 | |
| 99 | def test_whoami_json_output(self, repo: pathlib.Path) -> None: |
| 100 | self._store_entry() |
| 101 | result = runner.invoke(cli, ["auth", "whoami", "--json"]) |
| 102 | assert result.exit_code == 0 |
| 103 | data = json.loads(result.output) |
| 104 | assert data["type"] == "human" |
| 105 | assert data["handle"] == "Alice" |
| 106 | assert isinstance(data.get("key_set"), bool) |
| 107 | |
| 108 | def test_whoami_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None: |
| 109 | result = runner.invoke(cli, ["auth", "whoami"]) |
| 110 | assert result.exit_code != 0 |
| 111 | |
| 112 | def test_whoami_hub_option_selects_specific_hub(self, repo: pathlib.Path) -> None: |
| 113 | save_identity("https://staging.musehub.ai", {"type": "agent", "handle": "bot"}) |
| 114 | result = runner.invoke(cli, ["auth", "whoami", "--hub", "https://staging.musehub.ai"]) |
| 115 | assert result.exit_code == 0 |
| 116 | assert "staging.musehub.ai" in result.stderr |
| 117 | |
| 118 | def test_whoami_all_lists_all_hubs(self, repo: pathlib.Path) -> None: |
| 119 | self._store_entry("https://hub1.example.com") |
| 120 | self._store_entry("https://hub2.example.com") |
| 121 | result = runner.invoke(cli, ["auth", "whoami", "--all"]) |
| 122 | assert result.exit_code == 0 |
| 123 | assert "hub1.example.com" in result.stderr |
| 124 | assert "hub2.example.com" in result.stderr |
| 125 | |
| 126 | def test_whoami_all_no_identities_exits_nonzero(self, repo: pathlib.Path) -> None: |
| 127 | result = runner.invoke(cli, ["auth", "whoami", "--all"]) |
| 128 | assert result.exit_code != 0 |
| 129 | |
| 130 | def test_whoami_capabilities_shown(self, repo: pathlib.Path) -> None: |
| 131 | entry: IdentityEntry = { |
| 132 | "type": "agent", |
| 133 | "handle": "worker", |
| 134 | "capabilities": ["read:*", "write:midi"], |
| 135 | } |
| 136 | save_identity("https://musehub.ai", entry) |
| 137 | result = runner.invoke(cli, ["auth", "whoami"]) |
| 138 | assert "read:*" in result.stderr or "write:midi" in result.stderr |
| 139 | |
| 140 | def test_whoami_key_set_is_bool(self, repo: pathlib.Path) -> None: |
| 141 | self._store_entry() |
| 142 | result = runner.invoke(cli, ["auth", "whoami", "--json"]) |
| 143 | assert result.exit_code == 0 |
| 144 | raw = result.output |
| 145 | assert '"key_set": true' in raw or '"key_set":true' in raw |
| 146 | assert '"key_set": "true"' not in raw |
| 147 | |
| 148 | def test_whoami_all_json_is_single_array(self, repo: pathlib.Path) -> None: |
| 149 | """--all --json must emit one JSON envelope with an identities array.""" |
| 150 | save_identity("https://hub-a.example.com", {"type": "human", "handle": "a"}) |
| 151 | save_identity("https://hub-b.example.com", {"type": "agent", "handle": "b"}) |
| 152 | result = runner.invoke(cli, ["auth", "whoami", "--all", "--json"]) |
| 153 | assert result.exit_code == 0 |
| 154 | parsed = json.loads(result.output) # would raise if multiple top-level values |
| 155 | identities = parsed["identities"] |
| 156 | assert isinstance(identities, list) |
| 157 | assert len(identities) == 2 |
| 158 | |
| 159 | def test_whoami_ansi_in_fingerprint_stripped(self, repo: pathlib.Path) -> None: |
| 160 | """ANSI escape sequences in stored fingerprint must not appear in text output.""" |
| 161 | import unittest.mock |
| 162 | malicious_entry: IdentityEntry = { |
| 163 | "type": "human", |
| 164 | "handle": "alice", |
| 165 | "fingerprint": "\x1b[31mmalicious-fp\x1b[0m", |
| 166 | } |
| 167 | with unittest.mock.patch("muse.core.identity._load_all", return_value={"musehub.ai": malicious_entry}): |
| 168 | result = runner.invoke(cli, ["auth", "whoami"]) |
| 169 | assert result.exit_code == 0 |
| 170 | assert "\x1b" not in result.output |
| 171 | |
| 172 | def test_whoami_short_flags_accepted(self, repo: pathlib.Path) -> None: |
| 173 | """-j and -a short flags work.""" |
| 174 | self._store_entry() |
| 175 | result = runner.invoke(cli, ["auth", "whoami", "-j"]) |
| 176 | assert result.exit_code == 0 |
| 177 | json.loads(result.output) |
| 178 | |
| 179 | save_identity("https://hub-x.example.com", {"type": "agent", "handle": "bot"}) |
| 180 | result2 = runner.invoke(cli, ["auth", "whoami", "-a"]) |
| 181 | assert result2.exit_code == 0 |
| 182 | assert "musehub.ai" in result2.stderr or "hub-x.example.com" in result2.stderr |
| 183 | |
| 184 | |
| 185 | # --------------------------------------------------------------------------- |
| 186 | # muse auth whoami — global config fallback (regression: "no url provided") |
| 187 | # --------------------------------------------------------------------------- |
| 188 | |
| 189 | |
| 190 | class TestWhoamiGlobalConfigFallback: |
| 191 | """Regression tests for the bug where `muse auth whoami` (no --hub) fails |
| 192 | with "No hub URL provided" even when ~/.muse/config.toml has [hub] url set. |
| 193 | |
| 194 | Root cause: get_hub_url() only checked <repo>/.muse/config.toml and never |
| 195 | fell back to the global ~/.muse/config.toml. |
| 196 | """ |
| 197 | |
| 198 | def test_whoami_reads_hub_from_global_config( |
| 199 | self, |
| 200 | tmp_path: pathlib.Path, |
| 201 | monkeypatch: pytest.MonkeyPatch, |
| 202 | ) -> None: |
| 203 | """whoami without --hub succeeds when ~/.muse/config.toml has [hub] url.""" |
| 204 | # Simulate running from a directory with NO repo .muse/ |
| 205 | outside_dir = tmp_path / "not-a-repo" |
| 206 | outside_dir.mkdir() |
| 207 | monkeypatch.chdir(outside_dir) |
| 208 | |
| 209 | # Global config at ~/.muse/config.toml with a hub URL |
| 210 | fake_home = tmp_path / "home" |
| 211 | fake_muse = fake_home / ".muse" |
| 212 | fake_muse.mkdir(parents=True) |
| 213 | (fake_muse / "config.toml").write_text('[hub]\nurl = "https://staging.musehub.ai"\n') |
| 214 | |
| 215 | # Redirect global config and identity store to our fake home |
| 216 | monkeypatch.setattr("muse.cli.config._GLOBAL_CONFIG_FILE", fake_muse / "config.toml") |
| 217 | identity_file = fake_muse / "identity.toml" |
| 218 | monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_muse) |
| 219 | monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", identity_file) |
| 220 | |
| 221 | # Store identity for the hub URL that the global config references |
| 222 | entry: IdentityEntry = { |
| 223 | "type": "human", |
| 224 | "handle": "gabriel", |
| 225 | "algorithm": "ed25519", |
| 226 | "fingerprint": "abc123", |
| 227 | } |
| 228 | save_identity("https://staging.musehub.ai", entry) |
| 229 | |
| 230 | result = runner.invoke(cli, ["auth", "whoami"]) |
| 231 | assert result.exit_code == 0, f"Expected exit 0, got {result.exit_code}:\n{result.stderr}" |
| 232 | assert "staging.musehub.ai" in result.stderr |
| 233 | |
| 234 | def test_whoami_global_config_fallback_not_used_when_repo_config_present( |
| 235 | self, |
| 236 | tmp_path: pathlib.Path, |
| 237 | monkeypatch: pytest.MonkeyPatch, |
| 238 | ) -> None: |
| 239 | """Repo-local .muse/config.toml takes precedence over the global config.""" |
| 240 | # Repo with its own hub config |
| 241 | repo_dir = tmp_path / "my-repo" |
| 242 | dot_muse = muse_dir(repo_dir) |
| 243 | (dot_muse / "refs" / "heads").mkdir(parents=True) |
| 244 | (dot_muse / "config.toml").write_text('[hub]\nurl = "https://musehub.ai"\n') |
| 245 | monkeypatch.chdir(repo_dir) |
| 246 | |
| 247 | # Global config pointing at a DIFFERENT hub |
| 248 | fake_home = tmp_path / "home" |
| 249 | fake_muse = fake_home / ".muse" |
| 250 | fake_muse.mkdir(parents=True) |
| 251 | (fake_muse / "config.toml").write_text('[hub]\nurl = "https://staging.musehub.ai"\n') |
| 252 | monkeypatch.setattr("muse.cli.config._GLOBAL_CONFIG_FILE", fake_muse / "config.toml") |
| 253 | |
| 254 | identity_file = fake_muse / "identity.toml" |
| 255 | monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_muse) |
| 256 | monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", identity_file) |
| 257 | |
| 258 | entry: IdentityEntry = {"type": "human", "handle": "gabriel"} |
| 259 | save_identity("https://musehub.ai", entry) |
| 260 | |
| 261 | result = runner.invoke(cli, ["auth", "whoami"]) |
| 262 | assert result.exit_code == 0 |
| 263 | assert "musehub.ai" in result.stderr |
| 264 | |
| 265 | |
| 266 | # --------------------------------------------------------------------------- |
| 267 | # muse auth logout |
| 268 | # --------------------------------------------------------------------------- |
| 269 | |
| 270 | |
| 271 | class TestAuthLogout: |
| 272 | def _store(self, hub: str = "https://musehub.ai") -> None: |
| 273 | entry: IdentityEntry = {"type": "human", "handle": "alice"} |
| 274 | save_identity(hub, entry) |
| 275 | |
| 276 | def test_logout_removes_identity(self, repo: pathlib.Path) -> None: |
| 277 | self._store() |
| 278 | result = runner.invoke(cli, ["auth", "logout"]) |
| 279 | assert result.exit_code == 0 |
| 280 | assert load_identity("https://musehub.ai") is None |
| 281 | |
| 282 | def test_logout_shows_success_message(self, repo: pathlib.Path) -> None: |
| 283 | self._store() |
| 284 | result = runner.invoke(cli, ["auth", "logout"]) |
| 285 | assert "musehub.ai" in result.stderr or "Logged out" in result.stderr |
| 286 | |
| 287 | def test_logout_nothing_to_do_does_not_fail(self, repo: pathlib.Path) -> None: |
| 288 | result = runner.invoke(cli, ["auth", "logout"]) |
| 289 | assert result.exit_code == 0 |
| 290 | assert "nothing" in result.stderr.lower() or "nothing to do" in result.stderr.lower() |
| 291 | |
| 292 | def test_logout_hub_option_removes_specific_hub(self, repo: pathlib.Path) -> None: |
| 293 | self._store("https://hub1.example.com") |
| 294 | self._store("https://hub2.example.com") |
| 295 | result = runner.invoke(cli, ["auth", "logout", "--hub", "https://hub1.example.com"]) |
| 296 | assert result.exit_code == 0 |
| 297 | assert load_identity("https://hub1.example.com") is None |
| 298 | assert load_identity("https://hub2.example.com") is not None |
| 299 | |
| 300 | def test_logout_all_removes_all_identities(self, repo: pathlib.Path) -> None: |
| 301 | self._store("https://hub1.example.com") |
| 302 | self._store("https://hub2.example.com") |
| 303 | result = runner.invoke(cli, ["auth", "logout", "--all"]) |
| 304 | assert result.exit_code == 0 |
| 305 | assert not list_all_identities() |
| 306 | |
| 307 | def test_logout_all_reports_count(self, repo: pathlib.Path) -> None: |
| 308 | self._store("https://hub1.example.com") |
| 309 | self._store("https://hub2.example.com") |
| 310 | result = runner.invoke(cli, ["auth", "logout", "--all"]) |
| 311 | assert "2" in result.stderr |
| 312 | |
| 313 | def test_logout_all_no_identities_succeeds(self, repo: pathlib.Path) -> None: |
| 314 | result = runner.invoke(cli, ["auth", "logout", "--all"]) |
| 315 | assert result.exit_code == 0 |
| 316 | |
| 317 | def test_logout_fails_without_hub_source( |
| 318 | self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 319 | ) -> None: |
| 320 | """With no hub in config and no --hub flag, logout should fail.""" |
| 321 | dot_muse = muse_dir(tmp_path) |
| 322 | dot_muse.mkdir() |
| 323 | (dot_muse / "config.toml").write_text("") |
| 324 | (dot_muse / "repo.json").write_text( |
| 325 | json.dumps({"repo_id": "r", "schema_version": __version__, "domain": "midi"}) |
| 326 | ) |
| 327 | (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") |
| 328 | monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) |
| 329 | monkeypatch.chdir(tmp_path) |
| 330 | fake_dir = tmp_path / "home" / ".muse" |
| 331 | fake_dir.mkdir(parents=True) |
| 332 | monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir) |
| 333 | monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_dir / "identity.toml") |
| 334 | result = runner.invoke(cli, ["auth", "logout"]) |
| 335 | assert result.exit_code != 0 |