Coverage for tests / test_derivepassphrase_cli / test_000_basic.py: 100.000%
67 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
1# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
2#
3# SPDX-License-Identifier: Zlib
5"""Tests for the `derivepassphrase` command-line interface: common subsystems.
7(Currently, these tests are performed on the `derivepassphrase vault`
8subcommand, because that is the main command using all these
9subsystems.)
11"""
13from __future__ import annotations
15import contextlib
16import json
17import textwrap
18from typing import TYPE_CHECKING, ClassVar
20import pytest
22from derivepassphrase import _types, cli
23from derivepassphrase._internals import cli_helpers
24from tests import data, machinery
25from tests.machinery import pytest as pytest_machinery
27if TYPE_CHECKING:
28 from collections.abc import Generator
30DUMMY_SERVICE = data.DUMMY_SERVICE
31DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
32DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS
35class Parametrize(pytest_machinery.Parametrize):
36 """Common test parametrizations."""
38 UNICODE_NORMALIZATION_ERROR_INPUTS = pytest.mark.parametrize(
39 ["main_config", "command_line", "input", "error_message"],
40 [
41 pytest.param(
42 textwrap.dedent(r"""
43 [vault]
44 default-unicode-normalization-form = 'XXX'
45 """),
46 ["--import", "-"],
47 json.dumps({
48 "services": {
49 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
50 "with_normalization": {"phrase": "D\u00fcsseldorf"},
51 },
52 }),
53 (
54 "Invalid value 'XXX' for config key "
55 "vault.default-unicode-normalization-form"
56 ),
57 id="global",
58 ),
59 pytest.param(
60 textwrap.dedent(r"""
61 [vault.unicode-normalization-form]
62 with_normalization = 'XXX'
63 """),
64 ["--import", "-"],
65 json.dumps({
66 "services": {
67 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
68 "with_normalization": {"phrase": "D\u00fcsseldorf"},
69 },
70 }),
71 (
72 "Invalid value 'XXX' for config key "
73 "vault.with_normalization.unicode-normalization-form"
74 ),
75 id="service",
76 ),
77 pytest.param(
78 textwrap.dedent(r"""
79 [vault]
80 default-unicode-normalization-form = 'XXX'
81 """),
82 ["--config", "--phrase"],
83 DUMMY_PASSPHRASE,
84 (
85 "Invalid value 'XXX' for config key "
86 "vault.default-unicode-normalization-form"
87 ),
88 id="configure global passphrase",
89 ),
90 pytest.param(
91 textwrap.dedent(r"""
92 [vault]
93 default-unicode-normalization-form = 'XXX'
94 """),
95 ["--config", "--phrase", "--", DUMMY_SERVICE],
96 DUMMY_PASSPHRASE,
97 (
98 "Invalid value 'XXX' for config key "
99 "vault.default-unicode-normalization-form"
100 ),
101 id="configure service passphrase",
102 ),
103 pytest.param(
104 textwrap.dedent(r"""
105 [vault]
106 default-unicode-normalization-form = 'XXX'
107 """),
108 ["--phrase", "--", DUMMY_SERVICE],
109 DUMMY_PASSPHRASE,
110 (
111 "Invalid value 'XXX' for config key "
112 "vault.default-unicode-normalization-form"
113 ),
114 id="interactive passphrase",
115 ),
116 ],
117 )
118 UNICODE_NORMALIZATION_WARNING_INPUTS = pytest.mark.parametrize(
119 ["main_config", "command_line", "input", "warning_message"],
120 [
121 pytest.param(
122 "",
123 ["--import", "-"],
124 json.dumps({
125 "global": {"phrase": "Du\u0308sseldorf"},
126 "services": {},
127 }),
128 "The $.global passphrase is not NFC-normalized",
129 id="global-NFC",
130 ),
131 pytest.param(
132 "",
133 ["--import", "-"],
134 json.dumps({
135 "services": {
136 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
137 "weird entry name": {"phrase": "Du\u0308sseldorf"},
138 }
139 }),
140 (
141 'The $.services["weird entry name"] passphrase '
142 "is not NFC-normalized"
143 ),
144 id="service-weird-name-NFC",
145 ),
146 pytest.param(
147 "",
148 ["--config", "-p", "--", DUMMY_SERVICE],
149 "Du\u0308sseldorf",
150 (
151 f"The $.services.{DUMMY_SERVICE} passphrase "
152 f"is not NFC-normalized"
153 ),
154 id="config-NFC",
155 ),
156 pytest.param(
157 "",
158 ["-p", "--", DUMMY_SERVICE],
159 "Du\u0308sseldorf",
160 "The interactive input passphrase is not NFC-normalized",
161 id="direct-input-NFC",
162 ),
163 pytest.param(
164 textwrap.dedent(r"""
165 [vault]
166 default-unicode-normalization-form = 'NFD'
167 """),
168 ["--import", "-"],
169 json.dumps({
170 "global": {
171 "phrase": "D\u00fcsseldorf",
172 },
173 "services": {},
174 }),
175 "The $.global passphrase is not NFD-normalized",
176 id="global-NFD",
177 ),
178 pytest.param(
179 textwrap.dedent(r"""
180 [vault]
181 default-unicode-normalization-form = 'NFD'
182 """),
183 ["--import", "-"],
184 json.dumps({
185 "services": {
186 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
187 "weird entry name": {"phrase": "D\u00fcsseldorf"},
188 },
189 }),
190 (
191 'The $.services["weird entry name"] passphrase '
192 "is not NFD-normalized"
193 ),
194 id="service-weird-name-NFD",
195 ),
196 pytest.param(
197 textwrap.dedent(r"""
198 [vault.unicode-normalization-form]
199 'weird entry name 2' = 'NFKD'
200 """),
201 ["--import", "-"],
202 json.dumps({
203 "services": {
204 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
205 "weird entry name 1": {"phrase": "D\u00fcsseldorf"},
206 "weird entry name 2": {"phrase": "D\u00fcsseldorf"},
207 },
208 }),
209 (
210 'The $.services["weird entry name 2"] passphrase '
211 "is not NFKD-normalized"
212 ),
213 id="service-weird-name-2-NFKD",
214 ),
215 ],
216 )
219class TestPassphraseUnicodeNormalization:
220 """Tests concerning the Unicode normalization of passphrases."""
222 DEFAULT_VAULT_CONFIG: ClassVar[_types.VaultConfig] = {
223 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}
224 }
226 def _test(
227 self,
228 command_line: list[str],
229 /,
230 *,
231 main_config: str,
232 message: str,
233 caplog: pytest.LogCaptureFixture | None = None,
234 input: str | None = None,
235 ) -> None:
236 runner = machinery.CliRunner(mix_stderr=False) 1de
237 # TODO(the-13th-letter): Rewrite using parenthesized
238 # with-statements.
239 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
240 with contextlib.ExitStack() as stack: 1de
241 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1de
242 stack.enter_context( 1de
243 pytest_machinery.isolated_vault_config(
244 monkeypatch=monkeypatch,
245 runner=runner,
246 vault_config=self.DEFAULT_VAULT_CONFIG,
247 main_config_str=main_config,
248 )
249 )
250 result = runner.invoke( 1de
251 cli.derivepassphrase_vault,
252 ["--debug", *command_line]
253 if caplog is not None
254 else command_line,
255 catch_exceptions=False,
256 input=input,
257 )
258 if caplog is not None: 1de
259 assert result.clean_exit(), "expected clean exit" 1e
260 assert machinery.warning_emitted(message, caplog.record_tuples), ( 1e
261 "expected known warning message in stderr"
262 )
263 else:
264 assert result.error_exit( 1d
265 error="The user configuration file is invalid."
266 ), "expected error exit and known error message"
267 assert result.error_exit(error=message), ( 1d
268 "expected error exit and known error message"
269 )
271 @Parametrize.UNICODE_NORMALIZATION_WARNING_INPUTS
272 def test_warning(
273 self,
274 caplog: pytest.LogCaptureFixture,
275 main_config: str,
276 command_line: list[str],
277 input: str | None,
278 warning_message: str,
279 ) -> None:
280 """Using unnormalized Unicode passphrases warns."""
281 self._test( 1e
282 command_line,
283 main_config=main_config,
284 message=warning_message,
285 caplog=caplog,
286 input=input,
287 )
289 @Parametrize.UNICODE_NORMALIZATION_ERROR_INPUTS
290 def test_error(
291 self,
292 main_config: str,
293 command_line: list[str],
294 input: str | None,
295 error_message: str,
296 ) -> None:
297 """Using unknown Unicode normalization forms fails."""
298 self._test( 1d
299 command_line,
300 main_config=main_config,
301 message=error_message,
302 input=input,
303 )
306class TestUserConfigurationFileOther:
307 """Other tests concerning the user configuration file."""
309 @contextlib.contextmanager
310 def _test(
311 self,
312 *,
313 main_config: str = "",
314 ) -> Generator[pytest.MonkeyPatch, None, None]:
315 runner = machinery.CliRunner(mix_stderr=False) 1fb
316 # TODO(the-13th-letter): Rewrite using parenthesized
317 # with-statements.
318 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
319 with contextlib.ExitStack() as stack: 1fb
320 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1fb
321 stack.enter_context( 1fb
322 pytest_machinery.isolated_vault_config(
323 monkeypatch=monkeypatch,
324 runner=runner,
325 vault_config={"services": {}},
326 main_config_str=main_config,
327 )
328 )
329 # If the setup blows up, then bubble the exception.
330 yield monkeypatch # noqa: RUF075 1fb
331 result = runner.invoke( 1fb
332 cli.derivepassphrase_vault,
333 ["--phrase", "--", DUMMY_SERVICE],
334 input=DUMMY_PASSPHRASE,
335 catch_exceptions=False,
336 )
337 assert result.error_exit(error="Cannot load user config:"), ( 1fb
338 "expected error exit and known error message"
339 )
341 def test_bad_user_config_file(
342 self,
343 ) -> None:
344 """Loading a user configuration file in an invalid format fails."""
345 with self._test(main_config="This file is not valid TOML.\n"): 1f
346 pass 1f
348 def test_user_config_is_a_directory(
349 self,
350 ) -> None:
351 """Loading a user configuration non-file fails."""
352 with self._test(): 1b
353 user_config = cli_helpers.config_filename( 1b
354 subsystem="user configuration"
355 )
356 user_config.unlink() 1b
357 user_config.mkdir(parents=True, exist_ok=True) 1b
360class TestSSHAgentAvailability:
361 """Tests concerning the availability of the SSH agent."""
363 @Parametrize.MISSING_AGENT_SUPPORT
364 def test_missing_agent_support(
365 self,
366 caplog: pytest.LogCaptureFixture,
367 provider: _types.BuiltinSSHAgentSocketProvider,
368 sys_action: data.SystemSupportAction,
369 exception: type[NotImplementedError],
370 message: str,
371 ) -> None:
372 """Querying the SSH agent without support fails."""
373 del provider 1c
374 del exception 1c
375 runner = machinery.CliRunner(mix_stderr=False) 1c
376 # TODO(the-13th-letter): Rewrite using parenthesized
377 # with-statements.
378 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
379 with contextlib.ExitStack() as stack: 1c
380 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1c
381 stack.enter_context( 1c
382 pytest_machinery.isolated_vault_config(
383 monkeypatch=monkeypatch,
384 runner=runner,
385 vault_config={"services": {}},
386 )
387 )
388 monkeypatch.setenv( 1c
389 "SSH_AUTH_SOCK", "the value doesn't even matter"
390 )
391 sys_action(monkeypatch) 1c
392 result = runner.invoke( 1c
393 cli.derivepassphrase_vault,
394 ["--key", "--config"],
395 catch_exceptions=False,
396 )
397 assert result.error_exit( 1c
398 error="does not support communicating with it"
399 ), "expected error exit and known error message"
400 assert machinery.warning_emitted(message, caplog.record_tuples), ( 1c
401 "expected known warning message in stderr"
402 )