gabriel / muse public
test_auth_show_migrate.py python
390 lines 14.8 KB
Raw
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 16 hours ago
1 """Tests for ``muse auth show``.
2
3 Covers:
4 - ``muse auth show``: display identity detail including HD paths + AVAX address.
5
6 Test categories
7 ---------------
8 - unit : _show_identity_detail helper with bare and HD entries
9 - integration : CLI round-trips for show (via CliRunner + monkeypatch)
10 - data-integrity : show reads HD fields correctly
11 - performance : show completes under 5 s
12 - security : show never reveals mnemonic
13 - docstrings : public API has docstrings
14 """
15
16 from __future__ import annotations
17
18 import json
19 import pathlib
20 import time
21 from unittest.mock import MagicMock
22
23 import pytest
24 from tests.cli_test_helper import CliRunner
25
26 import muse.core.keypair as kp_mod
27
28 runner = CliRunner()
29
30 HUB = "https://localhost:1337"
31 HOSTNAME = "localhost:1337"
32 FAKE_MNEMONIC = (
33 "abandon abandon abandon abandon abandon abandon "
34 "abandon abandon abandon abandon abandon about"
35 )
36 FAKE_HD_PATH = "m/1075233755'/0'/0'/0'/0'/0'"
37 FAKE_FINGERPRINT = "a" * 64
38 FAKE_HANDLE = "gabriel"
39
40
41 # ---------------------------------------------------------------------------
42 # Shared fixtures
43 # ---------------------------------------------------------------------------
44
45
46 def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
47 """Redirect identity.toml and key paths to a temp directory."""
48 fake_home = tmp_path / "home"
49 fake_muse = fake_home / ".muse"
50 fake_keys = fake_muse / "keys"
51 fake_keys.mkdir(parents=True)
52 identity_file = fake_muse / "identity.toml"
53
54 import muse.core.identity as id_mod
55 monkeypatch.setattr(id_mod, "_IDENTITY_FILE", identity_file)
56 monkeypatch.setattr(id_mod, "_IDENTITY_DIR", fake_muse)
57 monkeypatch.setattr(kp_mod, "_KEYS_DIR", fake_keys)
58 return fake_home
59
60
61 def _write_bare_identity(fake_home: pathlib.Path, handle: str = FAKE_HANDLE) -> pathlib.Path:
62 """Write a minimal identity entry (no HD fields) to identity.toml."""
63 import muse.core.identity as id_mod
64 entry = {
65 "type": "human",
66 "handle": handle,
67 "key_path": str(kp_mod._KEYS_DIR / f"{HOSTNAME}.pem"),
68 "algorithm": "ed25519",
69 "fingerprint": FAKE_FINGERPRINT,
70 }
71 from muse.core.identity import save_identity
72 save_identity(f"http://{HOSTNAME}", entry)
73 return id_mod._IDENTITY_FILE
74
75
76 def _write_hd_identity(
77 fake_home: pathlib.Path,
78 monkeypatch: pytest.MonkeyPatch,
79 handle: str = FAKE_HANDLE,
80 ) -> pathlib.Path:
81 """Write an HD identity entry with mnemonic stored in an in-memory keychain."""
82 _kc: dict[str, str] = {}
83 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
84 monkeypatch.setattr("muse.core.keychain.store",
85 lambda m: _kc.__setitem__("mnemonic", m))
86 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
87
88 import muse.core.identity as id_mod
89 entry = {
90 "type": "human",
91 "handle": handle,
92 "key_path": str(kp_mod._KEYS_DIR / f"{HOSTNAME}.pem"),
93 "algorithm": "ed25519",
94 "fingerprint": FAKE_FINGERPRINT,
95 "hd_path": FAKE_HD_PATH,
96 }
97 from muse.core.identity import save_identity
98 save_identity(f"https://{HOSTNAME}", entry, mnemonic=FAKE_MNEMONIC)
99 return id_mod._IDENTITY_FILE
100
101
102 # ---------------------------------------------------------------------------
103 # Unit: _show_identity_detail helper
104 # ---------------------------------------------------------------------------
105
106
107 class TestShowIdentityDetail:
108 """Unit tests for the _show_identity_detail helper."""
109
110 def test_bare_entry_json(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
111 """Bare entry (no hd_path) must contain required fields and no HD extras."""
112 _patch_home(monkeypatch, tmp_path)
113 from muse.cli.commands.auth import _show_identity_detail
114
115 entry = {
116 "type": "human",
117 "handle": "gabriel",
118 "key_path": "/tmp/key.pem",
119 "fingerprint": FAKE_FINGERPRINT,
120 }
121 _show_identity_detail(HOSTNAME, entry, json_output=True)
122 out = json.loads(capsys.readouterr().out)
123
124 assert out["hub"] == HOSTNAME
125 assert out["handle"] == "gabriel"
126 assert out["type"] == "human"
127 assert out["fingerprint"] == FAKE_FINGERPRINT
128 assert "hd_path" not in out
129 assert "mnemonic_word_count" not in out
130 assert "derived_paths" not in out
131
132 def test_hd_entry_json_has_paths(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
133 """HD entry JSON must include derived_paths with the four standard paths."""
134 _patch_home(monkeypatch, tmp_path)
135 from muse.cli.commands.auth import _show_identity_detail
136
137 entry = {
138 "type": "human",
139 "handle": "gabriel",
140 "key_path": "/tmp/key.pem",
141 "fingerprint": FAKE_FINGERPRINT,
142 "mnemonic": FAKE_MNEMONIC,
143 "hd_path": FAKE_HD_PATH,
144 }
145 _show_identity_detail(HOSTNAME, entry, json_output=True)
146 out = json.loads(capsys.readouterr().out)
147
148 assert out["hd_path"] == FAKE_HD_PATH
149 assert out["mnemonic_word_count"] == 12
150 assert "derived_paths" in out
151 dp = out["derived_paths"]
152 assert "identity_msign" in dp
153 assert "payments_mpay" in dp
154 assert "avax_c_chain" in dp
155 assert "agent_slot_0" in dp
156
157 def test_hd_entry_has_avax_address(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
158 """HD entry must include avax_c_chain_address derived from the mnemonic."""
159 _patch_home(monkeypatch, tmp_path)
160 from muse.cli.commands.auth import _show_identity_detail
161
162 entry = {
163 "type": "human",
164 "handle": "gabriel",
165 "key_path": "/tmp/key.pem",
166 "fingerprint": FAKE_FINGERPRINT,
167 "mnemonic": FAKE_MNEMONIC,
168 "hd_path": FAKE_HD_PATH,
169 }
170 _show_identity_detail(HOSTNAME, entry, json_output=True)
171 out = json.loads(capsys.readouterr().out)
172
173 assert "avax_c_chain_address" in out
174 addr = out["avax_c_chain_address"]
175 assert addr.startswith("0x")
176 assert len(addr) == 42
177
178 def test_hd_derived_paths_format(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
179 """All derived paths must start with 'm/' and use the Muse purpose constant."""
180 _patch_home(monkeypatch, tmp_path)
181 from muse.cli.commands.auth import _show_identity_detail
182
183 entry = {
184 "type": "human", "handle": "gabriel", "key_path": "/tmp/key.pem",
185 "fingerprint": FAKE_FINGERPRINT,
186 "mnemonic": FAKE_MNEMONIC, "hd_path": FAKE_HD_PATH,
187 }
188 _show_identity_detail(HOSTNAME, entry, json_output=True)
189 out = json.loads(capsys.readouterr().out)
190
191 dp = out["derived_paths"]
192 for name, path in dp.items():
193 if name != "avax_c_chain":
194 assert path.startswith("m/1075233755'"), f"{name}: {path}"
195 else:
196 assert path == "m/44'/60'/0'/0/0"
197
198 def test_bare_entry_stderr_human_readable(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
199 """Bare entry human-readable output must appear on stderr, not stdout."""
200 _patch_home(monkeypatch, tmp_path)
201 from muse.cli.commands.auth import _show_identity_detail
202
203 entry = {
204 "type": "human", "handle": "gabriel",
205 "key_path": "/tmp/key.pem", "fingerprint": FAKE_FINGERPRINT,
206 }
207 _show_identity_detail(HOSTNAME, entry, json_output=False)
208 captured = capsys.readouterr()
209 assert captured.out == ""
210 assert "gabriel" in captured.err
211
212
213 # ---------------------------------------------------------------------------
214 # Integration: CLI round-trips
215 # ---------------------------------------------------------------------------
216
217
218 class TestShowCLI:
219 """Integration: ``muse auth show`` CLI round-trips."""
220
221 def test_show_bare_json(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
222 """``muse auth show --hub … --json`` returns correct JSON for a bare identity."""
223 _patch_home(monkeypatch, tmp_path)
224 _write_bare_identity(tmp_path)
225 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
226 monkeypatch.setattr("muse.cli.commands.auth._resolve_hub", lambda h, r=None: HUB)
227
228 from muse.cli.commands.auth import run_show
229 ns = MagicMock()
230 ns.hub = HUB
231 ns.json_output = True
232 run_show(ns)
233
234 out = json.loads(capsys.readouterr().out)
235 assert out["handle"] == FAKE_HANDLE
236 assert "key_source" not in out
237 assert "hd_path" not in out
238
239 def test_show_hd_json(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
240 """``muse auth show --hub … --json`` returns HD fields for an HD identity."""
241 _patch_home(monkeypatch, tmp_path)
242 _write_hd_identity(tmp_path, monkeypatch)
243 monkeypatch.setattr("muse.cli.commands.auth._resolve_hub", lambda h, r=None: HUB)
244
245 from muse.cli.commands.auth import run_show
246 ns = MagicMock()
247 ns.hub = HUB
248 ns.json_output = True
249 run_show(ns)
250
251 out = json.loads(capsys.readouterr().out)
252 assert "key_source" not in out
253 assert out["mnemonic_word_count"] == 12
254 assert "derived_paths" in out
255
256 def test_show_no_identity_exits_1(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
257 """``run_show`` exits with code 1 when no identity is stored."""
258 _patch_home(monkeypatch, tmp_path)
259 monkeypatch.setattr("muse.cli.commands.auth._resolve_hub", lambda h, r=None: HUB)
260
261 from muse.cli.commands.auth import run_show
262 ns = MagicMock()
263 ns.hub = HUB
264 ns.json_output = False
265
266 with pytest.raises(SystemExit) as exc_info:
267 run_show(ns)
268 assert exc_info.value.code == 1
269
270
271 # ---------------------------------------------------------------------------
272 # Data integrity: show reads correct fields
273 # ---------------------------------------------------------------------------
274
275
276 class TestDataIntegrity:
277 """Data-integrity tests for show."""
278
279 def test_show_mnemonic_word_count_matches_24_words(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
280 """show must report mnemonic_word_count = 24 for a 24-word mnemonic."""
281 _patch_home(monkeypatch, tmp_path)
282
283 long_mnemonic = " ".join(["abandon"] * 23 + ["art"])
284 _kc: dict[str, str] = {}
285 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
286 monkeypatch.setattr("muse.core.keychain.store",
287 lambda m: _kc.__setitem__("mnemonic", m))
288 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
289
290 from muse.core.identity import save_identity
291 save_identity(f"https://{HOSTNAME}", {
292 "type": "human", "handle": FAKE_HANDLE,
293 "key_path": str(kp_mod._KEYS_DIR / f"{HOSTNAME}.pem"),
294 "algorithm": "ed25519", "fingerprint": FAKE_FINGERPRINT,
295 "hd_path": FAKE_HD_PATH,
296 }, mnemonic=long_mnemonic)
297
298 monkeypatch.setattr("muse.cli.commands.auth._resolve_hub", lambda h, r=None: HUB)
299
300 from muse.cli.commands.auth import run_show
301 ns = MagicMock()
302 ns.hub = HUB
303 ns.json_output = True
304 run_show(ns)
305
306 out = json.loads(capsys.readouterr().out)
307 assert out["mnemonic_word_count"] == 24
308
309
310 # ---------------------------------------------------------------------------
311 # Performance: show completes within time limits
312 # ---------------------------------------------------------------------------
313
314
315 class TestPerformance:
316 """show must be fast (< 5 s including crypto)."""
317
318 def test_show_hd_latency(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
319 """``run_show`` on an HD identity (with AVAX derivation) under 5 s."""
320 _patch_home(monkeypatch, tmp_path)
321 _write_hd_identity(tmp_path, monkeypatch)
322 monkeypatch.setattr("muse.cli.commands.auth._resolve_hub", lambda h, r=None: HUB)
323
324 from muse.cli.commands.auth import run_show
325 ns = MagicMock()
326 ns.hub = HUB
327 ns.json_output = True
328
329 start = time.perf_counter()
330 run_show(ns)
331 elapsed = time.perf_counter() - start
332 assert elapsed < 5.0, f"Too slow: {elapsed:.2f} s"
333
334
335 # ---------------------------------------------------------------------------
336 # Security: mnemonic never exposed
337 # ---------------------------------------------------------------------------
338
339
340 class TestSecurity:
341 """Security properties of show."""
342
343 def test_show_never_prints_mnemonic(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
344 """``run_show`` must not emit the mnemonic to stdout or stderr."""
345 _patch_home(monkeypatch, tmp_path)
346 _write_hd_identity(tmp_path, monkeypatch)
347 monkeypatch.setattr("muse.cli.commands.auth._resolve_hub", lambda h, r=None: HUB)
348
349 from muse.cli.commands.auth import run_show
350 ns = MagicMock()
351 ns.hub = HUB
352 ns.json_output = True
353 run_show(ns)
354
355 captured = capsys.readouterr()
356 for word in FAKE_MNEMONIC.split():
357 out = json.loads(captured.out)
358 assert word not in json.dumps(out).split(), f"word '{word}' found in JSON output"
359
360 def test_show_json_has_no_mnemonic_key(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
361 """JSON output must not contain a ``mnemonic`` key."""
362 _patch_home(monkeypatch, tmp_path)
363 _write_hd_identity(tmp_path, monkeypatch)
364 monkeypatch.setattr("muse.cli.commands.auth._resolve_hub", lambda h, r=None: HUB)
365
366 from muse.cli.commands.auth import run_show
367 ns = MagicMock()
368 ns.hub = HUB
369 ns.json_output = True
370 run_show(ns)
371
372 out = json.loads(capsys.readouterr().out)
373 assert "mnemonic" not in out
374
375
376 # ---------------------------------------------------------------------------
377 # Docstrings: public API coverage
378 # ---------------------------------------------------------------------------
379
380
381 class TestDocstrings:
382 """All public functions must have docstrings."""
383
384 def test_run_show_docstring(self) -> None:
385 from muse.cli.commands.auth import run_show
386 assert run_show.__doc__
387
388 def test_show_identity_detail_docstring(self) -> None:
389 from muse.cli.commands.auth import _show_identity_detail
390 assert _show_identity_detail.__doc__
File History 1 commit
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 16 hours ago