test_cli_auth.py
python
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
26 days ago
| 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 | |
| 27 | runner = CliRunner() |
| 28 | |
| 29 | |
| 30 | # --------------------------------------------------------------------------- |
| 31 | # Fixture: minimal repo + isolated identity store |
| 32 | # --------------------------------------------------------------------------- |
| 33 | |
| 34 | |
| 35 | @pytest.fixture() |
| 36 | def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: |
| 37 | """Minimal .muse/ repo with a pre-configured hub URL. |
| 38 | |
| 39 | The identity store is redirected to *tmp_path* so tests never touch |
| 40 | the real ``~/.muse/identity.toml``. |
| 41 | """ |
| 42 | muse_dir = tmp_path / ".muse" |
| 43 | (muse_dir / "refs" / "heads").mkdir(parents=True) |
| 44 | (muse_dir / "objects").mkdir() |
| 45 | (muse_dir / "commits").mkdir() |
| 46 | (muse_dir / "snapshots").mkdir() |
| 47 | (muse_dir / "repo.json").write_text( |
| 48 | json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "midi"}) |
| 49 | ) |
| 50 | (muse_dir / "HEAD").write_text("ref: refs/heads/main\n") |
| 51 | (muse_dir / "config.toml").write_text( |
| 52 | '[hub]\nurl = "https://musehub.ai"\n' |
| 53 | ) |
| 54 | monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) |
| 55 | monkeypatch.chdir(tmp_path) |
| 56 | |
| 57 | # Isolate the identity store. |
| 58 | fake_dir = tmp_path / "home" / ".muse" |
| 59 | fake_dir.mkdir(parents=True) |
| 60 | fake_file = fake_dir / "identity.toml" |
| 61 | monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir) |
| 62 | monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_file) |
| 63 | return tmp_path |
| 64 | |
| 65 | |
| 66 | # --------------------------------------------------------------------------- |
| 67 | # muse auth whoami |
| 68 | # --------------------------------------------------------------------------- |
| 69 | |
| 70 | |
| 71 | class TestAuthWhoami: |
| 72 | def _store_entry(self, hub: str = "https://musehub.ai") -> None: |
| 73 | entry: IdentityEntry = { |
| 74 | "type": "human", |
| 75 | "handle": "Alice", |
| 76 | "key_path": "/fake/key.pem", |
| 77 | "algorithm": "ed25519", |
| 78 | "fingerprint": "abc123fingerprint", |
| 79 | } |
| 80 | save_identity(hub, entry) |
| 81 | |
| 82 | def test_whoami_shows_hub(self, repo: pathlib.Path) -> None: |
| 83 | self._store_entry() |
| 84 | result = runner.invoke(cli, ["auth", "whoami"]) |
| 85 | assert result.exit_code == 0 |
| 86 | assert "musehub.ai" in result.output |
| 87 | |
| 88 | def test_whoami_shows_type(self, repo: pathlib.Path) -> None: |
| 89 | self._store_entry() |
| 90 | result = runner.invoke(cli, ["auth", "whoami"]) |
| 91 | assert "human" in result.output |
| 92 | |
| 93 | def test_whoami_shows_handle(self, repo: pathlib.Path) -> None: |
| 94 | self._store_entry() |
| 95 | result = runner.invoke(cli, ["auth", "whoami"]) |
| 96 | assert "Alice" in result.output |
| 97 | |
| 98 | def test_whoami_json_output(self, repo: pathlib.Path) -> None: |
| 99 | self._store_entry() |
| 100 | result = runner.invoke(cli, ["auth", "whoami", "--json"]) |
| 101 | assert result.exit_code == 0 |
| 102 | data = json.loads(result.output) |
| 103 | assert data["type"] == "human" |
| 104 | assert data["handle"] == "Alice" |
| 105 | assert isinstance(data.get("key_set"), bool) |
| 106 | |
| 107 | def test_whoami_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None: |
| 108 | result = runner.invoke(cli, ["auth", "whoami"]) |
| 109 | assert result.exit_code != 0 |
| 110 | |
| 111 | def test_whoami_hub_option_selects_specific_hub(self, repo: pathlib.Path) -> None: |
| 112 | save_identity("https://staging.musehub.ai", {"type": "agent", "handle": "bot", "key_path": "/k"}) |
| 113 | result = runner.invoke(cli, ["auth", "whoami", "--hub", "https://staging.musehub.ai"]) |
| 114 | assert result.exit_code == 0 |
| 115 | assert "staging.musehub.ai" in result.output |
| 116 | |
| 117 | def test_whoami_all_lists_all_hubs(self, repo: pathlib.Path) -> None: |
| 118 | self._store_entry("https://hub1.example.com") |
| 119 | self._store_entry("https://hub2.example.com") |
| 120 | result = runner.invoke(cli, ["auth", "whoami", "--all"]) |
| 121 | assert result.exit_code == 0 |
| 122 | assert "hub1.example.com" in result.output |
| 123 | assert "hub2.example.com" in result.output |
| 124 | |
| 125 | def test_whoami_all_no_identities_exits_nonzero(self, repo: pathlib.Path) -> None: |
| 126 | result = runner.invoke(cli, ["auth", "whoami", "--all"]) |
| 127 | assert result.exit_code != 0 |
| 128 | |
| 129 | def test_whoami_capabilities_shown(self, repo: pathlib.Path) -> None: |
| 130 | entry: IdentityEntry = { |
| 131 | "type": "agent", |
| 132 | "handle": "worker", |
| 133 | "key_path": "/k", |
| 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.output or "write:midi" in result.output |
| 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 array, not multiple separate objects.""" |
| 150 | save_identity("https://hub-a.example.com", {"type": "human", "handle": "a", "key_path": "/k1"}) |
| 151 | save_identity("https://hub-b.example.com", {"type": "agent", "handle": "b", "key_path": "/k2"}) |
| 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 | assert isinstance(parsed, list) |
| 156 | assert len(parsed) == 2 |
| 157 | |
| 158 | def test_whoami_ansi_in_fingerprint_stripped(self, repo: pathlib.Path) -> None: |
| 159 | """ANSI escape sequences in stored fingerprint must not appear in text output.""" |
| 160 | import unittest.mock |
| 161 | evil_entry: IdentityEntry = { |
| 162 | "type": "human", |
| 163 | "handle": "alice", |
| 164 | "fingerprint": "\x1b[31mevil-fp\x1b[0m", |
| 165 | } |
| 166 | with unittest.mock.patch("muse.core.identity._load_all", return_value={"musehub.ai": evil_entry}): |
| 167 | result = runner.invoke(cli, ["auth", "whoami"]) |
| 168 | assert result.exit_code == 0 |
| 169 | assert "\x1b" not in result.output |
| 170 | |
| 171 | def test_whoami_short_flags_accepted(self, repo: pathlib.Path) -> None: |
| 172 | """-j and -a short flags work.""" |
| 173 | self._store_entry() |
| 174 | result = runner.invoke(cli, ["auth", "whoami", "-j"]) |
| 175 | assert result.exit_code == 0 |
| 176 | json.loads(result.output) |
| 177 | |
| 178 | save_identity("https://hub-x.example.com", {"type": "agent", "handle": "bot", "key_path": "/k"}) |
| 179 | result2 = runner.invoke(cli, ["auth", "whoami", "-a"]) |
| 180 | assert result2.exit_code == 0 |
| 181 | assert "musehub.ai" in result2.output or "hub-x.example.com" in result2.output |
| 182 | |
| 183 | |
| 184 | # --------------------------------------------------------------------------- |
| 185 | # muse auth whoami — global config fallback (regression: "no url provided") |
| 186 | # --------------------------------------------------------------------------- |
| 187 | |
| 188 | |
| 189 | class TestWhoamiGlobalConfigFallback: |
| 190 | """Regression tests for the bug where `muse auth whoami` (no --hub) fails |
| 191 | with "No hub URL provided" even when ~/.muse/config.toml has [hub] url set. |
| 192 | |
| 193 | Root cause: get_hub_url() only checked <repo>/.muse/config.toml and never |
| 194 | fell back to the global ~/.muse/config.toml. |
| 195 | """ |
| 196 | |
| 197 | def test_whoami_reads_hub_from_global_config( |
| 198 | self, |
| 199 | tmp_path: pathlib.Path, |
| 200 | monkeypatch: pytest.MonkeyPatch, |
| 201 | ) -> None: |
| 202 | """whoami without --hub succeeds when ~/.muse/config.toml has [hub] url.""" |
| 203 | # Simulate running from a directory with NO repo .muse/ |
| 204 | outside_dir = tmp_path / "not-a-repo" |
| 205 | outside_dir.mkdir() |
| 206 | monkeypatch.chdir(outside_dir) |
| 207 | |
| 208 | # Global config at ~/.muse/config.toml with a hub URL |
| 209 | fake_home = tmp_path / "home" |
| 210 | fake_muse = fake_home / ".muse" |
| 211 | fake_muse.mkdir(parents=True) |
| 212 | (fake_muse / "config.toml").write_text('[hub]\nurl = "https://staging.musehub.ai"\n') |
| 213 | |
| 214 | # Redirect global config and identity store to our fake home |
| 215 | monkeypatch.setattr("muse.cli.config._GLOBAL_CONFIG_FILE", fake_muse / "config.toml") |
| 216 | identity_file = fake_muse / "identity.toml" |
| 217 | monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_muse) |
| 218 | monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", identity_file) |
| 219 | |
| 220 | # Store identity for the hub URL that the global config references |
| 221 | entry: IdentityEntry = { |
| 222 | "type": "human", |
| 223 | "handle": "gabriel", |
| 224 | "key_path": "/fake/key.pem", |
| 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.output}" |
| 232 | assert "staging.musehub.ai" in result.output |
| 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 | muse_dir = repo_dir / ".muse" |
| 243 | (muse_dir / "refs" / "heads").mkdir(parents=True) |
| 244 | (muse_dir / "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", "key_path": "/k"} |
| 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.output |
| 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", "key_path": "/k"} |
| 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.output or "Logged out" in result.output |
| 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.output.lower() or "nothing to do" in result.output.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.output |
| 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 | muse_dir = tmp_path / ".muse" |
| 322 | muse_dir.mkdir() |
| 323 | (muse_dir / "config.toml").write_text("") |
| 324 | (muse_dir / "repo.json").write_text( |
| 325 | json.dumps({"repo_id": "r", "schema_version": __version__, "domain": "midi"}) |
| 326 | ) |
| 327 | (muse_dir / "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 |
File History
4 commits
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e
Merge branch 'dev' into main
Human
29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce
Merge branch 'dev' into main
Human
48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa
feat: Muse — version control for the agent era
Human
100 days ago