"""Data-integrity tests for the auth logout → keygen → push cycle. Bug --- ``muse auth logout --hub X`` removes the entire identity entry. ``muse auth keygen --hub X`` then writes a new entry with ``handle = ""``. Subsequent pushes fail 401 because the MSign Authorization header carries an empty handle that the server cannot match. Expected invariants ------------------- I1. ``keygen --force`` on an existing entry preserves the registered handle. I2. After ``logout`` + ``keygen``, ``register --handle H`` restores the handle (idempotent: server returns "already registered" but client still writes it). I3. After full restore (logout → keygen → register), ``resolve_signing_identity`` returns the original handle so push can sign successfully. """ from __future__ import annotations import pathlib from collections.abc import Mapping import pytest import tomllib 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 type _KcDict = dict[str, str] type _ChallengeResp = dict[str, bool | str] type _VerifyResp = dict[str, str | bool] runner = CliRunner() _FIXED_MNEMONIC = ( "abandon abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon about" ) _HUB = "https://localhost:1337" _HOSTNAME = "localhost:1337" _HANDLE = "gabriel" # --------------------------------------------------------------------------- # Shared 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) -> _KcDict: 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 _run_keygen(monkeypatch: pytest.MonkeyPatch, force: bool = False) -> None: import muse.core.bip39 as bip39_mod monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _FIXED_MNEMONIC) cmd = ["auth", "keygen", "--hub", _HUB] if force: cmd.append("--force") result = runner.invoke(None, cmd) assert result.exit_code == 0, f"keygen failed: {result.output}\n{result.stderr}" def _run_logout(monkeypatch: pytest.MonkeyPatch) -> None: result = runner.invoke(None, ["auth", "logout", "--hub", _HUB]) assert result.exit_code == 0, f"logout failed: {result.output}" def _seed_identity_with_handle(handle: str = _HANDLE) -> None: """Write a complete identity entry (including handle) into the toml file.""" from muse.core.bip39 import mnemonic_to_seed from muse.core.keypair import derive_hd_public_info from muse.core.hdkeys import muse_path, DOMAIN_IDENTITY, ENTITY_HUMAN seed = mnemonic_to_seed(_FIXED_MNEMONIC) pub_b64, fingerprint = derive_hd_public_info(seed) hd_path_str = muse_path(DOMAIN_IDENTITY, ENTITY_HUMAN) entry = { "type": "human", "handle": handle, "algorithm": "ed25519", "fingerprint": fingerprint, "hd_path": hd_path_str, } id_module.save_identity(_HUB, entry, mnemonic=_FIXED_MNEMONIC) # --------------------------------------------------------------------------- # I1 — keygen --force preserves the registered handle # --------------------------------------------------------------------------- class TestKeygenForcePreservesHandle: """I1: When --force is used on an existing entry, the handle must survive.""" def test_force_keygen_preserves_handle( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _patch_home(monkeypatch, tmp_path) _patch_keychain(monkeypatch) # Pre-condition: a complete identity with handle exists. _seed_identity_with_handle(_HANDLE) pre = tomllib.loads(id_module._IDENTITY_FILE.read_text()) assert pre[_HOSTNAME].get("handle") == _HANDLE # Act: re-key with --force (same mnemonic, same key material). _run_keygen(monkeypatch, force=True) # Assert: handle must be preserved in the new entry. post = tomllib.loads(id_module._IDENTITY_FILE.read_text()) entry = post[_HOSTNAME] assert entry.get("handle") == _HANDLE, ( f"keygen --force erased the registered handle. Entry: {entry}" ) def test_force_keygen_still_updates_fingerprint( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Sanity check: --force updates key material, just not the handle.""" _patch_home(monkeypatch, tmp_path) _patch_keychain(monkeypatch) _seed_identity_with_handle(_HANDLE) _run_keygen(monkeypatch, force=True) post = tomllib.loads(id_module._IDENTITY_FILE.read_text()) entry = post[_HOSTNAME] assert "fingerprint" in entry assert "hd_path" in entry assert entry.get("handle") == _HANDLE # --------------------------------------------------------------------------- # I2 — logout → keygen → register restores handle # --------------------------------------------------------------------------- class TestLogoutKeygenRegisterRestoresHandle: """I2: The logout → keygen → register cycle must end with a valid handle.""" def _fake_challenge_resp(self) -> _ChallengeResp: return {"challenge_token": "ab" * 32, "is_new_key": False} def _fake_verify_resp(self, handle: str = _HANDLE) -> _VerifyResp: return { "handle": handle, "identity_id": long_id("a" * 64), "is_new_identity": False, } def test_register_after_logout_keygen_writes_handle( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _patch_home(monkeypatch, tmp_path) _patch_keychain(monkeypatch) # Start from a complete identity. _seed_identity_with_handle(_HANDLE) # Logout wipes the entry. _run_logout(monkeypatch) # keygen creates an entry without a handle. _run_keygen(monkeypatch) mid = tomllib.loads(id_module._IDENTITY_FILE.read_text()) assert mid[_HOSTNAME].get("handle", "") == "", "precondition: handle is empty after logout+keygen" # Register (mocked server returns the handle). monkeypatch.setattr("muse.cli.commands.auth._post_challenge", lambda *a, **kw: self._fake_challenge_resp()) monkeypatch.setattr("muse.cli.commands.auth._post_verify", lambda *a, **kw: self._fake_verify_resp()) result = runner.invoke(None, ["auth", "register", "--hub", _HUB, "--handle", _HANDLE]) assert result.exit_code == 0, f"register failed: {result.output}\n{result.stderr}" post = tomllib.loads(id_module._IDENTITY_FILE.read_text()) entry = post[_HOSTNAME] assert entry.get("handle") == _HANDLE, ( f"handle not restored after logout+keygen+register. Entry: {entry}" ) def test_register_idempotent_when_key_already_on_server( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Server says key is known (isNewKey=False) — register must succeed and write handle.""" _patch_home(monkeypatch, tmp_path) _patch_keychain(monkeypatch) _seed_identity_with_handle(_HANDLE) _run_logout(monkeypatch) _run_keygen(monkeypatch) # Server returns isNewKey=False — the key is already registered. monkeypatch.setattr("muse.cli.commands.auth._post_challenge", lambda *a, **kw: {"challenge_token": "cd" * 32, "is_new_key": False}) monkeypatch.setattr("muse.cli.commands.auth._post_verify", lambda *a, **kw: {"handle": _HANDLE, "identity_id": long_id("b" * 64), "is_new_identity": False}) result = runner.invoke(None, ["auth", "register", "--hub", _HUB, "--handle", _HANDLE]) assert result.exit_code == 0, f"idempotent register failed: {result.output}" post = tomllib.loads(id_module._IDENTITY_FILE.read_text()) assert post[_HOSTNAME].get("handle") == _HANDLE # --------------------------------------------------------------------------- # I3 — full restore: resolve_signing_identity returns handle after cycle # --------------------------------------------------------------------------- class TestFullRestoreRoundTrip: """I3: resolve_signing_identity must return the correct handle after the full cycle.""" def test_resolve_signing_identity_after_logout_keygen_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) _seed_identity_with_handle(_HANDLE) _run_logout(monkeypatch) _run_keygen(monkeypatch) monkeypatch.setattr("muse.cli.commands.auth._post_challenge", lambda *a, **kw: {"challenge_token": "ef" * 32, "is_new_key": False}) monkeypatch.setattr("muse.cli.commands.auth._post_verify", lambda *a, **kw: {"handle": _HANDLE, "identity_id": long_id("c" * 64), "is_new_identity": False}) result = runner.invoke(None, ["auth", "register", "--hub", _HUB, "--handle", _HANDLE]) assert result.exit_code == 0 signing = resolve_signing_identity(_HUB) assert signing is not None, "resolve_signing_identity returned None after restore" handle, private_key = signing assert handle == _HANDLE, f"expected handle '{_HANDLE}', got '{handle}'" assert isinstance(private_key, Ed25519PrivateKey)