gabriel / muse public

test_auth_mnemonic_display.py file-level

at sha256:8 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:4 Merge branch 'dev' into main · gabriel · Jun 17, 2026
1 """Tests for TTY-gated mnemonic display at ``muse auth keygen``.
2
3 The mnemonic is the root secret β€” users *must* see it once to write it down.
4 But printing it unconditionally leaks it into CI logs, piped stderr, and
5 terminal scroll buffers.
6
7 Design: display mirrors GPG / ssh-keygen behaviour.
8 - stderr is a TTY β†’ show the full mnemonic box (user is watching)
9 - stderr is not a TTY β†’ suppress the mnemonic; print word count + hint to
10 re-run interactively
11
12 Coverage
13 --------
14 I Non-TTY stderr (CI / pipe / script β€” default in tests)
15 I1 mnemonic words do NOT appear in stderr
16 I2 output contains the word count
17 I3 output contains a hint to re-run interactively
18 I4 JSON stdout is unaffected (mnemonic_word_count present, no plaintext)
19
20 II TTY stderr β€” mnemonic never displayed (keychain-only model)
21 II1 mnemonic does NOT appear even when stderr is a TTY
22 II2 backup retrieval instructions appear on TTY stderr
23 II3 JSON stdout still has mnemonic_word_count; no plaintext mnemonic
24
25 III Boundary β€” non-TTY must never leak the mnemonic
26 III1 mnemonic absent even when --json is combined with non-TTY
27 III2 mnemonic absent from JSON stdout regardless of TTY state
28 """
29
30 from __future__ import annotations
31
32 import json
33 import pathlib
34
35 import pytest
36
37 from tests.cli_test_helper import CliRunner
38 from muse.core import keypair as kp_module
39 from muse.core import identity as id_module
40 from muse.core.bip39 import validate_mnemonic
41
42 runner = CliRunner()
43
44 _HUB = "https://localhost:1337"
45 _MNEMONIC = (
46 "abandon abandon abandon abandon abandon abandon abandon abandon "
47 "abandon abandon abandon about"
48 )
49
50
51 # ---------------------------------------------------------------------------
52 # Fixtures
53 # ---------------------------------------------------------------------------
54
55
56 @pytest.fixture()
57 def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
58 fake_home = tmp_path / "home"
59 fake_home.mkdir(parents=True, exist_ok=True)
60 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
61 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
62 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
63 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
64 return fake_home
65
66
67 @pytest.fixture()
68 def fixed_mnemonic(monkeypatch: pytest.MonkeyPatch) -> str:
69 from muse.core import bip39 as bip39_mod
70 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _MNEMONIC)
71 return _MNEMONIC
72
73
74 def _keygen(extra: list[str] | None = None) -> "InvokeResult":
75 return runner.invoke(None, ["auth", "keygen", "--hub", _HUB] + (extra or []))
76
77
78 def _enable_tty(monkeypatch: pytest.MonkeyPatch) -> None:
79 """Patch the stderr-isatty hook so the mnemonic display branch fires."""
80 import muse.cli.commands.auth as auth_mod
81 monkeypatch.setattr(auth_mod, "_stderr_isatty", lambda: True)
82
83
84 # ---------------------------------------------------------------------------
85 # I Non-TTY stderr (default in tests / CI)
86 # ---------------------------------------------------------------------------
87
88
89 class TestNonTtyMnemonicSuppressed:
90 def test_I1_mnemonic_not_in_output(
91 self, isolated: pathlib.Path, fixed_mnemonic: str
92 ) -> None:
93 """I1: mnemonic words must not appear in output when stderr is not a TTY."""
94 r = _keygen()
95 assert r.exit_code == 0, r.output # type: ignore[union-attr]
96 # Check each individual word β€” any one appearing is a leak.
97 for word in _MNEMONIC.split():
98 assert word not in r.output, ( # type: ignore[union-attr]
99 f"Mnemonic word {word!r} leaked into non-TTY output"
100 )
101
102 def test_I2_output_contains_word_count(
103 self, isolated: pathlib.Path, fixed_mnemonic: str
104 ) -> None:
105 """I2: word count is always shown so the user knows a mnemonic was generated."""
106 r = _keygen()
107 assert r.exit_code == 0, r.output # type: ignore[union-attr]
108 assert "12" in r.stderr, "Word count must appear in non-TTY output" # type: ignore[union-attr]
109
110 def test_I3_output_hints_to_run_interactively(
111 self, isolated: pathlib.Path, fixed_mnemonic: str
112 ) -> None:
113 """I3: non-TTY output must tell the user to re-run interactively to see the mnemonic."""
114 r = _keygen()
115 assert r.exit_code == 0, r.output # type: ignore[union-attr]
116 output_lower = r.stderr.lower() # type: ignore[union-attr]
117 assert "interactive" in output_lower or "terminal" in output_lower or "tty" in output_lower, (
118 "Non-TTY output must hint that the mnemonic is visible in an interactive terminal"
119 )
120
121 def test_I4_json_has_word_count_not_plaintext(
122 self, isolated: pathlib.Path, fixed_mnemonic: str
123 ) -> None:
124 """I4: --json output has mnemonic_word_count but never the plaintext."""
125 r = _keygen(["--json"])
126 assert r.exit_code == 0, r.output # type: ignore[union-attr]
127 payload = json.loads(r.output.splitlines()[0]) # type: ignore[union-attr]
128 assert payload.get("mnemonic_word_count") == 12
129 assert "mnemonic" not in payload or payload.get("mnemonic") is None
130 for word in _MNEMONIC.split():
131 assert word not in r.output, f"Mnemonic word {word!r} in JSON stdout" # type: ignore[union-attr]
132
133
134 # ---------------------------------------------------------------------------
135 # II TTY stderr β€” mnemonic never displayed (keychain-only model)
136 # ---------------------------------------------------------------------------
137
138
139 class TestTtyMnemonicNeverDisplayed:
140 def test_II1_mnemonic_not_in_tty_output(
141 self, isolated: pathlib.Path, fixed_mnemonic: str,
142 monkeypatch: pytest.MonkeyPatch,
143 ) -> None:
144 """II1: mnemonic must NOT appear even when stderr is a TTY.
145
146 Mnemonic never appears in terminal output β€” terminal scrollback is not
147 safe storage. The keychain is the only place it lives.
148 """
149 _enable_tty(monkeypatch)
150 r = _keygen()
151 assert r.exit_code == 0, r.output # type: ignore[union-attr]
152 for word in _MNEMONIC.split():
153 assert word not in (r.stderr or ""), (
154 f"Mnemonic word {word!r} leaked into TTY stderr β€” mnemonic must never be printed"
155 )
156
157 def test_II2_backup_instructions_shown_on_tty(
158 self, isolated: pathlib.Path, fixed_mnemonic: str,
159 monkeypatch: pytest.MonkeyPatch,
160 ) -> None:
161 """II2: backup retrieval instructions appear on TTY stderr."""
162 _enable_tty(monkeypatch)
163 r = _keygen()
164 assert r.exit_code == 0, r.output # type: ignore[union-attr]
165 assert "keychain" in (r.stderr or "").lower(), (
166 "Backup instructions must mention the keychain on TTY output"
167 )
168
169 def test_II3_json_word_count_correct_on_tty(
170 self, isolated: pathlib.Path, fixed_mnemonic: str,
171 monkeypatch: pytest.MonkeyPatch,
172 ) -> None:
173 """II3: --json mnemonic_word_count is correct on TTY too."""
174 _enable_tty(monkeypatch)
175 r = _keygen(["--json"])
176 assert r.exit_code == 0, r.output # type: ignore[union-attr]
177 payload = json.loads(r.output.splitlines()[0]) # type: ignore[union-attr]
178 assert payload.get("mnemonic_word_count") == 12
179
180
181 # ---------------------------------------------------------------------------
182 # III Boundary β€” non-TTY must never leak the mnemonic
183 # ---------------------------------------------------------------------------
184
185
186 class TestMnemonicNeverInStdout:
187 def test_III1_mnemonic_absent_json_plus_non_tty(
188 self, isolated: pathlib.Path, fixed_mnemonic: str
189 ) -> None:
190 """III1: --json + non-TTY stderr: mnemonic absent from all output."""
191 r = _keygen(["--json"])
192 assert r.exit_code == 0, r.output # type: ignore[union-attr]
193 for word in _MNEMONIC.split():
194 assert word not in r.output, f"Mnemonic word {word!r} leaked in JSON+non-TTY output" # type: ignore[union-attr]
195
196 def test_III2_mnemonic_never_in_json_payload(
197 self, isolated: pathlib.Path, fixed_mnemonic: str,
198 monkeypatch: pytest.MonkeyPatch,
199 ) -> None:
200 """III2: JSON stdout never contains mnemonic plaintext, even on a TTY."""
201 _enable_tty(monkeypatch)
202 r = _keygen(["--json"])
203 assert r.exit_code == 0, r.output # type: ignore[union-attr]
204 json_line = r.output.splitlines()[0] # type: ignore[union-attr]
205 payload = json.loads(json_line)
206 assert "mnemonic" not in payload or payload.get("mnemonic") is None, (
207 "JSON payload must never contain the mnemonic plaintext"
208 )