gabriel / muse public

test_cmd_auth_phase8.py file-level

at sha256:2 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 Merge branch 'dev' into main · gabriel · Jun 17, 2026
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}"