"""Phase 8 — migrate run_recover and run_rotate off PEM files. Invariants ---------- REC-1 run_recover writes no *.pem file. REC-2 run_recover writes hd_path to identity.toml; key_path absent. REC-3 run_recover fingerprint matches what the supplied mnemonic derives. REC-4 run_recover stores the mnemonic in the OS keychain. REC-5 run_recover without --force rejects if identity entry already exists. REC-6 run_recover JSON output has no key_path field. REC-7 run_recover --agent-id: no PEM; agent hd_path written. ROT-1 run_rotate writes no *.pem file. ROT-2 run_rotate writes updated hd_path; key_path absent from identity.toml. ROT-3 run_rotate reads mnemonic from keychain (no --mnemonic-fd required). ROT-4 run_rotate JSON output has no key_path field. """ from __future__ import annotations import json import pathlib import pytest from tests.cli_test_helper import CliRunner, InvokeResult from muse.core import keypair as kp_module from muse.core import identity as id_module runner = CliRunner() type _TomlData = dict[str, str | int | bool | None] _HUB = "https://localhost:1337" _HOSTNAME = "localhost:1337" _MNEMONIC = ( "abandon abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon about" ) # --------------------------------------------------------------------------- # Fixtures / helpers # --------------------------------------------------------------------------- @pytest.fixture() def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path: """Isolated home + keychain.""" fake_home = tmp_path / "home" fake_home.mkdir(parents=True, exist_ok=True) monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys") monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse") monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml") monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False) _kc: dict[str, str] = {} monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) monkeypatch.setattr("muse.core.keychain.delete", lambda: _kc.pop("mnemonic", None)) return fake_home def _keygen() -> InvokeResult: return runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"]) def _recover(extra: list[str] | None = None) -> InvokeResult: return runner.invoke( None, ["auth", "recover", "--hub", _HUB, "--json"] + (extra or []), input=_MNEMONIC + "\n", ) def _rotate(extra: list[str] | None = None) -> InvokeResult: return runner.invoke( None, ["auth", "rotate", "--hub", _HUB, "--json"] + (extra or []), ) def _mock_rotate_http(monkeypatch: pytest.MonkeyPatch) -> None: """Stub out the three HTTP calls that run_rotate makes against the hub.""" monkeypatch.setattr( "muse.cli.commands.auth._post_challenge", lambda *a, **kw: {"challenge_token": "ab" * 32, "is_new_key": True}, ) monkeypatch.setattr( "muse.cli.commands.auth._json_post_raw", lambda *a, **kw: {}, ) monkeypatch.setattr( "muse.cli.commands.auth._hub_delete", lambda *a, **kw: None, ) def _pem_files(home: pathlib.Path) -> list[pathlib.Path]: keys_dir = home / ".muse" / "keys" return list(keys_dir.glob("*.pem")) if keys_dir.exists() else [] def _toml(home: pathlib.Path) -> _TomlData: import tomllib return tomllib.loads((home / ".muse" / "identity.toml").read_text()) # --------------------------------------------------------------------------- # REC — run_recover # --------------------------------------------------------------------------- class TestRecoverNoPem: def test_REC_1_no_pem_written(self, isolated: pathlib.Path) -> None: """REC-1: recover must not write any *.pem file.""" result = _recover() assert result.exit_code == 0, result.output assert _pem_files(isolated) == [], f"PEM files found: {_pem_files(isolated)}" def test_REC_2_hd_path_in_toml_no_key_path(self, isolated: pathlib.Path) -> None: """REC-2: identity.toml has hd_path; key_path must be absent.""" result = _recover() assert result.exit_code == 0, result.output data = _toml(isolated) entry = data[_HOSTNAME] assert "hd_path" in entry, "hd_path missing after recover" assert "key_path" not in entry, "key_path must not be written" def test_REC_3_fingerprint_matches_mnemonic(self, isolated: pathlib.Path) -> None: """REC-3: fingerprint in output matches what the mnemonic derives.""" from muse.core.bip39 import mnemonic_to_seed from muse.core.keypair import derive_hd_public_info result = _recover() assert result.exit_code == 0, result.output payload = json.loads(result.output.splitlines()[0]) seed = mnemonic_to_seed(_MNEMONIC) _, expected_fp = derive_hd_public_info(seed) assert payload["fingerprint"] == expected_fp def test_REC_4_mnemonic_stored_in_keychain( self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """REC-4: the supplied mnemonic is stored in the OS keychain after recover.""" from muse.core.keychain import load as kc_load result = _recover() assert result.exit_code == 0, result.output assert kc_load() == _MNEMONIC, "Mnemonic not stored in keychain after recover" def test_REC_5_no_force_rejects_existing_entry(self, isolated: pathlib.Path) -> None: """REC-5: recover without --force fails if identity entry already exists.""" _recover() # first recover creates entry result = _recover() # second without --force must fail assert result.exit_code != 0, "Expected non-zero exit on duplicate recover without --force" def test_REC_5b_force_overwrites_existing(self, isolated: pathlib.Path) -> None: """REC-5b: recover --force succeeds even when entry already exists.""" _recover() result = _recover(["--force"]) assert result.exit_code == 0, result.output def test_REC_6_json_has_no_key_path(self, isolated: pathlib.Path) -> None: """REC-6: JSON output must not contain a key_path field.""" result = _recover() assert result.exit_code == 0, result.output payload = json.loads(result.output.splitlines()[0]) assert "key_path" not in payload, f"key_path found in JSON: {payload}" def test_REC_7_agent_recover_no_pem(self, isolated: pathlib.Path) -> None: """REC-7: recover --agent-id writes no PEM and stores correct agent hd_path.""" _keygen() # establish operator first result = runner.invoke( None, ["auth", "recover", "--hub", _HUB, "--agent-id", "bot-alpha", "--json"], input=_MNEMONIC + "\n", ) assert result.exit_code == 0, result.output assert _pem_files(isolated) == [], f"PEM files found: {_pem_files(isolated)}" data = _toml(isolated) agent_key = f"{_HOSTNAME}#bot-alpha" assert agent_key in data, f"No entry for {agent_key}" assert "hd_path" in data[agent_key] assert "key_path" not in data[agent_key] # --------------------------------------------------------------------------- # ROT — run_rotate # --------------------------------------------------------------------------- class TestRotateNoPem: def test_ROT_1_no_pem_written( self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """ROT-1: rotate must not write any *.pem file.""" _keygen() _mock_rotate_http(monkeypatch) result = _rotate() assert result.exit_code == 0, result.output assert _pem_files(isolated) == [], f"PEM files found: {_pem_files(isolated)}" def test_ROT_2_no_key_path_in_toml( self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """ROT-2: identity.toml after rotate must have hd_path; key_path absent.""" _keygen() _mock_rotate_http(monkeypatch) result = _rotate() assert result.exit_code == 0, result.output data = _toml(isolated) entry = data[_HOSTNAME] assert "hd_path" in entry, "hd_path missing after rotate" assert "key_path" not in entry, "key_path must not be written" def test_ROT_3_reads_mnemonic_from_keychain( self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """ROT-3: rotate succeeds without --mnemonic-fd by reading keychain.""" _keygen() _mock_rotate_http(monkeypatch) # _rotate() passes NO input — mnemonic must come from keychain result = _rotate() assert result.exit_code == 0, result.output payload = json.loads(result.output.splitlines()[0]) assert payload["status"] == "ok" assert payload["rotation_index"] == 1 def test_ROT_4_json_has_no_key_path( self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """ROT-4: JSON output must not contain a key_path field.""" _keygen() _mock_rotate_http(monkeypatch) result = _rotate() assert result.exit_code == 0, result.output payload = json.loads(result.output.splitlines()[0]) assert "key_path" not in payload, f"key_path found in JSON: {payload}"