gabriel / muse public
test_core_keychain.py python
666 lines 26.2 KB
Raw
sha256:cb2da6c61116ad1ab98d03747c21d6f66485839c7b4efd7d0124db0f8aa14e41 refactor(harmony): move auto_apply + record_resolutions int… Sonnet 4.6 minor ⚠ breaking 24 days ago
1 """Tests for muse.core.keychain — OS keychain integration — Tier 2.
2
3 The keychain module is the only place mnemonics are stored at rest.
4 Plaintext TOML storage of mnemonics is permanently retired.
5
6 One mnemonic per machine, not one per hub. The same BIP39 mnemonic is the
7 root of all identity keys across every hub (localhost, staging, prod). Cross-hub
8 replay is prevented by the host field in the MSign canonical message, not by
9 using separate mnemonics.
10
11 Coverage
12 --------
13 I keychain module API — single global mnemonic (no hub_url)
14 I1 store(mnemonic) → load() round-trip returns the stored phrase
15 I2 load() returns None when no entry exists
16 I3 delete() removes the entry; load() returns None afterward
17 I4 delete() on missing entry returns False without raising
18 I5 is_available returns False when MUSE_KEYCHAIN_BACKEND=disabled
19 I6 load() returns the same mnemonic regardless of which hub is queried
20 I7 store() is idempotent — calling it twice with the same value is safe
21 I8 mnemonic stored for hub A is readable without specifying a hub URL
22
23 II identity.toml never contains mnemonic
24 II1 save_identity with mnemonic kwarg stores in keychain, not TOML
25 II2 TOML written by save_identity has no "mnemonic" key
26 II3 load_identity retrieves mnemonic from keychain, not TOML
27 II4 identity TOML has no key_source field (derivation is always HD)
28
29 III keygen stores mnemonic in keychain
30 III1 muse auth keygen --json stdout has no "mnemonic" key
31 III2 identity.toml written after keygen has no mnemonic field
32 III3 keychain holds the mnemonic after keygen
33
34 IV keychain disabled (MUSE_KEYCHAIN_BACKEND=disabled)
35 IV1 is_available() is False
36 IV2 store() returns False without raising
37 IV3 load() returns None without raising
38 IV4 muse auth keygen still succeeds (mnemonic is ephemeral)
39
40 V keychain unavailable — operator must be warned (CRITICAL-1)
41 V1 warns when keychain is unavailable for non-intentional reason
42 V2 silent when MUSE_KEYCHAIN_BACKEND=disabled (intentional CI mode)
43
44 VI legacy per-hub mnemonic migration
45 VI1 load() promotes a legacy "{hostname}/mnemonic" entry to "mnemonic"
46 VI2 after migration the legacy entry is deleted from the keychain
47 VI3 migration is idempotent — running load() twice does not corrupt state
48 VI4 if both global and legacy entries exist, global wins (no overwrite)
49 VI5 two legacy entries for different hubs both migrate to the same slot
50 """
51
52 from __future__ import annotations
53
54 import json
55 import os
56 import pathlib
57
58 import pytest
59
60 try:
61 import tomllib
62 except ModuleNotFoundError:
63 import tomli as tomllib # type: ignore[no-reuse-def]
64
65 from tests.cli_test_helper import CliRunner
66 from muse.core.paths import muse_dir
67
68 cli = None
69 runner = CliRunner()
70
71 _TEST_HUB = "https://localhost:1337"
72 _TEST_HOSTNAME = "localhost:1337"
73 _TEST_MNEMONIC = (
74 "abandon abandon abandon abandon abandon abandon abandon abandon "
75 "abandon abandon abandon about"
76 )
77
78 # Capture the real keychain implementations at import time — before any
79 # autouse fixture patches them. The keychain_in_memory fixture restores
80 # these so tests that exercise internal keychain logic (migration, etc.)
81 # call through the real code rather than the conftest's in-memory shim.
82 import muse.core.keychain as _kc_module
83 _REAL_KC_STORE = _kc_module.store
84 _REAL_KC_LOAD = _kc_module.load
85 _REAL_KC_DELETE = _kc_module.delete
86 _REAL_KC_IS_AVAILABLE = _kc_module.is_available
87
88
89 # ---------------------------------------------------------------------------
90 # Fixtures
91 # ---------------------------------------------------------------------------
92
93
94 @pytest.fixture()
95 def keychain_in_memory(monkeypatch: pytest.MonkeyPatch) -> dict[tuple[str, str], str]:
96 """Patch keyring to use an in-memory dict as the backend.
97
98 Returns the dict so tests can inspect it directly.
99
100 Also restores the real muse.core.keychain functions (which the autouse
101 conftest fixture replaces with a simpler shim) so that tests in this file
102 exercise the actual store/load/delete/migration code paths.
103 """
104 store: dict[tuple[str, str], str] = {}
105
106 import keyring
107 monkeypatch.setattr(keyring, "set_password",
108 lambda svc, usr, pwd: store.__setitem__((svc, usr), pwd))
109 monkeypatch.setattr(keyring, "get_password",
110 lambda svc, usr: store.get((svc, usr)))
111
112 import keyring.errors
113
114 def _delete(svc: str, usr: str) -> None:
115 if (svc, usr) not in store:
116 raise keyring.errors.PasswordDeleteError("not found")
117 del store[(svc, usr)]
118
119 monkeypatch.setattr(keyring, "delete_password", _delete)
120
121 # Restore the real module-level functions so these tests exercise the
122 # actual keychain code (including migration logic), not the conftest shim.
123 import muse.core.keychain as kc_mod
124 monkeypatch.setattr(kc_mod, "store", _REAL_KC_STORE)
125 monkeypatch.setattr(kc_mod, "load", _REAL_KC_LOAD)
126 monkeypatch.setattr(kc_mod, "delete", _REAL_KC_DELETE)
127 monkeypatch.setattr(kc_mod, "is_available", lambda: True)
128
129 monkeypatch.delenv("MUSE_KEYCHAIN_BACKEND", raising=False)
130 return store # type: ignore[return-value]
131
132
133 @pytest.fixture()
134 def isolated_identity(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
135 fake_dir = tmp_path / "dot_muse"
136 fake_dir.mkdir()
137 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir)
138 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_dir / "identity.toml")
139 return fake_dir
140
141
142 @pytest.fixture()
143 def isolated_keys(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
144 keys_dir = tmp_path / "keys"
145 keys_dir.mkdir()
146 monkeypatch.setattr("muse.core.keypair._KEYS_DIR", keys_dir)
147 return keys_dir
148
149
150 @pytest.fixture()
151 def repo_with_hub(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
152 dot_muse = muse_dir(tmp_path)
153 dot_muse.mkdir()
154 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
155 (dot_muse / "refs" / "heads").mkdir(parents=True)
156 (dot_muse / "objects").mkdir()
157 (dot_muse / "commits").mkdir()
158 (dot_muse / "snapshots").mkdir()
159 (dot_muse / "config.toml").write_text(f'[hub]\nurl = "{_TEST_HUB}"\n')
160 monkeypatch.chdir(tmp_path)
161 return tmp_path
162
163
164 # ---------------------------------------------------------------------------
165 # I keychain module API — single global mnemonic (no hub_url)
166 # ---------------------------------------------------------------------------
167
168
169 class TestKeychainApiI:
170 def test_I1_store_load_roundtrip(
171 self, keychain_in_memory: dict[tuple[str, str], str]
172 ) -> None:
173 """I1: store(mnemonic) and load() round-trip — no hub URL required."""
174 from muse.core.keychain import store, load
175 assert store(_TEST_MNEMONIC)
176 assert load() == _TEST_MNEMONIC
177
178 def test_I2_load_missing_returns_none(
179 self, keychain_in_memory: dict[tuple[str, str], str]
180 ) -> None:
181 """I2: load() returns None when nothing has been stored."""
182 from muse.core.keychain import load
183 assert load() is None
184
185 def test_I3_delete_removes_entry(
186 self, keychain_in_memory: dict[tuple[str, str], str]
187 ) -> None:
188 """I3: delete() removes the global entry; subsequent load() returns None."""
189 from muse.core.keychain import store, load, delete
190 store(_TEST_MNEMONIC)
191 assert delete()
192 assert load() is None
193
194 def test_I4_delete_missing_returns_false(
195 self, keychain_in_memory: dict[tuple[str, str], str]
196 ) -> None:
197 """I4: delete() on empty keychain returns False without raising."""
198 from muse.core.keychain import delete
199 assert not delete()
200
201 def test_I5_disabled_backend_not_available(
202 self, monkeypatch: pytest.MonkeyPatch
203 ) -> None:
204 """I5: is_available() is False when MUSE_KEYCHAIN_BACKEND=disabled."""
205 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
206 from muse.core import keychain
207 import importlib
208 importlib.reload(keychain)
209 assert not keychain.is_available()
210
211 def test_I6_load_is_hub_agnostic(
212 self, keychain_in_memory: dict[tuple[str, str], str]
213 ) -> None:
214 """I6: the same mnemonic is returned no matter which hub context is active."""
215 from muse.core.keychain import store, load
216 store(_TEST_MNEMONIC)
217 # load() takes no hub_url — mnemonic is global
218 assert load() == _TEST_MNEMONIC
219 assert load() == _TEST_MNEMONIC # idempotent
220
221 def test_I7_store_is_idempotent(
222 self, keychain_in_memory: dict[tuple[str, str], str]
223 ) -> None:
224 """I7: calling store() twice with the same value does not corrupt state."""
225 from muse.core.keychain import store, load
226 assert store(_TEST_MNEMONIC)
227 assert store(_TEST_MNEMONIC)
228 assert load() == _TEST_MNEMONIC
229
230 def test_I8_single_keychain_entry(
231 self, keychain_in_memory: dict[tuple[str, str], str]
232 ) -> None:
233 """I8: exactly one keychain entry exists after store() — no per-hub duplicates."""
234 from muse.core.keychain import store
235 store(_TEST_MNEMONIC)
236 # Only one entry in the entire in-memory store
237 assert len(keychain_in_memory) == 1
238 # And its username is the global constant, not a hostname-scoped key
239 (service, username), value = next(iter(keychain_in_memory.items()))
240 assert service == "muse"
241 assert "/" not in username, (
242 f"Keychain username '{username}' looks per-hub — expected a global key with no '/'"
243 )
244 assert value == _TEST_MNEMONIC
245
246
247 # ---------------------------------------------------------------------------
248 # II identity.toml never contains mnemonic
249 # ---------------------------------------------------------------------------
250
251
252 class TestIdentityNoMnemonicII:
253 def test_II1_save_stores_mnemonic_in_keychain(
254 self,
255 isolated_identity: pathlib.Path,
256 keychain_in_memory: dict[tuple[str, str], str],
257 ) -> None:
258 """save_identity with mnemonic puts it in keychain, not TOML."""
259 from muse.core.identity import save_identity, IdentityEntry
260 entry: IdentityEntry = {
261 "type": "human",
262 "handle": "gabriel",
263 "algorithm": "ed25519",
264 "fingerprint": "a" * 64,
265 }
266 save_identity(_TEST_HUB, entry, mnemonic=_TEST_MNEMONIC)
267
268 # Keychain has the mnemonic
269 from muse.core.keychain import load as kc_load
270 assert kc_load() == _TEST_MNEMONIC
271
272 def test_II2_toml_has_no_mnemonic_key(
273 self,
274 isolated_identity: pathlib.Path,
275 keychain_in_memory: dict[tuple[str, str], str],
276 ) -> None:
277 """The TOML file written by save_identity must not contain 'mnemonic'."""
278 from muse.core.identity import save_identity, IdentityEntry
279 entry: IdentityEntry = {
280 "type": "human",
281 "handle": "gabriel",
282 "algorithm": "ed25519",
283 "fingerprint": "a" * 64,
284 }
285 save_identity(_TEST_HUB, entry, mnemonic=_TEST_MNEMONIC)
286
287 toml_text = (isolated_identity / "identity.toml").read_text()
288 assert "mnemonic" not in toml_text.lower(), (
289 f"'mnemonic' found in TOML:\n{toml_text}"
290 )
291
292 def test_II3_load_retrieves_mnemonic_from_keychain(
293 self,
294 isolated_identity: pathlib.Path,
295 keychain_in_memory: dict[tuple[str, str], str],
296 ) -> None:
297 """load_identity fetches the mnemonic from keychain and injects it."""
298 from muse.core.identity import save_identity, load_identity, IdentityEntry
299 entry: IdentityEntry = {
300 "type": "human",
301 "handle": "gabriel",
302 "algorithm": "ed25519",
303 "fingerprint": "a" * 64,
304 }
305 save_identity(_TEST_HUB, entry, mnemonic=_TEST_MNEMONIC)
306
307 loaded = load_identity(_TEST_HUB)
308 assert loaded is not None
309 assert loaded.get("mnemonic") == _TEST_MNEMONIC
310
311 def test_II4_toml_has_no_key_source_field(
312 self,
313 isolated_identity: pathlib.Path,
314 keychain_in_memory: dict[tuple[str, str], str],
315 ) -> None:
316 """TOML must not contain a key_source field — derivation method is implied."""
317 from muse.core.identity import save_identity, IdentityEntry
318 entry: IdentityEntry = {
319 "type": "human",
320 "handle": "gabriel",
321 "algorithm": "ed25519",
322 "fingerprint": "a" * 64,
323 }
324 save_identity(_TEST_HUB, entry, mnemonic=_TEST_MNEMONIC)
325
326 toml_text = (isolated_identity / "identity.toml").read_text()
327 assert "key_source" not in toml_text
328
329
330 # ---------------------------------------------------------------------------
331 # III keygen stores mnemonic in keychain
332 # ---------------------------------------------------------------------------
333
334
335 class TestKeygenUsesKeychainIII:
336 def test_III1_keygen_json_stdout_no_mnemonic(
337 self,
338 isolated_identity: pathlib.Path,
339 isolated_keys: pathlib.Path,
340 repo_with_hub: pathlib.Path,
341 keychain_in_memory: dict[tuple[str, str], str],
342 monkeypatch: pytest.MonkeyPatch,
343 ) -> None:
344 """III1: muse auth keygen --json stdout must not contain 'mnemonic'."""
345 from muse.core import bip39 as bip39_mod
346 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _TEST_MNEMONIC)
347
348 result = runner.invoke(cli, ["auth", "keygen", "--hub", _TEST_HUB, "--json"])
349 assert result.exit_code == 0, f"keygen failed:\n{result.output}"
350
351 json_lines = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")]
352 assert json_lines, "No JSON output found"
353 for line in json_lines:
354 data = json.loads(line)
355 assert "mnemonic" not in data, f"'mnemonic' key in JSON output: {data}"
356
357 def test_III2_keygen_toml_has_no_mnemonic(
358 self,
359 isolated_identity: pathlib.Path,
360 isolated_keys: pathlib.Path,
361 repo_with_hub: pathlib.Path,
362 keychain_in_memory: dict[tuple[str, str], str],
363 monkeypatch: pytest.MonkeyPatch,
364 ) -> None:
365 """III2: identity.toml after keygen must not have mnemonic in plaintext."""
366 from muse.core import bip39 as bip39_mod
367 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _TEST_MNEMONIC)
368
369 runner.invoke(cli, ["auth", "keygen", "--hub", _TEST_HUB, "--json"])
370
371 toml_file = isolated_identity / "identity.toml"
372 assert toml_file.exists(), "identity.toml not created"
373 content = toml_file.read_text()
374 assert "mnemonic" not in content.lower(), f"mnemonic in TOML:\n{content}"
375
376 def test_III3_keychain_holds_mnemonic_after_keygen(
377 self,
378 isolated_identity: pathlib.Path,
379 isolated_keys: pathlib.Path,
380 repo_with_hub: pathlib.Path,
381 keychain_in_memory: dict[tuple[str, str], str],
382 monkeypatch: pytest.MonkeyPatch,
383 ) -> None:
384 """III3: keychain holds the mnemonic globally after keygen (no hub scoping)."""
385 from muse.core import bip39 as bip39_mod
386 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _TEST_MNEMONIC)
387
388 runner.invoke(cli, ["auth", "keygen", "--hub", _TEST_HUB, "--json"])
389
390 from muse.core.keychain import load as kc_load
391 stored = kc_load()
392 assert stored == _TEST_MNEMONIC, f"Keychain does not have mnemonic, got: {stored!r}"
393
394
395 # ---------------------------------------------------------------------------
396 # IV keychain disabled
397 # ---------------------------------------------------------------------------
398
399
400 class TestKeychainDisabledIV:
401 def test_IV1_is_available_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
402 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
403 from muse.core import keychain
404 import importlib
405 importlib.reload(keychain)
406 assert not keychain.is_available()
407
408 def test_IV2_store_returns_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
409 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
410 from muse.core import keychain
411 import importlib
412 importlib.reload(keychain)
413 assert not keychain.store(_TEST_MNEMONIC)
414
415 def test_IV3_load_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
416 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
417 from muse.core import keychain
418 import importlib
419 importlib.reload(keychain)
420 assert keychain.load() is None
421
422 def test_IV4_keygen_succeeds_without_keychain(
423 self,
424 isolated_identity: pathlib.Path,
425 isolated_keys: pathlib.Path,
426 repo_with_hub: pathlib.Path,
427 monkeypatch: pytest.MonkeyPatch,
428 ) -> None:
429 """IV4: keygen still works when keychain is disabled (mnemonic is ephemeral)."""
430 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
431 from muse.core import bip39 as bip39_mod
432 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _TEST_MNEMONIC)
433
434 result = runner.invoke(cli, ["auth", "keygen", "--hub", _TEST_HUB, "--json"])
435 assert result.exit_code == 0, f"keygen failed with disabled keychain:\n{result.output}"
436
437
438 # ---------------------------------------------------------------------------
439 # V keychain unavailable — operator must be warned (CRITICAL-1)
440 # ---------------------------------------------------------------------------
441
442
443 class TestKeychainUnavailableWarnsV:
444 """V When the keychain is truly unavailable (not intentionally disabled),
445 save_identity must warn the operator that the mnemonic is ephemeral.
446
447 MUSE_KEYCHAIN_BACKEND=disabled is CI/test mode and must stay silent.
448 Any other cause of is_available()==False is an operational failure and
449 demands a log.warning so the operator knows their root key is not persisted.
450 """
451
452 _entry = {
453 "type": "human",
454 "handle": "gabriel",
455 "algorithm": "ed25519",
456 "fingerprint": "a" * 64,
457 }
458
459 def test_V1_warns_when_keychain_unavailable(
460 self,
461 isolated_identity: pathlib.Path,
462 monkeypatch: pytest.MonkeyPatch,
463 caplog: pytest.LogCaptureFixture,
464 ) -> None:
465 """V1: save_identity logs a warning when keychain is unavailable
466 for a non-intentional reason (no backend, library not installed, etc.).
467
468 Simulate: is_available() returns False but MUSE_KEYCHAIN_BACKEND is not set.
469 """
470 import logging
471 from unittest.mock import patch
472 from muse.core import keychain as kc_mod
473 from muse.core.identity import save_identity
474
475 monkeypatch.delenv("MUSE_KEYCHAIN_BACKEND", raising=False)
476
477 with patch.object(kc_mod, "is_available", return_value=False):
478 with caplog.at_level(logging.WARNING, logger="muse.core.identity"):
479 save_identity(_TEST_HUB, self._entry, mnemonic=_TEST_MNEMONIC) # type: ignore[arg-type]
480
481 warning_messages = [
482 r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING
483 ]
484 assert warning_messages, (
485 "Expected a warning about unavailable keychain — got none.\n"
486 f"All log records: {[r.getMessage() for r in caplog.records]}"
487 )
488 combined = " ".join(warning_messages).lower()
489 assert "keychain" in combined or "ephemeral" in combined, (
490 f"Warning must mention 'keychain' or 'ephemeral': {warning_messages}"
491 )
492
493 def test_V2_silent_when_keychain_intentionally_disabled(
494 self,
495 isolated_identity: pathlib.Path,
496 monkeypatch: pytest.MonkeyPatch,
497 caplog: pytest.LogCaptureFixture,
498 ) -> None:
499 """V2: no keychain warning when MUSE_KEYCHAIN_BACKEND=disabled (CI/test mode).
500
501 The disabled env var signals intentional ephemeral operation — the
502 operator has opted out of keychain storage on purpose, so no warning
503 should fire.
504 """
505 import logging
506 from muse.core.identity import save_identity
507
508 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
509
510 with caplog.at_level(logging.WARNING, logger="muse.core.identity"):
511 save_identity(_TEST_HUB, self._entry, mnemonic=_TEST_MNEMONIC) # type: ignore[arg-type]
512
513 keychain_warnings = [
514 r.getMessage()
515 for r in caplog.records
516 if r.levelno >= logging.WARNING
517 and ("keychain" in r.getMessage().lower() or "ephemeral" in r.getMessage().lower())
518 ]
519 assert not keychain_warnings, (
520 f"Unexpected keychain warning in intentional CI/disabled mode: {keychain_warnings}"
521 )
522
523
524 # ---------------------------------------------------------------------------
525 # VI legacy per-hub mnemonic migration
526 # ---------------------------------------------------------------------------
527
528
529 _STAGING_HUB = "https://staging.musehub.ai"
530 _STAGING_HOSTNAME = "staging.musehub.ai"
531 _LEGACY_USERNAME_LOCALHOST = "localhost:1337/mnemonic"
532 _LEGACY_USERNAME_STAGING = "staging.musehub.ai/mnemonic"
533 _GLOBAL_USERNAME = "mnemonic"
534
535
536 def _seed_identity_toml(isolated_identity: pathlib.Path, *hostnames: str) -> None:
537 """Write a minimal identity.toml so the migration scanner finds the hostnames."""
538 lines = []
539 for h in hostnames:
540 lines.append(f'["{h}"]')
541 lines.append('type = "human"')
542 lines.append('handle = "gabriel"')
543 lines.append('algorithm = "ed25519"')
544 lines.append(f'fingerprint = "{"a" * 64}"')
545 lines.append('hd_path = "m/1075233755\'/0\'/0\'/0\'/0\'/0\'"')
546 lines.append("")
547 (isolated_identity / "identity.toml").write_text("\n".join(lines))
548
549
550 class TestLegacyMigrationVI:
551 """VI Transparent migration from per-hub keychain entries to a single global entry.
552
553 Users who ran older versions of muse have entries keyed as
554 "{hostname}/mnemonic" in the keychain. load() must silently promote
555 one of these to the global "mnemonic" slot and delete the legacy entry.
556
557 Migration rules:
558 - Any "{hostname}/mnemonic" entry found → promoted to "mnemonic"
559 - The legacy entry is deleted after promotion
560 - If global "mnemonic" already exists, legacy entries are only cleaned up
561 (no overwrite — the operator explicitly stored a global mnemonic already)
562 - Migration is idempotent
563 """
564
565 def test_VI1_load_promotes_legacy_entry(
566 self,
567 isolated_identity: pathlib.Path,
568 keychain_in_memory: dict[tuple[str, str], str],
569 ) -> None:
570 """VI1: load() finds a legacy per-hub entry and returns its mnemonic."""
571 _seed_identity_toml(isolated_identity, _TEST_HOSTNAME)
572 keychain_in_memory[("muse", _LEGACY_USERNAME_LOCALHOST)] = _TEST_MNEMONIC
573
574 from muse.core.keychain import load
575 result = load()
576 assert result == _TEST_MNEMONIC, (
577 f"Expected legacy mnemonic to be returned, got: {result!r}"
578 )
579
580 def test_VI2_migration_deletes_legacy_entry(
581 self,
582 isolated_identity: pathlib.Path,
583 keychain_in_memory: dict[tuple[str, str], str],
584 ) -> None:
585 """VI2: after load() migrates a legacy entry, the old key is gone."""
586 _seed_identity_toml(isolated_identity, _TEST_HOSTNAME)
587 keychain_in_memory[("muse", _LEGACY_USERNAME_LOCALHOST)] = _TEST_MNEMONIC
588
589 from muse.core.keychain import load
590 load()
591
592 assert ("muse", _LEGACY_USERNAME_LOCALHOST) not in keychain_in_memory, (
593 "Legacy per-hub entry should have been deleted after migration"
594 )
595
596 def test_VI3_migration_writes_global_entry(
597 self,
598 isolated_identity: pathlib.Path,
599 keychain_in_memory: dict[tuple[str, str], str],
600 ) -> None:
601 """VI3: after load() migrates a legacy entry, the global key is written."""
602 _seed_identity_toml(isolated_identity, _TEST_HOSTNAME)
603 keychain_in_memory[("muse", _LEGACY_USERNAME_LOCALHOST)] = _TEST_MNEMONIC
604
605 from muse.core.keychain import load
606 load()
607
608 assert ("muse", _GLOBAL_USERNAME) in keychain_in_memory, (
609 "Global 'mnemonic' key should exist after migration"
610 )
611 assert keychain_in_memory[("muse", _GLOBAL_USERNAME)] == _TEST_MNEMONIC
612
613 def test_VI4_migration_is_idempotent(
614 self,
615 isolated_identity: pathlib.Path,
616 keychain_in_memory: dict[tuple[str, str], str],
617 ) -> None:
618 """VI4: calling load() twice with a legacy entry doesn't corrupt state."""
619 _seed_identity_toml(isolated_identity, _TEST_HOSTNAME)
620 keychain_in_memory[("muse", _LEGACY_USERNAME_LOCALHOST)] = _TEST_MNEMONIC
621
622 from muse.core.keychain import load
623 first = load()
624 second = load()
625
626 assert first == _TEST_MNEMONIC
627 assert second == _TEST_MNEMONIC
628 assert len(keychain_in_memory) == 1
629 assert ("muse", _GLOBAL_USERNAME) in keychain_in_memory
630
631 def test_VI5_global_wins_over_legacy(
632 self,
633 isolated_identity: pathlib.Path,
634 keychain_in_memory: dict[tuple[str, str], str],
635 ) -> None:
636 """VI5: if both global and legacy entries exist, global is returned unchanged."""
637 _seed_identity_toml(isolated_identity, _STAGING_HOSTNAME)
638 other_mnemonic = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
639 keychain_in_memory[("muse", _GLOBAL_USERNAME)] = _TEST_MNEMONIC
640 keychain_in_memory[("muse", _LEGACY_USERNAME_STAGING)] = other_mnemonic
641
642 from muse.core.keychain import load
643 result = load()
644
645 assert result == _TEST_MNEMONIC, (
646 "Global entry should win — legacy entry must not overwrite it"
647 )
648
649 def test_VI6_two_legacy_hubs_migrate_to_one_slot(
650 self,
651 isolated_identity: pathlib.Path,
652 keychain_in_memory: dict[tuple[str, str], str],
653 ) -> None:
654 """VI6: two legacy per-hub entries for different hubs both collapse to one global entry."""
655 _seed_identity_toml(isolated_identity, _TEST_HOSTNAME, _STAGING_HOSTNAME)
656 keychain_in_memory[("muse", _LEGACY_USERNAME_LOCALHOST)] = _TEST_MNEMONIC
657 keychain_in_memory[("muse", _LEGACY_USERNAME_STAGING)] = _TEST_MNEMONIC
658
659 from muse.core.keychain import load
660 result = load()
661
662 assert result == _TEST_MNEMONIC
663 remaining_keys = {usr for (svc, usr) in keychain_in_memory if svc == "muse"}
664 assert remaining_keys == {_GLOBAL_USERNAME}, (
665 f"Only global key should remain after migration, got: {remaining_keys}"
666 )
File History 2 commits
sha256:cb2da6c61116ad1ab98d03747c21d6f66485839c7b4efd7d0124db0f8aa14e41 refactor(harmony): move auto_apply + record_resolutions int… Sonnet 4.6 minor 24 days ago
sha256:596a4963c21debb14d9ef51e23c2ca9f825b602ab8585f69caca35eb81bcac77 chore(harmony): baseline audit — Phase 0 of issue #16 Sonnet 4.6 29 days ago