gabriel / muse public

test_cli_auth.py file-level

at sha256:2 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 Merge branch 'dev' into main · gabriel · Jun 17, 2026
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