gabriel / muse public
test_core_keychain.py python
647 lines 25.2 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 135 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
67 cli = None
68 runner = CliRunner()
69
70 _TEST_HUB = "https://localhost:1337"
71 _TEST_HOSTNAME = "localhost:1337"
72 _TEST_MNEMONIC = (
73 "abandon abandon abandon abandon abandon abandon abandon abandon "
74 "abandon abandon abandon about"
75 )
76
77
78 # ---------------------------------------------------------------------------
79 # Fixtures
80 # ---------------------------------------------------------------------------
81
82
83 @pytest.fixture()
84 def keychain_in_memory(monkeypatch: pytest.MonkeyPatch) -> dict[tuple[str, str], str]:
85 """Patch keyring to use an in-memory dict as the backend.
86
87 Returns the dict so tests can inspect it directly.
88 """
89 store: dict[tuple[str, str], str] = {}
90
91 import keyring
92 monkeypatch.setattr(keyring, "set_password",
93 lambda svc, usr, pwd: store.__setitem__((svc, usr), pwd))
94 monkeypatch.setattr(keyring, "get_password",
95 lambda svc, usr: store.get((svc, usr)))
96
97 import keyring.errors
98
99 def _delete(svc: str, usr: str) -> None:
100 if (svc, usr) not in store:
101 raise keyring.errors.PasswordDeleteError("not found")
102 del store[(svc, usr)]
103
104 monkeypatch.setattr(keyring, "delete_password", _delete)
105
106 # Patch is_available to return True since we have a working in-memory backend
107 import muse.core.keychain as kc_mod
108 monkeypatch.setattr(kc_mod, "is_available", lambda: True)
109
110 monkeypatch.delenv("MUSE_KEYCHAIN_BACKEND", raising=False)
111 return store # type: ignore[return-value]
112
113
114 @pytest.fixture()
115 def isolated_identity(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
116 fake_dir = tmp_path / "dot_muse"
117 fake_dir.mkdir()
118 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir)
119 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_dir / "identity.toml")
120 return fake_dir
121
122
123 @pytest.fixture()
124 def isolated_keys(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
125 keys_dir = tmp_path / "keys"
126 keys_dir.mkdir()
127 monkeypatch.setattr("muse.core.keypair._KEYS_DIR", keys_dir)
128 return keys_dir
129
130
131 @pytest.fixture()
132 def repo_with_hub(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
133 muse_dir = tmp_path / ".muse"
134 muse_dir.mkdir()
135 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
136 (muse_dir / "refs" / "heads").mkdir(parents=True)
137 (muse_dir / "objects").mkdir()
138 (muse_dir / "commits").mkdir()
139 (muse_dir / "snapshots").mkdir()
140 (muse_dir / "config.toml").write_text(f'[hub]\nurl = "{_TEST_HUB}"\n')
141 monkeypatch.chdir(tmp_path)
142 return tmp_path
143
144
145 # ---------------------------------------------------------------------------
146 # I keychain module API — single global mnemonic (no hub_url)
147 # ---------------------------------------------------------------------------
148
149
150 class TestKeychainApiI:
151 def test_I1_store_load_roundtrip(
152 self, keychain_in_memory: dict[tuple[str, str], str]
153 ) -> None:
154 """I1: store(mnemonic) and load() round-trip — no hub URL required."""
155 from muse.core.keychain import store, load
156 assert store(_TEST_MNEMONIC)
157 assert load() == _TEST_MNEMONIC
158
159 def test_I2_load_missing_returns_none(
160 self, keychain_in_memory: dict[tuple[str, str], str]
161 ) -> None:
162 """I2: load() returns None when nothing has been stored."""
163 from muse.core.keychain import load
164 assert load() is None
165
166 def test_I3_delete_removes_entry(
167 self, keychain_in_memory: dict[tuple[str, str], str]
168 ) -> None:
169 """I3: delete() removes the global entry; subsequent load() returns None."""
170 from muse.core.keychain import store, load, delete
171 store(_TEST_MNEMONIC)
172 assert delete()
173 assert load() is None
174
175 def test_I4_delete_missing_returns_false(
176 self, keychain_in_memory: dict[tuple[str, str], str]
177 ) -> None:
178 """I4: delete() on empty keychain returns False without raising."""
179 from muse.core.keychain import delete
180 assert not delete()
181
182 def test_I5_disabled_backend_not_available(
183 self, monkeypatch: pytest.MonkeyPatch
184 ) -> None:
185 """I5: is_available() is False when MUSE_KEYCHAIN_BACKEND=disabled."""
186 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
187 from muse.core import keychain
188 import importlib
189 importlib.reload(keychain)
190 assert not keychain.is_available()
191
192 def test_I6_load_is_hub_agnostic(
193 self, keychain_in_memory: dict[tuple[str, str], str]
194 ) -> None:
195 """I6: the same mnemonic is returned no matter which hub context is active."""
196 from muse.core.keychain import store, load
197 store(_TEST_MNEMONIC)
198 # load() takes no hub_url — mnemonic is global
199 assert load() == _TEST_MNEMONIC
200 assert load() == _TEST_MNEMONIC # idempotent
201
202 def test_I7_store_is_idempotent(
203 self, keychain_in_memory: dict[tuple[str, str], str]
204 ) -> None:
205 """I7: calling store() twice with the same value does not corrupt state."""
206 from muse.core.keychain import store, load
207 assert store(_TEST_MNEMONIC)
208 assert store(_TEST_MNEMONIC)
209 assert load() == _TEST_MNEMONIC
210
211 def test_I8_single_keychain_entry(
212 self, keychain_in_memory: dict[tuple[str, str], str]
213 ) -> None:
214 """I8: exactly one keychain entry exists after store() — no per-hub duplicates."""
215 from muse.core.keychain import store
216 store(_TEST_MNEMONIC)
217 # Only one entry in the entire in-memory store
218 assert len(keychain_in_memory) == 1
219 # And its username is the global constant, not a hostname-scoped key
220 (service, username), value = next(iter(keychain_in_memory.items()))
221 assert service == "muse"
222 assert "/" not in username, (
223 f"Keychain username '{username}' looks per-hub — expected a global key with no '/'"
224 )
225 assert value == _TEST_MNEMONIC
226
227
228 # ---------------------------------------------------------------------------
229 # II identity.toml never contains mnemonic
230 # ---------------------------------------------------------------------------
231
232
233 class TestIdentityNoMnemonicII:
234 def test_II1_save_stores_mnemonic_in_keychain(
235 self,
236 isolated_identity: pathlib.Path,
237 keychain_in_memory: dict[tuple[str, str], str],
238 ) -> None:
239 """save_identity with mnemonic puts it in keychain, not TOML."""
240 from muse.core.identity import save_identity, IdentityEntry
241 entry: IdentityEntry = {
242 "type": "human",
243 "handle": "gabriel",
244 "algorithm": "ed25519",
245 "fingerprint": "a" * 64,
246 }
247 save_identity(_TEST_HUB, entry, mnemonic=_TEST_MNEMONIC)
248
249 # Keychain has the mnemonic
250 from muse.core.keychain import load as kc_load
251 assert kc_load() == _TEST_MNEMONIC
252
253 def test_II2_toml_has_no_mnemonic_key(
254 self,
255 isolated_identity: pathlib.Path,
256 keychain_in_memory: dict[tuple[str, str], str],
257 ) -> None:
258 """The TOML file written by save_identity must not contain 'mnemonic'."""
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 toml_text = (isolated_identity / "identity.toml").read_text()
269 assert "mnemonic" not in toml_text.lower(), (
270 f"'mnemonic' found in TOML:\n{toml_text}"
271 )
272
273 def test_II3_load_retrieves_mnemonic_from_keychain(
274 self,
275 isolated_identity: pathlib.Path,
276 keychain_in_memory: dict[tuple[str, str], str],
277 ) -> None:
278 """load_identity fetches the mnemonic from keychain and injects it."""
279 from muse.core.identity import save_identity, load_identity, IdentityEntry
280 entry: IdentityEntry = {
281 "type": "human",
282 "handle": "gabriel",
283 "algorithm": "ed25519",
284 "fingerprint": "a" * 64,
285 }
286 save_identity(_TEST_HUB, entry, mnemonic=_TEST_MNEMONIC)
287
288 loaded = load_identity(_TEST_HUB)
289 assert loaded is not None
290 assert loaded.get("mnemonic") == _TEST_MNEMONIC
291
292 def test_II4_toml_has_no_key_source_field(
293 self,
294 isolated_identity: pathlib.Path,
295 keychain_in_memory: dict[tuple[str, str], str],
296 ) -> None:
297 """TOML must not contain a key_source field — derivation method is implied."""
298 from muse.core.identity import save_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 toml_text = (isolated_identity / "identity.toml").read_text()
308 assert "key_source" not in toml_text
309
310
311 # ---------------------------------------------------------------------------
312 # III keygen stores mnemonic in keychain
313 # ---------------------------------------------------------------------------
314
315
316 class TestKeygenUsesKeychainIII:
317 def test_III1_keygen_json_stdout_no_mnemonic(
318 self,
319 isolated_identity: pathlib.Path,
320 isolated_keys: pathlib.Path,
321 repo_with_hub: pathlib.Path,
322 keychain_in_memory: dict[tuple[str, str], str],
323 monkeypatch: pytest.MonkeyPatch,
324 ) -> None:
325 """III1: muse auth keygen --json stdout must not contain 'mnemonic'."""
326 from muse.core import bip39 as bip39_mod
327 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _TEST_MNEMONIC)
328
329 result = runner.invoke(cli, ["auth", "keygen", "--hub", _TEST_HUB, "--json"])
330 assert result.exit_code == 0, f"keygen failed:\n{result.output}"
331
332 json_lines = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")]
333 assert json_lines, "No JSON output found"
334 for line in json_lines:
335 data = json.loads(line)
336 assert "mnemonic" not in data, f"'mnemonic' key in JSON output: {data}"
337
338 def test_III2_keygen_toml_has_no_mnemonic(
339 self,
340 isolated_identity: pathlib.Path,
341 isolated_keys: pathlib.Path,
342 repo_with_hub: pathlib.Path,
343 keychain_in_memory: dict[tuple[str, str], str],
344 monkeypatch: pytest.MonkeyPatch,
345 ) -> None:
346 """III2: identity.toml after keygen must not have mnemonic in plaintext."""
347 from muse.core import bip39 as bip39_mod
348 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _TEST_MNEMONIC)
349
350 runner.invoke(cli, ["auth", "keygen", "--hub", _TEST_HUB, "--json"])
351
352 toml_file = isolated_identity / "identity.toml"
353 assert toml_file.exists(), "identity.toml not created"
354 content = toml_file.read_text()
355 assert "mnemonic" not in content.lower(), f"mnemonic in TOML:\n{content}"
356
357 def test_III3_keychain_holds_mnemonic_after_keygen(
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 """III3: keychain holds the mnemonic globally after keygen (no hub scoping)."""
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 from muse.core.keychain import load as kc_load
372 stored = kc_load()
373 assert stored == _TEST_MNEMONIC, f"Keychain does not have mnemonic, got: {stored!r}"
374
375
376 # ---------------------------------------------------------------------------
377 # IV keychain disabled
378 # ---------------------------------------------------------------------------
379
380
381 class TestKeychainDisabledIV:
382 def test_IV1_is_available_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
383 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
384 from muse.core import keychain
385 import importlib
386 importlib.reload(keychain)
387 assert not keychain.is_available()
388
389 def test_IV2_store_returns_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
390 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
391 from muse.core import keychain
392 import importlib
393 importlib.reload(keychain)
394 assert not keychain.store(_TEST_MNEMONIC)
395
396 def test_IV3_load_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
397 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
398 from muse.core import keychain
399 import importlib
400 importlib.reload(keychain)
401 assert keychain.load() is None
402
403 def test_IV4_keygen_succeeds_without_keychain(
404 self,
405 isolated_identity: pathlib.Path,
406 isolated_keys: pathlib.Path,
407 repo_with_hub: pathlib.Path,
408 monkeypatch: pytest.MonkeyPatch,
409 ) -> None:
410 """IV4: keygen still works when keychain is disabled (mnemonic is ephemeral)."""
411 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
412 from muse.core import bip39 as bip39_mod
413 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _TEST_MNEMONIC)
414
415 result = runner.invoke(cli, ["auth", "keygen", "--hub", _TEST_HUB, "--json"])
416 assert result.exit_code == 0, f"keygen failed with disabled keychain:\n{result.output}"
417
418
419 # ---------------------------------------------------------------------------
420 # V keychain unavailable — operator must be warned (CRITICAL-1)
421 # ---------------------------------------------------------------------------
422
423
424 class TestKeychainUnavailableWarnsV:
425 """V When the keychain is truly unavailable (not intentionally disabled),
426 save_identity must warn the operator that the mnemonic is ephemeral.
427
428 MUSE_KEYCHAIN_BACKEND=disabled is CI/test mode and must stay silent.
429 Any other cause of is_available()==False is an operational failure and
430 demands a log.warning so the operator knows their root key is not persisted.
431 """
432
433 _entry = {
434 "type": "human",
435 "handle": "gabriel",
436 "algorithm": "ed25519",
437 "fingerprint": "a" * 64,
438 }
439
440 def test_V1_warns_when_keychain_unavailable(
441 self,
442 isolated_identity: pathlib.Path,
443 monkeypatch: pytest.MonkeyPatch,
444 caplog: pytest.LogCaptureFixture,
445 ) -> None:
446 """V1: save_identity logs a warning when keychain is unavailable
447 for a non-intentional reason (no backend, library not installed, etc.).
448
449 Simulate: is_available() returns False but MUSE_KEYCHAIN_BACKEND is not set.
450 """
451 import logging
452 from unittest.mock import patch
453 from muse.core import keychain as kc_mod
454 from muse.core.identity import save_identity
455
456 monkeypatch.delenv("MUSE_KEYCHAIN_BACKEND", raising=False)
457
458 with patch.object(kc_mod, "is_available", return_value=False):
459 with caplog.at_level(logging.WARNING, logger="muse.core.identity"):
460 save_identity(_TEST_HUB, self._entry, mnemonic=_TEST_MNEMONIC) # type: ignore[arg-type]
461
462 warning_messages = [
463 r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING
464 ]
465 assert warning_messages, (
466 "Expected a warning about unavailable keychain — got none.\n"
467 f"All log records: {[r.getMessage() for r in caplog.records]}"
468 )
469 combined = " ".join(warning_messages).lower()
470 assert "keychain" in combined or "ephemeral" in combined, (
471 f"Warning must mention 'keychain' or 'ephemeral': {warning_messages}"
472 )
473
474 def test_V2_silent_when_keychain_intentionally_disabled(
475 self,
476 isolated_identity: pathlib.Path,
477 monkeypatch: pytest.MonkeyPatch,
478 caplog: pytest.LogCaptureFixture,
479 ) -> None:
480 """V2: no keychain warning when MUSE_KEYCHAIN_BACKEND=disabled (CI/test mode).
481
482 The disabled env var signals intentional ephemeral operation — the
483 operator has opted out of keychain storage on purpose, so no warning
484 should fire.
485 """
486 import logging
487 from muse.core.identity import save_identity
488
489 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
490
491 with caplog.at_level(logging.WARNING, logger="muse.core.identity"):
492 save_identity(_TEST_HUB, self._entry, mnemonic=_TEST_MNEMONIC) # type: ignore[arg-type]
493
494 keychain_warnings = [
495 r.getMessage()
496 for r in caplog.records
497 if r.levelno >= logging.WARNING
498 and ("keychain" in r.getMessage().lower() or "ephemeral" in r.getMessage().lower())
499 ]
500 assert not keychain_warnings, (
501 f"Unexpected keychain warning in intentional CI/disabled mode: {keychain_warnings}"
502 )
503
504
505 # ---------------------------------------------------------------------------
506 # VI legacy per-hub mnemonic migration
507 # ---------------------------------------------------------------------------
508
509
510 _STAGING_HUB = "https://staging.musehub.ai"
511 _STAGING_HOSTNAME = "staging.musehub.ai"
512 _LEGACY_USERNAME_LOCALHOST = "localhost:1337/mnemonic"
513 _LEGACY_USERNAME_STAGING = "staging.musehub.ai/mnemonic"
514 _GLOBAL_USERNAME = "mnemonic"
515
516
517 def _seed_identity_toml(isolated_identity: pathlib.Path, *hostnames: str) -> None:
518 """Write a minimal identity.toml so the migration scanner finds the hostnames."""
519 lines = []
520 for h in hostnames:
521 lines.append(f'["{h}"]')
522 lines.append('type = "human"')
523 lines.append('handle = "gabriel"')
524 lines.append('algorithm = "ed25519"')
525 lines.append(f'fingerprint = "{"a" * 64}"')
526 lines.append('hd_path = "m/1075233755\'/0\'/0\'/0\'/0\'/0\'"')
527 lines.append("")
528 (isolated_identity / "identity.toml").write_text("\n".join(lines))
529
530
531 class TestLegacyMigrationVI:
532 """VI Transparent migration from per-hub keychain entries to a single global entry.
533
534 Users who ran older versions of muse have entries keyed as
535 "{hostname}/mnemonic" in the keychain. load() must silently promote
536 one of these to the global "mnemonic" slot and delete the legacy entry.
537
538 Migration rules:
539 - Any "{hostname}/mnemonic" entry found → promoted to "mnemonic"
540 - The legacy entry is deleted after promotion
541 - If global "mnemonic" already exists, legacy entries are only cleaned up
542 (no overwrite — the operator explicitly stored a global mnemonic already)
543 - Migration is idempotent
544 """
545
546 def test_VI1_load_promotes_legacy_entry(
547 self,
548 isolated_identity: pathlib.Path,
549 keychain_in_memory: dict[tuple[str, str], str],
550 ) -> None:
551 """VI1: load() finds a legacy per-hub entry and returns its mnemonic."""
552 _seed_identity_toml(isolated_identity, _TEST_HOSTNAME)
553 keychain_in_memory[("muse", _LEGACY_USERNAME_LOCALHOST)] = _TEST_MNEMONIC
554
555 from muse.core.keychain import load
556 result = load()
557 assert result == _TEST_MNEMONIC, (
558 f"Expected legacy mnemonic to be returned, got: {result!r}"
559 )
560
561 def test_VI2_migration_deletes_legacy_entry(
562 self,
563 isolated_identity: pathlib.Path,
564 keychain_in_memory: dict[tuple[str, str], str],
565 ) -> None:
566 """VI2: after load() migrates a legacy entry, the old key is gone."""
567 _seed_identity_toml(isolated_identity, _TEST_HOSTNAME)
568 keychain_in_memory[("muse", _LEGACY_USERNAME_LOCALHOST)] = _TEST_MNEMONIC
569
570 from muse.core.keychain import load
571 load()
572
573 assert ("muse", _LEGACY_USERNAME_LOCALHOST) not in keychain_in_memory, (
574 "Legacy per-hub entry should have been deleted after migration"
575 )
576
577 def test_VI3_migration_writes_global_entry(
578 self,
579 isolated_identity: pathlib.Path,
580 keychain_in_memory: dict[tuple[str, str], str],
581 ) -> None:
582 """VI3: after load() migrates a legacy entry, the global key is written."""
583 _seed_identity_toml(isolated_identity, _TEST_HOSTNAME)
584 keychain_in_memory[("muse", _LEGACY_USERNAME_LOCALHOST)] = _TEST_MNEMONIC
585
586 from muse.core.keychain import load
587 load()
588
589 assert ("muse", _GLOBAL_USERNAME) in keychain_in_memory, (
590 "Global 'mnemonic' key should exist after migration"
591 )
592 assert keychain_in_memory[("muse", _GLOBAL_USERNAME)] == _TEST_MNEMONIC
593
594 def test_VI4_migration_is_idempotent(
595 self,
596 isolated_identity: pathlib.Path,
597 keychain_in_memory: dict[tuple[str, str], str],
598 ) -> None:
599 """VI4: calling load() twice with a legacy entry doesn't corrupt state."""
600 _seed_identity_toml(isolated_identity, _TEST_HOSTNAME)
601 keychain_in_memory[("muse", _LEGACY_USERNAME_LOCALHOST)] = _TEST_MNEMONIC
602
603 from muse.core.keychain import load
604 first = load()
605 second = load()
606
607 assert first == _TEST_MNEMONIC
608 assert second == _TEST_MNEMONIC
609 assert len(keychain_in_memory) == 1
610 assert ("muse", _GLOBAL_USERNAME) in keychain_in_memory
611
612 def test_VI5_global_wins_over_legacy(
613 self,
614 isolated_identity: pathlib.Path,
615 keychain_in_memory: dict[tuple[str, str], str],
616 ) -> None:
617 """VI5: if both global and legacy entries exist, global is returned unchanged."""
618 _seed_identity_toml(isolated_identity, _STAGING_HOSTNAME)
619 other_mnemonic = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
620 keychain_in_memory[("muse", _GLOBAL_USERNAME)] = _TEST_MNEMONIC
621 keychain_in_memory[("muse", _LEGACY_USERNAME_STAGING)] = other_mnemonic
622
623 from muse.core.keychain import load
624 result = load()
625
626 assert result == _TEST_MNEMONIC, (
627 "Global entry should win — legacy entry must not overwrite it"
628 )
629
630 def test_VI6_two_legacy_hubs_migrate_to_one_slot(
631 self,
632 isolated_identity: pathlib.Path,
633 keychain_in_memory: dict[tuple[str, str], str],
634 ) -> None:
635 """VI6: two legacy per-hub entries for different hubs both collapse to one global entry."""
636 _seed_identity_toml(isolated_identity, _TEST_HOSTNAME, _STAGING_HOSTNAME)
637 keychain_in_memory[("muse", _LEGACY_USERNAME_LOCALHOST)] = _TEST_MNEMONIC
638 keychain_in_memory[("muse", _LEGACY_USERNAME_STAGING)] = _TEST_MNEMONIC
639
640 from muse.core.keychain import load
641 result = load()
642
643 assert result == _TEST_MNEMONIC
644 remaining_keys = {usr for (svc, usr) in keychain_in_memory if svc == "muse"}
645 assert remaining_keys == {_GLOBAL_USERNAME}, (
646 f"Only global key should remain after migration, got: {remaining_keys}"
647 )
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 135 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 141 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 144 days ago