"""Tests for Phase 5 — PEM cleanup and security-check commands. Phase 5 invariants ------------------ After Phases 1–4, no PEM files should exist on disk and no ``key_path`` fields should appear in ``identity.toml``. Two new commands enforce this: muse auth cleanup-keys -- securely overwrite + delete all ~/.muse/keys/*.pem muse auth security-check -- verify all four invariants, exit 1 if any fail """ from __future__ import annotations import os import pathlib import pytest from tests.cli_test_helper import CliRunner import muse.core.keypair as kp_module import muse.core.identity as id_module from muse.core.types import NULL_LONG_ID, long_id runner = CliRunner() type _KeychainStore = dict[str, str] _FIXED_MNEMONIC = ( "abandon abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon about" ) _HUB = "https://localhost:1337" _HOSTNAME = "localhost:1337" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path: fake_home = tmp_path / "home" fake_home.mkdir(parents=True, exist_ok=True) monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys") monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse") monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml") monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False) return fake_home def _patch_keychain(monkeypatch: pytest.MonkeyPatch) -> _KeychainStore: _kc: dict[str, str] = {"mnemonic": _FIXED_MNEMONIC} monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) return _kc def _write_fake_pem(keys_dir: pathlib.Path, name: str = "localhost_1337.pem") -> pathlib.Path: """Write a fake PEM file (not a real key, just bytes to verify overwrite).""" keys_dir.mkdir(parents=True, mode=0o700, exist_ok=True) pem_path = keys_dir / name pem_path.write_bytes(b"FAKE_PEM_CONTENT_FOR_TESTING") pem_path.chmod(0o600) return pem_path def _run_keygen_and_register(monkeypatch: pytest.MonkeyPatch) -> None: """Run keygen + register with mocked hub to produce a clean identity entry.""" import muse.core.bip39 as bip39_mod monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _FIXED_MNEMONIC) result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB]) assert result.exit_code == 0, f"keygen failed: {result.output}" monkeypatch.setattr("muse.cli.commands.auth._post_challenge", lambda *a, **kw: {"challenge_token": "ab" * 32, "is_new_key": True}) monkeypatch.setattr("muse.cli.commands.auth._post_verify", lambda *a, **kw: {"handle": "gabriel", "identity_id": long_id("a" * 64), "is_new_identity": True}) result = runner.invoke(None, ["auth", "register", "--hub", _HUB, "--handle", "gabriel"]) assert result.exit_code == 0, f"register failed: {result.output}" # --------------------------------------------------------------------------- # cleanup-keys tests # --------------------------------------------------------------------------- class TestCleanupKeys: def test_C1_destroys_pem_files( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: fake_home = _patch_home(monkeypatch, tmp_path) keys_dir = fake_home / ".muse" / "keys" pem = _write_fake_pem(keys_dir, "localhost_1337.pem") original_content = pem.read_bytes() result = runner.invoke(None, ["auth", "cleanup-keys"]) assert result.exit_code == 0, f"cleanup-keys failed: {result.output}" assert not pem.exists(), "PEM file should be deleted" _ = original_content # referenced to confirm it was different before def test_C2_json_output_lists_destroyed_paths( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: import json fake_home = _patch_home(monkeypatch, tmp_path) keys_dir = fake_home / ".muse" / "keys" pem_a = _write_fake_pem(keys_dir, "host_a.pem") pem_b = _write_fake_pem(keys_dir, "host_b.pem") result = runner.invoke(None, ["auth", "cleanup-keys", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["count"] == 2 assert str(pem_a) in data["destroyed"] assert str(pem_b) in data["destroyed"] def test_C3_no_pem_files_is_not_an_error( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: import json _patch_home(monkeypatch, tmp_path) result = runner.invoke(None, ["auth", "cleanup-keys", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["count"] == 0 assert data["destroyed"] == [] def test_C4_pem_content_is_overwritten_before_deletion( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Verify the file is overwritten (content replaced) before deletion. We hook os.unlink to capture the final content before the file is gone. """ fake_home = _patch_home(monkeypatch, tmp_path) keys_dir = fake_home / ".muse" / "keys" pem = _write_fake_pem(keys_dir) original_content = b"FAKE_PEM_CONTENT_FOR_TESTING" assert pem.read_bytes() == original_content captured: list[bytes] = [] real_unlink = pathlib.Path.unlink def capturing_unlink(self: pathlib.Path, missing_ok: bool = False) -> None: if self == pem: captured.append(self.read_bytes()) real_unlink(self, missing_ok=missing_ok) monkeypatch.setattr(pathlib.Path, "unlink", capturing_unlink) result = runner.invoke(None, ["auth", "cleanup-keys"]) assert result.exit_code == 0 assert captured, "unlink hook was not called" assert captured[0] != original_content, "file content should be overwritten before deletion" def test_C5_only_pem_files_are_deleted( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: fake_home = _patch_home(monkeypatch, tmp_path) keys_dir = fake_home / ".muse" / "keys" keys_dir.mkdir(parents=True, mode=0o700, exist_ok=True) pem = _write_fake_pem(keys_dir) other_file = keys_dir / "notes.txt" other_file.write_text("not a pem") result = runner.invoke(None, ["auth", "cleanup-keys"]) assert result.exit_code == 0 assert not pem.exists() assert other_file.exists(), "non-PEM files must not be touched" # --------------------------------------------------------------------------- # security-check tests # --------------------------------------------------------------------------- class TestSecurityCheck: def test_S1_all_checks_pass_after_clean_keygen_register( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: import json _patch_home(monkeypatch, tmp_path) _patch_keychain(monkeypatch) _run_keygen_and_register(monkeypatch) result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"]) assert result.exit_code == 0, f"security-check failed: {result.output}" data = json.loads(result.output) assert data["ok"] is True assert data["mnemonic_in_keychain"] is True assert data["no_pem_files"] is True assert data["no_key_path_in_identity"] is True assert data["fingerprint_matches_mnemonic"] is True assert data["pem_files_found"] == [] assert data["key_path_entries"] == [] def test_S2_fails_when_pem_file_exists( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: import json fake_home = _patch_home(monkeypatch, tmp_path) _patch_keychain(monkeypatch) _run_keygen_and_register(monkeypatch) # Plant a stale PEM file keys_dir = fake_home / ".muse" / "keys" pem = _write_fake_pem(keys_dir) result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"]) assert result.exit_code != 0 data = json.loads(result.output) assert data["ok"] is False assert data["no_pem_files"] is False assert str(pem) in data["pem_files_found"] def test_S3_fails_when_key_path_in_identity( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """security-check detects key_path written by old versions of muse. _dump_identity no longer writes key_path, so we write the TOML directly to simulate an old-format identity file that still has the field. """ import json fake_home = _patch_home(monkeypatch, tmp_path) _patch_keychain(monkeypatch) _run_keygen_and_register(monkeypatch) # Read the TOML written by keygen+register, then append key_path manually # to simulate what an old muse version would have written. identity_file = fake_home / ".muse" / "identity.toml" toml_text = identity_file.read_text() # Inject key_path after the handle line to simulate old-format file toml_text = toml_text.replace( f'["{_HOSTNAME}"]', f'["{_HOSTNAME}"]', ) # Append key_path field to the section toml_text += f'\nkey_path = "/fake/path.pem"\n' identity_file.write_text(toml_text) result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"]) assert result.exit_code != 0 data = json.loads(result.output) assert data["ok"] is False assert data["no_key_path_in_identity"] is False assert _HOSTNAME in data["key_path_entries"] def test_S4_fails_when_fingerprint_mismatches( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: import json _patch_home(monkeypatch, tmp_path) _patch_keychain(monkeypatch) _run_keygen_and_register(monkeypatch) # Overwrite fingerprint with a stale/wrong value via save_identity from muse.core.identity import load_identity, save_identity entry = load_identity(_HUB) assert entry is not None entry["fingerprint"] = NULL_LONG_ID save_identity(_HUB, entry) result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"]) assert result.exit_code != 0 data = json.loads(result.output) assert data["ok"] is False assert data["fingerprint_matches_mnemonic"] is False def test_S5_fails_when_no_mnemonic_in_keychain( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: import json _patch_home(monkeypatch, tmp_path) _patch_keychain(monkeypatch) _run_keygen_and_register(monkeypatch) # Remove mnemonic from keychain monkeypatch.setattr("muse.core.keychain.load", lambda: None) result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"]) assert result.exit_code != 0 data = json.loads(result.output) assert data["ok"] is False assert data["mnemonic_in_keychain"] is False