gabriel / muse public
test_auth_mnemonic_display.py python
205 lines 8.5 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 134 days ago
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 (interactive session)
21 II1 full mnemonic appears in stderr
22 II2 the displayed mnemonic is valid BIP-39
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):
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.output, "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.output.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 (interactive session)
136 # ---------------------------------------------------------------------------
137
138
139 class TestTtyMnemonicDisplayed:
140 def test_II1_full_mnemonic_in_output(
141 self, isolated: pathlib.Path, fixed_mnemonic: str,
142 monkeypatch: pytest.MonkeyPatch,
143 ) -> None:
144 """II1: full mnemonic appears in output when stderr is a TTY."""
145 _enable_tty(monkeypatch)
146 r = _keygen()
147 assert r.exit_code == 0, r.output # type: ignore[union-attr]
148 assert _MNEMONIC in r.output, "Full mnemonic must appear in TTY output" # type: ignore[union-attr]
149
150 def test_II2_displayed_mnemonic_is_valid_bip39(
151 self, isolated: pathlib.Path, monkeypatch: pytest.MonkeyPatch
152 ) -> None:
153 """II2: the mnemonic shown on a TTY passes BIP-39 validation."""
154 _enable_tty(monkeypatch)
155 r = _keygen()
156 assert r.exit_code == 0, r.output # type: ignore[union-attr]
157 found = None
158 for line in r.output.splitlines(): # type: ignore[union-attr]
159 words = line.strip().split()
160 if len(words) in (12, 15, 18, 21, 24) and all(w.isalpha() for w in words):
161 found = line.strip()
162 break
163 assert found is not None, "No mnemonic line found in TTY output"
164 assert validate_mnemonic(found), f"Displayed mnemonic failed BIP-39 validation: {found!r}"
165
166 def test_II3_json_word_count_correct_on_tty(
167 self, isolated: pathlib.Path, fixed_mnemonic: str,
168 monkeypatch: pytest.MonkeyPatch,
169 ) -> None:
170 """II3: --json mnemonic_word_count is correct on TTY too."""
171 _enable_tty(monkeypatch)
172 r = _keygen(["--json"])
173 assert r.exit_code == 0, r.output # type: ignore[union-attr]
174 payload = json.loads(r.output.splitlines()[0]) # type: ignore[union-attr]
175 assert payload.get("mnemonic_word_count") == 12
176
177
178 # ---------------------------------------------------------------------------
179 # III Boundary — non-TTY must never leak the mnemonic
180 # ---------------------------------------------------------------------------
181
182
183 class TestMnemonicNeverInStdout:
184 def test_III1_mnemonic_absent_json_plus_non_tty(
185 self, isolated: pathlib.Path, fixed_mnemonic: str
186 ) -> None:
187 """III1: --json + non-TTY stderr: mnemonic absent from all output."""
188 r = _keygen(["--json"])
189 assert r.exit_code == 0, r.output # type: ignore[union-attr]
190 for word in _MNEMONIC.split():
191 assert word not in r.output, f"Mnemonic word {word!r} leaked in JSON+non-TTY output" # type: ignore[union-attr]
192
193 def test_III2_mnemonic_never_in_json_payload(
194 self, isolated: pathlib.Path, fixed_mnemonic: str,
195 monkeypatch: pytest.MonkeyPatch,
196 ) -> None:
197 """III2: JSON stdout never contains mnemonic plaintext, even on a TTY."""
198 _enable_tty(monkeypatch)
199 r = _keygen(["--json"])
200 assert r.exit_code == 0, r.output # type: ignore[union-attr]
201 json_line = r.output.splitlines()[0] # type: ignore[union-attr]
202 payload = json.loads(json_line)
203 assert "mnemonic" not in payload or payload.get("mnemonic") is None, (
204 "JSON payload must never contain the mnemonic plaintext"
205 )
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 134 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 140 days ago