"""Tests for TTY-gated mnemonic display at ``muse auth keygen``. The mnemonic is the root secret — users *must* see it once to write it down. But printing it unconditionally leaks it into CI logs, piped stderr, and terminal scroll buffers. Design: display mirrors GPG / ssh-keygen behaviour. - stderr is a TTY → show the full mnemonic box (user is watching) - stderr is not a TTY → suppress the mnemonic; print word count + hint to re-run interactively Coverage -------- I Non-TTY stderr (CI / pipe / script — default in tests) I1 mnemonic words do NOT appear in stderr I2 output contains the word count I3 output contains a hint to re-run interactively I4 JSON stdout is unaffected (mnemonic_word_count present, no plaintext) II TTY stderr — mnemonic never displayed (keychain-only model) II1 mnemonic does NOT appear even when stderr is a TTY II2 backup retrieval instructions appear on TTY stderr II3 JSON stdout still has mnemonic_word_count; no plaintext mnemonic III Boundary — non-TTY must never leak the mnemonic III1 mnemonic absent even when --json is combined with non-TTY III2 mnemonic absent from JSON stdout regardless of TTY state """ from __future__ import annotations import json import pathlib import pytest from tests.cli_test_helper import CliRunner from muse.core import keypair as kp_module from muse.core import identity as id_module from muse.core.bip39 import validate_mnemonic runner = CliRunner() _HUB = "https://localhost:1337" _MNEMONIC = ( "abandon abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon about" ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def isolated(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") return fake_home @pytest.fixture() def fixed_mnemonic(monkeypatch: pytest.MonkeyPatch) -> str: from muse.core import bip39 as bip39_mod monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _MNEMONIC) return _MNEMONIC def _keygen(extra: list[str] | None = None) -> "InvokeResult": return runner.invoke(None, ["auth", "keygen", "--hub", _HUB] + (extra or [])) def _enable_tty(monkeypatch: pytest.MonkeyPatch) -> None: """Patch the stderr-isatty hook so the mnemonic display branch fires.""" import muse.cli.commands.auth as auth_mod monkeypatch.setattr(auth_mod, "_stderr_isatty", lambda: True) # --------------------------------------------------------------------------- # I Non-TTY stderr (default in tests / CI) # --------------------------------------------------------------------------- class TestNonTtyMnemonicSuppressed: def test_I1_mnemonic_not_in_output( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """I1: mnemonic words must not appear in output when stderr is not a TTY.""" r = _keygen() assert r.exit_code == 0, r.output # type: ignore[union-attr] # Check each individual word — any one appearing is a leak. for word in _MNEMONIC.split(): assert word not in r.output, ( # type: ignore[union-attr] f"Mnemonic word {word!r} leaked into non-TTY output" ) def test_I2_output_contains_word_count( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """I2: word count is always shown so the user knows a mnemonic was generated.""" r = _keygen() assert r.exit_code == 0, r.output # type: ignore[union-attr] assert "12" in r.stderr, "Word count must appear in non-TTY output" # type: ignore[union-attr] def test_I3_output_hints_to_run_interactively( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """I3: non-TTY output must tell the user to re-run interactively to see the mnemonic.""" r = _keygen() assert r.exit_code == 0, r.output # type: ignore[union-attr] output_lower = r.stderr.lower() # type: ignore[union-attr] assert "interactive" in output_lower or "terminal" in output_lower or "tty" in output_lower, ( "Non-TTY output must hint that the mnemonic is visible in an interactive terminal" ) def test_I4_json_has_word_count_not_plaintext( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """I4: --json output has mnemonic_word_count but never the plaintext.""" r = _keygen(["--json"]) assert r.exit_code == 0, r.output # type: ignore[union-attr] payload = json.loads(r.output.splitlines()[0]) # type: ignore[union-attr] assert payload.get("mnemonic_word_count") == 12 assert "mnemonic" not in payload or payload.get("mnemonic") is None for word in _MNEMONIC.split(): assert word not in r.output, f"Mnemonic word {word!r} in JSON stdout" # type: ignore[union-attr] # --------------------------------------------------------------------------- # II TTY stderr — mnemonic never displayed (keychain-only model) # --------------------------------------------------------------------------- class TestTtyMnemonicNeverDisplayed: def test_II1_mnemonic_not_in_tty_output( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """II1: mnemonic must NOT appear even when stderr is a TTY. Mnemonic never appears in terminal output — terminal scrollback is not safe storage. The keychain is the only place it lives. """ _enable_tty(monkeypatch) r = _keygen() assert r.exit_code == 0, r.output # type: ignore[union-attr] for word in _MNEMONIC.split(): assert word not in (r.stderr or ""), ( f"Mnemonic word {word!r} leaked into TTY stderr — mnemonic must never be printed" ) def test_II2_backup_instructions_shown_on_tty( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """II2: backup retrieval instructions appear on TTY stderr.""" _enable_tty(monkeypatch) r = _keygen() assert r.exit_code == 0, r.output # type: ignore[union-attr] assert "keychain" in (r.stderr or "").lower(), ( "Backup instructions must mention the keychain on TTY output" ) def test_II3_json_word_count_correct_on_tty( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """II3: --json mnemonic_word_count is correct on TTY too.""" _enable_tty(monkeypatch) r = _keygen(["--json"]) assert r.exit_code == 0, r.output # type: ignore[union-attr] payload = json.loads(r.output.splitlines()[0]) # type: ignore[union-attr] assert payload.get("mnemonic_word_count") == 12 # --------------------------------------------------------------------------- # III Boundary — non-TTY must never leak the mnemonic # --------------------------------------------------------------------------- class TestMnemonicNeverInStdout: def test_III1_mnemonic_absent_json_plus_non_tty( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """III1: --json + non-TTY stderr: mnemonic absent from all output.""" r = _keygen(["--json"]) assert r.exit_code == 0, r.output # type: ignore[union-attr] for word in _MNEMONIC.split(): assert word not in r.output, f"Mnemonic word {word!r} leaked in JSON+non-TTY output" # type: ignore[union-attr] def test_III2_mnemonic_never_in_json_payload( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """III2: JSON stdout never contains mnemonic plaintext, even on a TTY.""" _enable_tty(monkeypatch) r = _keygen(["--json"]) assert r.exit_code == 0, r.output # type: ignore[union-attr] json_line = r.output.splitlines()[0] # type: ignore[union-attr] payload = json.loads(json_line) assert "mnemonic" not in payload or payload.get("mnemonic") is None, ( "JSON payload must never contain the mnemonic plaintext" )