gabriel / muse public
test_cli_auth.py python
334 lines 13.8 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 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 "algorithm": "ed25519",
77 "fingerprint": "abc123fingerprint",
78 "hd_path": "m/1075233755'/0'/0'/0'/0'/0'",
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"})
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 "capabilities": ["read:*", "write:midi"],
134 }
135 save_identity("https://musehub.ai", entry)
136 result = runner.invoke(cli, ["auth", "whoami"])
137 assert "read:*" in result.output or "write:midi" in result.output
138
139 def test_whoami_key_set_is_bool(self, repo: pathlib.Path) -> None:
140 self._store_entry()
141 result = runner.invoke(cli, ["auth", "whoami", "--json"])
142 assert result.exit_code == 0
143 raw = result.output
144 assert '"key_set": true' in raw or '"key_set":true' in raw
145 assert '"key_set": "true"' not in raw
146
147 def test_whoami_all_json_is_single_array(self, repo: pathlib.Path) -> None:
148 """--all --json must emit one JSON envelope with an identities array."""
149 save_identity("https://hub-a.example.com", {"type": "human", "handle": "a"})
150 save_identity("https://hub-b.example.com", {"type": "agent", "handle": "b"})
151 result = runner.invoke(cli, ["auth", "whoami", "--all", "--json"])
152 assert result.exit_code == 0
153 parsed = json.loads(result.output) # would raise if multiple top-level values
154 identities = parsed["identities"]
155 assert isinstance(identities, list)
156 assert len(identities) == 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"})
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 "algorithm": "ed25519",
225 "fingerprint": "abc123",
226 }
227 save_identity("https://staging.musehub.ai", entry)
228
229 result = runner.invoke(cli, ["auth", "whoami"])
230 assert result.exit_code == 0, f"Expected exit 0, got {result.exit_code}:\n{result.output}"
231 assert "staging.musehub.ai" in result.output
232
233 def test_whoami_global_config_fallback_not_used_when_repo_config_present(
234 self,
235 tmp_path: pathlib.Path,
236 monkeypatch: pytest.MonkeyPatch,
237 ) -> None:
238 """Repo-local .muse/config.toml takes precedence over the global config."""
239 # Repo with its own hub config
240 repo_dir = tmp_path / "my-repo"
241 muse_dir = repo_dir / ".muse"
242 (muse_dir / "refs" / "heads").mkdir(parents=True)
243 (muse_dir / "config.toml").write_text('[hub]\nurl = "https://musehub.ai"\n')
244 monkeypatch.chdir(repo_dir)
245
246 # Global config pointing at a DIFFERENT hub
247 fake_home = tmp_path / "home"
248 fake_muse = fake_home / ".muse"
249 fake_muse.mkdir(parents=True)
250 (fake_muse / "config.toml").write_text('[hub]\nurl = "https://staging.musehub.ai"\n')
251 monkeypatch.setattr("muse.cli.config._GLOBAL_CONFIG_FILE", fake_muse / "config.toml")
252
253 identity_file = fake_muse / "identity.toml"
254 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_muse)
255 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", identity_file)
256
257 entry: IdentityEntry = {"type": "human", "handle": "gabriel"}
258 save_identity("https://musehub.ai", entry)
259
260 result = runner.invoke(cli, ["auth", "whoami"])
261 assert result.exit_code == 0
262 assert "musehub.ai" in result.output
263
264
265 # ---------------------------------------------------------------------------
266 # muse auth logout
267 # ---------------------------------------------------------------------------
268
269
270 class TestAuthLogout:
271 def _store(self, hub: str = "https://musehub.ai") -> None:
272 entry: IdentityEntry = {"type": "human", "handle": "alice"}
273 save_identity(hub, entry)
274
275 def test_logout_removes_identity(self, repo: pathlib.Path) -> None:
276 self._store()
277 result = runner.invoke(cli, ["auth", "logout"])
278 assert result.exit_code == 0
279 assert load_identity("https://musehub.ai") is None
280
281 def test_logout_shows_success_message(self, repo: pathlib.Path) -> None:
282 self._store()
283 result = runner.invoke(cli, ["auth", "logout"])
284 assert "musehub.ai" in result.output or "Logged out" in result.output
285
286 def test_logout_nothing_to_do_does_not_fail(self, repo: pathlib.Path) -> None:
287 result = runner.invoke(cli, ["auth", "logout"])
288 assert result.exit_code == 0
289 assert "nothing" in result.output.lower() or "nothing to do" in result.output.lower()
290
291 def test_logout_hub_option_removes_specific_hub(self, repo: pathlib.Path) -> None:
292 self._store("https://hub1.example.com")
293 self._store("https://hub2.example.com")
294 result = runner.invoke(cli, ["auth", "logout", "--hub", "https://hub1.example.com"])
295 assert result.exit_code == 0
296 assert load_identity("https://hub1.example.com") is None
297 assert load_identity("https://hub2.example.com") is not None
298
299 def test_logout_all_removes_all_identities(self, repo: pathlib.Path) -> None:
300 self._store("https://hub1.example.com")
301 self._store("https://hub2.example.com")
302 result = runner.invoke(cli, ["auth", "logout", "--all"])
303 assert result.exit_code == 0
304 assert not list_all_identities()
305
306 def test_logout_all_reports_count(self, repo: pathlib.Path) -> None:
307 self._store("https://hub1.example.com")
308 self._store("https://hub2.example.com")
309 result = runner.invoke(cli, ["auth", "logout", "--all"])
310 assert "2" in result.output
311
312 def test_logout_all_no_identities_succeeds(self, repo: pathlib.Path) -> None:
313 result = runner.invoke(cli, ["auth", "logout", "--all"])
314 assert result.exit_code == 0
315
316 def test_logout_fails_without_hub_source(
317 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
318 ) -> None:
319 """With no hub in config and no --hub flag, logout should fail."""
320 muse_dir = tmp_path / ".muse"
321 muse_dir.mkdir()
322 (muse_dir / "config.toml").write_text("")
323 (muse_dir / "repo.json").write_text(
324 json.dumps({"repo_id": "r", "schema_version": __version__, "domain": "midi"})
325 )
326 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
327 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
328 monkeypatch.chdir(tmp_path)
329 fake_dir = tmp_path / "home" / ".muse"
330 fake_dir.mkdir(parents=True)
331 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir)
332 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_dir / "identity.toml")
333 result = runner.invoke(cli, ["auth", "logout"])
334 assert result.exit_code != 0
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago