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