gabriel / muse public
test_cmd_auth_phase8.py python
244 lines 9.7 KB
Raw
sha256:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3 docs: revert migrate hub-scoping/domain-integers rows from … Sonnet 5 13 hours 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, 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 from muse.core.hdkeys import hub_index
137
138 result = _recover()
139 assert result.exit_code == 0, result.output
140 payload = json.loads(result.output.splitlines()[0])
141
142 seed = mnemonic_to_seed(_MNEMONIC)
143 # Hub-scoped (musehub#221) — recover derives against _HOSTNAME.
144 _, expected_fp = derive_hd_public_info(seed, hub=hub_index(_HOSTNAME))
145 assert payload["fingerprint"] == expected_fp
146
147 def test_REC_4_mnemonic_stored_in_keychain(
148 self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch
149 ) -> None:
150 """REC-4: the supplied mnemonic is stored in the OS keychain after recover."""
151 from muse.core.keychain import load as kc_load
152
153 result = _recover()
154 assert result.exit_code == 0, result.output
155 assert kc_load() == _MNEMONIC, "Mnemonic not stored in keychain after recover"
156
157 def test_REC_5_no_force_rejects_existing_entry(self, isolated: pathlib.Path) -> None:
158 """REC-5: recover without --force fails if identity entry already exists."""
159 _recover() # first recover creates entry
160 result = _recover() # second without --force must fail
161 assert result.exit_code != 0, "Expected non-zero exit on duplicate recover without --force"
162
163 def test_REC_5b_force_overwrites_existing(self, isolated: pathlib.Path) -> None:
164 """REC-5b: recover --force succeeds even when entry already exists."""
165 _recover()
166 result = _recover(["--force"])
167 assert result.exit_code == 0, result.output
168
169 def test_REC_6_json_has_no_key_path(self, isolated: pathlib.Path) -> None:
170 """REC-6: JSON output must not contain a key_path field."""
171 result = _recover()
172 assert result.exit_code == 0, result.output
173 payload = json.loads(result.output.splitlines()[0])
174 assert "key_path" not in payload, f"key_path found in JSON: {payload}"
175
176 def test_REC_7_agent_recover_no_pem(self, isolated: pathlib.Path) -> None:
177 """REC-7: recover --agent-id writes no PEM and stores correct agent hd_path."""
178 _keygen() # establish operator first
179 result = runner.invoke(
180 None,
181 ["auth", "recover", "--hub", _HUB, "--agent-id", "bot-alpha", "--json"],
182 input=_MNEMONIC + "\n",
183 )
184 assert result.exit_code == 0, result.output
185 assert _pem_files(isolated) == [], f"PEM files found: {_pem_files(isolated)}"
186 data = _toml(isolated)
187 agent_key = f"{_HOSTNAME}#bot-alpha"
188 assert agent_key in data, f"No entry for {agent_key}"
189 assert "hd_path" in data[agent_key]
190 assert "key_path" not in data[agent_key]
191
192
193 # ---------------------------------------------------------------------------
194 # ROT — run_rotate
195 # ---------------------------------------------------------------------------
196
197
198 class TestRotateNoPem:
199 def test_ROT_1_no_pem_written(
200 self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch
201 ) -> None:
202 """ROT-1: rotate must not write any *.pem file."""
203 _keygen()
204 _mock_rotate_http(monkeypatch)
205 result = _rotate()
206 assert result.exit_code == 0, result.output
207 assert _pem_files(isolated) == [], f"PEM files found: {_pem_files(isolated)}"
208
209 def test_ROT_2_no_key_path_in_toml(
210 self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch
211 ) -> None:
212 """ROT-2: identity.toml after rotate must have hd_path; key_path absent."""
213 _keygen()
214 _mock_rotate_http(monkeypatch)
215 result = _rotate()
216 assert result.exit_code == 0, result.output
217 data = _toml(isolated)
218 entry = data[_HOSTNAME]
219 assert "hd_path" in entry, "hd_path missing after rotate"
220 assert "key_path" not in entry, "key_path must not be written"
221
222 def test_ROT_3_reads_mnemonic_from_keychain(
223 self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch
224 ) -> None:
225 """ROT-3: rotate succeeds without --mnemonic-fd by reading keychain."""
226 _keygen()
227 _mock_rotate_http(monkeypatch)
228 # _rotate() passes NO input — mnemonic must come from keychain
229 result = _rotate()
230 assert result.exit_code == 0, result.output
231 payload = json.loads(result.output.splitlines()[0])
232 assert payload["status"] == "ok"
233 assert payload["rotation_index"] == 1
234
235 def test_ROT_4_json_has_no_key_path(
236 self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch
237 ) -> None:
238 """ROT-4: JSON output must not contain a key_path field."""
239 _keygen()
240 _mock_rotate_http(monkeypatch)
241 result = _rotate()
242 assert result.exit_code == 0, result.output
243 payload = json.loads(result.output.splitlines()[0])
244 assert "key_path" not in payload, f"key_path found in JSON: {payload}"
File History 1 commit
sha256:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3 docs: revert migrate hub-scoping/domain-integers rows from … Sonnet 5 13 hours ago