gabriel / muse public
test_cmd_auth_phase5.py python
283 lines 11.4 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Tests for Phase 5 — PEM cleanup and security-check commands.
2
3 Phase 5 invariants
4 ------------------
5 After Phases 1–4, no PEM files should exist on disk and no ``key_path``
6 fields should appear in ``identity.toml``. Two new commands enforce this:
7
8 muse auth cleanup-keys -- securely overwrite + delete all ~/.muse/keys/*.pem
9 muse auth security-check -- verify all four invariants, exit 1 if any fail
10 """
11
12 from __future__ import annotations
13
14 import os
15 import pathlib
16
17 import pytest
18 from tests.cli_test_helper import CliRunner
19
20 import muse.core.keypair as kp_module
21 import muse.core.identity as id_module
22
23 runner = CliRunner()
24
25 _FIXED_MNEMONIC = (
26 "abandon abandon abandon abandon abandon abandon abandon abandon "
27 "abandon abandon abandon about"
28 )
29 _HUB = "https://localhost:1337"
30 _HOSTNAME = "localhost:1337"
31
32
33 # ---------------------------------------------------------------------------
34 # Helpers
35 # ---------------------------------------------------------------------------
36
37
38 def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
39 fake_home = tmp_path / "home"
40 fake_home.mkdir(parents=True, exist_ok=True)
41 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
42 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
43 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
44 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
45 monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False)
46 return fake_home
47
48
49 def _patch_keychain(monkeypatch: pytest.MonkeyPatch) -> dict:
50 _kc: dict[str, str] = {"mnemonic": _FIXED_MNEMONIC}
51 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
52 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
53 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
54 return _kc
55
56
57 def _write_fake_pem(keys_dir: pathlib.Path, name: str = "localhost_1337.pem") -> pathlib.Path:
58 """Write a fake PEM file (not a real key, just bytes to verify overwrite)."""
59 keys_dir.mkdir(parents=True, mode=0o700, exist_ok=True)
60 pem_path = keys_dir / name
61 pem_path.write_bytes(b"FAKE_PEM_CONTENT_FOR_TESTING")
62 pem_path.chmod(0o600)
63 return pem_path
64
65
66 def _run_keygen_and_register(monkeypatch: pytest.MonkeyPatch) -> None:
67 """Run keygen + register with mocked hub to produce a clean identity entry."""
68 import muse.core.bip39 as bip39_mod
69 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _FIXED_MNEMONIC)
70 result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB])
71 assert result.exit_code == 0, f"keygen failed: {result.output}"
72
73 monkeypatch.setattr("muse.cli.commands.auth._post_challenge",
74 lambda *a, **kw: {"challengeToken": "ab" * 32, "isNewKey": True})
75 monkeypatch.setattr("muse.cli.commands.auth._post_verify",
76 lambda *a, **kw: {"handle": "gabriel", "identityId": "sha256:" + "a" * 64, "isNewIdentity": True})
77 result = runner.invoke(None, ["auth", "register", "--hub", _HUB, "--handle", "gabriel"])
78 assert result.exit_code == 0, f"register failed: {result.output}"
79
80
81 # ---------------------------------------------------------------------------
82 # cleanup-keys tests
83 # ---------------------------------------------------------------------------
84
85
86 class TestCleanupKeys:
87 def test_C1_destroys_pem_files(
88 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
89 ) -> None:
90 fake_home = _patch_home(monkeypatch, tmp_path)
91 keys_dir = fake_home / ".muse" / "keys"
92 pem = _write_fake_pem(keys_dir, "localhost_1337.pem")
93 original_content = pem.read_bytes()
94
95 result = runner.invoke(None, ["auth", "cleanup-keys"])
96 assert result.exit_code == 0, f"cleanup-keys failed: {result.output}"
97 assert not pem.exists(), "PEM file should be deleted"
98 _ = original_content # referenced to confirm it was different before
99
100 def test_C2_json_output_lists_destroyed_paths(
101 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
102 ) -> None:
103 import json
104 fake_home = _patch_home(monkeypatch, tmp_path)
105 keys_dir = fake_home / ".muse" / "keys"
106 pem_a = _write_fake_pem(keys_dir, "host_a.pem")
107 pem_b = _write_fake_pem(keys_dir, "host_b.pem")
108
109 result = runner.invoke(None, ["auth", "cleanup-keys", "--json"])
110 assert result.exit_code == 0
111 data = json.loads(result.output)
112 assert data["count"] == 2
113 assert str(pem_a) in data["destroyed"]
114 assert str(pem_b) in data["destroyed"]
115
116 def test_C3_no_pem_files_is_not_an_error(
117 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
118 ) -> None:
119 import json
120 _patch_home(monkeypatch, tmp_path)
121 result = runner.invoke(None, ["auth", "cleanup-keys", "--json"])
122 assert result.exit_code == 0
123 data = json.loads(result.output)
124 assert data["count"] == 0
125 assert data["destroyed"] == []
126
127 def test_C4_pem_content_is_overwritten_before_deletion(
128 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
129 ) -> None:
130 """Verify the file is overwritten (content replaced) before deletion.
131
132 We hook os.unlink to capture the final content before the file is gone.
133 """
134 fake_home = _patch_home(monkeypatch, tmp_path)
135 keys_dir = fake_home / ".muse" / "keys"
136 pem = _write_fake_pem(keys_dir)
137 original_content = b"FAKE_PEM_CONTENT_FOR_TESTING"
138 assert pem.read_bytes() == original_content
139
140 captured: list[bytes] = []
141 real_unlink = pathlib.Path.unlink
142
143 def capturing_unlink(self: pathlib.Path, missing_ok: bool = False) -> None:
144 if self == pem:
145 captured.append(self.read_bytes())
146 real_unlink(self, missing_ok=missing_ok)
147
148 monkeypatch.setattr(pathlib.Path, "unlink", capturing_unlink)
149 result = runner.invoke(None, ["auth", "cleanup-keys"])
150 assert result.exit_code == 0
151 assert captured, "unlink hook was not called"
152 assert captured[0] != original_content, "file content should be overwritten before deletion"
153
154 def test_C5_only_pem_files_are_deleted(
155 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
156 ) -> None:
157 fake_home = _patch_home(monkeypatch, tmp_path)
158 keys_dir = fake_home / ".muse" / "keys"
159 keys_dir.mkdir(parents=True, mode=0o700, exist_ok=True)
160 pem = _write_fake_pem(keys_dir)
161 other_file = keys_dir / "notes.txt"
162 other_file.write_text("not a pem")
163
164 result = runner.invoke(None, ["auth", "cleanup-keys"])
165 assert result.exit_code == 0
166 assert not pem.exists()
167 assert other_file.exists(), "non-PEM files must not be touched"
168
169
170 # ---------------------------------------------------------------------------
171 # security-check tests
172 # ---------------------------------------------------------------------------
173
174
175 class TestSecurityCheck:
176 def test_S1_all_checks_pass_after_clean_keygen_register(
177 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
178 ) -> None:
179 import json
180 _patch_home(monkeypatch, tmp_path)
181 _patch_keychain(monkeypatch)
182 _run_keygen_and_register(monkeypatch)
183
184 result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"])
185 assert result.exit_code == 0, f"security-check failed: {result.output}"
186 data = json.loads(result.output)
187 assert data["ok"] is True
188 assert data["mnemonic_in_keychain"] is True
189 assert data["no_pem_files"] is True
190 assert data["no_key_path_in_identity"] is True
191 assert data["fingerprint_matches_mnemonic"] is True
192 assert data["pem_files_found"] == []
193 assert data["key_path_entries"] == []
194
195 def test_S2_fails_when_pem_file_exists(
196 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
197 ) -> None:
198 import json
199 fake_home = _patch_home(monkeypatch, tmp_path)
200 _patch_keychain(monkeypatch)
201 _run_keygen_and_register(monkeypatch)
202
203 # Plant a stale PEM file
204 keys_dir = fake_home / ".muse" / "keys"
205 pem = _write_fake_pem(keys_dir)
206
207 result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"])
208 assert result.exit_code != 0
209 data = json.loads(result.output)
210 assert data["ok"] is False
211 assert data["no_pem_files"] is False
212 assert str(pem) in data["pem_files_found"]
213
214 def test_S3_fails_when_key_path_in_identity(
215 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
216 ) -> None:
217 """security-check detects key_path written by old versions of muse.
218
219 _dump_identity no longer writes key_path, so we write the TOML directly
220 to simulate an old-format identity file that still has the field.
221 """
222 import json
223 fake_home = _patch_home(monkeypatch, tmp_path)
224 _patch_keychain(monkeypatch)
225 _run_keygen_and_register(monkeypatch)
226
227 # Read the TOML written by keygen+register, then append key_path manually
228 # to simulate what an old muse version would have written.
229 identity_file = fake_home / ".muse" / "identity.toml"
230 toml_text = identity_file.read_text()
231 # Inject key_path after the handle line to simulate old-format file
232 toml_text = toml_text.replace(
233 f'["{_HOSTNAME}"]',
234 f'["{_HOSTNAME}"]',
235 )
236 # Append key_path field to the section
237 toml_text += f'\nkey_path = "/fake/path.pem"\n'
238 identity_file.write_text(toml_text)
239
240 result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"])
241 assert result.exit_code != 0
242 data = json.loads(result.output)
243 assert data["ok"] is False
244 assert data["no_key_path_in_identity"] is False
245 assert _HOSTNAME in data["key_path_entries"]
246
247 def test_S4_fails_when_fingerprint_mismatches(
248 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
249 ) -> None:
250 import json
251 _patch_home(monkeypatch, tmp_path)
252 _patch_keychain(monkeypatch)
253 _run_keygen_and_register(monkeypatch)
254
255 # Overwrite fingerprint with a stale/wrong value via save_identity
256 from muse.core.identity import load_identity, save_identity
257 entry = load_identity(_HUB)
258 assert entry is not None
259 entry["fingerprint"] = "sha256:" + "0" * 64
260 save_identity(_HUB, entry)
261
262 result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"])
263 assert result.exit_code != 0
264 data = json.loads(result.output)
265 assert data["ok"] is False
266 assert data["fingerprint_matches_mnemonic"] is False
267
268 def test_S5_fails_when_no_mnemonic_in_keychain(
269 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
270 ) -> None:
271 import json
272 _patch_home(monkeypatch, tmp_path)
273 _patch_keychain(monkeypatch)
274 _run_keygen_and_register(monkeypatch)
275
276 # Remove mnemonic from keychain
277 monkeypatch.setattr("muse.core.keychain.load", lambda: None)
278
279 result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"])
280 assert result.exit_code != 0
281 data = json.loads(result.output)
282 assert data["ok"] is False
283 assert data["mnemonic_in_keychain"] is False
File History 1 commit
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago