gabriel / muse public
test_cmd_auth_hardening.py python
723 lines 30.4 KB
Raw
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
1 """Comprehensive hardening tests for ``muse auth``.
2
3 Coverage
4 --------
5 Unit
6 - _hub_base_url: scheme validation, port handling, path stripping
7 - _json_post: scheme guard fires before network, HTTP error, URLError,
8 oversized response, non-dict response
9 - _sanitize_token_or_exit: valid token, empty, control char, too long
10 - _display_entry: JSON schema correctness, all fields present, stderr routing
11 - _resolve_hub: explicit → config → None
12
13 Integration (real fixture — identity.toml in tmp_path)
14 - run_whoami: no identity exits nonzero, --json schema correct, --all lists all
15 - run_logout: missing identity is not an error, --json schema, --all clears all
16
17 Security
18 - file:// hub URL rejected in _hub_base_url and _json_post
19 - ftp:// hub URL rejected
20 - ANSI in hub URL sanitized in error messages
21 - Token never echoed to stdout
22
23 E2E (via CliRunner + fixture)
24 - keygen: --json schema when crypto unavailable (ImportError handled gracefully)
25 - login: --json schema with all required keys
26 - whoami: --json schema with all required keys, token_set=true
27 - logout: --json schema ok, --json schema nothing_to_do
28 - --all flag for whoami and logout
29
30 Stress
31 - 8 concurrent logins to isolated identity files do not race
32 """
33
34 from __future__ import annotations
35
36 import json
37 import pathlib
38 import threading
39 import urllib.request
40 from typing import TYPE_CHECKING
41 from unittest.mock import MagicMock, patch
42
43 import pytest
44
45 from tests.cli_test_helper import CliRunner, InvokeResult
46
47 if TYPE_CHECKING:
48 pass
49
50 from muse.cli.commands.auth import (
51 _KeygenJson,
52 _LogoutJson,
53 _WhoamiJson,
54 )
55 from muse.core.identity import IdentityEntry, save_identity
56
57 cli = None
58 runner = CliRunner()
59
60 # ── fixture ───────────────────────────────────────────────────────────────────
61
62
63 @pytest.fixture
64 def identity_dir(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
65 """Isolated ~/.muse dir with a fresh identity.toml; no repo needed for auth."""
66 muse_home = tmp_path / ".muse"
67 muse_home.mkdir()
68 (muse_home / "identity.toml").write_text("")
69 monkeypatch.setenv("MUSE_HOME", str(muse_home))
70 monkeypatch.setattr(
71 "muse.core.identity._IDENTITY_FILE",
72 muse_home / "identity.toml",
73 )
74 return muse_home
75
76
77 @pytest.fixture
78 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
79 """Minimal .muse/ repo with identity file, used for CLI E2E tests."""
80 from muse._version import __version__
81
82 muse_dir = tmp_path / ".muse"
83 for sub in ("refs/heads", "objects", "commits", "snapshots"):
84 (muse_dir / sub).mkdir(parents=True, exist_ok=True)
85 (muse_dir / "repo.json").write_text(
86 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
87 )
88 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
89 (muse_dir / "refs" / "heads" / "main").write_text("")
90 (muse_dir / "config.toml").write_text("")
91 muse_home = tmp_path / ".muse-home"
92 muse_home.mkdir()
93 (muse_home / "identity.toml").write_text("")
94 monkeypatch.setenv("MUSE_HOME", str(muse_home))
95 monkeypatch.setattr(
96 "muse.core.identity._IDENTITY_FILE",
97 muse_home / "identity.toml",
98 )
99 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
100 monkeypatch.chdir(tmp_path)
101 return tmp_path
102
103
104 def _json_whoami(result: InvokeResult) -> _WhoamiJson:
105 for line in result.output.splitlines():
106 stripped = line.strip()
107 if stripped.startswith("{"):
108 data: _WhoamiJson = json.loads(stripped)
109 return data
110 raise ValueError(f"No JSON line in output:\n{result.output!r}")
111
112
113 def _json_logout(result: InvokeResult) -> _LogoutJson:
114 for line in result.output.splitlines():
115 stripped = line.strip()
116 if stripped.startswith("{"):
117 data: _LogoutJson = json.loads(stripped)
118 return data
119 raise ValueError(f"No JSON line in output:\n{result.output!r}")
120
121
122 def _json_keygen(result: InvokeResult) -> _KeygenJson:
123 for line in result.output.splitlines():
124 stripped = line.strip()
125 if stripped.startswith("{"):
126 data: _KeygenJson = json.loads(stripped)
127 return data
128 raise ValueError(f"No JSON line in output:\n{result.output!r}")
129
130
131 # ── Unit: _hub_base_url ───────────────────────────────────────────────────────
132
133
134 class TestHubBaseUrl:
135 def test_https_path_stripped(self) -> None:
136 from muse.cli.commands.auth import _hub_base_url
137 assert _hub_base_url("https://musehub.ai/gabriel/muse") == "https://musehub.ai"
138
139 def test_http_localhost_port_preserved(self) -> None:
140 from muse.cli.commands.auth import _hub_base_url
141 assert _hub_base_url("http://localhost:10003") == "http://localhost:10003"
142
143 def test_explicit_port_preserved(self) -> None:
144 from muse.cli.commands.auth import _hub_base_url
145 assert _hub_base_url("https://hub.example.com:8443/owner/repo") == "https://hub.example.com:8443"
146
147 def test_file_scheme_exits_nonzero(self) -> None:
148 from muse.cli.commands.auth import _hub_base_url
149 from muse.core.errors import ExitCode
150 with pytest.raises(SystemExit) as exc_info:
151 _hub_base_url("file:///etc/passwd")
152 assert exc_info.value.code == ExitCode.USER_ERROR
153
154 def test_ftp_scheme_exits_nonzero(self) -> None:
155 from muse.cli.commands.auth import _hub_base_url
156 with pytest.raises(SystemExit):
157 _hub_base_url("ftp://ftp.example.com/repo")
158
159 def test_data_scheme_exits_nonzero(self) -> None:
160 from muse.cli.commands.auth import _hub_base_url
161 with pytest.raises(SystemExit):
162 _hub_base_url("data:text/plain,evil")
163
164
165 # ── Unit: _json_post ──────────────────────────────────────────────────────────
166
167
168 class TestJsonPost:
169 def test_file_scheme_rejected_before_network(self) -> None:
170 """_json_post must not make a network request for file:// URLs."""
171 from muse.cli.commands.auth import _json_post_raw as _json_post
172 with patch("urllib.request.urlopen") as mock_open:
173 with pytest.raises(SystemExit):
174 _json_post("file:///etc/passwd", "/api/test", {})
175 mock_open.assert_not_called()
176
177 def test_ftp_scheme_rejected_before_network(self) -> None:
178 from muse.cli.commands.auth import _json_post_raw as _json_post
179 with patch("urllib.request.urlopen") as mock_open:
180 with pytest.raises(SystemExit):
181 _json_post("ftp://example.com", "/api/test", {})
182 mock_open.assert_not_called()
183
184 def test_http_error_exits_user_error(self) -> None:
185 import urllib.error
186 from muse.cli.commands.auth import _json_post_raw as _json_post
187 exc = urllib.error.HTTPError(
188 url="", code=401, msg="Unauthorized", hdrs=MagicMock(), fp=None
189 )
190 # fp must be None to avoid type conflicts; HTTPError accepts None here
191 with patch("urllib.request.urlopen", side_effect=exc):
192 with pytest.raises(SystemExit):
193 _json_post("http://hub.local", "/api/test", {})
194
195 def test_url_error_exits_user_error(self) -> None:
196 import urllib.error
197 from muse.cli.commands.auth import _json_post_raw as _json_post
198 exc = urllib.error.URLError(reason="connection refused")
199 with patch("urllib.request.urlopen", side_effect=exc):
200 with pytest.raises(SystemExit):
201 _json_post("http://hub.local", "/api/test", {})
202
203 def test_oversized_response_exits(self) -> None:
204 from muse.cli.commands.auth import _MAX_RESPONSE_BYTES, _json_post_raw as _json_post
205 mock_resp = MagicMock()
206 mock_resp.__enter__ = lambda s: s
207 mock_resp.__exit__ = MagicMock(return_value=False)
208 mock_resp.read.return_value = b"x" * (_MAX_RESPONSE_BYTES + 2)
209 with patch("urllib.request.urlopen", return_value=mock_resp):
210 with pytest.raises(SystemExit):
211 _json_post("http://hub.local", "/api/test", {})
212
213 def test_non_dict_response_exits(self) -> None:
214 from muse.cli.commands.auth import _json_post_raw as _json_post
215 mock_resp = MagicMock()
216 mock_resp.__enter__ = lambda s: s
217 mock_resp.__exit__ = MagicMock(return_value=False)
218 mock_resp.read.return_value = b'["not", "a", "dict"]'
219 with patch("urllib.request.urlopen", return_value=mock_resp):
220 with pytest.raises(SystemExit):
221 _json_post("http://hub.local", "/api/test", {})
222
223 def test_none_values_stripped_from_payload(self) -> None:
224 """None-valued keys must not appear in the serialised request body."""
225 from muse.cli.commands.auth import _json_post_raw as _json_post
226 captured: list[bytes] = []
227
228 def _capture(req: urllib.request.Request, timeout: float) -> MagicMock:
229 data: bytes = req.data if isinstance(req.data, bytes) else b""
230 captured.append(data)
231 mock_resp = MagicMock()
232 mock_resp.__enter__ = lambda s: s
233 mock_resp.__exit__ = MagicMock(return_value=False)
234 mock_resp.read.return_value = b'{"ok": true}'
235 return mock_resp
236
237 with patch("urllib.request.urlopen", side_effect=_capture):
238 _json_post("http://hub.local", "/test", {"a": "1", "b": None, "c": "3"})
239
240 sent = json.loads(captured[0])
241 assert "b" not in sent
242 assert sent["a"] == "1"
243 assert sent["c"] == "3"
244
245
246 # ── Unit: _display_entry ──────────────────────────────────────────────────────
247
248
249 class TestDisplayEntry:
250 def _make_entry(self) -> IdentityEntry:
251 return {
252 "type": "human",
253 "handle": "alice",
254 "key_path": "/home/alice/.muse/keys/hub.pem",
255 "algorithm": "ed25519",
256 "fingerprint": "abc123fingerprint456",
257 }
258
259 def test_json_output_all_keys_present(self, capsys: pytest.CaptureFixture[str]) -> None:
260 from muse.cli.commands.auth import _display_entry
261 _display_entry("hub.example.com", self._make_entry(), json_output=True)
262 data = json.loads(capsys.readouterr().out)
263 for key in ("hub", "type", "handle", "fingerprint", "key_set", "capabilities"):
264 assert key in data, f"Missing key: {key}"
265
266 def test_json_key_set_true(self, capsys: pytest.CaptureFixture[str]) -> None:
267 from muse.cli.commands.auth import _display_entry
268 _display_entry("hub.example.com", self._make_entry(), json_output=True)
269 data = json.loads(capsys.readouterr().out)
270 assert data["key_set"] is True
271 assert isinstance(data["key_set"], bool)
272
273 def test_json_no_key_key_set_false(self, capsys: pytest.CaptureFixture[str]) -> None:
274 from muse.cli.commands.auth import _display_entry
275 entry: IdentityEntry = {"type": "agent"}
276 _display_entry("hub.example.com", entry, json_output=True)
277 data = json.loads(capsys.readouterr().out)
278 assert data["key_set"] is False
279 assert isinstance(data["key_set"], bool)
280
281 def test_json_goes_to_stdout_not_stderr(self, capsys: pytest.CaptureFixture[str]) -> None:
282 from muse.cli.commands.auth import _display_entry
283 _display_entry("hub.example.com", self._make_entry(), json_output=True)
284 captured = capsys.readouterr()
285 assert captured.out.strip().startswith("{")
286
287 def test_text_goes_to_stderr(self, capsys: pytest.CaptureFixture[str]) -> None:
288 from muse.cli.commands.auth import _display_entry
289 _display_entry("hub.example.com", self._make_entry(), json_output=False)
290 captured = capsys.readouterr()
291 assert captured.out.strip() == ""
292 assert "hub.example.com" in captured.err
293
294 def test_ansi_in_hostname_stripped_in_text_mode(self, capsys: pytest.CaptureFixture[str]) -> None:
295 from muse.cli.commands.auth import _display_entry
296 _display_entry("\x1b[31mevil\x1b[0m", self._make_entry(), json_output=False)
297 assert "\x1b[" not in capsys.readouterr().err
298
299 def test_capabilities_list_in_json(self, capsys: pytest.CaptureFixture[str]) -> None:
300 from muse.cli.commands.auth import _display_entry
301 entry: IdentityEntry = {"type": "agent", "capabilities": ["push", "pull"]}
302 _display_entry("hub.example.com", entry, json_output=True)
303 data = json.loads(capsys.readouterr().out)
304 assert data["capabilities"] == ["push", "pull"]
305
306
307 # ── Integration: run_whoami ───────────────────────────────────────────────────
308
309
310 class TestWhoamiHardening:
311 _HUB = "http://localhost:19999"
312
313 def _store(self, repo: pathlib.Path, *, handle: str = "alice") -> None:
314 save_identity(self._HUB, {
315 "type": "human",
316 "handle": handle,
317 "key_path": "/fake/key.pem",
318 "algorithm": "ed25519",
319 "fingerprint": "fp123",
320 })
321
322 def test_no_identity_exits_nonzero(self, repo: pathlib.Path) -> None:
323 result = runner.invoke(cli, ["auth", "whoami", "--hub", self._HUB])
324 assert result.exit_code != 0
325
326 def test_whoami_json_schema(self, repo: pathlib.Path) -> None:
327 self._store(repo)
328 result = runner.invoke(cli, ["auth", "whoami", "--hub", self._HUB, "--json"])
329 assert result.exit_code == 0
330 data = _json_whoami(result)
331 for key in ("hub", "type", "handle", "fingerprint", "key_set", "capabilities"):
332 assert key in data, f"Missing key: {key}"
333 assert data["key_set"] is True
334 assert isinstance(data["key_set"], bool)
335 assert data["handle"] == "alice"
336
337 def test_whoami_json_stdout_clean(self, repo: pathlib.Path) -> None:
338 self._store(repo)
339 result = runner.invoke(cli, ["auth", "whoami", "--hub", self._HUB, "--json"])
340 assert result.exit_code == 0
341 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
342 assert len(json_lines) >= 1
343
344 def test_whoami_all_lists_multiple(self, repo: pathlib.Path) -> None:
345 hub2 = "http://localhost:20000"
346 save_identity(self._HUB, {"type": "human", "handle": "alice", "key_path": "/k1"})
347 save_identity(hub2, {"type": "agent", "handle": "bot", "key_path": "/k2"})
348 result = runner.invoke(cli, ["auth", "whoami", "--all", "--json"])
349 assert result.exit_code == 0
350 parsed = json.loads(result.output)
351 assert isinstance(parsed, list)
352 assert len(parsed) == 2
353
354 def test_whoami_all_empty_exits_nonzero(self, repo: pathlib.Path) -> None:
355 result = runner.invoke(cli, ["auth", "whoami", "--all"])
356 assert result.exit_code != 0
357
358 def test_whoami_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
359 result = runner.invoke(cli, ["auth", "whoami"])
360 assert result.exit_code != 0
361
362 def test_whoami_all_json_is_single_array(self, repo: pathlib.Path) -> None:
363 hub2 = "http://localhost:20000"
364 save_identity(self._HUB, {"type": "human", "handle": "alice", "key_path": "/k1"})
365 save_identity(hub2, {"type": "agent", "handle": "bot", "key_path": "/k2"})
366 result = runner.invoke(cli, ["auth", "whoami", "--all", "--json"])
367 assert result.exit_code == 0
368 parsed = json.loads(result.output)
369 assert isinstance(parsed, list)
370 assert len(parsed) == 2
371
372 def test_whoami_key_set_is_bool(self, repo: pathlib.Path) -> None:
373 self._store(repo)
374 result = runner.invoke(cli, ["auth", "whoami", "--hub", self._HUB, "--json"])
375 assert result.exit_code == 0
376 raw = result.output
377 assert '"key_set": true' in raw or '"key_set":true' in raw
378 assert '"key_set": "true"' not in raw
379
380 def test_whoami_short_j_flag(self, repo: pathlib.Path) -> None:
381 self._store(repo)
382 result = runner.invoke(cli, ["auth", "whoami", "--hub", self._HUB, "-j"])
383 assert result.exit_code == 0
384 json.loads(result.output)
385
386 def test_whoami_short_a_flag(self, repo: pathlib.Path) -> None:
387 self._store(repo)
388 result = runner.invoke(cli, ["auth", "whoami", "-a"])
389 assert result.exit_code == 0
390 assert "localhost" in result.output
391
392
393 # ── Integration: run_logout ───────────────────────────────────────────────────
394
395
396 class TestLogoutHardening:
397 _HUB = "http://localhost:19999"
398
399 def _store(self, repo: pathlib.Path) -> None:
400 save_identity(self._HUB, {"type": "human", "handle": "alice", "key_path": "/k"})
401
402 def test_logout_success_json_schema(self, repo: pathlib.Path) -> None:
403 self._store(repo)
404 result = runner.invoke(
405 cli, ["auth", "logout", "--hub", self._HUB, "--json"]
406 )
407 assert result.exit_code == 0
408 data = _json_logout(result)
409 assert data["status"] == "ok"
410 assert data["count"] == 1
411 assert isinstance(data["hubs"], list)
412 assert len(data["hubs"]) == 1
413
414 def test_logout_nothing_to_do_json(self, repo: pathlib.Path) -> None:
415 result = runner.invoke(
416 cli, ["auth", "logout", "--hub", self._HUB, "--json"]
417 )
418 assert result.exit_code == 0
419 data = _json_logout(result)
420 assert data["status"] == "nothing_to_do"
421 assert data["count"] == 0
422 assert data["hubs"] == []
423
424 def test_logout_all_clears_all(self, repo: pathlib.Path) -> None:
425 hub2 = "http://localhost:20000"
426 save_identity(self._HUB, {"type": "human", "handle": "a", "key_path": "/k1"})
427 save_identity(hub2, {"type": "agent", "handle": "b", "key_path": "/k2"})
428 result = runner.invoke(cli, ["auth", "logout", "--all", "--json"])
429 assert result.exit_code == 0
430 data = _json_logout(result)
431 assert data["status"] == "ok"
432 assert data["count"] == 2
433
434 def test_logout_all_empty_json(self, repo: pathlib.Path) -> None:
435 result = runner.invoke(cli, ["auth", "logout", "--all", "--json"])
436 assert result.exit_code == 0
437 data = _json_logout(result)
438 assert data["status"] == "nothing_to_do"
439 assert data["count"] == 0
440
441 def test_logout_removes_identity(self, repo: pathlib.Path) -> None:
442 self._store(repo)
443 runner.invoke(cli, ["auth", "logout", "--hub", self._HUB])
444 from muse.core.identity import load_identity
445 assert load_identity(self._HUB) is None
446
447 def test_logout_json_stdout_clean(self, repo: pathlib.Path) -> None:
448 self._store(repo)
449 result = runner.invoke(cli, ["auth", "logout", "--hub", self._HUB, "--json"])
450 assert result.exit_code == 0
451 for line in result.output.splitlines():
452 stripped = line.strip()
453 if stripped:
454 assert stripped.startswith("{") or stripped.startswith('"') \
455 or stripped.startswith("}"), f"Non-JSON on stdout: {stripped!r}"
456
457 def test_logout_json_hubs_list_contains_hostname(self, repo: pathlib.Path) -> None:
458 """JSON output 'hubs' list contains the normalised hostname."""
459 self._store(repo)
460 result = runner.invoke(
461 cli, ["auth", "logout", "--hub", self._HUB, "--json"]
462 )
463 assert result.exit_code == 0
464 data = _json_logout(result)
465 assert "hubs" in data
466 assert len(data["hubs"]) == 1
467 assert "localhost" in data["hubs"][0]
468
469 def test_logout_all_json_hubs_sorted(self, repo: pathlib.Path) -> None:
470 """--all --json hubs list is alphabetically sorted."""
471 hubs = [
472 "http://z-hub.example.com",
473 "http://a-hub.example.com",
474 "http://m-hub.example.com",
475 ]
476 for h in hubs:
477 save_identity(h, {"type": "human", "handle": "u", "key_path": "/k"})
478 result = runner.invoke(cli, ["auth", "logout", "--all", "--json"])
479 assert result.exit_code == 0
480 data = _json_logout(result)
481 assert data["hubs"] == sorted(data["hubs"]), "hubs not sorted"
482 assert data["count"] == 3
483
484 def test_logout_idempotent_second_call(self, repo: pathlib.Path) -> None:
485 """Logging out twice succeeds both times (second call is nothing_to_do)."""
486 self._store(repo)
487 r1 = runner.invoke(cli, ["auth", "logout", "--hub", self._HUB, "--json"])
488 r2 = runner.invoke(cli, ["auth", "logout", "--hub", self._HUB, "--json"])
489 assert r1.exit_code == 0
490 assert r2.exit_code == 0
491 d1 = _json_logout(r1)
492 d2 = _json_logout(r2)
493 assert d1["status"] == "ok"
494 assert d2["status"] == "nothing_to_do"
495
496 def test_logout_preserves_sibling_hub(self, repo: pathlib.Path) -> None:
497 """Logging out from one hub must not remove a different hub's identity."""
498 hub2 = "http://sibling.example.com"
499 self._store(repo)
500 save_identity(hub2, {"type": "agent", "handle": "bot", "key_path": "/k2"})
501 runner.invoke(cli, ["auth", "logout", "--hub", self._HUB])
502 from muse.core.identity import load_identity
503 assert load_identity(self._HUB) is None
504 assert load_identity(hub2) is not None
505
506 def test_logout_short_flags(self, repo: pathlib.Path) -> None:
507 """-j and -a short flags work identically to --json and --all."""
508 hub2 = "http://shortflag.example.com"
509 self._store(repo)
510 save_identity(hub2, {"type": "agent", "handle": "bot", "key_path": "/k2"})
511 result = runner.invoke(cli, ["auth", "logout", "-a", "-j"])
512 assert result.exit_code == 0
513 data = _json_logout(result)
514 assert data["status"] == "ok"
515 assert data["count"] == 2
516
517 def test_logout_all_single_write(self, repo: pathlib.Path) -> None:
518 """clear_all_identities is called exactly once for --all (not N times)."""
519 import unittest.mock
520 hubs = [f"http://hub{i}.example.com" for i in range(5)]
521 for h in hubs:
522 save_identity(h, {"type": "human", "handle": "u", "key_path": "/k"})
523 with unittest.mock.patch(
524 "muse.core.identity._save_all", wraps=__import__("muse.core.identity", fromlist=["_save_all"])._save_all
525 ) as mock_save:
526 result = runner.invoke(cli, ["auth", "logout", "--all", "--json"])
527 assert result.exit_code == 0
528 # _save_all must be called exactly once for all 5 hubs
529 assert mock_save.call_count == 1
530
531 def test_logout_ansi_in_hub_url_sanitized(self, repo: pathlib.Path) -> None:
532 """ANSI codes injected into a hub URL display name are stripped in text output."""
533 import unittest.mock
534 ansi_hostname = "\x1b[31mevil.example.com\x1b[0m"
535 evil_entry: IdentityEntry = {"type": "human", "handle": "eve"}
536 with unittest.mock.patch(
537 "muse.core.identity._load_all",
538 return_value={ansi_hostname: evil_entry},
539 ):
540 # Provide a matching --hub so logout finds it
541 result = runner.invoke(
542 cli,
543 ["auth", "logout", "--hub", ansi_hostname],
544 )
545 # Output (stderr) must not contain raw ESC bytes
546 assert "\x1b" not in result.output, "ANSI escape leaked to output"
547
548
549 # ── Security ──────────────────────────────────────────────────────────────────
550
551
552 class TestAuthSecurity:
553 def test_json_post_file_scheme_blocked(self) -> None:
554 from muse.cli.commands.auth import _json_post_raw as _json_post
555 with patch("urllib.request.urlopen") as mock_net:
556 with pytest.raises(SystemExit):
557 _json_post("file:///etc/passwd", "/api/test", {})
558 mock_net.assert_not_called()
559
560 def test_hub_base_url_file_scheme_does_not_open_file(self) -> None:
561 from muse.cli.commands.auth import _hub_base_url
562 with patch("builtins.open") as mock_open:
563 with pytest.raises(SystemExit):
564 _hub_base_url("file:///etc/shadow")
565 mock_open.assert_not_called()
566
567
568 # ── E2E: keygen with unavailable crypto ──────────────────────────────────────
569
570
571 class TestKeygenCLI:
572 def test_keygen_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
573 result = runner.invoke(cli, ["auth", "keygen"])
574 assert result.exit_code != 0
575
576 def test_keygen_invalid_scheme_exits(self, repo: pathlib.Path) -> None:
577 result = runner.invoke(
578 cli, ["auth", "keygen", "--hub", "ftp://evil.com"]
579 )
580 # May fail at scheme validation or at key generation
581 assert result.exit_code != 0 or "ftp" in result.output.lower()
582
583
584 # ── E2E: whoami capabilities field ───────────────────────────────────────────
585
586
587 class TestWhoamiCapabilities:
588 _HUB = "http://localhost:19999"
589
590 def test_capabilities_empty_list_in_json(self, repo: pathlib.Path) -> None:
591 save_identity(self._HUB, {"type": "human", "handle": "alice", "key_path": "/k"})
592 result = runner.invoke(cli, ["auth", "whoami", "--hub", self._HUB, "--json"])
593 assert result.exit_code == 0
594 data = _json_whoami(result)
595 assert isinstance(data["capabilities"], list)
596
597
598 # ── Stress: concurrent logins ─────────────────────────────────────────────────
599
600
601 class TestStressConcurrent:
602 def test_8_sequential_saves_to_isolated_files_correct(
603 self, tmp_path: pathlib.Path
604 ) -> None:
605 """Sequential save→load round-trips to isolated identity files produce correct data.
606
607 This validates the save/load logic is correct before layering concurrency.
608 Module-level patching is not thread-safe, so isolation is tested sequentially.
609 """
610 import muse.core.identity as _id_mod
611 orig_file = _id_mod._IDENTITY_FILE
612
613 for idx in range(8):
614 identity_file = tmp_path / f"identity_{idx}.toml"
615 identity_file.write_text("")
616 hub = f"http://localhost:{19000 + idx}"
617
618 _id_mod._IDENTITY_FILE = identity_file
619 try:
620 from muse.core.identity import save_identity, load_identity, IdentityEntry
621 entry: IdentityEntry = {"type": "agent", "handle": f"agent-{idx}", "key_path": f"/k{idx}"}
622 save_identity(hub, entry)
623 stored = load_identity(hub)
624 assert stored is not None, f"Identity not stored for {hub}"
625 assert stored["handle"] == f"agent-{idx}"
626 finally:
627 _id_mod._IDENTITY_FILE = orig_file
628
629 def test_8_concurrent_logins_shared_identity_file(
630 self, tmp_path: pathlib.Path
631 ) -> None:
632 """8 threads writing different hubs to the same identity file.
633
634 The advisory lock in ``_identity_write_lock()`` must prevent
635 read-modify-write races. All 8 entries must survive after all threads
636 complete.
637
638 We patch _IDENTITY_FILE and _IDENTITY_DIR *once* before threads start
639 to avoid the non-thread-safe module-attribute race that would arise if
640 each thread patched them independently.
641 """
642 import muse.core.identity as _id_mod
643
644 identity_dir = tmp_path / "muse-home"
645 identity_dir.mkdir()
646 identity_file = identity_dir / "identity.toml"
647 identity_file.write_text("")
648
649 orig_file = _id_mod._IDENTITY_FILE
650 orig_dir = _id_mod._IDENTITY_DIR
651 _id_mod._IDENTITY_FILE = identity_file
652 _id_mod._IDENTITY_DIR = identity_dir
653
654 errors: list[str] = []
655
656 def _do(idx: int) -> None:
657 try:
658 hub = f"http://localhost:{19000 + idx}"
659 from muse.core.identity import save_identity, IdentityEntry
660 entry: IdentityEntry = {"type": "human", "handle": f"user-{idx:02d}", "key_path": f"/k{idx}"}
661 save_identity(hub, entry)
662 except Exception as exc:
663 errors.append(f"Thread {idx}: {exc}")
664
665 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
666 for t in threads:
667 t.start()
668 for t in threads:
669 t.join()
670
671 try:
672 from muse.core.identity import list_all_identities
673 all_ids = list_all_identities()
674 finally:
675 _id_mod._IDENTITY_FILE = orig_file
676 _id_mod._IDENTITY_DIR = orig_dir
677
678 assert errors == [], "Concurrent shared identity file failures:\n" + "\n".join(errors)
679 assert len(all_ids) == 8, f"Expected 8 identities, got {len(all_ids)}: {list(all_ids)}"
680
681 def test_16_sequential_logins_all_survive(
682 self, tmp_path: pathlib.Path
683 ) -> None:
684 """16 sequential logins to distinct hubs all persist correctly.
685
686 Validates that repeated save→load round-trips don't corrupt the TOML
687 file and that all entries coexist after the final write.
688 """
689 import muse.core.identity as _id_mod
690
691 identity_dir = tmp_path / "muse-stress"
692 identity_dir.mkdir()
693 identity_file = identity_dir / "identity.toml"
694 identity_file.write_text("")
695
696 orig_file = _id_mod._IDENTITY_FILE
697 orig_dir = _id_mod._IDENTITY_DIR
698 _id_mod._IDENTITY_FILE = identity_file
699 _id_mod._IDENTITY_DIR = identity_dir
700
701 try:
702 from muse.core.identity import save_identity, load_identity, IdentityEntry
703 for idx in range(16):
704 hub = f"http://localhost:{21000 + idx}"
705 entry: IdentityEntry = {
706 "type": "agent",
707 "handle": f"agent-{idx:02d}",
708 "key_path": f"/k{idx}",
709 }
710 save_identity(hub, entry)
711
712 from muse.core.identity import list_all_identities
713 all_ids = list_all_identities()
714 assert len(all_ids) == 16, f"Expected 16, got {len(all_ids)}"
715
716 for idx in (0, 7, 15):
717 hub = f"http://localhost:{21000 + idx}"
718 stored = load_identity(hub)
719 assert stored is not None
720 assert stored["handle"] == f"agent-{idx:02d}"
721 finally:
722 _id_mod._IDENTITY_FILE = orig_file
723 _id_mod._IDENTITY_DIR = orig_dir
File History 4 commits
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