"""Tests for identity data-integrity: keygen → register → resolve must be consistent. Critical invariant ------------------ After ``muse auth keygen`` followed by ``muse auth register``, the identity entry in ``identity.toml`` must: 1. Contain ``hd_path`` (written by keygen, must survive the register write). 2. NOT contain ``key_path`` (no PEM path should appear post-Phase-4). 3. Have a ``fingerprint`` that matches the mnemonic-derived key, NOT a stale PEM. 4. Allow ``resolve_signing_identity`` to return a key (full round-trip). These tests are RED until ``run_register`` is updated to: - Derive the public key from the mnemonic (via ``resolve_signing_identity``) instead of reading a PEM file. - Write the entry without ``key_path``. - Preserve ``hd_path`` from the keygen entry. """ from __future__ import annotations import json import pathlib from unittest.mock import MagicMock, patch 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 long_id runner = CliRunner() type _KeychainStore = dict[str, str] type _RegisterResp = dict[str, str | bool] _FIXED_MNEMONIC = ( "abandon abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon about" ) _HUB = "https://localhost:1337" _HOSTNAME = "localhost:1337" # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- 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: """Patch keychain with _FIXED_MNEMONIC pre-loaded.""" _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 _fake_register_response(handle: str = "gabriel") -> _RegisterResp: """Minimal hub verify response.""" return { "handle": handle, "identity_id": long_id("a" * 64), "is_new_identity": True, } def _run_keygen(monkeypatch: pytest.MonkeyPatch) -> None: """Run auth keygen with fixed mnemonic; no --force (reuses keychain).""" 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}" def _run_register(monkeypatch: pytest.MonkeyPatch) -> None: """Run auth register with a mocked hub HTTP response.""" from muse.core.bip39 import mnemonic_to_seed from muse.core.keypair import derive_hd_public_info seed = mnemonic_to_seed(_FIXED_MNEMONIC) pub_b64, fingerprint = derive_hd_public_info(seed) challenge_resp = {"challenge_token": "deadbeef" * 8, "is_new_key": True} verify_resp = _fake_register_response() monkeypatch.setattr("muse.cli.commands.auth._post_challenge", lambda *a, **kw: challenge_resp) monkeypatch.setattr("muse.cli.commands.auth._post_verify", lambda *a, **kw: verify_resp) result = runner.invoke(None, ["auth", "register", "--hub", _HUB, "--handle", "gabriel"]) assert result.exit_code == 0, f"register failed: {result.output}" # --------------------------------------------------------------------------- # Tests — each is independent, running keygen then register # --------------------------------------------------------------------------- class TestRegisterPreservesHdPath: """R1: hd_path written by keygen must survive the register write.""" def test_R1_hd_path_present_after_register( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: import tomllib _patch_home(monkeypatch, tmp_path) _patch_keychain(monkeypatch) _run_keygen(monkeypatch) _run_register(monkeypatch) identity_file = id_module._IDENTITY_FILE parsed = tomllib.loads(identity_file.read_text()) entry = parsed[_HOSTNAME] assert "hd_path" in entry, ( f"hd_path was lost during register. Entry: {entry}" ) assert entry["hd_path"].startswith("m/"), f"hd_path malformed: {entry['hd_path']}" class TestRegisterNoKeyPath: """R2: key_path must NOT appear in the entry after register.""" def test_R2_no_key_path_after_register( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: import tomllib _patch_home(monkeypatch, tmp_path) _patch_keychain(monkeypatch) _run_keygen(monkeypatch) _run_register(monkeypatch) identity_file = id_module._IDENTITY_FILE parsed = tomllib.loads(identity_file.read_text()) entry = parsed[_HOSTNAME] assert "key_path" not in entry, ( f"key_path must not appear in identity entry after register. Entry: {entry}" ) class TestRegisterFingerprintMatchesMnemonic: """R3: fingerprint in the entry must match the mnemonic-derived key, not a stale PEM.""" def test_R3_fingerprint_matches_mnemonic_derivation( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: import tomllib from muse.core.bip39 import mnemonic_to_seed from muse.core.keypair import derive_hd_public_info _patch_home(monkeypatch, tmp_path) _patch_keychain(monkeypatch) _run_keygen(monkeypatch) _run_register(monkeypatch) seed = mnemonic_to_seed(_FIXED_MNEMONIC) _, expected_fingerprint = derive_hd_public_info(seed) identity_file = id_module._IDENTITY_FILE parsed = tomllib.loads(identity_file.read_text()) entry = parsed[_HOSTNAME] stored_fp = entry.get("fingerprint", "") assert stored_fp == expected_fingerprint, ( f"Fingerprint mismatch: stored={stored_fp} expected={expected_fingerprint}. " "register wrote a stale PEM-derived fingerprint instead of the mnemonic-derived one." ) class TestRegisterRoundTrip: """R4: resolve_signing_identity must return a key after keygen + register.""" def test_R4_resolve_signing_identity_works_after_register( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from muse.core.identity import resolve_signing_identity _patch_home(monkeypatch, tmp_path) _patch_keychain(monkeypatch) _run_keygen(monkeypatch) _run_register(monkeypatch) result = resolve_signing_identity(_HUB) assert result is not None, ( "resolve_signing_identity returned None after keygen + register. " "The identity entry is missing hd_path or the mnemonic is not in the keychain." ) handle, private_key = result assert handle == "gabriel" assert isinstance(private_key, Ed25519PrivateKey)