Coverage for tests\test_derivepassphrase_cli.py: 98.941%
1851 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 base64
8import contextlib
9import copy
10import ctypes
11import enum
12import errno
13import io
14import json
15import logging
16import operator
17import os
18import pathlib
19import queue
20import re
21import shlex
22import shutil
23import socket
24import tempfile
25import textwrap
26import types
27import warnings
28from typing import TYPE_CHECKING, cast
30import click.testing
31import hypothesis
32import pytest
33from hypothesis import stateful, strategies
34from typing_extensions import Any, NamedTuple, TypeAlias
36import tests
37from derivepassphrase import _types, cli, ssh_agent, vault
38from derivepassphrase._internals import (
39 cli_helpers,
40 cli_machinery,
41 cli_messages,
42)
43from derivepassphrase.ssh_agent import socketprovider
45if TYPE_CHECKING:
46 import multiprocessing
47 from collections.abc import Callable, Iterable, Iterator, Sequence
48 from collections.abc import Set as AbstractSet
49 from typing import NoReturn
51 from typing_extensions import Literal
53DUMMY_SERVICE = tests.DUMMY_SERVICE
54DUMMY_PASSPHRASE = tests.DUMMY_PASSPHRASE
55DUMMY_CONFIG_SETTINGS = tests.DUMMY_CONFIG_SETTINGS
56DUMMY_RESULT_PASSPHRASE = tests.DUMMY_RESULT_PASSPHRASE
57DUMMY_RESULT_KEY1 = tests.DUMMY_RESULT_KEY1
58DUMMY_PHRASE_FROM_KEY1_RAW = tests.DUMMY_PHRASE_FROM_KEY1_RAW
59DUMMY_PHRASE_FROM_KEY1 = tests.DUMMY_PHRASE_FROM_KEY1
61DUMMY_KEY1 = tests.DUMMY_KEY1
62DUMMY_KEY1_B64 = tests.DUMMY_KEY1_B64
63DUMMY_KEY2 = tests.DUMMY_KEY2
64DUMMY_KEY2_B64 = tests.DUMMY_KEY2_B64
65DUMMY_KEY3 = tests.DUMMY_KEY3
66DUMMY_KEY3_B64 = tests.DUMMY_KEY3_B64
68TEST_CONFIGS = tests.TEST_CONFIGS
71class IncompatibleConfiguration(NamedTuple):
72 other_options: list[tuple[str, ...]]
73 needs_service: bool | None
74 input: str | None
77class SingleConfiguration(NamedTuple):
78 needs_service: bool | None
79 input: str | None
80 check_success: bool
83class OptionCombination(NamedTuple):
84 options: list[str]
85 incompatible: bool
86 needs_service: bool | None
87 input: str | None
88 check_success: bool
91class VersionOutputData(NamedTuple):
92 derivation_schemes: dict[str, bool]
93 foreign_configuration_formats: dict[str, bool]
94 extras: frozenset[str]
95 subcommands: frozenset[str]
96 features: dict[str, bool]
99class KnownLineType(str, enum.Enum):
100 SUPPORTED_FOREIGN_CONFS = cli_messages.Label.SUPPORTED_FOREIGN_CONFIGURATION_FORMATS.value.singular.rstrip(
101 ':'
102 )
103 UNAVAILABLE_FOREIGN_CONFS = cli_messages.Label.UNAVAILABLE_FOREIGN_CONFIGURATION_FORMATS.value.singular.rstrip(
104 ':'
105 )
106 SUPPORTED_SCHEMES = (
107 cli_messages.Label.SUPPORTED_DERIVATION_SCHEMES.value.singular.rstrip(
108 ':'
109 )
110 )
111 UNAVAILABLE_SCHEMES = cli_messages.Label.UNAVAILABLE_DERIVATION_SCHEMES.value.singular.rstrip(
112 ':'
113 )
114 SUPPORTED_SUBCOMMANDS = (
115 cli_messages.Label.SUPPORTED_SUBCOMMANDS.value.singular.rstrip(':')
116 )
117 SUPPORTED_FEATURES = (
118 cli_messages.Label.SUPPORTED_FEATURES.value.singular.rstrip(':')
119 )
120 UNAVAILABLE_FEATURES = (
121 cli_messages.Label.UNAVAILABLE_FEATURES.value.singular.rstrip(':')
122 )
123 ENABLED_EXTRAS = (
124 cli_messages.Label.ENABLED_PEP508_EXTRAS.value.singular.rstrip(':')
125 )
128PASSPHRASE_GENERATION_OPTIONS: list[tuple[str, ...]] = [
129 ('--phrase',),
130 ('--key',),
131 ('--length', '20'),
132 ('--repeat', '20'),
133 ('--lower', '1'),
134 ('--upper', '1'),
135 ('--number', '1'),
136 ('--space', '1'),
137 ('--dash', '1'),
138 ('--symbol', '1'),
139]
140CONFIGURATION_OPTIONS: list[tuple[str, ...]] = [
141 ('--notes',),
142 ('--config',),
143 ('--delete',),
144 ('--delete-globals',),
145 ('--clear',),
146]
147CONFIGURATION_COMMANDS: list[tuple[str, ...]] = [
148 ('--delete',),
149 ('--delete-globals',),
150 ('--clear',),
151]
152STORAGE_OPTIONS: list[tuple[str, ...]] = [('--export', '-'), ('--import', '-')]
153INCOMPATIBLE: dict[tuple[str, ...], IncompatibleConfiguration] = {
154 ('--phrase',): IncompatibleConfiguration(
155 [('--key',), *CONFIGURATION_COMMANDS, *STORAGE_OPTIONS],
156 True,
157 DUMMY_PASSPHRASE,
158 ),
159 ('--key',): IncompatibleConfiguration(
160 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
161 ),
162 ('--length', '20'): IncompatibleConfiguration(
163 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
164 ),
165 ('--repeat', '20'): IncompatibleConfiguration(
166 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
167 ),
168 ('--lower', '1'): IncompatibleConfiguration(
169 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
170 ),
171 ('--upper', '1'): IncompatibleConfiguration(
172 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
173 ),
174 ('--number', '1'): IncompatibleConfiguration(
175 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
176 ),
177 ('--space', '1'): IncompatibleConfiguration(
178 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
179 ),
180 ('--dash', '1'): IncompatibleConfiguration(
181 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
182 ),
183 ('--symbol', '1'): IncompatibleConfiguration(
184 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
185 ),
186 ('--notes',): IncompatibleConfiguration(
187 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, None
188 ),
189 ('--config', '-p'): IncompatibleConfiguration(
190 [('--delete',), ('--delete-globals',), ('--clear',), *STORAGE_OPTIONS],
191 None,
192 DUMMY_PASSPHRASE,
193 ),
194 ('--delete',): IncompatibleConfiguration(
195 [('--delete-globals',), ('--clear',), *STORAGE_OPTIONS], True, None
196 ),
197 ('--delete-globals',): IncompatibleConfiguration(
198 [('--clear',), *STORAGE_OPTIONS], False, None
199 ),
200 ('--clear',): IncompatibleConfiguration(STORAGE_OPTIONS, False, None),
201 ('--export', '-'): IncompatibleConfiguration(
202 [('--import', '-')], False, None
203 ),
204 ('--import', '-'): IncompatibleConfiguration([], False, None),
205}
206SINGLES: dict[tuple[str, ...], SingleConfiguration] = {
207 ('--phrase',): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
208 ('--key',): SingleConfiguration(True, None, False),
209 ('--length', '20'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
210 ('--repeat', '20'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
211 ('--lower', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
212 ('--upper', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
213 ('--number', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
214 ('--space', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
215 ('--dash', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
216 ('--symbol', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
217 ('--notes',): SingleConfiguration(True, None, False),
218 ('--config', '-p'): SingleConfiguration(None, DUMMY_PASSPHRASE, False),
219 ('--delete',): SingleConfiguration(True, None, False),
220 ('--delete-globals',): SingleConfiguration(False, None, True),
221 ('--clear',): SingleConfiguration(False, None, True),
222 ('--export', '-'): SingleConfiguration(False, None, True),
223 ('--import', '-'): SingleConfiguration(False, '{"services": {}}', True),
224}
225INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = []
226config: IncompatibleConfiguration | SingleConfiguration
227for opt, config in INCOMPATIBLE.items():
228 for opt2 in config.other_options:
229 INTERESTING_OPTION_COMBINATIONS.extend([
230 OptionCombination(
231 options=list(opt + opt2),
232 incompatible=True,
233 needs_service=config.needs_service,
234 input=config.input,
235 check_success=False,
236 ),
237 OptionCombination(
238 options=list(opt2 + opt),
239 incompatible=True,
240 needs_service=config.needs_service,
241 input=config.input,
242 check_success=False,
243 ),
244 ])
245for opt, config in SINGLES.items():
246 INTERESTING_OPTION_COMBINATIONS.append(
247 OptionCombination(
248 options=list(opt),
249 incompatible=False,
250 needs_service=config.needs_service,
251 input=config.input,
252 check_success=config.check_success,
253 )
254 )
257def is_warning_line(line: str) -> bool:
258 """Return true if the line is a warning line."""
259 return ' Warning: ' in line or ' Deprecation warning: ' in line 1vrso
262def is_harmless_config_import_warning(record: tuple[str, int, str]) -> bool:
263 """Return true if the warning is harmless, during config import."""
264 possible_warnings = [ 1vtCw
265 'Replacing invalid value ',
266 'Removing ineffective setting ',
267 (
268 'Setting a global passphrase is ineffective '
269 'because a key is also set.'
270 ),
271 (
272 'Setting a service passphrase is ineffective '
273 'because a key is also set:'
274 ),
275 ]
276 return any(tests.warning_emitted(w, [record]) for w in possible_warnings) 1vtCw
279def assert_vault_config_is_indented_and_line_broken(
280 config_txt: str,
281 /,
282) -> None:
283 """Return true if the vault configuration is indented and line broken.
285 Indented and rewrapped vault configurations as produced by
286 `json.dump` contain the closing '}' of the '$.services' object
287 on a separate, indented line:
289 ~~~~
290 {
291 "services": {
292 ...
293 } <-- this brace here
294 }
295 ~~~~
297 or, if there are no services, then the indented line
299 ~~~~
300 "services": {}
301 ~~~~
303 Both variations may end with a comma if there are more top-level
304 keys.
306 """
307 known_indented_lines = { 1CDFw
308 '}',
309 '},',
310 '"services": {}',
311 '"services": {},',
312 }
313 assert any([ 1CDFw
314 line.strip() in known_indented_lines and line.startswith((' ', '\t'))
315 for line in config_txt.splitlines()
316 ])
319def vault_config_exporter_shell_interpreter( # noqa: C901
320 script: str | Iterable[str],
321 /,
322 *,
323 prog_name_list: list[str] | None = None,
324 command: click.BaseCommand | None = None,
325 runner: tests.CliRunner | None = None,
326) -> Iterator[tests.ReadableResult]:
327 """A rudimentary sh(1) interpreter for `--export-as=sh` output.
329 Assumes a script as emitted by `derivepassphrase vault
330 --export-as=sh --export -` and interprets the calls to
331 `derivepassphrase vault` within. (One call per line, skips all
332 other lines.) Also has rudimentary support for (quoted)
333 here-documents using `HERE` as the marker.
335 """
336 if isinstance(script, str): # pragma: no cover 1jikl
337 script = script.splitlines(False) 1jikl
338 if prog_name_list is None: # pragma: no cover 1jikl
339 prog_name_list = ['derivepassphrase', 'vault'] 1jikl
340 if command is None: # pragma: no cover 1jikl
341 command = cli.derivepassphrase_vault 1jikl
342 if runner is None: # pragma: no cover 1jikl
343 runner = tests.CliRunner(mix_stderr=False) 1jikl
344 n = len(prog_name_list) 1jikl
345 it = iter(script) 1jikl
346 while True: 1jikl
347 try: 1jikl
348 raw_line = next(it) 1jikl
349 except StopIteration: 1jikl
350 break 1jikl
351 else:
352 line = shlex.split(raw_line) 1jikl
353 input_buffer: list[str] = [] 1jikl
354 if line[:n] != prog_name_list: 1jikl
355 continue 1jikl
356 line[:n] = [] 1jikl
357 if line and line[-1] == '<<HERE': 1jikl
358 # naive HERE document support
359 while True: 1jikl
360 try: 1jikl
361 raw_line = next(it) 1jikl
362 except StopIteration as exc: # pragma: no cover
363 msg = 'incomplete here document'
364 raise EOFError(msg) from exc
365 else:
366 if raw_line == 'HERE': 1jikl
367 break 1jikl
368 input_buffer.append(raw_line) 1jikl
369 line.pop() 1jikl
370 yield runner.invoke( 1jikl
371 command,
372 line,
373 catch_exceptions=False,
374 input=(''.join(x + '\n' for x in input_buffer) or None),
375 )
378def parse_version_output( # noqa: C901
379 version_output: str,
380 /,
381 *,
382 prog_name: str | None = cli_messages.PROG_NAME,
383 version: str | None = cli_messages.VERSION,
384) -> VersionOutputData:
385 r"""Parse the output of the `--version` option.
387 The version output contains two paragraphs. The first paragraph
388 details the version number, and the version number of any major
389 libraries in use. The second paragraph details known and supported
390 passphrase derivation schemes, foreign configuration formats,
391 subcommands and PEP 508 package extras. For the schemes and
392 formats, there is a "supported" line for supported items, and
393 a "known" line for known but currently unsupported items (usually
394 because of missing dependencies), either of which may be empty and
395 thus omitted. For extras, only active items are shown, and there is
396 a separate message for the "no extras active" case. Item lists may
397 be spilled across multiple lines, but only at item boundaries, and
398 the continuation lines are then indented.
400 Args:
401 version_output:
402 The version output text to parse.
403 prog_name:
404 The program name to assert, defaulting to the true program
405 name, `derivepassphrase`. Set to `None` to disable this
406 check.
407 version:
408 The program version to assert, defaulting to the true
409 current version of `derivepassphrase`. Set to `None` to
410 disable this check.
412 Examples:
413 See [`Parametrize.VERSION_OUTPUT_DATA`][].
415 """
416 paragraphs: list[list[str]] = [] 1hgedf
417 paragraph: list[str] = [] 1hgedf
418 for line in version_output.splitlines(keepends=False): 1hgedf
419 if not line.strip(): 1hgedf
420 if paragraph: 1hgedf
421 paragraphs.append(paragraph.copy()) 1hgedf
422 paragraph.clear() 1hgedf
423 elif paragraph and line.lstrip() != line: 1hgedf
424 paragraph[-1] = f'{paragraph[-1]} {line.lstrip()}' 1hed
425 else:
426 paragraph.append(line) 1hgedf
427 if paragraph: # pragma: no branch 1hgedf
428 paragraphs.append(paragraph.copy()) 1hgedf
429 paragraph.clear() 1hgedf
430 assert paragraphs, ( 1hgedf
431 f'expected at least one paragraph of version output: {paragraphs!r}'
432 )
433 assert prog_name is None or prog_name in paragraphs[0][0], ( 1hgedf
434 f'first version output line should mention '
435 f'{prog_name}: {paragraphs[0][0]!r}'
436 )
437 assert version is None or version in paragraphs[0][0], ( 1hgedf
438 f'first version output line should mention the version number '
439 f'{version}: {paragraphs[0][0]!r}'
440 )
441 schemes: dict[str, bool] = {} 1hgedf
442 formats: dict[str, bool] = {} 1hgedf
443 subcommands: set[str] = set() 1hgedf
444 extras: set[str] = set() 1hgedf
445 features: dict[str, bool] = {} 1hgedf
446 if len(paragraphs) < 2: # pragma: no cover 1hgedf
447 return VersionOutputData(
448 derivation_schemes=schemes,
449 foreign_configuration_formats=formats,
450 subcommands=frozenset(subcommands),
451 extras=frozenset(extras),
452 features=features,
453 )
454 for line in paragraphs[1]: 1hgedf
455 line_type, _, value = line.partition(':') 1hgedf
456 if line_type == line: 1hgedf
457 continue 1h
458 for item_ in re.split(r'(?:, *|.$)', value): 1hgedf
459 item = item_.strip() 1hgedf
460 if not item: 1hgedf
461 continue 1hgedf
462 if line_type == KnownLineType.SUPPORTED_FOREIGN_CONFS: 1hgedf
463 formats[item] = True 1hd
464 elif line_type == KnownLineType.UNAVAILABLE_FOREIGN_CONFS: 1hgedf
465 formats[item] = False 1hed
466 elif line_type == KnownLineType.SUPPORTED_SCHEMES: 1hgedf
467 schemes[item] = True 1hg
468 elif line_type == KnownLineType.UNAVAILABLE_SCHEMES: 1hgedf
469 schemes[item] = False 1h
470 elif line_type == KnownLineType.SUPPORTED_SUBCOMMANDS: 1hgedf
471 subcommands.add(item) 1hge
472 elif line_type == KnownLineType.ENABLED_EXTRAS: 1hdf
473 extras.add(item) 1hd
474 elif line_type == KnownLineType.SUPPORTED_FEATURES: 1hf
475 features[item] = True 1h
476 elif line_type == KnownLineType.UNAVAILABLE_FEATURES: 1hf
477 features[item] = False 1hf
478 else:
479 raise AssertionError( # noqa: TRY003
480 f'Unknown version info line type: {line_type!r}' # noqa: EM102
481 )
482 return VersionOutputData( 1hgedf
483 derivation_schemes=schemes,
484 foreign_configuration_formats=formats,
485 subcommands=frozenset(subcommands),
486 extras=frozenset(extras),
487 features=features,
488 )
491def bash_format(item: click.shell_completion.CompletionItem) -> str:
492 """A formatter for `bash`-style shell completion items.
494 The format is `type,value`, and is dictated by [`click`][].
496 """
497 type, value = ( # noqa: A001 1m
498 item.type,
499 item.value,
500 )
501 return f'{type},{value}' 1m
504def fish_format(item: click.shell_completion.CompletionItem) -> str:
505 r"""A formatter for `fish`-style shell completion items.
507 The format is `type,value<tab>help`, and is dictated by [`click`][].
509 """
510 type, value, help = ( # noqa: A001 1m
511 item.type,
512 item.value,
513 item.help,
514 )
515 return f'{type},{value}\t{help}' if help else f'{type},{value}' 1m
518def zsh_format(item: click.shell_completion.CompletionItem) -> str:
519 r"""A formatter for `zsh`-style shell completion items.
521 The format is `type<newline>value<newline>help<newline>`, and is
522 dictated by [`click`][]. Upstream `click` currently (v8.2.0) does
523 not deal with colons in the value correctly when the help text is
524 non-degenerate. Our formatter here does, provided the upstream
525 `zsh` completion script is used; see the
526 [`cli_machinery.ZshComplete`][] class. A request is underway to
527 merge this change into upstream `click`; see
528 [`pallets/click#2846`][PR2846].
530 [PR2846]: https://github.com/pallets/click/pull/2846
532 """
533 empty_help = '_' 1m
534 help_, value = ( 1m
535 (item.help, item.value.replace(':', r'\:'))
536 if item.help and item.help == empty_help
537 else (empty_help, item.value)
538 )
539 return f'{item.type}\n{value}\n{help_}' 1m
542class ListKeysAction(str, enum.Enum):
543 """Test fixture settings for [`ssh_agent.SSHAgentClient.list_keys`][].
545 Attributes:
546 EMPTY: Return an empty key list.
547 FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][].
548 FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][].
550 """
552 EMPTY = enum.auto()
553 """"""
554 FAIL = enum.auto()
555 """"""
556 FAIL_RUNTIME = enum.auto()
557 """"""
559 def __call__(self, *_args: Any, **_kwargs: Any) -> Any:
560 """Execute the respective action."""
561 # TODO(the-13th-letter): Rewrite using structural pattern
562 # matching.
563 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
564 if self == self.EMPTY: 1c
565 return [] 1c
566 if self == self.FAIL: 1c
567 raise ssh_agent.SSHAgentFailedError( 1c
568 _types.SSH_AGENT.FAILURE.value, b''
569 )
570 if self == self.FAIL_RUNTIME: 1c
571 raise ssh_agent.TrailingDataError() 1c
572 raise AssertionError()
575class SignAction(str, enum.Enum):
576 """Test fixture settings for [`ssh_agent.SSHAgentClient.sign`][].
578 Attributes:
579 FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][].
580 FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][].
582 """
584 FAIL = enum.auto()
585 """"""
586 FAIL_RUNTIME = enum.auto()
587 """"""
589 def __call__(self, *_args: Any, **_kwargs: Any) -> Any:
590 """Execute the respective action."""
591 # TODO(the-13th-letter): Rewrite using structural pattern
592 # matching.
593 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
594 if self == self.FAIL: 1c
595 raise ssh_agent.SSHAgentFailedError( 1c
596 _types.SSH_AGENT.FAILURE.value, b''
597 )
598 if self == self.FAIL_RUNTIME: 1c
599 raise ssh_agent.TrailingDataError() 1c
600 raise AssertionError()
603class SocketAddressAction(str, enum.Enum):
604 """Test fixture settings for the SSH agent socket address.
606 Attributes:
607 MANGLE_ANNOYING_OS_NAMED_PIPE:
608 Mangle the address for the Annoying OS named pipe endpoint.
609 MANGLE_SSH_AUTH_SOCK:
610 Mangle the address for the UNIX domain socket (the
611 `SSH_AUTH_SOCK` environment variable).
612 UNSET_ANNOYING_OS_NAMED_PIPE:
613 Unset the address for the Annoying OS named pipe endpoint.
614 UNSET_SSH_AUTH_SOCK:
615 Unset the `SSH_AUTH_SOCK` environment variable (the address
616 for the UNIX domain socket).
618 """
620 MANGLE_ANNOYING_OS_NAMED_PIPE = enum.auto()
621 """"""
622 MANGLE_SSH_AUTH_SOCK = enum.auto()
623 """"""
624 UNSET_ANNOYING_OS_NAMED_PIPE = enum.auto()
625 """"""
626 UNSET_SSH_AUTH_SOCK = enum.auto()
627 """"""
629 def __call__(
630 self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any
631 ) -> None:
632 """Execute the respective action."""
633 # TODO(the-13th-letter): Rewrite using structural pattern
634 # matching.
635 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
636 if self in { 1c
637 self.MANGLE_ANNOYING_OS_NAMED_PIPE,
638 self.UNSET_ANNOYING_OS_NAMED_PIPE,
639 }: # pragma: no cover
640 # Not implemented yet.
641 pass
642 elif self == self.MANGLE_SSH_AUTH_SOCK: 1c
643 monkeypatch.setenv( 1c
644 'SSH_AUTH_SOCK', os.environ['SSH_AUTH_SOCK'] + '~'
645 )
646 elif self == self.UNSET_SSH_AUTH_SOCK: 1c
647 monkeypatch.delenv('SSH_AUTH_SOCK', raising=False) 1c
648 else:
649 raise AssertionError()
652class SystemSupportAction(str, enum.Enum):
653 """Test fixture settings for [`ssh_agent.SSHAgentClient`][] system support.
655 Attributes:
656 UNSET_AF_UNIX:
657 Ensure lack of support for UNIX domain sockets.
658 UNSET_AF_UNIX_AND_ENSURE_USE:
659 Ensure lack of support for UNIX domain sockets, and that the
660 agent will use this socket provider.
661 UNSET_NATIVE:
662 Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`.
663 UNSET_NATIVE_AND_ENSURE_USE:
664 Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`, and that the
665 agent will use the native socket provider.
666 UNSET_PROVIDER_LIST:
667 Ensure an empty list of SSH agent socket providers.
668 UNSET_WINDLL:
669 Ensure lack of support for The Annoying OS named pipes.
670 UNSET_WINDLL_AND_ENSURE_USE:
671 Ensure lack of support for The Annoying OS named pipes, and
672 that the agent will use this socket provider.
674 """
676 UNSET_AF_UNIX = enum.auto()
677 """"""
678 UNSET_AF_UNIX_AND_ENSURE_USE = enum.auto()
679 """"""
680 UNSET_NATIVE = enum.auto()
681 """"""
682 UNSET_NATIVE_AND_ENSURE_USE = enum.auto()
683 """"""
684 UNSET_PROVIDER_LIST = enum.auto()
685 """"""
686 UNSET_WINDLL = enum.auto()
687 """"""
688 UNSET_WINDLL_AND_ENSURE_USE = enum.auto()
689 """"""
691 def __call__(
692 self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any
693 ) -> None:
694 """Execute the respective action.
696 Args:
697 monkeypatch: The current monkeypatch context.
699 """
700 # TODO(the-13th-letter): Rewrite using structural pattern
701 # matching.
702 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
703 if self == self.UNSET_PROVIDER_LIST: 1c
704 monkeypatch.setattr( 1c
705 ssh_agent.SSHAgentClient, 'SOCKET_PROVIDERS', []
706 )
707 elif self in {self.UNSET_NATIVE, self.UNSET_NATIVE_AND_ENSURE_USE}: 1c
708 self.check_or_ensure_use( 1c
709 'native',
710 monkeypatch=monkeypatch,
711 ensure_use=(self == self.UNSET_NATIVE_AND_ENSURE_USE),
712 )
713 monkeypatch.delattr(socket, 'AF_UNIX', raising=False)
714 monkeypatch.delattr(ctypes, 'WinDLL', raising=False)
715 monkeypatch.delattr(ctypes, 'windll', raising=False)
716 elif self in {self.UNSET_AF_UNIX, self.UNSET_AF_UNIX_AND_ENSURE_USE}: 1c
717 self.check_or_ensure_use( 1c
718 'posix',
719 monkeypatch=monkeypatch,
720 ensure_use=(self == self.UNSET_AF_UNIX_AND_ENSURE_USE),
721 )
722 monkeypatch.delattr(socket, 'AF_UNIX', raising=False) 1c
723 elif self in {self.UNSET_WINDLL, self.UNSET_WINDLL_AND_ENSURE_USE}: 1c
724 self.check_or_ensure_use( 1c
725 'the_annoying_os',
726 monkeypatch=monkeypatch,
727 ensure_use=(self == self.UNSET_WINDLL_AND_ENSURE_USE),
728 )
729 monkeypatch.delattr(ctypes, 'WinDLL', raising=False) 1c
730 monkeypatch.delattr(ctypes, 'windll', raising=False) 1c
731 else:
732 raise AssertionError()
734 @staticmethod
735 def check_or_ensure_use(
736 provider: str, /, *, monkeypatch: pytest.MonkeyPatch, ensure_use: bool
737 ) -> None:
738 """Check that the named SSH agent socket provider will be used.
740 Either ensure that the socket provider will definitely be used,
741 or, upon detecting that it won't be used, skip the test.
743 Args:
744 provider:
745 The provider to check for.
746 ensure_use:
747 If true, ensure that the socket provider will definitely
748 be used. If false, then check for whether it will be
749 used, and skip this test if not.
750 monkeypatch:
751 The monkeypatch context within which the fixture
752 adjustments should be executed.
754 """
755 if ensure_use: 1c
756 monkeypatch.setattr( 1c
757 ssh_agent.SSHAgentClient, 'SOCKET_PROVIDERS', [provider]
758 )
759 else: # pragma: no cover
760 # This branch operates completely on instrumented or on
761 # externally defined, non-deterministic state. So forego
762 # any coverage measurement.
763 intended: (
764 _types.SSHAgentSocketProvider
765 | socketprovider.NoSuchProviderError
766 | None
767 )
768 try: 1c
769 intended = socketprovider.SocketProvider.resolve(provider) 1c
770 except socketprovider.NoSuchProviderError as exc:
771 intended = exc
772 except NotImplementedError:
773 intended = None
774 actual: (
775 _types.SSHAgentSocketProvider
776 | socketprovider.NoSuchProviderError
777 | None
778 )
779 for name in ssh_agent.SSHAgentClient.SOCKET_PROVIDERS: 1c
780 try: 1c
781 actual = socketprovider.SocketProvider.resolve(name) 1c
782 except socketprovider.NoSuchProviderError as exc:
783 actual = exc
784 except NotImplementedError:
785 continue
786 break 1c
787 else:
788 actual = None
789 if intended != actual: 1c
790 pytest.skip( 1c
791 f'{provider!r} SSH agent socket provider '
792 f'is not currently in use'
793 )
796class Parametrize(types.SimpleNamespace):
797 """Common test parametrizations."""
799 EAGER_ARGUMENTS = pytest.mark.parametrize(
800 'arguments',
801 [['--help'], ['--version']],
802 ids=['help', 'version'],
803 )
804 CHARSET_NAME = pytest.mark.parametrize(
805 'charset_name', ['lower', 'upper', 'number', 'space', 'dash', 'symbol']
806 )
807 COMMAND_NON_EAGER_ARGUMENTS = pytest.mark.parametrize(
808 ['command', 'non_eager_arguments'],
809 [
810 pytest.param(
811 [],
812 [],
813 id='top-nothing',
814 ),
815 pytest.param(
816 [],
817 ['export'],
818 id='top-export',
819 ),
820 pytest.param(
821 ['export'],
822 [],
823 id='export-nothing',
824 ),
825 pytest.param(
826 ['export'],
827 ['vault'],
828 id='export-vault',
829 ),
830 pytest.param(
831 ['export', 'vault'],
832 [],
833 id='export-vault-nothing',
834 ),
835 pytest.param(
836 ['export', 'vault'],
837 ['--format', 'this-format-doesnt-exist'],
838 id='export-vault-args',
839 ),
840 pytest.param(
841 ['vault'],
842 [],
843 id='vault-nothing',
844 ),
845 pytest.param(
846 ['vault'],
847 ['--export', './'],
848 id='vault-args',
849 ),
850 ],
851 )
852 UNICODE_NORMALIZATION_COMMAND_LINES = pytest.mark.parametrize(
853 'command_line',
854 [
855 pytest.param(
856 ['--config', '--phrase'],
857 id='configure global passphrase',
858 ),
859 pytest.param(
860 ['--config', '--phrase', '--', 'DUMMY_SERVICE'],
861 id='configure service passphrase',
862 ),
863 pytest.param(
864 ['--phrase', '--', DUMMY_SERVICE],
865 id='interactive passphrase',
866 ),
867 ],
868 )
869 DELETE_CONFIG_INPUT = pytest.mark.parametrize(
870 ['command_line', 'config', 'result_config'],
871 [
872 pytest.param(
873 ['--delete-globals'],
874 {'global': {'phrase': 'abc'}, 'services': {}},
875 {'services': {}},
876 id='globals',
877 ),
878 pytest.param(
879 ['--delete', '--', DUMMY_SERVICE],
880 {
881 'global': {'phrase': 'abc'},
882 'services': {DUMMY_SERVICE: {'notes': '...'}},
883 },
884 {'global': {'phrase': 'abc'}, 'services': {}},
885 id='service',
886 ),
887 pytest.param(
888 ['--clear'],
889 {
890 'global': {'phrase': 'abc'},
891 'services': {DUMMY_SERVICE: {'notes': '...'}},
892 },
893 {'services': {}},
894 id='all',
895 ),
896 ],
897 )
898 COLORFUL_COMMAND_INPUT = pytest.mark.parametrize(
899 ['command_line', 'input'],
900 [
901 (
902 ['vault', '--import', '-'],
903 '{"services": {"": {"length": 20}}}',
904 ),
905 ],
906 ids=['cmd'],
907 )
908 CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES = pytest.mark.parametrize(
909 ['command_line', 'input', 'err_text'],
910 [
911 pytest.param(
912 [],
913 '',
914 'Cannot update the global settings without any given settings',
915 id='None',
916 ),
917 pytest.param(
918 ['--', 'sv'],
919 '',
920 'Cannot update the service-specific settings without any given settings',
921 id='None-sv',
922 ),
923 pytest.param(
924 ['--phrase', '--', 'sv'],
925 '\n',
926 'No passphrase was given',
927 id='phrase-sv',
928 ),
929 pytest.param(
930 ['--phrase', '--', 'sv'],
931 '',
932 'No passphrase was given',
933 id='phrase-sv-eof',
934 ),
935 pytest.param(
936 ['--key'],
937 '\n',
938 'No SSH key was selected',
939 id='key-sv',
940 ),
941 pytest.param(
942 ['--key'],
943 '',
944 'No SSH key was selected',
945 id='key-sv-eof',
946 ),
947 ],
948 )
949 CONFIG_EDITING_VIA_CONFIG_FLAG = pytest.mark.parametrize(
950 ['command_line', 'input', 'result_config'],
951 [
952 pytest.param(
953 ['--phrase'],
954 'my passphrase\n',
955 {'global': {'phrase': 'my passphrase'}, 'services': {}},
956 id='phrase',
957 ),
958 pytest.param(
959 ['--key'],
960 '1\n',
961 {
962 'global': {'key': DUMMY_KEY1_B64, 'phrase': 'abc'},
963 'services': {},
964 },
965 id='key',
966 ),
967 pytest.param(
968 ['--phrase', '--', 'sv'],
969 'my passphrase\n',
970 {
971 'global': {'phrase': 'abc'},
972 'services': {'sv': {'phrase': 'my passphrase'}},
973 },
974 id='phrase-sv',
975 ),
976 pytest.param(
977 ['--key', '--', 'sv'],
978 '1\n',
979 {
980 'global': {'phrase': 'abc'},
981 'services': {'sv': {'key': DUMMY_KEY1_B64}},
982 },
983 id='key-sv',
984 ),
985 pytest.param(
986 ['--key', '--length', '15', '--', 'sv'],
987 '1\n',
988 {
989 'global': {'phrase': 'abc'},
990 'services': {'sv': {'key': DUMMY_KEY1_B64, 'length': 15}},
991 },
992 id='key-length-sv',
993 ),
994 ],
995 )
996 COMPLETABLE_PATH_ARGUMENT = pytest.mark.parametrize(
997 'command_prefix',
998 [
999 pytest.param(
1000 ('export', 'vault'),
1001 id='derivepassphrase-export-vault',
1002 ),
1003 pytest.param(
1004 ('vault', '--export'),
1005 id='derivepassphrase-vault--export',
1006 ),
1007 pytest.param(
1008 ('vault', '--import'),
1009 id='derivepassphrase-vault--import',
1010 ),
1011 ],
1012 )
1013 COMPLETABLE_OPTIONS = pytest.mark.parametrize(
1014 ['command_prefix', 'incomplete', 'completions'],
1015 [
1016 pytest.param(
1017 (),
1018 '-',
1019 frozenset({
1020 '--help',
1021 '-h',
1022 '--version',
1023 '--debug',
1024 '--verbose',
1025 '-v',
1026 '--quiet',
1027 '-q',
1028 }),
1029 id='derivepassphrase',
1030 ),
1031 pytest.param(
1032 ('export',),
1033 '-',
1034 frozenset({
1035 '--help',
1036 '-h',
1037 '--version',
1038 '--debug',
1039 '--verbose',
1040 '-v',
1041 '--quiet',
1042 '-q',
1043 }),
1044 id='derivepassphrase-export',
1045 ),
1046 pytest.param(
1047 ('export', 'vault'),
1048 '-',
1049 frozenset({
1050 '--help',
1051 '-h',
1052 '--version',
1053 '--debug',
1054 '--verbose',
1055 '-v',
1056 '--quiet',
1057 '-q',
1058 '--format',
1059 '-f',
1060 '--key',
1061 '-k',
1062 }),
1063 id='derivepassphrase-export-vault',
1064 ),
1065 pytest.param(
1066 ('vault',),
1067 '-',
1068 frozenset({
1069 '--help',
1070 '-h',
1071 '--version',
1072 '--debug',
1073 '--verbose',
1074 '-v',
1075 '--quiet',
1076 '-q',
1077 '--phrase',
1078 '-p',
1079 '--key',
1080 '-k',
1081 '--length',
1082 '-l',
1083 '--repeat',
1084 '-r',
1085 '--upper',
1086 '--lower',
1087 '--number',
1088 '--space',
1089 '--dash',
1090 '--symbol',
1091 '--config',
1092 '-c',
1093 '--notes',
1094 '-n',
1095 '--delete',
1096 '-x',
1097 '--delete-globals',
1098 '--clear',
1099 '-X',
1100 '--export',
1101 '-e',
1102 '--import',
1103 '-i',
1104 '--overwrite-existing',
1105 '--merge-existing',
1106 '--unset',
1107 '--export-as',
1108 '--modern-editor-interface',
1109 '--vault-legacy-editor-interface',
1110 '--print-notes-before',
1111 '--print-notes-after',
1112 }),
1113 id='derivepassphrase-vault',
1114 ),
1115 ],
1116 )
1117 COMPLETABLE_SUBCOMMANDS = pytest.mark.parametrize(
1118 ['command_prefix', 'incomplete', 'completions'],
1119 [
1120 pytest.param(
1121 (),
1122 '',
1123 frozenset({'export', 'vault'}),
1124 id='derivepassphrase',
1125 ),
1126 pytest.param(
1127 ('export',),
1128 '',
1129 frozenset({'vault'}),
1130 id='derivepassphrase-export',
1131 ),
1132 ],
1133 )
1134 BAD_CONFIGS = pytest.mark.parametrize(
1135 'config',
1136 [
1137 {'global': '', 'services': {}},
1138 {'global': 0, 'services': {}},
1139 {
1140 'global': {'phrase': 'abc'},
1141 'services': False,
1142 },
1143 {
1144 'global': {'phrase': 'abc'},
1145 'services': True,
1146 },
1147 {
1148 'global': {'phrase': 'abc'},
1149 'services': None,
1150 },
1151 ],
1152 )
1153 BASE_CONFIG_VARIATIONS = pytest.mark.parametrize(
1154 'config',
1155 [
1156 {'global': {'phrase': 'my passphrase'}, 'services': {}},
1157 {'global': {'key': DUMMY_KEY1_B64}, 'services': {}},
1158 {
1159 'global': {'phrase': 'abc'},
1160 'services': {'sv': {'phrase': 'my passphrase'}},
1161 },
1162 {
1163 'global': {'phrase': 'abc'},
1164 'services': {'sv': {'key': DUMMY_KEY1_B64}},
1165 },
1166 {
1167 'global': {'phrase': 'abc'},
1168 'services': {'sv': {'key': DUMMY_KEY1_B64, 'length': 15}},
1169 },
1170 ],
1171 )
1172 BASE_CONFIG_WITH_KEY_VARIATIONS = pytest.mark.parametrize(
1173 'config',
1174 [
1175 pytest.param(
1176 {
1177 'global': {'key': DUMMY_KEY1_B64},
1178 'services': {DUMMY_SERVICE: {}},
1179 },
1180 id='global_config',
1181 ),
1182 pytest.param(
1183 {'services': {DUMMY_SERVICE: {'key': DUMMY_KEY2_B64}}},
1184 id='service_config',
1185 ),
1186 pytest.param(
1187 {
1188 'global': {'key': DUMMY_KEY1_B64},
1189 'services': {DUMMY_SERVICE: {'key': DUMMY_KEY2_B64}},
1190 },
1191 id='full_config',
1192 ),
1193 ],
1194 )
1195 CONFIG_WITH_KEY = pytest.mark.parametrize(
1196 'config',
1197 [
1198 pytest.param(
1199 {
1200 'global': {'key': DUMMY_KEY1_B64},
1201 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS},
1202 },
1203 id='global',
1204 ),
1205 pytest.param(
1206 {
1207 'global': {'phrase': DUMMY_PASSPHRASE.rstrip('\n')},
1208 'services': {
1209 DUMMY_SERVICE: {
1210 'key': DUMMY_KEY1_B64,
1211 **DUMMY_CONFIG_SETTINGS,
1212 }
1213 },
1214 },
1215 id='service',
1216 ),
1217 ],
1218 )
1219 VALID_TEST_CONFIGS = pytest.mark.parametrize(
1220 'config',
1221 [
1222 conf.config
1223 for conf in TEST_CONFIGS
1224 if tests.is_valid_test_config(conf)
1225 ],
1226 )
1227 KEY_OVERRIDING_IN_CONFIG = pytest.mark.parametrize(
1228 ['config', 'command_line'],
1229 [
1230 pytest.param(
1231 {
1232 'global': {'key': DUMMY_KEY1_B64},
1233 'services': {},
1234 },
1235 ['--config', '-p'],
1236 id='global',
1237 ),
1238 pytest.param(
1239 {
1240 'services': {
1241 DUMMY_SERVICE: {
1242 'key': DUMMY_KEY1_B64,
1243 **DUMMY_CONFIG_SETTINGS,
1244 },
1245 },
1246 },
1247 ['--config', '-p', '--', DUMMY_SERVICE],
1248 id='service',
1249 ),
1250 pytest.param(
1251 {
1252 'global': {'key': DUMMY_KEY1_B64},
1253 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()},
1254 },
1255 ['--config', '-p', '--', DUMMY_SERVICE],
1256 id='service-over-global',
1257 ),
1258 ],
1259 )
1260 COMPLETION_FUNCTION_INPUTS = pytest.mark.parametrize(
1261 ['config', 'comp_func', 'args', 'incomplete', 'results'],
1262 [
1263 pytest.param(
1264 {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}},
1265 cli_helpers.shell_complete_service,
1266 ['vault'],
1267 '',
1268 [DUMMY_SERVICE],
1269 id='base_config-service',
1270 ),
1271 pytest.param(
1272 {'services': {}},
1273 cli_helpers.shell_complete_service,
1274 ['vault'],
1275 '',
1276 [],
1277 id='empty_config-service',
1278 ),
1279 pytest.param(
1280 {
1281 'services': {
1282 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1283 'newline\nin\nname': DUMMY_CONFIG_SETTINGS.copy(),
1284 }
1285 },
1286 cli_helpers.shell_complete_service,
1287 ['vault'],
1288 '',
1289 [DUMMY_SERVICE],
1290 id='incompletable_newline_config-service',
1291 ),
1292 pytest.param(
1293 {
1294 'services': {
1295 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1296 'backspace\bin\bname': DUMMY_CONFIG_SETTINGS.copy(),
1297 }
1298 },
1299 cli_helpers.shell_complete_service,
1300 ['vault'],
1301 '',
1302 [DUMMY_SERVICE],
1303 id='incompletable_backspace_config-service',
1304 ),
1305 pytest.param(
1306 {
1307 'services': {
1308 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1309 'colon:in:name': DUMMY_CONFIG_SETTINGS.copy(),
1310 }
1311 },
1312 cli_helpers.shell_complete_service,
1313 ['vault'],
1314 '',
1315 sorted([DUMMY_SERVICE, 'colon:in:name']),
1316 id='brittle_colon_config-service',
1317 ),
1318 pytest.param(
1319 {
1320 'services': {
1321 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1322 'colon:in:name': DUMMY_CONFIG_SETTINGS.copy(),
1323 'newline\nin\nname': DUMMY_CONFIG_SETTINGS.copy(),
1324 'backspace\bin\bname': DUMMY_CONFIG_SETTINGS.copy(),
1325 'nul\x00in\x00name': DUMMY_CONFIG_SETTINGS.copy(),
1326 'del\x7fin\x7fname': DUMMY_CONFIG_SETTINGS.copy(),
1327 }
1328 },
1329 cli_helpers.shell_complete_service,
1330 ['vault'],
1331 '',
1332 sorted([DUMMY_SERVICE, 'colon:in:name']),
1333 id='brittle_incompletable_multi_config-service',
1334 ),
1335 pytest.param(
1336 {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}},
1337 cli_helpers.shell_complete_path,
1338 ['vault', '--import'],
1339 '',
1340 [click.shell_completion.CompletionItem('', type='file')],
1341 id='base_config-path',
1342 ),
1343 pytest.param(
1344 {'services': {}},
1345 cli_helpers.shell_complete_path,
1346 ['vault', '--import'],
1347 '',
1348 [click.shell_completion.CompletionItem('', type='file')],
1349 id='empty_config-path',
1350 ),
1351 ],
1352 )
1353 COMPLETABLE_SERVICE_NAMES = pytest.mark.parametrize(
1354 ['config', 'incomplete', 'completions'],
1355 [
1356 pytest.param(
1357 {'services': {}},
1358 '',
1359 frozenset(),
1360 id='no_services',
1361 ),
1362 pytest.param(
1363 {'services': {}},
1364 'partial',
1365 frozenset(),
1366 id='no_services_partial',
1367 ),
1368 pytest.param(
1369 {'services': {DUMMY_SERVICE: {'length': 10}}},
1370 '',
1371 frozenset({DUMMY_SERVICE}),
1372 id='one_service',
1373 ),
1374 pytest.param(
1375 {'services': {DUMMY_SERVICE: {'length': 10}}},
1376 DUMMY_SERVICE[:4],
1377 frozenset({DUMMY_SERVICE}),
1378 id='one_service_partial',
1379 ),
1380 pytest.param(
1381 {'services': {DUMMY_SERVICE: {'length': 10}}},
1382 DUMMY_SERVICE[-4:],
1383 frozenset(),
1384 id='one_service_partial_miss',
1385 ),
1386 ],
1387 )
1388 SERVICE_NAME_COMPLETION_INPUTS = pytest.mark.parametrize(
1389 ['config', 'key', 'incomplete', 'completions'],
1390 [
1391 pytest.param(
1392 {
1393 'services': {
1394 DUMMY_SERVICE: {'length': 10},
1395 'newline\nin\nname': {'length': 10},
1396 },
1397 },
1398 'newline\nin\nname',
1399 '',
1400 frozenset({DUMMY_SERVICE}),
1401 id='newline',
1402 ),
1403 pytest.param(
1404 {
1405 'services': {
1406 DUMMY_SERVICE: {'length': 10},
1407 'newline\nin\nname': {'length': 10},
1408 },
1409 },
1410 'newline\nin\nname',
1411 'serv',
1412 frozenset({DUMMY_SERVICE}),
1413 id='newline_partial_other',
1414 ),
1415 pytest.param(
1416 {
1417 'services': {
1418 DUMMY_SERVICE: {'length': 10},
1419 'newline\nin\nname': {'length': 10},
1420 },
1421 },
1422 'newline\nin\nname',
1423 'newline',
1424 frozenset({}),
1425 id='newline_partial_specific',
1426 ),
1427 pytest.param(
1428 {
1429 'services': {
1430 DUMMY_SERVICE: {'length': 10},
1431 'nul\x00in\x00name': {'length': 10},
1432 },
1433 },
1434 'nul\x00in\x00name',
1435 '',
1436 frozenset({DUMMY_SERVICE}),
1437 id='nul',
1438 ),
1439 pytest.param(
1440 {
1441 'services': {
1442 DUMMY_SERVICE: {'length': 10},
1443 'nul\x00in\x00name': {'length': 10},
1444 },
1445 },
1446 'nul\x00in\x00name',
1447 'serv',
1448 frozenset({DUMMY_SERVICE}),
1449 id='nul_partial_other',
1450 ),
1451 pytest.param(
1452 {
1453 'services': {
1454 DUMMY_SERVICE: {'length': 10},
1455 'nul\x00in\x00name': {'length': 10},
1456 },
1457 },
1458 'nul\x00in\x00name',
1459 'nul',
1460 frozenset({}),
1461 id='nul_partial_specific',
1462 ),
1463 pytest.param(
1464 {
1465 'services': {
1466 DUMMY_SERVICE: {'length': 10},
1467 'backspace\bin\bname': {'length': 10},
1468 },
1469 },
1470 'backspace\bin\bname',
1471 '',
1472 frozenset({DUMMY_SERVICE}),
1473 id='backspace',
1474 ),
1475 pytest.param(
1476 {
1477 'services': {
1478 DUMMY_SERVICE: {'length': 10},
1479 'backspace\bin\bname': {'length': 10},
1480 },
1481 },
1482 'backspace\bin\bname',
1483 'serv',
1484 frozenset({DUMMY_SERVICE}),
1485 id='backspace_partial_other',
1486 ),
1487 pytest.param(
1488 {
1489 'services': {
1490 DUMMY_SERVICE: {'length': 10},
1491 'backspace\bin\bname': {'length': 10},
1492 },
1493 },
1494 'backspace\bin\bname',
1495 'back',
1496 frozenset({}),
1497 id='backspace_partial_specific',
1498 ),
1499 pytest.param(
1500 {
1501 'services': {
1502 DUMMY_SERVICE: {'length': 10},
1503 'del\x7fin\x7fname': {'length': 10},
1504 },
1505 },
1506 'del\x7fin\x7fname',
1507 '',
1508 frozenset({DUMMY_SERVICE}),
1509 id='del',
1510 ),
1511 pytest.param(
1512 {
1513 'services': {
1514 DUMMY_SERVICE: {'length': 10},
1515 'del\x7fin\x7fname': {'length': 10},
1516 },
1517 },
1518 'del\x7fin\x7fname',
1519 'serv',
1520 frozenset({DUMMY_SERVICE}),
1521 id='del_partial_other',
1522 ),
1523 pytest.param(
1524 {
1525 'services': {
1526 DUMMY_SERVICE: {'length': 10},
1527 'del\x7fin\x7fname': {'length': 10},
1528 },
1529 },
1530 'del\x7fin\x7fname',
1531 'del',
1532 frozenset({}),
1533 id='del_partial_specific',
1534 ),
1535 ],
1536 )
1537 CONNECTION_HINTS = pytest.mark.parametrize(
1538 'conn_hint', ['none', 'socket', 'client']
1539 )
1540 NOOP_EDIT_FUNCS = pytest.mark.parametrize(
1541 ['edit_func_name', 'modern_editor_interface'],
1542 [
1543 pytest.param('empty', True, id='empty'),
1544 pytest.param('space', False, id='space-legacy'),
1545 pytest.param('space', True, id='space-modern'),
1546 ],
1547 )
1548 SERVICE_NAME_EXCEPTIONS = pytest.mark.parametrize(
1549 'exc_type', [RuntimeError, KeyError, ValueError]
1550 )
1551 EXPORT_FORMAT_OPTIONS = pytest.mark.parametrize(
1552 'export_options',
1553 [
1554 [],
1555 ['--export-as=sh'],
1556 ],
1557 )
1558 INCOMPLETE = pytest.mark.parametrize('incomplete', ['', 'partial'])
1559 ISATTY = pytest.mark.parametrize(
1560 'isatty',
1561 [False, True],
1562 ids=['notty', 'tty'],
1563 )
1564 KEY_INDEX = pytest.mark.parametrize(
1565 'key_index', [1, 2, 3], ids=lambda i: f'index{i}'
1566 )
1567 KEY_TO_PHRASE_SETTINGS = pytest.mark.parametrize(
1568 [
1569 'list_keys_action',
1570 'address_action',
1571 'system_support_action',
1572 'sign_action',
1573 'pattern',
1574 ],
1575 [
1576 pytest.param(
1577 ListKeysAction.EMPTY,
1578 None,
1579 None,
1580 SignAction.FAIL,
1581 'not loaded into the agent',
1582 id='key-not-loaded',
1583 ),
1584 pytest.param(
1585 ListKeysAction.FAIL,
1586 None,
1587 None,
1588 SignAction.FAIL,
1589 'SSH agent failed to or refused to',
1590 id='list-keys-refused',
1591 ),
1592 pytest.param(
1593 ListKeysAction.FAIL_RUNTIME,
1594 None,
1595 None,
1596 SignAction.FAIL,
1597 'SSH agent failed to or refused to',
1598 id='list-keys-protocol-error',
1599 ),
1600 pytest.param(
1601 None,
1602 SocketAddressAction.UNSET_SSH_AUTH_SOCK,
1603 None,
1604 SignAction.FAIL,
1605 'Cannot find any running SSH agent',
1606 id='agent-address-missing',
1607 ),
1608 pytest.param(
1609 None,
1610 SocketAddressAction.MANGLE_SSH_AUTH_SOCK,
1611 None,
1612 SignAction.FAIL,
1613 'Cannot connect to the SSH agent',
1614 id='agent-address-mangled',
1615 ),
1616 pytest.param(
1617 None,
1618 None,
1619 SystemSupportAction.UNSET_NATIVE,
1620 SignAction.FAIL,
1621 'does not support communicating with it',
1622 id='no-agent-support',
1623 ),
1624 pytest.param(
1625 None,
1626 None,
1627 SystemSupportAction.UNSET_PROVIDER_LIST,
1628 SignAction.FAIL,
1629 'does not support communicating with it',
1630 id='no-agent-support',
1631 ),
1632 pytest.param(
1633 None,
1634 None,
1635 SystemSupportAction.UNSET_AF_UNIX_AND_ENSURE_USE,
1636 SignAction.FAIL,
1637 'does not support communicating with it',
1638 id='no-agent-support',
1639 ),
1640 pytest.param(
1641 None,
1642 None,
1643 SystemSupportAction.UNSET_WINDLL_AND_ENSURE_USE,
1644 SignAction.FAIL,
1645 'does not support communicating with it',
1646 id='no-agent-support',
1647 ),
1648 pytest.param(
1649 None,
1650 None,
1651 None,
1652 SignAction.FAIL_RUNTIME,
1653 'violates the communication protocol',
1654 id='sign-violates-protocol',
1655 ),
1656 ],
1657 )
1658 UNICODE_NORMALIZATION_ERROR_INPUTS = pytest.mark.parametrize(
1659 ['main_config', 'command_line', 'input', 'error_message'],
1660 [
1661 pytest.param(
1662 textwrap.dedent(r"""
1663 [vault]
1664 default-unicode-normalization-form = 'XXX'
1665 """),
1666 ['--import', '-'],
1667 json.dumps({
1668 'services': {
1669 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1670 'with_normalization': {'phrase': 'D\u00fcsseldorf'},
1671 },
1672 }),
1673 (
1674 "Invalid value 'XXX' for config key "
1675 'vault.default-unicode-normalization-form'
1676 ),
1677 id='global',
1678 ),
1679 pytest.param(
1680 textwrap.dedent(r"""
1681 [vault.unicode-normalization-form]
1682 with_normalization = 'XXX'
1683 """),
1684 ['--import', '-'],
1685 json.dumps({
1686 'services': {
1687 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1688 'with_normalization': {'phrase': 'D\u00fcsseldorf'},
1689 },
1690 }),
1691 (
1692 "Invalid value 'XXX' for config key "
1693 'vault.with_normalization.unicode-normalization-form'
1694 ),
1695 id='service',
1696 ),
1697 ],
1698 )
1699 UNICODE_NORMALIZATION_WARNING_INPUTS = pytest.mark.parametrize(
1700 ['main_config', 'command_line', 'input', 'warning_message'],
1701 [
1702 pytest.param(
1703 '',
1704 ['--import', '-'],
1705 json.dumps({
1706 'global': {'phrase': 'Du\u0308sseldorf'},
1707 'services': {},
1708 }),
1709 'The $.global passphrase is not NFC-normalized',
1710 id='global-NFC',
1711 ),
1712 pytest.param(
1713 '',
1714 ['--import', '-'],
1715 json.dumps({
1716 'services': {
1717 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1718 'weird entry name': {'phrase': 'Du\u0308sseldorf'},
1719 }
1720 }),
1721 (
1722 'The $.services["weird entry name"] passphrase '
1723 'is not NFC-normalized'
1724 ),
1725 id='service-weird-name-NFC',
1726 ),
1727 pytest.param(
1728 '',
1729 ['--config', '-p', '--', DUMMY_SERVICE],
1730 'Du\u0308sseldorf',
1731 (
1732 f'The $.services.{DUMMY_SERVICE} passphrase '
1733 f'is not NFC-normalized'
1734 ),
1735 id='config-NFC',
1736 ),
1737 pytest.param(
1738 '',
1739 ['-p', '--', DUMMY_SERVICE],
1740 'Du\u0308sseldorf',
1741 'The interactive input passphrase is not NFC-normalized',
1742 id='direct-input-NFC',
1743 ),
1744 pytest.param(
1745 textwrap.dedent(r"""
1746 [vault]
1747 default-unicode-normalization-form = 'NFD'
1748 """),
1749 ['--import', '-'],
1750 json.dumps({
1751 'global': {
1752 'phrase': 'D\u00fcsseldorf',
1753 },
1754 'services': {},
1755 }),
1756 'The $.global passphrase is not NFD-normalized',
1757 id='global-NFD',
1758 ),
1759 pytest.param(
1760 textwrap.dedent(r"""
1761 [vault]
1762 default-unicode-normalization-form = 'NFD'
1763 """),
1764 ['--import', '-'],
1765 json.dumps({
1766 'services': {
1767 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1768 'weird entry name': {'phrase': 'D\u00fcsseldorf'},
1769 },
1770 }),
1771 (
1772 'The $.services["weird entry name"] passphrase '
1773 'is not NFD-normalized'
1774 ),
1775 id='service-weird-name-NFD',
1776 ),
1777 pytest.param(
1778 textwrap.dedent(r"""
1779 [vault.unicode-normalization-form]
1780 'weird entry name 2' = 'NFKD'
1781 """),
1782 ['--import', '-'],
1783 json.dumps({
1784 'services': {
1785 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1786 'weird entry name 1': {'phrase': 'D\u00fcsseldorf'},
1787 'weird entry name 2': {'phrase': 'D\u00fcsseldorf'},
1788 },
1789 }),
1790 (
1791 'The $.services["weird entry name 2"] passphrase '
1792 'is not NFKD-normalized'
1793 ),
1794 id='service-weird-name-2-NFKD',
1795 ),
1796 ],
1797 )
1798 MASK_PROG_NAME = pytest.mark.parametrize('mask_prog_name', [False, True])
1799 MASK_VERSION = pytest.mark.parametrize('mask_version', [False, True])
1800 CONFIG_SETTING_MODE = pytest.mark.parametrize('mode', ['config', 'import'])
1801 MODERN_EDITOR_INTERFACE = pytest.mark.parametrize(
1802 'modern_editor_interface', [False, True], ids=['legacy', 'modern']
1803 )
1804 NOTES_PLACEMENT = pytest.mark.parametrize(
1805 ['notes_placement', 'placement_args'],
1806 [
1807 pytest.param('after', ['--print-notes-after'], id='after'),
1808 pytest.param('before', ['--print-notes-before'], id='before'),
1809 ],
1810 )
1811 VAULT_CHARSET_OPTION = pytest.mark.parametrize(
1812 'option',
1813 [
1814 '--lower',
1815 '--upper',
1816 '--number',
1817 '--space',
1818 '--dash',
1819 '--symbol',
1820 '--repeat',
1821 '--length',
1822 ],
1823 )
1824 OPTION_COMBINATIONS_INCOMPATIBLE = pytest.mark.parametrize(
1825 ['options', 'service'],
1826 [
1827 pytest.param(o.options, o.needs_service, id=' '.join(o.options))
1828 for o in INTERESTING_OPTION_COMBINATIONS
1829 if o.incompatible
1830 ],
1831 )
1832 OPTION_COMBINATIONS_SERVICE_NEEDED = pytest.mark.parametrize(
1833 ['options', 'service', 'input', 'check_success'],
1834 [
1835 pytest.param(
1836 o.options,
1837 o.needs_service,
1838 o.input,
1839 o.check_success,
1840 id=' '.join(o.options),
1841 )
1842 for o in INTERESTING_OPTION_COMBINATIONS
1843 if not o.incompatible
1844 ],
1845 )
1846 COMPLETABLE_ITEMS = pytest.mark.parametrize(
1847 ['partial', 'is_completable'],
1848 [
1849 ('', True),
1850 (DUMMY_SERVICE, True),
1851 ('a\bn', False),
1852 ('\b', False),
1853 ('\x00', False),
1854 ('\x20', True),
1855 ('\x7f', False),
1856 ('service with spaces', True),
1857 ('service\nwith\nnewlines', False),
1858 ],
1859 )
1860 SHELL_FORMATTER = pytest.mark.parametrize(
1861 ['shell', 'format_func'],
1862 [
1863 pytest.param('bash', bash_format, id='bash'),
1864 pytest.param('fish', fish_format, id='fish'),
1865 pytest.param('zsh', zsh_format, id='zsh'),
1866 ],
1867 )
1868 TRY_RACE_FREE_IMPLEMENTATION = pytest.mark.parametrize(
1869 'try_race_free_implementation', [True, False]
1870 )
1871 VERSION_OUTPUT_DATA = pytest.mark.parametrize(
1872 ['version_output', 'prog_name', 'version', 'expected_parse'],
1873 [
1874 pytest.param(
1875 """\
1876derivepassphrase 0.4.0
1877Using cryptography 44.0.0
1879Supported foreign configuration formats: vault storeroom, vault v0.2,
1880 vault v0.3.
1881PEP 508 extras: export.
1882""",
1883 'derivepassphrase',
1884 '0.4.0',
1885 VersionOutputData(
1886 derivation_schemes={},
1887 foreign_configuration_formats={
1888 'vault storeroom': True,
1889 'vault v0.2': True,
1890 'vault v0.3': True,
1891 },
1892 subcommands=frozenset(),
1893 features={},
1894 extras=frozenset({'export'}),
1895 ),
1896 id='derivepassphrase-0.4.0-export',
1897 ),
1898 pytest.param(
1899 """\
1900derivepassphrase 0.5
1902Supported derivation schemes: vault.
1903Known foreign configuration formats: vault storeroom, vault v0.2, vault v0.3.
1904Supported subcommands: export, vault.
1905No PEP 508 extras are active.
1906""",
1907 'derivepassphrase',
1908 '0.5',
1909 VersionOutputData(
1910 derivation_schemes={'vault': True},
1911 foreign_configuration_formats={
1912 'vault storeroom': False,
1913 'vault v0.2': False,
1914 'vault v0.3': False,
1915 },
1916 subcommands=frozenset({'export', 'vault'}),
1917 features={},
1918 extras=frozenset({}),
1919 ),
1920 id='derivepassphrase-0.5-plain',
1921 ),
1922 pytest.param(
1923 """\
1927inventpassphrase -1.3
1928Using not-a-library 7.12
1929Copyright 2025 Nobody. All rights reserved.
1931Supported derivation schemes: nonsense.
1932Known derivation schemes: divination, /dev/random,
1933 geiger counter,
1934 crossword solver.
1935Supported foreign configuration formats: derivepassphrase, nonsense.
1936Known foreign configuration formats: divination v3.141592,
1937 /dev/random.
1938Supported subcommands: delete-all-files, dump-core.
1939Supported features: delete-while-open.
1940Known features: backups-are-nice-to-have.
1941PEP 508 extras: annoying-popups, delete-all-files,
1942 dump-core-depending-on-the-phase-of-the-moon.
1946""",
1947 'inventpassphrase',
1948 '-1.3',
1949 VersionOutputData(
1950 derivation_schemes={
1951 'nonsense': True,
1952 'divination': False,
1953 '/dev/random': False,
1954 'geiger counter': False,
1955 'crossword solver': False,
1956 },
1957 foreign_configuration_formats={
1958 'derivepassphrase': True,
1959 'nonsense': True,
1960 'divination v3.141592': False,
1961 '/dev/random': False,
1962 },
1963 subcommands=frozenset({'delete-all-files', 'dump-core'}),
1964 features={
1965 'delete-while-open': True,
1966 'backups-are-nice-to-have': False,
1967 },
1968 extras=frozenset({
1969 'annoying-popups',
1970 'delete-all-files',
1971 'dump-core-depending-on-the-phase-of-the-moon',
1972 }),
1973 ),
1974 id='inventpassphrase',
1975 ),
1976 ],
1977 )
1978 """Sample data for [`parse_version_output`][]."""
1979 VALIDATION_FUNCTION_INPUT = pytest.mark.parametrize(
1980 ['vfunc', 'input'],
1981 [
1982 (cli_machinery.validate_occurrence_constraint, 20),
1983 (cli_machinery.validate_length, 20),
1984 ],
1985 )
1988class TestAllCLI:
1989 """Tests uniformly for all command-line interfaces."""
1991 @Parametrize.MASK_PROG_NAME
1992 @Parametrize.MASK_VERSION
1993 @Parametrize.VERSION_OUTPUT_DATA
1994 def test_001_parse_version_output(
1995 self,
1996 version_output: str,
1997 prog_name: str | None,
1998 version: str | None,
1999 mask_prog_name: bool,
2000 mask_version: bool,
2001 expected_parse: VersionOutputData,
2002 ) -> None:
2003 """The parsing machinery for expected version output data works."""
2004 prog_name = None if mask_prog_name else prog_name 1h
2005 version = None if mask_version else version 1h
2006 assert ( 1h
2007 parse_version_output(
2008 version_output, prog_name=prog_name, version=version
2009 )
2010 == expected_parse
2011 )
2013 # TODO(the-13th-letter): Do we actually need this? What should we
2014 # check for?
2015 def test_100_help_output(self) -> None:
2016 """The top-level help text mentions subcommands.
2018 TODO: Do we actually need this? What should we check for?
2020 """
2021 runner = tests.CliRunner(mix_stderr=False) 1_
2022 # TODO(the-13th-letter): Rewrite using parenthesized
2023 # with-statements.
2024 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2025 with contextlib.ExitStack() as stack: 1_
2026 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1_
2027 stack.enter_context( 1_
2028 tests.isolated_config(
2029 monkeypatch=monkeypatch,
2030 runner=runner,
2031 )
2032 )
2033 result = runner.invoke( 1_
2034 cli.derivepassphrase, ['--help'], catch_exceptions=False
2035 )
2036 assert result.clean_exit( 1_
2037 empty_stderr=True, output='currently implemented subcommands'
2038 ), 'expected clean exit, and known help text'
2040 # TODO(the-13th-letter): Do we actually need this? What should we
2041 # check for?
2042 def test_101_help_output_export(
2043 self,
2044 ) -> None:
2045 """The "export" subcommand help text mentions subcommands.
2047 TODO: Do we actually need this? What should we check for?
2049 """
2050 runner = tests.CliRunner(mix_stderr=False) 1`
2051 # TODO(the-13th-letter): Rewrite using parenthesized
2052 # with-statements.
2053 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2054 with contextlib.ExitStack() as stack: 1`
2055 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1`
2056 stack.enter_context( 1`
2057 tests.isolated_config(
2058 monkeypatch=monkeypatch,
2059 runner=runner,
2060 )
2061 )
2062 result = runner.invoke( 1`
2063 cli.derivepassphrase,
2064 ['export', '--help'],
2065 catch_exceptions=False,
2066 )
2067 assert result.clean_exit( 1`
2068 empty_stderr=True, output='only available subcommand'
2069 ), 'expected clean exit, and known help text'
2071 # TODO(the-13th-letter): Do we actually need this? What should we
2072 # check for?
2073 def test_102_help_output_export_vault(
2074 self,
2075 ) -> None:
2076 """The "export vault" subcommand help text has known content.
2078 TODO: Do we actually need this? What should we check for?
2080 """
2081 runner = tests.CliRunner(mix_stderr=False) 1{
2082 # TODO(the-13th-letter): Rewrite using parenthesized
2083 # with-statements.
2084 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2085 with contextlib.ExitStack() as stack: 1{
2086 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1{
2087 stack.enter_context( 1{
2088 tests.isolated_config(
2089 monkeypatch=monkeypatch,
2090 runner=runner,
2091 )
2092 )
2093 result = runner.invoke( 1{
2094 cli.derivepassphrase,
2095 ['export', 'vault', '--help'],
2096 catch_exceptions=False,
2097 )
2098 assert result.clean_exit( 1{
2099 empty_stderr=True, output='Export a vault-native configuration'
2100 ), 'expected clean exit, and known help text'
2102 # TODO(the-13th-letter): Do we actually need this? What should we
2103 # check for?
2104 def test_103_help_output_vault(
2105 self,
2106 ) -> None:
2107 """The "vault" subcommand help text has known content.
2109 TODO: Do we actually need this? What should we check for?
2111 """
2112 runner = tests.CliRunner(mix_stderr=False) 1,
2113 # TODO(the-13th-letter): Rewrite using parenthesized
2114 # with-statements.
2115 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2116 with contextlib.ExitStack() as stack: 1,
2117 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1,
2118 stack.enter_context( 1,
2119 tests.isolated_config(
2120 monkeypatch=monkeypatch,
2121 runner=runner,
2122 )
2123 )
2124 result = runner.invoke( 1,
2125 cli.derivepassphrase,
2126 ['vault', '--help'],
2127 catch_exceptions=False,
2128 )
2129 assert result.clean_exit( 1,
2130 empty_stderr=True, output='Passphrase generation:\n'
2131 ), 'expected clean exit, and option groups in help text'
2132 assert result.clean_exit( 1,
2133 empty_stderr=True, output='Use $VISUAL or $EDITOR to configure'
2134 ), 'expected clean exit, and option group epilog in help text'
2136 @Parametrize.COMMAND_NON_EAGER_ARGUMENTS
2137 @Parametrize.EAGER_ARGUMENTS
2138 def test_200_eager_options(
2139 self,
2140 command: list[str],
2141 arguments: list[str],
2142 non_eager_arguments: list[str],
2143 ) -> None:
2144 """Eager options terminate option and argument processing."""
2145 runner = tests.CliRunner(mix_stderr=False) 1|
2146 # TODO(the-13th-letter): Rewrite using parenthesized
2147 # with-statements.
2148 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2149 with contextlib.ExitStack() as stack: 1|
2150 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1|
2151 stack.enter_context( 1|
2152 tests.isolated_config(
2153 monkeypatch=monkeypatch,
2154 runner=runner,
2155 )
2156 )
2157 result = runner.invoke( 1|
2158 cli.derivepassphrase,
2159 [*command, *arguments, *non_eager_arguments],
2160 catch_exceptions=False,
2161 )
2162 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1|
2164 @Parametrize.ISATTY
2165 @Parametrize.COLORFUL_COMMAND_INPUT
2166 def test_201_automatic_color_mode(
2167 self,
2168 isatty: bool,
2169 command_line: list[str],
2170 input: str | None,
2171 ) -> None:
2172 """Auto-detect if color should be used.
2174 (The answer currently is always no. See the
2175 [`conventional-configurable-text-styling` wishlist
2176 entry](../wishlist/conventional-configurable-text-styling.md).)
2178 """
2179 color = False 1#
2180 runner = tests.CliRunner(mix_stderr=False) 1#
2181 # TODO(the-13th-letter): Rewrite using parenthesized
2182 # with-statements.
2183 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2184 with contextlib.ExitStack() as stack: 1#
2185 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1#
2186 stack.enter_context( 1#
2187 tests.isolated_config(
2188 monkeypatch=monkeypatch,
2189 runner=runner,
2190 )
2191 )
2192 result = runner.invoke( 1#
2193 cli.derivepassphrase,
2194 command_line,
2195 input=input,
2196 catch_exceptions=False,
2197 color=isatty,
2198 )
2199 assert ( 1#
2200 not color
2201 or '\x1b[0m' in result.stderr
2202 or '\x1b[m' in result.stderr
2203 ), 'Expected color, but found no ANSI reset sequence'
2204 assert color or '\x1b[' not in result.stderr, ( 1#
2205 'Expected no color, but found an ANSI control sequence'
2206 )
2208 def test_202a_derivepassphrase_version_option_output(
2209 self,
2210 ) -> None:
2211 """The version output states supported features.
2213 The version output is parsed using [`parse_version_output`][].
2214 Format examples can be found in
2215 [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the
2216 top-level `derivepassphrase` command, the output should contain
2217 the known and supported derivation schemes, and a list of
2218 subcommands.
2220 """
2221 runner = tests.CliRunner(mix_stderr=False) 1g
2222 # TODO(the-13th-letter): Rewrite using parenthesized
2223 # with-statements.
2224 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2225 with contextlib.ExitStack() as stack: 1g
2226 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1g
2227 stack.enter_context( 1g
2228 tests.isolated_config(
2229 monkeypatch=monkeypatch,
2230 runner=runner,
2231 )
2232 )
2233 result = runner.invoke( 1g
2234 cli.derivepassphrase,
2235 ['--version'],
2236 catch_exceptions=False,
2237 )
2238 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1g
2239 assert result.stdout.strip(), 'expected version output' 1g
2240 version_data = parse_version_output(result.stdout) 1g
2241 actually_known_schemes = dict.fromkeys(_types.DerivationScheme, True) 1g
2242 subcommands = set(_types.Subcommand) 1g
2243 assert version_data.derivation_schemes == actually_known_schemes 1g
2244 assert not version_data.foreign_configuration_formats 1g
2245 assert version_data.subcommands == subcommands 1g
2246 assert not version_data.features 1g
2247 assert not version_data.extras 1g
2249 def test_202b_export_version_option_output(
2250 self,
2251 ) -> None:
2252 """The version output states supported features.
2254 The version output is parsed using [`parse_version_output`][].
2255 Format examples can be found in
2256 [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the
2257 `export` command, the output should contain the known foreign
2258 configuration formats (but not marked as supported), and a list
2259 of subcommands.
2261 """
2262 runner = tests.CliRunner(mix_stderr=False) 1e
2263 # TODO(the-13th-letter): Rewrite using parenthesized
2264 # with-statements.
2265 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2266 with contextlib.ExitStack() as stack: 1e
2267 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1e
2268 stack.enter_context( 1e
2269 tests.isolated_config(
2270 monkeypatch=monkeypatch,
2271 runner=runner,
2272 )
2273 )
2274 result = runner.invoke( 1e
2275 cli.derivepassphrase,
2276 ['export', '--version'],
2277 catch_exceptions=False,
2278 )
2279 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1e
2280 assert result.stdout.strip(), 'expected version output' 1e
2281 version_data = parse_version_output(result.stdout) 1e
2282 actually_known_formats: dict[str, bool] = { 1e
2283 _types.ForeignConfigurationFormat.VAULT_STOREROOM: False,
2284 _types.ForeignConfigurationFormat.VAULT_V02: False,
2285 _types.ForeignConfigurationFormat.VAULT_V03: False,
2286 }
2287 subcommands = set(_types.ExportSubcommand) 1e
2288 assert not version_data.derivation_schemes 1e
2289 assert ( 1e
2290 version_data.foreign_configuration_formats
2291 == actually_known_formats
2292 )
2293 assert version_data.subcommands == subcommands 1e
2294 assert not version_data.features 1e
2295 assert not version_data.extras 1e
2297 def test_202c_export_vault_version_option_output(
2298 self,
2299 ) -> None:
2300 """The version output states supported features.
2302 The version output is parsed using [`parse_version_output`][].
2303 Format examples can be found in
2304 [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the
2305 `export vault` subcommand, the output should contain the
2306 vault-specific subset of the known or supported foreign
2307 configuration formats, and a list of available PEP 508 extras.
2309 """
2310 runner = tests.CliRunner(mix_stderr=False) 1d
2311 # TODO(the-13th-letter): Rewrite using parenthesized
2312 # with-statements.
2313 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2314 with contextlib.ExitStack() as stack: 1d
2315 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1d
2316 stack.enter_context( 1d
2317 tests.isolated_config(
2318 monkeypatch=monkeypatch,
2319 runner=runner,
2320 )
2321 )
2322 result = runner.invoke( 1d
2323 cli.derivepassphrase,
2324 ['export', 'vault', '--version'],
2325 catch_exceptions=False,
2326 )
2327 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1d
2328 assert result.stdout.strip(), 'expected version output' 1d
2329 version_data = parse_version_output(result.stdout) 1d
2330 actually_known_formats: dict[str, bool] = {} 1d
2331 actually_enabled_extras: set[str] = set() 1d
2332 with contextlib.suppress(ModuleNotFoundError): 1d
2333 from derivepassphrase.exporter import storeroom, vault_native # noqa: I001,PLC0415 1d
2335 actually_known_formats.update({ 1d
2336 _types.ForeignConfigurationFormat.VAULT_STOREROOM: not storeroom.STUBBED,
2337 _types.ForeignConfigurationFormat.VAULT_V02: not vault_native.STUBBED,
2338 _types.ForeignConfigurationFormat.VAULT_V03: not vault_native.STUBBED,
2339 })
2340 if not storeroom.STUBBED and not vault_native.STUBBED: 1d
2341 actually_enabled_extras.add(_types.PEP508Extra.EXPORT) 1d
2342 assert not version_data.derivation_schemes 1d
2343 assert ( 1d
2344 version_data.foreign_configuration_formats
2345 == actually_known_formats
2346 )
2347 assert not version_data.subcommands 1d
2348 assert not version_data.features 1d
2349 assert version_data.extras == actually_enabled_extras 1d
2351 def test_202d_vault_version_option_output(
2352 self,
2353 ) -> None:
2354 """The version output states supported features.
2356 The version output is parsed using [`parse_version_output`][].
2357 Format examples can be found in
2358 [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the
2359 vault command, the output should not contain anything beyond the
2360 first paragraph.
2362 """
2363 runner = tests.CliRunner(mix_stderr=False) 1f
2364 # TODO(the-13th-letter): Rewrite using parenthesized
2365 # with-statements.
2366 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2367 with contextlib.ExitStack() as stack: 1f
2368 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1f
2369 stack.enter_context( 1f
2370 tests.isolated_config(
2371 monkeypatch=monkeypatch,
2372 runner=runner,
2373 )
2374 )
2375 result = runner.invoke( 1f
2376 cli.derivepassphrase,
2377 ['vault', '--version'],
2378 catch_exceptions=False,
2379 )
2380 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1f
2381 assert result.stdout.strip(), 'expected version output' 1f
2382 version_data = parse_version_output(result.stdout) 1f
2383 features: dict[str, bool] = { 1f
2384 _types.Feature.SSH_KEY: hasattr(socket, 'AF_UNIX'),
2385 }
2386 assert not version_data.derivation_schemes 1f
2387 assert not version_data.foreign_configuration_formats 1f
2388 assert not version_data.subcommands 1f
2389 assert version_data.features == features 1f
2390 assert not version_data.extras 1f
2393class TestCLI:
2394 """Tests for the `derivepassphrase vault` command-line interface."""
2396 def test_200_help_output(
2397 self,
2398 ) -> None:
2399 """The `--help` option emits help text."""
2400 runner = tests.CliRunner(mix_stderr=False) 1-
2401 # TODO(the-13th-letter): Rewrite using parenthesized
2402 # with-statements.
2403 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2404 with contextlib.ExitStack() as stack: 1-
2405 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1-
2406 stack.enter_context( 1-
2407 tests.isolated_config(
2408 monkeypatch=monkeypatch,
2409 runner=runner,
2410 )
2411 )
2412 result = runner.invoke( 1-
2413 cli.derivepassphrase_vault,
2414 ['--help'],
2415 catch_exceptions=False,
2416 )
2417 assert result.clean_exit( 1-
2418 empty_stderr=True, output='Passphrase generation:\n'
2419 ), 'expected clean exit, and option groups in help text'
2420 assert result.clean_exit( 1-
2421 empty_stderr=True, output='Use $VISUAL or $EDITOR to configure'
2422 ), 'expected clean exit, and option group epilog in help text'
2424 # TODO(the-13th-letter): Remove this test once
2425 # TestAllCLI.test_202_version_option_output no longer xfails.
2426 def test_200a_version_output(
2427 self,
2428 ) -> None:
2429 """The `--version` option emits version information."""
2430 runner = tests.CliRunner(mix_stderr=False) 1.
2431 # TODO(the-13th-letter): Rewrite using parenthesized
2432 # with-statements.
2433 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2434 with contextlib.ExitStack() as stack: 1.
2435 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1.
2436 stack.enter_context( 1.
2437 tests.isolated_config(
2438 monkeypatch=monkeypatch,
2439 runner=runner,
2440 )
2441 )
2442 result = runner.invoke( 1.
2443 cli.derivepassphrase_vault,
2444 ['--version'],
2445 catch_exceptions=False,
2446 )
2447 assert result.clean_exit(empty_stderr=True, output=cli.PROG_NAME), ( 1.
2448 'expected clean exit, and program name in version text'
2449 )
2450 assert result.clean_exit(empty_stderr=True, output=cli.VERSION), ( 1.
2451 'expected clean exit, and version in help text'
2452 )
2454 @Parametrize.CHARSET_NAME
2455 def test_201_disable_character_set(
2456 self,
2457 charset_name: str,
2458 ) -> None:
2459 """Named character classes can be disabled on the command-line."""
2460 option = f'--{charset_name}' 1Q
2461 charset = vault.Vault.CHARSETS[charset_name].decode('ascii') 1Q
2462 runner = tests.CliRunner(mix_stderr=False) 1Q
2463 # TODO(the-13th-letter): Rewrite using parenthesized
2464 # with-statements.
2465 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2466 with contextlib.ExitStack() as stack: 1Q
2467 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1Q
2468 stack.enter_context( 1Q
2469 tests.isolated_config(
2470 monkeypatch=monkeypatch,
2471 runner=runner,
2472 )
2473 )
2474 monkeypatch.setattr( 1Q
2475 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt
2476 )
2477 result = runner.invoke( 1Q
2478 cli.derivepassphrase_vault,
2479 [option, '0', '-p', '--', DUMMY_SERVICE],
2480 input=DUMMY_PASSPHRASE,
2481 catch_exceptions=False,
2482 )
2483 assert result.clean_exit(empty_stderr=True), 'expected clean exit:' 1Q
2484 for c in charset: 1Q
2485 assert c not in result.stdout, ( 1Q
2486 f'derived password contains forbidden character {c!r}'
2487 )
2489 def test_202_disable_repetition(
2490 self,
2491 ) -> None:
2492 """Character repetition can be disabled on the command-line."""
2493 runner = tests.CliRunner(mix_stderr=False) 1W
2494 # TODO(the-13th-letter): Rewrite using parenthesized
2495 # with-statements.
2496 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2497 with contextlib.ExitStack() as stack: 1W
2498 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1W
2499 stack.enter_context( 1W
2500 tests.isolated_config(
2501 monkeypatch=monkeypatch,
2502 runner=runner,
2503 )
2504 )
2505 monkeypatch.setattr( 1W
2506 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt
2507 )
2508 result = runner.invoke( 1W
2509 cli.derivepassphrase_vault,
2510 ['--repeat', '0', '-p', '--', DUMMY_SERVICE],
2511 input=DUMMY_PASSPHRASE,
2512 catch_exceptions=False,
2513 )
2514 assert result.clean_exit(empty_stderr=True), ( 1W
2515 'expected clean exit and empty stderr'
2516 )
2517 passphrase = result.stdout.rstrip('\r\n') 1W
2518 for i in range(len(passphrase) - 1): 1W
2519 assert passphrase[i : i + 1] != passphrase[i + 1 : i + 2], ( 1W
2520 f'derived password contains repeated character '
2521 f'at position {i}: {result.stdout!r}'
2522 )
2524 @Parametrize.CONFIG_WITH_KEY
2525 def test_204a_key_from_config(
2526 self,
2527 running_ssh_agent: tests.RunningSSHAgentInfo,
2528 config: _types.VaultConfig,
2529 ) -> None:
2530 """A stored configured SSH key will be used."""
2531 del running_ssh_agent 1R
2532 runner = tests.CliRunner(mix_stderr=False) 1R
2533 # TODO(the-13th-letter): Rewrite using parenthesized
2534 # with-statements.
2535 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2536 with contextlib.ExitStack() as stack: 1R
2537 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1R
2538 stack.enter_context( 1R
2539 tests.isolated_vault_config(
2540 monkeypatch=monkeypatch,
2541 runner=runner,
2542 vault_config=config,
2543 )
2544 )
2545 monkeypatch.setattr( 1R
2546 vault.Vault, 'phrase_from_key', tests.phrase_from_key
2547 )
2548 result = runner.invoke( 1R
2549 cli.derivepassphrase_vault,
2550 ['--', DUMMY_SERVICE],
2551 catch_exceptions=False,
2552 )
2553 assert result.clean_exit(empty_stderr=True), ( 1R
2554 'expected clean exit and empty stderr'
2555 )
2556 assert result.stdout 1R
2557 assert ( 1R
2558 result.stdout.rstrip('\n').encode('UTF-8')
2559 != DUMMY_RESULT_PASSPHRASE
2560 ), 'known false output: phrase-based instead of key-based'
2561 assert ( 1R
2562 result.stdout.rstrip('\n').encode('UTF-8') == DUMMY_RESULT_KEY1
2563 ), 'expected known output'
2565 def test_204b_key_from_command_line(
2566 self,
2567 running_ssh_agent: tests.RunningSSHAgentInfo,
2568 ) -> None:
2569 """An SSH key requested on the command-line will be used."""
2570 del running_ssh_agent 1G
2571 runner = tests.CliRunner(mix_stderr=False) 1G
2572 # TODO(the-13th-letter): Rewrite using parenthesized
2573 # with-statements.
2574 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2575 with contextlib.ExitStack() as stack: 1G
2576 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1G
2577 stack.enter_context( 1G
2578 tests.isolated_vault_config(
2579 monkeypatch=monkeypatch,
2580 runner=runner,
2581 vault_config={
2582 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
2583 },
2584 )
2585 )
2586 monkeypatch.setattr( 1G
2587 cli_helpers, 'get_suitable_ssh_keys', tests.suitable_ssh_keys
2588 )
2589 monkeypatch.setattr( 1G
2590 vault.Vault, 'phrase_from_key', tests.phrase_from_key
2591 )
2592 result = runner.invoke( 1G
2593 cli.derivepassphrase_vault,
2594 ['-k', '--', DUMMY_SERVICE],
2595 input='1\n',
2596 catch_exceptions=False,
2597 )
2598 assert result.clean_exit(), 'expected clean exit' 1G
2599 assert result.stdout, 'expected program output' 1G
2600 last_line = result.stdout.splitlines(True)[-1] 1G
2601 assert ( 1G
2602 last_line.rstrip('\n').encode('UTF-8') != DUMMY_RESULT_PASSPHRASE
2603 ), 'known false output: phrase-based instead of key-based'
2604 assert last_line.rstrip('\n').encode('UTF-8') == DUMMY_RESULT_KEY1, ( 1G
2605 'expected known output'
2606 )
2608 @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS
2609 @Parametrize.KEY_INDEX
2610 def test_204c_key_override_on_command_line(
2611 self,
2612 running_ssh_agent: tests.RunningSSHAgentInfo,
2613 config: dict[str, Any],
2614 key_index: int,
2615 ) -> None:
2616 """A command-line SSH key will override the configured key."""
2617 del running_ssh_agent 1L
2618 runner = tests.CliRunner(mix_stderr=False) 1L
2619 # TODO(the-13th-letter): Rewrite using parenthesized
2620 # with-statements.
2621 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2622 with contextlib.ExitStack() as stack: 1L
2623 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1L
2624 stack.enter_context( 1L
2625 tests.isolated_vault_config(
2626 monkeypatch=monkeypatch,
2627 runner=runner,
2628 vault_config=config,
2629 )
2630 )
2631 monkeypatch.setattr( 1L
2632 ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys
2633 )
2634 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', tests.sign) 1L
2635 result = runner.invoke( 1L
2636 cli.derivepassphrase_vault,
2637 ['-k', '--', DUMMY_SERVICE],
2638 input=f'{key_index}\n',
2639 )
2640 assert result.clean_exit(), 'expected clean exit' 1L
2641 assert result.stdout, 'expected program output' 1L
2642 assert result.stderr, 'expected stderr' 1L
2643 assert 'Error:' not in result.stderr, ( 1L
2644 'expected no error messages on stderr'
2645 )
2647 def test_205_service_phrase_if_key_in_global_config(
2648 self,
2649 running_ssh_agent: tests.RunningSSHAgentInfo,
2650 ) -> None:
2651 """A command-line passphrase will override the configured key."""
2652 del running_ssh_agent 1H
2653 runner = tests.CliRunner(mix_stderr=False) 1H
2654 # TODO(the-13th-letter): Rewrite using parenthesized
2655 # with-statements.
2656 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2657 with contextlib.ExitStack() as stack: 1H
2658 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1H
2659 stack.enter_context( 1H
2660 tests.isolated_vault_config(
2661 monkeypatch=monkeypatch,
2662 runner=runner,
2663 vault_config={
2664 'global': {'key': DUMMY_KEY1_B64},
2665 'services': {
2666 DUMMY_SERVICE: {
2667 'phrase': DUMMY_PASSPHRASE.rstrip('\n'),
2668 **DUMMY_CONFIG_SETTINGS,
2669 }
2670 },
2671 },
2672 )
2673 )
2674 monkeypatch.setattr( 1H
2675 ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys
2676 )
2677 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', tests.sign) 1H
2678 result = runner.invoke( 1H
2679 cli.derivepassphrase_vault,
2680 ['--', DUMMY_SERVICE],
2681 catch_exceptions=False,
2682 )
2683 assert result.clean_exit(), 'expected clean exit' 1H
2684 assert result.stdout, 'expected program output' 1H
2685 last_line = result.stdout.splitlines(True)[-1] 1H
2686 assert ( 1H
2687 last_line.rstrip('\n').encode('UTF-8') != DUMMY_RESULT_PASSPHRASE
2688 ), 'known false output: phrase-based instead of key-based'
2689 assert last_line.rstrip('\n').encode('UTF-8') == DUMMY_RESULT_KEY1, ( 1H
2690 'expected known output'
2691 )
2693 @Parametrize.KEY_OVERRIDING_IN_CONFIG
2694 def test_206_setting_phrase_thus_overriding_key_in_config(
2695 self,
2696 running_ssh_agent: tests.RunningSSHAgentInfo,
2697 caplog: pytest.LogCaptureFixture,
2698 config: _types.VaultConfig,
2699 command_line: list[str],
2700 ) -> None:
2701 """Configuring a passphrase atop an SSH key works, but warns."""
2702 del running_ssh_agent 1v
2703 runner = tests.CliRunner(mix_stderr=False) 1v
2704 # TODO(the-13th-letter): Rewrite using parenthesized
2705 # with-statements.
2706 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2707 with contextlib.ExitStack() as stack: 1v
2708 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1v
2709 stack.enter_context( 1v
2710 tests.isolated_vault_config(
2711 monkeypatch=monkeypatch,
2712 runner=runner,
2713 vault_config=config,
2714 )
2715 )
2716 monkeypatch.setattr( 1v
2717 ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys
2718 )
2719 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', tests.sign) 1v
2720 result = runner.invoke( 1v
2721 cli.derivepassphrase_vault,
2722 command_line,
2723 input=DUMMY_PASSPHRASE,
2724 catch_exceptions=False,
2725 )
2726 assert result.clean_exit(), 'expected clean exit' 1v
2727 assert not result.stdout.strip(), 'expected no program output' 1v
2728 assert result.stderr, 'expected known error output' 1v
2729 err_lines = result.stderr.splitlines(False) 1v
2730 assert err_lines[0].startswith('Passphrase:') 1v
2731 assert tests.warning_emitted( 1v
2732 'Setting a service passphrase is ineffective ',
2733 caplog.record_tuples,
2734 ) or tests.warning_emitted(
2735 'Setting a global passphrase is ineffective ',
2736 caplog.record_tuples,
2737 ), 'expected known warning message'
2738 assert all(map(is_warning_line, result.stderr.splitlines(True))) 1v
2739 assert all( 1v
2740 map(is_harmless_config_import_warning, caplog.record_tuples)
2741 ), 'unexpected error output'
2743 @hypothesis.given(
2744 notes=strategies.text(
2745 strategies.characters(
2746 min_codepoint=32,
2747 max_codepoint=126,
2748 include_characters='\n',
2749 ),
2750 max_size=256,
2751 ),
2752 )
2753 def test_207_service_with_notes_actually_prints_notes(
2754 self,
2755 notes: str,
2756 ) -> None:
2757 """Service notes are printed, if they exist."""
2758 hypothesis.assume('Error:' not in notes) 1M
2759 runner = tests.CliRunner(mix_stderr=False) 1M
2760 # TODO(the-13th-letter): Rewrite using parenthesized
2761 # with-statements.
2762 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2763 with contextlib.ExitStack() as stack: 1M
2764 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1M
2765 stack.enter_context( 1M
2766 tests.isolated_vault_config(
2767 monkeypatch=monkeypatch,
2768 runner=runner,
2769 vault_config={
2770 'global': {
2771 'phrase': DUMMY_PASSPHRASE,
2772 },
2773 'services': {
2774 DUMMY_SERVICE: {
2775 'notes': notes,
2776 **DUMMY_CONFIG_SETTINGS,
2777 },
2778 },
2779 },
2780 )
2781 )
2782 result = runner.invoke( 1M
2783 cli.derivepassphrase_vault,
2784 ['--', DUMMY_SERVICE],
2785 )
2786 assert result.clean_exit(), 'expected clean exit' 1M
2787 assert result.stdout, 'expected program output' 1M
2788 assert result.stdout.strip() == DUMMY_RESULT_PASSPHRASE.decode( 1M
2789 'ascii'
2790 ), 'expected known program output'
2791 assert result.stderr or not notes.strip(), 'expected stderr' 1M
2792 assert 'Error:' not in result.stderr, ( 1M
2793 'expected no error messages on stderr'
2794 )
2795 assert result.stderr.strip() == notes.strip(), ( 1M
2796 'expected known stderr contents'
2797 )
2799 @Parametrize.VAULT_CHARSET_OPTION
2800 def test_210_invalid_argument_range(
2801 self,
2802 option: str,
2803 ) -> None:
2804 """Requesting invalidly many characters from a class fails."""
2805 runner = tests.CliRunner(mix_stderr=False) 1/
2806 # TODO(the-13th-letter): Rewrite using parenthesized
2807 # with-statements.
2808 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2809 with contextlib.ExitStack() as stack: 1/
2810 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1/
2811 stack.enter_context( 1/
2812 tests.isolated_config(
2813 monkeypatch=monkeypatch,
2814 runner=runner,
2815 )
2816 )
2817 for value in '-42', 'invalid': 1/
2818 result = runner.invoke( 1/
2819 cli.derivepassphrase_vault,
2820 [option, value, '-p', '--', DUMMY_SERVICE],
2821 input=DUMMY_PASSPHRASE,
2822 catch_exceptions=False,
2823 )
2824 assert result.error_exit(error='Invalid value'), ( 1/
2825 'expected error exit and known error message'
2826 )
2828 @Parametrize.OPTION_COMBINATIONS_SERVICE_NEEDED
2829 def test_211_service_needed(
2830 self,
2831 options: list[str],
2832 service: bool | None,
2833 input: str | None,
2834 check_success: bool,
2835 ) -> None:
2836 """We require or forbid a service argument, depending on options."""
2837 runner = tests.CliRunner(mix_stderr=False) 1A
2838 # TODO(the-13th-letter): Rewrite using parenthesized
2839 # with-statements.
2840 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2841 with contextlib.ExitStack() as stack: 1A
2842 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1A
2843 stack.enter_context( 1A
2844 tests.isolated_vault_config(
2845 monkeypatch=monkeypatch,
2846 runner=runner,
2847 vault_config={'global': {'phrase': 'abc'}, 'services': {}},
2848 )
2849 )
2850 monkeypatch.setattr( 1A
2851 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt
2852 )
2853 result = runner.invoke( 1A
2854 cli.derivepassphrase_vault,
2855 options if service else [*options, '--', DUMMY_SERVICE],
2856 input=input,
2857 catch_exceptions=False,
2858 )
2859 if service is not None: 1A
2860 err_msg = ( 1A
2861 ' requires a SERVICE'
2862 if service
2863 else ' does not take a SERVICE argument'
2864 )
2865 assert result.error_exit(error=err_msg), ( 1A
2866 'expected error exit and known error message'
2867 )
2868 else:
2869 assert result.clean_exit(empty_stderr=True), ( 1A
2870 'expected clean exit'
2871 )
2872 if check_success: 1A
2873 # TODO(the-13th-letter): Rewrite using parenthesized
2874 # with-statements.
2875 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2876 with contextlib.ExitStack() as stack: 1A
2877 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1A
2878 stack.enter_context( 1A
2879 tests.isolated_vault_config(
2880 monkeypatch=monkeypatch,
2881 runner=runner,
2882 vault_config={
2883 'global': {'phrase': 'abc'},
2884 'services': {},
2885 },
2886 )
2887 )
2888 monkeypatch.setattr( 1A
2889 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt
2890 )
2891 result = runner.invoke( 1A
2892 cli.derivepassphrase_vault,
2893 [*options, '--', DUMMY_SERVICE] if service else options,
2894 input=input,
2895 catch_exceptions=False,
2896 )
2897 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1A
2899 def test_211a_empty_service_name_causes_warning(
2900 self,
2901 caplog: pytest.LogCaptureFixture,
2902 ) -> None:
2903 """Using an empty service name (where permissible) warns.
2905 Only the `--config` option can optionally take a service name.
2907 """
2909 def is_expected_warning(record: tuple[str, int, str]) -> bool: 1t
2910 return is_harmless_config_import_warning( 1t
2911 record
2912 ) or tests.warning_emitted(
2913 'An empty SERVICE is not supported by vault(1)', [record]
2914 )
2916 runner = tests.CliRunner(mix_stderr=False) 1t
2917 # TODO(the-13th-letter): Rewrite using parenthesized
2918 # with-statements.
2919 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2920 with contextlib.ExitStack() as stack: 1t
2921 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1t
2922 stack.enter_context( 1t
2923 tests.isolated_vault_config(
2924 monkeypatch=monkeypatch,
2925 runner=runner,
2926 vault_config={'services': {}},
2927 )
2928 )
2929 monkeypatch.setattr( 1t
2930 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt
2931 )
2932 result = runner.invoke( 1t
2933 cli.derivepassphrase_vault,
2934 ['--config', '--length=30', '--', ''],
2935 catch_exceptions=False,
2936 )
2937 assert result.clean_exit(empty_stderr=False), 'expected clean exit' 1t
2938 assert result.stderr is not None, 'expected known error output' 1t
2939 assert all(map(is_expected_warning, caplog.record_tuples)), ( 1t
2940 'expected known error output'
2941 )
2942 assert cli_helpers.load_config() == { 1t
2943 'global': {'length': 30},
2944 'services': {},
2945 }, 'requested configuration change was not applied'
2946 caplog.clear() 1t
2947 result = runner.invoke( 1t
2948 cli.derivepassphrase_vault,
2949 ['--import', '-'],
2950 input=json.dumps({'services': {'': {'length': 40}}}),
2951 catch_exceptions=False,
2952 )
2953 assert result.clean_exit(empty_stderr=False), 'expected clean exit' 1t
2954 assert result.stderr is not None, 'expected known error output' 1t
2955 assert all(map(is_expected_warning, caplog.record_tuples)), ( 1t
2956 'expected known error output'
2957 )
2958 assert cli_helpers.load_config() == { 1t
2959 'global': {'length': 30},
2960 'services': {'': {'length': 40}},
2961 }, 'requested configuration change was not applied'
2963 @Parametrize.OPTION_COMBINATIONS_INCOMPATIBLE
2964 def test_212_incompatible_options(
2965 self,
2966 options: list[str],
2967 service: bool | None,
2968 ) -> None:
2969 """Incompatible options are detected."""
2970 runner = tests.CliRunner(mix_stderr=False) 1}
2971 # TODO(the-13th-letter): Rewrite using parenthesized
2972 # with-statements.
2973 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2974 with contextlib.ExitStack() as stack: 1}
2975 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1}
2976 stack.enter_context( 1}
2977 tests.isolated_config(
2978 monkeypatch=monkeypatch,
2979 runner=runner,
2980 )
2981 )
2982 result = runner.invoke( 1}
2983 cli.derivepassphrase_vault,
2984 [*options, '--', DUMMY_SERVICE] if service else options,
2985 input=DUMMY_PASSPHRASE,
2986 catch_exceptions=False,
2987 )
2988 assert result.error_exit(error='mutually exclusive with '), ( 1}
2989 'expected error exit and known error message'
2990 )
2992 @Parametrize.VALID_TEST_CONFIGS
2993 def test_213_import_config_success(
2994 self,
2995 caplog: pytest.LogCaptureFixture,
2996 config: Any,
2997 ) -> None:
2998 """Importing a configuration works."""
2999 runner = tests.CliRunner(mix_stderr=False) 1C
3000 # TODO(the-13th-letter): Rewrite using parenthesized
3001 # with-statements.
3002 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3003 with contextlib.ExitStack() as stack: 1C
3004 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1C
3005 stack.enter_context( 1C
3006 tests.isolated_vault_config(
3007 monkeypatch=monkeypatch,
3008 runner=runner,
3009 vault_config={'services': {}},
3010 )
3011 )
3012 result = runner.invoke( 1C
3013 cli.derivepassphrase_vault,
3014 ['--import', '-'],
3015 input=json.dumps(config),
3016 catch_exceptions=False,
3017 )
3018 config_txt = cli_helpers.config_filename( 1C
3019 subsystem='vault'
3020 ).read_text(encoding='UTF-8')
3021 config2 = json.loads(config_txt) 1C
3022 assert result.clean_exit(empty_stderr=False), 'expected clean exit' 1C
3023 assert config2 == config, 'config not imported correctly' 1C
3024 assert not result.stderr or all( # pragma: no branch 1C
3025 map(is_harmless_config_import_warning, caplog.record_tuples)
3026 ), 'unexpected error output'
3027 assert_vault_config_is_indented_and_line_broken(config_txt) 1C
3029 @hypothesis.settings(
3030 suppress_health_check=[
3031 *hypothesis.settings().suppress_health_check,
3032 hypothesis.HealthCheck.function_scoped_fixture,
3033 ],
3034 )
3035 @hypothesis.given(
3036 conf=tests.smudged_vault_test_config(
3037 strategies.sampled_from([
3038 conf
3039 for conf in tests.TEST_CONFIGS
3040 if tests.is_valid_test_config(conf)
3041 ])
3042 )
3043 )
3044 def test_213a_import_config_success(
3045 self,
3046 caplog: pytest.LogCaptureFixture,
3047 conf: tests.VaultTestConfig,
3048 ) -> None:
3049 """Importing a smudged configuration works.
3051 Tested via hypothesis.
3053 """
3054 config = conf.config 1w
3055 config2 = copy.deepcopy(config) 1w
3056 _types.clean_up_falsy_vault_config_values(config2) 1w
3057 # Reset caplog between hypothesis runs.
3058 caplog.clear() 1w
3059 runner = tests.CliRunner(mix_stderr=False) 1w
3060 # TODO(the-13th-letter): Rewrite using parenthesized
3061 # with-statements.
3062 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3063 with contextlib.ExitStack() as stack: 1w
3064 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1w
3065 stack.enter_context( 1w
3066 tests.isolated_vault_config(
3067 monkeypatch=monkeypatch,
3068 runner=runner,
3069 vault_config={'services': {}},
3070 )
3071 )
3072 result = runner.invoke( 1w
3073 cli.derivepassphrase_vault,
3074 ['--import', '-'],
3075 input=json.dumps(config),
3076 catch_exceptions=False,
3077 )
3078 config_txt = cli_helpers.config_filename( 1w
3079 subsystem='vault'
3080 ).read_text(encoding='UTF-8')
3081 config3 = json.loads(config_txt) 1w
3082 assert result.clean_exit(empty_stderr=False), 'expected clean exit' 1w
3083 assert config3 == config2, 'config not imported correctly' 1w
3084 assert not result.stderr or all( 1w
3085 map(is_harmless_config_import_warning, caplog.record_tuples)
3086 ), 'unexpected error output'
3087 assert_vault_config_is_indented_and_line_broken(config_txt) 1w
3089 def test_213b_import_bad_config_not_vault_config(
3090 self,
3091 ) -> None:
3092 """Importing an invalid config fails."""
3093 runner = tests.CliRunner(mix_stderr=False) 1~
3094 # TODO(the-13th-letter): Rewrite using parenthesized
3095 # with-statements.
3096 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3097 with contextlib.ExitStack() as stack: 1~
3098 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1~
3099 stack.enter_context( 1~
3100 tests.isolated_config(
3101 monkeypatch=monkeypatch,
3102 runner=runner,
3103 )
3104 )
3105 result = runner.invoke( 1~
3106 cli.derivepassphrase_vault,
3107 ['--import', '-'],
3108 input='null',
3109 catch_exceptions=False,
3110 )
3111 assert result.error_exit(error='Invalid vault config'), ( 1~
3112 'expected error exit and known error message'
3113 )
3115 def test_213c_import_bad_config_not_json_data(
3116 self,
3117 ) -> None:
3118 """Importing an invalid config fails."""
3119 runner = tests.CliRunner(mix_stderr=False) 2ab
3120 # TODO(the-13th-letter): Rewrite using parenthesized
3121 # with-statements.
3122 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3123 with contextlib.ExitStack() as stack: 2ab
3124 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2ab
3125 stack.enter_context( 2ab
3126 tests.isolated_config(
3127 monkeypatch=monkeypatch,
3128 runner=runner,
3129 )
3130 )
3131 result = runner.invoke( 2ab
3132 cli.derivepassphrase_vault,
3133 ['--import', '-'],
3134 input='This string is not valid JSON.',
3135 catch_exceptions=False,
3136 )
3137 assert result.error_exit(error='cannot decode JSON'), ( 2ab
3138 'expected error exit and known error message'
3139 )
3141 def test_213d_import_bad_config_not_a_file(
3142 self,
3143 ) -> None:
3144 """Importing an invalid config fails."""
3145 runner = tests.CliRunner(mix_stderr=False) 1$
3146 # `isolated_vault_config` ensures the configuration is valid
3147 # JSON. So, to pass an actual broken configuration, we must
3148 # open the configuration file ourselves afterwards, inside the
3149 # context.
3150 #
3151 # TODO(the-13th-letter): Rewrite using parenthesized
3152 # with-statements.
3153 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3154 with contextlib.ExitStack() as stack: 1$
3155 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1$
3156 stack.enter_context( 1$
3157 tests.isolated_vault_config(
3158 monkeypatch=monkeypatch,
3159 runner=runner,
3160 vault_config={'services': {}},
3161 )
3162 )
3163 cli_helpers.config_filename(subsystem='vault').write_text( 1$
3164 'This string is not valid JSON.\n', encoding='UTF-8'
3165 )
3166 dname = cli_helpers.config_filename(subsystem=None) 1$
3167 result = runner.invoke( 1$
3168 cli.derivepassphrase_vault,
3169 ['--import', os.fsdecode(dname)],
3170 catch_exceptions=False,
3171 )
3172 # The Annoying OS uses EACCES, other OSes use EISDIR.
3173 assert result.error_exit( 1$
3174 error=os.strerror(errno.EISDIR)
3175 ) or result.error_exit(error=os.strerror(errno.EACCES)), (
3176 'expected error exit and known error message'
3177 )
3179 @Parametrize.VALID_TEST_CONFIGS
3180 def test_214_export_config_success(
3181 self,
3182 caplog: pytest.LogCaptureFixture,
3183 config: Any,
3184 ) -> None:
3185 """Exporting a configuration works."""
3186 runner = tests.CliRunner(mix_stderr=False) 1D
3187 # TODO(the-13th-letter): Rewrite using parenthesized
3188 # with-statements.
3189 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3190 with contextlib.ExitStack() as stack: 1D
3191 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1D
3192 stack.enter_context( 1D
3193 tests.isolated_vault_config(
3194 monkeypatch=monkeypatch,
3195 runner=runner,
3196 vault_config=config,
3197 )
3198 )
3199 with cli_helpers.config_filename(subsystem='vault').open( 1D
3200 'w', encoding='UTF-8'
3201 ) as outfile:
3202 # Ensure the config is written on one line.
3203 json.dump(config, outfile, indent=None) 1D
3204 result = runner.invoke( 1D
3205 cli.derivepassphrase_vault,
3206 ['--export', '-'],
3207 catch_exceptions=False,
3208 )
3209 with cli_helpers.config_filename(subsystem='vault').open( 1D
3210 encoding='UTF-8'
3211 ) as infile:
3212 config2 = json.load(infile) 1D
3213 assert result.clean_exit(empty_stderr=False), 'expected clean exit' 1D
3214 assert config2 == config, 'config not imported correctly' 1D
3215 assert not result.stderr or all( # pragma: no branch 1D
3216 map(is_harmless_config_import_warning, caplog.record_tuples)
3217 ), 'unexpected error output'
3218 assert_vault_config_is_indented_and_line_broken(result.stdout) 1D
3220 @Parametrize.EXPORT_FORMAT_OPTIONS
3221 def test_214a_export_settings_no_stored_settings(
3222 self,
3223 export_options: list[str],
3224 ) -> None:
3225 """Exporting the default, empty config works."""
3226 runner = tests.CliRunner(mix_stderr=False) 1:
3227 # TODO(the-13th-letter): Rewrite using parenthesized
3228 # with-statements.
3229 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3230 with contextlib.ExitStack() as stack: 1:
3231 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1:
3232 stack.enter_context( 1:
3233 tests.isolated_config(
3234 monkeypatch=monkeypatch,
3235 runner=runner,
3236 )
3237 )
3238 cli_helpers.config_filename(subsystem='vault').unlink( 1:
3239 missing_ok=True
3240 )
3241 result = runner.invoke( 1:
3242 # Test parent context navigation by not calling
3243 # `cli.derivepassphrase_vault` directly. Used e.g. in
3244 # the `--export-as=sh` section to autoconstruct the
3245 # program name correctly.
3246 cli.derivepassphrase,
3247 ['vault', '--export', '-', *export_options],
3248 catch_exceptions=False,
3249 )
3250 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1:
3252 @Parametrize.EXPORT_FORMAT_OPTIONS
3253 def test_214b_export_settings_bad_stored_config(
3254 self,
3255 export_options: list[str],
3256 ) -> None:
3257 """Exporting an invalid config fails."""
3258 runner = tests.CliRunner(mix_stderr=False) 2bb
3259 # TODO(the-13th-letter): Rewrite using parenthesized
3260 # with-statements.
3261 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3262 with contextlib.ExitStack() as stack: 2bb
3263 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2bb
3264 stack.enter_context( 2bb
3265 tests.isolated_vault_config(
3266 monkeypatch=monkeypatch,
3267 runner=runner,
3268 vault_config={},
3269 )
3270 )
3271 result = runner.invoke( 2bb
3272 cli.derivepassphrase_vault,
3273 ['--export', '-', *export_options],
3274 input='null',
3275 catch_exceptions=False,
3276 )
3277 assert result.error_exit(error='Cannot load vault settings:'), ( 2bb
3278 'expected error exit and known error message'
3279 )
3281 @Parametrize.EXPORT_FORMAT_OPTIONS
3282 def test_214c_export_settings_not_a_file(
3283 self,
3284 export_options: list[str],
3285 ) -> None:
3286 """Exporting an invalid config fails."""
3287 runner = tests.CliRunner(mix_stderr=False) 17
3288 # TODO(the-13th-letter): Rewrite using parenthesized
3289 # with-statements.
3290 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3291 with contextlib.ExitStack() as stack: 17
3292 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 17
3293 stack.enter_context( 17
3294 tests.isolated_config(
3295 monkeypatch=monkeypatch,
3296 runner=runner,
3297 )
3298 )
3299 config_file = cli_helpers.config_filename(subsystem='vault') 17
3300 config_file.unlink(missing_ok=True) 17
3301 config_file.mkdir(parents=True, exist_ok=True) 17
3302 result = runner.invoke( 17
3303 cli.derivepassphrase_vault,
3304 ['--export', '-', *export_options],
3305 input='null',
3306 catch_exceptions=False,
3307 )
3308 assert result.error_exit(error='Cannot load vault settings:'), ( 17
3309 'expected error exit and known error message'
3310 )
3312 @Parametrize.EXPORT_FORMAT_OPTIONS
3313 def test_214d_export_settings_target_not_a_file(
3314 self,
3315 export_options: list[str],
3316 ) -> None:
3317 """Exporting an invalid config fails."""
3318 runner = tests.CliRunner(mix_stderr=False) 1;
3319 # TODO(the-13th-letter): Rewrite using parenthesized
3320 # with-statements.
3321 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3322 with contextlib.ExitStack() as stack: 1;
3323 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1;
3324 stack.enter_context( 1;
3325 tests.isolated_config(
3326 monkeypatch=monkeypatch,
3327 runner=runner,
3328 )
3329 )
3330 dname = cli_helpers.config_filename(subsystem=None) 1;
3331 result = runner.invoke( 1;
3332 cli.derivepassphrase_vault,
3333 ['--export', os.fsdecode(dname), *export_options],
3334 input='null',
3335 catch_exceptions=False,
3336 )
3337 assert result.error_exit(error='Cannot export vault settings:'), ( 1;
3338 'expected error exit and known error message'
3339 )
3341 @tests.skip_if_on_the_annoying_os
3342 @Parametrize.EXPORT_FORMAT_OPTIONS
3343 def test_214e_export_settings_settings_directory_not_a_directory(
3344 self,
3345 export_options: list[str],
3346 ) -> None:
3347 """Exporting an invalid config fails."""
3348 runner = tests.CliRunner(mix_stderr=False)
3349 # TODO(the-13th-letter): Rewrite using parenthesized
3350 # with-statements.
3351 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3352 with contextlib.ExitStack() as stack:
3353 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3354 stack.enter_context(
3355 tests.isolated_config(
3356 monkeypatch=monkeypatch,
3357 runner=runner,
3358 )
3359 )
3360 config_dir = cli_helpers.config_filename(subsystem=None)
3361 with contextlib.suppress(FileNotFoundError):
3362 shutil.rmtree(config_dir)
3363 config_dir.write_text('Obstruction!!\n')
3364 result = runner.invoke(
3365 cli.derivepassphrase_vault,
3366 ['--export', '-', *export_options],
3367 input='null',
3368 catch_exceptions=False,
3369 )
3370 assert result.error_exit(
3371 error='Cannot load vault settings:'
3372 ) or result.error_exit(error='Cannot load user config:'), (
3373 'expected error exit and known error message'
3374 )
3376 @Parametrize.NOTES_PLACEMENT
3377 @hypothesis.given( 1aN
3378 notes=strategies.text(
3379 strategies.characters(
3380 min_codepoint=32, max_codepoint=126, include_characters='\n'
3381 ),
3382 min_size=1,
3383 max_size=512,
3384 ).filter(str.strip),
3385 )
3386 def test_215_notes_placement(
3387 self,
3388 notes_placement: Literal['before', 'after'],
3389 placement_args: list[str],
3390 notes: str,
3391 ) -> None:
3392 notes = notes.strip() 1N
3393 maybe_notes = {'notes': notes} if notes else {} 1N
3394 vault_config = { 1N
3395 'global': {'phrase': DUMMY_PASSPHRASE},
3396 'services': {
3397 DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
3398 },
3399 }
3400 result_phrase = DUMMY_RESULT_PASSPHRASE.decode('ascii') 1N
3401 expected = ( 1N
3402 f'{notes}\n\n{result_phrase}\n'
3403 if notes_placement == 'before'
3404 else f'{result_phrase}\n\n{notes}\n\n'
3405 )
3406 runner = tests.CliRunner(mix_stderr=True) 1N
3407 # TODO(the-13th-letter): Rewrite using parenthesized
3408 # with-statements.
3409 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3410 with contextlib.ExitStack() as stack: 1N
3411 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1N
3412 stack.enter_context( 1N
3413 tests.isolated_vault_config(
3414 monkeypatch=monkeypatch,
3415 runner=runner,
3416 vault_config=vault_config,
3417 )
3418 )
3419 result = runner.invoke( 1N
3420 cli.derivepassphrase_vault,
3421 [*placement_args, '--', DUMMY_SERVICE],
3422 catch_exceptions=False,
3423 )
3424 assert result.clean_exit(output=expected), 'expected clean exit' 1N
3426 @Parametrize.MODERN_EDITOR_INTERFACE
3427 @hypothesis.settings( 1ar
3428 suppress_health_check=[
3429 *hypothesis.settings().suppress_health_check,
3430 hypothesis.HealthCheck.function_scoped_fixture,
3431 ],
3432 )
3433 @hypothesis.given(
3434 notes=strategies.text(
3435 strategies.characters(
3436 min_codepoint=32, max_codepoint=126, include_characters='\n'
3437 ),
3438 min_size=1,
3439 max_size=512,
3440 ).filter(str.strip),
3441 )
3442 def test_220_edit_notes_successfully(
3443 self,
3444 caplog: pytest.LogCaptureFixture,
3445 modern_editor_interface: bool,
3446 notes: str,
3447 ) -> None:
3448 """Editing notes works."""
3449 marker = cli_messages.TranslatedString( 1r
3450 cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
3451 )
3452 edit_result = f""" 1r
3454{marker}
3455{notes}
3456"""
3457 # Reset caplog between hypothesis runs.
3458 caplog.clear() 1r
3459 runner = tests.CliRunner(mix_stderr=False) 1r
3460 # TODO(the-13th-letter): Rewrite using parenthesized
3461 # with-statements.
3462 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3463 with contextlib.ExitStack() as stack: 1r
3464 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1r
3465 stack.enter_context( 1r
3466 tests.isolated_vault_config(
3467 monkeypatch=monkeypatch,
3468 runner=runner,
3469 vault_config={
3470 'global': {'phrase': 'abc'},
3471 'services': {'sv': {'notes': 'Contents go here'}},
3472 },
3473 )
3474 )
3475 notes_backup_file = cli_helpers.config_filename( 1r
3476 subsystem='notes backup'
3477 )
3478 notes_backup_file.write_text( 1r
3479 'These backup notes are left over from the previous session.',
3480 encoding='UTF-8',
3481 )
3482 monkeypatch.setattr(click, 'edit', lambda *_a, **_kw: edit_result) 1r
3483 result = runner.invoke( 1r
3484 cli.derivepassphrase_vault,
3485 [
3486 '--config',
3487 '--notes',
3488 '--modern-editor-interface'
3489 if modern_editor_interface
3490 else '--vault-legacy-editor-interface',
3491 '--',
3492 'sv',
3493 ],
3494 catch_exceptions=False,
3495 )
3496 assert result.clean_exit(), 'expected clean exit' 1r
3497 assert all(map(is_warning_line, result.stderr.splitlines(True))) 1r
3498 assert modern_editor_interface or tests.warning_emitted( 1r
3499 'A backup copy of the old notes was saved',
3500 caplog.record_tuples,
3501 ), 'expected known warning message in stderr'
3502 assert ( 1r
3503 modern_editor_interface
3504 or notes_backup_file.read_text(encoding='UTF-8')
3505 == 'Contents go here'
3506 )
3507 with cli_helpers.config_filename(subsystem='vault').open( 1r
3508 encoding='UTF-8'
3509 ) as infile:
3510 config = json.load(infile) 1r
3511 assert config == { 1r
3512 'global': {'phrase': 'abc'},
3513 'services': {
3514 'sv': {
3515 'notes': notes.strip()
3516 if modern_editor_interface
3517 else edit_result.strip()
3518 }
3519 },
3520 }
3522 @Parametrize.NOOP_EDIT_FUNCS
3523 @hypothesis.given( 1aq
3524 notes=strategies.text(
3525 strategies.characters(
3526 min_codepoint=32, max_codepoint=126, include_characters='\n'
3527 ),
3528 min_size=1,
3529 max_size=512,
3530 ).filter(str.strip),
3531 )
3532 def test_221_edit_notes_noop(
3533 self,
3534 edit_func_name: Literal['empty', 'space'],
3535 modern_editor_interface: bool,
3536 notes: str,
3537 ) -> None:
3538 """Abandoning edited notes works."""
3540 def empty(text: str, *_args: Any, **_kwargs: Any) -> str: 1q
3541 del text 1q
3542 return '' 1q
3544 def space(text: str, *_args: Any, **_kwargs: Any) -> str: 1q
3545 del text 1q
3546 return ' ' + notes.strip() + '\n\n\n\n\n\n' 1q
3548 edit_funcs = {'empty': empty, 'space': space} 1q
3549 runner = tests.CliRunner(mix_stderr=False) 1q
3550 # TODO(the-13th-letter): Rewrite using parenthesized
3551 # with-statements.
3552 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3553 with contextlib.ExitStack() as stack: 1q
3554 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1q
3555 stack.enter_context( 1q
3556 tests.isolated_vault_config(
3557 monkeypatch=monkeypatch,
3558 runner=runner,
3559 vault_config={
3560 'global': {'phrase': 'abc'},
3561 'services': {'sv': {'notes': notes.strip()}},
3562 },
3563 )
3564 )
3565 notes_backup_file = cli_helpers.config_filename( 1q
3566 subsystem='notes backup'
3567 )
3568 notes_backup_file.write_text( 1q
3569 'These backup notes are left over from the previous session.',
3570 encoding='UTF-8',
3571 )
3572 monkeypatch.setattr(click, 'edit', edit_funcs[edit_func_name]) 1q
3573 result = runner.invoke( 1q
3574 cli.derivepassphrase_vault,
3575 [
3576 '--config',
3577 '--notes',
3578 '--modern-editor-interface'
3579 if modern_editor_interface
3580 else '--vault-legacy-editor-interface',
3581 '--',
3582 'sv',
3583 ],
3584 catch_exceptions=False,
3585 )
3586 assert result.clean_exit(empty_stderr=True) or result.error_exit( 1q
3587 error='the user aborted the request'
3588 ), 'expected clean exit'
3589 assert ( 1q
3590 modern_editor_interface
3591 or notes_backup_file.read_text(encoding='UTF-8')
3592 == 'These backup notes are left over from the previous session.'
3593 )
3594 with cli_helpers.config_filename(subsystem='vault').open( 1q
3595 encoding='UTF-8'
3596 ) as infile:
3597 config = json.load(infile) 1q
3598 assert config == { 1q
3599 'global': {'phrase': 'abc'},
3600 'services': {'sv': {'notes': notes.strip()}},
3601 }
3603 # TODO(the-13th-letter): Keep this behavior or not, with or without
3604 # warning?
3605 @Parametrize.MODERN_EDITOR_INTERFACE
3606 @hypothesis.settings( 1as
3607 suppress_health_check=[
3608 *hypothesis.settings().suppress_health_check,
3609 hypothesis.HealthCheck.function_scoped_fixture,
3610 ],
3611 )
3612 @hypothesis.given(
3613 notes=strategies.text(
3614 strategies.characters(
3615 min_codepoint=32, max_codepoint=126, include_characters='\n'
3616 ),
3617 min_size=1,
3618 max_size=512,
3619 ).filter(str.strip),
3620 )
3621 def test_222_edit_notes_marker_removed(
3622 self,
3623 caplog: pytest.LogCaptureFixture,
3624 modern_editor_interface: bool,
3625 notes: str,
3626 ) -> None:
3627 """Removing the notes marker still saves the notes.
3629 TODO: Keep this behavior or not, with or without warning?
3631 """
3632 notes_marker = cli_messages.TranslatedString( 1s
3633 cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
3634 )
3635 hypothesis.assume(str(notes_marker) not in notes.strip()) 1s
3636 # Reset caplog between hypothesis runs.
3637 caplog.clear() 1s
3638 runner = tests.CliRunner(mix_stderr=False) 1s
3639 # TODO(the-13th-letter): Rewrite using parenthesized
3640 # with-statements.
3641 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3642 with contextlib.ExitStack() as stack: 1s
3643 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1s
3644 stack.enter_context( 1s
3645 tests.isolated_vault_config(
3646 monkeypatch=monkeypatch,
3647 runner=runner,
3648 vault_config={
3649 'global': {'phrase': 'abc'},
3650 'services': {'sv': {'notes': 'Contents go here'}},
3651 },
3652 )
3653 )
3654 notes_backup_file = cli_helpers.config_filename( 1s
3655 subsystem='notes backup'
3656 )
3657 notes_backup_file.write_text( 1s
3658 'These backup notes are left over from the previous session.',
3659 encoding='UTF-8',
3660 )
3661 monkeypatch.setattr(click, 'edit', lambda *_a, **_kw: notes) 1s
3662 result = runner.invoke( 1s
3663 cli.derivepassphrase_vault,
3664 [
3665 '--config',
3666 '--notes',
3667 '--modern-editor-interface'
3668 if modern_editor_interface
3669 else '--vault-legacy-editor-interface',
3670 '--',
3671 'sv',
3672 ],
3673 catch_exceptions=False,
3674 )
3675 assert result.clean_exit(), 'expected clean exit' 1s
3676 assert not result.stderr or all( 1s
3677 map(is_warning_line, result.stderr.splitlines(True))
3678 )
3679 assert not caplog.record_tuples or tests.warning_emitted( 1s
3680 'A backup copy of the old notes was saved',
3681 caplog.record_tuples,
3682 ), 'expected known warning message in stderr'
3683 assert ( 1s
3684 modern_editor_interface
3685 or notes_backup_file.read_text(encoding='UTF-8')
3686 == 'Contents go here'
3687 )
3688 with cli_helpers.config_filename(subsystem='vault').open( 1s
3689 encoding='UTF-8'
3690 ) as infile:
3691 config = json.load(infile) 1s
3692 assert config == { 1s
3693 'global': {'phrase': 'abc'},
3694 'services': {'sv': {'notes': notes.strip()}},
3695 }
3697 @hypothesis.given(
3698 notes=strategies.text(
3699 strategies.characters(
3700 min_codepoint=32, max_codepoint=126, include_characters='\n'
3701 ),
3702 min_size=1,
3703 max_size=512,
3704 ).filter(str.strip),
3705 )
3706 def test_223_edit_notes_abort(
3707 self,
3708 notes: str,
3709 ) -> None:
3710 """Aborting editing notes works.
3712 Aborting is only supported with the modern editor interface.
3714 """
3715 runner = tests.CliRunner(mix_stderr=False) 1X
3716 # TODO(the-13th-letter): Rewrite using parenthesized
3717 # with-statements.
3718 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3719 with contextlib.ExitStack() as stack: 1X
3720 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1X
3721 stack.enter_context( 1X
3722 tests.isolated_vault_config(
3723 monkeypatch=monkeypatch,
3724 runner=runner,
3725 vault_config={
3726 'global': {'phrase': 'abc'},
3727 'services': {'sv': {'notes': notes.strip()}},
3728 },
3729 )
3730 )
3731 monkeypatch.setattr(click, 'edit', lambda *_a, **_kw: '') 1X
3732 result = runner.invoke( 1X
3733 cli.derivepassphrase_vault,
3734 [
3735 '--config',
3736 '--notes',
3737 '--modern-editor-interface',
3738 '--',
3739 'sv',
3740 ],
3741 catch_exceptions=False,
3742 )
3743 assert result.error_exit(error='the user aborted the request'), ( 1X
3744 'expected known error message'
3745 )
3746 with cli_helpers.config_filename(subsystem='vault').open( 1X
3747 encoding='UTF-8'
3748 ) as infile:
3749 config = json.load(infile) 1X
3750 assert config == { 1X
3751 'global': {'phrase': 'abc'},
3752 'services': {'sv': {'notes': notes.strip()}},
3753 }
3755 def test_223a_edit_empty_notes_abort(
3756 self,
3757 ) -> None:
3758 """Aborting editing notes works even if no notes are stored yet.
3760 Aborting is only supported with the modern editor interface.
3762 """
3763 runner = tests.CliRunner(mix_stderr=False) 1Y
3764 # TODO(the-13th-letter): Rewrite using parenthesized
3765 # with-statements.
3766 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3767 with contextlib.ExitStack() as stack: 1Y
3768 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1Y
3769 stack.enter_context( 1Y
3770 tests.isolated_vault_config(
3771 monkeypatch=monkeypatch,
3772 runner=runner,
3773 vault_config={
3774 'global': {'phrase': 'abc'},
3775 'services': {},
3776 },
3777 )
3778 )
3779 monkeypatch.setattr(click, 'edit', lambda *_a, **_kw: '') 1Y
3780 result = runner.invoke( 1Y
3781 cli.derivepassphrase_vault,
3782 [
3783 '--config',
3784 '--notes',
3785 '--modern-editor-interface',
3786 '--',
3787 'sv',
3788 ],
3789 catch_exceptions=False,
3790 )
3791 assert result.error_exit(error='the user aborted the request'), ( 1Y
3792 'expected known error message'
3793 )
3794 with cli_helpers.config_filename(subsystem='vault').open( 1Y
3795 encoding='UTF-8'
3796 ) as infile:
3797 config = json.load(infile) 1Y
3798 assert config == { 1Y
3799 'global': {'phrase': 'abc'},
3800 'services': {},
3801 }
3803 @Parametrize.MODERN_EDITOR_INTERFACE
3804 @hypothesis.settings( 1ao
3805 suppress_health_check=[
3806 *hypothesis.settings().suppress_health_check,
3807 hypothesis.HealthCheck.function_scoped_fixture,
3808 ],
3809 )
3810 @hypothesis.given(
3811 notes=strategies.text(
3812 strategies.characters(
3813 min_codepoint=32, max_codepoint=126, include_characters='\n'
3814 ),
3815 max_size=512,
3816 ),
3817 )
3818 def test_223b_edit_notes_fail_config_option_missing(
3819 self,
3820 caplog: pytest.LogCaptureFixture,
3821 modern_editor_interface: bool,
3822 notes: str,
3823 ) -> None:
3824 """Editing notes fails (and warns) if `--config` is missing."""
3825 maybe_notes = {'notes': notes.strip()} if notes.strip() else {} 1o
3826 vault_config = { 1o
3827 'global': {'phrase': DUMMY_PASSPHRASE},
3828 'services': {
3829 DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
3830 },
3831 }
3832 # Reset caplog between hypothesis runs.
3833 caplog.clear() 1o
3834 runner = tests.CliRunner(mix_stderr=False) 1o
3835 # TODO(the-13th-letter): Rewrite using parenthesized
3836 # with-statements.
3837 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3838 with contextlib.ExitStack() as stack: 1o
3839 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1o
3840 stack.enter_context( 1o
3841 tests.isolated_vault_config(
3842 monkeypatch=monkeypatch,
3843 runner=runner,
3844 vault_config=vault_config,
3845 )
3846 )
3847 EDIT_ATTEMPTED = 'edit attempted!' # noqa: N806 1o
3849 def raiser(*_args: Any, **_kwargs: Any) -> NoReturn: 1o
3850 pytest.fail(EDIT_ATTEMPTED)
3852 notes_backup_file = cli_helpers.config_filename( 1o
3853 subsystem='notes backup'
3854 )
3855 notes_backup_file.write_text( 1o
3856 'These backup notes are left over from the previous session.',
3857 encoding='UTF-8',
3858 )
3859 monkeypatch.setattr(click, 'edit', raiser) 1o
3860 result = runner.invoke( 1o
3861 cli.derivepassphrase_vault,
3862 [
3863 '--notes',
3864 '--modern-editor-interface'
3865 if modern_editor_interface
3866 else '--vault-legacy-editor-interface',
3867 '--',
3868 DUMMY_SERVICE,
3869 ],
3870 catch_exceptions=False,
3871 )
3872 assert result.clean_exit( 1o
3873 output=DUMMY_RESULT_PASSPHRASE.decode('ascii')
3874 ), 'expected clean exit'
3875 assert result.stderr 1o
3876 assert notes.strip() in result.stderr 1o
3877 assert all( 1o
3878 is_warning_line(line)
3879 for line in result.stderr.splitlines(True)
3880 if line.startswith(f'{cli.PROG_NAME}: ')
3881 )
3882 assert tests.warning_emitted( 1o
3883 'Specifying --notes without --config is ineffective. '
3884 'No notes will be edited.',
3885 caplog.record_tuples,
3886 ), 'expected known warning message in stderr'
3887 assert ( 1o
3888 modern_editor_interface
3889 or notes_backup_file.read_text(encoding='UTF-8')
3890 == 'These backup notes are left over from the previous session.'
3891 )
3892 with cli_helpers.config_filename(subsystem='vault').open( 1o
3893 encoding='UTF-8'
3894 ) as infile:
3895 config = json.load(infile) 1o
3896 assert config == vault_config 1o
3898 @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG
3899 def test_224_store_config_good(
3900 self,
3901 command_line: list[str],
3902 input: str,
3903 result_config: Any,
3904 ) -> None:
3905 """Storing valid settings via `--config` works.
3907 The format also contains embedded newlines and indentation to make
3908 the config more readable.
3910 """
3911 runner = tests.CliRunner(mix_stderr=False) 1F
3912 # TODO(the-13th-letter): Rewrite using parenthesized
3913 # with-statements.
3914 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3915 with contextlib.ExitStack() as stack: 1F
3916 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1F
3917 stack.enter_context( 1F
3918 tests.isolated_vault_config(
3919 monkeypatch=monkeypatch,
3920 runner=runner,
3921 vault_config={'global': {'phrase': 'abc'}, 'services': {}},
3922 )
3923 )
3924 monkeypatch.setattr( 1F
3925 cli_helpers, 'get_suitable_ssh_keys', tests.suitable_ssh_keys
3926 )
3927 result = runner.invoke( 1F
3928 cli.derivepassphrase_vault,
3929 ['--config', *command_line],
3930 catch_exceptions=False,
3931 input=input,
3932 )
3933 assert result.clean_exit(), 'expected clean exit' 1F
3934 config_txt = cli_helpers.config_filename( 1F
3935 subsystem='vault'
3936 ).read_text(encoding='UTF-8')
3937 config = json.loads(config_txt) 1F
3938 assert config == result_config, ( 1F
3939 'stored config does not match expectation'
3940 )
3941 assert_vault_config_is_indented_and_line_broken(config_txt) 1F
3943 @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES
3944 def test_225_store_config_fail(
3945 self,
3946 command_line: list[str],
3947 input: str,
3948 err_text: str,
3949 ) -> None:
3950 """Storing invalid settings via `--config` fails."""
3951 runner = tests.CliRunner(mix_stderr=False) 1=
3952 # TODO(the-13th-letter): Rewrite using parenthesized
3953 # with-statements.
3954 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3955 with contextlib.ExitStack() as stack: 1=
3956 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1=
3957 stack.enter_context( 1=
3958 tests.isolated_vault_config(
3959 monkeypatch=monkeypatch,
3960 runner=runner,
3961 vault_config={'global': {'phrase': 'abc'}, 'services': {}},
3962 )
3963 )
3964 monkeypatch.setattr( 1=
3965 cli_helpers, 'get_suitable_ssh_keys', tests.suitable_ssh_keys
3966 )
3967 result = runner.invoke( 1=
3968 cli.derivepassphrase_vault,
3969 ['--config', *command_line],
3970 catch_exceptions=False,
3971 input=input,
3972 )
3973 assert result.error_exit(error=err_text), ( 1=
3974 'expected error exit and known error message'
3975 )
3977 # NOTE: test_225a_store_config_fail_manual_no_ssh_key_selection was
3978 # removed, because the code path it tested -- catching error returns
3979 # from cli_helpers.select_ssh_key -- no longer exists: the error
3980 # checks are now *inside* the select_ssh_key call, and are already
3981 # tested elsewhere.
3982 #
3983 # Original docstring: "Not selecting an SSH key during `--config
3984 # --key` fails."
3986 def test_225b_store_config_fail_manual_no_ssh_agent(
3987 self,
3988 running_ssh_agent: tests.RunningSSHAgentInfo,
3989 ) -> None:
3990 """Not running an SSH agent during `--config --key` fails."""
3991 del running_ssh_agent 1%
3992 runner = tests.CliRunner(mix_stderr=False) 1%
3993 # TODO(the-13th-letter): Rewrite using parenthesized
3994 # with-statements.
3995 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3996 with contextlib.ExitStack() as stack: 1%
3997 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1%
3998 stack.enter_context( 1%
3999 tests.isolated_vault_config(
4000 monkeypatch=monkeypatch,
4001 runner=runner,
4002 vault_config={'global': {'phrase': 'abc'}, 'services': {}},
4003 )
4004 )
4005 monkeypatch.delenv('SSH_AUTH_SOCK', raising=False) 1%
4006 result = runner.invoke( 1%
4007 cli.derivepassphrase_vault,
4008 ['--key', '--config'],
4009 catch_exceptions=False,
4010 )
4011 assert result.error_exit(error='Cannot find any running SSH agent'), ( 1%
4012 'expected error exit and known error message'
4013 )
4015 def test_225c_store_config_fail_manual_bad_ssh_agent_connection(
4016 self,
4017 running_ssh_agent: tests.RunningSSHAgentInfo,
4018 ) -> None:
4019 """Not running a reachable SSH agent during `--config --key` fails."""
4020 running_ssh_agent.require_external_address() 2lb
4021 runner = tests.CliRunner(mix_stderr=False)
4022 # TODO(the-13th-letter): Rewrite using parenthesized
4023 # with-statements.
4024 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4025 with contextlib.ExitStack() as stack:
4026 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4027 stack.enter_context(
4028 tests.isolated_vault_config(
4029 monkeypatch=monkeypatch,
4030 runner=runner,
4031 vault_config={'global': {'phrase': 'abc'}, 'services': {}},
4032 )
4033 )
4034 cwd = pathlib.Path.cwd().resolve()
4035 monkeypatch.setenv('SSH_AUTH_SOCK', str(cwd))
4036 result = runner.invoke(
4037 cli.derivepassphrase_vault,
4038 ['--key', '--config'],
4039 catch_exceptions=False,
4040 )
4041 assert result.error_exit(error='Cannot connect to the SSH agent'), (
4042 'expected error exit and known error message'
4043 )
4045 @Parametrize.TRY_RACE_FREE_IMPLEMENTATION
4046 def test_225d_store_config_fail_manual_read_only_file(
4047 self,
4048 try_race_free_implementation: bool,
4049 ) -> None:
4050 """Using a read-only configuration file with `--config` fails."""
4051 runner = tests.CliRunner(mix_stderr=False) 1?
4052 # TODO(the-13th-letter): Rewrite using parenthesized
4053 # with-statements.
4054 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4055 with contextlib.ExitStack() as stack: 1?
4056 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1?
4057 stack.enter_context( 1?
4058 tests.isolated_vault_config(
4059 monkeypatch=monkeypatch,
4060 runner=runner,
4061 vault_config={'global': {'phrase': 'abc'}, 'services': {}},
4062 )
4063 )
4064 tests.make_file_readonly( 1?
4065 cli_helpers.config_filename(subsystem='vault'),
4066 try_race_free_implementation=try_race_free_implementation,
4067 )
4068 result = runner.invoke( 1?
4069 cli.derivepassphrase_vault,
4070 ['--config', '--length=15', '--', DUMMY_SERVICE],
4071 catch_exceptions=False,
4072 )
4073 assert result.error_exit(error='Cannot store vault settings:'), ( 1?
4074 'expected error exit and known error message'
4075 )
4077 def test_225e_store_config_fail_manual_custom_error(
4078 self,
4079 ) -> None:
4080 """OS-erroring with `--config` fails."""
4081 runner = tests.CliRunner(mix_stderr=False) 1S
4082 # TODO(the-13th-letter): Rewrite using parenthesized
4083 # with-statements.
4084 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4085 with contextlib.ExitStack() as stack: 1S
4086 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1S
4087 stack.enter_context( 1S
4088 tests.isolated_vault_config(
4089 monkeypatch=monkeypatch,
4090 runner=runner,
4091 vault_config={'global': {'phrase': 'abc'}, 'services': {}},
4092 )
4093 )
4094 custom_error = 'custom error message' 1S
4096 def raiser(config: Any) -> None: 1S
4097 del config 1S
4098 raise RuntimeError(custom_error) 1S
4100 monkeypatch.setattr(cli_helpers, 'save_config', raiser) 1S
4101 result = runner.invoke( 1S
4102 cli.derivepassphrase_vault,
4103 ['--config', '--length=15', '--', DUMMY_SERVICE],
4104 catch_exceptions=False,
4105 )
4106 assert result.error_exit(error=custom_error), ( 1S
4107 'expected error exit and known error message'
4108 )
4110 def test_225f_store_config_fail_unset_and_set_same_settings(
4111 self,
4112 ) -> None:
4113 """Issuing conflicting settings to `--config` fails."""
4114 runner = tests.CliRunner(mix_stderr=False) 2cb
4115 # TODO(the-13th-letter): Rewrite using parenthesized
4116 # with-statements.
4117 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4118 with contextlib.ExitStack() as stack: 2cb
4119 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2cb
4120 stack.enter_context( 2cb
4121 tests.isolated_vault_config(
4122 monkeypatch=monkeypatch,
4123 runner=runner,
4124 vault_config={'global': {'phrase': 'abc'}, 'services': {}},
4125 )
4126 )
4127 result = runner.invoke( 2cb
4128 cli.derivepassphrase_vault,
4129 [
4130 '--config',
4131 '--unset=length',
4132 '--length=15',
4133 '--',
4134 DUMMY_SERVICE,
4135 ],
4136 catch_exceptions=False,
4137 )
4138 assert result.error_exit( 2cb
4139 error='Attempted to unset and set --length at the same time.'
4140 ), 'expected error exit and known error message'
4142 def test_225g_store_config_fail_manual_ssh_agent_no_keys_loaded(
4143 self,
4144 running_ssh_agent: tests.RunningSSHAgentInfo,
4145 ) -> None:
4146 """Not holding any SSH keys during `--config --key` fails."""
4147 del running_ssh_agent 1Z
4148 runner = tests.CliRunner(mix_stderr=False) 1Z
4149 # TODO(the-13th-letter): Rewrite using parenthesized
4150 # with-statements.
4151 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4152 with contextlib.ExitStack() as stack: 1Z
4153 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1Z
4154 stack.enter_context( 1Z
4155 tests.isolated_vault_config(
4156 monkeypatch=monkeypatch,
4157 runner=runner,
4158 vault_config={'global': {'phrase': 'abc'}, 'services': {}},
4159 )
4160 )
4162 def func( 1Z
4163 *_args: Any,
4164 **_kwargs: Any,
4165 ) -> list[_types.SSHKeyCommentPair]:
4166 return [] 1Z
4168 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'list_keys', func) 1Z
4169 result = runner.invoke( 1Z
4170 cli.derivepassphrase_vault,
4171 ['--key', '--config'],
4172 catch_exceptions=False,
4173 )
4174 assert result.error_exit(error='no keys suitable'), ( 1Z
4175 'expected error exit and known error message'
4176 )
4178 def test_225h_store_config_fail_manual_ssh_agent_runtime_error(
4179 self,
4180 running_ssh_agent: tests.RunningSSHAgentInfo,
4181 ) -> None:
4182 """The SSH agent erroring during `--config --key` fails."""
4183 del running_ssh_agent 10
4184 runner = tests.CliRunner(mix_stderr=False) 10
4185 # TODO(the-13th-letter): Rewrite using parenthesized
4186 # with-statements.
4187 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4188 with contextlib.ExitStack() as stack: 10
4189 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 10
4190 stack.enter_context( 10
4191 tests.isolated_vault_config(
4192 monkeypatch=monkeypatch,
4193 runner=runner,
4194 vault_config={'global': {'phrase': 'abc'}, 'services': {}},
4195 )
4196 )
4198 def raiser(*_args: Any, **_kwargs: Any) -> None: 10
4199 raise ssh_agent.TrailingDataError() 10
4201 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'list_keys', raiser) 10
4202 result = runner.invoke( 10
4203 cli.derivepassphrase_vault,
4204 ['--key', '--config'],
4205 catch_exceptions=False,
4206 )
4207 assert result.error_exit( 10
4208 error='violates the communication protocol.'
4209 ), 'expected error exit and known error message'
4211 def test_225i_store_config_fail_manual_ssh_agent_refuses(
4212 self,
4213 running_ssh_agent: tests.RunningSSHAgentInfo,
4214 ) -> None:
4215 """The SSH agent refusing during `--config --key` fails."""
4216 del running_ssh_agent 11
4217 runner = tests.CliRunner(mix_stderr=False) 11
4218 # TODO(the-13th-letter): Rewrite using parenthesized
4219 # with-statements.
4220 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4221 with contextlib.ExitStack() as stack: 11
4222 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 11
4223 stack.enter_context( 11
4224 tests.isolated_vault_config(
4225 monkeypatch=monkeypatch,
4226 runner=runner,
4227 vault_config={'global': {'phrase': 'abc'}, 'services': {}},
4228 )
4229 )
4231 def func(*_args: Any, **_kwargs: Any) -> NoReturn: 11
4232 raise ssh_agent.SSHAgentFailedError( 11
4233 _types.SSH_AGENT.FAILURE, b''
4234 )
4236 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'list_keys', func) 11
4237 result = runner.invoke( 11
4238 cli.derivepassphrase_vault,
4239 ['--key', '--config'],
4240 catch_exceptions=False,
4241 )
4242 assert result.error_exit(error='refused to'), ( 11
4243 'expected error exit and known error message'
4244 )
4246 def test_226_no_arguments(self) -> None:
4247 """Calling `derivepassphrase vault` without any arguments fails."""
4248 runner = tests.CliRunner(mix_stderr=False) 2db
4249 # TODO(the-13th-letter): Rewrite using parenthesized
4250 # with-statements.
4251 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4252 with contextlib.ExitStack() as stack: 2db
4253 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2db
4254 stack.enter_context( 2db
4255 tests.isolated_config(
4256 monkeypatch=monkeypatch,
4257 runner=runner,
4258 )
4259 )
4260 result = runner.invoke( 2db
4261 cli.derivepassphrase_vault, [], catch_exceptions=False
4262 )
4263 assert result.error_exit( 2db
4264 error='Deriving a passphrase requires a SERVICE'
4265 ), 'expected error exit and known error message'
4267 def test_226a_no_passphrase_or_key(
4268 self,
4269 ) -> None:
4270 """Deriving a passphrase without a passphrase or key fails."""
4271 runner = tests.CliRunner(mix_stderr=False) 2eb
4272 # TODO(the-13th-letter): Rewrite using parenthesized
4273 # with-statements.
4274 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4275 with contextlib.ExitStack() as stack: 2eb
4276 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2eb
4277 stack.enter_context( 2eb
4278 tests.isolated_config(
4279 monkeypatch=monkeypatch,
4280 runner=runner,
4281 )
4282 )
4283 result = runner.invoke( 2eb
4284 cli.derivepassphrase_vault,
4285 ['--', DUMMY_SERVICE],
4286 catch_exceptions=False,
4287 )
4288 assert result.error_exit(error='No passphrase or key was given'), ( 2eb
4289 'expected error exit and known error message'
4290 )
4292 def test_230_config_directory_nonexistant(
4293 self,
4294 ) -> None:
4295 """Running without an existing config directory works.
4297 This is a regression test; see [issue\u00a0#6][] for context.
4299 [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6
4301 """
4302 runner = tests.CliRunner(mix_stderr=False) 1O
4303 # TODO(the-13th-letter): Rewrite using parenthesized
4304 # with-statements.
4305 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4306 with contextlib.ExitStack() as stack: 1O
4307 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1O
4308 stack.enter_context( 1O
4309 tests.isolated_config(
4310 monkeypatch=monkeypatch,
4311 runner=runner,
4312 )
4313 )
4314 with contextlib.suppress(FileNotFoundError): 1O
4315 shutil.rmtree(cli_helpers.config_filename(subsystem=None)) 1O
4316 result = runner.invoke( 1O
4317 cli.derivepassphrase_vault,
4318 ['--config', '-p'],
4319 catch_exceptions=False,
4320 input='abc\n',
4321 )
4322 assert result.clean_exit(), 'expected clean exit' 1O
4323 assert result.stderr == 'Passphrase:', ( 1O
4324 'program unexpectedly failed?!'
4325 )
4326 with cli_helpers.config_filename(subsystem='vault').open( 1O
4327 encoding='UTF-8'
4328 ) as infile:
4329 config_readback = json.load(infile) 1O
4330 assert config_readback == { 1O
4331 'global': {'phrase': 'abc'},
4332 'services': {},
4333 }, 'config mismatch'
4335 def test_230a_config_directory_not_a_file(
4336 self,
4337 ) -> None:
4338 """Erroring without an existing config directory errors normally.
4340 That is, the missing configuration directory does not cause any
4341 errors by itself.
4343 This is a regression test; see [issue\u00a0#6][] for context.
4345 [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6
4347 """
4348 runner = tests.CliRunner(mix_stderr=False) 1E
4349 # TODO(the-13th-letter): Rewrite using parenthesized
4350 # with-statements.
4351 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4352 with contextlib.ExitStack() as stack: 1E
4353 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1E
4354 stack.enter_context( 1E
4355 tests.isolated_config(
4356 monkeypatch=monkeypatch,
4357 runner=runner,
4358 )
4359 )
4360 save_config_ = cli_helpers.save_config 1E
4362 def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any: 1E
4363 config_dir = cli_helpers.config_filename(subsystem=None) 1E
4364 with contextlib.suppress(FileNotFoundError): 1E
4365 shutil.rmtree(config_dir) 1E
4366 config_dir.write_text('Obstruction!!\n') 1E
4367 monkeypatch.setattr(cli_helpers, 'save_config', save_config_) 1E
4368 return save_config_(*args, **kwargs) 1E
4370 monkeypatch.setattr( 1E
4371 cli_helpers, 'save_config', obstruct_config_saving
4372 )
4373 result = runner.invoke( 1E
4374 cli.derivepassphrase_vault,
4375 ['--config', '-p'],
4376 catch_exceptions=False,
4377 input='abc\n',
4378 )
4379 assert result.error_exit(error='Cannot store vault settings:'), ( 1E
4380 'expected error exit and known error message'
4381 )
4383 def test_230b_store_config_custom_error(
4384 self,
4385 ) -> None:
4386 """Storing the configuration reacts even to weird errors."""
4387 runner = tests.CliRunner(mix_stderr=False) 1T
4388 # TODO(the-13th-letter): Rewrite using parenthesized
4389 # with-statements.
4390 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4391 with contextlib.ExitStack() as stack: 1T
4392 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1T
4393 stack.enter_context( 1T
4394 tests.isolated_config(
4395 monkeypatch=monkeypatch,
4396 runner=runner,
4397 )
4398 )
4399 custom_error = 'custom error message' 1T
4401 def raiser(config: Any) -> None: 1T
4402 del config 1T
4403 raise RuntimeError(custom_error) 1T
4405 monkeypatch.setattr(cli_helpers, 'save_config', raiser) 1T
4406 result = runner.invoke( 1T
4407 cli.derivepassphrase_vault,
4408 ['--config', '-p'],
4409 catch_exceptions=False,
4410 input='abc\n',
4411 )
4412 assert result.error_exit(error=custom_error), ( 1T
4413 'expected error exit and known error message'
4414 )
4416 @Parametrize.UNICODE_NORMALIZATION_WARNING_INPUTS
4417 def test_300_unicode_normalization_form_warning(
4418 self,
4419 caplog: pytest.LogCaptureFixture,
4420 main_config: str,
4421 command_line: list[str],
4422 input: str | None,
4423 warning_message: str,
4424 ) -> None:
4425 """Using unnormalized Unicode passphrases warns."""
4426 runner = tests.CliRunner(mix_stderr=False) 1@
4427 # TODO(the-13th-letter): Rewrite using parenthesized
4428 # with-statements.
4429 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4430 with contextlib.ExitStack() as stack: 1@
4431 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1@
4432 stack.enter_context( 1@
4433 tests.isolated_vault_config(
4434 monkeypatch=monkeypatch,
4435 runner=runner,
4436 vault_config={
4437 'services': {
4438 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
4439 }
4440 },
4441 main_config_str=main_config,
4442 )
4443 )
4444 result = runner.invoke( 1@
4445 cli.derivepassphrase_vault,
4446 ['--debug', *command_line],
4447 catch_exceptions=False,
4448 input=input,
4449 )
4450 assert result.clean_exit(), 'expected clean exit' 1@
4451 assert tests.warning_emitted(warning_message, caplog.record_tuples), ( 1@
4452 'expected known warning message in stderr'
4453 )
4455 @Parametrize.UNICODE_NORMALIZATION_ERROR_INPUTS
4456 def test_301_unicode_normalization_form_error(
4457 self,
4458 main_config: str,
4459 command_line: list[str],
4460 input: str | None,
4461 error_message: str,
4462 ) -> None:
4463 """Using unknown Unicode normalization forms fails."""
4464 runner = tests.CliRunner(mix_stderr=False) 1[
4465 # TODO(the-13th-letter): Rewrite using parenthesized
4466 # with-statements.
4467 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4468 with contextlib.ExitStack() as stack: 1[
4469 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1[
4470 stack.enter_context( 1[
4471 tests.isolated_vault_config(
4472 monkeypatch=monkeypatch,
4473 runner=runner,
4474 vault_config={
4475 'services': {
4476 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
4477 }
4478 },
4479 main_config_str=main_config,
4480 )
4481 )
4482 result = runner.invoke( 1[
4483 cli.derivepassphrase_vault,
4484 command_line,
4485 catch_exceptions=False,
4486 input=input,
4487 )
4488 assert result.error_exit( 1[
4489 error='The user configuration file is invalid.'
4490 ), 'expected error exit and known error message'
4491 assert result.error_exit(error=error_message), ( 1[
4492 'expected error exit and known error message'
4493 )
4495 @Parametrize.UNICODE_NORMALIZATION_COMMAND_LINES
4496 def test_301a_unicode_normalization_form_error_from_stored_config(
4497 self,
4498 command_line: list[str],
4499 ) -> None:
4500 """Using unknown Unicode normalization forms in the config fails."""
4501 runner = tests.CliRunner(mix_stderr=False) 1]
4502 # TODO(the-13th-letter): Rewrite using parenthesized
4503 # with-statements.
4504 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4505 with contextlib.ExitStack() as stack: 1]
4506 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1]
4507 stack.enter_context( 1]
4508 tests.isolated_vault_config(
4509 monkeypatch=monkeypatch,
4510 runner=runner,
4511 vault_config={
4512 'services': {
4513 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
4514 }
4515 },
4516 main_config_str=(
4517 "[vault]\ndefault-unicode-normalization-form = 'XXX'\n"
4518 ),
4519 )
4520 )
4521 result = runner.invoke( 1]
4522 cli.derivepassphrase_vault,
4523 command_line,
4524 input=DUMMY_PASSPHRASE,
4525 catch_exceptions=False,
4526 )
4527 assert result.error_exit( 1]
4528 error='The user configuration file is invalid.'
4529 ), 'expected error exit and known error message'
4530 assert result.error_exit( 1]
4531 error=(
4532 "Invalid value 'XXX' for config key "
4533 'vault.default-unicode-normalization-form'
4534 ),
4535 ), 'expected error exit and known error message'
4537 def test_310_bad_user_config_file(
4538 self,
4539 ) -> None:
4540 """Loading a user configuration file in an invalid format fails."""
4541 runner = tests.CliRunner(mix_stderr=False) 2fb
4542 # TODO(the-13th-letter): Rewrite using parenthesized
4543 # with-statements.
4544 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4545 with contextlib.ExitStack() as stack: 2fb
4546 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2fb
4547 stack.enter_context( 2fb
4548 tests.isolated_vault_config(
4549 monkeypatch=monkeypatch,
4550 runner=runner,
4551 vault_config={'services': {}},
4552 main_config_str='This file is not valid TOML.\n',
4553 )
4554 )
4555 result = runner.invoke( 2fb
4556 cli.derivepassphrase_vault,
4557 ['--phrase', '--', DUMMY_SERVICE],
4558 input=DUMMY_PASSPHRASE,
4559 catch_exceptions=False,
4560 )
4561 assert result.error_exit(error='Cannot load user config:'), ( 2fb
4562 'expected error exit and known error message'
4563 )
4565 def test_311_bad_user_config_is_a_directory(
4566 self,
4567 ) -> None:
4568 """Loading a user configuration file in an invalid format fails."""
4569 runner = tests.CliRunner(mix_stderr=False) 18
4570 # TODO(the-13th-letter): Rewrite using parenthesized
4571 # with-statements.
4572 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4573 with contextlib.ExitStack() as stack: 18
4574 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 18
4575 stack.enter_context( 18
4576 tests.isolated_vault_config(
4577 monkeypatch=monkeypatch,
4578 runner=runner,
4579 vault_config={'services': {}},
4580 main_config_str='',
4581 )
4582 )
4583 user_config = cli_helpers.config_filename( 18
4584 subsystem='user configuration'
4585 )
4586 user_config.unlink() 18
4587 user_config.mkdir(parents=True, exist_ok=True) 18
4588 result = runner.invoke( 18
4589 cli.derivepassphrase_vault,
4590 ['--phrase', '--', DUMMY_SERVICE],
4591 input=DUMMY_PASSPHRASE,
4592 catch_exceptions=False,
4593 )
4594 assert result.error_exit(error='Cannot load user config:'), ( 18
4595 'expected error exit and known error message'
4596 )
4598 def test_400_missing_af_unix_support(
4599 self,
4600 caplog: pytest.LogCaptureFixture,
4601 ) -> None:
4602 """Querying the SSH agent without `AF_UNIX` support fails."""
4603 runner = tests.CliRunner(mix_stderr=False) 12
4604 # TODO(the-13th-letter): Rewrite using parenthesized
4605 # with-statements.
4606 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4607 with contextlib.ExitStack() as stack: 12
4608 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 12
4609 stack.enter_context( 12
4610 tests.isolated_vault_config(
4611 monkeypatch=monkeypatch,
4612 runner=runner,
4613 vault_config={'global': {'phrase': 'abc'}, 'services': {}},
4614 )
4615 )
4616 monkeypatch.setenv( 12
4617 'SSH_AUTH_SOCK', "the value doesn't even matter"
4618 )
4619 monkeypatch.setattr( 12
4620 ssh_agent.SSHAgentClient, 'SOCKET_PROVIDERS', ['posix']
4621 )
4622 monkeypatch.delattr(socket, 'AF_UNIX', raising=False) 12
4623 result = runner.invoke( 12
4624 cli.derivepassphrase_vault,
4625 ['--key', '--config'],
4626 catch_exceptions=False,
4627 )
4628 assert result.error_exit( 12
4629 error='does not support communicating with it'
4630 ), 'expected error exit and known error message'
4631 assert tests.warning_emitted( 12
4632 'Cannot connect to an SSH agent via UNIX domain sockets',
4633 caplog.record_tuples,
4634 ), 'expected known warning message in stderr'
4637class TestCLIUtils:
4638 """Tests for command-line utility functions."""
4640 @Parametrize.BASE_CONFIG_VARIATIONS
4641 def test_100_load_config(
4642 self,
4643 config: Any,
4644 ) -> None:
4645 """[`cli_helpers.load_config`][] works for valid configurations."""
4646 runner = tests.CliRunner(mix_stderr=False) 1'
4647 # TODO(the-13th-letter): Rewrite using parenthesized
4648 # with-statements.
4649 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4650 with contextlib.ExitStack() as stack: 1'
4651 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1'
4652 stack.enter_context( 1'
4653 tests.isolated_vault_config(
4654 monkeypatch=monkeypatch,
4655 runner=runner,
4656 vault_config=config,
4657 )
4658 )
4659 config_filename = cli_helpers.config_filename(subsystem='vault') 1'
4660 with config_filename.open(encoding='UTF-8') as fileobj: 1'
4661 assert json.load(fileobj) == config 1'
4662 assert cli_helpers.load_config() == config 1'
4664 def test_110_save_bad_config(
4665 self,
4666 ) -> None:
4667 """[`cli_helpers.save_config`][] fails for bad configurations."""
4668 runner = tests.CliRunner(mix_stderr=False) 2gb
4669 # TODO(the-13th-letter): Rewrite using parenthesized
4670 # with-statements.
4671 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4672 with contextlib.ExitStack() as stack: 2gb
4673 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2gb
4674 stack.enter_context( 2gb
4675 tests.isolated_vault_config(
4676 monkeypatch=monkeypatch,
4677 runner=runner,
4678 vault_config={},
4679 )
4680 )
4681 stack.enter_context( 2gb
4682 pytest.raises(ValueError, match='Invalid vault config')
4683 )
4684 cli_helpers.save_config(None) # type: ignore[arg-type] 2gb
4686 def test_111_prompt_for_selection_multiple(self) -> None:
4687 """[`cli_helpers.prompt_for_selection`][] works in the "multiple" case."""
4689 @click.command() 1y
4690 @click.option('--heading', default='Our menu:') 1y
4691 @click.argument('items', nargs=-1) 1y
4692 def driver(heading: str, items: list[str]) -> None: 1y
4693 # from https://montypython.fandom.com/wiki/Spam#The_menu
4694 items = items or [ 1y
4695 'Egg and bacon',
4696 'Egg, sausage and bacon',
4697 'Egg and spam',
4698 'Egg, bacon and spam',
4699 'Egg, bacon, sausage and spam',
4700 'Spam, bacon, sausage and spam',
4701 'Spam, egg, spam, spam, bacon and spam',
4702 'Spam, spam, spam, egg and spam',
4703 (
4704 'Spam, spam, spam, spam, spam, spam, baked beans, '
4705 'spam, spam, spam and spam'
4706 ),
4707 (
4708 'Lobster thermidor aux crevettes with a mornay sauce '
4709 'garnished with truffle paté, brandy '
4710 'and a fried egg on top and spam'
4711 ),
4712 ]
4713 index = cli_helpers.prompt_for_selection(items, heading=heading) 1y
4714 click.echo('A fine choice: ', nl=False) 1y
4715 click.echo(items[index]) 1y
4716 click.echo('(Note: Vikings strictly optional.)') 1y
4718 runner = tests.CliRunner(mix_stderr=True) 1y
4719 result = runner.invoke(driver, [], input='9') 1y
4720 assert result.clean_exit( 1y
4721 output="""\
4722Our menu:
4723[1] Egg and bacon
4724[2] Egg, sausage and bacon
4725[3] Egg and spam
4726[4] Egg, bacon and spam
4727[5] Egg, bacon, sausage and spam
4728[6] Spam, bacon, sausage and spam
4729[7] Spam, egg, spam, spam, bacon and spam
4730[8] Spam, spam, spam, egg and spam
4731[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
4732[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
4733Your selection? (1-10, leave empty to abort): 9
4734A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
4735(Note: Vikings strictly optional.)
4736"""
4737 ), 'expected clean exit'
4738 result = runner.invoke( 1y
4739 driver, ['--heading='], input='\n', catch_exceptions=True
4740 )
4741 assert result.error_exit(error=IndexError), ( 1y
4742 'expected error exit and known error type'
4743 )
4744 assert ( 1y
4745 result.stdout
4746 == """\
4747[1] Egg and bacon
4748[2] Egg, sausage and bacon
4749[3] Egg and spam
4750[4] Egg, bacon and spam
4751[5] Egg, bacon, sausage and spam
4752[6] Spam, bacon, sausage and spam
4753[7] Spam, egg, spam, spam, bacon and spam
4754[8] Spam, spam, spam, egg and spam
4755[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
4756[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
4757Your selection? (1-10, leave empty to abort):\x20
4758"""
4759 ), 'expected known output'
4760 # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the
4761 # click prompting machinery, meaning that the mixed output will
4762 # incorrectly contain a line break, contrary to what the
4763 # documentation for click.prompt prescribes.
4764 result = runner.invoke( 1y
4765 driver, ['--heading='], input='', catch_exceptions=True
4766 )
4767 assert result.error_exit(error=IndexError), ( 1y
4768 'expected error exit and known error type'
4769 )
4770 assert result.stdout in { 1y
4771 """\
4772[1] Egg and bacon
4773[2] Egg, sausage and bacon
4774[3] Egg and spam
4775[4] Egg, bacon and spam
4776[5] Egg, bacon, sausage and spam
4777[6] Spam, bacon, sausage and spam
4778[7] Spam, egg, spam, spam, bacon and spam
4779[8] Spam, spam, spam, egg and spam
4780[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
4781[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
4782Your selection? (1-10, leave empty to abort):\x20
4783""",
4784 """\
4785[1] Egg and bacon
4786[2] Egg, sausage and bacon
4787[3] Egg and spam
4788[4] Egg, bacon and spam
4789[5] Egg, bacon, sausage and spam
4790[6] Spam, bacon, sausage and spam
4791[7] Spam, egg, spam, spam, bacon and spam
4792[8] Spam, spam, spam, egg and spam
4793[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
4794[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
4795Your selection? (1-10, leave empty to abort): """,
4796 }, 'expected known output'
4798 def test_112_prompt_for_selection_single(self) -> None:
4799 """[`cli_helpers.prompt_for_selection`][] works in the "single" case."""
4801 @click.command() 1x
4802 @click.option('--item', default='baked beans') 1x
4803 @click.argument('prompt') 1x
4804 def driver(item: str, prompt: str) -> None: 1x
4805 try: 1x
4806 cli_helpers.prompt_for_selection( 1x
4807 [item], heading='', single_choice_prompt=prompt
4808 )
4809 except IndexError: 1x
4810 click.echo('Boo.') 1x
4811 raise 1x
4812 else:
4813 click.echo('Great!') 1x
4815 runner = tests.CliRunner(mix_stderr=True) 1x
4816 result = runner.invoke( 1x
4817 driver, ['Will replace with spam. Confirm, y/n?'], input='y'
4818 )
4819 assert result.clean_exit( 1x
4820 output="""\
4821[1] baked beans
4822Will replace with spam. Confirm, y/n? y
4823Great!
4824"""
4825 ), 'expected clean exit'
4826 result = runner.invoke( 1x
4827 driver,
4828 ['Will replace with spam, okay? (Please say "y" or "n".)'],
4829 input='\n',
4830 )
4831 assert result.error_exit(error=IndexError), ( 1x
4832 'expected error exit and known error type'
4833 )
4834 assert ( 1x
4835 result.stdout
4836 == """\
4837[1] baked beans
4838Will replace with spam, okay? (Please say "y" or "n".):\x20
4839Boo.
4840"""
4841 ), 'expected known output'
4842 # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the
4843 # click prompting machinery, meaning that the mixed output will
4844 # incorrectly contain a line break, contrary to what the
4845 # documentation for click.prompt prescribes.
4846 result = runner.invoke( 1x
4847 driver,
4848 ['Will replace with spam, okay? (Please say "y" or "n".)'],
4849 input='',
4850 )
4851 assert result.error_exit(error=IndexError), ( 1x
4852 'expected error exit and known error type'
4853 )
4854 assert result.stdout in { 1x
4855 """\
4856[1] baked beans
4857Will replace with spam, okay? (Please say "y" or "n".):\x20
4858Boo.
4859""",
4860 """\
4861[1] baked beans
4862Will replace with spam, okay? (Please say "y" or "n".): Boo.
4863""",
4864 }, 'expected known output'
4866 def test_113_prompt_for_passphrase(
4867 self,
4868 ) -> None:
4869 """[`cli_helpers.prompt_for_passphrase`][] works."""
4870 with pytest.MonkeyPatch.context() as monkeypatch: 1U
4871 monkeypatch.setattr( 1U
4872 click,
4873 'prompt',
4874 lambda *a, **kw: json.dumps({'args': a, 'kwargs': kw}),
4875 )
4876 res = json.loads(cli_helpers.prompt_for_passphrase()) 1U
4877 err_msg = 'missing arguments to passphrase prompt' 1U
4878 assert 'args' in res, err_msg 1U
4879 assert 'kwargs' in res, err_msg 1U
4880 assert res['args'][:1] == ['Passphrase'], err_msg 1U
4881 assert res['kwargs'].get('default') == '', err_msg 1U
4882 assert not res['kwargs'].get('show_default', True), err_msg 1U
4883 assert res['kwargs'].get('err'), err_msg 1U
4884 assert res['kwargs'].get('hide_input'), err_msg 1U
4886 def test_120_standard_logging_context_manager(
4887 self,
4888 caplog: pytest.LogCaptureFixture,
4889 capsys: pytest.CaptureFixture[str],
4890 ) -> None:
4891 """The standard logging context manager works.
4893 It registers its handlers, once, and emits formatted calls to
4894 standard error prefixed with the program name.
4896 """
4897 prog_name = cli_machinery.StandardCLILogging.prog_name 1B
4898 package_name = cli_machinery.StandardCLILogging.package_name 1B
4899 logger = logging.getLogger(package_name) 1B
4900 deprecation_logger = logging.getLogger(f'{package_name}.deprecation') 1B
4901 logging_cm = cli_machinery.StandardCLILogging.ensure_standard_logging() 1B
4902 with logging_cm: 1B
4903 assert ( 1B
4904 sum(
4905 1
4906 for h in logger.handlers
4907 if h is cli_machinery.StandardCLILogging.cli_handler
4908 )
4909 == 1
4910 )
4911 logger.warning('message 1') 1B
4912 with logging_cm: 1B
4913 deprecation_logger.warning('message 2') 1B
4914 assert ( 1B
4915 sum(
4916 1
4917 for h in logger.handlers
4918 if h is cli_machinery.StandardCLILogging.cli_handler
4919 )
4920 == 1
4921 )
4922 assert capsys.readouterr() == ( 1B
4923 '',
4924 (
4925 f'{prog_name}: Warning: message 1\n'
4926 f'{prog_name}: Deprecation warning: message 2\n'
4927 ),
4928 )
4929 logger.warning('message 3') 1B
4930 assert ( 1B
4931 sum(
4932 1
4933 for h in logger.handlers
4934 if h is cli_machinery.StandardCLILogging.cli_handler
4935 )
4936 == 1
4937 )
4938 assert capsys.readouterr() == ( 1B
4939 '',
4940 f'{prog_name}: Warning: message 3\n',
4941 )
4942 assert caplog.record_tuples == [ 1B
4943 (package_name, logging.WARNING, 'message 1'),
4944 (f'{package_name}.deprecation', logging.WARNING, 'message 2'),
4945 (package_name, logging.WARNING, 'message 3'),
4946 ]
4948 def test_121_standard_logging_warnings_context_manager(
4949 self,
4950 caplog: pytest.LogCaptureFixture,
4951 capsys: pytest.CaptureFixture[str],
4952 ) -> None:
4953 """The standard warnings logging context manager works.
4955 It registers its handlers, once, and emits formatted calls to
4956 standard error prefixed with the program name. It also adheres
4957 to the global warnings filter concerning which messages it
4958 actually emits to standard error.
4960 """
4961 warnings_cm = ( 1p
4962 cli_machinery.StandardCLILogging.ensure_standard_warnings_logging()
4963 )
4964 THE_FUTURE = 'the future will be here sooner than you think' # noqa: N806 1p
4965 JUST_TESTING = 'just testing whether warnings work' # noqa: N806 1p
4966 with warnings_cm: 1p
4967 assert ( 1p
4968 sum(
4969 1
4970 for h in logging.getLogger('py.warnings').handlers
4971 if h is cli_machinery.StandardCLILogging.warnings_handler
4972 )
4973 == 1
4974 )
4975 warnings.warn(UserWarning(JUST_TESTING), stacklevel=1) 1p
4976 with warnings_cm: 1p
4977 warnings.warn(FutureWarning(THE_FUTURE), stacklevel=1) 1p
4978 _out, err = capsys.readouterr() 1p
4979 err_lines = err.splitlines(True) 1p
4980 assert any( 1p
4981 f'UserWarning: {JUST_TESTING}' in line
4982 for line in err_lines
4983 )
4984 assert any( 1p
4985 f'FutureWarning: {THE_FUTURE}' in line
4986 for line in err_lines
4987 )
4988 warnings.warn(UserWarning(JUST_TESTING), stacklevel=1) 1p
4989 _out, err = capsys.readouterr() 1p
4990 err_lines = err.splitlines(True) 1p
4991 assert any( 1p
4992 f'UserWarning: {JUST_TESTING}' in line for line in err_lines
4993 )
4994 assert not any( 1p
4995 f'FutureWarning: {THE_FUTURE}' in line for line in err_lines
4996 )
4997 record_tuples = caplog.record_tuples 1p
4998 assert [tup[:2] for tup in record_tuples] == [ 1p
4999 ('py.warnings', logging.WARNING),
5000 ('py.warnings', logging.WARNING),
5001 ('py.warnings', logging.WARNING),
5002 ]
5003 assert f'UserWarning: {JUST_TESTING}' in record_tuples[0][2] 1p
5004 assert f'FutureWarning: {THE_FUTURE}' in record_tuples[1][2] 1p
5005 assert f'UserWarning: {JUST_TESTING}' in record_tuples[2][2] 1p
5007 def export_as_sh_helper(
5008 self,
5009 config: Any,
5010 ) -> None:
5011 """Emits a config in sh(1) format, then reads it back to verify it.
5013 This function exports the configuration, sets up a new
5014 enviroment, then calls
5015 [`vault_config_exporter_shell_interpreter`][] on the export
5016 script, verifying that each command ran successfully and that
5017 the final configuration matches the initial one.
5019 Args:
5020 config:
5021 The configuration to emit and read back.
5023 """
5024 prog_name_list = ('derivepassphrase', 'vault') 1jikl
5025 with io.StringIO() as outfile: 1jikl
5026 cli_helpers.print_config_as_sh_script( 1jikl
5027 config, outfile=outfile, prog_name_list=prog_name_list
5028 )
5029 script = outfile.getvalue() 1jikl
5030 runner = tests.CliRunner(mix_stderr=False) 1jikl
5031 # TODO(the-13th-letter): Rewrite using parenthesized
5032 # with-statements.
5033 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5034 with contextlib.ExitStack() as stack: 1jikl
5035 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1jikl
5036 stack.enter_context( 1jikl
5037 tests.isolated_vault_config(
5038 monkeypatch=monkeypatch,
5039 runner=runner,
5040 vault_config={'services': {}},
5041 )
5042 )
5043 for result in vault_config_exporter_shell_interpreter(script): 1jikl
5044 assert result.clean_exit() 1jikl
5045 assert cli_helpers.load_config() == config 1jikl
5047 @hypothesis.given(
5048 global_config_settable=tests.vault_full_service_config(),
5049 global_config_importable=strategies.fixed_dictionaries(
5050 {},
5051 optional={
5052 'key': strategies.text(
5053 alphabet=strategies.characters(
5054 min_codepoint=32,
5055 max_codepoint=126,
5056 ),
5057 max_size=128,
5058 ),
5059 'phrase': strategies.text(
5060 alphabet=strategies.characters(
5061 min_codepoint=32,
5062 max_codepoint=126,
5063 ),
5064 max_size=64,
5065 ),
5066 },
5067 ),
5068 )
5069 def test_130a_export_as_sh_global(
5070 self,
5071 global_config_settable: _types.VaultConfigServicesSettings,
5072 global_config_importable: _types.VaultConfigServicesSettings,
5073 ) -> None:
5074 """Exporting configurations as sh(1) script works.
5076 Here, we check global-only configurations which use both
5077 settings settable via `--config` and settings requiring
5078 `--import`.
5080 The actual verification is done by [`export_as_sh_helper`][].
5082 """
5083 config: _types.VaultConfig = { 1j
5084 'global': global_config_settable | global_config_importable,
5085 'services': {},
5086 }
5087 assert _types.clean_up_falsy_vault_config_values(config) is not None 1j
5088 assert _types.is_vault_config(config) 1j
5089 return self.export_as_sh_helper(config) 1j
5091 @hypothesis.given(
5092 global_config_importable=strategies.fixed_dictionaries(
5093 {},
5094 optional={
5095 'key': strategies.text(
5096 alphabet=strategies.characters(
5097 min_codepoint=32,
5098 max_codepoint=126,
5099 ),
5100 max_size=128,
5101 ),
5102 'phrase': strategies.text(
5103 alphabet=strategies.characters(
5104 min_codepoint=32,
5105 max_codepoint=126,
5106 ),
5107 max_size=64,
5108 ),
5109 },
5110 ),
5111 )
5112 def test_130b_export_as_sh_global_only_imports(
5113 self,
5114 global_config_importable: _types.VaultConfigServicesSettings,
5115 ) -> None:
5116 """Exporting configurations as sh(1) script works.
5118 Here, we check global-only configurations which only use
5119 settings requiring `--import`.
5121 The actual verification is done by [`export_as_sh_helper`][].
5123 """
5124 config: _types.VaultConfig = { 1i
5125 'global': global_config_importable,
5126 'services': {},
5127 }
5128 assert _types.clean_up_falsy_vault_config_values(config) is not None 1i
5129 assert _types.is_vault_config(config) 1i
5130 if not config['global']: 1i
5131 config.pop('global') 1i
5132 return self.export_as_sh_helper(config) 1i
5134 @hypothesis.given(
5135 service_name=strategies.text(
5136 alphabet=strategies.characters(
5137 min_codepoint=32,
5138 max_codepoint=126,
5139 ),
5140 min_size=4,
5141 max_size=64,
5142 ),
5143 service_config_settable=tests.vault_full_service_config(),
5144 service_config_importable=strategies.fixed_dictionaries(
5145 {},
5146 optional={
5147 'key': strategies.text(
5148 alphabet=strategies.characters(
5149 min_codepoint=32,
5150 max_codepoint=126,
5151 ),
5152 max_size=128,
5153 ),
5154 'phrase': strategies.text(
5155 alphabet=strategies.characters(
5156 min_codepoint=32,
5157 max_codepoint=126,
5158 ),
5159 max_size=64,
5160 ),
5161 'notes': strategies.text(
5162 alphabet=strategies.characters(
5163 min_codepoint=32,
5164 max_codepoint=126,
5165 include_characters=('\n', '\f', '\t'),
5166 ),
5167 max_size=256,
5168 ),
5169 },
5170 ),
5171 )
5172 def test_130c_export_as_sh_service(
5173 self,
5174 service_name: str,
5175 service_config_settable: _types.VaultConfigServicesSettings,
5176 service_config_importable: _types.VaultConfigServicesSettings,
5177 ) -> None:
5178 """Exporting configurations as sh(1) script works.
5180 Here, we check service-only configurations which use both
5181 settings settable via `--config` and settings requiring
5182 `--import`.
5184 The actual verification is done by [`export_as_sh_helper`][].
5186 """
5187 config: _types.VaultConfig = { 1k
5188 'services': {
5189 service_name: (
5190 service_config_settable | service_config_importable
5191 ),
5192 },
5193 }
5194 assert _types.clean_up_falsy_vault_config_values(config) is not None 1k
5195 assert _types.is_vault_config(config) 1k
5196 return self.export_as_sh_helper(config) 1k
5198 @hypothesis.given(
5199 service_name=strategies.text(
5200 alphabet=strategies.characters(
5201 min_codepoint=32,
5202 max_codepoint=126,
5203 ),
5204 min_size=4,
5205 max_size=64,
5206 ),
5207 service_config_importable=strategies.fixed_dictionaries(
5208 {},
5209 optional={
5210 'key': strategies.text(
5211 alphabet=strategies.characters(
5212 min_codepoint=32,
5213 max_codepoint=126,
5214 ),
5215 max_size=128,
5216 ),
5217 'phrase': strategies.text(
5218 alphabet=strategies.characters(
5219 min_codepoint=32,
5220 max_codepoint=126,
5221 ),
5222 max_size=64,
5223 ),
5224 'notes': strategies.text(
5225 alphabet=strategies.characters(
5226 min_codepoint=32,
5227 max_codepoint=126,
5228 include_characters=('\n', '\f', '\t'),
5229 ),
5230 max_size=256,
5231 ),
5232 },
5233 ),
5234 )
5235 def test_130d_export_as_sh_service_only_imports(
5236 self,
5237 service_name: str,
5238 service_config_importable: _types.VaultConfigServicesSettings,
5239 ) -> None:
5240 """Exporting configurations as sh(1) script works.
5242 Here, we check service-only configurations which only use
5243 settings requiring `--import`.
5245 The actual verification is done by [`export_as_sh_helper`][].
5247 """
5248 config: _types.VaultConfig = { 1l
5249 'services': {
5250 service_name: service_config_importable,
5251 },
5252 }
5253 assert _types.clean_up_falsy_vault_config_values(config) is not None 1l
5254 assert _types.is_vault_config(config) 1l
5255 return self.export_as_sh_helper(config) 1l
5257 # The Annoying OS appears to silently truncate spaces at the end of
5258 # filenames.
5259 @hypothesis.given(
5260 env_var=strategies.sampled_from(['TMPDIR', 'TEMP', 'TMP']),
5261 suffix=strategies.builds(
5262 operator.add,
5263 strategies.text(
5264 tuple(' 0123456789abcdefghijklmnopqrstuvwxyz'),
5265 min_size=11,
5266 max_size=11,
5267 ),
5268 strategies.text(
5269 tuple('0123456789abcdefghijklmnopqrstuvwxyz'),
5270 min_size=1,
5271 max_size=1,
5272 ),
5273 ),
5274 )
5275 @hypothesis.example(env_var='', suffix='.')
5276 def test_140a_get_tempdir(
5277 self,
5278 env_var: str,
5279 suffix: str,
5280 ) -> None:
5281 """[`cli_helpers.get_tempdir`][] returns a temporary directory.
5283 If it is not the same as the temporary directory determined by
5284 [`tempfile.gettempdir`][], then assert that
5285 `tempfile.gettempdir` returned the current directory and
5286 `cli_helpers.get_tempdir` returned the configuration directory.
5288 """
5290 @contextlib.contextmanager 1n
5291 def make_temporary_directory( 1n
5292 path: pathlib.Path,
5293 ) -> Iterator[pathlib.Path]:
5294 try: 1n
5295 path.mkdir() 1n
5296 yield path 1n
5297 finally:
5298 shutil.rmtree(path) 1n
5300 runner = tests.CliRunner(mix_stderr=False) 1n
5301 # TODO(the-13th-letter): Rewrite using parenthesized
5302 # with-statements.
5303 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5304 with contextlib.ExitStack() as stack: 1n
5305 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1n
5306 stack.enter_context( 1n
5307 tests.isolated_vault_config(
5308 monkeypatch=monkeypatch,
5309 runner=runner,
5310 vault_config={'services': {}},
5311 )
5312 )
5313 old_tempdir = os.fsdecode(tempfile.gettempdir()) 1n
5314 monkeypatch.delenv('TMPDIR', raising=False) 1n
5315 monkeypatch.delenv('TEMP', raising=False) 1n
5316 monkeypatch.delenv('TMP', raising=False) 1n
5317 monkeypatch.setattr(tempfile, 'tempdir', None) 1n
5318 temp_path = pathlib.Path.cwd() / suffix 1n
5319 if env_var: 1n
5320 monkeypatch.setenv(env_var, os.fsdecode(temp_path)) 1n
5321 stack.enter_context(make_temporary_directory(temp_path)) 1n
5322 new_tempdir = os.fsdecode(tempfile.gettempdir()) 1n
5323 hypothesis.assume( 1n
5324 temp_path.resolve() == pathlib.Path.cwd().resolve()
5325 or old_tempdir != new_tempdir
5326 )
5327 system_tempdir = os.fsdecode(tempfile.gettempdir()) 1n
5328 our_tempdir = cli_helpers.get_tempdir() 1n
5329 assert system_tempdir == os.fsdecode(our_tempdir) or ( 1n
5330 # TODO(the-13th-letter): `tests.isolated_config`
5331 # guarantees that `Path.cwd() == config_filename(None)`.
5332 # So this sub-branch ought to never trigger in our
5333 # tests.
5334 system_tempdir == os.getcwd() # noqa: PTH109
5335 and our_tempdir == cli_helpers.config_filename(subsystem=None)
5336 )
5337 assert not temp_path.exists(), f'temp path {temp_path} not cleaned up!' 1n
5339 def test_140b_get_tempdir_force_default(self) -> None:
5340 """[`cli_helpers.get_tempdir`][] returns a temporary directory.
5342 If all candidates are mocked to fail for the standard temporary
5343 directory choices, then we return the `derivepassphrase`
5344 configuration directory.
5346 """
5347 runner = tests.CliRunner(mix_stderr=False) 1z
5348 # TODO(the-13th-letter): Rewrite using parenthesized
5349 # with-statements.
5350 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5351 with contextlib.ExitStack() as stack: 1z
5352 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1z
5353 stack.enter_context( 1z
5354 tests.isolated_vault_config(
5355 monkeypatch=monkeypatch,
5356 runner=runner,
5357 vault_config={'services': {}},
5358 )
5359 )
5360 monkeypatch.delenv('TMPDIR', raising=False) 1z
5361 monkeypatch.delenv('TEMP', raising=False) 1z
5362 monkeypatch.delenv('TMP', raising=False) 1z
5363 config_dir = cli_helpers.config_filename(subsystem=None) 1z
5365 def is_dir_false( 1z
5366 self: pathlib.Path,
5367 /,
5368 *,
5369 follow_symlinks: bool = False,
5370 ) -> bool:
5371 del self, follow_symlinks 1z
5372 return False 1z
5374 def is_dir_error( 1z
5375 self: pathlib.Path,
5376 /,
5377 *,
5378 follow_symlinks: bool = False,
5379 ) -> bool:
5380 del follow_symlinks 1z
5381 raise OSError( 1z
5382 errno.EACCES,
5383 os.strerror(errno.EACCES),
5384 str(self),
5385 )
5387 monkeypatch.setattr(pathlib.Path, 'is_dir', is_dir_false) 1z
5388 assert cli_helpers.get_tempdir() == config_dir 1z
5390 monkeypatch.setattr(pathlib.Path, 'is_dir', is_dir_error) 1z
5391 assert cli_helpers.get_tempdir() == config_dir 1z
5393 @Parametrize.DELETE_CONFIG_INPUT
5394 def test_203_repeated_config_deletion(
5395 self,
5396 command_line: list[str],
5397 config: _types.VaultConfig,
5398 result_config: _types.VaultConfig,
5399 ) -> None:
5400 """Repeatedly removing the same parts of a configuration works."""
5401 for start_config in [config, result_config]: 13
5402 runner = tests.CliRunner(mix_stderr=False) 13
5403 # TODO(the-13th-letter): Rewrite using parenthesized
5404 # with-statements.
5405 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5406 with contextlib.ExitStack() as stack: 13
5407 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 13
5408 stack.enter_context( 13
5409 tests.isolated_vault_config(
5410 monkeypatch=monkeypatch,
5411 runner=runner,
5412 vault_config=start_config,
5413 )
5414 )
5415 result = runner.invoke( 13
5416 cli.derivepassphrase_vault,
5417 command_line,
5418 catch_exceptions=False,
5419 )
5420 assert result.clean_exit(empty_stderr=True), ( 13
5421 'expected clean exit'
5422 )
5423 with cli_helpers.config_filename(subsystem='vault').open( 13
5424 encoding='UTF-8'
5425 ) as infile:
5426 config_readback = json.load(infile) 13
5427 assert config_readback == result_config 13
5429 def test_204_phrase_from_key_manually(self) -> None:
5430 """The dummy service, key and config settings are consistent."""
5431 assert ( 2mb
5432 vault.Vault(
5433 phrase=DUMMY_PHRASE_FROM_KEY1, **DUMMY_CONFIG_SETTINGS
5434 ).generate(DUMMY_SERVICE)
5435 == DUMMY_RESULT_KEY1
5436 )
5438 @Parametrize.VALIDATION_FUNCTION_INPUT
5439 def test_210a_validate_constraints_manually(
5440 self,
5441 vfunc: Callable[[click.Context, click.Parameter, Any], int | None],
5442 input: int,
5443 ) -> None:
5444 """Command-line argument constraint validation works."""
5445 ctx = cli.derivepassphrase_vault.make_context(cli.PROG_NAME, []) 2kb
5446 param = cli.derivepassphrase_vault.params[0] 2kb
5447 assert vfunc(ctx, param, input) == input 2kb
5449 @Parametrize.CONNECTION_HINTS
5450 def test_227_get_suitable_ssh_keys(
5451 self,
5452 running_ssh_agent: tests.RunningSSHAgentInfo,
5453 conn_hint: str,
5454 ) -> None:
5455 """[`cli_helpers.get_suitable_ssh_keys`][] works."""
5456 with pytest.MonkeyPatch.context() as monkeypatch: 1I
5457 monkeypatch.setattr( 1I
5458 ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys
5459 )
5460 hint: ssh_agent.SSHAgentClient | _types.SSHAgentSocket | None
5461 # TODO(the-13th-letter): Rewrite using structural pattern
5462 # matching.
5463 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5464 if conn_hint == 'client': 1I
5465 hint = ssh_agent.SSHAgentClient() 1I
5466 elif conn_hint == 'socket': 1I
5467 if isinstance( 1I
5468 running_ssh_agent.socket, str
5469 ): # pragma: no cover
5470 if not hasattr(socket, 'AF_UNIX'):
5471 pytest.skip('socket module does not support AF_UNIX')
5472 # socket.AF_UNIX is not defined everywhere.
5473 hint = socket.socket(family=socket.AF_UNIX) # type: ignore[attr-defined]
5474 hint.connect(running_ssh_agent.socket)
5475 else: # pragma: no cover
5476 hint = running_ssh_agent.socket() 1I
5477 else:
5478 assert conn_hint == 'none' 1I
5479 hint = None 1I
5480 exception: Exception | None = None 1I
5481 try: 1I
5482 list(cli_helpers.get_suitable_ssh_keys(hint)) 1I
5483 except RuntimeError: # pragma: no cover
5484 pass
5485 except Exception as e: # noqa: BLE001 # pragma: no cover
5486 exception = e
5487 finally:
5488 assert exception is None, ( 1I
5489 'exception querying suitable SSH keys'
5490 )
5492 @Parametrize.KEY_TO_PHRASE_SETTINGS
5493 def test_400_key_to_phrase(
5494 self,
5495 ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
5496 list_keys_action: ListKeysAction | None,
5497 system_support_action: SystemSupportAction | None,
5498 address_action: SocketAddressAction | None,
5499 sign_action: SignAction,
5500 pattern: str,
5501 ) -> None:
5502 """All errors in [`cli_helpers.key_to_phrase`][] are handled."""
5504 class ErrCallback(BaseException): 1c
5505 def __init__(self, *args: Any, **kwargs: Any) -> None: 1c
5506 super().__init__(*args[:1]) 1c
5507 self.args = args 1c
5508 self.kwargs = kwargs 1c
5510 def err(*args: Any, **_kwargs: Any) -> NoReturn: 1c
5511 raise ErrCallback(*args, **_kwargs) 1c
5513 with pytest.MonkeyPatch.context() as monkeypatch: 1c
5514 loaded_keys = list( 1c
5515 ssh_agent_client_with_test_keys_loaded.list_keys()
5516 )
5517 loaded_key = base64.standard_b64encode(loaded_keys[0][0]) 1c
5518 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', sign_action) 1c
5519 if list_keys_action: 1c
5520 monkeypatch.setattr( 1c
5521 ssh_agent.SSHAgentClient, 'list_keys', list_keys_action
5522 )
5523 if address_action: 1c
5524 address_action(monkeypatch) 1c
5525 if system_support_action: 1c
5526 system_support_action(monkeypatch) 1c
5527 with pytest.raises(ErrCallback, match=pattern) as excinfo: 1c
5528 cli_helpers.key_to_phrase(loaded_key, error_callback=err) 1c
5529 if list_keys_action == ListKeysAction.FAIL_RUNTIME: 1c
5530 assert excinfo.value.kwargs 1c
5531 assert isinstance( 1c
5532 excinfo.value.kwargs['exc_info'],
5533 ssh_agent.SSHAgentFailedError,
5534 )
5535 assert excinfo.value.kwargs['exc_info'].__context__ is not None 1c
5536 assert isinstance( 1c
5537 excinfo.value.kwargs['exc_info'].__context__,
5538 ssh_agent.TrailingDataError,
5539 )
5542# TODO(the-13th-letter): Remove this class in v1.0.
5543# https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#upgrading-to-v1.0
5544class TestCLITransition:
5545 """Transition tests for the command-line interface up to v1.0."""
5547 @Parametrize.BASE_CONFIG_VARIATIONS
5548 def test_110_load_config_backup(
5549 self,
5550 config: Any,
5551 ) -> None:
5552 """Loading the old settings file works."""
5553 runner = tests.CliRunner(mix_stderr=False) 2hb
5554 # TODO(the-13th-letter): Rewrite using parenthesized
5555 # with-statements.
5556 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5557 with contextlib.ExitStack() as stack: 2hb
5558 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2hb
5559 stack.enter_context( 2hb
5560 tests.isolated_config(
5561 monkeypatch=monkeypatch,
5562 runner=runner,
5563 )
5564 )
5565 cli_helpers.config_filename( 2hb
5566 subsystem='old settings.json'
5567 ).write_text(json.dumps(config, indent=2) + '\n', encoding='UTF-8')
5568 assert cli_helpers.migrate_and_load_old_config()[0] == config 2hb
5570 @Parametrize.BASE_CONFIG_VARIATIONS
5571 def test_111_migrate_config(
5572 self,
5573 config: Any,
5574 ) -> None:
5575 """Migrating the old settings file works."""
5576 runner = tests.CliRunner(mix_stderr=False) 2ib
5577 # TODO(the-13th-letter): Rewrite using parenthesized
5578 # with-statements.
5579 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5580 with contextlib.ExitStack() as stack: 2ib
5581 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2ib
5582 stack.enter_context( 2ib
5583 tests.isolated_config(
5584 monkeypatch=monkeypatch,
5585 runner=runner,
5586 )
5587 )
5588 cli_helpers.config_filename( 2ib
5589 subsystem='old settings.json'
5590 ).write_text(json.dumps(config, indent=2) + '\n', encoding='UTF-8')
5591 assert cli_helpers.migrate_and_load_old_config() == (config, None) 2ib
5593 @Parametrize.BASE_CONFIG_VARIATIONS
5594 def test_112_migrate_config_error(
5595 self,
5596 config: Any,
5597 ) -> None:
5598 """Migrating the old settings file atop a directory fails."""
5599 runner = tests.CliRunner(mix_stderr=False) 14
5600 # TODO(the-13th-letter): Rewrite using parenthesized
5601 # with-statements.
5602 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5603 with contextlib.ExitStack() as stack: 14
5604 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 14
5605 stack.enter_context( 14
5606 tests.isolated_config(
5607 monkeypatch=monkeypatch,
5608 runner=runner,
5609 )
5610 )
5611 cli_helpers.config_filename( 14
5612 subsystem='old settings.json'
5613 ).write_text(json.dumps(config, indent=2) + '\n', encoding='UTF-8')
5614 cli_helpers.config_filename(subsystem='vault').mkdir( 14
5615 parents=True, exist_ok=True
5616 )
5617 config2, err = cli_helpers.migrate_and_load_old_config() 14
5618 assert config2 == config 14
5619 assert isinstance(err, OSError) 14
5620 # The Annoying OS uses EEXIST, other OSes use EISDIR.
5621 assert err.errno in {errno.EISDIR, errno.EEXIST} 14
5623 @Parametrize.BAD_CONFIGS
5624 def test_113_migrate_config_error_bad_config_value(
5625 self,
5626 config: Any,
5627 ) -> None:
5628 """Migrating an invalid old settings file fails."""
5629 runner = tests.CliRunner(mix_stderr=False) 1^
5630 # TODO(the-13th-letter): Rewrite using parenthesized
5631 # with-statements.
5632 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5633 with contextlib.ExitStack() as stack: 1^
5634 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1^
5635 stack.enter_context( 1^
5636 tests.isolated_config(
5637 monkeypatch=monkeypatch,
5638 runner=runner,
5639 )
5640 )
5641 cli_helpers.config_filename( 1^
5642 subsystem='old settings.json'
5643 ).write_text(json.dumps(config, indent=2) + '\n', encoding='UTF-8')
5644 with pytest.raises( 1^
5645 ValueError, match=cli_helpers.INVALID_VAULT_CONFIG
5646 ):
5647 cli_helpers.migrate_and_load_old_config() 1^
5649 def test_200_forward_export_vault_path_parameter(
5650 self,
5651 caplog: pytest.LogCaptureFixture,
5652 ) -> None:
5653 """Forwarding arguments from "export" to "export vault" works."""
5654 pytest.importorskip('cryptography', minversion='38.0') 1V
5655 runner = tests.CliRunner(mix_stderr=False) 1V
5656 # TODO(the-13th-letter): Rewrite using parenthesized
5657 # with-statements.
5658 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5659 with contextlib.ExitStack() as stack: 1V
5660 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1V
5661 stack.enter_context( 1V
5662 tests.isolated_vault_exporter_config(
5663 monkeypatch=monkeypatch,
5664 runner=runner,
5665 vault_config=tests.VAULT_V03_CONFIG,
5666 vault_key=tests.VAULT_MASTER_KEY,
5667 )
5668 )
5669 monkeypatch.setenv('VAULT_KEY', tests.VAULT_MASTER_KEY) 1V
5670 result = runner.invoke( 1V
5671 cli.derivepassphrase,
5672 ['export', 'VAULT_PATH'],
5673 )
5674 assert result.clean_exit(empty_stderr=False), 'expected clean exit' 1V
5675 assert tests.deprecation_warning_emitted( 1V
5676 'A subcommand will be required here in v1.0', caplog.record_tuples
5677 )
5678 assert tests.deprecation_warning_emitted( 1V
5679 'Defaulting to subcommand "vault"', caplog.record_tuples
5680 )
5681 assert json.loads(result.stdout) == tests.VAULT_V03_CONFIG_DATA 1V
5683 def test_201_forward_export_vault_empty_commandline(
5684 self,
5685 caplog: pytest.LogCaptureFixture,
5686 ) -> None:
5687 """Deferring from "export" to "export vault" works."""
5688 pytest.importorskip('cryptography', minversion='38.0') 19
5689 runner = tests.CliRunner(mix_stderr=False) 19
5690 # TODO(the-13th-letter): Rewrite using parenthesized
5691 # with-statements.
5692 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5693 with contextlib.ExitStack() as stack: 19
5694 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 19
5695 stack.enter_context( 19
5696 tests.isolated_config(
5697 monkeypatch=monkeypatch,
5698 runner=runner,
5699 )
5700 )
5701 result = runner.invoke( 19
5702 cli.derivepassphrase,
5703 ['export'],
5704 )
5705 assert tests.deprecation_warning_emitted( 19
5706 'A subcommand will be required here in v1.0', caplog.record_tuples
5707 )
5708 assert tests.deprecation_warning_emitted( 19
5709 'Defaulting to subcommand "vault"', caplog.record_tuples
5710 )
5711 assert result.error_exit(error="Missing argument 'PATH'"), ( 19
5712 'expected error exit and known error type'
5713 )
5715 @Parametrize.CHARSET_NAME
5716 def test_210_forward_vault_disable_character_set(
5717 self,
5718 caplog: pytest.LogCaptureFixture,
5719 charset_name: str,
5720 ) -> None:
5721 """Forwarding arguments from top-level to "vault" works."""
5722 option = f'--{charset_name}' 1J
5723 charset = vault.Vault.CHARSETS[charset_name].decode('ascii') 1J
5724 runner = tests.CliRunner(mix_stderr=False) 1J
5725 # TODO(the-13th-letter): Rewrite using parenthesized
5726 # with-statements.
5727 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5728 with contextlib.ExitStack() as stack: 1J
5729 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1J
5730 stack.enter_context( 1J
5731 tests.isolated_config(
5732 monkeypatch=monkeypatch,
5733 runner=runner,
5734 )
5735 )
5736 monkeypatch.setattr( 1J
5737 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt
5738 )
5739 result = runner.invoke( 1J
5740 cli.derivepassphrase,
5741 [option, '0', '-p', '--', DUMMY_SERVICE],
5742 input=DUMMY_PASSPHRASE,
5743 catch_exceptions=False,
5744 )
5745 assert result.clean_exit(empty_stderr=False), 'expected clean exit' 1J
5746 assert tests.deprecation_warning_emitted( 1J
5747 'A subcommand will be required here in v1.0', caplog.record_tuples
5748 )
5749 assert tests.deprecation_warning_emitted( 1J
5750 'Defaulting to subcommand "vault"', caplog.record_tuples
5751 )
5752 for c in charset: 1J
5753 assert c not in result.stdout, ( 1J
5754 f'derived password contains forbidden character {c!r}'
5755 )
5757 def test_211_forward_vault_empty_command_line(
5758 self,
5759 caplog: pytest.LogCaptureFixture,
5760 ) -> None:
5761 """Deferring from top-level to "vault" works."""
5762 runner = tests.CliRunner(mix_stderr=False) 1(
5763 # TODO(the-13th-letter): Rewrite using parenthesized
5764 # with-statements.
5765 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5766 with contextlib.ExitStack() as stack: 1(
5767 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1(
5768 stack.enter_context( 1(
5769 tests.isolated_config(
5770 monkeypatch=monkeypatch,
5771 runner=runner,
5772 )
5773 )
5774 result = runner.invoke( 1(
5775 cli.derivepassphrase,
5776 [],
5777 input=DUMMY_PASSPHRASE,
5778 catch_exceptions=False,
5779 )
5780 assert tests.deprecation_warning_emitted( 1(
5781 'A subcommand will be required here in v1.0', caplog.record_tuples
5782 )
5783 assert tests.deprecation_warning_emitted( 1(
5784 'Defaulting to subcommand "vault"', caplog.record_tuples
5785 )
5786 assert result.error_exit( 1(
5787 error='Deriving a passphrase requires a SERVICE.'
5788 ), 'expected error exit and known error type'
5790 def test_300_export_using_old_config_file(
5791 self,
5792 caplog: pytest.LogCaptureFixture,
5793 ) -> None:
5794 """Exporting from (and migrating) the old settings file works."""
5795 caplog.set_level(logging.INFO) 15
5796 runner = tests.CliRunner(mix_stderr=False) 15
5797 # TODO(the-13th-letter): Rewrite using parenthesized
5798 # with-statements.
5799 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5800 with contextlib.ExitStack() as stack: 15
5801 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 15
5802 stack.enter_context( 15
5803 tests.isolated_config(
5804 monkeypatch=monkeypatch,
5805 runner=runner,
5806 )
5807 )
5808 cli_helpers.config_filename( 15
5809 subsystem='old settings.json'
5810 ).write_text(
5811 json.dumps(
5812 {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}},
5813 indent=2,
5814 )
5815 + '\n',
5816 encoding='UTF-8',
5817 )
5818 result = runner.invoke( 15
5819 cli.derivepassphrase_vault,
5820 ['--export', '-'],
5821 catch_exceptions=False,
5822 )
5823 assert result.clean_exit(), 'expected clean exit' 15
5824 assert tests.deprecation_warning_emitted( 15
5825 'v0.1-style config file', caplog.record_tuples
5826 ), 'expected known warning message in stderr'
5827 assert tests.deprecation_info_emitted( 15
5828 'Successfully migrated to ', caplog.record_tuples
5829 ), 'expected known warning message in stderr'
5831 def test_300a_export_using_old_config_file_migration_error(
5832 self,
5833 caplog: pytest.LogCaptureFixture,
5834 ) -> None:
5835 """Exporting from (and not migrating) the old settings file fails."""
5836 runner = tests.CliRunner(mix_stderr=False) 1K
5837 # TODO(the-13th-letter): Rewrite using parenthesized
5838 # with-statements.
5839 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5840 with contextlib.ExitStack() as stack: 1K
5841 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1K
5842 stack.enter_context( 1K
5843 tests.isolated_config(
5844 monkeypatch=monkeypatch,
5845 runner=runner,
5846 )
5847 )
5848 cli_helpers.config_filename( 1K
5849 subsystem='old settings.json'
5850 ).write_text(
5851 json.dumps(
5852 {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}},
5853 indent=2,
5854 )
5855 + '\n',
5856 encoding='UTF-8',
5857 )
5859 def raiser(*_args: Any, **_kwargs: Any) -> None: 1K
5860 raise OSError( 1K
5861 errno.EACCES,
5862 os.strerror(errno.EACCES),
5863 cli_helpers.config_filename(subsystem='vault'),
5864 )
5866 monkeypatch.setattr(os, 'replace', raiser) 1K
5867 monkeypatch.setattr(pathlib.Path, 'rename', raiser) 1K
5868 result = runner.invoke( 1K
5869 cli.derivepassphrase_vault,
5870 ['--export', '-'],
5871 catch_exceptions=False,
5872 )
5873 assert result.clean_exit(), 'expected clean exit' 1K
5874 assert tests.deprecation_warning_emitted( 1K
5875 'v0.1-style config file', caplog.record_tuples
5876 ), 'expected known warning message in stderr'
5877 assert tests.warning_emitted( 1K
5878 'Failed to migrate to ', caplog.record_tuples
5879 ), 'expected known warning message in stderr'
5881 def test_400_completion_service_name_old_config_file(
5882 self,
5883 ) -> None:
5884 """Completing service names from the old settings file works."""
5885 config = {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}} 16
5886 runner = tests.CliRunner(mix_stderr=False) 16
5887 # TODO(the-13th-letter): Rewrite using parenthesized
5888 # with-statements.
5889 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5890 with contextlib.ExitStack() as stack: 16
5891 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 16
5892 stack.enter_context( 16
5893 tests.isolated_vault_config(
5894 monkeypatch=monkeypatch,
5895 runner=runner,
5896 vault_config=config,
5897 )
5898 )
5899 old_name = cli_helpers.config_filename( 16
5900 subsystem='old settings.json'
5901 )
5902 new_name = cli_helpers.config_filename(subsystem='vault') 16
5903 old_name.unlink(missing_ok=True) 16
5904 new_name.rename(old_name) 16
5905 assert cli_helpers.shell_complete_service( 16
5906 click.Context(cli.derivepassphrase),
5907 click.Argument(['some_parameter']),
5908 '',
5909 ) == [DUMMY_SERVICE]
5912KNOWN_SERVICES = (DUMMY_SERVICE, 'email', 'bank', 'work')
5913"""Known service names. Used for the [`ConfigManagementStateMachine`][]."""
5914VALID_PROPERTIES = (
5915 'length',
5916 'repeat',
5917 'upper',
5918 'lower',
5919 'number',
5920 'space',
5921 'dash',
5922 'symbol',
5923)
5924"""Known vault properties. Used for the [`ConfigManagementStateMachine`][]."""
5927def build_reduced_vault_config_settings(
5928 config: _types.VaultConfigServicesSettings,
5929 keys_to_prune: frozenset[str],
5930) -> _types.VaultConfigServicesSettings:
5931 """Return a service settings object with certain keys pruned.
5933 Args:
5934 config:
5935 The original service settings object.
5936 keys_to_prune:
5937 The keys to prune from the settings object.
5939 """
5940 config2 = copy.deepcopy(config) 1b
5941 for key in keys_to_prune: 1b
5942 config2.pop(key, None) # type: ignore[misc] 1b
5943 return config2 1b
5946SERVICES_STRATEGY = strategies.builds(
5947 build_reduced_vault_config_settings,
5948 tests.vault_full_service_config(),
5949 strategies.sets(
5950 strategies.sampled_from(VALID_PROPERTIES),
5951 max_size=7,
5952 ),
5953)
5954"""A hypothesis strategy to build incomplete service configurations."""
5957def services_strategy() -> strategies.SearchStrategy[
5958 _types.VaultConfigServicesSettings
5959]:
5960 """Return a strategy to build incomplete service configurations."""
5961 return SERVICES_STRATEGY 1ab
5964def assemble_config(
5965 global_data: _types.VaultConfigGlobalSettings,
5966 service_data: list[tuple[str, _types.VaultConfigServicesSettings]],
5967) -> _types.VaultConfig:
5968 """Return a vault config using the global and service data."""
5969 services_dict = dict(service_data) 1b
5970 return ( 1b
5971 {'global': global_data, 'services': services_dict}
5972 if global_data
5973 else {'services': services_dict}
5974 )
5977@strategies.composite
5978def draw_service_name_and_data(
5979 draw: hypothesis.strategies.DrawFn,
5980 num_entries: int,
5981) -> tuple[tuple[str, _types.VaultConfigServicesSettings], ...]:
5982 """Draw a service name and settings, as a hypothesis strategy.
5984 Will draw service names from [`KNOWN_SERVICES`][] and service
5985 settings via [`services_strategy`][].
5987 Args:
5988 draw:
5989 The `draw` function, as provided for by hypothesis.
5990 num_entries:
5991 The number of services to draw.
5993 Returns:
5994 A sequence of pairs of service names and service settings.
5996 """
5997 possible_services = list(KNOWN_SERVICES) 1b
5998 selected_services: list[str] = [] 1b
5999 for _ in range(num_entries): 1b
6000 selected_services.append( 1b
6001 draw(strategies.sampled_from(possible_services))
6002 )
6003 possible_services.remove(selected_services[-1]) 1b
6004 return tuple( 1b
6005 (service, draw(services_strategy())) for service in selected_services
6006 )
6009VAULT_FULL_CONFIG = strategies.builds(
6010 assemble_config,
6011 services_strategy(),
6012 strategies.integers(
6013 min_value=2,
6014 max_value=4,
6015 ).flatmap(draw_service_name_and_data),
6016)
6017"""A hypothesis strategy to build full vault configurations."""
6020def vault_full_config() -> strategies.SearchStrategy[_types.VaultConfig]:
6021 """Return a strategy to build full vault configurations."""
6022 return VAULT_FULL_CONFIG
6025class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
6026 """A state machine recording changes in the vault configuration.
6028 Record possible configuration states in bundles, then in each rule,
6029 take a configuration and manipulate it somehow.
6031 Attributes:
6032 setting:
6033 A bundle for single-service settings.
6034 configuration:
6035 A bundle for full vault configurations.
6037 """
6039 def __init__(self) -> None:
6040 """Initialize self, set up context managers and enter them."""
6041 super().__init__() 1b
6042 self.runner = tests.CliRunner(mix_stderr=False) 1b
6043 self.exit_stack = contextlib.ExitStack().__enter__() 1b
6044 self.monkeypatch = self.exit_stack.enter_context( 1b
6045 pytest.MonkeyPatch().context()
6046 )
6047 self.isolated_config = self.exit_stack.enter_context( 1b
6048 tests.isolated_vault_config(
6049 monkeypatch=self.monkeypatch,
6050 runner=self.runner,
6051 vault_config={'services': {}},
6052 )
6053 )
6055 setting: stateful.Bundle[_types.VaultConfigServicesSettings] = (
6056 stateful.Bundle('setting')
6057 )
6058 """"""
6059 configuration: stateful.Bundle[_types.VaultConfig] = stateful.Bundle(
6060 'configuration'
6061 )
6062 """"""
6064 @stateful.initialize(
6065 target=configuration,
6066 configs=strategies.lists(
6067 vault_full_config(),
6068 min_size=8,
6069 max_size=8,
6070 ),
6071 )
6072 def declare_initial_configs(
6073 self,
6074 configs: Iterable[_types.VaultConfig],
6075 ) -> stateful.MultipleResults[_types.VaultConfig]:
6076 """Initialize the configuration bundle with eight configurations."""
6077 return stateful.multiple(*configs) 1b
6079 @stateful.initialize(
6080 target=setting,
6081 configs=strategies.lists(
6082 vault_full_config(),
6083 min_size=4,
6084 max_size=4,
6085 ),
6086 )
6087 def extract_initial_settings(
6088 self,
6089 configs: list[_types.VaultConfig],
6090 ) -> stateful.MultipleResults[_types.VaultConfigServicesSettings]:
6091 """Initialize the settings bundle with four service settings."""
6092 settings: list[_types.VaultConfigServicesSettings] = [] 1b
6093 for c in configs: 1b
6094 settings.extend(c['services'].values()) 1b
6095 return stateful.multiple(*map(copy.deepcopy, settings)) 1b
6097 @staticmethod
6098 def fold_configs(
6099 c1: _types.VaultConfig, c2: _types.VaultConfig
6100 ) -> _types.VaultConfig:
6101 """Fold `c1` into `c2`, overriding the latter."""
6102 new_global_dict = c1.get('global', c2.get('global')) 1b
6103 if new_global_dict is not None: 1b
6104 return { 1b
6105 'global': new_global_dict,
6106 'services': {**c2['services'], **c1['services']},
6107 }
6108 return { 1b
6109 'services': {**c2['services'], **c1['services']},
6110 }
6112 @stateful.rule(
6113 target=configuration,
6114 config=configuration,
6115 setting=setting.filter(bool),
6116 maybe_unset=strategies.sets(
6117 strategies.sampled_from(VALID_PROPERTIES),
6118 max_size=3,
6119 ),
6120 overwrite=strategies.booleans(),
6121 )
6122 def set_globals(
6123 self,
6124 config: _types.VaultConfig,
6125 setting: _types.VaultConfigGlobalSettings,
6126 maybe_unset: set[str],
6127 overwrite: bool,
6128 ) -> _types.VaultConfig:
6129 """Set the global settings of a configuration.
6131 Args:
6132 config:
6133 The configuration to edit.
6134 setting:
6135 The new global settings.
6136 maybe_unset:
6137 Settings keys to additionally unset, if not already
6138 present in the new settings. Corresponds to the
6139 `--unset` command-line argument.
6140 overwrite:
6141 Overwrite the settings object if true, or merge if
6142 false. Corresponds to the `--overwrite-existing` and
6143 `--merge-existing` command-line arguments.
6145 Returns:
6146 The amended configuration.
6148 """
6149 cli_helpers.save_config(config) 1b
6150 config_global = config.get('global', {}) 1b
6151 maybe_unset = set(maybe_unset) - setting.keys() 1b
6152 if overwrite: 1b
6153 config['global'] = config_global = {} 1b
6154 elif maybe_unset: 1b
6155 for key in maybe_unset: 1b
6156 config_global.pop(key, None) # type: ignore[misc] 1b
6157 config.setdefault('global', {}).update(setting) 1b
6158 assert _types.is_vault_config(config) 1b
6159 # NOTE: This relies on settings_obj containing only the keys
6160 # "length", "repeat", "upper", "lower", "number", "space",
6161 # "dash" and "symbol".
6162 result = self.runner.invoke( 1b
6163 cli.derivepassphrase_vault,
6164 [
6165 '--config',
6166 '--overwrite-existing' if overwrite else '--merge-existing',
6167 ]
6168 + [f'--unset={key}' for key in maybe_unset]
6169 + [
6170 f'--{key}={value}'
6171 for key, value in setting.items()
6172 if key in VALID_PROPERTIES
6173 ],
6174 catch_exceptions=False,
6175 )
6176 assert result.clean_exit(empty_stderr=False) 1b
6177 assert cli_helpers.load_config() == config 1b
6178 return config 1b
6180 @stateful.rule(
6181 target=configuration,
6182 config=configuration,
6183 service=strategies.sampled_from(KNOWN_SERVICES),
6184 setting=setting.filter(bool),
6185 maybe_unset=strategies.sets(
6186 strategies.sampled_from(VALID_PROPERTIES),
6187 max_size=3,
6188 ),
6189 overwrite=strategies.booleans(),
6190 )
6191 def set_service(
6192 self,
6193 config: _types.VaultConfig,
6194 service: str,
6195 setting: _types.VaultConfigServicesSettings,
6196 maybe_unset: set[str],
6197 overwrite: bool,
6198 ) -> _types.VaultConfig:
6199 """Set the named service settings for a configuration.
6201 Args:
6202 config:
6203 The configuration to edit.
6204 service:
6205 The name of the service to set.
6206 setting:
6207 The new service settings.
6208 maybe_unset:
6209 Settings keys to additionally unset, if not already
6210 present in the new settings. Corresponds to the
6211 `--unset` command-line argument.
6212 overwrite:
6213 Overwrite the settings object if true, or merge if
6214 false. Corresponds to the `--overwrite-existing` and
6215 `--merge-existing` command-line arguments.
6217 Returns:
6218 The amended configuration.
6220 """
6221 cli_helpers.save_config(config) 1b
6222 config_service = config['services'].get(service, {}) 1b
6223 maybe_unset = set(maybe_unset) - setting.keys() 1b
6224 if overwrite: 1b
6225 config['services'][service] = config_service = {} 1b
6226 elif maybe_unset: 1b
6227 for key in maybe_unset: 1b
6228 config_service.pop(key, None) # type: ignore[misc] 1b
6229 config['services'].setdefault(service, {}).update(setting) 1b
6230 assert _types.is_vault_config(config) 1b
6231 # NOTE: This relies on settings_obj containing only the keys
6232 # "length", "repeat", "upper", "lower", "number", "space",
6233 # "dash" and "symbol".
6234 result = self.runner.invoke( 1b
6235 cli.derivepassphrase_vault,
6236 [
6237 '--config',
6238 '--overwrite-existing' if overwrite else '--merge-existing',
6239 ]
6240 + [f'--unset={key}' for key in maybe_unset]
6241 + [
6242 f'--{key}={value}'
6243 for key, value in setting.items()
6244 if key in VALID_PROPERTIES
6245 ]
6246 + ['--', service],
6247 catch_exceptions=False,
6248 )
6249 assert result.clean_exit(empty_stderr=False) 1b
6250 assert cli_helpers.load_config() == config 1b
6251 return config 1b
6253 @stateful.rule(
6254 target=configuration,
6255 config=configuration,
6256 )
6257 def purge_global(
6258 self,
6259 config: _types.VaultConfig,
6260 ) -> _types.VaultConfig:
6261 """Purge the globals of a configuration.
6263 Args:
6264 config:
6265 The configuration to edit.
6267 Returns:
6268 The pruned configuration.
6270 """
6271 cli_helpers.save_config(config) 1b
6272 config.pop('global', None) 1b
6273 result = self.runner.invoke( 1b
6274 cli.derivepassphrase_vault,
6275 ['--delete-globals'],
6276 input='y',
6277 catch_exceptions=False,
6278 )
6279 assert result.clean_exit(empty_stderr=False) 1b
6280 assert cli_helpers.load_config() == config 1b
6281 return config 1b
6283 @stateful.rule(
6284 target=configuration,
6285 config_and_service=configuration.filter(
6286 lambda c: bool(c['services'])
6287 ).flatmap(
6288 lambda c: strategies.tuples(
6289 strategies.just(c),
6290 strategies.sampled_from(tuple(c['services'].keys())),
6291 )
6292 ),
6293 )
6294 def purge_service(
6295 self,
6296 config_and_service: tuple[_types.VaultConfig, str],
6297 ) -> _types.VaultConfig:
6298 """Purge the settings of a named service in a configuration.
6300 Args:
6301 config_and_service:
6302 A 2-tuple containing the configuration to edit, and the
6303 service name to purge.
6305 Returns:
6306 The pruned configuration.
6308 """
6309 config, service = config_and_service 1b
6310 cli_helpers.save_config(config) 1b
6311 config['services'].pop(service, None) 1b
6312 result = self.runner.invoke( 1b
6313 cli.derivepassphrase_vault,
6314 ['--delete', '--', service],
6315 input='y',
6316 catch_exceptions=False,
6317 )
6318 assert result.clean_exit(empty_stderr=False) 1b
6319 assert cli_helpers.load_config() == config 1b
6320 return config 1b
6322 @stateful.rule(
6323 target=configuration,
6324 config=configuration,
6325 )
6326 def purge_all(
6327 self,
6328 config: _types.VaultConfig,
6329 ) -> _types.VaultConfig:
6330 """Purge the entire configuration.
6332 Args:
6333 config:
6334 The configuration to edit.
6336 Returns:
6337 The empty configuration.
6339 """
6340 cli_helpers.save_config(config) 1b
6341 config = {'services': {}} 1b
6342 result = self.runner.invoke( 1b
6343 cli.derivepassphrase_vault,
6344 ['--clear'],
6345 input='y',
6346 catch_exceptions=False,
6347 )
6348 assert result.clean_exit(empty_stderr=False) 1b
6349 assert cli_helpers.load_config() == config 1b
6350 return config 1b
6352 @stateful.rule(
6353 target=configuration,
6354 base_config=configuration,
6355 config_to_import=configuration,
6356 overwrite=strategies.booleans(),
6357 )
6358 def import_configuration(
6359 self,
6360 base_config: _types.VaultConfig,
6361 config_to_import: _types.VaultConfig,
6362 overwrite: bool,
6363 ) -> _types.VaultConfig:
6364 """Import the given configuration into a base configuration.
6366 Args:
6367 base_config:
6368 The configuration to import into.
6369 config_to_import:
6370 The configuration to import.
6371 overwrite:
6372 Overwrite the base configuration if true, or merge if
6373 false. Corresponds to the `--overwrite-existing` and
6374 `--merge-existing` command-line arguments.
6376 Returns:
6377 The imported or merged configuration.
6379 """
6380 cli_helpers.save_config(base_config) 1b
6381 config = ( 1b
6382 self.fold_configs(config_to_import, base_config)
6383 if not overwrite
6384 else config_to_import
6385 )
6386 assert _types.is_vault_config(config) 1b
6387 result = self.runner.invoke( 1b
6388 cli.derivepassphrase_vault,
6389 ['--import', '-']
6390 + (['--overwrite-existing'] if overwrite else []),
6391 input=json.dumps(config_to_import),
6392 catch_exceptions=False,
6393 )
6394 assert result.clean_exit(empty_stderr=False) 1b
6395 assert cli_helpers.load_config() == config 1b
6396 return config 1b
6398 def teardown(self) -> None:
6399 """Upon teardown, exit all contexts entered in `__init__`."""
6400 self.exit_stack.close() 1b
6403TestConfigManagement = ConfigManagementStateMachine.TestCase
6404"""The [`unittest.TestCase`][] class that will actually be run."""
6407class FakeConfigurationMutexAction(NamedTuple):
6408 """An action/a step in the [`FakeConfigurationMutexStateMachine`][].
6410 Attributes:
6411 command_line:
6412 The command-line for `derivepassphrase vault` to execute.
6413 input:
6414 The input to this command.
6416 """
6418 command_line: list[str]
6419 """"""
6420 input: str | bytes | None = None
6421 """"""
6424def run_actions_handler(
6425 id_num: int,
6426 action: FakeConfigurationMutexAction,
6427 *,
6428 input_queue: queue.Queue,
6429 output_queue: queue.Queue,
6430 timeout: int,
6431) -> None:
6432 """Prepare the faked mutex, then run `action`.
6434 This is a top-level handler function -- to be used in a new
6435 [`multiprocessing.Process`][] -- to run a single action from the
6436 [`FakeConfigurationMutexStateMachine`][]. Output from this function
6437 must be sent down the output queue instead of relying on the call
6438 stack. Additionally, because this runs in a separate process, we
6439 need to restart coverage tracking if it is currently running.
6441 Args:
6442 id_num:
6443 The internal ID of this subprocess.
6444 action:
6445 The action to execute.
6446 input_queue:
6447 The queue for data passed from the manager/parent process to
6448 this subprocess.
6449 output_queue:
6450 The queue for data passed from this subprocess to the
6451 manager/parent process.
6452 timeout:
6453 The maximum amount of time to wait for a data transfer along
6454 the input or the output queue. If exceeded, we exit
6455 immediately.
6457 """
6458 with pytest.MonkeyPatch.context() as monkeypatch:
6459 monkeypatch.setattr(
6460 cli_helpers,
6461 'configuration_mutex',
6462 lambda: FakeConfigurationMutexStateMachine.ConfigurationMutexStub(
6463 my_id=id_num,
6464 input_queue=input_queue,
6465 output_queue=output_queue,
6466 timeout=timeout,
6467 ),
6468 )
6469 runner = tests.CliRunner(mix_stderr=False)
6470 try:
6471 result = runner.invoke(
6472 cli.derivepassphrase_vault,
6473 args=action.command_line,
6474 input=action.input,
6475 catch_exceptions=True,
6476 )
6477 output_queue.put(
6478 FakeConfigurationMutexStateMachine.IPCMessage(
6479 id_num,
6480 'result',
6481 (
6482 result.clean_exit(empty_stderr=False),
6483 copy.copy(result.stdout),
6484 copy.copy(result.stderr),
6485 ),
6486 ),
6487 block=True,
6488 timeout=timeout,
6489 )
6490 except Exception as exc: # pragma: no cover # noqa: BLE001
6491 output_queue.put(
6492 FakeConfigurationMutexStateMachine.IPCMessage(
6493 id_num, 'exception', exc
6494 ),
6495 block=False,
6496 )
6499@hypothesis.settings(
6500 stateful_step_count=tests.get_concurrency_step_count(),
6501 deadline=None,
6502)
6503class FakeConfigurationMutexStateMachine(stateful.RuleBasedStateMachine):
6504 """A state machine simulating the (faked) configuration mutex.
6506 Generate an ordered set of concurrent writers to the
6507 derivepassphrase configuration, then test that the writers' accesses
6508 are serialized correctly, i.e., test that the writers correctly use
6509 the mutex to avoid concurrent accesses, under the assumption that
6510 the mutex itself is correctly implemented.
6512 We use a custom mutex implementation to both ensure that all writers
6513 attempt to lock the configuration at the same time and that the lock
6514 is granted in our desired order. This test is therefore independent
6515 of the actual (operating system-specific) mutex implementation in
6516 `derivepassphrase`.
6518 Attributes:
6519 setting:
6520 A bundle for single-service settings.
6521 configuration:
6522 A bundle for full vault configurations.
6524 """
6526 class IPCMessage(NamedTuple):
6527 """A message for inter-process communication.
6529 Used by the configuration mutex stub class to affect/signal the
6530 control flow amongst the linked mutex clients.
6532 Attributes:
6533 child_id:
6534 The ID of the sending or receiving child process.
6535 message:
6536 One of "ready", "go", "config", "result" or "exception".
6537 payload:
6538 The (optional) message payload.
6540 """
6542 child_id: int
6543 """"""
6544 message: Literal['ready', 'go', 'config', 'result', 'exception']
6545 """"""
6546 payload: object | None
6547 """"""
6549 class ConfigurationMutexStub(cli_helpers.ConfigurationMutex):
6550 """Configuration mutex subclass that enforces a locking order.
6552 Each configuration mutex stub object ("mutex client") has an
6553 associated ID, and one read-only and one write-only pipe
6554 (actually: [`multiprocessing.Queue`][] objects) to the "manager"
6555 instance coordinating these stub objects. First, the mutex
6556 client signals readiness, then the manager signals when the
6557 mutex shall be considered "acquired", then finally the mutex
6558 client sends the result back (simultaneously releasing the mutex
6559 again). The manager may optionally send an abort signal if the
6560 operations take too long.
6562 This subclass also copies the effective vault configuration
6563 to `intermediate_configs` upon releasing the lock.
6565 """
6567 def __init__(
6568 self,
6569 *,
6570 my_id: int,
6571 timeout: int,
6572 input_queue: queue.Queue[
6573 FakeConfigurationMutexStateMachine.IPCMessage
6574 ],
6575 output_queue: queue.Queue[
6576 FakeConfigurationMutexStateMachine.IPCMessage
6577 ],
6578 ) -> None:
6579 """Initialize this mutex client.
6581 Args:
6582 my_id:
6583 The ID of this client.
6584 timeout:
6585 The timeout for each get and put operation on the
6586 queues.
6587 input_queue:
6588 The message queue for IPC messages from the manager
6589 instance to this mutex client.
6590 output_queue:
6591 The message queue for IPC messages from this mutex
6592 client to the manager instance.
6594 """
6595 super().__init__()
6597 def lock() -> None:
6598 """Simulate locking of the mutex.
6600 Issue a "ready" message, wait for a "go", then return.
6601 If an exception occurs, issue an "exception" message,
6602 then raise the exception.
6604 """
6605 IPCMessage: TypeAlias = (
6606 FakeConfigurationMutexStateMachine.IPCMessage
6607 )
6608 try:
6609 output_queue.put(
6610 IPCMessage(my_id, 'ready', None),
6611 block=True,
6612 timeout=timeout,
6613 )
6614 ok = input_queue.get(block=True, timeout=timeout)
6615 if ok != IPCMessage(my_id, 'go', None): # pragma: no cover
6616 output_queue.put(
6617 IPCMessage(my_id, 'exception', ok), block=False
6618 )
6619 raise (
6620 ok[2]
6621 if isinstance(ok[2], BaseException)
6622 else RuntimeError(ok[2])
6623 )
6624 except (queue.Empty, queue.Full) as exc: # pragma: no cover
6625 output_queue.put(
6626 IPCMessage(my_id, 'exception', exc), block=False
6627 )
6628 return
6630 def unlock() -> None:
6631 """Simulate unlocking of the mutex.
6633 Issue a "config" message, then return. If an exception
6634 occurs, issue an "exception" message, then raise the
6635 exception.
6637 """
6638 IPCMessage: TypeAlias = (
6639 FakeConfigurationMutexStateMachine.IPCMessage
6640 )
6641 try:
6642 output_queue.put(
6643 IPCMessage(
6644 my_id,
6645 'config',
6646 copy.copy(cli_helpers.load_config()),
6647 ),
6648 block=True,
6649 timeout=timeout,
6650 )
6651 except (queue.Empty, queue.Full) as exc: # pragma: no cover
6652 output_queue.put(
6653 IPCMessage(my_id, 'exception', exc), block=False
6654 )
6655 raise
6657 self.lock = lock
6658 self.unlock = unlock
6660 setting: stateful.Bundle[_types.VaultConfigServicesSettings] = (
6661 stateful.Bundle('setting')
6662 )
6663 """"""
6664 configuration: stateful.Bundle[_types.VaultConfig] = stateful.Bundle(
6665 'configuration'
6666 )
6667 """"""
6669 def __init__(self, *args: Any, **kwargs: Any) -> None:
6670 """Initialize the state machine."""
6671 super().__init__(*args, **kwargs) 1b
6672 self.actions: list[FakeConfigurationMutexAction] = [] 1b
6673 # Determine the step count by poking around in the hypothesis
6674 # internals. As this isn't guaranteed to be stable, turn off
6675 # coverage.
6676 try: # pragma: no cover 1b
6677 settings: hypothesis.settings | None
6678 settings = FakeConfigurationMutexStateMachine.TestCase.settings 1b
6679 except AttributeError: # pragma: no cover
6680 settings = None
6681 self.step_count = tests.get_concurrency_step_count(settings) 1b
6683 @stateful.initialize(
6684 target=configuration,
6685 configs=strategies.lists(
6686 vault_full_config(),
6687 min_size=8,
6688 max_size=8,
6689 ),
6690 )
6691 def declare_initial_configs(
6692 self,
6693 configs: list[_types.VaultConfig],
6694 ) -> stateful.MultipleResults[_types.VaultConfig]:
6695 """Initialize the configuration bundle with eight configurations."""
6696 return stateful.multiple(*configs) 1b
6698 @stateful.initialize(
6699 target=setting,
6700 configs=strategies.lists(
6701 vault_full_config(),
6702 min_size=4,
6703 max_size=4,
6704 ),
6705 )
6706 def extract_initial_settings(
6707 self,
6708 configs: list[_types.VaultConfig],
6709 ) -> stateful.MultipleResults[_types.VaultConfigServicesSettings]:
6710 """Initialize the settings bundle with four service settings."""
6711 settings: list[_types.VaultConfigServicesSettings] = [] 1b
6712 for c in configs: 1b
6713 settings.extend(c['services'].values()) 1b
6714 return stateful.multiple(*map(copy.deepcopy, settings)) 1b
6716 @stateful.initialize(
6717 config=vault_full_config(),
6718 )
6719 def declare_initial_action(
6720 self,
6721 config: _types.VaultConfig,
6722 ) -> None:
6723 """Initialize the actions bundle from the configuration bundle.
6725 This is roughly comparable to the
6726 [`add_import_configuration_action`][] general rule, but adding
6727 it as a separate initialize rule avoids having to guard every
6728 other action-amending rule against empty action sequences, which
6729 would discard huge portions of the rule selection search space
6730 and thus trigger loads of hypothesis health check warnings.
6732 """
6733 command_line = ['--import', '-', '--overwrite-existing'] 1b
6734 input = json.dumps(config) # noqa: A001 1b
6735 hypothesis.note(f'# {command_line = }, {input = }') 1b
6736 action = FakeConfigurationMutexAction( 1b
6737 command_line=command_line, input=input
6738 )
6739 self.actions.append(action) 1b
6741 @stateful.rule(
6742 setting=setting.filter(bool),
6743 maybe_unset=strategies.sets(
6744 strategies.sampled_from(VALID_PROPERTIES),
6745 max_size=3,
6746 ),
6747 overwrite=strategies.booleans(),
6748 )
6749 def add_set_globals_action(
6750 self,
6751 setting: _types.VaultConfigGlobalSettings,
6752 maybe_unset: set[str],
6753 overwrite: bool,
6754 ) -> None:
6755 """Set the global settings of a configuration.
6757 Args:
6758 setting:
6759 The new global settings.
6760 maybe_unset:
6761 Settings keys to additionally unset, if not already
6762 present in the new settings. Corresponds to the
6763 `--unset` command-line argument.
6764 overwrite:
6765 Overwrite the settings object if true, or merge if
6766 false. Corresponds to the `--overwrite-existing` and
6767 `--merge-existing` command-line arguments.
6769 """
6770 maybe_unset = set(maybe_unset) - setting.keys() 1b
6771 command_line = ( 1b
6772 [
6773 '--config',
6774 '--overwrite-existing' if overwrite else '--merge-existing',
6775 ]
6776 + [f'--unset={key}' for key in maybe_unset]
6777 + [
6778 f'--{key}={value}'
6779 for key, value in setting.items()
6780 if key in VALID_PROPERTIES
6781 ]
6782 )
6783 input = None # noqa: A001 1b
6784 hypothesis.note(f'# {command_line = }, {input = }') 1b
6785 action = FakeConfigurationMutexAction( 1b
6786 command_line=command_line, input=input
6787 )
6788 self.actions.append(action) 1b
6790 @stateful.rule(
6791 service=strategies.sampled_from(KNOWN_SERVICES),
6792 setting=setting.filter(bool),
6793 maybe_unset=strategies.sets(
6794 strategies.sampled_from(VALID_PROPERTIES),
6795 max_size=3,
6796 ),
6797 overwrite=strategies.booleans(),
6798 )
6799 def add_set_service_action(
6800 self,
6801 service: str,
6802 setting: _types.VaultConfigServicesSettings,
6803 maybe_unset: set[str],
6804 overwrite: bool,
6805 ) -> None:
6806 """Set the named service settings for a configuration.
6808 Args:
6809 service:
6810 The name of the service to set.
6811 setting:
6812 The new service settings.
6813 maybe_unset:
6814 Settings keys to additionally unset, if not already
6815 present in the new settings. Corresponds to the
6816 `--unset` command-line argument.
6817 overwrite:
6818 Overwrite the settings object if true, or merge if
6819 false. Corresponds to the `--overwrite-existing` and
6820 `--merge-existing` command-line arguments.
6822 """
6823 maybe_unset = set(maybe_unset) - setting.keys() 1b
6824 command_line = ( 1b
6825 [
6826 '--config',
6827 '--overwrite-existing' if overwrite else '--merge-existing',
6828 ]
6829 + [f'--unset={key}' for key in maybe_unset]
6830 + [
6831 f'--{key}={value}'
6832 for key, value in setting.items()
6833 if key in VALID_PROPERTIES
6834 ]
6835 + ['--', service]
6836 )
6837 input = None # noqa: A001 1b
6838 hypothesis.note(f'# {command_line = }, {input = }') 1b
6839 action = FakeConfigurationMutexAction( 1b
6840 command_line=command_line, input=input
6841 )
6842 self.actions.append(action) 1b
6844 @stateful.rule()
6845 def add_purge_global_action(
6846 self,
6847 ) -> None:
6848 """Purge the globals of a configuration."""
6849 command_line = ['--delete-globals'] 1b
6850 input = None # 'y' # noqa: A001 1b
6851 hypothesis.note(f'# {command_line = }, {input = }') 1b
6852 action = FakeConfigurationMutexAction( 1b
6853 command_line=command_line, input=input
6854 )
6855 self.actions.append(action) 1b
6857 @stateful.rule(
6858 service=strategies.sampled_from(KNOWN_SERVICES),
6859 )
6860 def add_purge_service_action(
6861 self,
6862 service: str,
6863 ) -> None:
6864 """Purge the settings of a named service in a configuration.
6866 Args:
6867 service:
6868 The service name to purge.
6870 """
6871 command_line = ['--delete', '--', service] 1b
6872 input = None # 'y' # noqa: A001 1b
6873 hypothesis.note(f'# {command_line = }, {input = }') 1b
6874 action = FakeConfigurationMutexAction( 1b
6875 command_line=command_line, input=input
6876 )
6877 self.actions.append(action) 1b
6879 @stateful.rule()
6880 def add_purge_all_action(
6881 self,
6882 ) -> None:
6883 """Purge the entire configuration."""
6884 command_line = ['--clear'] 1b
6885 input = None # 'y' # noqa: A001 1b
6886 hypothesis.note(f'# {command_line = }, {input = }') 1b
6887 action = FakeConfigurationMutexAction( 1b
6888 command_line=command_line, input=input
6889 )
6890 self.actions.append(action) 1b
6892 @stateful.rule(
6893 config_to_import=configuration,
6894 overwrite=strategies.booleans(),
6895 )
6896 def add_import_configuration_action(
6897 self,
6898 config_to_import: _types.VaultConfig,
6899 overwrite: bool,
6900 ) -> None:
6901 """Import the given configuration.
6903 Args:
6904 config_to_import:
6905 The configuration to import.
6906 overwrite:
6907 Overwrite the base configuration if true, or merge if
6908 false. Corresponds to the `--overwrite-existing` and
6909 `--merge-existing` command-line arguments.
6911 """
6912 command_line = ['--import', '-'] + ( 1b
6913 ['--overwrite-existing'] if overwrite else []
6914 )
6915 input = json.dumps(config_to_import) # noqa: A001 1b
6916 hypothesis.note(f'# {command_line = }, {input = }') 1b
6917 action = FakeConfigurationMutexAction( 1b
6918 command_line=command_line, input=input
6919 )
6920 self.actions.append(action) 1b
6922 @stateful.precondition(lambda self: len(self.actions) > 0) 1ab
6923 @stateful.invariant()
6924 def run_actions( # noqa: C901
6925 self,
6926 ) -> None:
6927 """Run the actions, serially and concurrently.
6929 Run the actions once serially, then once more concurrently with
6930 the faked configuration mutex, and assert that both runs yield
6931 identical intermediate and final results.
6933 We must run the concurrent version in processes, not threads or
6934 Python async functions, because the `click` testing machinery
6935 manipulates global properties (e.g. the standard I/O streams,
6936 the current directory, and the environment), and we require this
6937 manipulation to happen in a time-overlapped manner.
6939 However, running multiple processes increases the risk of the
6940 operating system imposing process count or memory limits on us.
6941 We therefore skip the test as a whole if we fail to start a new
6942 process due to lack of necessary resources (memory, processes,
6943 or open file descriptors).
6945 """
6946 if not TYPE_CHECKING: # pragma: no branch 1b
6947 multiprocessing = pytest.importorskip('multiprocessing') 1b
6948 IPCMessage: TypeAlias = FakeConfigurationMutexStateMachine.IPCMessage 1b
6949 intermediate_configs: dict[int, _types.VaultConfig] = {} 1b
6950 intermediate_results: dict[ 1b
6951 int, tuple[bool, str | None, str | None]
6952 ] = {}
6953 true_configs: dict[int, _types.VaultConfig] = {} 1b
6954 true_results: dict[int, tuple[bool, str | None, str | None]] = {} 1b
6955 timeout = 30 # Hopefully slow enough to accomodate The Annoying OS. 1b
6956 actions = self.actions 1b
6957 mp = multiprocessing.get_context() 1b
6958 # Coverage tracking writes coverage data to the current working
6959 # directory, but because the subprocesses are spawned within the
6960 # `tests.isolated_vault_config` context manager, their starting
6961 # working directory is the isolated one, not the original one.
6962 orig_cwd = pathlib.Path.cwd() 1b
6964 fatal_process_creation_errnos = { 1b
6965 # Specified by POSIX for fork(3).
6966 errno.ENOMEM,
6967 # Specified by POSIX for fork(3).
6968 errno.EAGAIN,
6969 # Specified by Linux/glibc for fork(3)
6970 getattr(errno, 'ENOSYS', errno.ENOMEM),
6971 # Specified by POSIX for posix_spawn(3).
6972 errno.EINVAL,
6973 }
6975 hypothesis.note(f'# {actions = }') 1b
6977 stack = contextlib.ExitStack() 1b
6978 with stack: 1b
6979 runner = tests.CliRunner(mix_stderr=False) 1b
6980 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1b
6981 stack.enter_context( 1b
6982 tests.isolated_vault_config(
6983 monkeypatch=monkeypatch,
6984 runner=runner,
6985 vault_config={'services': {}},
6986 )
6987 )
6988 for i, action in enumerate(actions): 1b
6989 result = runner.invoke( 1b
6990 cli.derivepassphrase_vault,
6991 args=action.command_line,
6992 input=action.input,
6993 catch_exceptions=True,
6994 )
6995 true_configs[i] = copy.copy(cli_helpers.load_config()) 1b
6996 true_results[i] = ( 1b
6997 result.clean_exit(empty_stderr=False),
6998 result.stdout,
6999 result.stderr,
7000 )
7002 with stack: # noqa: PLR1702 1b
7003 runner = tests.CliRunner(mix_stderr=False) 1b
7004 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1b
7005 stack.enter_context( 1b
7006 tests.isolated_vault_config(
7007 monkeypatch=monkeypatch,
7008 runner=runner,
7009 vault_config={'services': {}},
7010 )
7011 )
7013 child_output_queue: multiprocessing.Queue[IPCMessage] = mp.Queue() 1b
7014 child_input_queues: list[ 1b
7015 multiprocessing.Queue[IPCMessage] | None
7016 ] = []
7017 processes: list[multiprocessing.process.BaseProcess] = [] 1b
7018 processes_pending: set[multiprocessing.process.BaseProcess] = set() 1b
7019 ready_wait: set[int] = set() 1b
7021 try: 1b
7022 for i, action in enumerate(actions): 1b
7023 q: multiprocessing.Queue[IPCMessage] | None = mp.Queue() 1b
7024 try: 1b
7025 p: multiprocessing.process.BaseProcess = mp.Process( 1b
7026 name=f'fake-mutex-action-{i:02d}',
7027 target=run_actions_handler,
7028 kwargs={
7029 'id_num': i,
7030 'timeout': timeout,
7031 'action': action,
7032 'input_queue': q,
7033 'output_queue': child_output_queue,
7034 },
7035 daemon=False,
7036 )
7037 p.start() 1b
7038 except OSError as exc: # pragma: no cover
7039 if exc.errno in fatal_process_creation_errnos:
7040 pytest.skip(
7041 'cannot test mutex functionality due to '
7042 'lack of system resources for '
7043 'creating enough subprocesses'
7044 )
7045 raise
7046 else:
7047 processes.append(p) 1b
7048 processes_pending.add(p) 1b
7049 child_input_queues.append(q) 1b
7050 ready_wait.add(i) 1b
7052 while processes_pending: 1b
7053 try: 1b
7054 self.mainloop( 1b
7055 timeout=timeout,
7056 child_output_queue=child_output_queue,
7057 child_input_queues=child_input_queues,
7058 ready_wait=ready_wait,
7059 intermediate_configs=intermediate_configs,
7060 intermediate_results=intermediate_results,
7061 processes=processes,
7062 processes_pending=processes_pending,
7063 block=True,
7064 )
7065 except Exception as exc: # pragma: no cover
7066 for i, q in enumerate(child_input_queues):
7067 if q:
7068 q.put(IPCMessage(i, 'exception', exc))
7069 for p in processes_pending:
7070 p.join(timeout=timeout)
7071 raise
7072 finally:
7073 try: 1b
7074 while True: 1b
7075 try: 1b
7076 self.mainloop( 1b
7077 timeout=timeout,
7078 child_output_queue=child_output_queue,
7079 child_input_queues=child_input_queues,
7080 ready_wait=ready_wait,
7081 intermediate_configs=intermediate_configs,
7082 intermediate_results=intermediate_results,
7083 processes=processes,
7084 processes_pending=processes_pending,
7085 block=False,
7086 )
7087 except queue.Empty: 1b
7088 break 1b
7089 finally:
7090 # The subprocesses have this
7091 # `tests.isolated_vault_config` directory as their
7092 # startup and working directory, so systems like
7093 # coverage tracking write their data files to this
7094 # directory. We need to manually move them back to
7095 # the starting working directory if they are to
7096 # survive this test.
7097 for coverage_file in pathlib.Path.cwd().glob( 1b
7098 '.coverage.*'
7099 ):
7100 shutil.move(coverage_file, orig_cwd) 1b
7101 hypothesis.note( 1b
7102 f'# {true_results = }, {intermediate_results = }, '
7103 f'identical = {true_results == intermediate_results}'
7104 )
7105 hypothesis.note( 1b
7106 f'# {true_configs = }, {intermediate_configs = }, '
7107 f'identical = {true_configs == intermediate_configs}'
7108 )
7109 assert intermediate_results == true_results 1b
7110 assert intermediate_configs == true_configs 1b
7112 @staticmethod
7113 def mainloop(
7114 *,
7115 timeout: int,
7116 child_output_queue: multiprocessing.Queue[
7117 FakeConfigurationMutexStateMachine.IPCMessage
7118 ],
7119 child_input_queues: list[
7120 multiprocessing.Queue[
7121 FakeConfigurationMutexStateMachine.IPCMessage
7122 ]
7123 | None
7124 ],
7125 ready_wait: set[int],
7126 intermediate_configs: dict[int, _types.VaultConfig],
7127 intermediate_results: dict[int, tuple[bool, str | None, str | None]],
7128 processes: list[multiprocessing.process.BaseProcess],
7129 processes_pending: set[multiprocessing.process.BaseProcess],
7130 block: bool = True,
7131 ) -> None:
7132 IPCMessage: TypeAlias = FakeConfigurationMutexStateMachine.IPCMessage 1b
7133 msg = child_output_queue.get(block=block, timeout=timeout) 1b
7134 # TODO(the-13th-letter): Rewrite using structural pattern
7135 # matching.
7136 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
7137 if ( # pragma: no cover 1b
7138 isinstance(msg, IPCMessage)
7139 and msg[1] == 'exception'
7140 and isinstance(msg[2], Exception)
7141 ):
7142 e = msg[2]
7143 raise e
7144 if isinstance(msg, IPCMessage) and msg[1] == 'ready': 1b
7145 n = msg[0] 1b
7146 ready_wait.remove(n) 1b
7147 if not ready_wait: 1b
7148 assert child_input_queues 1b
7149 assert child_input_queues[0] 1b
7150 child_input_queues[0].put( 1b
7151 IPCMessage(0, 'go', None),
7152 block=True,
7153 timeout=timeout,
7154 )
7155 elif isinstance(msg, IPCMessage) and msg[1] == 'config': 1b
7156 n = msg[0] 1b
7157 config = msg[2] 1b
7158 intermediate_configs[n] = cast('_types.VaultConfig', config) 1b
7159 elif isinstance(msg, IPCMessage) and msg[1] == 'result': 1b
7160 n = msg[0] 1b
7161 result_ = msg[2] 1b
7162 result_tuple: tuple[bool, str | None, str | None] = cast( 1b
7163 'tuple[bool, str | None, str | None]', result_
7164 )
7165 intermediate_results[n] = result_tuple 1b
7166 child_input_queues[n] = None 1b
7167 p = processes[n] 1b
7168 p.join(timeout=timeout) 1b
7169 assert not p.is_alive() 1b
7170 processes_pending.remove(p) 1b
7171 assert result_tuple[0], ( 1b
7172 f'action #{n} exited with an error: {result_tuple!r}'
7173 )
7174 if n + 1 < len(processes): 1b
7175 next_child_input_queue = child_input_queues[n + 1] 1b
7176 assert next_child_input_queue 1b
7177 next_child_input_queue.put( 1b
7178 IPCMessage(n + 1, 'go', None),
7179 block=True,
7180 timeout=timeout,
7181 )
7182 else:
7183 raise AssertionError()
7186TestFakedConfigurationMutex = tests.skip_if_no_multiprocessing_support(
7187 FakeConfigurationMutexStateMachine.TestCase
7188)
7189"""The [`unittest.TestCase`][] class that will actually be run."""
7192def completion_item(
7193 item: str | click.shell_completion.CompletionItem,
7194) -> click.shell_completion.CompletionItem:
7195 """Convert a string to a completion item, if necessary."""
7196 return ( 1m
7197 click.shell_completion.CompletionItem(item, type='plain')
7198 if isinstance(item, str)
7199 else item
7200 )
7203def assertable_item(
7204 item: str | click.shell_completion.CompletionItem,
7205) -> tuple[str, Any, str | None]:
7206 """Convert a completion item into a pretty-printable item.
7208 Intended to make completion items introspectable in pytest's
7209 `assert` output.
7211 """
7212 item = completion_item(item) 1m
7213 return (item.type, item.value, item.help) 1m
7216class TestShellCompletion:
7217 """Tests for the shell completion machinery."""
7219 class Completions:
7220 """A deferred completion call."""
7222 def __init__(
7223 self,
7224 args: Sequence[str],
7225 incomplete: str,
7226 ) -> None:
7227 """Initialize the object.
7229 Args:
7230 args:
7231 The sequence of complete command-line arguments.
7232 incomplete:
7233 The final, incomplete, partial argument.
7235 """
7236 self.args = tuple(args) 1)*!Pu
7237 self.incomplete = incomplete 1)*!Pu
7239 def __call__(self) -> Sequence[click.shell_completion.CompletionItem]:
7240 """Return the completion items."""
7241 args = list(self.args) 1)*!Pu
7242 completion = click.shell_completion.ShellComplete( 1)*!Pu
7243 cli=cli.derivepassphrase,
7244 ctx_args={},
7245 prog_name='derivepassphrase',
7246 complete_var='_DERIVEPASSPHRASE_COMPLETE',
7247 )
7248 return completion.get_completions(args, self.incomplete) 1)*!Pu
7250 def get_words(self) -> Sequence[str]:
7251 """Return the completion items' values, as a sequence."""
7252 return tuple(c.value for c in self()) 1)*Pu
7254 @Parametrize.COMPLETABLE_ITEMS
7255 def test_100_is_completable_item(
7256 self,
7257 partial: str,
7258 is_completable: bool,
7259 ) -> None:
7260 """Our `_is_completable_item` predicate for service names works."""
7261 assert cli_helpers.is_completable_item(partial) == is_completable 2nb
7263 @Parametrize.COMPLETABLE_OPTIONS
7264 def test_200_options(
7265 self,
7266 command_prefix: Sequence[str],
7267 incomplete: str,
7268 completions: AbstractSet[str],
7269 ) -> None:
7270 """Our completion machinery works for all commands' options."""
7271 comp = self.Completions(command_prefix, incomplete) 1)
7272 assert frozenset(comp.get_words()) == completions 1)
7274 @Parametrize.COMPLETABLE_SUBCOMMANDS
7275 def test_201_subcommands(
7276 self,
7277 command_prefix: Sequence[str],
7278 incomplete: str,
7279 completions: AbstractSet[str],
7280 ) -> None:
7281 """Our completion machinery works for all commands' subcommands."""
7282 comp = self.Completions(command_prefix, incomplete) 1*
7283 assert frozenset(comp.get_words()) == completions 1*
7285 @Parametrize.COMPLETABLE_PATH_ARGUMENT
7286 @Parametrize.INCOMPLETE
7287 def test_202_paths(
7288 self,
7289 command_prefix: Sequence[str],
7290 incomplete: str,
7291 ) -> None:
7292 """Our completion machinery works for all commands' paths."""
7293 file = click.shell_completion.CompletionItem('', type='file') 1!
7294 completions = frozenset({(file.type, file.value, file.help)}) 1!
7295 comp = self.Completions(command_prefix, incomplete) 1!
7296 assert ( 1!
7297 frozenset((x.type, x.value, x.help) for x in comp()) == completions
7298 )
7300 @Parametrize.COMPLETABLE_SERVICE_NAMES
7301 def test_203_service_names(
7302 self,
7303 config: _types.VaultConfig,
7304 incomplete: str,
7305 completions: AbstractSet[str],
7306 ) -> None:
7307 """Our completion machinery works for vault service names."""
7308 runner = tests.CliRunner(mix_stderr=False) 1P
7309 # TODO(the-13th-letter): Rewrite using parenthesized
7310 # with-statements.
7311 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
7312 with contextlib.ExitStack() as stack: 1P
7313 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1P
7314 stack.enter_context( 1P
7315 tests.isolated_vault_config(
7316 monkeypatch=monkeypatch,
7317 runner=runner,
7318 vault_config=config,
7319 )
7320 )
7321 comp = self.Completions(['vault'], incomplete) 1P
7322 assert frozenset(comp.get_words()) == completions 1P
7324 @Parametrize.SHELL_FORMATTER
7325 @Parametrize.COMPLETION_FUNCTION_INPUTS
7326 def test_300_shell_completion_formatting(
7327 self,
7328 shell: str,
7329 format_func: Callable[[click.shell_completion.CompletionItem], str],
7330 config: _types.VaultConfig,
7331 comp_func: Callable[
7332 [click.Context, click.Parameter, str],
7333 list[str | click.shell_completion.CompletionItem],
7334 ],
7335 args: list[str],
7336 incomplete: str,
7337 results: list[str | click.shell_completion.CompletionItem],
7338 ) -> None:
7339 """Custom completion functions work for all shells."""
7340 runner = tests.CliRunner(mix_stderr=False) 1m
7341 # TODO(the-13th-letter): Rewrite using parenthesized
7342 # with-statements.
7343 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
7344 with contextlib.ExitStack() as stack: 1m
7345 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1m
7346 stack.enter_context( 1m
7347 tests.isolated_vault_config(
7348 monkeypatch=monkeypatch,
7349 runner=runner,
7350 vault_config=config,
7351 )
7352 )
7353 expected_items = [assertable_item(item) for item in results] 1m
7354 expected_string = '\n'.join( 1m
7355 format_func(completion_item(item)) for item in results
7356 )
7357 manual_raw_items = comp_func( 1m
7358 click.Context(cli.derivepassphrase),
7359 click.Argument(['sample_parameter']),
7360 incomplete,
7361 )
7362 manual_items = [assertable_item(item) for item in manual_raw_items] 1m
7363 manual_string = '\n'.join( 1m
7364 format_func(completion_item(item)) for item in manual_raw_items
7365 )
7366 assert manual_items == expected_items 1m
7367 assert manual_string == expected_string 1m
7368 comp_class = click.shell_completion.get_completion_class(shell) 1m
7369 assert comp_class is not None 1m
7370 comp = comp_class( 1m
7371 cli.derivepassphrase,
7372 {},
7373 'derivepassphrase',
7374 '_DERIVEPASSPHRASE_COMPLETE',
7375 )
7376 monkeypatch.setattr( 1m
7377 comp,
7378 'get_completion_args',
7379 lambda *_a, **_kw: (args, incomplete),
7380 )
7381 actual_raw_items = comp.get_completions( 1m
7382 *comp.get_completion_args()
7383 )
7384 actual_items = [assertable_item(item) for item in actual_raw_items] 1m
7385 actual_string = comp.complete() 1m
7386 assert actual_items == expected_items 1m
7387 assert actual_string == expected_string 1m
7389 @Parametrize.CONFIG_SETTING_MODE
7390 @Parametrize.SERVICE_NAME_COMPLETION_INPUTS
7391 def test_400_incompletable_service_names(
7392 self,
7393 caplog: pytest.LogCaptureFixture,
7394 mode: Literal['config', 'import'],
7395 config: _types.VaultConfig,
7396 key: str,
7397 incomplete: str,
7398 completions: AbstractSet[str],
7399 ) -> None:
7400 """Completion skips incompletable items."""
7401 vault_config = config if mode == 'config' else {'services': {}} 1u
7402 runner = tests.CliRunner(mix_stderr=False) 1u
7403 # TODO(the-13th-letter): Rewrite using parenthesized
7404 # with-statements.
7405 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
7406 with contextlib.ExitStack() as stack: 1u
7407 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1u
7408 stack.enter_context( 1u
7409 tests.isolated_vault_config(
7410 monkeypatch=monkeypatch,
7411 runner=runner,
7412 vault_config=vault_config,
7413 )
7414 )
7415 if mode == 'config': 1u
7416 result = runner.invoke( 1u
7417 cli.derivepassphrase_vault,
7418 ['--config', '--length=10', '--', key],
7419 catch_exceptions=False,
7420 )
7421 else:
7422 result = runner.invoke( 1u
7423 cli.derivepassphrase_vault,
7424 ['--import', '-'],
7425 catch_exceptions=False,
7426 input=json.dumps(config),
7427 )
7428 assert result.clean_exit(), 'expected clean exit' 1u
7429 assert tests.warning_emitted( 1u
7430 'contains an ASCII control character', caplog.record_tuples
7431 ), 'expected known warning message in stderr'
7432 assert tests.warning_emitted( 1u
7433 'not be available for completion', caplog.record_tuples
7434 ), 'expected known warning message in stderr'
7435 assert cli_helpers.load_config() == config 1u
7436 comp = self.Completions(['vault'], incomplete) 1u
7437 assert frozenset(comp.get_words()) == completions 1u
7439 def test_410a_service_name_exceptions_not_found(
7440 self,
7441 ) -> None:
7442 """Service name completion quietly fails on missing configuration."""
7443 runner = tests.CliRunner(mix_stderr=False) 2jb
7444 # TODO(the-13th-letter): Rewrite using parenthesized
7445 # with-statements.
7446 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
7447 with contextlib.ExitStack() as stack: 2jb
7448 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2jb
7449 stack.enter_context( 2jb
7450 tests.isolated_vault_config(
7451 monkeypatch=monkeypatch,
7452 runner=runner,
7453 vault_config={
7454 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
7455 },
7456 )
7457 )
7458 cli_helpers.config_filename(subsystem='vault').unlink( 2jb
7459 missing_ok=True
7460 )
7461 assert not cli_helpers.shell_complete_service( 2jb
7462 click.Context(cli.derivepassphrase),
7463 click.Argument(['some_parameter']),
7464 '',
7465 )
7467 @Parametrize.SERVICE_NAME_EXCEPTIONS
7468 def test_410b_service_name_exceptions_custom_error(
7469 self,
7470 exc_type: type[Exception],
7471 ) -> None:
7472 """Service name completion quietly fails on configuration errors."""
7473 runner = tests.CliRunner(mix_stderr=False) 1+
7474 # TODO(the-13th-letter): Rewrite using parenthesized
7475 # with-statements.
7476 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
7477 with contextlib.ExitStack() as stack: 1+
7478 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1+
7479 stack.enter_context( 1+
7480 tests.isolated_vault_config(
7481 monkeypatch=monkeypatch,
7482 runner=runner,
7483 vault_config={
7484 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
7485 },
7486 )
7487 )
7489 def raiser(*_a: Any, **_kw: Any) -> NoReturn: 1+
7490 raise exc_type('just being difficult') # noqa: EM101,TRY003 1+
7492 monkeypatch.setattr(cli_helpers, 'load_config', raiser) 1+
7493 assert not cli_helpers.shell_complete_service( 1+
7494 click.Context(cli.derivepassphrase),
7495 click.Argument(['some_parameter']),
7496 '',
7497 )