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