gabriel / muse public
test_auth_hd_persistence.py python
574 lines 24.4 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 22 days ago
1 """HD identity persistence tests.
2
3 Design invariants under test
4 ------------------------------
5 - Mnemonic lives in the OS keychain (or is ephemeral in CI); it is NEVER
6 written to ``identity.toml``.
7 - ``hd_path`` and other identity fields ARE written to ``identity.toml``.
8 - There is no ``key_source`` field — all keys are HD-derived; the distinction
9 is meaningless and the field does not exist.
10 - ``muse auth keygen`` always uses HD derivation.
11 - ``muse auth register`` must carry forward ``hd_path`` from the existing
12 provisional entry.
13
14 Test categories
15 ---------------
16 1. unit : _load_all field coverage; _dump_identity field coverage
17 2. integration : run_keygen writes hd_path; mnemonic absent from TOML
18 3. e2e : keygen → register field preservation round-trip
19 4. security : mnemonic absent from identity.toml and from JSON stdout
20 5. data_integrity: HD fields survive repeated register + TOML escaping
21 6. docstrings : public API has docstrings (smoke)
22 7. performance : save+load under 100 ms; keygen under 3 s
23 8. stress : 10 re-registrations; 20 successive saves without corruption
24 """
25
26 from __future__ import annotations
27 from collections.abc import Mapping
28
29 import json
30 import pathlib
31 import time
32 import tomllib
33
34 import pytest
35 from tests.cli_test_helper import CliRunner, InvokeResult
36
37 from muse.core import keypair as kp_module
38
39 cli = None
40 runner = CliRunner()
41
42 # Use a non-standard port that will never match gabriel's real keychain entries.
43 # load_identity() injects the mnemonic from the OS keychain for the exact hub
44 # URL — using a port that has never been registered ensures clean isolation.
45 HUB = "http://localhost:19007"
46 HOSTNAME = "localhost:19007"
47 FAKE_MNEMONIC = (
48 "abandon abandon abandon abandon abandon abandon "
49 "abandon abandon abandon abandon abandon about"
50 )
51 FAKE_HD_PATH = "m/1075233755'/0'/0'/0'/0'/0'"
52 FAKE_FINGERPRINT = "a" * 64
53 FAKE_HANDLE = "gabriel"
54
55
56 # ---------------------------------------------------------------------------
57 # Shared fixtures
58 # ---------------------------------------------------------------------------
59
60
61 def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
62 """Redirect pathlib.Path.home() and module-level constants to a temp dir."""
63 fake_home = tmp_path / "home"
64 fake_home.mkdir(parents=True, exist_ok=True)
65 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
66 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
67 from muse.core import identity as id_module
68 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
69 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
70 return fake_home
71
72
73 def _mock_hub(monkeypatch: pytest.MonkeyPatch, handle: str = FAKE_HANDLE) -> None:
74 """Patch _json_post_raw to simulate a successful hub challenge-response."""
75 import muse.cli.commands.auth as auth_mod
76 challenge = {"challenge_token": "deadbeef" * 8, "is_new_key": True, "algorithm": "ed25519"}
77 verify = {"handle": handle, "identity_id": "id-123", "is_new_identity": False, "auth_method": "ed25519"}
78 monkeypatch.setattr(
79 auth_mod,
80 "_json_post_raw",
81 lambda base, path, payload: challenge if "challenge" in path else verify,
82 )
83
84
85 def _mock_bip39(monkeypatch: pytest.MonkeyPatch) -> None:
86 """Patch only mnemonic *generation* to avoid OS entropy.
87
88 ``mnemonic_to_seed`` and ``derive_hd_public_info`` run for real so that:
89 - The fingerprint stored in identity.toml is the genuine derived value.
90 - SLIP-0010 derivation is exercised, not bypassed.
91 """
92 import muse.core.bip39 as bip39_mod
93 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: FAKE_MNEMONIC)
94
95
96 def _identity_file(fake_home: pathlib.Path) -> pathlib.Path:
97 return fake_home / ".muse" / "identity.toml"
98
99
100 def _read_identity_toml(fake_home: pathlib.Path) -> Mapping[str, object]:
101 ifile = _identity_file(fake_home)
102 with ifile.open("rb") as fh:
103 return tomllib.load(fh)
104
105
106 # ---------------------------------------------------------------------------
107 # 1. Unit — _load_all field coverage
108 # ---------------------------------------------------------------------------
109
110
111 class TestLoadAllHdFields:
112 """_load_all must parse hd_path and provisioned_by from TOML.
113 Mnemonic is keychain-only — must NOT be loaded from TOML.
114 key_source does not exist — derivation is always HD."""
115
116 def _write(self, path: pathlib.Path, text: str) -> None:
117 path.write_text(text, encoding="utf-8")
118
119 def test_mnemonic_not_loaded_from_toml(self, tmp_path: pathlib.Path) -> None:
120 """Even when mnemonic appears in a legacy TOML file, _load_all must not surface it."""
121 p = tmp_path / "identity.toml"
122 self._write(p, f'["{HOSTNAME}"]\ntype="human"\nhandle="{FAKE_HANDLE}"\n'
123 f'key_path="/tmp/k.pem"\nalgorithm="ed25519"\n'
124 f'fingerprint="{FAKE_FINGERPRINT}"\nmnemonic="{FAKE_MNEMONIC}"\n')
125 from muse.core.identity import _load_all
126 entry = _load_all(p)[HOSTNAME]
127 assert "mnemonic" not in entry, (
128 "mnemonic must NOT be surfaced from TOML — it lives in the keychain"
129 )
130
131 def test_loads_hd_path(self, tmp_path: pathlib.Path) -> None:
132 p = tmp_path / "identity.toml"
133 self._write(p, f'["{HOSTNAME}"]\ntype="human"\nhandle="{FAKE_HANDLE}"\n'
134 f'key_path="/tmp/k.pem"\nalgorithm="ed25519"\n'
135 f'fingerprint="{FAKE_FINGERPRINT}"\nhd_path="{FAKE_HD_PATH}"\n')
136 from muse.core.identity import _load_all
137 assert _load_all(p)[HOSTNAME]["hd_path"] == FAKE_HD_PATH
138
139 def test_no_key_source_field_exists(self, tmp_path: pathlib.Path) -> None:
140 """key_source is not a valid field — derivation is always HD."""
141 p = tmp_path / "identity.toml"
142 self._write(p, f'["{HOSTNAME}"]\ntype="human"\nhandle="{FAKE_HANDLE}"\n'
143 f'key_path="/tmp/k.pem"\nalgorithm="ed25519"\n'
144 f'fingerprint="{FAKE_FINGERPRINT}"\nhd_path="{FAKE_HD_PATH}"\n')
145 from muse.core.identity import _load_all
146 entry = _load_all(p)[HOSTNAME]
147 assert "key_source" not in entry
148
149 def test_entry_without_hd_fields_has_no_hd_keys(self, tmp_path: pathlib.Path) -> None:
150 p = tmp_path / "identity.toml"
151 self._write(p, f'["{HOSTNAME}"]\ntype="human"\nhandle="{FAKE_HANDLE}"\n'
152 f'key_path="/tmp/k.pem"\nalgorithm="ed25519"\n'
153 f'fingerprint="{FAKE_FINGERPRINT}"\n')
154 from muse.core.identity import _load_all
155 entry = _load_all(p)[HOSTNAME]
156 assert "mnemonic" not in entry
157 assert "hd_path" not in entry
158 assert "key_source" not in entry
159
160 def test_loads_provisioned_by(self, tmp_path: pathlib.Path) -> None:
161 p = tmp_path / "identity.toml"
162 self._write(p, f'["{HOSTNAME}#agent"]\ntype="agent"\nhandle="agent"\n'
163 f'key_path="/tmp/k.pem"\nalgorithm="ed25519"\n'
164 f'fingerprint="{FAKE_FINGERPRINT}"\nprovisioned_by="alice"\n')
165 from muse.core.identity import _load_all
166 entry = _load_all(p)[f"{HOSTNAME}#agent"]
167 assert entry.get("provisioned_by") == "alice"
168
169
170 # ---------------------------------------------------------------------------
171 # Unit — _dump_identity field coverage
172 # ---------------------------------------------------------------------------
173
174
175 class TestDumpIdentityHdFields:
176 """_dump_identity must write hd_path; mnemonic and key_source are never written."""
177
178 def test_hd_path_serialised(self) -> None:
179 from muse.core.identity import _dump_identity
180 entry = {
181 "type": "human", "handle": FAKE_HANDLE,
182 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
183 "fingerprint": FAKE_FINGERPRINT,
184 "hd_path": FAKE_HD_PATH,
185 }
186 toml_text = _dump_identity({HOSTNAME: entry})
187 assert "hd_path" in toml_text
188 assert FAKE_HD_PATH in toml_text
189
190 def test_mnemonic_never_serialised(self) -> None:
191 """_dump_identity must not write mnemonic even if the entry dict contains it."""
192 from muse.core.identity import _dump_identity
193 entry = {
194 "type": "human", "handle": FAKE_HANDLE,
195 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
196 "fingerprint": FAKE_FINGERPRINT,
197 "hd_path": FAKE_HD_PATH,
198 "mnemonic": FAKE_MNEMONIC, # must be stripped
199 }
200 toml_text = _dump_identity({HOSTNAME: entry})
201 assert "mnemonic" not in toml_text
202 assert FAKE_MNEMONIC not in toml_text
203
204 def test_key_source_never_serialised(self) -> None:
205 """key_source is not a valid field and must never appear in TOML."""
206 from muse.core.identity import _dump_identity
207 entry = {
208 "type": "human", "handle": FAKE_HANDLE,
209 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
210 "fingerprint": FAKE_FINGERPRINT,
211 }
212 toml_text = _dump_identity({HOSTNAME: entry})
213 assert "key_source" not in toml_text
214
215 def test_hd_path_round_trips(self, tmp_path: pathlib.Path) -> None:
216 """hd_path survives _dump_identity → write → _load_all."""
217 from muse.core.identity import _dump_identity, _load_all
218 identities = {HOSTNAME: {
219 "type": "human", "handle": FAKE_HANDLE,
220 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
221 "fingerprint": FAKE_FINGERPRINT,
222 "hd_path": FAKE_HD_PATH,
223 }}
224 p = tmp_path / "identity.toml"
225 p.write_text(_dump_identity(identities), encoding="utf-8")
226 entry = _load_all(p)[HOSTNAME]
227 assert entry["hd_path"] == FAKE_HD_PATH
228 assert "mnemonic" not in entry
229 assert "key_source" not in entry
230
231
232 # ---------------------------------------------------------------------------
233 # 2. Integration — run_keygen writes HD fields to identity.toml
234 # ---------------------------------------------------------------------------
235
236
237 class TestKeygenWritesIdentity:
238 """run_keygen must persist hd_path to identity.toml.
239 Mnemonic and key_source must NOT appear in identity.toml."""
240
241 def _run(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, extra_args: list[str] | None = None) -> tuple[InvokeResult, pathlib.Path]:
242 fake_home = _patch_home(monkeypatch, tmp_path)
243 _mock_bip39(monkeypatch)
244 args = ["auth", "keygen", "--hub", HUB] + (extra_args or [])
245 result = runner.invoke(cli, args, catch_exceptions=False)
246 return result, fake_home
247
248 def test_identity_toml_created(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
249 result, fake_home = self._run(monkeypatch, tmp_path)
250 assert result.exit_code == 0, result.output
251 assert _identity_file(fake_home).exists()
252
253 def test_hd_path_written(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
254 _, fake_home = self._run(monkeypatch, tmp_path)
255 data = _read_identity_toml(fake_home)
256 assert data[HOSTNAME].get("hd_path", "").startswith("m/")
257
258 def test_mnemonic_not_in_toml(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
259 """After keygen, identity.toml must contain NO mnemonic field."""
260 _, fake_home = self._run(monkeypatch, tmp_path)
261 data = _read_identity_toml(fake_home)
262 assert "mnemonic" not in data[HOSTNAME], (
263 "mnemonic leaked into identity.toml — must live in keychain only"
264 )
265 raw_text = _identity_file(fake_home).read_text()
266 assert FAKE_MNEMONIC not in raw_text
267
268 def test_key_source_not_in_toml(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
269 """key_source is not a field — must not appear in identity.toml."""
270 _, fake_home = self._run(monkeypatch, tmp_path)
271 data = _read_identity_toml(fake_home)
272 assert "key_source" not in data[HOSTNAME]
273
274 def test_force_overwrites_writes_hd_path(
275 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
276 ) -> None:
277 """--force overwrites the key; hd_path must be present in the new entry."""
278 self._run(monkeypatch, tmp_path)
279 result, fake_home = self._run(monkeypatch, tmp_path, extra_args=["--force"])
280 assert result.exit_code == 0, result.output
281 data = _read_identity_toml(fake_home)
282 assert data[HOSTNAME].get("hd_path", "").startswith("m/")
283
284
285 # ---------------------------------------------------------------------------
286 # 3. Security — mnemonic never in JSON stdout
287 # ---------------------------------------------------------------------------
288
289
290 class TestMnemonicNeverInJsonObject:
291 """Mnemonic must not appear in any JSON stdout object."""
292
293 def test_keygen_json_no_mnemonic_key(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
294 _patch_home(monkeypatch, tmp_path)
295 _mock_bip39(monkeypatch)
296 result = runner.invoke(
297 cli, ["auth", "keygen", "--hub", HUB, "--json"],
298 catch_exceptions=False,
299 )
300 assert result.exit_code == 0
301 json_line = next(
302 (l for l in result.output.splitlines() if l.startswith("{")), None
303 )
304 assert json_line is not None, "No JSON in output"
305 obj = json.loads(json_line)
306 assert "mnemonic" not in obj
307
308 def test_keygen_json_mnemonic_content_absent(
309 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
310 ) -> None:
311 """The mnemonic phrase itself must not appear anywhere in the JSON dump."""
312 _patch_home(monkeypatch, tmp_path)
313 _mock_bip39(monkeypatch)
314 result = runner.invoke(
315 cli, ["auth", "keygen", "--hub", HUB, "--json"],
316 catch_exceptions=False,
317 )
318 json_line = next(
319 (l for l in result.output.splitlines() if l.startswith("{")), None
320 )
321 obj = json.loads(json_line)
322 assert FAKE_MNEMONIC not in json.dumps(obj)
323
324 def test_keygen_json_has_mnemonic_word_count(
325 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
326 ) -> None:
327 """JSON has mnemonic_word_count (count, not content); value equals actual word count."""
328 _patch_home(monkeypatch, tmp_path)
329 _mock_bip39(monkeypatch)
330 result = runner.invoke(
331 cli, ["auth", "keygen", "--hub", HUB, "--json"],
332 catch_exceptions=False,
333 )
334 json_line = next(
335 (l for l in result.output.splitlines() if l.startswith("{")), None
336 )
337 obj = json.loads(json_line)
338 assert "mnemonic_word_count" in obj
339 assert isinstance(obj["mnemonic_word_count"], int)
340 # FAKE_MNEMONIC has 12 words; run_keygen derives n_words from the
341 # actual mnemonic string so this must match.
342 assert obj["mnemonic_word_count"] == len(FAKE_MNEMONIC.split())
343
344 def test_mnemonic_not_in_toml_after_keygen(
345 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
346 ) -> None:
347 """TOML file on disk must contain no mnemonic after keygen."""
348 fake_home = _patch_home(monkeypatch, tmp_path)
349 _mock_bip39(monkeypatch)
350 runner.invoke(cli, ["auth", "keygen", "--hub", HUB], catch_exceptions=False)
351 raw = _identity_file(fake_home).read_text()
352 # Check no mnemonic *value* is stored — the key name itself must not
353 # appear as a TOML assignment (key_path may contain the word in its
354 # tmp-directory path, but no `mnemonic = ...` key should be written).
355 import re
356 assert re.search(r'^\s*mnemonic\s*=', raw, re.MULTILINE) is None, (
357 f"mnemonic key found in identity.toml:\n{raw}"
358 )
359 assert FAKE_MNEMONIC not in raw
360
361
362 # ---------------------------------------------------------------------------
363 # 4. E2E — register preserves HD fields
364 # ---------------------------------------------------------------------------
365
366
367 class TestRegisterPreservesHdFields:
368 """run_register must carry forward hd_path from the provisional entry
369 written by keygen."""
370
371 def _setup_keygen(
372 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
373 ) -> pathlib.Path:
374 """Run keygen so identity.toml gets HD fields. Return fake_home."""
375 fake_home = _patch_home(monkeypatch, tmp_path)
376 _mock_bip39(monkeypatch)
377 result = runner.invoke(cli, ["auth", "keygen", "--hub", HUB], catch_exceptions=False)
378 assert result.exit_code == 0, result.output
379 return fake_home
380
381 def _run_register(self, monkeypatch: pytest.MonkeyPatch) -> InvokeResult:
382 _mock_hub(monkeypatch)
383 return runner.invoke(
384 cli, ["auth", "register", "--hub", HUB, "--handle", FAKE_HANDLE],
385 catch_exceptions=False,
386 )
387
388 def test_hd_path_preserved_after_register(
389 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
390 ) -> None:
391 fake_home = self._setup_keygen(monkeypatch, tmp_path)
392 result = self._run_register(monkeypatch)
393 assert result.exit_code == 0, result.output
394
395 data = _read_identity_toml(fake_home)
396 entry = data[HOSTNAME]
397 assert entry.get("hd_path", "").startswith("m/"), "hd_path lost after register"
398
399 def test_mnemonic_not_in_toml_after_register(
400 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
401 ) -> None:
402 """register must not write mnemonic to TOML even when carrying forward entry."""
403 fake_home = self._setup_keygen(monkeypatch, tmp_path)
404 self._run_register(monkeypatch)
405 data = _read_identity_toml(fake_home)
406 assert "mnemonic" not in data[HOSTNAME], (
407 "register wrote mnemonic to TOML — keychain-only invariant violated"
408 )
409
410 def test_handle_updated_after_register(
411 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
412 ) -> None:
413 """Provisional empty handle must be replaced by server-assigned handle."""
414 fake_home = self._setup_keygen(monkeypatch, tmp_path)
415 pre_data = _read_identity_toml(fake_home)
416 assert pre_data[HOSTNAME].get("handle", "") == ""
417
418 self._run_register(monkeypatch)
419 post_data = _read_identity_toml(fake_home)
420 assert post_data[HOSTNAME].get("handle") == FAKE_HANDLE
421
422
423 # ---------------------------------------------------------------------------
424 # 5. Data integrity — TOML escaping edge cases
425 # ---------------------------------------------------------------------------
426
427
428 class TestHdPathTomlEscaping:
429 """HD path with primes and slashes must survive _dump_identity → _load_all."""
430
431 def test_hd_path_prime_and_slash_preserved(self, tmp_path: pathlib.Path) -> None:
432 from muse.core.identity import _dump_identity, _load_all
433 entry = {
434 "type": "human", "handle": FAKE_HANDLE,
435 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
436 "fingerprint": FAKE_FINGERPRINT,
437 "hd_path": FAKE_HD_PATH,
438 }
439 p = tmp_path / "identity.toml"
440 p.write_text(_dump_identity({HOSTNAME: entry}), encoding="utf-8")
441 assert _load_all(p)[HOSTNAME]["hd_path"] == FAKE_HD_PATH
442
443 def test_handle_with_special_chars_round_trips(self, tmp_path: pathlib.Path) -> None:
444 from muse.core.identity import _dump_identity, _load_all
445 entry = {
446 "type": "human", "handle": 'alice "the dev"',
447 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
448 "fingerprint": FAKE_FINGERPRINT,
449 }
450 p = tmp_path / "identity.toml"
451 p.write_text(_dump_identity({HOSTNAME: entry}), encoding="utf-8")
452 assert _load_all(p)[HOSTNAME]["handle"] == 'alice "the dev"'
453
454
455 # ---------------------------------------------------------------------------
456 # 6. Docstring smoke tests
457 # ---------------------------------------------------------------------------
458
459
460 class TestDocstrings:
461 def test_load_all_has_docstring(self) -> None:
462 from muse.core.identity import _load_all
463 assert _load_all.__doc__
464
465 def test_save_identity_has_docstring(self) -> None:
466 from muse.core.identity import save_identity
467 assert save_identity.__doc__
468
469 def test_load_identity_has_docstring(self) -> None:
470 from muse.core.identity import load_identity
471 assert load_identity.__doc__
472
473
474 # ---------------------------------------------------------------------------
475 # 7. Performance
476 # ---------------------------------------------------------------------------
477
478
479 class TestPersistencePerformance:
480 """Identity TOML read/write must add negligible latency."""
481
482 def test_save_and_load_identity_under_100ms(
483 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
484 ) -> None:
485 from muse.core import identity as id_module
486 identity_file = tmp_path / "identity.toml"
487 monkeypatch.setattr(id_module, "_IDENTITY_DIR", tmp_path)
488 monkeypatch.setattr(id_module, "_IDENTITY_FILE", identity_file)
489 entry = {
490 "type": "human", "handle": FAKE_HANDLE,
491 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
492 "fingerprint": FAKE_FINGERPRINT,
493 "hd_path": FAKE_HD_PATH,
494 }
495 start = time.monotonic()
496 id_module.save_identity(HUB, entry)
497 # Use _load_all directly to avoid keychain injection for an isolated test.
498 from muse.core.identity import _load_all
499 loaded = _load_all(identity_file).get(HOSTNAME)
500 elapsed = time.monotonic() - start
501 assert loaded is not None
502 assert elapsed < 0.1, f"save+load took {elapsed * 1000:.1f}ms"
503
504 def test_keygen_then_load_identity_under_3s(
505 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
506 ) -> None:
507 """Full keygen including SLIP-0010 HD derivation must complete in under 3 s."""
508 _patch_home(monkeypatch, tmp_path)
509 _mock_bip39(monkeypatch)
510 start = time.monotonic()
511 result = runner.invoke(
512 cli, ["auth", "keygen", "--hub", HUB],
513 catch_exceptions=False,
514 )
515 elapsed = time.monotonic() - start
516 assert result.exit_code == 0, result.output
517 assert elapsed < 3.0, f"keygen took {elapsed:.2f}s"
518
519
520 # ---------------------------------------------------------------------------
521 # 8. Stress
522 # ---------------------------------------------------------------------------
523
524
525 class TestPersistenceStress:
526 """HD field persistence must hold under repeated writes and re-loads."""
527
528 def test_10_successive_registers_preserve_hd_fields(
529 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
530 ) -> None:
531 """hd_path must survive 10 consecutive register calls."""
532 fake_home = _patch_home(monkeypatch, tmp_path)
533 _mock_bip39(monkeypatch)
534 runner.invoke(cli, ["auth", "keygen", "--hub", HUB], catch_exceptions=False)
535
536 _mock_hub(monkeypatch)
537 for i in range(10):
538 result = runner.invoke(
539 cli, ["auth", "register", "--hub", HUB, "--handle", FAKE_HANDLE],
540 catch_exceptions=False,
541 )
542 assert result.exit_code == 0, f"iteration {i}: {result.output}"
543 data = _read_identity_toml(fake_home)
544 assert data[HOSTNAME].get("hd_path", "").startswith("m/"), \
545 f"hd_path lost after {i + 1} register calls"
546 assert "mnemonic" not in data[HOSTNAME], \
547 f"mnemonic leaked into TOML after {i + 1} register calls"
548
549 def test_20_successive_saves_preserve_hd_path(
550 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
551 ) -> None:
552 """20 successive save_identity writes must not corrupt hd_path."""
553 from muse.core import identity as id_module
554 from muse.core.identity import _load_all
555 identity_file = tmp_path / "identity.toml"
556 monkeypatch.setattr(id_module, "_IDENTITY_DIR", tmp_path)
557 monkeypatch.setattr(id_module, "_IDENTITY_FILE", identity_file)
558
559 base_entry = {
560 "type": "human", "handle": FAKE_HANDLE,
561 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
562 "fingerprint": FAKE_FINGERPRINT,
563 "hd_path": FAKE_HD_PATH,
564 }
565 for i in range(20):
566 entry = {**base_entry, "handle": f"user_{i}"}
567 id_module.save_identity(HUB, entry)
568
569 # Use _load_all to avoid keychain injection for the stress hub URL.
570 loaded = _load_all(identity_file).get(HOSTNAME)
571 assert loaded is not None
572 assert loaded.get("hd_path") == FAKE_HD_PATH
573 assert "mnemonic" not in loaded
574 assert "key_source" not in loaded
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 22 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 23 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 29 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 30 days ago