gabriel / muse public
test_hd_keygen_unified.py python
562 lines 23.9 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days ago
1 """Unified TDD tests for HD-only keygen architecture.
2
3 This file validates:
4 - agent_id_to_slot: stable, deterministic, BIP32-safe slot mapping
5 - Human keygen: fresh mnemonic, no --hd flag needed (HD is the only mode)
6 - Agent keygen: derived from operator's mnemonic via derive_agent_sub_seed
7 - run_recover: re-derives same fingerprint from stored mnemonic
8 - No JBOK: generate_keypair must not exist
9 - Integration flow: keygen → agent keygen → recover round-trip
10 """
11
12 from __future__ import annotations
13
14 import base64
15 import hashlib
16 import json
17 import pathlib
18
19 import pytest
20
21 from tests.cli_test_helper import CliRunner
22 from muse.core import keypair as kp_module
23 from muse.core import identity as id_module
24 from muse.core.bip39 import mnemonic_to_seed, validate_mnemonic
25 from muse.core.hdkeys import (
26 DOMAIN_IDENTITY,
27 ENTITY_AGENT,
28 ENTITY_HUMAN,
29 MUSE_PURPOSE,
30 ROLE_SIGN,
31 agent_id_to_slot,
32 derive_agent_sub_seed,
33 derive_identity_key,
34 muse_path,
35 )
36
37 runner = CliRunner()
38
39 _HUB = "https://localhost:1337"
40 _HOSTNAME = "localhost:1337"
41 # A well-known BIP39 test mnemonic (abandon × 11 + about)
42 _TEST_MNEMONIC_12 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
43
44
45 # ---------------------------------------------------------------------------
46 # Helpers
47 # ---------------------------------------------------------------------------
48
49
50 def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
51 fake_home = tmp_path / "home"
52 fake_home.mkdir(parents=True, exist_ok=True)
53 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
54 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
55 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
56 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
57 return fake_home
58
59
60 _kc_store: dict[str, str] = {}
61
62
63 def _patch_keychain(monkeypatch: pytest.MonkeyPatch) -> dict[str, str]:
64 """Isolate the keychain from the real OS keychain."""
65 _kc_store.clear()
66 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
67 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc_store.get("mnemonic"))
68 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc_store.__setitem__("mnemonic", m))
69 monkeypatch.setattr("muse.core.keychain.delete", lambda: _kc_store.pop("mnemonic", None))
70 return _kc_store
71
72
73 def _keygen(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path,
74 extra_args: list[str] | None = None):
75 """Run ``muse auth keygen --hub <HUB>`` and return (fake_home, result).
76
77 Patches both the home directory and the keychain so tests are hermetic.
78 """
79 fake_home = _patch_home(monkeypatch, tmp_path)
80 _patch_keychain(monkeypatch)
81 args = ["auth", "keygen", "--hub", _HUB] + (extra_args or [])
82 result = runner.invoke(None, args)
83 return fake_home, result
84
85
86 # ---------------------------------------------------------------------------
87 # agent_id_to_slot — unit tests
88 # ---------------------------------------------------------------------------
89
90
91 class TestAgentIdToSlot:
92 """agent_id_to_slot must map handle strings to stable, valid BIP32 indices."""
93
94 def test_returns_int(self) -> None:
95 slot = agent_id_to_slot("my-agent")
96 assert isinstance(slot, int)
97
98 def test_in_valid_bip32_range(self) -> None:
99 """All slots must be in [0, 2^31 - 1] (hardened offset applied by caller)."""
100 for handle in ["alpha", "beta", "gamma-007", "a" * 100]:
101 slot = agent_id_to_slot(handle)
102 assert 0 <= slot <= 0x7FFF_FFFF, f"slot={slot} out of range for {handle!r}"
103
104 def test_deterministic(self) -> None:
105 """Same handle must always produce the same slot."""
106 handle = "agentception-abc123"
107 assert agent_id_to_slot(handle) == agent_id_to_slot(handle)
108
109 def test_distinct_handles_likely_distinct_slots(self) -> None:
110 """Different handles should not collide (SHA-256 collision resistance)."""
111 handles = ["alice", "bob", "carol", "dave", "eve", "frank"]
112 slots = [agent_id_to_slot(h) for h in handles]
113 assert len(set(slots)) == len(slots), f"Unexpected slot collision: {slots}"
114
115 def test_known_vector(self) -> None:
116 """Verify the slot for 'agentception' against a manually computed value."""
117 import hashlib as _hashlib
118 handle = "agentception"
119 digest = _hashlib.sha256(handle.encode()).digest()
120 expected = int.from_bytes(digest[:4], "big") & 0x7FFF_FFFF
121 assert agent_id_to_slot(handle) == expected
122
123 def test_empty_string_handled(self) -> None:
124 """Edge case: empty string handle should not crash."""
125 slot = agent_id_to_slot("")
126 assert 0 <= slot <= 0x7FFF_FFFF
127
128 def test_unicode_handle(self) -> None:
129 """Unicode agent handles should produce valid slots."""
130 slot = agent_id_to_slot("音楽エージェント")
131 assert 0 <= slot <= 0x7FFF_FFFF
132
133
134 # ---------------------------------------------------------------------------
135 # No JBOK — generate_keypair must not exist
136 # ---------------------------------------------------------------------------
137
138
139 class TestNoJbok:
140 """JBOK mode is deleted. generate_keypair must not exist anywhere."""
141
142 def test_generate_keypair_not_in_module(self) -> None:
143 import importlib
144 kp = importlib.import_module("muse.core.keypair")
145 assert not hasattr(kp, "generate_keypair"), \
146 "generate_keypair still exists — JBOK was not fully removed"
147
148 def test_generate_keypair_not_importable(self) -> None:
149 with pytest.raises(ImportError):
150 from muse.core.keypair import generate_keypair # noqa: F401
151
152
153 # ---------------------------------------------------------------------------
154 # Human keygen — no --hd flag, 24-word default
155 # ---------------------------------------------------------------------------
156
157
158 class TestHumanKeygen:
159 """Human keygen: HD is the only mode. No --hd flag required."""
160
161 def test_exits_zero(
162 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
163 ) -> None:
164 _, result = _keygen(monkeypatch, tmp_path)
165 assert result.exit_code == 0, result.output
166
167 def test_no_pem_written(
168 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
169 ) -> None:
170 """Keygen must NOT write any PEM file — keys live in keychain only."""
171 fake_home, result = _keygen(monkeypatch, tmp_path)
172 keys_dir = fake_home / ".muse" / "keys"
173 pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else []
174 assert pem_files == [], f"Unexpected PEM files written: {pem_files}"
175
176 def test_hd_path_in_identity_toml(
177 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
178 ) -> None:
179 """Identity entry must contain hd_path (derivation anchor) and no key_path."""
180 import tomllib
181 fake_home, result = _keygen(monkeypatch, tmp_path)
182 assert result.exit_code == 0
183 data = tomllib.loads((fake_home / ".muse" / "identity.toml").read_text())
184 entry = data[_HOSTNAME]
185 assert "hd_path" in entry, "hd_path missing from identity.toml"
186 assert "key_path" not in entry, "key_path must not be written"
187
188 def test_default_24_word_mnemonic(
189 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
190 ) -> None:
191 """Default strength=256 produces a 24-word mnemonic (visible on a TTY)."""
192 import muse.cli.commands.auth as auth_mod
193 monkeypatch.setattr(auth_mod, "_stderr_isatty", lambda: True)
194 _, result = _keygen(monkeypatch, tmp_path)
195 assert result.exit_code == 0
196 all_text = result.output
197 mnemonic_line = None
198 for line in all_text.splitlines():
199 words = line.strip().split()
200 if len(words) == 24 and all(w.isalpha() for w in words):
201 mnemonic_line = line.strip()
202 break
203 assert mnemonic_line is not None, f"No 24-word line found:\n{all_text}"
204 assert validate_mnemonic(mnemonic_line)
205
206 def test_json_no_mnemonic_in_stdout(
207 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
208 ) -> None:
209 """Mnemonic is sensitive — must never appear in JSON stdout."""
210 _, result = _keygen(monkeypatch, tmp_path, ["--json"])
211 payload = json.loads(result.output.splitlines()[0])
212 assert "mnemonic" not in payload
213
214 def test_json_mnemonic_word_count_24(
215 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
216 ) -> None:
217 _, result = _keygen(monkeypatch, tmp_path, ["--json"])
218 payload = json.loads(result.output.splitlines()[0])
219 assert payload.get("mnemonic_word_count") == 24
220
221 def test_identity_toml_has_no_key_source(
222 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
223 ) -> None:
224 import tomllib
225 fake_home, result = _keygen(monkeypatch, tmp_path)
226 assert result.exit_code == 0
227 data = tomllib.loads((fake_home / ".muse" / "identity.toml").read_text())
228 assert "key_source" not in data[_HOSTNAME]
229
230 def test_force_overwrites_existing(
231 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
232 ) -> None:
233 _keygen(monkeypatch, tmp_path)
234 _, result = _keygen(monkeypatch, tmp_path, ["--force"])
235 assert result.exit_code == 0
236
237 def test_no_force_rejects_existing(
238 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
239 ) -> None:
240 _keygen(monkeypatch, tmp_path)
241 _, result = _keygen(monkeypatch, tmp_path) # second time, no --force
242 assert result.exit_code != 0
243
244 def test_strength_128_gives_12_words(
245 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
246 ) -> None:
247 _, result = _keygen(monkeypatch, tmp_path, ["--strength", "128", "--json"])
248 assert result.exit_code == 0
249 payload = json.loads(result.output.splitlines()[0])
250 assert payload["mnemonic_word_count"] == 12
251
252
253 # ---------------------------------------------------------------------------
254 # Agent keygen — derived from operator's mnemonic
255 # ---------------------------------------------------------------------------
256
257
258 class TestAgentKeygen:
259 """Agent keys must be derived from the operator's HD mnemonic."""
260
261 def _setup_operator(
262 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
263 ) -> pathlib.Path:
264 """Generate a human (operator) key first, with isolated home + keychain."""
265 fake_home = _patch_home(monkeypatch, tmp_path)
266 _patch_keychain(monkeypatch)
267 result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB])
268 assert result.exit_code == 0, f"Operator keygen failed:\n{result.output}"
269 return fake_home
270
271 def test_agent_keygen_exits_zero(
272 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
273 ) -> None:
274 self._setup_operator(monkeypatch, tmp_path)
275 result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
276 assert result.exit_code == 0, result.output
277
278 def test_agent_no_pem_written(
279 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
280 ) -> None:
281 """Agent keygen must not write any PEM file."""
282 fake_home = self._setup_operator(monkeypatch, tmp_path)
283 runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
284 keys_dir = fake_home / ".muse" / "keys"
285 pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else []
286 assert pem_files == [], f"Unexpected PEM files written: {pem_files}"
287
288 def test_agent_hd_path_in_identity_toml(
289 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
290 ) -> None:
291 """Agent entry must contain hd_path and no key_path."""
292 import tomllib
293 fake_home = self._setup_operator(monkeypatch, tmp_path)
294 runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
295 data = tomllib.loads((fake_home / ".muse" / "identity.toml").read_text())
296 agent_key = f"{_HOSTNAME}#bot-alpha"
297 assert agent_key in data, f"No entry for {agent_key} in identity.toml"
298 entry = data[agent_key]
299 assert "hd_path" in entry, "hd_path missing from agent identity entry"
300 assert "key_path" not in entry, "key_path must not be written"
301
302 def test_agent_json_has_hd_path(
303 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
304 ) -> None:
305 self._setup_operator(monkeypatch, tmp_path)
306 result = runner.invoke(
307 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha", "--json"]
308 )
309 assert result.exit_code == 0, result.output
310 payload = json.loads(result.output.splitlines()[0])
311 assert "hd_path" in payload
312 assert str(MUSE_PURPOSE) in payload["hd_path"]
313
314 def test_agent_json_has_provisioned_by(
315 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
316 ) -> None:
317 self._setup_operator(monkeypatch, tmp_path)
318 result = runner.invoke(
319 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha", "--json"]
320 )
321 payload = json.loads(result.output.splitlines()[0])
322 assert "provisioned_by_fingerprint" in payload
323 assert payload["provisioned_by_fingerprint"].startswith("sha256:")
324
325 def test_agent_key_different_from_human_key(
326 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
327 ) -> None:
328 """Agent fingerprint must differ from the operator fingerprint."""
329 self._setup_operator(monkeypatch, tmp_path)
330 op_result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"])
331 # operator already exists — get fingerprint from identity.toml via agent keygen output
332 result = runner.invoke(
333 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha", "--json"]
334 )
335 assert result.exit_code == 0, result.output
336 agent_payload = json.loads(result.output.splitlines()[0])
337 human_payload = json.loads(op_result.output.splitlines()[0]) if op_result.exit_code == 0 else None
338
339 if human_payload:
340 assert agent_payload["fingerprint"] != human_payload["fingerprint"], \
341 "Agent and human keys must be distinct"
342 # Also verify provisioned_by differs from the agent fingerprint
343 assert agent_payload["fingerprint"] != agent_payload["provisioned_by_fingerprint"]
344
345 def test_two_agents_have_distinct_keys(
346 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
347 ) -> None:
348 """Different agent handles must produce different fingerprints."""
349 self._setup_operator(monkeypatch, tmp_path)
350 result_a = runner.invoke(
351 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha", "--json"]
352 )
353 result_b = runner.invoke(
354 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-beta", "--json"]
355 )
356 assert result_a.exit_code == 0, result_a.output
357 assert result_b.exit_code == 0, result_b.output
358 fp_a = json.loads(result_a.output.splitlines()[0])["fingerprint"]
359 fp_b = json.loads(result_b.output.splitlines()[0])["fingerprint"]
360 assert fp_a != fp_b, "Different agent handles must produce different keys"
361
362 def test_agent_keygen_without_operator_exits_nonzero(
363 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
364 ) -> None:
365 """Attempt to derive agent key before operator key is set up."""
366 _patch_home(monkeypatch, tmp_path)
367 result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
368 assert result.exit_code != 0
369
370 def test_agent_key_deterministic(
371 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
372 ) -> None:
373 """Same operator mnemonic + same agent handle = same agent key."""
374 fake_home = self._setup_operator(monkeypatch, tmp_path)
375 result1 = runner.invoke(
376 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha", "--json"]
377 )
378 fp1 = json.loads(result1.output.splitlines()[0])["fingerprint"]
379
380 # Re-derive: force-overwrite the agent key (same operator mnemonic on disk)
381 result2 = runner.invoke(
382 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha", "--force", "--json"]
383 )
384 fp2 = json.loads(result2.output.splitlines()[0])["fingerprint"]
385 assert fp1 == fp2, "Agent key not deterministic given same operator mnemonic + handle"
386
387
388 # ---------------------------------------------------------------------------
389 # derive_agent_sub_seed — unit tests (no CLI)
390 # ---------------------------------------------------------------------------
391
392
393 class TestDeriveAgentSubSeed:
394 """derive_agent_sub_seed must produce stable, domain-isolated sub-seeds."""
395
396 def test_returns_64_bytes(self) -> None:
397 seed = mnemonic_to_seed(_TEST_MNEMONIC_12)
398 slot = agent_id_to_slot("bot-alpha")
399 sub_seed = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot)
400 assert len(sub_seed) == 64
401
402 def test_deterministic(self) -> None:
403 seed = mnemonic_to_seed(_TEST_MNEMONIC_12)
404 slot = agent_id_to_slot("bot-alpha")
405 s1 = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot)
406 s2 = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot)
407 assert s1 == s2
408
409 def test_different_slots_different_sub_seeds(self) -> None:
410 seed = mnemonic_to_seed(_TEST_MNEMONIC_12)
411 slot_a = agent_id_to_slot("bot-alpha")
412 slot_b = agent_id_to_slot("bot-beta")
413 assert slot_a != slot_b
414 sub_a = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot_a)
415 sub_b = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot_b)
416 assert sub_a != sub_b
417
418 def test_different_domains_different_sub_seeds(self) -> None:
419 seed = mnemonic_to_seed(_TEST_MNEMONIC_12)
420 slot = agent_id_to_slot("bot-alpha")
421 DOMAIN_PAYMENTS = 1
422 sub_id = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot)
423 sub_pay = derive_agent_sub_seed(seed, DOMAIN_PAYMENTS, slot)
424 assert sub_id != sub_pay
425
426 def test_sub_seed_differs_from_parent_seed(self) -> None:
427 seed = mnemonic_to_seed(_TEST_MNEMONIC_12)
428 slot = agent_id_to_slot("bot-alpha")
429 sub = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot)
430 assert sub != seed
431
432
433 # ---------------------------------------------------------------------------
434 # run_recover — re-derive from mnemonic
435 # ---------------------------------------------------------------------------
436
437
438 class TestRunRecover:
439 """muse auth recover must re-derive the exact same key from the mnemonic."""
440
441 def _do_recover(
442 self,
443 monkeypatch: pytest.MonkeyPatch,
444 tmp_path: pathlib.Path,
445 mnemonic: str,
446 extra_args: list[str] | None = None,
447 ):
448 fake_home = _patch_home(monkeypatch, tmp_path)
449 _patch_keychain(monkeypatch)
450 args = ["auth", "recover", "--hub", _HUB] + (extra_args or [])
451 return fake_home, runner.invoke(None, args, input=mnemonic)
452
453 def test_recover_exits_zero(
454 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
455 ) -> None:
456 _, result = self._do_recover(monkeypatch, tmp_path, _TEST_MNEMONIC_12, ["--force"])
457 assert result.exit_code == 0, result.output
458
459 def test_recover_writes_no_pem(
460 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
461 ) -> None:
462 fake_home, result = self._do_recover(monkeypatch, tmp_path, _TEST_MNEMONIC_12, ["--force"])
463 assert result.exit_code == 0
464 keys_dir = fake_home / ".muse" / "keys"
465 pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else []
466 assert pem_files == [], f"PEM files found after recover: {pem_files}"
467
468 def test_recover_produces_same_fingerprint_as_keygen(
469 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
470 ) -> None:
471 """Key recovered from mnemonic must match the original keygen fingerprint."""
472 import muse.core.bip39 as bip39_mod
473
474 fixed_mnemonic = _TEST_MNEMONIC_12
475 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed_mnemonic)
476
477 # Keygen
478 fake_home = _patch_home(monkeypatch, tmp_path)
479 _patch_keychain(monkeypatch)
480 keygen_result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"])
481 assert keygen_result.exit_code == 0, keygen_result.output
482 keygen_fp = json.loads(keygen_result.output.splitlines()[0])["fingerprint"]
483
484 # Recover into same tmpdir (--force to overwrite)
485 recover_result = runner.invoke(
486 None,
487 ["auth", "recover", "--hub", _HUB, "--force", "--json"],
488 input=fixed_mnemonic,
489 )
490 assert recover_result.exit_code == 0, recover_result.output
491 recover_fp = json.loads(recover_result.output.splitlines()[0])["fingerprint"]
492
493 assert keygen_fp == recover_fp, \
494 f"Recovered fingerprint {recover_fp} != original {keygen_fp}"
495
496 def test_recover_invalid_mnemonic_exits_nonzero(
497 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
498 ) -> None:
499 _, result = self._do_recover(monkeypatch, tmp_path, "not valid mnemonic words here ok", ["--force"])
500 assert result.exit_code != 0
501
502 def test_recover_json_has_fingerprint(
503 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
504 ) -> None:
505 _, result = self._do_recover(monkeypatch, tmp_path, _TEST_MNEMONIC_12, ["--force", "--json"])
506 assert result.exit_code == 0
507 payload = json.loads(result.output.splitlines()[0])
508 assert "fingerprint" in payload
509 assert payload["fingerprint"].startswith("sha256:")
510
511
512 # ---------------------------------------------------------------------------
513 # Integration — full operator → agent → recover flow
514 # ---------------------------------------------------------------------------
515
516
517 class TestIntegrationFlow:
518 """Full flow: human keygen → agent keygen → recover → fingerprints match."""
519
520 def test_operator_then_agent_then_recover(
521 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
522 ) -> None:
523 import muse.core.bip39 as bip39_mod
524 # Use a fixed mnemonic so we can recover without reading from the keychain.
525 fixed_mnemonic = _TEST_MNEMONIC_12
526 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed_mnemonic)
527
528 _patch_home(monkeypatch, tmp_path)
529
530 # 1. Operator keygen
531 r1 = runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"])
532 assert r1.exit_code == 0, r1.output
533 op_payload = json.loads(r1.output.splitlines()[0])
534 op_fp = op_payload["fingerprint"]
535
536 # 2. Agent keygen derives from the operator's mnemonic in keychain / ephemeral store
537 r2 = runner.invoke(
538 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "worker-1", "--json"]
539 )
540 assert r2.exit_code == 0, r2.output
541 agent_payload = json.loads(r2.output.splitlines()[0])
542 agent_fp = agent_payload["fingerprint"]
543 assert agent_fp != op_fp, "Agent fingerprint must differ from operator"
544
545 # 3. Recover operator key via stdin pipe (--force since PEM already exists)
546 r3 = runner.invoke(
547 None,
548 ["auth", "recover", "--hub", _HUB, "--force", "--json"],
549 input=fixed_mnemonic,
550 )
551 assert r3.exit_code == 0, r3.output
552 recovered_fp = json.loads(r3.output.splitlines()[0])["fingerprint"]
553 assert recovered_fp == op_fp, \
554 f"Recovered operator fp {recovered_fp!r} != original {op_fp!r}"
555
556 def test_slot_stability_across_keygen_invocations(self) -> None:
557 """agent_id_to_slot must return the same value before and after any keygen."""
558 handle = "production-agent-42"
559 slot_before = agent_id_to_slot(handle)
560 # Simulate "after keygen" by just calling again — slot is a pure function
561 slot_after = agent_id_to_slot(handle)
562 assert slot_before == slot_after
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago