test_cmd_auth_phase8.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | """Phase 8 — migrate run_recover and run_rotate off PEM files. |
| 2 | |
| 3 | Invariants |
| 4 | ---------- |
| 5 | REC-1 run_recover writes no *.pem file. |
| 6 | REC-2 run_recover writes hd_path to identity.toml; key_path absent. |
| 7 | REC-3 run_recover fingerprint matches what the supplied mnemonic derives. |
| 8 | REC-4 run_recover stores the mnemonic in the OS keychain. |
| 9 | REC-5 run_recover without --force rejects if identity entry already exists. |
| 10 | REC-6 run_recover JSON output has no key_path field. |
| 11 | REC-7 run_recover --agent-id: no PEM; agent hd_path written. |
| 12 | |
| 13 | ROT-1 run_rotate writes no *.pem file. |
| 14 | ROT-2 run_rotate writes updated hd_path; key_path absent from identity.toml. |
| 15 | ROT-3 run_rotate reads mnemonic from keychain (no --mnemonic-fd required). |
| 16 | ROT-4 run_rotate JSON output has no key_path field. |
| 17 | """ |
| 18 | |
| 19 | from __future__ import annotations |
| 20 | |
| 21 | import json |
| 22 | import pathlib |
| 23 | |
| 24 | import pytest |
| 25 | |
| 26 | from tests.cli_test_helper import CliRunner, InvokeResult |
| 27 | from muse.core import keypair as kp_module |
| 28 | from muse.core import identity as id_module |
| 29 | |
| 30 | runner = CliRunner() |
| 31 | |
| 32 | type _TomlData = dict[str, str | int | bool | None] |
| 33 | |
| 34 | _HUB = "https://localhost:1337" |
| 35 | _HOSTNAME = "localhost:1337" |
| 36 | _MNEMONIC = ( |
| 37 | "abandon abandon abandon abandon abandon abandon abandon abandon " |
| 38 | "abandon abandon abandon about" |
| 39 | ) |
| 40 | |
| 41 | |
| 42 | # --------------------------------------------------------------------------- |
| 43 | # Fixtures / helpers |
| 44 | # --------------------------------------------------------------------------- |
| 45 | |
| 46 | |
| 47 | @pytest.fixture() |
| 48 | def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path: |
| 49 | """Isolated home + keychain.""" |
| 50 | fake_home = tmp_path / "home" |
| 51 | fake_home.mkdir(parents=True, exist_ok=True) |
| 52 | monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home)) |
| 53 | monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys") |
| 54 | monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse") |
| 55 | monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml") |
| 56 | monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False) |
| 57 | |
| 58 | _kc: dict[str, str] = {} |
| 59 | monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) |
| 60 | monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) |
| 61 | monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) |
| 62 | monkeypatch.setattr("muse.core.keychain.delete", lambda: _kc.pop("mnemonic", None)) |
| 63 | return fake_home |
| 64 | |
| 65 | |
| 66 | def _keygen() -> InvokeResult: |
| 67 | return runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"]) |
| 68 | |
| 69 | |
| 70 | def _recover(extra: list[str] | None = None) -> InvokeResult: |
| 71 | return runner.invoke( |
| 72 | None, |
| 73 | ["auth", "recover", "--hub", _HUB, "--json"] + (extra or []), |
| 74 | input=_MNEMONIC + "\n", |
| 75 | ) |
| 76 | |
| 77 | |
| 78 | def _rotate(extra: list[str] | None = None) -> InvokeResult: |
| 79 | return runner.invoke( |
| 80 | None, |
| 81 | ["auth", "rotate", "--hub", _HUB, "--json"] + (extra or []), |
| 82 | ) |
| 83 | |
| 84 | |
| 85 | def _mock_rotate_http(monkeypatch: pytest.MonkeyPatch) -> None: |
| 86 | """Stub out the three HTTP calls that run_rotate makes against the hub.""" |
| 87 | monkeypatch.setattr( |
| 88 | "muse.cli.commands.auth._post_challenge", |
| 89 | lambda *a, **kw: {"challenge_token": "ab" * 32, "is_new_key": True}, |
| 90 | ) |
| 91 | monkeypatch.setattr( |
| 92 | "muse.cli.commands.auth._json_post_raw", |
| 93 | lambda *a, **kw: {}, |
| 94 | ) |
| 95 | monkeypatch.setattr( |
| 96 | "muse.cli.commands.auth._hub_delete", |
| 97 | lambda *a, **kw: None, |
| 98 | ) |
| 99 | |
| 100 | |
| 101 | def _pem_files(home: pathlib.Path) -> list[pathlib.Path]: |
| 102 | keys_dir = home / ".muse" / "keys" |
| 103 | return list(keys_dir.glob("*.pem")) if keys_dir.exists() else [] |
| 104 | |
| 105 | |
| 106 | def _toml(home: pathlib.Path) -> _TomlData: |
| 107 | import tomllib |
| 108 | return tomllib.loads((home / ".muse" / "identity.toml").read_text()) |
| 109 | |
| 110 | |
| 111 | # --------------------------------------------------------------------------- |
| 112 | # REC — run_recover |
| 113 | # --------------------------------------------------------------------------- |
| 114 | |
| 115 | |
| 116 | class TestRecoverNoPem: |
| 117 | def test_REC_1_no_pem_written(self, isolated: pathlib.Path) -> None: |
| 118 | """REC-1: recover must not write any *.pem file.""" |
| 119 | result = _recover() |
| 120 | assert result.exit_code == 0, result.output |
| 121 | assert _pem_files(isolated) == [], f"PEM files found: {_pem_files(isolated)}" |
| 122 | |
| 123 | def test_REC_2_hd_path_in_toml_no_key_path(self, isolated: pathlib.Path) -> None: |
| 124 | """REC-2: identity.toml has hd_path; key_path must be absent.""" |
| 125 | result = _recover() |
| 126 | assert result.exit_code == 0, result.output |
| 127 | data = _toml(isolated) |
| 128 | entry = data[_HOSTNAME] |
| 129 | assert "hd_path" in entry, "hd_path missing after recover" |
| 130 | assert "key_path" not in entry, "key_path must not be written" |
| 131 | |
| 132 | def test_REC_3_fingerprint_matches_mnemonic(self, isolated: pathlib.Path) -> None: |
| 133 | """REC-3: fingerprint in output matches what the mnemonic derives.""" |
| 134 | from muse.core.bip39 import mnemonic_to_seed |
| 135 | from muse.core.keypair import derive_hd_public_info |
| 136 | |
| 137 | result = _recover() |
| 138 | assert result.exit_code == 0, result.output |
| 139 | payload = json.loads(result.output.splitlines()[0]) |
| 140 | |
| 141 | seed = mnemonic_to_seed(_MNEMONIC) |
| 142 | _, expected_fp = derive_hd_public_info(seed) |
| 143 | assert payload["fingerprint"] == expected_fp |
| 144 | |
| 145 | def test_REC_4_mnemonic_stored_in_keychain( |
| 146 | self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 147 | ) -> None: |
| 148 | """REC-4: the supplied mnemonic is stored in the OS keychain after recover.""" |
| 149 | from muse.core.keychain import load as kc_load |
| 150 | |
| 151 | result = _recover() |
| 152 | assert result.exit_code == 0, result.output |
| 153 | assert kc_load() == _MNEMONIC, "Mnemonic not stored in keychain after recover" |
| 154 | |
| 155 | def test_REC_5_no_force_rejects_existing_entry(self, isolated: pathlib.Path) -> None: |
| 156 | """REC-5: recover without --force fails if identity entry already exists.""" |
| 157 | _recover() # first recover creates entry |
| 158 | result = _recover() # second without --force must fail |
| 159 | assert result.exit_code != 0, "Expected non-zero exit on duplicate recover without --force" |
| 160 | |
| 161 | def test_REC_5b_force_overwrites_existing(self, isolated: pathlib.Path) -> None: |
| 162 | """REC-5b: recover --force succeeds even when entry already exists.""" |
| 163 | _recover() |
| 164 | result = _recover(["--force"]) |
| 165 | assert result.exit_code == 0, result.output |
| 166 | |
| 167 | def test_REC_6_json_has_no_key_path(self, isolated: pathlib.Path) -> None: |
| 168 | """REC-6: JSON output must not contain a key_path field.""" |
| 169 | result = _recover() |
| 170 | assert result.exit_code == 0, result.output |
| 171 | payload = json.loads(result.output.splitlines()[0]) |
| 172 | assert "key_path" not in payload, f"key_path found in JSON: {payload}" |
| 173 | |
| 174 | def test_REC_7_agent_recover_no_pem(self, isolated: pathlib.Path) -> None: |
| 175 | """REC-7: recover --agent-id writes no PEM and stores correct agent hd_path.""" |
| 176 | _keygen() # establish operator first |
| 177 | result = runner.invoke( |
| 178 | None, |
| 179 | ["auth", "recover", "--hub", _HUB, "--agent-id", "bot-alpha", "--json"], |
| 180 | input=_MNEMONIC + "\n", |
| 181 | ) |
| 182 | assert result.exit_code == 0, result.output |
| 183 | assert _pem_files(isolated) == [], f"PEM files found: {_pem_files(isolated)}" |
| 184 | data = _toml(isolated) |
| 185 | agent_key = f"{_HOSTNAME}#bot-alpha" |
| 186 | assert agent_key in data, f"No entry for {agent_key}" |
| 187 | assert "hd_path" in data[agent_key] |
| 188 | assert "key_path" not in data[agent_key] |
| 189 | |
| 190 | |
| 191 | # --------------------------------------------------------------------------- |
| 192 | # ROT — run_rotate |
| 193 | # --------------------------------------------------------------------------- |
| 194 | |
| 195 | |
| 196 | class TestRotateNoPem: |
| 197 | def test_ROT_1_no_pem_written( |
| 198 | self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 199 | ) -> None: |
| 200 | """ROT-1: rotate must not write any *.pem file.""" |
| 201 | _keygen() |
| 202 | _mock_rotate_http(monkeypatch) |
| 203 | result = _rotate() |
| 204 | assert result.exit_code == 0, result.output |
| 205 | assert _pem_files(isolated) == [], f"PEM files found: {_pem_files(isolated)}" |
| 206 | |
| 207 | def test_ROT_2_no_key_path_in_toml( |
| 208 | self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 209 | ) -> None: |
| 210 | """ROT-2: identity.toml after rotate must have hd_path; key_path absent.""" |
| 211 | _keygen() |
| 212 | _mock_rotate_http(monkeypatch) |
| 213 | result = _rotate() |
| 214 | assert result.exit_code == 0, result.output |
| 215 | data = _toml(isolated) |
| 216 | entry = data[_HOSTNAME] |
| 217 | assert "hd_path" in entry, "hd_path missing after rotate" |
| 218 | assert "key_path" not in entry, "key_path must not be written" |
| 219 | |
| 220 | def test_ROT_3_reads_mnemonic_from_keychain( |
| 221 | self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 222 | ) -> None: |
| 223 | """ROT-3: rotate succeeds without --mnemonic-fd by reading keychain.""" |
| 224 | _keygen() |
| 225 | _mock_rotate_http(monkeypatch) |
| 226 | # _rotate() passes NO input — mnemonic must come from keychain |
| 227 | result = _rotate() |
| 228 | assert result.exit_code == 0, result.output |
| 229 | payload = json.loads(result.output.splitlines()[0]) |
| 230 | assert payload["status"] == "ok" |
| 231 | assert payload["rotation_index"] == 1 |
| 232 | |
| 233 | def test_ROT_4_json_has_no_key_path( |
| 234 | self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 235 | ) -> None: |
| 236 | """ROT-4: JSON output must not contain a key_path field.""" |
| 237 | _keygen() |
| 238 | _mock_rotate_http(monkeypatch) |
| 239 | result = _rotate() |
| 240 | assert result.exit_code == 0, result.output |
| 241 | payload = json.loads(result.output.splitlines()[0]) |
| 242 | assert "key_path" not in payload, f"key_path found in JSON: {payload}" |