gabriel / muse public
test_core_keychain.py python
447 lines 16.5 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 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 Coverage
7 --------
8 I keychain module API
9 I1 store → load round-trip returns the stored phrase
10 I2 load returns None when no entry exists
11 I3 delete removes the entry; load returns None afterward
12 I4 delete on missing entry returns False without raising
13 I5 is_available returns False when MUSE_KEYCHAIN_BACKEND=disabled
14
15 II identity.toml never contains mnemonic
16 II1 save_identity with mnemonic kwarg stores in keychain, not TOML
17 II2 TOML written by save_identity has no "mnemonic" key
18 II3 load_identity retrieves mnemonic from keychain, not TOML
19 II4 identity TOML has no key_source field (derivation is always HD)
20
21 III keygen stores mnemonic in keychain
22 III1 muse auth keygen --json stdout has no "mnemonic" key
23 III2 identity.toml written after keygen has no mnemonic field
24 III3 keychain holds the mnemonic after keygen
25
26 IV keychain disabled (MUSE_KEYCHAIN_BACKEND=disabled)
27 IV1 is_available() is False
28 IV2 store() returns False without raising
29 IV3 load() returns None without raising
30 IV4 muse auth keygen still succeeds (mnemonic is ephemeral)
31 """
32
33 from __future__ import annotations
34
35 import json
36 import os
37 import pathlib
38
39 import pytest
40
41 try:
42 import tomllib
43 except ModuleNotFoundError:
44 import tomli as tomllib # type: ignore[no-reuse-def]
45
46 from tests.cli_test_helper import CliRunner
47
48 cli = None
49 runner = CliRunner()
50
51 _TEST_HUB = "http://localhost:10003"
52 _TEST_HOSTNAME = "localhost:10003"
53 _TEST_MNEMONIC = (
54 "abandon abandon abandon abandon abandon abandon abandon abandon "
55 "abandon abandon abandon about"
56 )
57
58
59 # ---------------------------------------------------------------------------
60 # Fixtures
61 # ---------------------------------------------------------------------------
62
63
64 @pytest.fixture()
65 def keychain_in_memory(monkeypatch: pytest.MonkeyPatch) -> dict:
66 """Patch keyring to use an in-memory dict as the backend.
67
68 Returns the dict so tests can inspect it directly.
69 """
70 store: dict[tuple[str, str], str] = {}
71
72 import keyring
73 monkeypatch.setattr(keyring, "set_password",
74 lambda svc, usr, pwd: store.__setitem__((svc, usr), pwd))
75 monkeypatch.setattr(keyring, "get_password",
76 lambda svc, usr: store.get((svc, usr)))
77
78 import keyring.errors
79
80 def _delete(svc: str, usr: str) -> None:
81 if (svc, usr) not in store:
82 raise keyring.errors.PasswordDeleteError("not found")
83 del store[(svc, usr)]
84
85 monkeypatch.setattr(keyring, "delete_password", _delete)
86
87 # Patch is_available to return True since we have a working in-memory backend
88 import muse.core.keychain as kc_mod
89 monkeypatch.setattr(kc_mod, "is_available", lambda: True)
90
91 monkeypatch.delenv("MUSE_KEYCHAIN_BACKEND", raising=False)
92 return store # type: ignore[return-value]
93
94
95 @pytest.fixture()
96 def isolated_identity(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
97 fake_dir = tmp_path / "dot_muse"
98 fake_dir.mkdir()
99 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir)
100 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_dir / "identity.toml")
101 return fake_dir
102
103
104 @pytest.fixture()
105 def isolated_keys(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
106 keys_dir = tmp_path / "keys"
107 keys_dir.mkdir()
108 monkeypatch.setattr("muse.core.keypair._KEYS_DIR", keys_dir)
109 return keys_dir
110
111
112 @pytest.fixture()
113 def repo_with_hub(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
114 muse_dir = tmp_path / ".muse"
115 muse_dir.mkdir()
116 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
117 (muse_dir / "refs" / "heads").mkdir(parents=True)
118 (muse_dir / "objects").mkdir()
119 (muse_dir / "commits").mkdir()
120 (muse_dir / "snapshots").mkdir()
121 (muse_dir / "config.toml").write_text(f'[hub]\nurl = "{_TEST_HUB}"\n')
122 monkeypatch.chdir(tmp_path)
123 return tmp_path
124
125
126 # ---------------------------------------------------------------------------
127 # I keychain module API
128 # ---------------------------------------------------------------------------
129
130
131 class TestKeychainApiI:
132 def test_I1_store_load_roundtrip(
133 self, keychain_in_memory: dict, monkeypatch: pytest.MonkeyPatch
134 ) -> None:
135 from muse.core.keychain import store, load
136 assert store(_TEST_HUB, _TEST_MNEMONIC)
137 assert load(_TEST_HUB) == _TEST_MNEMONIC
138
139 def test_I2_load_missing_returns_none(
140 self, keychain_in_memory: dict
141 ) -> None:
142 from muse.core.keychain import load
143 assert load("http://not-registered.example.com") is None
144
145 def test_I3_delete_removes_entry(
146 self, keychain_in_memory: dict
147 ) -> None:
148 from muse.core.keychain import store, load, delete
149 store(_TEST_HUB, _TEST_MNEMONIC)
150 assert delete(_TEST_HUB)
151 assert load(_TEST_HUB) is None
152
153 def test_I4_delete_missing_returns_false(
154 self, keychain_in_memory: dict
155 ) -> None:
156 from muse.core.keychain import delete
157 assert not delete("http://not-registered.example.com")
158
159 def test_I5_disabled_backend_not_available(
160 self, monkeypatch: pytest.MonkeyPatch
161 ) -> None:
162 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
163 from muse.core import keychain
164 import importlib
165 importlib.reload(keychain)
166 assert not keychain.is_available()
167
168
169 # ---------------------------------------------------------------------------
170 # II identity.toml never contains mnemonic
171 # ---------------------------------------------------------------------------
172
173
174 class TestIdentityNoMnemonicII:
175 def test_II1_save_stores_mnemonic_in_keychain(
176 self,
177 isolated_identity: pathlib.Path,
178 keychain_in_memory: dict,
179 ) -> None:
180 """save_identity with mnemonic puts it in keychain, not TOML."""
181 from muse.core.identity import save_identity, IdentityEntry
182 entry: IdentityEntry = {
183 "type": "human",
184 "handle": "gabriel",
185 "key_path": "/fake/key.pem",
186 "algorithm": "ed25519",
187 "fingerprint": "a" * 64,
188 }
189 save_identity(_TEST_HUB, entry, mnemonic=_TEST_MNEMONIC)
190
191 # Keychain has the mnemonic
192 from muse.core.keychain import load as kc_load
193 assert kc_load(_TEST_HUB) == _TEST_MNEMONIC
194
195 def test_II2_toml_has_no_mnemonic_key(
196 self,
197 isolated_identity: pathlib.Path,
198 keychain_in_memory: dict,
199 ) -> None:
200 """The TOML file written by save_identity must not contain 'mnemonic'."""
201 from muse.core.identity import save_identity, IdentityEntry
202 entry: IdentityEntry = {
203 "type": "human",
204 "handle": "gabriel",
205 "key_path": "/fake/key.pem",
206 "algorithm": "ed25519",
207 "fingerprint": "a" * 64,
208 }
209 save_identity(_TEST_HUB, entry, mnemonic=_TEST_MNEMONIC)
210
211 toml_text = (isolated_identity / "identity.toml").read_text()
212 assert "mnemonic" not in toml_text.lower(), (
213 f"'mnemonic' found in TOML:\n{toml_text}"
214 )
215
216 def test_II3_load_retrieves_mnemonic_from_keychain(
217 self,
218 isolated_identity: pathlib.Path,
219 keychain_in_memory: dict,
220 ) -> None:
221 """load_identity fetches the mnemonic from keychain and injects it."""
222 from muse.core.identity import save_identity, load_identity, IdentityEntry
223 entry: IdentityEntry = {
224 "type": "human",
225 "handle": "gabriel",
226 "key_path": "/fake/key.pem",
227 "algorithm": "ed25519",
228 "fingerprint": "a" * 64,
229 }
230 save_identity(_TEST_HUB, entry, mnemonic=_TEST_MNEMONIC)
231
232 loaded = load_identity(_TEST_HUB)
233 assert loaded is not None
234 assert loaded.get("mnemonic") == _TEST_MNEMONIC
235
236 def test_II4_toml_has_no_key_source_field(
237 self,
238 isolated_identity: pathlib.Path,
239 keychain_in_memory: dict,
240 ) -> None:
241 """TOML must not contain a key_source field — derivation method is implied."""
242 from muse.core.identity import save_identity, IdentityEntry
243 entry: IdentityEntry = {
244 "type": "human",
245 "handle": "gabriel",
246 "key_path": "/fake/key.pem",
247 "algorithm": "ed25519",
248 "fingerprint": "a" * 64,
249 }
250 save_identity(_TEST_HUB, entry, mnemonic=_TEST_MNEMONIC)
251
252 toml_text = (isolated_identity / "identity.toml").read_text()
253 assert "key_source" not in toml_text
254
255
256 # ---------------------------------------------------------------------------
257 # III keygen stores mnemonic in keychain
258 # ---------------------------------------------------------------------------
259
260
261 class TestKeygenUsesKeychainIII:
262 def test_III1_keygen_json_stdout_no_mnemonic(
263 self,
264 isolated_identity: pathlib.Path,
265 isolated_keys: pathlib.Path,
266 repo_with_hub: pathlib.Path,
267 keychain_in_memory: dict,
268 monkeypatch: pytest.MonkeyPatch,
269 ) -> None:
270 """III1: muse auth keygen --json stdout must not contain 'mnemonic'."""
271 from muse.core import bip39 as bip39_mod
272 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _TEST_MNEMONIC)
273
274 result = runner.invoke(cli, ["auth", "keygen", "--hub", _TEST_HUB, "--json"])
275 assert result.exit_code == 0, f"keygen failed:\n{result.output}"
276
277 json_lines = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")]
278 assert json_lines, "No JSON output found"
279 for line in json_lines:
280 data = json.loads(line)
281 assert "mnemonic" not in data, f"'mnemonic' key in JSON output: {data}"
282
283 def test_III2_keygen_toml_has_no_mnemonic(
284 self,
285 isolated_identity: pathlib.Path,
286 isolated_keys: pathlib.Path,
287 repo_with_hub: pathlib.Path,
288 keychain_in_memory: dict,
289 monkeypatch: pytest.MonkeyPatch,
290 ) -> None:
291 """III2: identity.toml after keygen must not have mnemonic in plaintext."""
292 from muse.core import bip39 as bip39_mod
293 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _TEST_MNEMONIC)
294
295 runner.invoke(cli, ["auth", "keygen", "--hub", _TEST_HUB, "--json"])
296
297 toml_file = isolated_identity / "identity.toml"
298 assert toml_file.exists(), "identity.toml not created"
299 content = toml_file.read_text()
300 assert "mnemonic" not in content.lower(), f"mnemonic in TOML:\n{content}"
301
302 def test_III3_keychain_holds_mnemonic_after_keygen(
303 self,
304 isolated_identity: pathlib.Path,
305 isolated_keys: pathlib.Path,
306 repo_with_hub: pathlib.Path,
307 keychain_in_memory: dict,
308 monkeypatch: pytest.MonkeyPatch,
309 ) -> None:
310 """III3: the keychain has the generated mnemonic after keygen."""
311 from muse.core import bip39 as bip39_mod
312 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _TEST_MNEMONIC)
313
314 runner.invoke(cli, ["auth", "keygen", "--hub", _TEST_HUB, "--json"])
315
316 from muse.core.keychain import load as kc_load
317 stored = kc_load(_TEST_HUB)
318 assert stored == _TEST_MNEMONIC, f"Keychain does not have mnemonic, got: {stored!r}"
319
320
321 # ---------------------------------------------------------------------------
322 # IV keychain disabled
323 # ---------------------------------------------------------------------------
324
325
326 class TestKeychainDisabledIV:
327 def test_IV1_is_available_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
328 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
329 from muse.core import keychain
330 import importlib
331 importlib.reload(keychain)
332 assert not keychain.is_available()
333
334 def test_IV2_store_returns_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
335 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
336 from muse.core import keychain
337 import importlib
338 importlib.reload(keychain)
339 assert not keychain.store(_TEST_HUB, _TEST_MNEMONIC)
340
341 def test_IV3_load_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
342 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
343 from muse.core import keychain
344 import importlib
345 importlib.reload(keychain)
346 assert keychain.load(_TEST_HUB) is None
347
348 def test_IV4_keygen_succeeds_without_keychain(
349 self,
350 isolated_identity: pathlib.Path,
351 isolated_keys: pathlib.Path,
352 repo_with_hub: pathlib.Path,
353 monkeypatch: pytest.MonkeyPatch,
354 ) -> None:
355 """IV4: keygen still works when keychain is disabled (mnemonic is ephemeral)."""
356 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
357 from muse.core import bip39 as bip39_mod
358 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _TEST_MNEMONIC)
359
360 result = runner.invoke(cli, ["auth", "keygen", "--hub", _TEST_HUB, "--json"])
361 assert result.exit_code == 0, f"keygen failed with disabled keychain:\n{result.output}"
362
363
364 # ---------------------------------------------------------------------------
365 # V keychain unavailable — operator must be warned (CRITICAL-1)
366 # ---------------------------------------------------------------------------
367
368
369 class TestKeychainUnavailableWarnsV:
370 """V When the keychain is truly unavailable (not intentionally disabled),
371 save_identity must warn the operator that the mnemonic is ephemeral.
372
373 MUSE_KEYCHAIN_BACKEND=disabled is CI/test mode and must stay silent.
374 Any other cause of is_available()==False is an operational failure and
375 demands a log.warning so the operator knows their root key is not persisted.
376 """
377
378 _entry: dict = {
379 "type": "human",
380 "handle": "gabriel",
381 "algorithm": "ed25519",
382 "fingerprint": "a" * 64,
383 }
384
385 def test_V1_warns_when_keychain_unavailable(
386 self,
387 isolated_identity: pathlib.Path,
388 monkeypatch: pytest.MonkeyPatch,
389 caplog: pytest.LogCaptureFixture,
390 ) -> None:
391 """V1: save_identity logs a warning when keychain is unavailable
392 for a non-intentional reason (no backend, library not installed, etc.).
393
394 Simulate: is_available() returns False but MUSE_KEYCHAIN_BACKEND is not set.
395 """
396 import logging
397 from unittest.mock import patch
398 from muse.core import keychain as kc_mod
399 from muse.core.identity import save_identity
400
401 monkeypatch.delenv("MUSE_KEYCHAIN_BACKEND", raising=False)
402
403 with patch.object(kc_mod, "is_available", return_value=False):
404 with caplog.at_level(logging.WARNING, logger="muse.core.identity"):
405 save_identity(_TEST_HUB, self._entry, mnemonic=_TEST_MNEMONIC) # type: ignore[arg-type]
406
407 warning_messages = [
408 r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING
409 ]
410 assert warning_messages, (
411 "Expected a warning about unavailable keychain — got none.\n"
412 f"All log records: {[r.getMessage() for r in caplog.records]}"
413 )
414 combined = " ".join(warning_messages).lower()
415 assert "keychain" in combined or "ephemeral" in combined, (
416 f"Warning must mention 'keychain' or 'ephemeral': {warning_messages}"
417 )
418
419 def test_V2_silent_when_keychain_intentionally_disabled(
420 self,
421 isolated_identity: pathlib.Path,
422 monkeypatch: pytest.MonkeyPatch,
423 caplog: pytest.LogCaptureFixture,
424 ) -> None:
425 """V2: no keychain warning when MUSE_KEYCHAIN_BACKEND=disabled (CI/test mode).
426
427 The disabled env var signals intentional ephemeral operation — the
428 operator has opted out of keychain storage on purpose, so no warning
429 should fire.
430 """
431 import logging
432 from muse.core.identity import save_identity
433
434 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
435
436 with caplog.at_level(logging.WARNING, logger="muse.core.identity"):
437 save_identity(_TEST_HUB, self._entry, mnemonic=_TEST_MNEMONIC) # type: ignore[arg-type]
438
439 keychain_warnings = [
440 r.getMessage()
441 for r in caplog.records
442 if r.levelno >= logging.WARNING
443 and ("keychain" in r.getMessage().lower() or "ephemeral" in r.getMessage().lower())
444 ]
445 assert not keychain_warnings, (
446 f"Unexpected keychain warning in intentional CI/disabled mode: {keychain_warnings}"
447 )
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 143 days ago