Coverage for tests / test_derivepassphrase_exporter.py: 100.000%
158 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"""Test `derivepassphrase.exporter` and the `derivepassphrase export` CLI."""
7from __future__ import annotations
9import contextlib
10import operator
11import os
12import pathlib
13import string
14import types
15from typing import TYPE_CHECKING, NamedTuple, TypeVar
17import hypothesis
18import pytest
19from hypothesis import strategies
21from derivepassphrase import cli, exporter
22from tests import data, machinery
23from tests.machinery import pytest as pytest_machinery
25if TYPE_CHECKING:
26 from collections.abc import Callable, Generator
28 from typing_extensions import Any, Buffer
31class Strategies:
32 @strategies.composite
33 @staticmethod
34 def names(draw: strategies.DrawFn) -> str:
35 """Return a strategy for identifier names."""
36 first_letter = draw( 1c
37 strategies.text(string.ascii_letters, min_size=1, max_size=1),
38 label="first_letter",
39 )
40 rest = draw( 1c
41 strategies.text(
42 string.ascii_letters + string.digits + "_-", max_size=23
43 ),
44 label="rest",
45 )
46 return first_letter + rest 1c
48 _T = TypeVar("_T")
50 @strategies.composite
51 @staticmethod
52 def pairs_of_lists(
53 draw: strategies.DrawFn,
54 strat: strategies.SearchStrategy[_T],
55 min_size: int = 1,
56 max_size: int = 3,
57 ) -> tuple[list[_T], list[_T]]:
58 """Return a strategy for two short lists, with unique items."""
59 size1 = draw( 1c
60 strategies.integers(min_value=min_size, max_value=max_size),
61 label="size1",
62 )
63 size2 = draw( 1c
64 strategies.integers(min_value=min_size, max_value=max_size),
65 label="size2",
66 )
67 all_values = draw( 1c
68 strategies.lists(
69 strat,
70 min_size=size1 + size2,
71 max_size=size1 + size2,
72 unique=True,
73 ),
74 label="all_values",
75 )
76 return all_values[:size1], all_values[size1:] 1c
79class Parametrize(types.SimpleNamespace):
80 EXPECTED_VAULT_PATH = pytest.mark.parametrize(
81 ["expected", "path"],
82 [
83 (pathlib.Path("/tmp"), pathlib.Path("/tmp")),
84 (pathlib.Path("~"), pathlib.Path()),
85 (pathlib.Path("~/.vault"), None),
86 ],
87 )
88 EXPORT_VAULT_CONFIG_DATA_HANDLER_NAMELISTS = pytest.mark.parametrize(
89 ["namelist", "err_pat"],
90 [
91 pytest.param((), "[Nn]o names given", id="empty"),
92 pytest.param(
93 ("name1", "", "name2"),
94 "[Uu]nder an empty name",
95 id="empty-string",
96 ),
97 pytest.param(
98 ("dummy", "name1", "name2"),
99 "[Aa]lready registered",
100 id="existing",
101 ),
102 ],
103 )
106class TestCLIUtilities:
107 """Test the command-line utility functions in the `exporter` subpackage."""
109 class VaultKeyEnvironment(NamedTuple):
110 """An environment configuration for vault key determination.
112 Attributes:
113 expected:
114 The correct vault key value.
115 vault_key:
116 The value for the `VAULT_KEY` environment variable.
117 logname:
118 The value for the `LOGNAME` environment variable.
119 user:
120 The value for the `USER` environment variable.
121 username:
122 The value for the `USERNAME` environment variable.
124 """
126 expected: str | None
127 """"""
128 vault_key: str | None
129 """"""
130 logname: str | None
131 """"""
132 user: str | None
133 """"""
134 username: str | None
135 """"""
137 @strategies.composite
138 @staticmethod
139 def strategy(
140 draw: strategies.DrawFn,
141 allow_missing: bool = False,
142 ) -> TestCLIUtilities.VaultKeyEnvironment:
143 """Return a vault key environment configuration."""
144 text_strategy = strategies.text( 1b
145 strategies.characters(min_codepoint=32, max_codepoint=127),
146 min_size=1,
147 max_size=24,
148 )
149 env_var_strategy = strategies.one_of( 1b
150 strategies.none(),
151 text_strategy,
152 )
153 num_fields = sum( 1b
154 1
155 for f in TestCLIUtilities.VaultKeyEnvironment._fields
156 if f != "expected"
157 )
158 env_vars: list[str | None] = draw( 1b
159 strategies.builds(
160 operator.add,
161 strategies.lists(
162 env_var_strategy,
163 min_size=num_fields - 1,
164 max_size=num_fields - 1,
165 ),
166 strategies.lists(
167 text_strategy
168 if not allow_missing
169 else env_var_strategy,
170 min_size=1,
171 max_size=1,
172 ),
173 )
174 )
175 expected: str | None = None 1b
176 for value in reversed(env_vars): 1b
177 if value is not None: 1b
178 expected = value 1b
179 return TestCLIUtilities.VaultKeyEnvironment(expected, *env_vars) 1b
181 @contextlib.contextmanager
182 def _setup_environment(self) -> Generator[pytest.MonkeyPatch, None, None]:
183 runner = machinery.CliRunner(mix_stderr=False) 1fb
184 # TODO(the-13th-letter): Rewrite using parenthesized
185 # with-statements.
186 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
187 with contextlib.ExitStack() as stack: 1fb
188 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1fb
189 stack.enter_context( 1fb
190 pytest_machinery.isolated_vault_exporter_config(
191 monkeypatch=monkeypatch, runner=runner
192 )
193 )
194 yield monkeypatch 1fb
196 @hypothesis.given(
197 vault_key_env=VaultKeyEnvironment.strategy().filter(
198 lambda env: bool(env.expected)
199 ),
200 )
201 # BEGIN pre-hypothesis parametrization examples
202 @hypothesis.example(
203 vault_key_env=VaultKeyEnvironment(
204 "4username", None, None, None, "4username"
205 )
206 )
207 @hypothesis.example(
208 vault_key_env=VaultKeyEnvironment("3user", None, None, "3user", None)
209 )
210 @hypothesis.example(
211 vault_key_env=VaultKeyEnvironment(
212 "3user", None, None, "3user", "4username"
213 )
214 )
215 @hypothesis.example(
216 vault_key_env=VaultKeyEnvironment(
217 "2logname", None, "2logname", None, None
218 )
219 )
220 @hypothesis.example(
221 vault_key_env=VaultKeyEnvironment(
222 "2logname", None, "2logname", None, "4username"
223 )
224 )
225 @hypothesis.example(
226 vault_key_env=VaultKeyEnvironment(
227 "2logname", None, "2logname", "3user", None
228 )
229 )
230 @hypothesis.example(
231 vault_key_env=VaultKeyEnvironment(
232 "2logname", None, "2logname", "3user", "4username"
233 )
234 )
235 @hypothesis.example(
236 vault_key_env=VaultKeyEnvironment(
237 "1vault_key", "1vault_key", None, None, None
238 )
239 )
240 @hypothesis.example(
241 vault_key_env=VaultKeyEnvironment(
242 "1vault_key", "1vault_key", None, None, "4username"
243 )
244 )
245 @hypothesis.example(
246 vault_key_env=VaultKeyEnvironment(
247 "1vault_key", "1vault_key", None, "3user", None
248 )
249 )
250 @hypothesis.example(
251 vault_key_env=VaultKeyEnvironment(
252 "1vault_key", "1vault_key", None, "3user", "4username"
253 )
254 )
255 @hypothesis.example(
256 vault_key_env=VaultKeyEnvironment(
257 "1vault_key", "1vault_key", "2logname", None, None
258 )
259 )
260 @hypothesis.example(
261 vault_key_env=VaultKeyEnvironment(
262 "1vault_key", "1vault_key", "2logname", None, "4username"
263 )
264 )
265 @hypothesis.example(
266 vault_key_env=VaultKeyEnvironment(
267 "1vault_key", "1vault_key", "2logname", "3user", None
268 )
269 )
270 @hypothesis.example(
271 vault_key_env=VaultKeyEnvironment(
272 "1vault_key", "1vault_key", "2logname", "3user", "4username"
273 )
274 )
275 # END pre-hypothesis parametrization examples
276 def test_get_vault_key(
277 self,
278 vault_key_env: VaultKeyEnvironment,
279 ) -> None:
280 """Look up the vault key in `VAULT_KEY`/`LOGNAME`/`USER`/`USERNAME`.
282 The correct environment variable value is used, according to
283 their relative priorities.
285 """
286 expected, vault_key, logname, user, username = vault_key_env 1b
287 assert expected is not None 1b
288 priority_list = [ 1b
289 ("VAULT_KEY", vault_key),
290 ("LOGNAME", logname),
291 ("USER", user),
292 ("USERNAME", username),
293 ]
294 with self._setup_environment() as monkeypatch: 1b
295 for key, value in priority_list: 1b
296 if value is not None: 1b
297 monkeypatch.setenv(key, value) 1b
298 assert os.fsdecode(exporter.get_vault_key()) == expected 1b
300 def test_get_vault_key_without_envs(self) -> None:
301 """Fail to look up the vault key in the empty environment."""
302 with pytest.MonkeyPatch.context() as monkeypatch: 1i
303 monkeypatch.delenv("VAULT_KEY", raising=False) 1i
304 monkeypatch.delenv("LOGNAME", raising=False) 1i
305 monkeypatch.delenv("USER", raising=False) 1i
306 monkeypatch.delenv("USERNAME", raising=False) 1i
307 with pytest.raises(KeyError, match="VAULT_KEY"): 1i
308 exporter.get_vault_key() 1i
310 @Parametrize.EXPECTED_VAULT_PATH
311 def test_get_vault_path(
312 self,
313 expected: pathlib.Path,
314 path: str | os.PathLike[str] | None,
315 ) -> None:
316 """Determine the vault path from `VAULT_PATH`.
318 Handle relative paths, absolute paths, and missing paths.
320 """
321 with self._setup_environment() as monkeypatch: 1f
322 if path: 1f
323 monkeypatch.setenv("VAULT_PATH", os.fspath(path)) 1f
324 assert ( 1f
325 exporter.get_vault_path().resolve()
326 == expected.expanduser().resolve()
327 )
329 # TODO(the-13th-letter): Revise, or remove.
330 def test_get_vault_path_without_home(self) -> None:
331 """Fail to look up the vault path without `HOME`."""
333 def raiser(*_args: Any, **_kwargs: Any) -> Any: 1j
334 raise RuntimeError("Cannot determine home directory.") # noqa: EM101,TRY003 1j
336 with pytest.MonkeyPatch.context() as monkeypatch: 1j
337 monkeypatch.setattr(pathlib.Path, "expanduser", raiser) 1j
338 monkeypatch.setattr(os.path, "expanduser", raiser) 1j
339 with pytest.raises( 1j
340 RuntimeError, match=r"[Cc]annot determine home directory"
341 ):
342 exporter.get_vault_path() 1j
345class TestExportVaultConfigDataHandlerRegistry:
346 """Test the registry of `vault` config data exporters."""
348 @contextlib.contextmanager
349 def _setup_environment(
350 self,
351 *,
352 registry: dict[str, Any] | None = None,
353 find_handlers: Callable | None = None,
354 ) -> Generator[pytest.MonkeyPatch, None, None]:
355 with pytest.MonkeyPatch.context() as monkeypatch: 1ghc
356 if registry is not None: # pragma: no branch 1ghc
357 monkeypatch.setattr( 1ghc
358 exporter, "_export_vault_config_data_registry", registry
359 )
360 if find_handlers is not None: # pragma: no branch 1ghc
361 monkeypatch.setattr( 1g
362 exporter, "find_vault_config_data_handlers", find_handlers
363 )
364 yield monkeypatch 1ghc
366 @staticmethod
367 def dummy_handler( # pragma: no cover
368 path: str | bytes | os.PathLike | None = None,
369 key: str | Buffer | None = None,
370 *,
371 format: str,
372 ) -> Any:
373 del path, key
374 raise ValueError(format)
376 @hypothesis.given(name_data=Strategies.pairs_of_lists(Strategies.names()))
377 def test_register_export_vault_config_data_handler( 1ac
378 self, name_data: tuple[list[str], list[str]]
379 ) -> None:
380 """Register vault config data export handlers."""
381 names1, names2 = name_data 1c
382 registry = dict.fromkeys(names1, self.dummy_handler) 1c
383 with self._setup_environment(registry=registry): 1c
384 dec = exporter.register_export_vault_config_data_handler(*names2) 1c
385 assert dec(self.dummy_handler) == self.dummy_handler 1c
386 assert registry == dict.fromkeys( 1c
387 names1 + names2, self.dummy_handler
388 )
390 @Parametrize.EXPORT_VAULT_CONFIG_DATA_HANDLER_NAMELISTS
391 def test_register_export_vault_config_data_handler_errors(
392 self,
393 namelist: tuple[str, ...],
394 err_pat: str,
395 ) -> None:
396 """Fail to register a vault config data export handler.
398 Fail because e.g. the associated name is missing, or already
399 present in the handler registry.
401 """
403 with self._setup_environment(registry={"dummy": self.dummy_handler}): # noqa: SIM117 1h
404 # TODO(the-13th-letter): Rewrite using parenthesized
405 # with-statements.
406 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
407 with pytest.raises(ValueError, match=err_pat): 1h
408 exporter.register_export_vault_config_data_handler(*namelist)( 1h
409 self.dummy_handler
410 )
412 def test_export_vault_config_data_bad_handler(self) -> None:
413 """Fail to export vault config data without known handlers."""
414 with self._setup_environment(registry={}, find_handlers=lambda: None): # noqa: SIM117 1g
415 # TODO(the-13th-letter): Rewrite using parenthesized
416 # with-statements.
417 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
418 with pytest.raises( 1g
419 ValueError,
420 match=r"Invalid vault native configuration format",
421 ):
422 exporter.export_vault_config_data(format="v0.3") 1g
425class TestGenericVaultCLIErrors:
426 """Test errors in the `derivepassphrase export vault` subpackage.
428 These errors are always possible, even if the `export` extra is not
429 available.
431 """
433 @contextlib.contextmanager
434 def _setup_environment(
435 self, *, vault_config: str | bytes = data.VAULT_V03_CONFIG
436 ) -> Generator[tuple[pytest.MonkeyPatch, machinery.CliRunner], None, None]:
437 runner = machinery.CliRunner(mix_stderr=False) 1de
438 # TODO(the-13th-letter): Rewrite using parenthesized
439 # with-statements.
440 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
441 with contextlib.ExitStack() as stack: 1de
442 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1de
443 stack.enter_context( 1de
444 pytest_machinery.isolated_vault_exporter_config(
445 monkeypatch=monkeypatch,
446 runner=runner,
447 vault_config=vault_config,
448 vault_key=data.VAULT_MASTER_KEY,
449 )
450 )
451 yield monkeypatch, runner 1de
453 def _call_cli(
454 self,
455 command_line: list[str],
456 *,
457 vault_config: str | bytes = data.VAULT_V03_CONFIG,
458 ) -> machinery.ReadableResult:
459 with self._setup_environment(vault_config=vault_config) as contexts: 1de
460 _, runner = contexts 1de
461 return runner.invoke( 1de
462 cli.derivepassphrase_export_vault,
463 command_line,
464 catch_exceptions=False,
465 )
467 # TODO(the-13th-letter): Rewrite as property-based test.
468 def test_invalid_format(self) -> None:
469 """Reject invalid vault configuration format names."""
470 result = self._call_cli(["-f", "INVALID", "VAULT_PATH"]) 1d
471 for snippet in ("Invalid value for", "-f", "--format", "INVALID"): 1d
472 assert result.error_exit(error=snippet), ( 1d
473 "expected error exit and known error message"
474 )
476 @pytest_machinery.skip_if_cryptography_support
477 @pytest_machinery.Parametrize.VAULT_CONFIG_FORMATS_DATA
478 def test_no_cryptography_error_message(
479 self,
480 caplog: pytest.LogCaptureFixture,
481 config: str | bytes,
482 format: str,
483 config_data: str,
484 ) -> None:
485 """Abort export call if no cryptography is available."""
486 del config_data 1e
487 result = self._call_cli( 1e
488 ["-f", format, "VAULT_PATH"], vault_config=config
489 )
490 assert result.error_exit( 1e
491 error=data.CANNOT_LOAD_CRYPTOGRAPHY,
492 record_tuples=caplog.record_tuples,
493 ), "expected error exit and known error message"