gabriel / muse public
test_cli_auth.py python
335 lines 14.0 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 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 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
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