Coverage for tests\test_derivepassphrase_exporter.py: 100.000%
134 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-23 12:17 +0200
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-23 12:17 +0200
1# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
2#
3# SPDX-License-Identifier: Zlib
5from __future__ import annotations
7import contextlib
8import operator
9import os
10import pathlib
11import string
12import types
13from typing import TYPE_CHECKING, Any, NamedTuple
15import hypothesis
16import pytest
17from hypothesis import strategies
19import tests
20from derivepassphrase import cli, exporter
22if TYPE_CHECKING:
23 from typing_extensions import Buffer
26class Parametrize(types.SimpleNamespace):
27 EXPECTED_VAULT_PATH = pytest.mark.parametrize(
28 ['expected', 'path'],
29 [
30 (pathlib.Path('/tmp'), pathlib.Path('/tmp')),
31 (pathlib.Path('~'), pathlib.Path()),
32 (pathlib.Path('~/.vault'), None),
33 ],
34 )
35 EXPORT_VAULT_CONFIG_DATA_HANDLER_NAMELISTS = pytest.mark.parametrize(
36 ['namelist', 'err_pat'],
37 [
38 pytest.param((), '[Nn]o names given', id='empty'),
39 pytest.param(
40 ('name1', '', 'name2'),
41 '[Uu]nder an empty name',
42 id='empty-string',
43 ),
44 pytest.param(
45 ('dummy', 'name1', 'name2'),
46 '[Aa]lready registered',
47 id='existing',
48 ),
49 ],
50 )
53class Test001ExporterUtils:
54 """Test the utility functions in the `exporter` subpackage."""
56 class VaultKeyEnvironment(NamedTuple):
57 """An environment configuration for vault key determination.
59 Attributes:
60 expected:
61 The correct vault key value.
62 vault_key:
63 The value for the `VAULT_KEY` environment variable.
64 logname:
65 The value for the `LOGNAME` environment variable.
66 user:
67 The value for the `USER` environment variable.
68 username:
69 The value for the `USERNAME` environment variable.
71 """
73 expected: str | None
74 """"""
75 vault_key: str | None
76 """"""
77 logname: str | None
78 """"""
79 user: str | None
80 """"""
81 username: str | None
82 """"""
84 @strategies.composite
85 @staticmethod
86 def strategy(
87 draw: strategies.DrawFn,
88 allow_missing: bool = False,
89 ) -> Test001ExporterUtils.VaultKeyEnvironment:
90 """Return a vault key environment configuration."""
91 text_strategy = strategies.text( 1b
92 strategies.characters(min_codepoint=32, max_codepoint=127),
93 min_size=1,
94 max_size=24,
95 )
96 env_var_strategy = strategies.one_of( 1b
97 strategies.none(),
98 text_strategy,
99 )
100 num_fields = sum( 1b
101 1
102 for f in Test001ExporterUtils.VaultKeyEnvironment._fields
103 if f != 'expected'
104 )
105 env_vars: list[str | None] = draw( 1b
106 strategies.builds(
107 operator.add,
108 strategies.lists(
109 env_var_strategy,
110 min_size=num_fields - 1,
111 max_size=num_fields - 1,
112 ),
113 strategies.lists(
114 text_strategy
115 if not allow_missing
116 else env_var_strategy,
117 min_size=1,
118 max_size=1,
119 ),
120 )
121 )
122 expected: str | None = None 1b
123 for value in reversed(env_vars): 1b
124 if value is not None: 1b
125 expected = value 1b
126 return Test001ExporterUtils.VaultKeyEnvironment( 1b
127 expected, *env_vars
128 )
130 @hypothesis.example(
131 VaultKeyEnvironment('4username', None, None, None, '4username')
132 ).via('manual, pre-hypothesis parametrization value')
133 @hypothesis.example(
134 VaultKeyEnvironment('3user', None, None, '3user', None)
135 ).via('manual, pre-hypothesis parametrization value')
136 @hypothesis.example(
137 VaultKeyEnvironment('3user', None, None, '3user', '4username')
138 ).via('manual, pre-hypothesis parametrization value')
139 @hypothesis.example(
140 VaultKeyEnvironment('2logname', None, '2logname', None, None)
141 ).via('manual, pre-hypothesis parametrization value')
142 @hypothesis.example(
143 VaultKeyEnvironment('2logname', None, '2logname', None, '4username')
144 ).via('manual, pre-hypothesis parametrization value')
145 @hypothesis.example(
146 VaultKeyEnvironment('2logname', None, '2logname', '3user', None)
147 ).via('manual, pre-hypothesis parametrization value')
148 @hypothesis.example(
149 VaultKeyEnvironment('2logname', None, '2logname', '3user', '4username')
150 ).via('manual, pre-hypothesis parametrization value')
151 @hypothesis.example(
152 VaultKeyEnvironment('1vault_key', '1vault_key', None, None, None)
153 ).via('manual, pre-hypothesis parametrization value')
154 @hypothesis.example(
155 VaultKeyEnvironment(
156 '1vault_key', '1vault_key', None, None, '4username'
157 )
158 ).via('manual, pre-hypothesis parametrization value')
159 @hypothesis.example(
160 VaultKeyEnvironment('1vault_key', '1vault_key', None, '3user', None)
161 ).via('manual, pre-hypothesis parametrization value')
162 @hypothesis.example(
163 VaultKeyEnvironment(
164 '1vault_key', '1vault_key', None, '3user', '4username'
165 )
166 ).via('manual, pre-hypothesis parametrization value')
167 @hypothesis.example(
168 VaultKeyEnvironment('1vault_key', '1vault_key', '2logname', None, None)
169 ).via('manual, pre-hypothesis parametrization value')
170 @hypothesis.example(
171 VaultKeyEnvironment(
172 '1vault_key', '1vault_key', '2logname', None, '4username'
173 )
174 ).via('manual, pre-hypothesis parametrization value')
175 @hypothesis.example(
176 VaultKeyEnvironment(
177 '1vault_key', '1vault_key', '2logname', '3user', None
178 )
179 ).via('manual, pre-hypothesis parametrization value')
180 @hypothesis.example(
181 VaultKeyEnvironment(
182 '1vault_key', '1vault_key', '2logname', '3user', '4username'
183 )
184 ).via('manual, pre-hypothesis parametrization value')
185 @hypothesis.given(
186 vault_key_env=VaultKeyEnvironment.strategy().filter(
187 lambda env: bool(env.expected)
188 ),
189 )
190 def test_200_get_vault_key(
191 self,
192 vault_key_env: VaultKeyEnvironment,
193 ) -> None:
194 """Look up the vault key in `VAULT_KEY`/`LOGNAME`/`USER`/`USERNAME`.
196 The correct environment variable value is used, according to
197 their relative priorities.
199 """
200 expected, vault_key, logname, user, username = vault_key_env 1b
201 assert expected is not None 1b
202 priority_list = [ 1b
203 ('VAULT_KEY', vault_key),
204 ('LOGNAME', logname),
205 ('USER', user),
206 ('USERNAME', username),
207 ]
208 runner = tests.CliRunner(mix_stderr=False) 1b
209 # TODO(the-13th-letter): Rewrite using parenthesized
210 # with-statements.
211 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
212 with contextlib.ExitStack() as stack: 1b
213 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1b
214 stack.enter_context( 1b
215 tests.isolated_vault_exporter_config(
216 monkeypatch=monkeypatch, runner=runner
217 )
218 )
219 for key, value in priority_list: 1b
220 if value is not None: 1b
221 monkeypatch.setenv(key, value) 1b
222 assert os.fsdecode(exporter.get_vault_key()) == expected 1b
224 @Parametrize.EXPECTED_VAULT_PATH
225 def test_210_get_vault_path(
226 self,
227 expected: pathlib.Path,
228 path: str | os.PathLike[str] | None,
229 ) -> None:
230 """Determine the vault path from `VAULT_PATH`.
232 Handle relative paths, absolute paths, and missing paths.
234 """
235 runner = tests.CliRunner(mix_stderr=False) 1e
236 # TODO(the-13th-letter): Rewrite using parenthesized
237 # with-statements.
238 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
239 with contextlib.ExitStack() as stack: 1e
240 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1e
241 stack.enter_context( 1e
242 tests.isolated_vault_exporter_config(
243 monkeypatch=monkeypatch, runner=runner
244 )
245 )
246 if path: 1e
247 monkeypatch.setenv( 1e
248 'VAULT_PATH', os.fspath(path) if path is not None else None
249 )
250 assert ( 1e
251 exporter.get_vault_path().resolve()
252 == expected.expanduser().resolve()
253 )
255 @hypothesis.given(
256 name_data=strategies.lists(
257 strategies.integers(min_value=1, max_value=3),
258 min_size=2,
259 max_size=2,
260 ).flatmap(
261 lambda nm: strategies.lists(
262 strategies.builds(
263 operator.add,
264 strategies.sampled_from(string.ascii_letters),
265 strategies.text(
266 string.ascii_letters + string.digits + '_-',
267 max_size=23,
268 ),
269 ),
270 min_size=sum(nm),
271 max_size=sum(nm),
272 unique=True,
273 ).flatmap(
274 lambda list_: strategies.just((list_[nm[0] :], list_[: nm[0]]))
275 )
276 ),
277 )
278 def test_220_register_export_vault_config_data_handler(
279 self, name_data: tuple[list[str], list[str]]
280 ) -> None:
281 """Register vault config data export handlers."""
283 def handler( # pragma: no cover 1c
284 path: str | bytes | os.PathLike | None = None, 1c
285 key: str | Buffer | None = None, 1c
286 *,
287 format: str, 1c
288 ) -> Any: 1c
289 del path, key
290 raise ValueError(format)
292 names1, names2 = name_data 1c
294 with pytest.MonkeyPatch.context() as monkeypatch: 1c
295 registry = dict.fromkeys(names1, handler) 1c
296 monkeypatch.setattr( 1c
297 exporter, '_export_vault_config_data_registry', registry
298 )
299 dec = exporter.register_export_vault_config_data_handler(*names2) 1c
300 assert dec(handler) == handler 1c
301 assert registry == dict.fromkeys(names1 + names2, handler) 1c
303 def test_300_get_vault_key_without_envs(self) -> None:
304 """Fail to look up the vault key in the empty environment."""
305 with pytest.MonkeyPatch.context() as monkeypatch: 1f
306 monkeypatch.delenv('VAULT_KEY', raising=False) 1f
307 monkeypatch.delenv('LOGNAME', raising=False) 1f
308 monkeypatch.delenv('USER', raising=False) 1f
309 monkeypatch.delenv('USERNAME', raising=False) 1f
310 with pytest.raises(KeyError, match='VAULT_KEY'): 1f
311 exporter.get_vault_key() 1f
313 def test_310_get_vault_path_without_home(self) -> None:
314 """Fail to look up the vault path without `HOME`."""
316 def raiser(*_args: Any, **_kwargs: Any) -> Any: 1g
317 raise RuntimeError('Cannot determine home directory.') # noqa: EM101,TRY003 1g
319 with pytest.MonkeyPatch.context() as monkeypatch: 1g
320 monkeypatch.setattr(pathlib.Path, 'expanduser', raiser) 1g
321 monkeypatch.setattr(os.path, 'expanduser', raiser) 1g
322 with pytest.raises( 1g
323 RuntimeError, match=r'[Cc]annot determine home directory'
324 ):
325 exporter.get_vault_path() 1g
327 @Parametrize.EXPORT_VAULT_CONFIG_DATA_HANDLER_NAMELISTS
328 def test_320_register_export_vault_config_data_handler_errors(
329 self,
330 namelist: tuple[str, ...],
331 err_pat: str,
332 ) -> None:
333 """Fail to register a vault config data export handler.
335 Fail because e.g. the associated name is missing, or already
336 present in the handler registry.
338 """
340 def handler( # pragma: no cover 1d
341 path: str | bytes | os.PathLike | None = None, 1d
342 key: str | Buffer | None = None, 1d
343 *,
344 format: str, 1d
345 ) -> Any: 1d
346 del path, key
347 raise ValueError(format)
349 with pytest.MonkeyPatch.context() as monkeypatch: 1d
350 registry = {'dummy': handler} 1d
351 monkeypatch.setattr( 1d
352 exporter, '_export_vault_config_data_registry', registry
353 )
354 with pytest.raises(ValueError, match=err_pat): 1d
355 exporter.register_export_vault_config_data_handler(*namelist)( 1d
356 handler
357 )
359 def test_321_export_vault_config_data_bad_handler(self) -> None:
360 """Fail to export vault config data without known handlers."""
361 with pytest.MonkeyPatch.context() as monkeypatch: 1j
362 monkeypatch.setattr( 1j
363 exporter, '_export_vault_config_data_registry', {}
364 )
365 monkeypatch.setattr( 1j
366 exporter, 'find_vault_config_data_handlers', lambda: None
367 )
368 with pytest.raises( 1j
369 ValueError,
370 match=r'Invalid vault native configuration format',
371 ):
372 exporter.export_vault_config_data(format='v0.3') 1j
375class Test002CLI:
376 """Test the command-line functionality of the `exporter` subpackage."""
378 def test_300_invalid_format(self) -> None:
379 """Reject invalid vault configuration format names."""
380 runner = tests.CliRunner(mix_stderr=False) 1h
381 # TODO(the-13th-letter): Rewrite using parenthesized
382 # with-statements.
383 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
384 with contextlib.ExitStack() as stack: 1h
385 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1h
386 stack.enter_context( 1h
387 tests.isolated_vault_exporter_config(
388 monkeypatch=monkeypatch,
389 runner=runner,
390 vault_config=tests.VAULT_V03_CONFIG,
391 vault_key=tests.VAULT_MASTER_KEY,
392 )
393 )
394 result = runner.invoke( 1h
395 cli.derivepassphrase_export_vault,
396 ['-f', 'INVALID', 'VAULT_PATH'],
397 catch_exceptions=False,
398 )
399 for snippet in ('Invalid value for', '-f', '--format', 'INVALID'): 1h
400 assert result.error_exit(error=snippet), ( 1h
401 'expected error exit and known error message'
402 )
404 @tests.skip_if_cryptography_support
405 @tests.Parametrize.VAULT_CONFIG_FORMATS_DATA
406 def test_999_no_cryptography_error_message(
407 self,
408 caplog: pytest.LogCaptureFixture,
409 config: str | bytes,
410 format: str,
411 config_data: str,
412 ) -> None:
413 """Abort export call if no cryptography is available."""
414 del config_data 1i
415 runner = tests.CliRunner(mix_stderr=False) 1i
416 # TODO(the-13th-letter): Rewrite using parenthesized
417 # with-statements.
418 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
419 with contextlib.ExitStack() as stack: 1i
420 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1i
421 stack.enter_context( 1i
422 tests.isolated_vault_exporter_config(
423 monkeypatch=monkeypatch,
424 runner=runner,
425 vault_config=config,
426 vault_key=tests.VAULT_MASTER_KEY,
427 )
428 )
429 result = runner.invoke( 1i
430 cli.derivepassphrase_export_vault,
431 ['-f', format, 'VAULT_PATH'],
432 catch_exceptions=False,
433 )
434 assert result.error_exit( 1i
435 error=tests.CANNOT_LOAD_CRYPTOGRAPHY,
436 record_tuples=caplog.record_tuples,
437 ), 'expected error exit and known error message'