gabriel / muse public
test_cmd_auth_phase8.py python
212 lines 8.6 KB
Raw
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