gabriel / muse public
test_cli_auth.py python
337 lines 14.1 KB
8aa515d5 refactor: consolidate schema_version to single source of truth Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 """Tests for `muse auth` CLI commands — login, 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 getpass.getpass is mocked for tests that exercise the interactive token
8 prompt path, so tests run fully non-interactively.
9 """
10
11 from __future__ import annotations
12
13 import json
14 import pathlib
15 import unittest.mock
16
17 import pytest
18 from typer.testing import CliRunner
19
20 from muse._version import __version__
21 from muse.cli.app import cli
22 from muse.cli.config import get_hub_url, set_hub_url
23 from muse.core.identity import (
24 IdentityEntry,
25 get_identity_path,
26 list_all_identities,
27 load_identity,
28 save_identity,
29 )
30
31 runner = CliRunner()
32
33
34 # ---------------------------------------------------------------------------
35 # Fixture: minimal repo + isolated identity store
36 # ---------------------------------------------------------------------------
37
38
39 @pytest.fixture()
40 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
41 """Minimal .muse/ repo with a pre-configured hub URL.
42
43 The identity store is redirected to *tmp_path* so tests never touch
44 the real ``~/.muse/identity.toml``.
45 """
46 muse_dir = tmp_path / ".muse"
47 (muse_dir / "refs" / "heads").mkdir(parents=True)
48 (muse_dir / "objects").mkdir()
49 (muse_dir / "commits").mkdir()
50 (muse_dir / "snapshots").mkdir()
51 (muse_dir / "repo.json").write_text(
52 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "midi"})
53 )
54 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
55 (muse_dir / "config.toml").write_text(
56 '[hub]\nurl = "https://musehub.ai"\n'
57 )
58 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
59 monkeypatch.chdir(tmp_path)
60
61 # Isolate the identity store.
62 fake_dir = tmp_path / "home" / ".muse"
63 fake_dir.mkdir(parents=True)
64 fake_file = fake_dir / "identity.toml"
65 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir)
66 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_file)
67 return tmp_path
68
69
70 # ---------------------------------------------------------------------------
71 # muse auth login
72 # ---------------------------------------------------------------------------
73
74
75 class TestAuthLogin:
76 def test_login_with_token_flag(self, repo: pathlib.Path) -> None:
77 result = runner.invoke(cli, ["auth", "login", "--token", "mytoken123"])
78 assert result.exit_code == 0
79 entry = load_identity("https://musehub.ai")
80 assert entry is not None
81 assert entry.get("token") == "mytoken123"
82
83 def test_login_stores_human_type_by_default(self, repo: pathlib.Path) -> None:
84 runner.invoke(cli, ["auth", "login", "--token", "tok"])
85 entry = load_identity("https://musehub.ai")
86 assert entry is not None
87 assert entry.get("type") == "human"
88
89 def test_login_stores_agent_type_with_flag(self, repo: pathlib.Path) -> None:
90 runner.invoke(cli, ["auth", "login", "--token", "tok", "--agent"])
91 entry = load_identity("https://musehub.ai")
92 assert entry is not None
93 assert entry.get("type") == "agent"
94
95 def test_login_stores_name(self, repo: pathlib.Path) -> None:
96 runner.invoke(cli, ["auth", "login", "--token", "tok", "--name", "Alice"])
97 entry = load_identity("https://musehub.ai")
98 assert entry is not None
99 assert entry.get("name") == "Alice"
100
101 def test_login_stores_id(self, repo: pathlib.Path) -> None:
102 runner.invoke(cli, ["auth", "login", "--token", "tok", "--id", "usr_abc123"])
103 entry = load_identity("https://musehub.ai")
104 assert entry is not None
105 assert entry.get("id") == "usr_abc123"
106
107 def test_login_hub_option_overrides_config(self, repo: pathlib.Path) -> None:
108 result = runner.invoke(
109 cli, ["auth", "login", "--token", "tok", "--hub", "https://staging.musehub.ai"]
110 )
111 assert result.exit_code == 0
112 entry = load_identity("https://staging.musehub.ai")
113 assert entry is not None
114 assert entry.get("token") == "tok"
115
116 def test_login_env_var_accepted_silently(
117 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
118 ) -> None:
119 monkeypatch.setenv("MUSE_TOKEN", "env_token_value")
120 result = runner.invoke(cli, ["auth", "login"])
121 assert result.exit_code == 0
122 # Should NOT warn about shell history exposure when token comes from env.
123 assert "shell history" not in result.output
124 assert "⚠️" not in result.output or "MUSE_TOKEN" not in result.output
125 entry = load_identity("https://musehub.ai")
126 assert entry is not None
127 assert entry.get("token") == "env_token_value"
128
129 def test_login_warns_when_token_passed_via_cli_flag(
130 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
131 ) -> None:
132 monkeypatch.delenv("MUSE_TOKEN", raising=False)
133 result = runner.invoke(cli, ["auth", "login", "--token", "plaintext_token"])
134 # Warning goes to stderr; we just confirm exit code is 0 (success) and
135 # that the token was stored, since CliRunner merges stdout/stderr.
136 assert result.exit_code == 0
137 entry = load_identity("https://musehub.ai")
138 assert entry is not None
139 assert entry.get("token") == "plaintext_token"
140
141 def test_login_interactive_prompt(
142 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
143 ) -> None:
144 """When no token source is given, getpass.getpass is called."""
145 monkeypatch.delenv("MUSE_TOKEN", raising=False)
146 with unittest.mock.patch("getpass.getpass", return_value="prompted_token"):
147 result = runner.invoke(cli, ["auth", "login"])
148 assert result.exit_code == 0
149 entry = load_identity("https://musehub.ai")
150 assert entry is not None
151 assert entry.get("token") == "prompted_token"
152
153 def test_login_fails_without_hub(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
154 """With no hub in config and no --hub flag, login should fail."""
155 muse_dir = tmp_path / ".muse"
156 muse_dir.mkdir()
157 (muse_dir / "config.toml").write_text("") # no [hub] section
158 (muse_dir / "repo.json").write_text(
159 json.dumps({"repo_id": "r", "schema_version": __version__, "domain": "midi"})
160 )
161 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
162 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
163 monkeypatch.chdir(tmp_path)
164 monkeypatch.delenv("MUSE_TOKEN", raising=False)
165 fake_dir = tmp_path / "home" / ".muse"
166 fake_dir.mkdir(parents=True)
167 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir)
168 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_dir / "identity.toml")
169 result = runner.invoke(cli, ["auth", "login", "--token", "tok"])
170 assert result.exit_code != 0
171 assert "hub" in result.output.lower()
172
173 def test_login_success_message_shown(self, repo: pathlib.Path) -> None:
174 result = runner.invoke(cli, ["auth", "login", "--token", "tok", "--name", "Bob"])
175 assert result.exit_code == 0
176 assert "Bob" in result.output or "Authenticated" in result.output
177
178
179 # ---------------------------------------------------------------------------
180 # muse auth whoami
181 # ---------------------------------------------------------------------------
182
183
184 class TestAuthWhoami:
185 def _store_entry(self, hub: str = "https://musehub.ai") -> None:
186 entry: IdentityEntry = {
187 "type": "human",
188 "token": "tok_secret",
189 "name": "Alice",
190 "id": "usr_001",
191 }
192 save_identity(hub, entry)
193
194 def test_whoami_shows_hub(self, repo: pathlib.Path) -> None:
195 self._store_entry()
196 result = runner.invoke(cli, ["auth", "whoami"])
197 assert result.exit_code == 0
198 assert "musehub.ai" in result.output
199
200 def test_whoami_shows_type(self, repo: pathlib.Path) -> None:
201 self._store_entry()
202 result = runner.invoke(cli, ["auth", "whoami"])
203 assert "human" in result.output
204
205 def test_whoami_shows_name(self, repo: pathlib.Path) -> None:
206 self._store_entry()
207 result = runner.invoke(cli, ["auth", "whoami"])
208 assert "Alice" in result.output
209
210 def test_whoami_does_not_print_raw_token(self, repo: pathlib.Path) -> None:
211 self._store_entry()
212 result = runner.invoke(cli, ["auth", "whoami"])
213 assert "tok_secret" not in result.output
214
215 def test_whoami_shows_token_set_indicator(self, repo: pathlib.Path) -> None:
216 self._store_entry()
217 result = runner.invoke(cli, ["auth", "whoami"])
218 assert "set" in result.output.lower() or "***" in result.output
219
220 def test_whoami_json_output(self, repo: pathlib.Path) -> None:
221 self._store_entry()
222 result = runner.invoke(cli, ["auth", "whoami", "--json"])
223 assert result.exit_code == 0
224 data = json.loads(result.output)
225 assert data["type"] == "human"
226 assert data["name"] == "Alice"
227 assert data.get("token_set") in ("true", "false")
228
229 def test_whoami_json_does_not_include_raw_token(self, repo: pathlib.Path) -> None:
230 self._store_entry()
231 result = runner.invoke(cli, ["auth", "whoami", "--json"])
232 assert "tok_secret" not in result.output
233
234 def test_whoami_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None:
235 result = runner.invoke(cli, ["auth", "whoami"])
236 assert result.exit_code != 0
237
238 def test_whoami_hub_option_selects_specific_hub(self, repo: pathlib.Path) -> None:
239 save_identity("https://staging.musehub.ai", {"type": "agent", "token": "tok2", "name": "bot"})
240 result = runner.invoke(cli, ["auth", "whoami", "--hub", "https://staging.musehub.ai"])
241 assert result.exit_code == 0
242 assert "staging.musehub.ai" in result.output
243
244 def test_whoami_all_lists_all_hubs(self, repo: pathlib.Path) -> None:
245 self._store_entry("https://hub1.example.com")
246 self._store_entry("https://hub2.example.com")
247 result = runner.invoke(cli, ["auth", "whoami", "--all"])
248 assert result.exit_code == 0
249 assert "hub1.example.com" in result.output
250 assert "hub2.example.com" in result.output
251
252 def test_whoami_all_no_identities_exits_nonzero(self, repo: pathlib.Path) -> None:
253 result = runner.invoke(cli, ["auth", "whoami", "--all"])
254 assert result.exit_code != 0
255
256 def test_whoami_capabilities_shown(self, repo: pathlib.Path) -> None:
257 entry: IdentityEntry = {
258 "type": "agent",
259 "token": "tok",
260 "name": "worker",
261 "capabilities": ["read:*", "write:midi"],
262 }
263 save_identity("https://musehub.ai", entry)
264 result = runner.invoke(cli, ["auth", "whoami"])
265 assert "read:*" in result.output or "write:midi" in result.output
266
267
268 # ---------------------------------------------------------------------------
269 # muse auth logout
270 # ---------------------------------------------------------------------------
271
272
273 class TestAuthLogout:
274 def _store(self, hub: str = "https://musehub.ai") -> None:
275 entry: IdentityEntry = {"type": "human", "token": "tok"}
276 save_identity(hub, entry)
277
278 def test_logout_removes_identity(self, repo: pathlib.Path) -> None:
279 self._store()
280 result = runner.invoke(cli, ["auth", "logout"])
281 assert result.exit_code == 0
282 assert load_identity("https://musehub.ai") is None
283
284 def test_logout_shows_success_message(self, repo: pathlib.Path) -> None:
285 self._store()
286 result = runner.invoke(cli, ["auth", "logout"])
287 assert "musehub.ai" in result.output or "Logged out" in result.output
288
289 def test_logout_nothing_to_do_does_not_fail(self, repo: pathlib.Path) -> None:
290 result = runner.invoke(cli, ["auth", "logout"])
291 assert result.exit_code == 0
292 assert "nothing" in result.output.lower() or "nothing to do" in result.output.lower()
293
294 def test_logout_hub_option_removes_specific_hub(self, repo: pathlib.Path) -> None:
295 self._store("https://hub1.example.com")
296 self._store("https://hub2.example.com")
297 result = runner.invoke(cli, ["auth", "logout", "--hub", "https://hub1.example.com"])
298 assert result.exit_code == 0
299 assert load_identity("https://hub1.example.com") is None
300 assert load_identity("https://hub2.example.com") is not None
301
302 def test_logout_all_removes_all_identities(self, repo: pathlib.Path) -> None:
303 self._store("https://hub1.example.com")
304 self._store("https://hub2.example.com")
305 result = runner.invoke(cli, ["auth", "logout", "--all"])
306 assert result.exit_code == 0
307 assert not list_all_identities()
308
309 def test_logout_all_reports_count(self, repo: pathlib.Path) -> None:
310 self._store("https://hub1.example.com")
311 self._store("https://hub2.example.com")
312 result = runner.invoke(cli, ["auth", "logout", "--all"])
313 assert "2" in result.output
314
315 def test_logout_all_no_identities_succeeds(self, repo: pathlib.Path) -> None:
316 result = runner.invoke(cli, ["auth", "logout", "--all"])
317 assert result.exit_code == 0
318
319 def test_logout_fails_without_hub_source(
320 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
321 ) -> None:
322 """With no hub in config and no --hub flag, logout should fail."""
323 muse_dir = tmp_path / ".muse"
324 muse_dir.mkdir()
325 (muse_dir / "config.toml").write_text("")
326 (muse_dir / "repo.json").write_text(
327 json.dumps({"repo_id": "r", "schema_version": __version__, "domain": "midi"})
328 )
329 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
330 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
331 monkeypatch.chdir(tmp_path)
332 fake_dir = tmp_path / "home" / ".muse"
333 fake_dir.mkdir(parents=True)
334 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir)
335 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_dir / "identity.toml")
336 result = runner.invoke(cli, ["auth", "logout"])
337 assert result.exit_code != 0