test_cmd_auth_phase8.py
python
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9
docs: expand cache plan with all seven testing tiers and do…
Sonnet 4.6
131 days ago
| 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 |
| 27 | from muse.core import keypair as kp_module |
| 28 | from muse.core import identity as id_module |
| 29 | |
| 30 | runner = CliRunner() |
| 31 | |
| 32 | _HUB = "https://localhost:1337" |
| 33 | _HOSTNAME = "localhost:1337" |
| 34 | _MNEMONIC = ( |
| 35 | "abandon abandon abandon abandon abandon abandon abandon abandon " |
| 36 | "abandon abandon abandon about" |
| 37 | ) |
| 38 | |
| 39 | |
| 40 | # --------------------------------------------------------------------------- |
| 41 | # Fixtures / helpers |
| 42 | # --------------------------------------------------------------------------- |
| 43 | |
| 44 | |
| 45 | @pytest.fixture() |
| 46 | def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path: |
| 47 | """Isolated home + keychain.""" |
| 48 | fake_home = tmp_path / "home" |
| 49 | fake_home.mkdir(parents=True, exist_ok=True) |
| 50 | monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home)) |
| 51 | monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys") |
| 52 | monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse") |
| 53 | monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml") |
| 54 | monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False) |
| 55 | |
| 56 | _kc: dict[str, str] = {} |
| 57 | monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) |
| 58 | monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) |
| 59 | monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) |
| 60 | monkeypatch.setattr("muse.core.keychain.delete", lambda: _kc.pop("mnemonic", None)) |
| 61 | return fake_home |
| 62 | |
| 63 | |
| 64 | def _keygen() -> object: |
| 65 | return runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"]) |
| 66 | |
| 67 | |
| 68 | def _recover(extra: list[str] | None = None) -> object: |
| 69 | return runner.invoke( |
| 70 | None, |
| 71 | ["auth", "recover", "--hub", _HUB, "--json"] + (extra or []), |
| 72 | input=_MNEMONIC + "\n", |
| 73 | ) |
| 74 | |
| 75 | |
| 76 | def _rotate(extra: list[str] | None = None) -> object: |
| 77 | return runner.invoke( |
| 78 | None, |
| 79 | ["auth", "rotate", "--hub", _HUB, "--json"] + (extra or []), |
| 80 | ) |
| 81 | |
| 82 | |
| 83 | def _pem_files(home: pathlib.Path) -> list[pathlib.Path]: |
| 84 | keys_dir = home / ".muse" / "keys" |
| 85 | return list(keys_dir.glob("*.pem")) if keys_dir.exists() else [] |
| 86 | |
| 87 | |
| 88 | def _toml(home: pathlib.Path) -> dict: |
| 89 | import tomllib |
| 90 | return tomllib.loads((home / ".muse" / "identity.toml").read_text()) |
| 91 | |
| 92 | |
| 93 | # --------------------------------------------------------------------------- |
| 94 | # REC — run_recover |
| 95 | # --------------------------------------------------------------------------- |
| 96 | |
| 97 | |
| 98 | class TestRecoverNoPem: |
| 99 | def test_REC_1_no_pem_written(self, isolated: pathlib.Path) -> None: |
| 100 | """REC-1: recover must not write any *.pem file.""" |
| 101 | result = _recover() |
| 102 | assert result.exit_code == 0, result.output |
| 103 | assert _pem_files(isolated) == [], f"PEM files found: {_pem_files(isolated)}" |
| 104 | |
| 105 | def test_REC_2_hd_path_in_toml_no_key_path(self, isolated: pathlib.Path) -> None: |
| 106 | """REC-2: identity.toml has hd_path; key_path must be absent.""" |
| 107 | result = _recover() |
| 108 | assert result.exit_code == 0, result.output |
| 109 | data = _toml(isolated) |
| 110 | entry = data[_HOSTNAME] |
| 111 | assert "hd_path" in entry, "hd_path missing after recover" |
| 112 | assert "key_path" not in entry, "key_path must not be written" |
| 113 | |
| 114 | def test_REC_3_fingerprint_matches_mnemonic(self, isolated: pathlib.Path) -> None: |
| 115 | """REC-3: fingerprint in output matches what the mnemonic derives.""" |
| 116 | from muse.core.bip39 import mnemonic_to_seed |
| 117 | from muse.core.keypair import derive_hd_public_info |
| 118 | |
| 119 | result = _recover() |
| 120 | assert result.exit_code == 0, result.output |
| 121 | payload = json.loads(result.output.splitlines()[0]) |
| 122 | |
| 123 | seed = mnemonic_to_seed(_MNEMONIC) |
| 124 | _, expected_fp = derive_hd_public_info(seed) |
| 125 | assert payload["fingerprint"] == expected_fp |
| 126 | |
| 127 | def test_REC_4_mnemonic_stored_in_keychain( |
| 128 | self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 129 | ) -> None: |
| 130 | """REC-4: the supplied mnemonic is stored in the OS keychain after recover.""" |
| 131 | from muse.core.keychain import load as kc_load |
| 132 | |
| 133 | result = _recover() |
| 134 | assert result.exit_code == 0, result.output |
| 135 | assert kc_load() == _MNEMONIC, "Mnemonic not stored in keychain after recover" |
| 136 | |
| 137 | def test_REC_5_no_force_rejects_existing_entry(self, isolated: pathlib.Path) -> None: |
| 138 | """REC-5: recover without --force fails if identity entry already exists.""" |
| 139 | _recover() # first recover creates entry |
| 140 | result = _recover() # second without --force must fail |
| 141 | assert result.exit_code != 0, "Expected non-zero exit on duplicate recover without --force" |
| 142 | |
| 143 | def test_REC_5b_force_overwrites_existing(self, isolated: pathlib.Path) -> None: |
| 144 | """REC-5b: recover --force succeeds even when entry already exists.""" |
| 145 | _recover() |
| 146 | result = _recover(["--force"]) |
| 147 | assert result.exit_code == 0, result.output |
| 148 | |
| 149 | def test_REC_6_json_has_no_key_path(self, isolated: pathlib.Path) -> None: |
| 150 | """REC-6: JSON output must not contain a key_path field.""" |
| 151 | result = _recover() |
| 152 | assert result.exit_code == 0, result.output |
| 153 | payload = json.loads(result.output.splitlines()[0]) |
| 154 | assert "key_path" not in payload, f"key_path found in JSON: {payload}" |
| 155 | |
| 156 | def test_REC_7_agent_recover_no_pem(self, isolated: pathlib.Path) -> None: |
| 157 | """REC-7: recover --agent-id writes no PEM and stores correct agent hd_path.""" |
| 158 | _keygen() # establish operator first |
| 159 | result = runner.invoke( |
| 160 | None, |
| 161 | ["auth", "recover", "--hub", _HUB, "--agent-id", "bot-alpha", "--json"], |
| 162 | input=_MNEMONIC + "\n", |
| 163 | ) |
| 164 | assert result.exit_code == 0, result.output |
| 165 | assert _pem_files(isolated) == [], f"PEM files found: {_pem_files(isolated)}" |
| 166 | data = _toml(isolated) |
| 167 | agent_key = f"{_HOSTNAME}#bot-alpha" |
| 168 | assert agent_key in data, f"No entry for {agent_key}" |
| 169 | assert "hd_path" in data[agent_key] |
| 170 | assert "key_path" not in data[agent_key] |
| 171 | |
| 172 | |
| 173 | # --------------------------------------------------------------------------- |
| 174 | # ROT — run_rotate |
| 175 | # --------------------------------------------------------------------------- |
| 176 | |
| 177 | |
| 178 | class TestRotateNoPem: |
| 179 | def test_ROT_1_no_pem_written(self, isolated: pathlib.Path) -> None: |
| 180 | """ROT-1: rotate must not write any *.pem file.""" |
| 181 | _keygen() |
| 182 | result = _rotate() |
| 183 | assert result.exit_code == 0, result.output |
| 184 | assert _pem_files(isolated) == [], f"PEM files found: {_pem_files(isolated)}" |
| 185 | |
| 186 | def test_ROT_2_no_key_path_in_toml(self, isolated: pathlib.Path) -> None: |
| 187 | """ROT-2: identity.toml after rotate must have hd_path; key_path absent.""" |
| 188 | _keygen() |
| 189 | result = _rotate() |
| 190 | assert result.exit_code == 0, result.output |
| 191 | data = _toml(isolated) |
| 192 | entry = data[_HOSTNAME] |
| 193 | assert "hd_path" in entry, "hd_path missing after rotate" |
| 194 | assert "key_path" not in entry, "key_path must not be written" |
| 195 | |
| 196 | def test_ROT_3_reads_mnemonic_from_keychain(self, isolated: pathlib.Path) -> None: |
| 197 | """ROT-3: rotate succeeds without --mnemonic-fd by reading keychain.""" |
| 198 | _keygen() |
| 199 | # _rotate() passes NO input — mnemonic must come from keychain |
| 200 | result = _rotate() |
| 201 | assert result.exit_code == 0, result.output |
| 202 | payload = json.loads(result.output.splitlines()[0]) |
| 203 | assert payload["status"] == "ok" |
| 204 | assert payload["rotation_index"] == 1 |
| 205 | |
| 206 | def test_ROT_4_json_has_no_key_path(self, isolated: pathlib.Path) -> None: |
| 207 | """ROT-4: JSON output must not contain a key_path field.""" |
| 208 | _keygen() |
| 209 | result = _rotate() |
| 210 | assert result.exit_code == 0, result.output |
| 211 | payload = json.loads(result.output.splitlines()[0]) |
| 212 | assert "key_path" not in payload, f"key_path found in JSON: {payload}" |
File History
1 commit
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9
docs: expand cache plan with all seven testing tiers and do…
Sonnet 4.6
131 days ago