Marco Ricci commited on 2025-08-09 16:22:42
Zeige 5 geänderte Dateien mit 4745 Einfügungen und 4597 Löschungen.
The CLI tests, already loosely grouped through the use of classes, are now distributed to different files, one file per test class. This is mostly an attempt to keep the file size managable. Navigating in a multi-thousand-line Python file with very similar looking tests gets disorienting very quickly.
... | ... |
@@ -4,30 +4,19 @@ |
4 | 4 |
|
5 | 5 |
from __future__ import annotations |
6 | 6 |
|
7 |
-import base64 |
|
8 | 7 |
import contextlib |
9 | 8 |
import copy |
10 |
-import ctypes |
|
11 |
-import enum |
|
12 | 9 |
import errno |
13 |
-import io |
|
14 | 10 |
import json |
15 |
-import logging |
|
16 |
-import operator |
|
17 | 11 |
import os |
18 | 12 |
import pathlib |
19 |
-import re |
|
20 |
-import shlex |
|
21 | 13 |
import shutil |
22 | 14 |
import socket |
23 |
-import tempfile |
|
24 | 15 |
import textwrap |
25 | 16 |
import types |
26 |
-import warnings |
|
27 | 17 |
from typing import TYPE_CHECKING |
28 | 18 |
|
29 | 19 |
import click.testing |
30 |
-import exceptiongroup |
|
31 | 20 |
import hypothesis |
32 | 21 |
import pytest |
33 | 22 |
from hypothesis import strategies |
... | ... |
@@ -36,18 +25,14 @@ from typing_extensions import Any, NamedTuple |
36 | 25 |
from derivepassphrase import _types, cli, ssh_agent, vault |
37 | 26 |
from derivepassphrase._internals import ( |
38 | 27 |
cli_helpers, |
39 |
- cli_machinery, |
|
40 | 28 |
cli_messages, |
41 | 29 |
) |
42 |
-from derivepassphrase.ssh_agent import socketprovider |
|
43 | 30 |
from tests import data, machinery |
44 | 31 |
from tests.data import callables |
45 | 32 |
from tests.machinery import hypothesis as hypothesis_machinery |
46 | 33 |
from tests.machinery import pytest as pytest_machinery |
47 | 34 |
|
48 | 35 |
if TYPE_CHECKING: |
49 |
- from collections.abc import Callable, Iterable, Iterator, Sequence |
|
50 |
- from collections.abc import Set as AbstractSet |
|
51 | 36 |
from typing import NoReturn |
52 | 37 |
|
53 | 38 |
from typing_extensions import Literal |
... | ... |
@@ -90,43 +75,6 @@ class OptionCombination(NamedTuple): |
90 | 75 |
check_success: bool |
91 | 76 |
|
92 | 77 |
|
93 |
-class VersionOutputData(NamedTuple): |
|
94 |
- derivation_schemes: dict[str, bool] |
|
95 |
- foreign_configuration_formats: dict[str, bool] |
|
96 |
- extras: frozenset[str] |
|
97 |
- subcommands: frozenset[str] |
|
98 |
- features: dict[str, bool] |
|
99 |
- |
|
100 |
- |
|
101 |
-class KnownLineType(str, enum.Enum): |
|
102 |
- SUPPORTED_FOREIGN_CONFS = cli_messages.Label.SUPPORTED_FOREIGN_CONFIGURATION_FORMATS.value.singular.rstrip( |
|
103 |
- ":" |
|
104 |
- ) |
|
105 |
- UNAVAILABLE_FOREIGN_CONFS = cli_messages.Label.UNAVAILABLE_FOREIGN_CONFIGURATION_FORMATS.value.singular.rstrip( |
|
106 |
- ":" |
|
107 |
- ) |
|
108 |
- SUPPORTED_SCHEMES = ( |
|
109 |
- cli_messages.Label.SUPPORTED_DERIVATION_SCHEMES.value.singular.rstrip( |
|
110 |
- ":" |
|
111 |
- ) |
|
112 |
- ) |
|
113 |
- UNAVAILABLE_SCHEMES = cli_messages.Label.UNAVAILABLE_DERIVATION_SCHEMES.value.singular.rstrip( |
|
114 |
- ":" |
|
115 |
- ) |
|
116 |
- SUPPORTED_SUBCOMMANDS = ( |
|
117 |
- cli_messages.Label.SUPPORTED_SUBCOMMANDS.value.singular.rstrip(":") |
|
118 |
- ) |
|
119 |
- SUPPORTED_FEATURES = ( |
|
120 |
- cli_messages.Label.SUPPORTED_FEATURES.value.singular.rstrip(":") |
|
121 |
- ) |
|
122 |
- UNAVAILABLE_FEATURES = ( |
|
123 |
- cli_messages.Label.UNAVAILABLE_FEATURES.value.singular.rstrip(":") |
|
124 |
- ) |
|
125 |
- ENABLED_EXTRAS = ( |
|
126 |
- cli_messages.Label.ENABLED_PEP508_EXTRAS.value.singular.rstrip(":") |
|
127 |
- ) |
|
128 |
- |
|
129 |
- |
|
130 | 78 |
PASSPHRASE_GENERATION_OPTIONS: list[tuple[str, ...]] = [ |
131 | 79 |
("--phrase",), |
132 | 80 |
("--key",), |
... | ... |
@@ -320,535 +268,12 @@ def assert_vault_config_is_indented_and_line_broken( |
320 | 268 |
]) |
321 | 269 |
|
322 | 270 |
|
323 |
-def vault_config_exporter_shell_interpreter( # noqa: C901 |
|
324 |
- script: str | Iterable[str], |
|
325 |
- /, |
|
326 |
- *, |
|
327 |
- prog_name_list: list[str] | None = None, |
|
328 |
- command: click.BaseCommand | None = None, |
|
329 |
- runner: machinery.CliRunner | None = None, |
|
330 |
-) -> Iterator[machinery.ReadableResult]: |
|
331 |
- """A rudimentary sh(1) interpreter for `--export-as=sh` output. |
|
332 |
- |
|
333 |
- Assumes a script as emitted by `derivepassphrase vault |
|
334 |
- --export-as=sh --export -` and interprets the calls to |
|
335 |
- `derivepassphrase vault` within. (One call per line, skips all |
|
336 |
- other lines.) Also has rudimentary support for (quoted) |
|
337 |
- here-documents using `HERE` as the marker. |
|
338 |
- |
|
339 |
- """ |
|
340 |
- if isinstance(script, str): # pragma: no cover |
|
341 |
- script = script.splitlines(False) |
|
342 |
- if prog_name_list is None: # pragma: no cover |
|
343 |
- prog_name_list = ["derivepassphrase", "vault"] |
|
344 |
- if command is None: # pragma: no cover |
|
345 |
- command = cli.derivepassphrase_vault |
|
346 |
- if runner is None: # pragma: no cover |
|
347 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
348 |
- n = len(prog_name_list) |
|
349 |
- it = iter(script) |
|
350 |
- while True: |
|
351 |
- try: |
|
352 |
- raw_line = next(it) |
|
353 |
- except StopIteration: |
|
354 |
- break |
|
355 |
- else: |
|
356 |
- line = shlex.split(raw_line) |
|
357 |
- input_buffer: list[str] = [] |
|
358 |
- if line[:n] != prog_name_list: |
|
359 |
- continue |
|
360 |
- line[:n] = [] |
|
361 |
- if line and line[-1] == "<<HERE": |
|
362 |
- # naive HERE document support |
|
363 |
- while True: |
|
364 |
- try: |
|
365 |
- raw_line = next(it) |
|
366 |
- except StopIteration as exc: # pragma: no cover |
|
367 |
- msg = "incomplete here document" |
|
368 |
- raise EOFError(msg) from exc |
|
369 |
- else: |
|
370 |
- if raw_line == "HERE": |
|
371 |
- break |
|
372 |
- input_buffer.append(raw_line) |
|
373 |
- line.pop() |
|
374 |
- yield runner.invoke( |
|
375 |
- command, |
|
376 |
- line, |
|
377 |
- catch_exceptions=False, |
|
378 |
- input=("".join(x + "\n" for x in input_buffer) or None), |
|
379 |
- ) |
|
380 |
- |
|
381 |
- |
|
382 |
-def parse_version_output( # noqa: C901 |
|
383 |
- version_output: str, |
|
384 |
- /, |
|
385 |
- *, |
|
386 |
- prog_name: str | None = cli_messages.PROG_NAME, |
|
387 |
- version: str | None = cli_messages.VERSION, |
|
388 |
-) -> VersionOutputData: |
|
389 |
- r"""Parse the output of the `--version` option. |
|
390 |
- |
|
391 |
- The version output contains two paragraphs. The first paragraph |
|
392 |
- details the version number, and the version number of any major |
|
393 |
- libraries in use. The second paragraph details known and supported |
|
394 |
- passphrase derivation schemes, foreign configuration formats, |
|
395 |
- subcommands and PEP 508 package extras. For the schemes and |
|
396 |
- formats, there is a "supported" line for supported items, and |
|
397 |
- a "known" line for known but currently unsupported items (usually |
|
398 |
- because of missing dependencies), either of which may be empty and |
|
399 |
- thus omitted. For extras, only active items are shown, and there is |
|
400 |
- a separate message for the "no extras active" case. Item lists may |
|
401 |
- be spilled across multiple lines, but only at item boundaries, and |
|
402 |
- the continuation lines are then indented. |
|
403 |
- |
|
404 |
- Args: |
|
405 |
- version_output: |
|
406 |
- The version output text to parse. |
|
407 |
- prog_name: |
|
408 |
- The program name to assert, defaulting to the true program |
|
409 |
- name, `derivepassphrase`. Set to `None` to disable this |
|
410 |
- check. |
|
411 |
- version: |
|
412 |
- The program version to assert, defaulting to the true |
|
413 |
- current version of `derivepassphrase`. Set to `None` to |
|
414 |
- disable this check. |
|
415 |
- |
|
416 |
- Examples: |
|
417 |
- See [`Parametrize.VERSION_OUTPUT_DATA`][]. |
|
418 |
- |
|
419 |
- """ |
|
420 |
- paragraphs: list[list[str]] = [] |
|
421 |
- paragraph: list[str] = [] |
|
422 |
- for line in version_output.splitlines(keepends=False): |
|
423 |
- if not line.strip(): |
|
424 |
- if paragraph: |
|
425 |
- paragraphs.append(paragraph.copy()) |
|
426 |
- paragraph.clear() |
|
427 |
- elif paragraph and line.lstrip() != line: |
|
428 |
- paragraph[-1] = f"{paragraph[-1]} {line.lstrip()}" |
|
429 |
- else: |
|
430 |
- paragraph.append(line) |
|
431 |
- if paragraph: # pragma: no branch |
|
432 |
- paragraphs.append(paragraph.copy()) |
|
433 |
- paragraph.clear() |
|
434 |
- assert paragraphs, ( |
|
435 |
- f"expected at least one paragraph of version output: {paragraphs!r}" |
|
436 |
- ) |
|
437 |
- assert prog_name is None or prog_name in paragraphs[0][0], ( |
|
438 |
- f"first version output line should mention " |
|
439 |
- f"{prog_name}: {paragraphs[0][0]!r}" |
|
440 |
- ) |
|
441 |
- assert version is None or version in paragraphs[0][0], ( |
|
442 |
- f"first version output line should mention the version number " |
|
443 |
- f"{version}: {paragraphs[0][0]!r}" |
|
444 |
- ) |
|
445 |
- schemes: dict[str, bool] = {} |
|
446 |
- formats: dict[str, bool] = {} |
|
447 |
- subcommands: set[str] = set() |
|
448 |
- extras: set[str] = set() |
|
449 |
- features: dict[str, bool] = {} |
|
450 |
- if len(paragraphs) < 2: # pragma: no cover |
|
451 |
- return VersionOutputData( |
|
452 |
- derivation_schemes=schemes, |
|
453 |
- foreign_configuration_formats=formats, |
|
454 |
- subcommands=frozenset(subcommands), |
|
455 |
- extras=frozenset(extras), |
|
456 |
- features=features, |
|
457 |
- ) |
|
458 |
- for line in paragraphs[1]: |
|
459 |
- line_type, _, value = line.partition(":") |
|
460 |
- if line_type == line: |
|
461 |
- continue |
|
462 |
- for item_ in re.split(r"(?:, *|.$)", value): |
|
463 |
- item = item_.strip() |
|
464 |
- if not item: |
|
465 |
- continue |
|
466 |
- if line_type == KnownLineType.SUPPORTED_FOREIGN_CONFS: |
|
467 |
- formats[item] = True |
|
468 |
- elif line_type == KnownLineType.UNAVAILABLE_FOREIGN_CONFS: |
|
469 |
- formats[item] = False |
|
470 |
- elif line_type == KnownLineType.SUPPORTED_SCHEMES: |
|
471 |
- schemes[item] = True |
|
472 |
- elif line_type == KnownLineType.UNAVAILABLE_SCHEMES: |
|
473 |
- schemes[item] = False |
|
474 |
- elif line_type == KnownLineType.SUPPORTED_SUBCOMMANDS: |
|
475 |
- subcommands.add(item) |
|
476 |
- elif line_type == KnownLineType.ENABLED_EXTRAS: |
|
477 |
- extras.add(item) |
|
478 |
- elif line_type == KnownLineType.SUPPORTED_FEATURES: |
|
479 |
- features[item] = True |
|
480 |
- elif line_type == KnownLineType.UNAVAILABLE_FEATURES: |
|
481 |
- features[item] = False |
|
482 |
- else: |
|
483 |
- raise AssertionError( # noqa: TRY003 |
|
484 |
- f"Unknown version info line type: {line_type!r}" # noqa: EM102 |
|
485 |
- ) |
|
486 |
- return VersionOutputData( |
|
487 |
- derivation_schemes=schemes, |
|
488 |
- foreign_configuration_formats=formats, |
|
489 |
- subcommands=frozenset(subcommands), |
|
490 |
- extras=frozenset(extras), |
|
491 |
- features=features, |
|
492 |
- ) |
|
493 |
- |
|
494 |
- |
|
495 |
-def bash_format(item: click.shell_completion.CompletionItem) -> str: |
|
496 |
- """A formatter for `bash`-style shell completion items. |
|
497 |
- |
|
498 |
- The format is `type,value`, and is dictated by [`click`][]. |
|
499 |
- |
|
500 |
- """ |
|
501 |
- type, value = ( # noqa: A001 |
|
502 |
- item.type, |
|
503 |
- item.value, |
|
504 |
- ) |
|
505 |
- return f"{type},{value}" |
|
506 |
- |
|
507 |
- |
|
508 |
-def fish_format(item: click.shell_completion.CompletionItem) -> str: |
|
509 |
- r"""A formatter for `fish`-style shell completion items. |
|
510 |
- |
|
511 |
- The format is `type,value<tab>help`, and is dictated by [`click`][]. |
|
512 |
- |
|
513 |
- """ |
|
514 |
- type, value, help = ( # noqa: A001 |
|
515 |
- item.type, |
|
516 |
- item.value, |
|
517 |
- item.help, |
|
518 |
- ) |
|
519 |
- return f"{type},{value}\t{help}" if help else f"{type},{value}" |
|
520 |
- |
|
521 |
- |
|
522 |
-def zsh_format(item: click.shell_completion.CompletionItem) -> str: |
|
523 |
- r"""A formatter for `zsh`-style shell completion items. |
|
524 |
- |
|
525 |
- The format is `type<newline>value<newline>help<newline>`, and is |
|
526 |
- dictated by [`click`][]. Upstream `click` currently (v8.2.0) does |
|
527 |
- not deal with colons in the value correctly when the help text is |
|
528 |
- non-degenerate. Our formatter here does, provided the upstream |
|
529 |
- `zsh` completion script is used; see the |
|
530 |
- [`cli_machinery.ZshComplete`][] class. A request is underway to |
|
531 |
- merge this change into upstream `click`; see |
|
532 |
- [`pallets/click#2846`][PR2846]. |
|
533 |
- |
|
534 |
- [PR2846]: https://github.com/pallets/click/pull/2846 |
|
535 |
- |
|
536 |
- """ |
|
537 |
- empty_help = "_" |
|
538 |
- help_, value = ( |
|
539 |
- (item.help, item.value.replace(":", r"\:")) |
|
540 |
- if item.help and item.help == empty_help |
|
541 |
- else (empty_help, item.value) |
|
542 |
- ) |
|
543 |
- return f"{item.type}\n{value}\n{help_}" |
|
544 |
- |
|
545 |
- |
|
546 |
-class ListKeysAction(str, enum.Enum): |
|
547 |
- """Test fixture settings for [`ssh_agent.SSHAgentClient.list_keys`][]. |
|
548 |
- |
|
549 |
- Attributes: |
|
550 |
- EMPTY: Return an empty key list. |
|
551 |
- FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][]. |
|
552 |
- FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][]. |
|
553 |
- |
|
554 |
- """ |
|
555 |
- |
|
556 |
- EMPTY = enum.auto() |
|
557 |
- """""" |
|
558 |
- FAIL = enum.auto() |
|
559 |
- """""" |
|
560 |
- FAIL_RUNTIME = enum.auto() |
|
561 |
- """""" |
|
562 |
- |
|
563 |
- def __call__(self, *_args: Any, **_kwargs: Any) -> Any: |
|
564 |
- """Execute the respective action.""" |
|
565 |
- # TODO(the-13th-letter): Rewrite using structural pattern |
|
566 |
- # matching. |
|
567 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
568 |
- if self == self.EMPTY: |
|
569 |
- return [] |
|
570 |
- if self == self.FAIL: |
|
571 |
- raise ssh_agent.SSHAgentFailedError( |
|
572 |
- _types.SSH_AGENT.FAILURE.value, b"" |
|
573 |
- ) |
|
574 |
- if self == self.FAIL_RUNTIME: |
|
575 |
- raise ssh_agent.TrailingDataError() |
|
576 |
- raise AssertionError() |
|
577 |
- |
|
578 |
- |
|
579 |
-class SignAction(str, enum.Enum): |
|
580 |
- """Test fixture settings for [`ssh_agent.SSHAgentClient.sign`][]. |
|
581 |
- |
|
582 |
- Attributes: |
|
583 |
- FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][]. |
|
584 |
- FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][]. |
|
585 |
- |
|
586 |
- """ |
|
587 |
- |
|
588 |
- FAIL = enum.auto() |
|
589 |
- """""" |
|
590 |
- FAIL_RUNTIME = enum.auto() |
|
591 |
- """""" |
|
592 |
- |
|
593 |
- def __call__(self, *_args: Any, **_kwargs: Any) -> Any: |
|
594 |
- """Execute the respective action.""" |
|
595 |
- # TODO(the-13th-letter): Rewrite using structural pattern |
|
596 |
- # matching. |
|
597 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
598 |
- if self == self.FAIL: |
|
599 |
- raise ssh_agent.SSHAgentFailedError( |
|
600 |
- _types.SSH_AGENT.FAILURE.value, b"" |
|
601 |
- ) |
|
602 |
- if self == self.FAIL_RUNTIME: |
|
603 |
- raise ssh_agent.TrailingDataError() |
|
604 |
- raise AssertionError() |
|
605 |
- |
|
606 |
- |
|
607 |
-class SocketAddressAction(str, enum.Enum): |
|
608 |
- """Test fixture settings for the SSH agent socket address. |
|
609 |
- |
|
610 |
- Attributes: |
|
611 |
- MANGLE_ANNOYING_OS_NAMED_PIPE: |
|
612 |
- Mangle the address for the Annoying OS named pipe endpoint. |
|
613 |
- MANGLE_SSH_AUTH_SOCK: |
|
614 |
- Mangle the address for the UNIX domain socket (the |
|
615 |
- `SSH_AUTH_SOCK` environment variable). |
|
616 |
- UNSET_ANNOYING_OS_NAMED_PIPE: |
|
617 |
- Unset the address for the Annoying OS named pipe endpoint. |
|
618 |
- UNSET_SSH_AUTH_SOCK: |
|
619 |
- Unset the `SSH_AUTH_SOCK` environment variable (the address |
|
620 |
- for the UNIX domain socket). |
|
621 |
- |
|
622 |
- """ |
|
623 |
- |
|
624 |
- MANGLE_ANNOYING_OS_NAMED_PIPE = enum.auto() |
|
625 |
- """""" |
|
626 |
- MANGLE_SSH_AUTH_SOCK = enum.auto() |
|
627 |
- """""" |
|
628 |
- UNSET_ANNOYING_OS_NAMED_PIPE = enum.auto() |
|
629 |
- """""" |
|
630 |
- UNSET_SSH_AUTH_SOCK = enum.auto() |
|
631 |
- """""" |
|
632 |
- |
|
633 |
- def __call__( |
|
634 |
- self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any |
|
635 |
- ) -> None: |
|
636 |
- """Execute the respective action.""" |
|
637 |
- # TODO(the-13th-letter): Rewrite using structural pattern |
|
638 |
- # matching. |
|
639 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
640 |
- if self in { |
|
641 |
- self.MANGLE_ANNOYING_OS_NAMED_PIPE, |
|
642 |
- self.UNSET_ANNOYING_OS_NAMED_PIPE, |
|
643 |
- }: # pragma: no cover [unused] |
|
644 |
- pass |
|
645 |
- elif self == self.MANGLE_SSH_AUTH_SOCK: |
|
646 |
- monkeypatch.setenv( |
|
647 |
- "SSH_AUTH_SOCK", os.environ["SSH_AUTH_SOCK"] + "~" |
|
648 |
- ) |
|
649 |
- elif self == self.UNSET_SSH_AUTH_SOCK: |
|
650 |
- monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) |
|
651 |
- else: |
|
652 |
- raise AssertionError() |
|
653 |
- |
|
654 |
- |
|
655 |
-class SystemSupportAction(str, enum.Enum): |
|
656 |
- """Test fixture settings for [`ssh_agent.SSHAgentClient`][] system support. |
|
657 |
- |
|
658 |
- Attributes: |
|
659 |
- UNSET_AF_UNIX: |
|
660 |
- Ensure lack of support for UNIX domain sockets. |
|
661 |
- UNSET_AF_UNIX_AND_ENSURE_USE: |
|
662 |
- Ensure lack of support for UNIX domain sockets, and that the |
|
663 |
- agent will use this socket provider. |
|
664 |
- UNSET_NATIVE: |
|
665 |
- Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`. |
|
666 |
- UNSET_NATIVE_AND_ENSURE_USE: |
|
667 |
- Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`, and that the |
|
668 |
- agent will use the native socket provider. |
|
669 |
- UNSET_PROVIDER_LIST: |
|
670 |
- Ensure an empty list of SSH agent socket providers. |
|
671 |
- UNSET_WINDLL: |
|
672 |
- Ensure lack of support for The Annoying OS named pipes. |
|
673 |
- UNSET_WINDLL_AND_ENSURE_USE: |
|
674 |
- Ensure lack of support for The Annoying OS named pipes, and |
|
675 |
- that the agent will use this socket provider. |
|
676 |
- |
|
677 |
- """ |
|
678 |
- |
|
679 |
- UNSET_AF_UNIX = enum.auto() |
|
680 |
- """""" |
|
681 |
- UNSET_AF_UNIX_AND_ENSURE_USE = enum.auto() |
|
682 |
- """""" |
|
683 |
- UNSET_NATIVE = enum.auto() |
|
684 |
- """""" |
|
685 |
- UNSET_NATIVE_AND_ENSURE_USE = enum.auto() |
|
686 |
- """""" |
|
687 |
- UNSET_PROVIDER_LIST = enum.auto() |
|
688 |
- """""" |
|
689 |
- UNSET_WINDLL = enum.auto() |
|
690 |
- """""" |
|
691 |
- UNSET_WINDLL_AND_ENSURE_USE = enum.auto() |
|
692 |
- """""" |
|
693 |
- |
|
694 |
- def __call__( |
|
695 |
- self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any |
|
696 |
- ) -> None: |
|
697 |
- """Execute the respective action. |
|
698 |
- |
|
699 |
- Args: |
|
700 |
- monkeypatch: The current monkeypatch context. |
|
701 |
- |
|
702 |
- """ |
|
703 |
- # TODO(the-13th-letter): Rewrite using structural pattern |
|
704 |
- # matching. |
|
705 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
706 |
- if self == self.UNSET_PROVIDER_LIST: |
|
707 |
- monkeypatch.setattr( |
|
708 |
- ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", [] |
|
709 |
- ) |
|
710 |
- elif self in {self.UNSET_NATIVE, self.UNSET_NATIVE_AND_ENSURE_USE}: |
|
711 |
- self.check_or_ensure_use( |
|
712 |
- "native", |
|
713 |
- monkeypatch=monkeypatch, |
|
714 |
- ensure_use=(self == self.UNSET_NATIVE_AND_ENSURE_USE), |
|
715 |
- ) |
|
716 |
- monkeypatch.delattr(socket, "AF_UNIX", raising=False) |
|
717 |
- monkeypatch.delattr(ctypes, "WinDLL", raising=False) |
|
718 |
- monkeypatch.delattr(ctypes, "windll", raising=False) |
|
719 |
- elif self in {self.UNSET_AF_UNIX, self.UNSET_AF_UNIX_AND_ENSURE_USE}: |
|
720 |
- self.check_or_ensure_use( |
|
721 |
- "posix", |
|
722 |
- monkeypatch=monkeypatch, |
|
723 |
- ensure_use=(self == self.UNSET_AF_UNIX_AND_ENSURE_USE), |
|
724 |
- ) |
|
725 |
- monkeypatch.delattr(socket, "AF_UNIX", raising=False) |
|
726 |
- elif self in {self.UNSET_WINDLL, self.UNSET_WINDLL_AND_ENSURE_USE}: |
|
727 |
- self.check_or_ensure_use( |
|
728 |
- "the_annoying_os", |
|
729 |
- monkeypatch=monkeypatch, |
|
730 |
- ensure_use=(self == self.UNSET_WINDLL_AND_ENSURE_USE), |
|
731 |
- ) |
|
732 |
- monkeypatch.delattr(ctypes, "WinDLL", raising=False) |
|
733 |
- monkeypatch.delattr(ctypes, "windll", raising=False) |
|
734 |
- else: |
|
735 |
- raise AssertionError() |
|
736 |
- |
|
737 |
- @staticmethod |
|
738 |
- def check_or_ensure_use( |
|
739 |
- provider: str, /, *, monkeypatch: pytest.MonkeyPatch, ensure_use: bool |
|
740 |
- ) -> None: |
|
741 |
- """Check that the named SSH agent socket provider will be used. |
|
742 |
- |
|
743 |
- Either ensure that the socket provider will definitely be used, |
|
744 |
- or, upon detecting that it won't be used, skip the test. |
|
745 |
- |
|
746 |
- Args: |
|
747 |
- provider: |
|
748 |
- The provider to check for. |
|
749 |
- ensure_use: |
|
750 |
- If true, ensure that the socket provider will definitely |
|
751 |
- be used. If false, then check for whether it will be |
|
752 |
- used, and skip this test if not. |
|
753 |
- monkeypatch: |
|
754 |
- The monkeypatch context within which the fixture |
|
755 |
- adjustments should be executed. |
|
756 |
- |
|
757 |
- """ |
|
758 |
- if ensure_use: |
|
759 |
- monkeypatch.setattr( |
|
760 |
- ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", [provider] |
|
761 |
- ) |
|
762 |
- else: # pragma: no cover [external] |
|
763 |
- # This branch operates completely on instrumented or on |
|
764 |
- # externally defined, non-deterministic state. |
|
765 |
- intended: ( |
|
766 |
- _types.SSHAgentSocketProvider |
|
767 |
- | socketprovider.NoSuchProviderError |
|
768 |
- | None |
|
769 |
- ) |
|
770 |
- try: |
|
771 |
- intended = socketprovider.SocketProvider.lookup(provider) |
|
772 |
- except socketprovider.NoSuchProviderError as exc: |
|
773 |
- intended = exc |
|
774 |
- actual: ( |
|
775 |
- _types.SSHAgentSocketProvider |
|
776 |
- | socketprovider.NoSuchProviderError |
|
777 |
- | None |
|
778 |
- ) |
|
779 |
- for name in ssh_agent.SSHAgentClient.SOCKET_PROVIDERS: |
|
780 |
- try: |
|
781 |
- actual = socketprovider.SocketProvider.lookup(name) |
|
782 |
- except socketprovider.NoSuchProviderError as exc: |
|
783 |
- actual = exc |
|
784 |
- if actual is None: |
|
785 |
- continue |
|
786 |
- break |
|
787 |
- else: |
|
788 |
- actual = None |
|
789 |
- if intended != actual: |
|
790 |
- pytest.skip( |
|
791 |
- f"{provider!r} SSH agent socket provider " |
|
792 |
- f"is not currently in use" |
|
793 |
- ) |
|
794 |
- |
|
795 |
- |
|
796 | 271 |
class Parametrize(types.SimpleNamespace): |
797 | 272 |
"""Common test parametrizations.""" |
798 | 273 |
|
799 |
- EAGER_ARGUMENTS = pytest.mark.parametrize( |
|
800 |
- "arguments", |
|
801 |
- [["--help"], ["--version"]], |
|
802 |
- ids=["help", "version"], |
|
803 |
- ) |
|
804 | 274 |
CHARSET_NAME = pytest.mark.parametrize( |
805 | 275 |
"charset_name", ["lower", "upper", "number", "space", "dash", "symbol"] |
806 | 276 |
) |
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 | 277 |
UNICODE_NORMALIZATION_COMMAND_LINES = pytest.mark.parametrize( |
853 | 278 |
"command_line", |
854 | 279 |
[ |
... | ... |
@@ -866,45 +291,6 @@ class Parametrize(types.SimpleNamespace): |
866 | 291 |
), |
867 | 292 |
], |
868 | 293 |
) |
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 | 294 |
CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES = pytest.mark.parametrize( |
909 | 295 |
["command_line", "input", "err_text"], |
910 | 296 |
[ |
... | ... |
@@ -993,182 +379,6 @@ class Parametrize(types.SimpleNamespace): |
993 | 379 |
), |
994 | 380 |
], |
995 | 381 |
) |
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 | 382 |
BASE_CONFIG_WITH_KEY_VARIATIONS = pytest.mark.parametrize( |
1173 | 383 |
"config", |
1174 | 384 |
[ |
... | ... |
@@ -1253,404 +463,24 @@ class Parametrize(types.SimpleNamespace): |
1253 | 463 |
), |
1254 | 464 |
], |
1255 | 465 |
) |
1256 |
- COMPLETION_FUNCTION_INPUTS = pytest.mark.parametrize( |
|
1257 |
- ["config", "comp_func", "args", "incomplete", "results"], |
|
1258 |
- [ |
|
1259 |
- pytest.param( |
|
1260 |
- {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}}, |
|
1261 |
- cli_helpers.shell_complete_service, |
|
1262 |
- ["vault"], |
|
1263 |
- "", |
|
1264 |
- [DUMMY_SERVICE], |
|
1265 |
- id="base_config-service", |
|
1266 |
- ), |
|
1267 |
- pytest.param( |
|
1268 |
- {"services": {}}, |
|
1269 |
- cli_helpers.shell_complete_service, |
|
1270 |
- ["vault"], |
|
1271 |
- "", |
|
1272 |
- [], |
|
1273 |
- id="empty_config-service", |
|
1274 |
- ), |
|
1275 |
- pytest.param( |
|
1276 |
- { |
|
1277 |
- "services": { |
|
1278 |
- DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
1279 |
- "newline\nin\nname": DUMMY_CONFIG_SETTINGS.copy(), |
|
1280 |
- } |
|
1281 |
- }, |
|
1282 |
- cli_helpers.shell_complete_service, |
|
1283 |
- ["vault"], |
|
1284 |
- "", |
|
1285 |
- [DUMMY_SERVICE], |
|
1286 |
- id="incompletable_newline_config-service", |
|
1287 |
- ), |
|
1288 |
- pytest.param( |
|
1289 |
- { |
|
1290 |
- "services": { |
|
1291 |
- DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
1292 |
- "backspace\bin\bname": DUMMY_CONFIG_SETTINGS.copy(), |
|
1293 |
- } |
|
1294 |
- }, |
|
1295 |
- cli_helpers.shell_complete_service, |
|
1296 |
- ["vault"], |
|
1297 |
- "", |
|
1298 |
- [DUMMY_SERVICE], |
|
1299 |
- id="incompletable_backspace_config-service", |
|
1300 |
- ), |
|
1301 |
- pytest.param( |
|
1302 |
- { |
|
1303 |
- "services": { |
|
1304 |
- DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
1305 |
- "colon:in:name": DUMMY_CONFIG_SETTINGS.copy(), |
|
1306 |
- } |
|
1307 |
- }, |
|
1308 |
- cli_helpers.shell_complete_service, |
|
1309 |
- ["vault"], |
|
1310 |
- "", |
|
1311 |
- sorted([DUMMY_SERVICE, "colon:in:name"]), |
|
1312 |
- id="brittle_colon_config-service", |
|
1313 |
- ), |
|
1314 |
- pytest.param( |
|
1315 |
- { |
|
1316 |
- "services": { |
|
1317 |
- DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
1318 |
- "colon:in:name": DUMMY_CONFIG_SETTINGS.copy(), |
|
1319 |
- "newline\nin\nname": DUMMY_CONFIG_SETTINGS.copy(), |
|
1320 |
- "backspace\bin\bname": DUMMY_CONFIG_SETTINGS.copy(), |
|
1321 |
- "nul\x00in\x00name": DUMMY_CONFIG_SETTINGS.copy(), |
|
1322 |
- "del\x7fin\x7fname": DUMMY_CONFIG_SETTINGS.copy(), |
|
1323 |
- } |
|
1324 |
- }, |
|
1325 |
- cli_helpers.shell_complete_service, |
|
1326 |
- ["vault"], |
|
1327 |
- "", |
|
1328 |
- sorted([DUMMY_SERVICE, "colon:in:name"]), |
|
1329 |
- id="brittle_incompletable_multi_config-service", |
|
1330 |
- ), |
|
1331 |
- pytest.param( |
|
1332 |
- {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}}, |
|
1333 |
- cli_helpers.shell_complete_path, |
|
1334 |
- ["vault", "--import"], |
|
1335 |
- "", |
|
1336 |
- [click.shell_completion.CompletionItem("", type="file")], |
|
1337 |
- id="base_config-path", |
|
1338 |
- ), |
|
1339 |
- pytest.param( |
|
1340 |
- {"services": {}}, |
|
1341 |
- cli_helpers.shell_complete_path, |
|
1342 |
- ["vault", "--import"], |
|
1343 |
- "", |
|
1344 |
- [click.shell_completion.CompletionItem("", type="file")], |
|
1345 |
- id="empty_config-path", |
|
1346 |
- ), |
|
1347 |
- ], |
|
1348 |
- ) |
|
1349 |
- COMPLETABLE_SERVICE_NAMES = pytest.mark.parametrize( |
|
1350 |
- ["config", "incomplete", "completions"], |
|
1351 |
- [ |
|
1352 |
- pytest.param( |
|
1353 |
- {"services": {}}, |
|
1354 |
- "", |
|
1355 |
- frozenset(), |
|
1356 |
- id="no_services", |
|
1357 |
- ), |
|
1358 |
- pytest.param( |
|
1359 |
- {"services": {}}, |
|
1360 |
- "partial", |
|
1361 |
- frozenset(), |
|
1362 |
- id="no_services_partial", |
|
1363 |
- ), |
|
1364 |
- pytest.param( |
|
1365 |
- {"services": {DUMMY_SERVICE: {"length": 10}}}, |
|
1366 |
- "", |
|
1367 |
- frozenset({DUMMY_SERVICE}), |
|
1368 |
- id="one_service", |
|
1369 |
- ), |
|
1370 |
- pytest.param( |
|
1371 |
- {"services": {DUMMY_SERVICE: {"length": 10}}}, |
|
1372 |
- DUMMY_SERVICE[:4], |
|
1373 |
- frozenset({DUMMY_SERVICE}), |
|
1374 |
- id="one_service_partial", |
|
1375 |
- ), |
|
1376 |
- pytest.param( |
|
1377 |
- {"services": {DUMMY_SERVICE: {"length": 10}}}, |
|
1378 |
- DUMMY_SERVICE[-4:], |
|
1379 |
- frozenset(), |
|
1380 |
- id="one_service_partial_miss", |
|
1381 |
- ), |
|
1382 |
- ], |
|
1383 |
- ) |
|
1384 |
- SERVICE_NAME_COMPLETION_INPUTS = pytest.mark.parametrize( |
|
1385 |
- ["config", "key", "incomplete", "completions"], |
|
1386 |
- [ |
|
1387 |
- pytest.param( |
|
1388 |
- { |
|
1389 |
- "services": { |
|
1390 |
- DUMMY_SERVICE: {"length": 10}, |
|
1391 |
- "newline\nin\nname": {"length": 10}, |
|
1392 |
- }, |
|
1393 |
- }, |
|
1394 |
- "newline\nin\nname", |
|
1395 |
- "", |
|
1396 |
- frozenset({DUMMY_SERVICE}), |
|
1397 |
- id="newline", |
|
1398 |
- ), |
|
1399 |
- pytest.param( |
|
1400 |
- { |
|
1401 |
- "services": { |
|
1402 |
- DUMMY_SERVICE: {"length": 10}, |
|
1403 |
- "newline\nin\nname": {"length": 10}, |
|
1404 |
- }, |
|
1405 |
- }, |
|
1406 |
- "newline\nin\nname", |
|
1407 |
- "serv", |
|
1408 |
- frozenset({DUMMY_SERVICE}), |
|
1409 |
- id="newline_partial_other", |
|
1410 |
- ), |
|
1411 |
- pytest.param( |
|
1412 |
- { |
|
1413 |
- "services": { |
|
1414 |
- DUMMY_SERVICE: {"length": 10}, |
|
1415 |
- "newline\nin\nname": {"length": 10}, |
|
1416 |
- }, |
|
1417 |
- }, |
|
1418 |
- "newline\nin\nname", |
|
1419 |
- "newline", |
|
1420 |
- frozenset({}), |
|
1421 |
- id="newline_partial_specific", |
|
1422 |
- ), |
|
1423 |
- pytest.param( |
|
1424 |
- { |
|
1425 |
- "services": { |
|
1426 |
- DUMMY_SERVICE: {"length": 10}, |
|
1427 |
- "nul\x00in\x00name": {"length": 10}, |
|
1428 |
- }, |
|
1429 |
- }, |
|
1430 |
- "nul\x00in\x00name", |
|
1431 |
- "", |
|
1432 |
- frozenset({DUMMY_SERVICE}), |
|
1433 |
- id="nul", |
|
1434 |
- ), |
|
1435 |
- pytest.param( |
|
1436 |
- { |
|
1437 |
- "services": { |
|
1438 |
- DUMMY_SERVICE: {"length": 10}, |
|
1439 |
- "nul\x00in\x00name": {"length": 10}, |
|
1440 |
- }, |
|
1441 |
- }, |
|
1442 |
- "nul\x00in\x00name", |
|
1443 |
- "serv", |
|
1444 |
- frozenset({DUMMY_SERVICE}), |
|
1445 |
- id="nul_partial_other", |
|
1446 |
- ), |
|
1447 |
- pytest.param( |
|
1448 |
- { |
|
1449 |
- "services": { |
|
1450 |
- DUMMY_SERVICE: {"length": 10}, |
|
1451 |
- "nul\x00in\x00name": {"length": 10}, |
|
1452 |
- }, |
|
1453 |
- }, |
|
1454 |
- "nul\x00in\x00name", |
|
1455 |
- "nul", |
|
1456 |
- frozenset({}), |
|
1457 |
- id="nul_partial_specific", |
|
1458 |
- ), |
|
1459 |
- pytest.param( |
|
1460 |
- { |
|
1461 |
- "services": { |
|
1462 |
- DUMMY_SERVICE: {"length": 10}, |
|
1463 |
- "backspace\bin\bname": {"length": 10}, |
|
1464 |
- }, |
|
1465 |
- }, |
|
1466 |
- "backspace\bin\bname", |
|
1467 |
- "", |
|
1468 |
- frozenset({DUMMY_SERVICE}), |
|
1469 |
- id="backspace", |
|
1470 |
- ), |
|
1471 |
- pytest.param( |
|
1472 |
- { |
|
1473 |
- "services": { |
|
1474 |
- DUMMY_SERVICE: {"length": 10}, |
|
1475 |
- "backspace\bin\bname": {"length": 10}, |
|
1476 |
- }, |
|
1477 |
- }, |
|
1478 |
- "backspace\bin\bname", |
|
1479 |
- "serv", |
|
1480 |
- frozenset({DUMMY_SERVICE}), |
|
1481 |
- id="backspace_partial_other", |
|
1482 |
- ), |
|
1483 |
- pytest.param( |
|
1484 |
- { |
|
1485 |
- "services": { |
|
1486 |
- DUMMY_SERVICE: {"length": 10}, |
|
1487 |
- "backspace\bin\bname": {"length": 10}, |
|
1488 |
- }, |
|
1489 |
- }, |
|
1490 |
- "backspace\bin\bname", |
|
1491 |
- "back", |
|
1492 |
- frozenset({}), |
|
1493 |
- id="backspace_partial_specific", |
|
1494 |
- ), |
|
1495 |
- pytest.param( |
|
1496 |
- { |
|
1497 |
- "services": { |
|
1498 |
- DUMMY_SERVICE: {"length": 10}, |
|
1499 |
- "del\x7fin\x7fname": {"length": 10}, |
|
1500 |
- }, |
|
1501 |
- }, |
|
1502 |
- "del\x7fin\x7fname", |
|
1503 |
- "", |
|
1504 |
- frozenset({DUMMY_SERVICE}), |
|
1505 |
- id="del", |
|
1506 |
- ), |
|
1507 |
- pytest.param( |
|
1508 |
- { |
|
1509 |
- "services": { |
|
1510 |
- DUMMY_SERVICE: {"length": 10}, |
|
1511 |
- "del\x7fin\x7fname": {"length": 10}, |
|
1512 |
- }, |
|
1513 |
- }, |
|
1514 |
- "del\x7fin\x7fname", |
|
1515 |
- "serv", |
|
1516 |
- frozenset({DUMMY_SERVICE}), |
|
1517 |
- id="del_partial_other", |
|
1518 |
- ), |
|
1519 |
- pytest.param( |
|
1520 |
- { |
|
1521 |
- "services": { |
|
1522 |
- DUMMY_SERVICE: {"length": 10}, |
|
1523 |
- "del\x7fin\x7fname": {"length": 10}, |
|
1524 |
- }, |
|
1525 |
- }, |
|
1526 |
- "del\x7fin\x7fname", |
|
1527 |
- "del", |
|
1528 |
- frozenset({}), |
|
1529 |
- id="del_partial_specific", |
|
1530 |
- ), |
|
1531 |
- ], |
|
1532 |
- ) |
|
1533 |
- CONNECTION_HINTS = pytest.mark.parametrize( |
|
1534 |
- "conn_hint", ["none", "socket", "client"] |
|
1535 |
- ) |
|
1536 |
- NOOP_EDIT_FUNCS = pytest.mark.parametrize( |
|
1537 |
- ["edit_func_name", "modern_editor_interface"], |
|
1538 |
- [ |
|
1539 |
- pytest.param("empty", True, id="empty"), |
|
1540 |
- pytest.param("space", False, id="space-legacy"), |
|
1541 |
- pytest.param("space", True, id="space-modern"), |
|
1542 |
- ], |
|
1543 |
- ) |
|
1544 |
- SERVICE_NAME_EXCEPTIONS = pytest.mark.parametrize( |
|
1545 |
- "exc_type", [RuntimeError, KeyError, ValueError] |
|
1546 |
- ) |
|
1547 |
- EXPORT_FORMAT_OPTIONS = pytest.mark.parametrize( |
|
1548 |
- "export_options", |
|
466 |
+ NOOP_EDIT_FUNCS = pytest.mark.parametrize( |
|
467 |
+ ["edit_func_name", "modern_editor_interface"], |
|
468 |
+ [ |
|
469 |
+ pytest.param("empty", True, id="empty"), |
|
470 |
+ pytest.param("space", False, id="space-legacy"), |
|
471 |
+ pytest.param("space", True, id="space-modern"), |
|
472 |
+ ], |
|
473 |
+ ) |
|
474 |
+ EXPORT_FORMAT_OPTIONS = pytest.mark.parametrize( |
|
475 |
+ "export_options", |
|
1549 | 476 |
[ |
1550 | 477 |
[], |
1551 | 478 |
["--export-as=sh"], |
1552 | 479 |
], |
1553 | 480 |
) |
1554 |
- INCOMPLETE = pytest.mark.parametrize("incomplete", ["", "partial"]) |
|
1555 |
- ISATTY = pytest.mark.parametrize( |
|
1556 |
- "isatty", |
|
1557 |
- [False, True], |
|
1558 |
- ids=["notty", "tty"], |
|
1559 |
- ) |
|
1560 | 481 |
KEY_INDEX = pytest.mark.parametrize( |
1561 | 482 |
"key_index", [1, 2, 3], ids=lambda i: f"index{i}" |
1562 | 483 |
) |
1563 |
- KEY_TO_PHRASE_SETTINGS = pytest.mark.parametrize( |
|
1564 |
- [ |
|
1565 |
- "list_keys_action", |
|
1566 |
- "address_action", |
|
1567 |
- "system_support_action", |
|
1568 |
- "sign_action", |
|
1569 |
- "pattern", |
|
1570 |
- ], |
|
1571 |
- [ |
|
1572 |
- pytest.param( |
|
1573 |
- ListKeysAction.EMPTY, |
|
1574 |
- None, |
|
1575 |
- None, |
|
1576 |
- SignAction.FAIL, |
|
1577 |
- "not loaded into the agent", |
|
1578 |
- id="key-not-loaded", |
|
1579 |
- ), |
|
1580 |
- pytest.param( |
|
1581 |
- ListKeysAction.FAIL, |
|
1582 |
- None, |
|
1583 |
- None, |
|
1584 |
- SignAction.FAIL, |
|
1585 |
- "SSH agent failed to or refused to", |
|
1586 |
- id="list-keys-refused", |
|
1587 |
- ), |
|
1588 |
- pytest.param( |
|
1589 |
- ListKeysAction.FAIL_RUNTIME, |
|
1590 |
- None, |
|
1591 |
- None, |
|
1592 |
- SignAction.FAIL, |
|
1593 |
- "SSH agent failed to or refused to", |
|
1594 |
- id="list-keys-protocol-error", |
|
1595 |
- ), |
|
1596 |
- pytest.param( |
|
1597 |
- None, |
|
1598 |
- SocketAddressAction.UNSET_SSH_AUTH_SOCK, |
|
1599 |
- None, |
|
1600 |
- SignAction.FAIL, |
|
1601 |
- "Cannot find any running SSH agent", |
|
1602 |
- id="agent-address-missing", |
|
1603 |
- ), |
|
1604 |
- pytest.param( |
|
1605 |
- None, |
|
1606 |
- SocketAddressAction.MANGLE_SSH_AUTH_SOCK, |
|
1607 |
- None, |
|
1608 |
- SignAction.FAIL, |
|
1609 |
- "Cannot connect to the SSH agent", |
|
1610 |
- id="agent-address-mangled", |
|
1611 |
- ), |
|
1612 |
- pytest.param( |
|
1613 |
- None, |
|
1614 |
- None, |
|
1615 |
- SystemSupportAction.UNSET_NATIVE, |
|
1616 |
- SignAction.FAIL, |
|
1617 |
- "does not support communicating with it", |
|
1618 |
- id="no-agent-support", |
|
1619 |
- ), |
|
1620 |
- pytest.param( |
|
1621 |
- None, |
|
1622 |
- None, |
|
1623 |
- SystemSupportAction.UNSET_PROVIDER_LIST, |
|
1624 |
- SignAction.FAIL, |
|
1625 |
- "does not support communicating with it", |
|
1626 |
- id="no-agent-support", |
|
1627 |
- ), |
|
1628 |
- pytest.param( |
|
1629 |
- None, |
|
1630 |
- None, |
|
1631 |
- SystemSupportAction.UNSET_AF_UNIX_AND_ENSURE_USE, |
|
1632 |
- SignAction.FAIL, |
|
1633 |
- "does not support communicating with it", |
|
1634 |
- id="no-agent-support", |
|
1635 |
- ), |
|
1636 |
- pytest.param( |
|
1637 |
- None, |
|
1638 |
- None, |
|
1639 |
- SystemSupportAction.UNSET_WINDLL_AND_ENSURE_USE, |
|
1640 |
- SignAction.FAIL, |
|
1641 |
- "does not support communicating with it", |
|
1642 |
- id="no-agent-support", |
|
1643 |
- ), |
|
1644 |
- pytest.param( |
|
1645 |
- None, |
|
1646 |
- None, |
|
1647 |
- None, |
|
1648 |
- SignAction.FAIL_RUNTIME, |
|
1649 |
- "violates the communication protocol", |
|
1650 |
- id="sign-violates-protocol", |
|
1651 |
- ), |
|
1652 |
- ], |
|
1653 |
- ) |
|
1654 | 484 |
UNICODE_NORMALIZATION_ERROR_INPUTS = pytest.mark.parametrize( |
1655 | 485 |
["main_config", "command_line", "input", "error_message"], |
1656 | 486 |
[ |
... | ... |
@@ -1791,9 +621,6 @@ class Parametrize(types.SimpleNamespace): |
1791 | 621 |
), |
1792 | 622 |
], |
1793 | 623 |
) |
1794 |
- MASK_PROG_NAME = pytest.mark.parametrize("mask_prog_name", [False, True]) |
|
1795 |
- MASK_VERSION = pytest.mark.parametrize("mask_version", [False, True]) |
|
1796 |
- CONFIG_SETTING_MODE = pytest.mark.parametrize("mode", ["config", "import"]) |
|
1797 | 624 |
MODERN_EDITOR_INTERFACE = pytest.mark.parametrize( |
1798 | 625 |
"modern_editor_interface", [False, True], ids=["legacy", "modern"] |
1799 | 626 |
) |
... | ... |
@@ -1839,181 +666,18 @@ class Parametrize(types.SimpleNamespace): |
1839 | 666 |
if not o.incompatible |
1840 | 667 |
], |
1841 | 668 |
) |
1842 |
- COMPLETABLE_ITEMS = pytest.mark.parametrize( |
|
1843 |
- ["partial", "is_completable"], |
|
1844 |
- [ |
|
1845 |
- ("", True), |
|
1846 |
- (DUMMY_SERVICE, True), |
|
1847 |
- ("a\bn", False), |
|
1848 |
- ("\b", False), |
|
1849 |
- ("\x00", False), |
|
1850 |
- ("\x20", True), |
|
1851 |
- ("\x7f", False), |
|
1852 |
- ("service with spaces", True), |
|
1853 |
- ("service\nwith\nnewlines", False), |
|
1854 |
- ], |
|
1855 |
- ) |
|
1856 |
- SHELL_FORMATTER = pytest.mark.parametrize( |
|
1857 |
- ["shell", "format_func"], |
|
1858 |
- [ |
|
1859 |
- pytest.param("bash", bash_format, id="bash"), |
|
1860 |
- pytest.param("fish", fish_format, id="fish"), |
|
1861 |
- pytest.param("zsh", zsh_format, id="zsh"), |
|
1862 |
- ], |
|
1863 |
- ) |
|
1864 | 669 |
TRY_RACE_FREE_IMPLEMENTATION = pytest.mark.parametrize( |
1865 | 670 |
"try_race_free_implementation", [True, False] |
1866 | 671 |
) |
1867 |
- VERSION_OUTPUT_DATA = pytest.mark.parametrize( |
|
1868 |
- ["version_output", "prog_name", "version", "expected_parse"], |
|
1869 |
- [ |
|
1870 |
- pytest.param( |
|
1871 |
- """\ |
|
1872 |
-derivepassphrase 0.4.0 |
|
1873 |
-Using cryptography 44.0.0 |
|
1874 |
- |
|
1875 |
-Supported foreign configuration formats: vault storeroom, vault v0.2, |
|
1876 |
- vault v0.3. |
|
1877 |
-PEP 508 extras: export. |
|
1878 |
-""", |
|
1879 |
- "derivepassphrase", |
|
1880 |
- "0.4.0", |
|
1881 |
- VersionOutputData( |
|
1882 |
- derivation_schemes={}, |
|
1883 |
- foreign_configuration_formats={ |
|
1884 |
- "vault storeroom": True, |
|
1885 |
- "vault v0.2": True, |
|
1886 |
- "vault v0.3": True, |
|
1887 |
- }, |
|
1888 |
- subcommands=frozenset(), |
|
1889 |
- features={}, |
|
1890 |
- extras=frozenset({"export"}), |
|
1891 |
- ), |
|
1892 |
- id="derivepassphrase-0.4.0-export", |
|
1893 |
- ), |
|
1894 |
- pytest.param( |
|
1895 |
- """\ |
|
1896 |
-derivepassphrase 0.5 |
|
1897 |
- |
|
1898 |
-Supported derivation schemes: vault. |
|
1899 |
-Known foreign configuration formats: vault storeroom, vault v0.2, vault v0.3. |
|
1900 |
-Supported subcommands: export, vault. |
|
1901 |
-No PEP 508 extras are active. |
|
1902 |
-""", |
|
1903 |
- "derivepassphrase", |
|
1904 |
- "0.5", |
|
1905 |
- VersionOutputData( |
|
1906 |
- derivation_schemes={"vault": True}, |
|
1907 |
- foreign_configuration_formats={ |
|
1908 |
- "vault storeroom": False, |
|
1909 |
- "vault v0.2": False, |
|
1910 |
- "vault v0.3": False, |
|
1911 |
- }, |
|
1912 |
- subcommands=frozenset({"export", "vault"}), |
|
1913 |
- features={}, |
|
1914 |
- extras=frozenset({}), |
|
1915 |
- ), |
|
1916 |
- id="derivepassphrase-0.5-plain", |
|
1917 |
- ), |
|
1918 |
- pytest.param( |
|
1919 |
- """\ |
|
1920 |
- |
|
1921 |
- |
|
1922 |
- |
|
1923 |
-inventpassphrase -1.3 |
|
1924 |
-Using not-a-library 7.12 |
|
1925 |
-Copyright 2025 Nobody. All rights reserved. |
|
1926 |
- |
|
1927 |
-Supported derivation schemes: nonsense. |
|
1928 |
-Known derivation schemes: divination, /dev/random, |
|
1929 |
- geiger counter, |
|
1930 |
- crossword solver. |
|
1931 |
-Supported foreign configuration formats: derivepassphrase, nonsense. |
|
1932 |
-Known foreign configuration formats: divination v3.141592, |
|
1933 |
- /dev/random. |
|
1934 |
-Supported subcommands: delete-all-files, dump-core. |
|
1935 |
-Supported features: delete-while-open. |
|
1936 |
-Known features: backups-are-nice-to-have. |
|
1937 |
-PEP 508 extras: annoying-popups, delete-all-files, |
|
1938 |
- dump-core-depending-on-the-phase-of-the-moon. |
|
1939 |
- |
|
1940 |
- |
|
1941 |
- |
|
1942 |
-""", |
|
1943 |
- "inventpassphrase", |
|
1944 |
- "-1.3", |
|
1945 |
- VersionOutputData( |
|
1946 |
- derivation_schemes={ |
|
1947 |
- "nonsense": True, |
|
1948 |
- "divination": False, |
|
1949 |
- "/dev/random": False, |
|
1950 |
- "geiger counter": False, |
|
1951 |
- "crossword solver": False, |
|
1952 |
- }, |
|
1953 |
- foreign_configuration_formats={ |
|
1954 |
- "derivepassphrase": True, |
|
1955 |
- "nonsense": True, |
|
1956 |
- "divination v3.141592": False, |
|
1957 |
- "/dev/random": False, |
|
1958 |
- }, |
|
1959 |
- subcommands=frozenset({"delete-all-files", "dump-core"}), |
|
1960 |
- features={ |
|
1961 |
- "delete-while-open": True, |
|
1962 |
- "backups-are-nice-to-have": False, |
|
1963 |
- }, |
|
1964 |
- extras=frozenset({ |
|
1965 |
- "annoying-popups", |
|
1966 |
- "delete-all-files", |
|
1967 |
- "dump-core-depending-on-the-phase-of-the-moon", |
|
1968 |
- }), |
|
1969 |
- ), |
|
1970 |
- id="inventpassphrase", |
|
1971 |
- ), |
|
1972 |
- ], |
|
1973 |
- ) |
|
1974 |
- """Sample data for [`parse_version_output`][].""" |
|
1975 |
- VALIDATION_FUNCTION_INPUT = pytest.mark.parametrize( |
|
1976 |
- ["vfunc", "input"], |
|
1977 |
- [ |
|
1978 |
- (cli_machinery.validate_occurrence_constraint, 20), |
|
1979 |
- (cli_machinery.validate_length, 20), |
|
1980 |
- ], |
|
1981 |
- ) |
|
1982 | 672 |
|
1983 | 673 |
|
1984 |
-class TestAllCLI: |
|
1985 |
- """Tests uniformly for all command-line interfaces.""" |
|
674 |
+class TestCLI: |
|
675 |
+ """Tests for the `derivepassphrase vault` command-line interface.""" |
|
1986 | 676 |
|
1987 |
- @Parametrize.MASK_PROG_NAME |
|
1988 |
- @Parametrize.MASK_VERSION |
|
1989 |
- @Parametrize.VERSION_OUTPUT_DATA |
|
1990 |
- def test_001_parse_version_output( |
|
677 |
+ def test_200_help_output( |
|
1991 | 678 |
self, |
1992 |
- version_output: str, |
|
1993 |
- prog_name: str | None, |
|
1994 |
- version: str | None, |
|
1995 |
- mask_prog_name: bool, |
|
1996 |
- mask_version: bool, |
|
1997 |
- expected_parse: VersionOutputData, |
|
1998 | 679 |
) -> None: |
1999 |
- """The parsing machinery for expected version output data works.""" |
|
2000 |
- prog_name = None if mask_prog_name else prog_name |
|
2001 |
- version = None if mask_version else version |
|
2002 |
- assert ( |
|
2003 |
- parse_version_output( |
|
2004 |
- version_output, prog_name=prog_name, version=version |
|
2005 |
- ) |
|
2006 |
- == expected_parse |
|
2007 |
- ) |
|
2008 |
- |
|
2009 |
- # TODO(the-13th-letter): Do we actually need this? What should we |
|
2010 |
- # check for? |
|
2011 |
- def test_100_help_output(self) -> None: |
|
2012 |
- """The top-level help text mentions subcommands. |
|
2013 |
- |
|
2014 |
- TODO: Do we actually need this? What should we check for? |
|
2015 |
- |
|
2016 |
- """ |
|
680 |
+ """The `--help` option emits help text.""" |
|
2017 | 681 |
runner = machinery.CliRunner(mix_stderr=False) |
2018 | 682 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2019 | 683 |
# with-statements. |
... | ... |
@@ -2027,22 +691,23 @@ class TestAllCLI: |
2027 | 691 |
) |
2028 | 692 |
) |
2029 | 693 |
result = runner.invoke( |
2030 |
- cli.derivepassphrase, ["--help"], catch_exceptions=False |
|
694 |
+ cli.derivepassphrase_vault, |
|
695 |
+ ["--help"], |
|
696 |
+ catch_exceptions=False, |
|
2031 | 697 |
) |
2032 | 698 |
assert result.clean_exit( |
2033 |
- empty_stderr=True, output="currently implemented subcommands" |
|
2034 |
- ), "expected clean exit, and known help text" |
|
699 |
+ empty_stderr=True, output="Passphrase generation:\n" |
|
700 |
+ ), "expected clean exit, and option groups in help text" |
|
701 |
+ assert result.clean_exit( |
|
702 |
+ empty_stderr=True, output="Use $VISUAL or $EDITOR to configure" |
|
703 |
+ ), "expected clean exit, and option group epilog in help text" |
|
2035 | 704 |
|
2036 |
- # TODO(the-13th-letter): Do we actually need this? What should we |
|
2037 |
- # check for? |
|
2038 |
- def test_101_help_output_export( |
|
705 |
+ # TODO(the-13th-letter): Remove this test once |
|
706 |
+ # TestAllCLI.test_202_version_option_output no longer xfails. |
|
707 |
+ def test_200a_version_output( |
|
2039 | 708 |
self, |
2040 | 709 |
) -> None: |
2041 |
- """The "export" subcommand help text mentions subcommands. |
|
2042 |
- |
|
2043 |
- TODO: Do we actually need this? What should we check for? |
|
2044 |
- |
|
2045 |
- """ |
|
710 |
+ """The `--version` option emits version information.""" |
|
2046 | 711 |
runner = machinery.CliRunner(mix_stderr=False) |
2047 | 712 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2048 | 713 |
# with-statements. |
... | ... |
@@ -2056,24 +721,25 @@ class TestAllCLI: |
2056 | 721 |
) |
2057 | 722 |
) |
2058 | 723 |
result = runner.invoke( |
2059 |
- cli.derivepassphrase, |
|
2060 |
- ["export", "--help"], |
|
724 |
+ cli.derivepassphrase_vault, |
|
725 |
+ ["--version"], |
|
2061 | 726 |
catch_exceptions=False, |
2062 | 727 |
) |
2063 |
- assert result.clean_exit( |
|
2064 |
- empty_stderr=True, output="only available subcommand" |
|
2065 |
- ), "expected clean exit, and known help text" |
|
728 |
+ assert result.clean_exit(empty_stderr=True, output=cli.PROG_NAME), ( |
|
729 |
+ "expected clean exit, and program name in version text" |
|
730 |
+ ) |
|
731 |
+ assert result.clean_exit(empty_stderr=True, output=cli.VERSION), ( |
|
732 |
+ "expected clean exit, and version in help text" |
|
733 |
+ ) |
|
2066 | 734 |
|
2067 |
- # TODO(the-13th-letter): Do we actually need this? What should we |
|
2068 |
- # check for? |
|
2069 |
- def test_102_help_output_export_vault( |
|
735 |
+ @Parametrize.CHARSET_NAME |
|
736 |
+ def test_201_disable_character_set( |
|
2070 | 737 |
self, |
738 |
+ charset_name: str, |
|
2071 | 739 |
) -> None: |
2072 |
- """The "export vault" subcommand help text has known content. |
|
2073 |
- |
|
2074 |
- TODO: Do we actually need this? What should we check for? |
|
2075 |
- |
|
2076 |
- """ |
|
740 |
+ """Named character classes can be disabled on the command-line.""" |
|
741 |
+ option = f"--{charset_name}" |
|
742 |
+ charset = vault.Vault.CHARSETS[charset_name].decode("ascii") |
|
2077 | 743 |
runner = machinery.CliRunner(mix_stderr=False) |
2078 | 744 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2079 | 745 |
# with-statements. |
... | ... |
@@ -2086,25 +752,27 @@ class TestAllCLI: |
2086 | 752 |
runner=runner, |
2087 | 753 |
) |
2088 | 754 |
) |
755 |
+ monkeypatch.setattr( |
|
756 |
+ cli_helpers, |
|
757 |
+ "prompt_for_passphrase", |
|
758 |
+ callables.auto_prompt, |
|
759 |
+ ) |
|
2089 | 760 |
result = runner.invoke( |
2090 |
- cli.derivepassphrase, |
|
2091 |
- ["export", "vault", "--help"], |
|
761 |
+ cli.derivepassphrase_vault, |
|
762 |
+ [option, "0", "-p", "--", DUMMY_SERVICE], |
|
763 |
+ input=DUMMY_PASSPHRASE, |
|
2092 | 764 |
catch_exceptions=False, |
2093 | 765 |
) |
2094 |
- assert result.clean_exit( |
|
2095 |
- empty_stderr=True, output="Export a vault-native configuration" |
|
2096 |
- ), "expected clean exit, and known help text" |
|
766 |
+ assert result.clean_exit(empty_stderr=True), "expected clean exit:" |
|
767 |
+ for c in charset: |
|
768 |
+ assert c not in result.stdout, ( |
|
769 |
+ f"derived password contains forbidden character {c!r}" |
|
770 |
+ ) |
|
2097 | 771 |
|
2098 |
- # TODO(the-13th-letter): Do we actually need this? What should we |
|
2099 |
- # check for? |
|
2100 |
- def test_103_help_output_vault( |
|
772 |
+ def test_202_disable_repetition( |
|
2101 | 773 |
self, |
2102 | 774 |
) -> None: |
2103 |
- """The "vault" subcommand help text has known content. |
|
2104 |
- |
|
2105 |
- TODO: Do we actually need this? What should we check for? |
|
2106 |
- |
|
2107 |
- """ |
|
775 |
+ """Character repetition can be disabled on the command-line.""" |
|
2108 | 776 |
runner = machinery.CliRunner(mix_stderr=False) |
2109 | 777 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2110 | 778 |
# with-statements. |
... | ... |
@@ -2117,27 +785,35 @@ class TestAllCLI: |
2117 | 785 |
runner=runner, |
2118 | 786 |
) |
2119 | 787 |
) |
788 |
+ monkeypatch.setattr( |
|
789 |
+ cli_helpers, |
|
790 |
+ "prompt_for_passphrase", |
|
791 |
+ callables.auto_prompt, |
|
792 |
+ ) |
|
2120 | 793 |
result = runner.invoke( |
2121 |
- cli.derivepassphrase, |
|
2122 |
- ["vault", "--help"], |
|
794 |
+ cli.derivepassphrase_vault, |
|
795 |
+ ["--repeat", "0", "-p", "--", DUMMY_SERVICE], |
|
796 |
+ input=DUMMY_PASSPHRASE, |
|
2123 | 797 |
catch_exceptions=False, |
2124 | 798 |
) |
2125 |
- assert result.clean_exit( |
|
2126 |
- empty_stderr=True, output="Passphrase generation:\n" |
|
2127 |
- ), "expected clean exit, and option groups in help text" |
|
2128 |
- assert result.clean_exit( |
|
2129 |
- empty_stderr=True, output="Use $VISUAL or $EDITOR to configure" |
|
2130 |
- ), "expected clean exit, and option group epilog in help text" |
|
799 |
+ assert result.clean_exit(empty_stderr=True), ( |
|
800 |
+ "expected clean exit and empty stderr" |
|
801 |
+ ) |
|
802 |
+ passphrase = result.stdout.rstrip("\r\n") |
|
803 |
+ for i in range(len(passphrase) - 1): |
|
804 |
+ assert passphrase[i : i + 1] != passphrase[i + 1 : i + 2], ( |
|
805 |
+ f"derived password contains repeated character " |
|
806 |
+ f"at position {i}: {result.stdout!r}" |
|
807 |
+ ) |
|
2131 | 808 |
|
2132 |
- @Parametrize.COMMAND_NON_EAGER_ARGUMENTS |
|
2133 |
- @Parametrize.EAGER_ARGUMENTS |
|
2134 |
- def test_200_eager_options( |
|
809 |
+ @Parametrize.CONFIG_WITH_KEY |
|
810 |
+ def test_204a_key_from_config( |
|
2135 | 811 |
self, |
2136 |
- command: list[str], |
|
2137 |
- arguments: list[str], |
|
2138 |
- non_eager_arguments: list[str], |
|
812 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
813 |
+ config: _types.VaultConfig, |
|
2139 | 814 |
) -> None: |
2140 |
- """Eager options terminate option and argument processing.""" |
|
815 |
+ """A stored configured SSH key will be used.""" |
|
816 |
+ del running_ssh_agent |
|
2141 | 817 |
runner = machinery.CliRunner(mix_stderr=False) |
2142 | 818 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2143 | 819 |
# with-statements. |
... | ... |
@@ -2145,34 +821,40 @@ class TestAllCLI: |
2145 | 821 |
with contextlib.ExitStack() as stack: |
2146 | 822 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
2147 | 823 |
stack.enter_context( |
2148 |
- pytest_machinery.isolated_config( |
|
824 |
+ pytest_machinery.isolated_vault_config( |
|
2149 | 825 |
monkeypatch=monkeypatch, |
2150 | 826 |
runner=runner, |
827 |
+ vault_config=config, |
|
2151 | 828 |
) |
2152 | 829 |
) |
830 |
+ monkeypatch.setattr( |
|
831 |
+ vault.Vault, |
|
832 |
+ "phrase_from_key", |
|
833 |
+ callables.phrase_from_key, |
|
834 |
+ ) |
|
2153 | 835 |
result = runner.invoke( |
2154 |
- cli.derivepassphrase, |
|
2155 |
- [*command, *arguments, *non_eager_arguments], |
|
836 |
+ cli.derivepassphrase_vault, |
|
837 |
+ ["--", DUMMY_SERVICE], |
|
2156 | 838 |
catch_exceptions=False, |
2157 | 839 |
) |
2158 |
- assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
840 |
+ assert result.clean_exit(empty_stderr=True), ( |
|
841 |
+ "expected clean exit and empty stderr" |
|
842 |
+ ) |
|
843 |
+ assert result.stdout |
|
844 |
+ assert ( |
|
845 |
+ result.stdout.rstrip("\n").encode("UTF-8") |
|
846 |
+ != DUMMY_RESULT_PASSPHRASE |
|
847 |
+ ), "known false output: phrase-based instead of key-based" |
|
848 |
+ assert ( |
|
849 |
+ result.stdout.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1 |
|
850 |
+ ), "expected known output" |
|
2159 | 851 |
|
2160 |
- @Parametrize.ISATTY |
|
2161 |
- @Parametrize.COLORFUL_COMMAND_INPUT |
|
2162 |
- def test_201_automatic_color_mode( |
|
852 |
+ def test_204b_key_from_command_line( |
|
2163 | 853 |
self, |
2164 |
- isatty: bool, |
|
2165 |
- command_line: list[str], |
|
2166 |
- input: str | None, |
|
854 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
2167 | 855 |
) -> None: |
2168 |
- """Auto-detect if color should be used. |
|
2169 |
- |
|
2170 |
- (The answer currently is always no. See the |
|
2171 |
- [`conventional-configurable-text-styling` wishlist |
|
2172 |
- entry](../wishlist/conventional-configurable-text-styling.md).) |
|
2173 |
- |
|
2174 |
- """ |
|
2175 |
- color = False |
|
856 |
+ """An SSH key requested on the command-line will be used.""" |
|
857 |
+ del running_ssh_agent |
|
2176 | 858 |
runner = machinery.CliRunner(mix_stderr=False) |
2177 | 859 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2178 | 860 |
# with-statements. |
... | ... |
@@ -2180,40 +862,50 @@ class TestAllCLI: |
2180 | 862 |
with contextlib.ExitStack() as stack: |
2181 | 863 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
2182 | 864 |
stack.enter_context( |
2183 |
- pytest_machinery.isolated_config( |
|
865 |
+ pytest_machinery.isolated_vault_config( |
|
2184 | 866 |
monkeypatch=monkeypatch, |
2185 | 867 |
runner=runner, |
868 |
+ vault_config={ |
|
869 |
+ "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS} |
|
870 |
+ }, |
|
871 |
+ ) |
|
872 |
+ ) |
|
873 |
+ monkeypatch.setattr( |
|
874 |
+ cli_helpers, |
|
875 |
+ "get_suitable_ssh_keys", |
|
876 |
+ callables.suitable_ssh_keys, |
|
2186 | 877 |
) |
878 |
+ monkeypatch.setattr( |
|
879 |
+ vault.Vault, |
|
880 |
+ "phrase_from_key", |
|
881 |
+ callables.phrase_from_key, |
|
2187 | 882 |
) |
2188 | 883 |
result = runner.invoke( |
2189 |
- cli.derivepassphrase, |
|
2190 |
- command_line, |
|
2191 |
- input=input, |
|
884 |
+ cli.derivepassphrase_vault, |
|
885 |
+ ["-k", "--", DUMMY_SERVICE], |
|
886 |
+ input="1\n", |
|
2192 | 887 |
catch_exceptions=False, |
2193 |
- color=isatty, |
|
2194 | 888 |
) |
889 |
+ assert result.clean_exit(), "expected clean exit" |
|
890 |
+ assert result.stdout, "expected program output" |
|
891 |
+ last_line = result.stdout.splitlines(True)[-1] |
|
2195 | 892 |
assert ( |
2196 |
- not color |
|
2197 |
- or "\x1b[0m" in result.stderr |
|
2198 |
- or "\x1b[m" in result.stderr |
|
2199 |
- ), "Expected color, but found no ANSI reset sequence" |
|
2200 |
- assert color or "\x1b[" not in result.stderr, ( |
|
2201 |
- "Expected no color, but found an ANSI control sequence" |
|
893 |
+ last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE |
|
894 |
+ ), "known false output: phrase-based instead of key-based" |
|
895 |
+ assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, ( |
|
896 |
+ "expected known output" |
|
2202 | 897 |
) |
2203 | 898 |
|
2204 |
- def test_202a_derivepassphrase_version_option_output( |
|
899 |
+ @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS |
|
900 |
+ @Parametrize.KEY_INDEX |
|
901 |
+ def test_204c_key_override_on_command_line( |
|
2205 | 902 |
self, |
903 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
904 |
+ config: dict[str, Any], |
|
905 |
+ key_index: int, |
|
2206 | 906 |
) -> None: |
2207 |
- """The version output states supported features. |
|
2208 |
- |
|
2209 |
- The version output is parsed using [`parse_version_output`][]. |
|
2210 |
- Format examples can be found in |
|
2211 |
- [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the |
|
2212 |
- top-level `derivepassphrase` command, the output should contain |
|
2213 |
- the known and supported derivation schemes, and a list of |
|
2214 |
- subcommands. |
|
2215 |
- |
|
2216 |
- """ |
|
907 |
+ """A command-line SSH key will override the configured key.""" |
|
908 |
+ del running_ssh_agent |
|
2217 | 909 |
runner = machinery.CliRunner(mix_stderr=False) |
2218 | 910 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2219 | 911 |
# with-statements. |
... | ... |
@@ -2221,40 +913,38 @@ class TestAllCLI: |
2221 | 913 |
with contextlib.ExitStack() as stack: |
2222 | 914 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
2223 | 915 |
stack.enter_context( |
2224 |
- pytest_machinery.isolated_config( |
|
916 |
+ pytest_machinery.isolated_vault_config( |
|
2225 | 917 |
monkeypatch=monkeypatch, |
2226 | 918 |
runner=runner, |
919 |
+ vault_config=config, |
|
920 |
+ ) |
|
2227 | 921 |
) |
922 |
+ monkeypatch.setattr( |
|
923 |
+ ssh_agent.SSHAgentClient, |
|
924 |
+ "list_keys", |
|
925 |
+ callables.list_keys, |
|
926 |
+ ) |
|
927 |
+ monkeypatch.setattr( |
|
928 |
+ ssh_agent.SSHAgentClient, "sign", callables.sign |
|
2228 | 929 |
) |
2229 | 930 |
result = runner.invoke( |
2230 |
- cli.derivepassphrase, |
|
2231 |
- ["--version"], |
|
2232 |
- catch_exceptions=False, |
|
931 |
+ cli.derivepassphrase_vault, |
|
932 |
+ ["-k", "--", DUMMY_SERVICE], |
|
933 |
+ input=f"{key_index}\n", |
|
2233 | 934 |
) |
2234 |
- assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
2235 |
- assert result.stdout.strip(), "expected version output" |
|
2236 |
- version_data = parse_version_output(result.stdout) |
|
2237 |
- actually_known_schemes = dict.fromkeys(_types.DerivationScheme, True) |
|
2238 |
- subcommands = set(_types.Subcommand) |
|
2239 |
- assert version_data.derivation_schemes == actually_known_schemes |
|
2240 |
- assert not version_data.foreign_configuration_formats |
|
2241 |
- assert version_data.subcommands == subcommands |
|
2242 |
- assert not version_data.features |
|
2243 |
- assert not version_data.extras |
|
2244 |
- |
|
2245 |
- def test_202b_export_version_option_output( |
|
935 |
+ assert result.clean_exit(), "expected clean exit" |
|
936 |
+ assert result.stdout, "expected program output" |
|
937 |
+ assert result.stderr, "expected stderr" |
|
938 |
+ assert "Error:" not in result.stderr, ( |
|
939 |
+ "expected no error messages on stderr" |
|
940 |
+ ) |
|
941 |
+ |
|
942 |
+ def test_205_service_phrase_if_key_in_global_config( |
|
2246 | 943 |
self, |
944 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
2247 | 945 |
) -> None: |
2248 |
- """The version output states supported features. |
|
2249 |
- |
|
2250 |
- The version output is parsed using [`parse_version_output`][]. |
|
2251 |
- Format examples can be found in |
|
2252 |
- [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the |
|
2253 |
- `export` command, the output should contain the known foreign |
|
2254 |
- configuration formats (but not marked as supported), and a list |
|
2255 |
- of subcommands. |
|
2256 |
- |
|
2257 |
- """ |
|
946 |
+ """A command-line passphrase will override the configured key.""" |
|
947 |
+ del running_ssh_agent |
|
2258 | 948 |
runner = machinery.CliRunner(mix_stderr=False) |
2259 | 949 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2260 | 950 |
# with-statements. |
... | ... |
@@ -2262,47 +952,53 @@ class TestAllCLI: |
2262 | 952 |
with contextlib.ExitStack() as stack: |
2263 | 953 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
2264 | 954 |
stack.enter_context( |
2265 |
- pytest_machinery.isolated_config( |
|
955 |
+ pytest_machinery.isolated_vault_config( |
|
2266 | 956 |
monkeypatch=monkeypatch, |
2267 | 957 |
runner=runner, |
958 |
+ vault_config={ |
|
959 |
+ "global": {"key": DUMMY_KEY1_B64}, |
|
960 |
+ "services": { |
|
961 |
+ DUMMY_SERVICE: { |
|
962 |
+ "phrase": DUMMY_PASSPHRASE.rstrip("\n"), |
|
963 |
+ **DUMMY_CONFIG_SETTINGS, |
|
964 |
+ } |
|
965 |
+ }, |
|
966 |
+ }, |
|
967 |
+ ) |
|
2268 | 968 |
) |
969 |
+ monkeypatch.setattr( |
|
970 |
+ ssh_agent.SSHAgentClient, |
|
971 |
+ "list_keys", |
|
972 |
+ callables.list_keys, |
|
973 |
+ ) |
|
974 |
+ monkeypatch.setattr( |
|
975 |
+ ssh_agent.SSHAgentClient, "sign", callables.sign |
|
2269 | 976 |
) |
2270 | 977 |
result = runner.invoke( |
2271 |
- cli.derivepassphrase, |
|
2272 |
- ["export", "--version"], |
|
978 |
+ cli.derivepassphrase_vault, |
|
979 |
+ ["--", DUMMY_SERVICE], |
|
2273 | 980 |
catch_exceptions=False, |
2274 | 981 |
) |
2275 |
- assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
2276 |
- assert result.stdout.strip(), "expected version output" |
|
2277 |
- version_data = parse_version_output(result.stdout) |
|
2278 |
- actually_known_formats: dict[str, bool] = { |
|
2279 |
- _types.ForeignConfigurationFormat.VAULT_STOREROOM: False, |
|
2280 |
- _types.ForeignConfigurationFormat.VAULT_V02: False, |
|
2281 |
- _types.ForeignConfigurationFormat.VAULT_V03: False, |
|
2282 |
- } |
|
2283 |
- subcommands = set(_types.ExportSubcommand) |
|
2284 |
- assert not version_data.derivation_schemes |
|
982 |
+ assert result.clean_exit(), "expected clean exit" |
|
983 |
+ assert result.stdout, "expected program output" |
|
984 |
+ last_line = result.stdout.splitlines(True)[-1] |
|
2285 | 985 |
assert ( |
2286 |
- version_data.foreign_configuration_formats |
|
2287 |
- == actually_known_formats |
|
986 |
+ last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE |
|
987 |
+ ), "known false output: phrase-based instead of key-based" |
|
988 |
+ assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, ( |
|
989 |
+ "expected known output" |
|
2288 | 990 |
) |
2289 |
- assert version_data.subcommands == subcommands |
|
2290 |
- assert not version_data.features |
|
2291 |
- assert not version_data.extras |
|
2292 | 991 |
|
2293 |
- def test_202c_export_vault_version_option_output( |
|
992 |
+ @Parametrize.KEY_OVERRIDING_IN_CONFIG |
|
993 |
+ def test_206_setting_phrase_thus_overriding_key_in_config( |
|
2294 | 994 |
self, |
995 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
996 |
+ caplog: pytest.LogCaptureFixture, |
|
997 |
+ config: _types.VaultConfig, |
|
998 |
+ command_line: list[str], |
|
2295 | 999 |
) -> None: |
2296 |
- """The version output states supported features. |
|
2297 |
- |
|
2298 |
- The version output is parsed using [`parse_version_output`][]. |
|
2299 |
- Format examples can be found in |
|
2300 |
- [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the |
|
2301 |
- `export vault` subcommand, the output should contain the |
|
2302 |
- vault-specific subset of the known or supported foreign |
|
2303 |
- configuration formats, and a list of available PEP 508 extras. |
|
2304 |
- |
|
2305 |
- """ |
|
1000 |
+ """Configuring a passphrase atop an SSH key works, but warns.""" |
|
1001 |
+ del running_ssh_agent |
|
2306 | 1002 |
runner = machinery.CliRunner(mix_stderr=False) |
2307 | 1003 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2308 | 1004 |
# with-statements. |
... | ... |
@@ -2310,54 +1006,59 @@ class TestAllCLI: |
2310 | 1006 |
with contextlib.ExitStack() as stack: |
2311 | 1007 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
2312 | 1008 |
stack.enter_context( |
2313 |
- pytest_machinery.isolated_config( |
|
1009 |
+ pytest_machinery.isolated_vault_config( |
|
2314 | 1010 |
monkeypatch=monkeypatch, |
2315 | 1011 |
runner=runner, |
1012 |
+ vault_config=config, |
|
1013 |
+ ) |
|
1014 |
+ ) |
|
1015 |
+ monkeypatch.setattr( |
|
1016 |
+ ssh_agent.SSHAgentClient, |
|
1017 |
+ "list_keys", |
|
1018 |
+ callables.list_keys, |
|
2316 | 1019 |
) |
1020 |
+ monkeypatch.setattr( |
|
1021 |
+ ssh_agent.SSHAgentClient, "sign", callables.sign |
|
2317 | 1022 |
) |
2318 | 1023 |
result = runner.invoke( |
2319 |
- cli.derivepassphrase, |
|
2320 |
- ["export", "vault", "--version"], |
|
1024 |
+ cli.derivepassphrase_vault, |
|
1025 |
+ command_line, |
|
1026 |
+ input=DUMMY_PASSPHRASE, |
|
2321 | 1027 |
catch_exceptions=False, |
2322 | 1028 |
) |
2323 |
- assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
2324 |
- assert result.stdout.strip(), "expected version output" |
|
2325 |
- version_data = parse_version_output(result.stdout) |
|
2326 |
- actually_known_formats: dict[str, bool] = {} |
|
2327 |
- actually_enabled_extras: set[str] = set() |
|
2328 |
- with contextlib.suppress(ModuleNotFoundError): |
|
2329 |
- from derivepassphrase.exporter import storeroom, vault_native # noqa: I001,PLC0415 |
|
2330 |
- |
|
2331 |
- actually_known_formats.update({ |
|
2332 |
- _types.ForeignConfigurationFormat.VAULT_STOREROOM: not storeroom.STUBBED, |
|
2333 |
- _types.ForeignConfigurationFormat.VAULT_V02: not vault_native.STUBBED, |
|
2334 |
- _types.ForeignConfigurationFormat.VAULT_V03: not vault_native.STUBBED, |
|
2335 |
- }) |
|
2336 |
- with contextlib.suppress(ModuleNotFoundError): |
|
2337 |
- import cryptography # noqa: F401,PLC0415 |
|
2338 |
- |
|
2339 |
- actually_enabled_extras.add(_types.PEP508Extra.EXPORT) |
|
2340 |
- assert not version_data.derivation_schemes |
|
2341 |
- assert ( |
|
2342 |
- version_data.foreign_configuration_formats |
|
2343 |
- == actually_known_formats |
|
2344 |
- ) |
|
2345 |
- assert not version_data.subcommands |
|
2346 |
- assert not version_data.features |
|
2347 |
- assert version_data.extras == actually_enabled_extras |
|
1029 |
+ assert result.clean_exit(), "expected clean exit" |
|
1030 |
+ assert not result.stdout.strip(), "expected no program output" |
|
1031 |
+ assert result.stderr, "expected known error output" |
|
1032 |
+ err_lines = result.stderr.splitlines(False) |
|
1033 |
+ assert err_lines[0].startswith("Passphrase:") |
|
1034 |
+ assert machinery.warning_emitted( |
|
1035 |
+ "Setting a service passphrase is ineffective ", |
|
1036 |
+ caplog.record_tuples, |
|
1037 |
+ ) or machinery.warning_emitted( |
|
1038 |
+ "Setting a global passphrase is ineffective ", |
|
1039 |
+ caplog.record_tuples, |
|
1040 |
+ ), "expected known warning message" |
|
1041 |
+ assert all(map(is_warning_line, result.stderr.splitlines(True))) |
|
1042 |
+ assert all( |
|
1043 |
+ map(is_harmless_config_import_warning, caplog.record_tuples) |
|
1044 |
+ ), "unexpected error output" |
|
2348 | 1045 |
|
2349 |
- def test_202d_vault_version_option_output( |
|
1046 |
+ @hypothesis.given( |
|
1047 |
+ notes=strategies.text( |
|
1048 |
+ strategies.characters( |
|
1049 |
+ min_codepoint=32, |
|
1050 |
+ max_codepoint=126, |
|
1051 |
+ include_characters="\n", |
|
1052 |
+ ), |
|
1053 |
+ max_size=256, |
|
1054 |
+ ), |
|
1055 |
+ ) |
|
1056 |
+ def test_207_service_with_notes_actually_prints_notes( |
|
2350 | 1057 |
self, |
1058 |
+ notes: str, |
|
2351 | 1059 |
) -> None: |
2352 |
- """The version output states supported features. |
|
2353 |
- |
|
2354 |
- The version output is parsed using [`parse_version_output`][]. |
|
2355 |
- Format examples can be found in |
|
2356 |
- [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the |
|
2357 |
- vault command, the output should not contain anything beyond the |
|
2358 |
- first paragraph. |
|
2359 |
- |
|
2360 |
- """ |
|
1060 |
+ """Service notes are printed, if they exist.""" |
|
1061 |
+ hypothesis.assume("Error:" not in notes) |
|
2361 | 1062 |
runner = machinery.CliRunner(mix_stderr=False) |
2362 | 1063 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2363 | 1064 |
# with-statements. |
... | ... |
@@ -2365,81 +1066,45 @@ class TestAllCLI: |
2365 | 1066 |
with contextlib.ExitStack() as stack: |
2366 | 1067 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
2367 | 1068 |
stack.enter_context( |
2368 |
- pytest_machinery.isolated_config( |
|
2369 |
- monkeypatch=monkeypatch, |
|
2370 |
- runner=runner, |
|
2371 |
- ) |
|
2372 |
- ) |
|
2373 |
- result = runner.invoke( |
|
2374 |
- cli.derivepassphrase, |
|
2375 |
- ["vault", "--version"], |
|
2376 |
- catch_exceptions=False, |
|
2377 |
- ) |
|
2378 |
- assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
2379 |
- assert result.stdout.strip(), "expected version output" |
|
2380 |
- version_data = parse_version_output(result.stdout) |
|
2381 |
- |
|
2382 |
- ssh_key_supported = True |
|
2383 |
- |
|
2384 |
- def react_to_notimplementederror( |
|
2385 |
- _exc: BaseException, |
|
2386 |
- ) -> None: # pragma: no cover[unused] |
|
2387 |
- nonlocal ssh_key_supported |
|
2388 |
- ssh_key_supported = False |
|
2389 |
- |
|
2390 |
- with exceptiongroup.catch({ # noqa: SIM117 |
|
2391 |
- NotImplementedError: react_to_notimplementederror, |
|
2392 |
- Exception: lambda *_args: None, |
|
2393 |
- }): |
|
2394 |
- with ssh_agent.SSHAgentClient.ensure_agent_subcontext(): |
|
2395 |
- pass |
|
2396 |
- features: dict[str, bool] = { |
|
2397 |
- _types.Feature.SSH_KEY: ssh_key_supported, |
|
2398 |
- } |
|
2399 |
- assert not version_data.derivation_schemes |
|
2400 |
- assert not version_data.foreign_configuration_formats |
|
2401 |
- assert not version_data.subcommands |
|
2402 |
- assert version_data.features == features |
|
2403 |
- assert not version_data.extras |
|
2404 |
- |
|
2405 |
- |
|
2406 |
-class TestCLI: |
|
2407 |
- """Tests for the `derivepassphrase vault` command-line interface.""" |
|
2408 |
- |
|
2409 |
- def test_200_help_output( |
|
2410 |
- self, |
|
2411 |
- ) -> None: |
|
2412 |
- """The `--help` option emits help text.""" |
|
2413 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2414 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2415 |
- # with-statements. |
|
2416 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2417 |
- with contextlib.ExitStack() as stack: |
|
2418 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2419 |
- stack.enter_context( |
|
2420 |
- pytest_machinery.isolated_config( |
|
1069 |
+ pytest_machinery.isolated_vault_config( |
|
2421 | 1070 |
monkeypatch=monkeypatch, |
2422 | 1071 |
runner=runner, |
1072 |
+ vault_config={ |
|
1073 |
+ "global": { |
|
1074 |
+ "phrase": DUMMY_PASSPHRASE, |
|
1075 |
+ }, |
|
1076 |
+ "services": { |
|
1077 |
+ DUMMY_SERVICE: { |
|
1078 |
+ "notes": notes, |
|
1079 |
+ **DUMMY_CONFIG_SETTINGS, |
|
1080 |
+ }, |
|
1081 |
+ }, |
|
1082 |
+ }, |
|
2423 | 1083 |
) |
2424 | 1084 |
) |
2425 | 1085 |
result = runner.invoke( |
2426 | 1086 |
cli.derivepassphrase_vault, |
2427 |
- ["--help"], |
|
2428 |
- catch_exceptions=False, |
|
1087 |
+ ["--", DUMMY_SERVICE], |
|
1088 |
+ ) |
|
1089 |
+ assert result.clean_exit(), "expected clean exit" |
|
1090 |
+ assert result.stdout, "expected program output" |
|
1091 |
+ assert result.stdout.strip() == DUMMY_RESULT_PASSPHRASE.decode( |
|
1092 |
+ "ascii" |
|
1093 |
+ ), "expected known program output" |
|
1094 |
+ assert result.stderr or not notes.strip(), "expected stderr" |
|
1095 |
+ assert "Error:" not in result.stderr, ( |
|
1096 |
+ "expected no error messages on stderr" |
|
1097 |
+ ) |
|
1098 |
+ assert result.stderr.strip() == notes.strip(), ( |
|
1099 |
+ "expected known stderr contents" |
|
2429 | 1100 |
) |
2430 |
- assert result.clean_exit( |
|
2431 |
- empty_stderr=True, output="Passphrase generation:\n" |
|
2432 |
- ), "expected clean exit, and option groups in help text" |
|
2433 |
- assert result.clean_exit( |
|
2434 |
- empty_stderr=True, output="Use $VISUAL or $EDITOR to configure" |
|
2435 |
- ), "expected clean exit, and option group epilog in help text" |
|
2436 | 1101 |
|
2437 |
- # TODO(the-13th-letter): Remove this test once |
|
2438 |
- # TestAllCLI.test_202_version_option_output no longer xfails. |
|
2439 |
- def test_200a_version_output( |
|
1102 |
+ @Parametrize.VAULT_CHARSET_OPTION |
|
1103 |
+ def test_210_invalid_argument_range( |
|
2440 | 1104 |
self, |
1105 |
+ option: str, |
|
2441 | 1106 |
) -> None: |
2442 |
- """The `--version` option emits version information.""" |
|
1107 |
+ """Requesting invalidly many characters from a class fails.""" |
|
2443 | 1108 |
runner = machinery.CliRunner(mix_stderr=False) |
2444 | 1109 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2445 | 1110 |
# with-statements. |
... | ... |
@@ -2452,26 +1117,26 @@ class TestCLI: |
2452 | 1117 |
runner=runner, |
2453 | 1118 |
) |
2454 | 1119 |
) |
1120 |
+ for value in "-42", "invalid": |
|
2455 | 1121 |
result = runner.invoke( |
2456 | 1122 |
cli.derivepassphrase_vault, |
2457 |
- ["--version"], |
|
1123 |
+ [option, value, "-p", "--", DUMMY_SERVICE], |
|
1124 |
+ input=DUMMY_PASSPHRASE, |
|
2458 | 1125 |
catch_exceptions=False, |
2459 | 1126 |
) |
2460 |
- assert result.clean_exit(empty_stderr=True, output=cli.PROG_NAME), ( |
|
2461 |
- "expected clean exit, and program name in version text" |
|
2462 |
- ) |
|
2463 |
- assert result.clean_exit(empty_stderr=True, output=cli.VERSION), ( |
|
2464 |
- "expected clean exit, and version in help text" |
|
1127 |
+ assert result.error_exit(error="Invalid value"), ( |
|
1128 |
+ "expected error exit and known error message" |
|
2465 | 1129 |
) |
2466 | 1130 |
|
2467 |
- @Parametrize.CHARSET_NAME |
|
2468 |
- def test_201_disable_character_set( |
|
1131 |
+ @Parametrize.OPTION_COMBINATIONS_SERVICE_NEEDED |
|
1132 |
+ def test_211_service_needed( |
|
2469 | 1133 |
self, |
2470 |
- charset_name: str, |
|
1134 |
+ options: list[str], |
|
1135 |
+ service: bool | None, |
|
1136 |
+ input: str | None, |
|
1137 |
+ check_success: bool, |
|
2471 | 1138 |
) -> None: |
2472 |
- """Named character classes can be disabled on the command-line.""" |
|
2473 |
- option = f"--{charset_name}" |
|
2474 |
- charset = vault.Vault.CHARSETS[charset_name].decode("ascii") |
|
1139 |
+ """We require or forbid a service argument, depending on options.""" |
|
2475 | 1140 |
runner = machinery.CliRunner(mix_stderr=False) |
2476 | 1141 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2477 | 1142 |
# with-statements. |
... | ... |
@@ -2479,9 +1144,10 @@ class TestCLI: |
2479 | 1144 |
with contextlib.ExitStack() as stack: |
2480 | 1145 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
2481 | 1146 |
stack.enter_context( |
2482 |
- pytest_machinery.isolated_config( |
|
1147 |
+ pytest_machinery.isolated_vault_config( |
|
2483 | 1148 |
monkeypatch=monkeypatch, |
2484 | 1149 |
runner=runner, |
1150 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2485 | 1151 |
) |
2486 | 1152 |
) |
2487 | 1153 |
monkeypatch.setattr( |
... | ... |
@@ -2491,30 +1157,37 @@ class TestCLI: |
2491 | 1157 |
) |
2492 | 1158 |
result = runner.invoke( |
2493 | 1159 |
cli.derivepassphrase_vault, |
2494 |
- [option, "0", "-p", "--", DUMMY_SERVICE], |
|
2495 |
- input=DUMMY_PASSPHRASE, |
|
1160 |
+ options if service else [*options, "--", DUMMY_SERVICE], |
|
1161 |
+ input=input, |
|
2496 | 1162 |
catch_exceptions=False, |
2497 | 1163 |
) |
2498 |
- assert result.clean_exit(empty_stderr=True), "expected clean exit:" |
|
2499 |
- for c in charset: |
|
2500 |
- assert c not in result.stdout, ( |
|
2501 |
- f"derived password contains forbidden character {c!r}" |
|
1164 |
+ if service is not None: |
|
1165 |
+ err_msg = ( |
|
1166 |
+ " requires a SERVICE" |
|
1167 |
+ if service |
|
1168 |
+ else " does not take a SERVICE argument" |
|
2502 | 1169 |
) |
2503 |
- |
|
2504 |
- def test_202_disable_repetition( |
|
2505 |
- self, |
|
2506 |
- ) -> None: |
|
2507 |
- """Character repetition can be disabled on the command-line.""" |
|
2508 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1170 |
+ assert result.error_exit(error=err_msg), ( |
|
1171 |
+ "expected error exit and known error message" |
|
1172 |
+ ) |
|
1173 |
+ else: |
|
1174 |
+ assert result.clean_exit(empty_stderr=True), ( |
|
1175 |
+ "expected clean exit" |
|
1176 |
+ ) |
|
1177 |
+ if check_success: |
|
2509 | 1178 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2510 | 1179 |
# with-statements. |
2511 | 1180 |
# https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
2512 | 1181 |
with contextlib.ExitStack() as stack: |
2513 | 1182 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
2514 | 1183 |
stack.enter_context( |
2515 |
- pytest_machinery.isolated_config( |
|
1184 |
+ pytest_machinery.isolated_vault_config( |
|
2516 | 1185 |
monkeypatch=monkeypatch, |
2517 | 1186 |
runner=runner, |
1187 |
+ vault_config={ |
|
1188 |
+ "global": {"phrase": "abc"}, |
|
1189 |
+ "services": {}, |
|
1190 |
+ }, |
|
2518 | 1191 |
) |
2519 | 1192 |
) |
2520 | 1193 |
monkeypatch.setattr( |
... | ... |
@@ -2524,28 +1197,29 @@ class TestCLI: |
2524 | 1197 |
) |
2525 | 1198 |
result = runner.invoke( |
2526 | 1199 |
cli.derivepassphrase_vault, |
2527 |
- ["--repeat", "0", "-p", "--", DUMMY_SERVICE], |
|
2528 |
- input=DUMMY_PASSPHRASE, |
|
1200 |
+ [*options, "--", DUMMY_SERVICE] if service else options, |
|
1201 |
+ input=input, |
|
2529 | 1202 |
catch_exceptions=False, |
2530 | 1203 |
) |
2531 |
- assert result.clean_exit(empty_stderr=True), ( |
|
2532 |
- "expected clean exit and empty stderr" |
|
2533 |
- ) |
|
2534 |
- passphrase = result.stdout.rstrip("\r\n") |
|
2535 |
- for i in range(len(passphrase) - 1): |
|
2536 |
- assert passphrase[i : i + 1] != passphrase[i + 1 : i + 2], ( |
|
2537 |
- f"derived password contains repeated character " |
|
2538 |
- f"at position {i}: {result.stdout!r}" |
|
2539 |
- ) |
|
1204 |
+ assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
2540 | 1205 |
|
2541 |
- @Parametrize.CONFIG_WITH_KEY |
|
2542 |
- def test_204a_key_from_config( |
|
1206 |
+ def test_211a_empty_service_name_causes_warning( |
|
2543 | 1207 |
self, |
2544 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
2545 |
- config: _types.VaultConfig, |
|
1208 |
+ caplog: pytest.LogCaptureFixture, |
|
2546 | 1209 |
) -> None: |
2547 |
- """A stored configured SSH key will be used.""" |
|
2548 |
- del running_ssh_agent |
|
1210 |
+ """Using an empty service name (where permissible) warns. |
|
1211 |
+ |
|
1212 |
+ Only the `--config` option can optionally take a service name. |
|
1213 |
+ |
|
1214 |
+ """ |
|
1215 |
+ |
|
1216 |
+ def is_expected_warning(record: tuple[str, int, str]) -> bool: |
|
1217 |
+ return is_harmless_config_import_warning( |
|
1218 |
+ record |
|
1219 |
+ ) or machinery.warning_emitted( |
|
1220 |
+ "An empty SERVICE is not supported by vault(1)", [record] |
|
1221 |
+ ) |
|
1222 |
+ |
|
2549 | 1223 |
runner = machinery.CliRunner(mix_stderr=False) |
2550 | 1224 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2551 | 1225 |
# with-statements. |
... | ... |
@@ -2556,37 +1230,52 @@ class TestCLI: |
2556 | 1230 |
pytest_machinery.isolated_vault_config( |
2557 | 1231 |
monkeypatch=monkeypatch, |
2558 | 1232 |
runner=runner, |
2559 |
- vault_config=config, |
|
1233 |
+ vault_config={"services": {}}, |
|
2560 | 1234 |
) |
2561 | 1235 |
) |
2562 | 1236 |
monkeypatch.setattr( |
2563 |
- vault.Vault, |
|
2564 |
- "phrase_from_key", |
|
2565 |
- callables.phrase_from_key, |
|
1237 |
+ cli_helpers, |
|
1238 |
+ "prompt_for_passphrase", |
|
1239 |
+ callables.auto_prompt, |
|
2566 | 1240 |
) |
2567 | 1241 |
result = runner.invoke( |
2568 | 1242 |
cli.derivepassphrase_vault, |
2569 |
- ["--", DUMMY_SERVICE], |
|
1243 |
+ ["--config", "--length=30", "--", ""], |
|
2570 | 1244 |
catch_exceptions=False, |
2571 | 1245 |
) |
2572 |
- assert result.clean_exit(empty_stderr=True), ( |
|
2573 |
- "expected clean exit and empty stderr" |
|
1246 |
+ assert result.clean_exit(empty_stderr=False), "expected clean exit" |
|
1247 |
+ assert result.stderr is not None, "expected known error output" |
|
1248 |
+ assert all(map(is_expected_warning, caplog.record_tuples)), ( |
|
1249 |
+ "expected known error output" |
|
2574 | 1250 |
) |
2575 |
- assert result.stdout |
|
2576 |
- assert ( |
|
2577 |
- result.stdout.rstrip("\n").encode("UTF-8") |
|
2578 |
- != DUMMY_RESULT_PASSPHRASE |
|
2579 |
- ), "known false output: phrase-based instead of key-based" |
|
2580 |
- assert ( |
|
2581 |
- result.stdout.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1 |
|
2582 |
- ), "expected known output" |
|
1251 |
+ assert cli_helpers.load_config() == { |
|
1252 |
+ "global": {"length": 30}, |
|
1253 |
+ "services": {}, |
|
1254 |
+ }, "requested configuration change was not applied" |
|
1255 |
+ caplog.clear() |
|
1256 |
+ result = runner.invoke( |
|
1257 |
+ cli.derivepassphrase_vault, |
|
1258 |
+ ["--import", "-"], |
|
1259 |
+ input=json.dumps({"services": {"": {"length": 40}}}), |
|
1260 |
+ catch_exceptions=False, |
|
1261 |
+ ) |
|
1262 |
+ assert result.clean_exit(empty_stderr=False), "expected clean exit" |
|
1263 |
+ assert result.stderr is not None, "expected known error output" |
|
1264 |
+ assert all(map(is_expected_warning, caplog.record_tuples)), ( |
|
1265 |
+ "expected known error output" |
|
1266 |
+ ) |
|
1267 |
+ assert cli_helpers.load_config() == { |
|
1268 |
+ "global": {"length": 30}, |
|
1269 |
+ "services": {"": {"length": 40}}, |
|
1270 |
+ }, "requested configuration change was not applied" |
|
2583 | 1271 |
|
2584 |
- def test_204b_key_from_command_line( |
|
1272 |
+ @Parametrize.OPTION_COMBINATIONS_INCOMPATIBLE |
|
1273 |
+ def test_212_incompatible_options( |
|
2585 | 1274 |
self, |
2586 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
1275 |
+ options: list[str], |
|
1276 |
+ service: bool | None, |
|
2587 | 1277 |
) -> None: |
2588 |
- """An SSH key requested on the command-line will be used.""" |
|
2589 |
- del running_ssh_agent |
|
1278 |
+ """Incompatible options are detected.""" |
|
2590 | 1279 |
runner = machinery.CliRunner(mix_stderr=False) |
2591 | 1280 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2592 | 1281 |
# with-statements. |
... | ... |
@@ -2594,50 +1283,28 @@ class TestCLI: |
2594 | 1283 |
with contextlib.ExitStack() as stack: |
2595 | 1284 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
2596 | 1285 |
stack.enter_context( |
2597 |
- pytest_machinery.isolated_vault_config( |
|
1286 |
+ pytest_machinery.isolated_config( |
|
2598 | 1287 |
monkeypatch=monkeypatch, |
2599 | 1288 |
runner=runner, |
2600 |
- vault_config={ |
|
2601 |
- "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS} |
|
2602 |
- }, |
|
2603 |
- ) |
|
2604 | 1289 |
) |
2605 |
- monkeypatch.setattr( |
|
2606 |
- cli_helpers, |
|
2607 |
- "get_suitable_ssh_keys", |
|
2608 |
- callables.suitable_ssh_keys, |
|
2609 |
- ) |
|
2610 |
- monkeypatch.setattr( |
|
2611 |
- vault.Vault, |
|
2612 |
- "phrase_from_key", |
|
2613 |
- callables.phrase_from_key, |
|
2614 | 1290 |
) |
2615 | 1291 |
result = runner.invoke( |
2616 | 1292 |
cli.derivepassphrase_vault, |
2617 |
- ["-k", "--", DUMMY_SERVICE], |
|
2618 |
- input="1\n", |
|
1293 |
+ [*options, "--", DUMMY_SERVICE] if service else options, |
|
1294 |
+ input=DUMMY_PASSPHRASE, |
|
2619 | 1295 |
catch_exceptions=False, |
2620 | 1296 |
) |
2621 |
- assert result.clean_exit(), "expected clean exit" |
|
2622 |
- assert result.stdout, "expected program output" |
|
2623 |
- last_line = result.stdout.splitlines(True)[-1] |
|
2624 |
- assert ( |
|
2625 |
- last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE |
|
2626 |
- ), "known false output: phrase-based instead of key-based" |
|
2627 |
- assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, ( |
|
2628 |
- "expected known output" |
|
1297 |
+ assert result.error_exit(error="mutually exclusive with "), ( |
|
1298 |
+ "expected error exit and known error message" |
|
2629 | 1299 |
) |
2630 | 1300 |
|
2631 |
- @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS |
|
2632 |
- @Parametrize.KEY_INDEX |
|
2633 |
- def test_204c_key_override_on_command_line( |
|
1301 |
+ @Parametrize.VALID_TEST_CONFIGS |
|
1302 |
+ def test_213_import_config_success( |
|
2634 | 1303 |
self, |
2635 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
2636 |
- config: dict[str, Any], |
|
2637 |
- key_index: int, |
|
1304 |
+ caplog: pytest.LogCaptureFixture, |
|
1305 |
+ config: Any, |
|
2638 | 1306 |
) -> None: |
2639 |
- """A command-line SSH key will override the configured key.""" |
|
2640 |
- del running_ssh_agent |
|
1307 |
+ """Importing a configuration works.""" |
|
2641 | 1308 |
runner = machinery.CliRunner(mix_stderr=False) |
2642 | 1309 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2643 | 1310 |
# with-statements. |
... | ... |
@@ -2648,89 +1315,54 @@ class TestCLI: |
2648 | 1315 |
pytest_machinery.isolated_vault_config( |
2649 | 1316 |
monkeypatch=monkeypatch, |
2650 | 1317 |
runner=runner, |
2651 |
- vault_config=config, |
|
1318 |
+ vault_config={"services": {}}, |
|
2652 | 1319 |
) |
2653 | 1320 |
) |
2654 |
- monkeypatch.setattr( |
|
2655 |
- ssh_agent.SSHAgentClient, |
|
2656 |
- "list_keys", |
|
2657 |
- callables.list_keys, |
|
2658 |
- ) |
|
2659 |
- monkeypatch.setattr( |
|
2660 |
- ssh_agent.SSHAgentClient, "sign", callables.sign |
|
2661 |
- ) |
|
2662 | 1321 |
result = runner.invoke( |
2663 | 1322 |
cli.derivepassphrase_vault, |
2664 |
- ["-k", "--", DUMMY_SERVICE], |
|
2665 |
- input=f"{key_index}\n", |
|
2666 |
- ) |
|
2667 |
- assert result.clean_exit(), "expected clean exit" |
|
2668 |
- assert result.stdout, "expected program output" |
|
2669 |
- assert result.stderr, "expected stderr" |
|
2670 |
- assert "Error:" not in result.stderr, ( |
|
2671 |
- "expected no error messages on stderr" |
|
1323 |
+ ["--import", "-"], |
|
1324 |
+ input=json.dumps(config), |
|
1325 |
+ catch_exceptions=False, |
|
2672 | 1326 |
) |
1327 |
+ config_txt = cli_helpers.config_filename( |
|
1328 |
+ subsystem="vault" |
|
1329 |
+ ).read_text(encoding="UTF-8") |
|
1330 |
+ config2 = json.loads(config_txt) |
|
1331 |
+ assert result.clean_exit(empty_stderr=False), "expected clean exit" |
|
1332 |
+ assert config2 == config, "config not imported correctly" |
|
1333 |
+ assert not result.stderr or all( # pragma: no branch |
|
1334 |
+ map(is_harmless_config_import_warning, caplog.record_tuples) |
|
1335 |
+ ), "unexpected error output" |
|
1336 |
+ assert_vault_config_is_indented_and_line_broken(config_txt) |
|
2673 | 1337 |
|
2674 |
- def test_205_service_phrase_if_key_in_global_config( |
|
2675 |
- self, |
|
2676 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
2677 |
- ) -> None: |
|
2678 |
- """A command-line passphrase will override the configured key.""" |
|
2679 |
- del running_ssh_agent |
|
2680 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2681 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2682 |
- # with-statements. |
|
2683 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2684 |
- with contextlib.ExitStack() as stack: |
|
2685 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2686 |
- stack.enter_context( |
|
2687 |
- pytest_machinery.isolated_vault_config( |
|
2688 |
- monkeypatch=monkeypatch, |
|
2689 |
- runner=runner, |
|
2690 |
- vault_config={ |
|
2691 |
- "global": {"key": DUMMY_KEY1_B64}, |
|
2692 |
- "services": { |
|
2693 |
- DUMMY_SERVICE: { |
|
2694 |
- "phrase": DUMMY_PASSPHRASE.rstrip("\n"), |
|
2695 |
- **DUMMY_CONFIG_SETTINGS, |
|
2696 |
- } |
|
2697 |
- }, |
|
2698 |
- }, |
|
2699 |
- ) |
|
2700 |
- ) |
|
2701 |
- monkeypatch.setattr( |
|
2702 |
- ssh_agent.SSHAgentClient, |
|
2703 |
- "list_keys", |
|
2704 |
- callables.list_keys, |
|
2705 |
- ) |
|
2706 |
- monkeypatch.setattr( |
|
2707 |
- ssh_agent.SSHAgentClient, "sign", callables.sign |
|
1338 |
+ @hypothesis.settings( |
|
1339 |
+ suppress_health_check=[ |
|
1340 |
+ *hypothesis.settings().suppress_health_check, |
|
1341 |
+ hypothesis.HealthCheck.function_scoped_fixture, |
|
1342 |
+ ], |
|
2708 | 1343 |
) |
2709 |
- result = runner.invoke( |
|
2710 |
- cli.derivepassphrase_vault, |
|
2711 |
- ["--", DUMMY_SERVICE], |
|
2712 |
- catch_exceptions=False, |
|
1344 |
+ @hypothesis.given( |
|
1345 |
+ conf=hypothesis_machinery.smudged_vault_test_config( |
|
1346 |
+ strategies.sampled_from([ |
|
1347 |
+ conf for conf in data.TEST_CONFIGS if conf.is_valid() |
|
1348 |
+ ]) |
|
2713 | 1349 |
) |
2714 |
- assert result.clean_exit(), "expected clean exit" |
|
2715 |
- assert result.stdout, "expected program output" |
|
2716 |
- last_line = result.stdout.splitlines(True)[-1] |
|
2717 |
- assert ( |
|
2718 |
- last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE |
|
2719 |
- ), "known false output: phrase-based instead of key-based" |
|
2720 |
- assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, ( |
|
2721 |
- "expected known output" |
|
2722 | 1350 |
) |
2723 |
- |
|
2724 |
- @Parametrize.KEY_OVERRIDING_IN_CONFIG |
|
2725 |
- def test_206_setting_phrase_thus_overriding_key_in_config( |
|
1351 |
+ def test_213a_import_config_success( |
|
2726 | 1352 |
self, |
2727 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
2728 | 1353 |
caplog: pytest.LogCaptureFixture, |
2729 |
- config: _types.VaultConfig, |
|
2730 |
- command_line: list[str], |
|
1354 |
+ conf: data.VaultTestConfig, |
|
2731 | 1355 |
) -> None: |
2732 |
- """Configuring a passphrase atop an SSH key works, but warns.""" |
|
2733 |
- del running_ssh_agent |
|
1356 |
+ """Importing a smudged configuration works. |
|
1357 |
+ |
|
1358 |
+ Tested via hypothesis. |
|
1359 |
+ |
|
1360 |
+ """ |
|
1361 |
+ config = conf.config |
|
1362 |
+ config2 = copy.deepcopy(config) |
|
1363 |
+ _types.clean_up_falsy_vault_config_values(config2) |
|
1364 |
+ # Reset caplog between hypothesis runs. |
|
1365 |
+ caplog.clear() |
|
2734 | 1366 |
runner = machinery.CliRunner(mix_stderr=False) |
2735 | 1367 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2736 | 1368 |
# with-statements. |
... | ... |
@@ -2741,56 +1373,30 @@ class TestCLI: |
2741 | 1373 |
pytest_machinery.isolated_vault_config( |
2742 | 1374 |
monkeypatch=monkeypatch, |
2743 | 1375 |
runner=runner, |
2744 |
- vault_config=config, |
|
2745 |
- ) |
|
2746 |
- ) |
|
2747 |
- monkeypatch.setattr( |
|
2748 |
- ssh_agent.SSHAgentClient, |
|
2749 |
- "list_keys", |
|
2750 |
- callables.list_keys, |
|
1376 |
+ vault_config={"services": {}}, |
|
2751 | 1377 |
) |
2752 |
- monkeypatch.setattr( |
|
2753 |
- ssh_agent.SSHAgentClient, "sign", callables.sign |
|
2754 | 1378 |
) |
2755 | 1379 |
result = runner.invoke( |
2756 | 1380 |
cli.derivepassphrase_vault, |
2757 |
- command_line, |
|
2758 |
- input=DUMMY_PASSPHRASE, |
|
1381 |
+ ["--import", "-"], |
|
1382 |
+ input=json.dumps(config), |
|
2759 | 1383 |
catch_exceptions=False, |
2760 | 1384 |
) |
2761 |
- assert result.clean_exit(), "expected clean exit" |
|
2762 |
- assert not result.stdout.strip(), "expected no program output" |
|
2763 |
- assert result.stderr, "expected known error output" |
|
2764 |
- err_lines = result.stderr.splitlines(False) |
|
2765 |
- assert err_lines[0].startswith("Passphrase:") |
|
2766 |
- assert machinery.warning_emitted( |
|
2767 |
- "Setting a service passphrase is ineffective ", |
|
2768 |
- caplog.record_tuples, |
|
2769 |
- ) or machinery.warning_emitted( |
|
2770 |
- "Setting a global passphrase is ineffective ", |
|
2771 |
- caplog.record_tuples, |
|
2772 |
- ), "expected known warning message" |
|
2773 |
- assert all(map(is_warning_line, result.stderr.splitlines(True))) |
|
2774 |
- assert all( |
|
1385 |
+ config_txt = cli_helpers.config_filename( |
|
1386 |
+ subsystem="vault" |
|
1387 |
+ ).read_text(encoding="UTF-8") |
|
1388 |
+ config3 = json.loads(config_txt) |
|
1389 |
+ assert result.clean_exit(empty_stderr=False), "expected clean exit" |
|
1390 |
+ assert config3 == config2, "config not imported correctly" |
|
1391 |
+ assert not result.stderr or all( |
|
2775 | 1392 |
map(is_harmless_config_import_warning, caplog.record_tuples) |
2776 | 1393 |
), "unexpected error output" |
1394 |
+ assert_vault_config_is_indented_and_line_broken(config_txt) |
|
2777 | 1395 |
|
2778 |
- @hypothesis.given( |
|
2779 |
- notes=strategies.text( |
|
2780 |
- strategies.characters( |
|
2781 |
- min_codepoint=32, |
|
2782 |
- max_codepoint=126, |
|
2783 |
- include_characters="\n", |
|
2784 |
- ), |
|
2785 |
- max_size=256, |
|
2786 |
- ), |
|
2787 |
- ) |
|
2788 |
- def test_207_service_with_notes_actually_prints_notes( |
|
1396 |
+ def test_213b_import_bad_config_not_vault_config( |
|
2789 | 1397 |
self, |
2790 |
- notes: str, |
|
2791 | 1398 |
) -> None: |
2792 |
- """Service notes are printed, if they exist.""" |
|
2793 |
- hypothesis.assume("Error:" not in notes) |
|
1399 |
+ """Importing an invalid config fails.""" |
|
2794 | 1400 |
runner = machinery.CliRunner(mix_stderr=False) |
2795 | 1401 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2796 | 1402 |
# with-statements. |
... | ... |
@@ -2798,45 +1404,25 @@ class TestCLI: |
2798 | 1404 |
with contextlib.ExitStack() as stack: |
2799 | 1405 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
2800 | 1406 |
stack.enter_context( |
2801 |
- pytest_machinery.isolated_vault_config( |
|
1407 |
+ pytest_machinery.isolated_config( |
|
2802 | 1408 |
monkeypatch=monkeypatch, |
2803 | 1409 |
runner=runner, |
2804 |
- vault_config={ |
|
2805 |
- "global": { |
|
2806 |
- "phrase": DUMMY_PASSPHRASE, |
|
2807 |
- }, |
|
2808 |
- "services": { |
|
2809 |
- DUMMY_SERVICE: { |
|
2810 |
- "notes": notes, |
|
2811 |
- **DUMMY_CONFIG_SETTINGS, |
|
2812 |
- }, |
|
2813 |
- }, |
|
2814 |
- }, |
|
2815 | 1410 |
) |
2816 | 1411 |
) |
2817 | 1412 |
result = runner.invoke( |
2818 | 1413 |
cli.derivepassphrase_vault, |
2819 |
- ["--", DUMMY_SERVICE], |
|
2820 |
- ) |
|
2821 |
- assert result.clean_exit(), "expected clean exit" |
|
2822 |
- assert result.stdout, "expected program output" |
|
2823 |
- assert result.stdout.strip() == DUMMY_RESULT_PASSPHRASE.decode( |
|
2824 |
- "ascii" |
|
2825 |
- ), "expected known program output" |
|
2826 |
- assert result.stderr or not notes.strip(), "expected stderr" |
|
2827 |
- assert "Error:" not in result.stderr, ( |
|
2828 |
- "expected no error messages on stderr" |
|
1414 |
+ ["--import", "-"], |
|
1415 |
+ input="null", |
|
1416 |
+ catch_exceptions=False, |
|
2829 | 1417 |
) |
2830 |
- assert result.stderr.strip() == notes.strip(), ( |
|
2831 |
- "expected known stderr contents" |
|
1418 |
+ assert result.error_exit(error="Invalid vault config"), ( |
|
1419 |
+ "expected error exit and known error message" |
|
2832 | 1420 |
) |
2833 | 1421 |
|
2834 |
- @Parametrize.VAULT_CHARSET_OPTION |
|
2835 |
- def test_210_invalid_argument_range( |
|
1422 |
+ def test_213c_import_bad_config_not_json_data( |
|
2836 | 1423 |
self, |
2837 |
- option: str, |
|
2838 | 1424 |
) -> None: |
2839 |
- """Requesting invalidly many characters from a class fails.""" |
|
1425 |
+ """Importing an invalid config fails.""" |
|
2840 | 1426 |
runner = machinery.CliRunner(mix_stderr=False) |
2841 | 1427 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2842 | 1428 |
# with-statements. |
... | ... |
@@ -2849,27 +1435,26 @@ class TestCLI: |
2849 | 1435 |
runner=runner, |
2850 | 1436 |
) |
2851 | 1437 |
) |
2852 |
- for value in "-42", "invalid": |
|
2853 | 1438 |
result = runner.invoke( |
2854 | 1439 |
cli.derivepassphrase_vault, |
2855 |
- [option, value, "-p", "--", DUMMY_SERVICE], |
|
2856 |
- input=DUMMY_PASSPHRASE, |
|
1440 |
+ ["--import", "-"], |
|
1441 |
+ input="This string is not valid JSON.", |
|
2857 | 1442 |
catch_exceptions=False, |
2858 | 1443 |
) |
2859 |
- assert result.error_exit(error="Invalid value"), ( |
|
1444 |
+ assert result.error_exit(error="cannot decode JSON"), ( |
|
2860 | 1445 |
"expected error exit and known error message" |
2861 | 1446 |
) |
2862 | 1447 |
|
2863 |
- @Parametrize.OPTION_COMBINATIONS_SERVICE_NEEDED |
|
2864 |
- def test_211_service_needed( |
|
1448 |
+ def test_213d_import_bad_config_not_a_file( |
|
2865 | 1449 |
self, |
2866 |
- options: list[str], |
|
2867 |
- service: bool | None, |
|
2868 |
- input: str | None, |
|
2869 |
- check_success: bool, |
|
2870 | 1450 |
) -> None: |
2871 |
- """We require or forbid a service argument, depending on options.""" |
|
1451 |
+ """Importing an invalid config fails.""" |
|
2872 | 1452 |
runner = machinery.CliRunner(mix_stderr=False) |
1453 |
+ # `isolated_vault_config` ensures the configuration is valid |
|
1454 |
+ # JSON. So, to pass an actual broken configuration, we must |
|
1455 |
+ # open the configuration file ourselves afterwards, inside the |
|
1456 |
+ # context. |
|
1457 |
+ # |
|
2873 | 1458 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2874 | 1459 |
# with-statements. |
2875 | 1460 |
# https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
... | ... |
@@ -2879,34 +1464,33 @@ class TestCLI: |
2879 | 1464 |
pytest_machinery.isolated_vault_config( |
2880 | 1465 |
monkeypatch=monkeypatch, |
2881 | 1466 |
runner=runner, |
2882 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
1467 |
+ vault_config={"services": {}}, |
|
2883 | 1468 |
) |
2884 | 1469 |
) |
2885 |
- monkeypatch.setattr( |
|
2886 |
- cli_helpers, |
|
2887 |
- "prompt_for_passphrase", |
|
2888 |
- callables.auto_prompt, |
|
1470 |
+ cli_helpers.config_filename(subsystem="vault").write_text( |
|
1471 |
+ "This string is not valid JSON.\n", encoding="UTF-8" |
|
2889 | 1472 |
) |
1473 |
+ dname = cli_helpers.config_filename(subsystem=None) |
|
2890 | 1474 |
result = runner.invoke( |
2891 | 1475 |
cli.derivepassphrase_vault, |
2892 |
- options if service else [*options, "--", DUMMY_SERVICE], |
|
2893 |
- input=input, |
|
1476 |
+ ["--import", os.fsdecode(dname)], |
|
2894 | 1477 |
catch_exceptions=False, |
2895 | 1478 |
) |
2896 |
- if service is not None: |
|
2897 |
- err_msg = ( |
|
2898 |
- " requires a SERVICE" |
|
2899 |
- if service |
|
2900 |
- else " does not take a SERVICE argument" |
|
2901 |
- ) |
|
2902 |
- assert result.error_exit(error=err_msg), ( |
|
1479 |
+ # The Annoying OS uses EACCES, other OSes use EISDIR. |
|
1480 |
+ assert result.error_exit( |
|
1481 |
+ error=os.strerror(errno.EISDIR) |
|
1482 |
+ ) or result.error_exit(error=os.strerror(errno.EACCES)), ( |
|
2903 | 1483 |
"expected error exit and known error message" |
2904 | 1484 |
) |
2905 |
- else: |
|
2906 |
- assert result.clean_exit(empty_stderr=True), ( |
|
2907 |
- "expected clean exit" |
|
2908 |
- ) |
|
2909 |
- if check_success: |
|
1485 |
+ |
|
1486 |
+ @Parametrize.VALID_TEST_CONFIGS |
|
1487 |
+ def test_214_export_config_success( |
|
1488 |
+ self, |
|
1489 |
+ caplog: pytest.LogCaptureFixture, |
|
1490 |
+ config: Any, |
|
1491 |
+ ) -> None: |
|
1492 |
+ """Exporting a configuration works.""" |
|
1493 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2910 | 1494 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2911 | 1495 |
# with-statements. |
2912 | 1496 |
# https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
... | ... |
@@ -2916,42 +1500,36 @@ class TestCLI: |
2916 | 1500 |
pytest_machinery.isolated_vault_config( |
2917 | 1501 |
monkeypatch=monkeypatch, |
2918 | 1502 |
runner=runner, |
2919 |
- vault_config={ |
|
2920 |
- "global": {"phrase": "abc"}, |
|
2921 |
- "services": {}, |
|
2922 |
- }, |
|
1503 |
+ vault_config=config, |
|
2923 | 1504 |
) |
2924 | 1505 |
) |
2925 |
- monkeypatch.setattr( |
|
2926 |
- cli_helpers, |
|
2927 |
- "prompt_for_passphrase", |
|
2928 |
- callables.auto_prompt, |
|
2929 |
- ) |
|
1506 |
+ with cli_helpers.config_filename(subsystem="vault").open( |
|
1507 |
+ "w", encoding="UTF-8" |
|
1508 |
+ ) as outfile: |
|
1509 |
+ # Ensure the config is written on one line. |
|
1510 |
+ json.dump(config, outfile, indent=None) |
|
2930 | 1511 |
result = runner.invoke( |
2931 | 1512 |
cli.derivepassphrase_vault, |
2932 |
- [*options, "--", DUMMY_SERVICE] if service else options, |
|
2933 |
- input=input, |
|
1513 |
+ ["--export", "-"], |
|
2934 | 1514 |
catch_exceptions=False, |
2935 | 1515 |
) |
2936 |
- assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
1516 |
+ with cli_helpers.config_filename(subsystem="vault").open( |
|
1517 |
+ encoding="UTF-8" |
|
1518 |
+ ) as infile: |
|
1519 |
+ config2 = json.load(infile) |
|
1520 |
+ assert result.clean_exit(empty_stderr=False), "expected clean exit" |
|
1521 |
+ assert config2 == config, "config not imported correctly" |
|
1522 |
+ assert not result.stderr or all( # pragma: no branch |
|
1523 |
+ map(is_harmless_config_import_warning, caplog.record_tuples) |
|
1524 |
+ ), "unexpected error output" |
|
1525 |
+ assert_vault_config_is_indented_and_line_broken(result.stdout) |
|
2937 | 1526 |
|
2938 |
- def test_211a_empty_service_name_causes_warning( |
|
1527 |
+ @Parametrize.EXPORT_FORMAT_OPTIONS |
|
1528 |
+ def test_214a_export_settings_no_stored_settings( |
|
2939 | 1529 |
self, |
2940 |
- caplog: pytest.LogCaptureFixture, |
|
1530 |
+ export_options: list[str], |
|
2941 | 1531 |
) -> None: |
2942 |
- """Using an empty service name (where permissible) warns. |
|
2943 |
- |
|
2944 |
- Only the `--config` option can optionally take a service name. |
|
2945 |
- |
|
2946 |
- """ |
|
2947 |
- |
|
2948 |
- def is_expected_warning(record: tuple[str, int, str]) -> bool: |
|
2949 |
- return is_harmless_config_import_warning( |
|
2950 |
- record |
|
2951 |
- ) or machinery.warning_emitted( |
|
2952 |
- "An empty SERVICE is not supported by vault(1)", [record] |
|
2953 |
- ) |
|
2954 |
- |
|
1532 |
+ """Exporting the default, empty config works.""" |
|
2955 | 1533 |
runner = machinery.CliRunner(mix_stderr=False) |
2956 | 1534 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2957 | 1535 |
# with-statements. |
... | ... |
@@ -2959,55 +1537,31 @@ class TestCLI: |
2959 | 1537 |
with contextlib.ExitStack() as stack: |
2960 | 1538 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
2961 | 1539 |
stack.enter_context( |
2962 |
- pytest_machinery.isolated_vault_config( |
|
1540 |
+ pytest_machinery.isolated_config( |
|
2963 | 1541 |
monkeypatch=monkeypatch, |
2964 | 1542 |
runner=runner, |
2965 |
- vault_config={"services": {}}, |
|
2966 |
- ) |
|
2967 |
- ) |
|
2968 |
- monkeypatch.setattr( |
|
2969 |
- cli_helpers, |
|
2970 |
- "prompt_for_passphrase", |
|
2971 |
- callables.auto_prompt, |
|
2972 | 1543 |
) |
2973 |
- result = runner.invoke( |
|
2974 |
- cli.derivepassphrase_vault, |
|
2975 |
- ["--config", "--length=30", "--", ""], |
|
2976 |
- catch_exceptions=False, |
|
2977 | 1544 |
) |
2978 |
- assert result.clean_exit(empty_stderr=False), "expected clean exit" |
|
2979 |
- assert result.stderr is not None, "expected known error output" |
|
2980 |
- assert all(map(is_expected_warning, caplog.record_tuples)), ( |
|
2981 |
- "expected known error output" |
|
1545 |
+ cli_helpers.config_filename(subsystem="vault").unlink( |
|
1546 |
+ missing_ok=True |
|
2982 | 1547 |
) |
2983 |
- assert cli_helpers.load_config() == { |
|
2984 |
- "global": {"length": 30}, |
|
2985 |
- "services": {}, |
|
2986 |
- }, "requested configuration change was not applied" |
|
2987 |
- caplog.clear() |
|
2988 | 1548 |
result = runner.invoke( |
2989 |
- cli.derivepassphrase_vault, |
|
2990 |
- ["--import", "-"], |
|
2991 |
- input=json.dumps({"services": {"": {"length": 40}}}), |
|
1549 |
+ # Test parent context navigation by not calling |
|
1550 |
+ # `cli.derivepassphrase_vault` directly. Used e.g. in |
|
1551 |
+ # the `--export-as=sh` section to autoconstruct the |
|
1552 |
+ # program name correctly. |
|
1553 |
+ cli.derivepassphrase, |
|
1554 |
+ ["vault", "--export", "-", *export_options], |
|
2992 | 1555 |
catch_exceptions=False, |
2993 | 1556 |
) |
2994 |
- assert result.clean_exit(empty_stderr=False), "expected clean exit" |
|
2995 |
- assert result.stderr is not None, "expected known error output" |
|
2996 |
- assert all(map(is_expected_warning, caplog.record_tuples)), ( |
|
2997 |
- "expected known error output" |
|
2998 |
- ) |
|
2999 |
- assert cli_helpers.load_config() == { |
|
3000 |
- "global": {"length": 30}, |
|
3001 |
- "services": {"": {"length": 40}}, |
|
3002 |
- }, "requested configuration change was not applied" |
|
1557 |
+ assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
3003 | 1558 |
|
3004 |
- @Parametrize.OPTION_COMBINATIONS_INCOMPATIBLE |
|
3005 |
- def test_212_incompatible_options( |
|
1559 |
+ @Parametrize.EXPORT_FORMAT_OPTIONS |
|
1560 |
+ def test_214b_export_settings_bad_stored_config( |
|
3006 | 1561 |
self, |
3007 |
- options: list[str], |
|
3008 |
- service: bool | None, |
|
1562 |
+ export_options: list[str], |
|
3009 | 1563 |
) -> None: |
3010 |
- """Incompatible options are detected.""" |
|
1564 |
+ """Exporting an invalid config fails.""" |
|
3011 | 1565 |
runner = machinery.CliRunner(mix_stderr=False) |
3012 | 1566 |
# TODO(the-13th-letter): Rewrite using parenthesized |
3013 | 1567 |
# with-statements. |
... | ... |
@@ -3015,28 +1569,28 @@ class TestCLI: |
3015 | 1569 |
with contextlib.ExitStack() as stack: |
3016 | 1570 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
3017 | 1571 |
stack.enter_context( |
3018 |
- pytest_machinery.isolated_config( |
|
1572 |
+ pytest_machinery.isolated_vault_config( |
|
3019 | 1573 |
monkeypatch=monkeypatch, |
3020 | 1574 |
runner=runner, |
1575 |
+ vault_config={}, |
|
3021 | 1576 |
) |
3022 | 1577 |
) |
3023 | 1578 |
result = runner.invoke( |
3024 | 1579 |
cli.derivepassphrase_vault, |
3025 |
- [*options, "--", DUMMY_SERVICE] if service else options, |
|
3026 |
- input=DUMMY_PASSPHRASE, |
|
1580 |
+ ["--export", "-", *export_options], |
|
1581 |
+ input="null", |
|
3027 | 1582 |
catch_exceptions=False, |
3028 | 1583 |
) |
3029 |
- assert result.error_exit(error="mutually exclusive with "), ( |
|
1584 |
+ assert result.error_exit(error="Cannot load vault settings:"), ( |
|
3030 | 1585 |
"expected error exit and known error message" |
3031 | 1586 |
) |
3032 | 1587 |
|
3033 |
- @Parametrize.VALID_TEST_CONFIGS |
|
3034 |
- def test_213_import_config_success( |
|
1588 |
+ @Parametrize.EXPORT_FORMAT_OPTIONS |
|
1589 |
+ def test_214c_export_settings_not_a_file( |
|
3035 | 1590 |
self, |
3036 |
- caplog: pytest.LogCaptureFixture, |
|
3037 |
- config: Any, |
|
1591 |
+ export_options: list[str], |
|
3038 | 1592 |
) -> None: |
3039 |
- """Importing a configuration works.""" |
|
1593 |
+ """Exporting an invalid config fails.""" |
|
3040 | 1594 |
runner = machinery.CliRunner(mix_stderr=False) |
3041 | 1595 |
# TODO(the-13th-letter): Rewrite using parenthesized |
3042 | 1596 |
# with-statements. |
... | ... |
@@ -3044,57 +1598,30 @@ class TestCLI: |
3044 | 1598 |
with contextlib.ExitStack() as stack: |
3045 | 1599 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
3046 | 1600 |
stack.enter_context( |
3047 |
- pytest_machinery.isolated_vault_config( |
|
1601 |
+ pytest_machinery.isolated_config( |
|
3048 | 1602 |
monkeypatch=monkeypatch, |
3049 | 1603 |
runner=runner, |
3050 |
- vault_config={"services": {}}, |
|
3051 | 1604 |
) |
3052 | 1605 |
) |
1606 |
+ config_file = cli_helpers.config_filename(subsystem="vault") |
|
1607 |
+ config_file.unlink(missing_ok=True) |
|
1608 |
+ config_file.mkdir(parents=True, exist_ok=True) |
|
3053 | 1609 |
result = runner.invoke( |
3054 | 1610 |
cli.derivepassphrase_vault, |
3055 |
- ["--import", "-"], |
|
3056 |
- input=json.dumps(config), |
|
1611 |
+ ["--export", "-", *export_options], |
|
1612 |
+ input="null", |
|
3057 | 1613 |
catch_exceptions=False, |
3058 | 1614 |
) |
3059 |
- config_txt = cli_helpers.config_filename( |
|
3060 |
- subsystem="vault" |
|
3061 |
- ).read_text(encoding="UTF-8") |
|
3062 |
- config2 = json.loads(config_txt) |
|
3063 |
- assert result.clean_exit(empty_stderr=False), "expected clean exit" |
|
3064 |
- assert config2 == config, "config not imported correctly" |
|
3065 |
- assert not result.stderr or all( # pragma: no branch |
|
3066 |
- map(is_harmless_config_import_warning, caplog.record_tuples) |
|
3067 |
- ), "unexpected error output" |
|
3068 |
- assert_vault_config_is_indented_and_line_broken(config_txt) |
|
3069 |
- |
|
3070 |
- @hypothesis.settings( |
|
3071 |
- suppress_health_check=[ |
|
3072 |
- *hypothesis.settings().suppress_health_check, |
|
3073 |
- hypothesis.HealthCheck.function_scoped_fixture, |
|
3074 |
- ], |
|
3075 |
- ) |
|
3076 |
- @hypothesis.given( |
|
3077 |
- conf=hypothesis_machinery.smudged_vault_test_config( |
|
3078 |
- strategies.sampled_from([ |
|
3079 |
- conf for conf in data.TEST_CONFIGS if conf.is_valid() |
|
3080 |
- ]) |
|
3081 |
- ) |
|
1615 |
+ assert result.error_exit(error="Cannot load vault settings:"), ( |
|
1616 |
+ "expected error exit and known error message" |
|
3082 | 1617 |
) |
3083 |
- def test_213a_import_config_success( |
|
1618 |
+ |
|
1619 |
+ @Parametrize.EXPORT_FORMAT_OPTIONS |
|
1620 |
+ def test_214d_export_settings_target_not_a_file( |
|
3084 | 1621 |
self, |
3085 |
- caplog: pytest.LogCaptureFixture, |
|
3086 |
- conf: data.VaultTestConfig, |
|
1622 |
+ export_options: list[str], |
|
3087 | 1623 |
) -> None: |
3088 |
- """Importing a smudged configuration works. |
|
3089 |
- |
|
3090 |
- Tested via hypothesis. |
|
3091 |
- |
|
3092 |
- """ |
|
3093 |
- config = conf.config |
|
3094 |
- config2 = copy.deepcopy(config) |
|
3095 |
- _types.clean_up_falsy_vault_config_values(config2) |
|
3096 |
- # Reset caplog between hypothesis runs. |
|
3097 |
- caplog.clear() |
|
1624 |
+ """Exporting an invalid config fails.""" |
|
3098 | 1625 |
runner = machinery.CliRunner(mix_stderr=False) |
3099 | 1626 |
# TODO(the-13th-letter): Rewrite using parenthesized |
3100 | 1627 |
# with-statements. |
... | ... |
@@ -3102,33 +1629,29 @@ class TestCLI: |
3102 | 1629 |
with contextlib.ExitStack() as stack: |
3103 | 1630 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
3104 | 1631 |
stack.enter_context( |
3105 |
- pytest_machinery.isolated_vault_config( |
|
1632 |
+ pytest_machinery.isolated_config( |
|
3106 | 1633 |
monkeypatch=monkeypatch, |
3107 | 1634 |
runner=runner, |
3108 |
- vault_config={"services": {}}, |
|
3109 | 1635 |
) |
3110 | 1636 |
) |
1637 |
+ dname = cli_helpers.config_filename(subsystem=None) |
|
3111 | 1638 |
result = runner.invoke( |
3112 | 1639 |
cli.derivepassphrase_vault, |
3113 |
- ["--import", "-"], |
|
3114 |
- input=json.dumps(config), |
|
1640 |
+ ["--export", os.fsdecode(dname), *export_options], |
|
1641 |
+ input="null", |
|
3115 | 1642 |
catch_exceptions=False, |
3116 | 1643 |
) |
3117 |
- config_txt = cli_helpers.config_filename( |
|
3118 |
- subsystem="vault" |
|
3119 |
- ).read_text(encoding="UTF-8") |
|
3120 |
- config3 = json.loads(config_txt) |
|
3121 |
- assert result.clean_exit(empty_stderr=False), "expected clean exit" |
|
3122 |
- assert config3 == config2, "config not imported correctly" |
|
3123 |
- assert not result.stderr or all( |
|
3124 |
- map(is_harmless_config_import_warning, caplog.record_tuples) |
|
3125 |
- ), "unexpected error output" |
|
3126 |
- assert_vault_config_is_indented_and_line_broken(config_txt) |
|
1644 |
+ assert result.error_exit(error="Cannot export vault settings:"), ( |
|
1645 |
+ "expected error exit and known error message" |
|
1646 |
+ ) |
|
3127 | 1647 |
|
3128 |
- def test_213b_import_bad_config_not_vault_config( |
|
1648 |
+ @pytest_machinery.skip_if_on_the_annoying_os |
|
1649 |
+ @Parametrize.EXPORT_FORMAT_OPTIONS |
|
1650 |
+ def test_214e_export_settings_settings_directory_not_a_directory( |
|
3129 | 1651 |
self, |
1652 |
+ export_options: list[str], |
|
3130 | 1653 |
) -> None: |
3131 |
- """Importing an invalid config fails.""" |
|
1654 |
+ """Exporting an invalid config fails.""" |
|
3132 | 1655 |
runner = machinery.CliRunner(mix_stderr=False) |
3133 | 1656 |
# TODO(the-13th-letter): Rewrite using parenthesized |
3134 | 1657 |
# with-statements. |
... | ... |
@@ -3141,1240 +1664,105 @@ class TestCLI: |
3141 | 1664 |
runner=runner, |
3142 | 1665 |
) |
3143 | 1666 |
) |
1667 |
+ config_dir = cli_helpers.config_filename(subsystem=None) |
|
1668 |
+ with contextlib.suppress(FileNotFoundError): |
|
1669 |
+ shutil.rmtree(config_dir) |
|
1670 |
+ config_dir.write_text("Obstruction!!\n") |
|
3144 | 1671 |
result = runner.invoke( |
3145 | 1672 |
cli.derivepassphrase_vault, |
3146 |
- ["--import", "-"], |
|
1673 |
+ ["--export", "-", *export_options], |
|
3147 | 1674 |
input="null", |
3148 | 1675 |
catch_exceptions=False, |
3149 | 1676 |
) |
3150 |
- assert result.error_exit(error="Invalid vault config"), ( |
|
1677 |
+ assert result.error_exit( |
|
1678 |
+ error="Cannot load vault settings:" |
|
1679 |
+ ) or result.error_exit(error="Cannot load user config:"), ( |
|
3151 | 1680 |
"expected error exit and known error message" |
3152 | 1681 |
) |
3153 | 1682 |
|
3154 |
- def test_213c_import_bad_config_not_json_data( |
|
1683 |
+ @Parametrize.NOTES_PLACEMENT |
|
1684 |
+ @hypothesis.given( |
|
1685 |
+ notes=strategies.text( |
|
1686 |
+ strategies.characters( |
|
1687 |
+ min_codepoint=32, max_codepoint=126, include_characters="\n" |
|
1688 |
+ ), |
|
1689 |
+ min_size=1, |
|
1690 |
+ max_size=512, |
|
1691 |
+ ).filter(str.strip), |
|
1692 |
+ ) |
|
1693 |
+ def test_215_notes_placement( |
|
3155 | 1694 |
self, |
1695 |
+ notes_placement: Literal["before", "after"], |
|
1696 |
+ placement_args: list[str], |
|
1697 |
+ notes: str, |
|
3156 | 1698 |
) -> None: |
3157 |
- """Importing an invalid config fails.""" |
|
3158 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1699 |
+ notes = notes.strip() |
|
1700 |
+ maybe_notes = {"notes": notes} if notes else {} |
|
1701 |
+ vault_config = { |
|
1702 |
+ "global": {"phrase": DUMMY_PASSPHRASE}, |
|
1703 |
+ "services": { |
|
1704 |
+ DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS} |
|
1705 |
+ }, |
|
1706 |
+ } |
|
1707 |
+ result_phrase = DUMMY_RESULT_PASSPHRASE.decode("ascii") |
|
1708 |
+ expected = ( |
|
1709 |
+ f"{notes}\n\n{result_phrase}\n" |
|
1710 |
+ if notes_placement == "before" |
|
1711 |
+ else f"{result_phrase}\n\n{notes}\n\n" |
|
1712 |
+ ) |
|
1713 |
+ runner = machinery.CliRunner(mix_stderr=True) |
|
3159 | 1714 |
# TODO(the-13th-letter): Rewrite using parenthesized |
3160 | 1715 |
# with-statements. |
3161 | 1716 |
# https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
3162 | 1717 |
with contextlib.ExitStack() as stack: |
3163 | 1718 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
3164 | 1719 |
stack.enter_context( |
3165 |
- pytest_machinery.isolated_config( |
|
1720 |
+ pytest_machinery.isolated_vault_config( |
|
3166 | 1721 |
monkeypatch=monkeypatch, |
3167 | 1722 |
runner=runner, |
1723 |
+ vault_config=vault_config, |
|
3168 | 1724 |
) |
3169 | 1725 |
) |
3170 | 1726 |
result = runner.invoke( |
3171 | 1727 |
cli.derivepassphrase_vault, |
3172 |
- ["--import", "-"], |
|
3173 |
- input="This string is not valid JSON.", |
|
1728 |
+ [*placement_args, "--", DUMMY_SERVICE], |
|
3174 | 1729 |
catch_exceptions=False, |
3175 | 1730 |
) |
3176 |
- assert result.error_exit(error="cannot decode JSON"), ( |
|
3177 |
- "expected error exit and known error message" |
|
3178 |
- ) |
|
1731 |
+ assert result.clean_exit(output=expected), "expected clean exit" |
|
3179 | 1732 |
|
3180 |
- def test_213d_import_bad_config_not_a_file( |
|
3181 |
- self, |
|
3182 |
- ) -> None: |
|
3183 |
- """Importing an invalid config fails.""" |
|
3184 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
3185 |
- # `isolated_vault_config` ensures the configuration is valid |
|
3186 |
- # JSON. So, to pass an actual broken configuration, we must |
|
3187 |
- # open the configuration file ourselves afterwards, inside the |
|
3188 |
- # context. |
|
3189 |
- # |
|
3190 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
3191 |
- # with-statements. |
|
3192 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
3193 |
- with contextlib.ExitStack() as stack: |
|
3194 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3195 |
- stack.enter_context( |
|
3196 |
- pytest_machinery.isolated_vault_config( |
|
3197 |
- monkeypatch=monkeypatch, |
|
3198 |
- runner=runner, |
|
3199 |
- vault_config={"services": {}}, |
|
3200 |
- ) |
|
3201 |
- ) |
|
3202 |
- cli_helpers.config_filename(subsystem="vault").write_text( |
|
3203 |
- "This string is not valid JSON.\n", encoding="UTF-8" |
|
3204 |
- ) |
|
3205 |
- dname = cli_helpers.config_filename(subsystem=None) |
|
3206 |
- result = runner.invoke( |
|
3207 |
- cli.derivepassphrase_vault, |
|
3208 |
- ["--import", os.fsdecode(dname)], |
|
3209 |
- catch_exceptions=False, |
|
3210 |
- ) |
|
3211 |
- # The Annoying OS uses EACCES, other OSes use EISDIR. |
|
3212 |
- assert result.error_exit( |
|
3213 |
- error=os.strerror(errno.EISDIR) |
|
3214 |
- ) or result.error_exit(error=os.strerror(errno.EACCES)), ( |
|
3215 |
- "expected error exit and known error message" |
|
3216 |
- ) |
|
3217 |
- |
|
3218 |
- @Parametrize.VALID_TEST_CONFIGS |
|
3219 |
- def test_214_export_config_success( |
|
3220 |
- self, |
|
3221 |
- caplog: pytest.LogCaptureFixture, |
|
3222 |
- config: Any, |
|
3223 |
- ) -> None: |
|
3224 |
- """Exporting a configuration works.""" |
|
3225 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
3226 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
3227 |
- # with-statements. |
|
3228 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
3229 |
- with contextlib.ExitStack() as stack: |
|
3230 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3231 |
- stack.enter_context( |
|
3232 |
- pytest_machinery.isolated_vault_config( |
|
3233 |
- monkeypatch=monkeypatch, |
|
3234 |
- runner=runner, |
|
3235 |
- vault_config=config, |
|
3236 |
- ) |
|
3237 |
- ) |
|
3238 |
- with cli_helpers.config_filename(subsystem="vault").open( |
|
3239 |
- "w", encoding="UTF-8" |
|
3240 |
- ) as outfile: |
|
3241 |
- # Ensure the config is written on one line. |
|
3242 |
- json.dump(config, outfile, indent=None) |
|
3243 |
- result = runner.invoke( |
|
3244 |
- cli.derivepassphrase_vault, |
|
3245 |
- ["--export", "-"], |
|
3246 |
- catch_exceptions=False, |
|
3247 |
- ) |
|
3248 |
- with cli_helpers.config_filename(subsystem="vault").open( |
|
3249 |
- encoding="UTF-8" |
|
3250 |
- ) as infile: |
|
3251 |
- config2 = json.load(infile) |
|
3252 |
- assert result.clean_exit(empty_stderr=False), "expected clean exit" |
|
3253 |
- assert config2 == config, "config not imported correctly" |
|
3254 |
- assert not result.stderr or all( # pragma: no branch |
|
3255 |
- map(is_harmless_config_import_warning, caplog.record_tuples) |
|
3256 |
- ), "unexpected error output" |
|
3257 |
- assert_vault_config_is_indented_and_line_broken(result.stdout) |
|
3258 |
- |
|
3259 |
- @Parametrize.EXPORT_FORMAT_OPTIONS |
|
3260 |
- def test_214a_export_settings_no_stored_settings( |
|
3261 |
- self, |
|
3262 |
- export_options: list[str], |
|
3263 |
- ) -> None: |
|
3264 |
- """Exporting the default, empty config works.""" |
|
3265 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
3266 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
3267 |
- # with-statements. |
|
3268 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
3269 |
- with contextlib.ExitStack() as stack: |
|
3270 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3271 |
- stack.enter_context( |
|
3272 |
- pytest_machinery.isolated_config( |
|
3273 |
- monkeypatch=monkeypatch, |
|
3274 |
- runner=runner, |
|
3275 |
- ) |
|
3276 |
- ) |
|
3277 |
- cli_helpers.config_filename(subsystem="vault").unlink( |
|
3278 |
- missing_ok=True |
|
3279 |
- ) |
|
3280 |
- result = runner.invoke( |
|
3281 |
- # Test parent context navigation by not calling |
|
3282 |
- # `cli.derivepassphrase_vault` directly. Used e.g. in |
|
3283 |
- # the `--export-as=sh` section to autoconstruct the |
|
3284 |
- # program name correctly. |
|
3285 |
- cli.derivepassphrase, |
|
3286 |
- ["vault", "--export", "-", *export_options], |
|
3287 |
- catch_exceptions=False, |
|
3288 |
- ) |
|
3289 |
- assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
3290 |
- |
|
3291 |
- @Parametrize.EXPORT_FORMAT_OPTIONS |
|
3292 |
- def test_214b_export_settings_bad_stored_config( |
|
3293 |
- self, |
|
3294 |
- export_options: list[str], |
|
3295 |
- ) -> None: |
|
3296 |
- """Exporting an invalid config fails.""" |
|
3297 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
3298 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
3299 |
- # with-statements. |
|
3300 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
3301 |
- with contextlib.ExitStack() as stack: |
|
3302 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3303 |
- stack.enter_context( |
|
3304 |
- pytest_machinery.isolated_vault_config( |
|
3305 |
- monkeypatch=monkeypatch, |
|
3306 |
- runner=runner, |
|
3307 |
- vault_config={}, |
|
3308 |
- ) |
|
3309 |
- ) |
|
3310 |
- result = runner.invoke( |
|
3311 |
- cli.derivepassphrase_vault, |
|
3312 |
- ["--export", "-", *export_options], |
|
3313 |
- input="null", |
|
3314 |
- catch_exceptions=False, |
|
3315 |
- ) |
|
3316 |
- assert result.error_exit(error="Cannot load vault settings:"), ( |
|
3317 |
- "expected error exit and known error message" |
|
3318 |
- ) |
|
3319 |
- |
|
3320 |
- @Parametrize.EXPORT_FORMAT_OPTIONS |
|
3321 |
- def test_214c_export_settings_not_a_file( |
|
3322 |
- self, |
|
3323 |
- export_options: list[str], |
|
3324 |
- ) -> None: |
|
3325 |
- """Exporting an invalid config fails.""" |
|
3326 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
3327 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
3328 |
- # with-statements. |
|
3329 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
3330 |
- with contextlib.ExitStack() as stack: |
|
3331 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3332 |
- stack.enter_context( |
|
3333 |
- pytest_machinery.isolated_config( |
|
3334 |
- monkeypatch=monkeypatch, |
|
3335 |
- runner=runner, |
|
3336 |
- ) |
|
3337 |
- ) |
|
3338 |
- config_file = cli_helpers.config_filename(subsystem="vault") |
|
3339 |
- config_file.unlink(missing_ok=True) |
|
3340 |
- config_file.mkdir(parents=True, exist_ok=True) |
|
3341 |
- result = runner.invoke( |
|
3342 |
- cli.derivepassphrase_vault, |
|
3343 |
- ["--export", "-", *export_options], |
|
3344 |
- input="null", |
|
3345 |
- catch_exceptions=False, |
|
3346 |
- ) |
|
3347 |
- assert result.error_exit(error="Cannot load vault settings:"), ( |
|
3348 |
- "expected error exit and known error message" |
|
3349 |
- ) |
|
3350 |
- |
|
3351 |
- @Parametrize.EXPORT_FORMAT_OPTIONS |
|
3352 |
- def test_214d_export_settings_target_not_a_file( |
|
3353 |
- self, |
|
3354 |
- export_options: list[str], |
|
3355 |
- ) -> None: |
|
3356 |
- """Exporting an invalid config fails.""" |
|
3357 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
3358 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
3359 |
- # with-statements. |
|
3360 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
3361 |
- with contextlib.ExitStack() as stack: |
|
3362 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3363 |
- stack.enter_context( |
|
3364 |
- pytest_machinery.isolated_config( |
|
3365 |
- monkeypatch=monkeypatch, |
|
3366 |
- runner=runner, |
|
3367 |
- ) |
|
3368 |
- ) |
|
3369 |
- dname = cli_helpers.config_filename(subsystem=None) |
|
3370 |
- result = runner.invoke( |
|
3371 |
- cli.derivepassphrase_vault, |
|
3372 |
- ["--export", os.fsdecode(dname), *export_options], |
|
3373 |
- input="null", |
|
3374 |
- catch_exceptions=False, |
|
3375 |
- ) |
|
3376 |
- assert result.error_exit(error="Cannot export vault settings:"), ( |
|
3377 |
- "expected error exit and known error message" |
|
3378 |
- ) |
|
3379 |
- |
|
3380 |
- @pytest_machinery.skip_if_on_the_annoying_os |
|
3381 |
- @Parametrize.EXPORT_FORMAT_OPTIONS |
|
3382 |
- def test_214e_export_settings_settings_directory_not_a_directory( |
|
3383 |
- self, |
|
3384 |
- export_options: list[str], |
|
3385 |
- ) -> None: |
|
3386 |
- """Exporting an invalid config fails.""" |
|
3387 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
3388 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
3389 |
- # with-statements. |
|
3390 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
3391 |
- with contextlib.ExitStack() as stack: |
|
3392 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3393 |
- stack.enter_context( |
|
3394 |
- pytest_machinery.isolated_config( |
|
3395 |
- monkeypatch=monkeypatch, |
|
3396 |
- runner=runner, |
|
3397 |
- ) |
|
3398 |
- ) |
|
3399 |
- config_dir = cli_helpers.config_filename(subsystem=None) |
|
3400 |
- with contextlib.suppress(FileNotFoundError): |
|
3401 |
- shutil.rmtree(config_dir) |
|
3402 |
- config_dir.write_text("Obstruction!!\n") |
|
3403 |
- result = runner.invoke( |
|
3404 |
- cli.derivepassphrase_vault, |
|
3405 |
- ["--export", "-", *export_options], |
|
3406 |
- input="null", |
|
3407 |
- catch_exceptions=False, |
|
3408 |
- ) |
|
3409 |
- assert result.error_exit( |
|
3410 |
- error="Cannot load vault settings:" |
|
3411 |
- ) or result.error_exit(error="Cannot load user config:"), ( |
|
3412 |
- "expected error exit and known error message" |
|
3413 |
- ) |
|
3414 |
- |
|
3415 |
- @Parametrize.NOTES_PLACEMENT |
|
3416 |
- @hypothesis.given( |
|
3417 |
- notes=strategies.text( |
|
3418 |
- strategies.characters( |
|
3419 |
- min_codepoint=32, max_codepoint=126, include_characters="\n" |
|
3420 |
- ), |
|
3421 |
- min_size=1, |
|
3422 |
- max_size=512, |
|
3423 |
- ).filter(str.strip), |
|
3424 |
- ) |
|
3425 |
- def test_215_notes_placement( |
|
3426 |
- self, |
|
3427 |
- notes_placement: Literal["before", "after"], |
|
3428 |
- placement_args: list[str], |
|
3429 |
- notes: str, |
|
3430 |
- ) -> None: |
|
3431 |
- notes = notes.strip() |
|
3432 |
- maybe_notes = {"notes": notes} if notes else {} |
|
3433 |
- vault_config = { |
|
3434 |
- "global": {"phrase": DUMMY_PASSPHRASE}, |
|
3435 |
- "services": { |
|
3436 |
- DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS} |
|
3437 |
- }, |
|
3438 |
- } |
|
3439 |
- result_phrase = DUMMY_RESULT_PASSPHRASE.decode("ascii") |
|
3440 |
- expected = ( |
|
3441 |
- f"{notes}\n\n{result_phrase}\n" |
|
3442 |
- if notes_placement == "before" |
|
3443 |
- else f"{result_phrase}\n\n{notes}\n\n" |
|
3444 |
- ) |
|
3445 |
- runner = machinery.CliRunner(mix_stderr=True) |
|
3446 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
3447 |
- # with-statements. |
|
3448 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
3449 |
- with contextlib.ExitStack() as stack: |
|
3450 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3451 |
- stack.enter_context( |
|
3452 |
- pytest_machinery.isolated_vault_config( |
|
3453 |
- monkeypatch=monkeypatch, |
|
3454 |
- runner=runner, |
|
3455 |
- vault_config=vault_config, |
|
3456 |
- ) |
|
3457 |
- ) |
|
3458 |
- result = runner.invoke( |
|
3459 |
- cli.derivepassphrase_vault, |
|
3460 |
- [*placement_args, "--", DUMMY_SERVICE], |
|
3461 |
- catch_exceptions=False, |
|
3462 |
- ) |
|
3463 |
- assert result.clean_exit(output=expected), "expected clean exit" |
|
3464 |
- |
|
3465 |
- @Parametrize.MODERN_EDITOR_INTERFACE |
|
3466 |
- @hypothesis.settings( |
|
3467 |
- suppress_health_check=[ |
|
3468 |
- *hypothesis.settings().suppress_health_check, |
|
3469 |
- hypothesis.HealthCheck.function_scoped_fixture, |
|
3470 |
- ], |
|
3471 |
- ) |
|
3472 |
- @hypothesis.given( |
|
3473 |
- notes=strategies.text( |
|
3474 |
- strategies.characters( |
|
3475 |
- min_codepoint=32, max_codepoint=126, include_characters="\n" |
|
3476 |
- ), |
|
3477 |
- min_size=1, |
|
3478 |
- max_size=512, |
|
3479 |
- ).filter(str.strip), |
|
3480 |
- ) |
|
3481 |
- def test_220_edit_notes_successfully( |
|
3482 |
- self, |
|
3483 |
- caplog: pytest.LogCaptureFixture, |
|
3484 |
- modern_editor_interface: bool, |
|
3485 |
- notes: str, |
|
3486 |
- ) -> None: |
|
3487 |
- """Editing notes works.""" |
|
3488 |
- marker = cli_messages.TranslatedString( |
|
3489 |
- cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER |
|
3490 |
- ) |
|
3491 |
- edit_result = f""" |
|
3492 |
- |
|
3493 |
-{marker} |
|
3494 |
-{notes} |
|
3495 |
-""" |
|
3496 |
- # Reset caplog between hypothesis runs. |
|
3497 |
- caplog.clear() |
|
3498 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
3499 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
3500 |
- # with-statements. |
|
3501 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
3502 |
- with contextlib.ExitStack() as stack: |
|
3503 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3504 |
- stack.enter_context( |
|
3505 |
- pytest_machinery.isolated_vault_config( |
|
3506 |
- monkeypatch=monkeypatch, |
|
3507 |
- runner=runner, |
|
3508 |
- vault_config={ |
|
3509 |
- "global": {"phrase": "abc"}, |
|
3510 |
- "services": {"sv": {"notes": "Contents go here"}}, |
|
3511 |
- }, |
|
3512 |
- ) |
|
3513 |
- ) |
|
3514 |
- notes_backup_file = cli_helpers.config_filename( |
|
3515 |
- subsystem="notes backup" |
|
3516 |
- ) |
|
3517 |
- notes_backup_file.write_text( |
|
3518 |
- "These backup notes are left over from the previous session.", |
|
3519 |
- encoding="UTF-8", |
|
3520 |
- ) |
|
3521 |
- monkeypatch.setattr(click, "edit", lambda *_a, **_kw: edit_result) |
|
3522 |
- result = runner.invoke( |
|
3523 |
- cli.derivepassphrase_vault, |
|
3524 |
- [ |
|
3525 |
- "--config", |
|
3526 |
- "--notes", |
|
3527 |
- "--modern-editor-interface" |
|
3528 |
- if modern_editor_interface |
|
3529 |
- else "--vault-legacy-editor-interface", |
|
3530 |
- "--", |
|
3531 |
- "sv", |
|
3532 |
- ], |
|
3533 |
- catch_exceptions=False, |
|
3534 |
- ) |
|
3535 |
- assert result.clean_exit(), "expected clean exit" |
|
3536 |
- assert all(map(is_warning_line, result.stderr.splitlines(True))) |
|
3537 |
- assert modern_editor_interface or machinery.warning_emitted( |
|
3538 |
- "A backup copy of the old notes was saved", |
|
3539 |
- caplog.record_tuples, |
|
3540 |
- ), "expected known warning message in stderr" |
|
3541 |
- assert ( |
|
3542 |
- modern_editor_interface |
|
3543 |
- or notes_backup_file.read_text(encoding="UTF-8") |
|
3544 |
- == "Contents go here" |
|
3545 |
- ) |
|
3546 |
- with cli_helpers.config_filename(subsystem="vault").open( |
|
3547 |
- encoding="UTF-8" |
|
3548 |
- ) as infile: |
|
3549 |
- config = json.load(infile) |
|
3550 |
- assert config == { |
|
3551 |
- "global": {"phrase": "abc"}, |
|
3552 |
- "services": { |
|
3553 |
- "sv": { |
|
3554 |
- "notes": notes.strip() |
|
3555 |
- if modern_editor_interface |
|
3556 |
- else edit_result.strip() |
|
3557 |
- } |
|
3558 |
- }, |
|
3559 |
- } |
|
3560 |
- |
|
3561 |
- @Parametrize.NOOP_EDIT_FUNCS |
|
3562 |
- @hypothesis.given( |
|
3563 |
- notes=strategies.text( |
|
3564 |
- strategies.characters( |
|
3565 |
- min_codepoint=32, max_codepoint=126, include_characters="\n" |
|
3566 |
- ), |
|
3567 |
- min_size=1, |
|
3568 |
- max_size=512, |
|
3569 |
- ).filter(str.strip), |
|
3570 |
- ) |
|
3571 |
- def test_221_edit_notes_noop( |
|
3572 |
- self, |
|
3573 |
- edit_func_name: Literal["empty", "space"], |
|
3574 |
- modern_editor_interface: bool, |
|
3575 |
- notes: str, |
|
3576 |
- ) -> None: |
|
3577 |
- """Abandoning edited notes works.""" |
|
3578 |
- |
|
3579 |
- def empty(text: str, *_args: Any, **_kwargs: Any) -> str: |
|
3580 |
- del text |
|
3581 |
- return "" |
|
3582 |
- |
|
3583 |
- def space(text: str, *_args: Any, **_kwargs: Any) -> str: |
|
3584 |
- del text |
|
3585 |
- return " " + notes.strip() + "\n\n\n\n\n\n" |
|
3586 |
- |
|
3587 |
- edit_funcs = {"empty": empty, "space": space} |
|
3588 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
3589 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
3590 |
- # with-statements. |
|
3591 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
3592 |
- with contextlib.ExitStack() as stack: |
|
3593 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3594 |
- stack.enter_context( |
|
3595 |
- pytest_machinery.isolated_vault_config( |
|
3596 |
- monkeypatch=monkeypatch, |
|
3597 |
- runner=runner, |
|
3598 |
- vault_config={ |
|
3599 |
- "global": {"phrase": "abc"}, |
|
3600 |
- "services": {"sv": {"notes": notes.strip()}}, |
|
3601 |
- }, |
|
3602 |
- ) |
|
3603 |
- ) |
|
3604 |
- notes_backup_file = cli_helpers.config_filename( |
|
3605 |
- subsystem="notes backup" |
|
3606 |
- ) |
|
3607 |
- notes_backup_file.write_text( |
|
3608 |
- "These backup notes are left over from the previous session.", |
|
3609 |
- encoding="UTF-8", |
|
3610 |
- ) |
|
3611 |
- monkeypatch.setattr(click, "edit", edit_funcs[edit_func_name]) |
|
3612 |
- result = runner.invoke( |
|
3613 |
- cli.derivepassphrase_vault, |
|
3614 |
- [ |
|
3615 |
- "--config", |
|
3616 |
- "--notes", |
|
3617 |
- "--modern-editor-interface" |
|
3618 |
- if modern_editor_interface |
|
3619 |
- else "--vault-legacy-editor-interface", |
|
3620 |
- "--", |
|
3621 |
- "sv", |
|
3622 |
- ], |
|
3623 |
- catch_exceptions=False, |
|
3624 |
- ) |
|
3625 |
- assert result.clean_exit(empty_stderr=True) or result.error_exit( |
|
3626 |
- error="the user aborted the request" |
|
3627 |
- ), "expected clean exit" |
|
3628 |
- assert ( |
|
3629 |
- modern_editor_interface |
|
3630 |
- or notes_backup_file.read_text(encoding="UTF-8") |
|
3631 |
- == "These backup notes are left over from the previous session." |
|
3632 |
- ) |
|
3633 |
- with cli_helpers.config_filename(subsystem="vault").open( |
|
3634 |
- encoding="UTF-8" |
|
3635 |
- ) as infile: |
|
3636 |
- config = json.load(infile) |
|
3637 |
- assert config == { |
|
3638 |
- "global": {"phrase": "abc"}, |
|
3639 |
- "services": {"sv": {"notes": notes.strip()}}, |
|
3640 |
- } |
|
3641 |
- |
|
3642 |
- # TODO(the-13th-letter): Keep this behavior or not, with or without |
|
3643 |
- # warning? |
|
3644 |
- @Parametrize.MODERN_EDITOR_INTERFACE |
|
3645 |
- @hypothesis.settings( |
|
3646 |
- suppress_health_check=[ |
|
3647 |
- *hypothesis.settings().suppress_health_check, |
|
3648 |
- hypothesis.HealthCheck.function_scoped_fixture, |
|
3649 |
- ], |
|
3650 |
- ) |
|
3651 |
- @hypothesis.given( |
|
3652 |
- notes=strategies.text( |
|
3653 |
- strategies.characters( |
|
3654 |
- min_codepoint=32, max_codepoint=126, include_characters="\n" |
|
3655 |
- ), |
|
3656 |
- min_size=1, |
|
3657 |
- max_size=512, |
|
3658 |
- ).filter(str.strip), |
|
3659 |
- ) |
|
3660 |
- def test_222_edit_notes_marker_removed( |
|
3661 |
- self, |
|
3662 |
- caplog: pytest.LogCaptureFixture, |
|
3663 |
- modern_editor_interface: bool, |
|
3664 |
- notes: str, |
|
3665 |
- ) -> None: |
|
3666 |
- """Removing the notes marker still saves the notes. |
|
3667 |
- |
|
3668 |
- TODO: Keep this behavior or not, with or without warning? |
|
3669 |
- |
|
3670 |
- """ |
|
3671 |
- notes_marker = cli_messages.TranslatedString( |
|
3672 |
- cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER |
|
3673 |
- ) |
|
3674 |
- hypothesis.assume(str(notes_marker) not in notes.strip()) |
|
3675 |
- # Reset caplog between hypothesis runs. |
|
3676 |
- caplog.clear() |
|
3677 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
3678 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
3679 |
- # with-statements. |
|
3680 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
3681 |
- with contextlib.ExitStack() as stack: |
|
3682 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3683 |
- stack.enter_context( |
|
3684 |
- pytest_machinery.isolated_vault_config( |
|
3685 |
- monkeypatch=monkeypatch, |
|
3686 |
- runner=runner, |
|
3687 |
- vault_config={ |
|
3688 |
- "global": {"phrase": "abc"}, |
|
3689 |
- "services": {"sv": {"notes": "Contents go here"}}, |
|
3690 |
- }, |
|
3691 |
- ) |
|
3692 |
- ) |
|
3693 |
- notes_backup_file = cli_helpers.config_filename( |
|
3694 |
- subsystem="notes backup" |
|
3695 |
- ) |
|
3696 |
- notes_backup_file.write_text( |
|
3697 |
- "These backup notes are left over from the previous session.", |
|
3698 |
- encoding="UTF-8", |
|
3699 |
- ) |
|
3700 |
- monkeypatch.setattr(click, "edit", lambda *_a, **_kw: notes) |
|
3701 |
- result = runner.invoke( |
|
3702 |
- cli.derivepassphrase_vault, |
|
3703 |
- [ |
|
3704 |
- "--config", |
|
3705 |
- "--notes", |
|
3706 |
- "--modern-editor-interface" |
|
3707 |
- if modern_editor_interface |
|
3708 |
- else "--vault-legacy-editor-interface", |
|
3709 |
- "--", |
|
3710 |
- "sv", |
|
3711 |
- ], |
|
3712 |
- catch_exceptions=False, |
|
3713 |
- ) |
|
3714 |
- assert result.clean_exit(), "expected clean exit" |
|
3715 |
- assert not result.stderr or all( |
|
3716 |
- map(is_warning_line, result.stderr.splitlines(True)) |
|
3717 |
- ) |
|
3718 |
- assert not caplog.record_tuples or machinery.warning_emitted( |
|
3719 |
- "A backup copy of the old notes was saved", |
|
3720 |
- caplog.record_tuples, |
|
3721 |
- ), "expected known warning message in stderr" |
|
3722 |
- assert ( |
|
3723 |
- modern_editor_interface |
|
3724 |
- or notes_backup_file.read_text(encoding="UTF-8") |
|
3725 |
- == "Contents go here" |
|
3726 |
- ) |
|
3727 |
- with cli_helpers.config_filename(subsystem="vault").open( |
|
3728 |
- encoding="UTF-8" |
|
3729 |
- ) as infile: |
|
3730 |
- config = json.load(infile) |
|
3731 |
- assert config == { |
|
3732 |
- "global": {"phrase": "abc"}, |
|
3733 |
- "services": {"sv": {"notes": notes.strip()}}, |
|
3734 |
- } |
|
3735 |
- |
|
3736 |
- @hypothesis.given( |
|
3737 |
- notes=strategies.text( |
|
3738 |
- strategies.characters( |
|
3739 |
- min_codepoint=32, max_codepoint=126, include_characters="\n" |
|
3740 |
- ), |
|
3741 |
- min_size=1, |
|
3742 |
- max_size=512, |
|
3743 |
- ).filter(str.strip), |
|
3744 |
- ) |
|
3745 |
- def test_223_edit_notes_abort( |
|
3746 |
- self, |
|
3747 |
- notes: str, |
|
3748 |
- ) -> None: |
|
3749 |
- """Aborting editing notes works. |
|
3750 |
- |
|
3751 |
- Aborting is only supported with the modern editor interface. |
|
3752 |
- |
|
3753 |
- """ |
|
3754 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
3755 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
3756 |
- # with-statements. |
|
3757 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
3758 |
- with contextlib.ExitStack() as stack: |
|
3759 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3760 |
- stack.enter_context( |
|
3761 |
- pytest_machinery.isolated_vault_config( |
|
3762 |
- monkeypatch=monkeypatch, |
|
3763 |
- runner=runner, |
|
3764 |
- vault_config={ |
|
3765 |
- "global": {"phrase": "abc"}, |
|
3766 |
- "services": {"sv": {"notes": notes.strip()}}, |
|
3767 |
- }, |
|
3768 |
- ) |
|
3769 |
- ) |
|
3770 |
- monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "") |
|
3771 |
- result = runner.invoke( |
|
3772 |
- cli.derivepassphrase_vault, |
|
3773 |
- [ |
|
3774 |
- "--config", |
|
3775 |
- "--notes", |
|
3776 |
- "--modern-editor-interface", |
|
3777 |
- "--", |
|
3778 |
- "sv", |
|
3779 |
- ], |
|
3780 |
- catch_exceptions=False, |
|
3781 |
- ) |
|
3782 |
- assert result.error_exit(error="the user aborted the request"), ( |
|
3783 |
- "expected known error message" |
|
3784 |
- ) |
|
3785 |
- with cli_helpers.config_filename(subsystem="vault").open( |
|
3786 |
- encoding="UTF-8" |
|
3787 |
- ) as infile: |
|
3788 |
- config = json.load(infile) |
|
3789 |
- assert config == { |
|
3790 |
- "global": {"phrase": "abc"}, |
|
3791 |
- "services": {"sv": {"notes": notes.strip()}}, |
|
3792 |
- } |
|
3793 |
- |
|
3794 |
- def test_223a_edit_empty_notes_abort( |
|
3795 |
- self, |
|
3796 |
- ) -> None: |
|
3797 |
- """Aborting editing notes works even if no notes are stored yet. |
|
3798 |
- |
|
3799 |
- Aborting is only supported with the modern editor interface. |
|
3800 |
- |
|
3801 |
- """ |
|
3802 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
3803 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
3804 |
- # with-statements. |
|
3805 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
3806 |
- with contextlib.ExitStack() as stack: |
|
3807 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3808 |
- stack.enter_context( |
|
3809 |
- pytest_machinery.isolated_vault_config( |
|
3810 |
- monkeypatch=monkeypatch, |
|
3811 |
- runner=runner, |
|
3812 |
- vault_config={ |
|
3813 |
- "global": {"phrase": "abc"}, |
|
3814 |
- "services": {}, |
|
3815 |
- }, |
|
3816 |
- ) |
|
3817 |
- ) |
|
3818 |
- monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "") |
|
3819 |
- result = runner.invoke( |
|
3820 |
- cli.derivepassphrase_vault, |
|
3821 |
- [ |
|
3822 |
- "--config", |
|
3823 |
- "--notes", |
|
3824 |
- "--modern-editor-interface", |
|
3825 |
- "--", |
|
3826 |
- "sv", |
|
3827 |
- ], |
|
3828 |
- catch_exceptions=False, |
|
3829 |
- ) |
|
3830 |
- assert result.error_exit(error="the user aborted the request"), ( |
|
3831 |
- "expected known error message" |
|
3832 |
- ) |
|
3833 |
- with cli_helpers.config_filename(subsystem="vault").open( |
|
3834 |
- encoding="UTF-8" |
|
3835 |
- ) as infile: |
|
3836 |
- config = json.load(infile) |
|
3837 |
- assert config == { |
|
3838 |
- "global": {"phrase": "abc"}, |
|
3839 |
- "services": {}, |
|
3840 |
- } |
|
3841 |
- |
|
3842 |
- @Parametrize.MODERN_EDITOR_INTERFACE |
|
3843 |
- @hypothesis.settings( |
|
3844 |
- suppress_health_check=[ |
|
3845 |
- *hypothesis.settings().suppress_health_check, |
|
3846 |
- hypothesis.HealthCheck.function_scoped_fixture, |
|
3847 |
- ], |
|
3848 |
- ) |
|
3849 |
- @hypothesis.given( |
|
3850 |
- notes=strategies.text( |
|
3851 |
- strategies.characters( |
|
3852 |
- min_codepoint=32, max_codepoint=126, include_characters="\n" |
|
3853 |
- ), |
|
3854 |
- max_size=512, |
|
3855 |
- ), |
|
3856 |
- ) |
|
3857 |
- def test_223b_edit_notes_fail_config_option_missing( |
|
3858 |
- self, |
|
3859 |
- caplog: pytest.LogCaptureFixture, |
|
3860 |
- modern_editor_interface: bool, |
|
3861 |
- notes: str, |
|
3862 |
- ) -> None: |
|
3863 |
- """Editing notes fails (and warns) if `--config` is missing.""" |
|
3864 |
- maybe_notes = {"notes": notes.strip()} if notes.strip() else {} |
|
3865 |
- vault_config = { |
|
3866 |
- "global": {"phrase": DUMMY_PASSPHRASE}, |
|
3867 |
- "services": { |
|
3868 |
- DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS} |
|
3869 |
- }, |
|
3870 |
- } |
|
3871 |
- # Reset caplog between hypothesis runs. |
|
3872 |
- caplog.clear() |
|
3873 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
3874 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
3875 |
- # with-statements. |
|
3876 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
3877 |
- with contextlib.ExitStack() as stack: |
|
3878 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3879 |
- stack.enter_context( |
|
3880 |
- pytest_machinery.isolated_vault_config( |
|
3881 |
- monkeypatch=monkeypatch, |
|
3882 |
- runner=runner, |
|
3883 |
- vault_config=vault_config, |
|
3884 |
- ) |
|
3885 |
- ) |
|
3886 |
- EDIT_ATTEMPTED = "edit attempted!" # noqa: N806 |
|
3887 |
- |
|
3888 |
- def raiser(*_args: Any, **_kwargs: Any) -> NoReturn: |
|
3889 |
- pytest.fail(EDIT_ATTEMPTED) |
|
3890 |
- |
|
3891 |
- notes_backup_file = cli_helpers.config_filename( |
|
3892 |
- subsystem="notes backup" |
|
3893 |
- ) |
|
3894 |
- notes_backup_file.write_text( |
|
3895 |
- "These backup notes are left over from the previous session.", |
|
3896 |
- encoding="UTF-8", |
|
3897 |
- ) |
|
3898 |
- monkeypatch.setattr(click, "edit", raiser) |
|
3899 |
- result = runner.invoke( |
|
3900 |
- cli.derivepassphrase_vault, |
|
3901 |
- [ |
|
3902 |
- "--notes", |
|
3903 |
- "--modern-editor-interface" |
|
3904 |
- if modern_editor_interface |
|
3905 |
- else "--vault-legacy-editor-interface", |
|
3906 |
- "--", |
|
3907 |
- DUMMY_SERVICE, |
|
3908 |
- ], |
|
3909 |
- catch_exceptions=False, |
|
3910 |
- ) |
|
3911 |
- assert result.clean_exit( |
|
3912 |
- output=DUMMY_RESULT_PASSPHRASE.decode("ascii") |
|
3913 |
- ), "expected clean exit" |
|
3914 |
- assert result.stderr |
|
3915 |
- assert notes.strip() in result.stderr |
|
3916 |
- assert all( |
|
3917 |
- is_warning_line(line) |
|
3918 |
- for line in result.stderr.splitlines(True) |
|
3919 |
- if line.startswith(f"{cli.PROG_NAME}: ") |
|
3920 |
- ) |
|
3921 |
- assert machinery.warning_emitted( |
|
3922 |
- "Specifying --notes without --config is ineffective. " |
|
3923 |
- "No notes will be edited.", |
|
3924 |
- caplog.record_tuples, |
|
3925 |
- ), "expected known warning message in stderr" |
|
3926 |
- assert ( |
|
3927 |
- modern_editor_interface |
|
3928 |
- or notes_backup_file.read_text(encoding="UTF-8") |
|
3929 |
- == "These backup notes are left over from the previous session." |
|
3930 |
- ) |
|
3931 |
- with cli_helpers.config_filename(subsystem="vault").open( |
|
3932 |
- encoding="UTF-8" |
|
3933 |
- ) as infile: |
|
3934 |
- config = json.load(infile) |
|
3935 |
- assert config == vault_config |
|
3936 |
- |
|
3937 |
- @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG |
|
3938 |
- def test_224_store_config_good( |
|
3939 |
- self, |
|
3940 |
- command_line: list[str], |
|
3941 |
- input: str, |
|
3942 |
- result_config: Any, |
|
3943 |
- ) -> None: |
|
3944 |
- """Storing valid settings via `--config` works. |
|
3945 |
- |
|
3946 |
- The format also contains embedded newlines and indentation to make |
|
3947 |
- the config more readable. |
|
3948 |
- |
|
3949 |
- """ |
|
3950 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
3951 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
3952 |
- # with-statements. |
|
3953 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
3954 |
- with contextlib.ExitStack() as stack: |
|
3955 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3956 |
- stack.enter_context( |
|
3957 |
- pytest_machinery.isolated_vault_config( |
|
3958 |
- monkeypatch=monkeypatch, |
|
3959 |
- runner=runner, |
|
3960 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
3961 |
- ) |
|
3962 |
- ) |
|
3963 |
- monkeypatch.setattr( |
|
3964 |
- cli_helpers, |
|
3965 |
- "get_suitable_ssh_keys", |
|
3966 |
- callables.suitable_ssh_keys, |
|
3967 |
- ) |
|
3968 |
- result = runner.invoke( |
|
3969 |
- cli.derivepassphrase_vault, |
|
3970 |
- ["--config", *command_line], |
|
3971 |
- catch_exceptions=False, |
|
3972 |
- input=input, |
|
3973 |
- ) |
|
3974 |
- assert result.clean_exit(), "expected clean exit" |
|
3975 |
- config_txt = cli_helpers.config_filename( |
|
3976 |
- subsystem="vault" |
|
3977 |
- ).read_text(encoding="UTF-8") |
|
3978 |
- config = json.loads(config_txt) |
|
3979 |
- assert config == result_config, ( |
|
3980 |
- "stored config does not match expectation" |
|
3981 |
- ) |
|
3982 |
- assert_vault_config_is_indented_and_line_broken(config_txt) |
|
3983 |
- |
|
3984 |
- @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES |
|
3985 |
- def test_225_store_config_fail( |
|
3986 |
- self, |
|
3987 |
- command_line: list[str], |
|
3988 |
- input: str, |
|
3989 |
- err_text: str, |
|
3990 |
- ) -> None: |
|
3991 |
- """Storing invalid settings via `--config` fails.""" |
|
3992 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
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: |
|
3997 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
3998 |
- stack.enter_context( |
|
3999 |
- pytest_machinery.isolated_vault_config( |
|
4000 |
- monkeypatch=monkeypatch, |
|
4001 |
- runner=runner, |
|
4002 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
4003 |
- ) |
|
4004 |
- ) |
|
4005 |
- monkeypatch.setattr( |
|
4006 |
- cli_helpers, |
|
4007 |
- "get_suitable_ssh_keys", |
|
4008 |
- callables.suitable_ssh_keys, |
|
4009 |
- ) |
|
4010 |
- result = runner.invoke( |
|
4011 |
- cli.derivepassphrase_vault, |
|
4012 |
- ["--config", *command_line], |
|
4013 |
- catch_exceptions=False, |
|
4014 |
- input=input, |
|
4015 |
- ) |
|
4016 |
- assert result.error_exit(error=err_text), ( |
|
4017 |
- "expected error exit and known error message" |
|
4018 |
- ) |
|
4019 |
- |
|
4020 |
- def test_225a_store_config_fail_manual_no_ssh_key_selection( |
|
4021 |
- self, |
|
4022 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
4023 |
- ) -> None: |
|
4024 |
- """Not selecting an SSH key during `--config --key` fails.""" |
|
4025 |
- del running_ssh_agent |
|
4026 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
4027 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
4028 |
- # with-statements. |
|
4029 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
4030 |
- with contextlib.ExitStack() as stack: |
|
4031 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
4032 |
- stack.enter_context( |
|
4033 |
- pytest_machinery.isolated_vault_config( |
|
4034 |
- monkeypatch=monkeypatch, |
|
4035 |
- runner=runner, |
|
4036 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
4037 |
- ) |
|
4038 |
- ) |
|
4039 |
- |
|
4040 |
- def prompt_for_selection(*_args: Any, **_kwargs: Any) -> NoReturn: |
|
4041 |
- raise IndexError(cli_helpers.EMPTY_SELECTION) |
|
4042 |
- |
|
4043 |
- monkeypatch.setattr( |
|
4044 |
- cli_helpers, "prompt_for_selection", prompt_for_selection |
|
4045 |
- ) |
|
4046 |
- # Also patch the list of suitable SSH keys, lest we be at |
|
4047 |
- # the mercy of whatever SSH agent may be running. |
|
4048 |
- monkeypatch.setattr( |
|
4049 |
- cli_helpers, |
|
4050 |
- "get_suitable_ssh_keys", |
|
4051 |
- callables.suitable_ssh_keys, |
|
4052 |
- ) |
|
4053 |
- result = runner.invoke( |
|
4054 |
- cli.derivepassphrase_vault, |
|
4055 |
- ["--key", "--config"], |
|
4056 |
- catch_exceptions=False, |
|
4057 |
- ) |
|
4058 |
- assert result.error_exit(error="the user aborted the request"), ( |
|
4059 |
- "expected error exit and known error message" |
|
4060 |
- ) |
|
4061 |
- |
|
4062 |
- def test_225b_store_config_fail_manual_no_ssh_agent( |
|
4063 |
- self, |
|
4064 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
4065 |
- ) -> None: |
|
4066 |
- """Not running an SSH agent during `--config --key` fails.""" |
|
4067 |
- del running_ssh_agent |
|
4068 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
4069 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
4070 |
- # with-statements. |
|
4071 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
4072 |
- with contextlib.ExitStack() as stack: |
|
4073 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
4074 |
- stack.enter_context( |
|
4075 |
- pytest_machinery.isolated_vault_config( |
|
4076 |
- monkeypatch=monkeypatch, |
|
4077 |
- runner=runner, |
|
4078 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
4079 |
- ) |
|
4080 |
- ) |
|
4081 |
- monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) |
|
4082 |
- result = runner.invoke( |
|
4083 |
- cli.derivepassphrase_vault, |
|
4084 |
- ["--key", "--config"], |
|
4085 |
- catch_exceptions=False, |
|
4086 |
- ) |
|
4087 |
- assert result.error_exit(error="Cannot find any running SSH agent"), ( |
|
4088 |
- "expected error exit and known error message" |
|
4089 |
- ) |
|
4090 |
- |
|
4091 |
- def test_225c_store_config_fail_manual_bad_ssh_agent_connection( |
|
4092 |
- self, |
|
4093 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
4094 |
- ) -> None: |
|
4095 |
- """Not running a reachable SSH agent during `--config --key` fails.""" |
|
4096 |
- running_ssh_agent.require_external_address() |
|
4097 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
4098 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
4099 |
- # with-statements. |
|
4100 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
4101 |
- with contextlib.ExitStack() as stack: |
|
4102 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
4103 |
- stack.enter_context( |
|
4104 |
- pytest_machinery.isolated_vault_config( |
|
4105 |
- monkeypatch=monkeypatch, |
|
4106 |
- runner=runner, |
|
4107 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
4108 |
- ) |
|
4109 |
- ) |
|
4110 |
- cwd = pathlib.Path.cwd().resolve() |
|
4111 |
- monkeypatch.setenv("SSH_AUTH_SOCK", str(cwd)) |
|
4112 |
- result = runner.invoke( |
|
4113 |
- cli.derivepassphrase_vault, |
|
4114 |
- ["--key", "--config"], |
|
4115 |
- catch_exceptions=False, |
|
4116 |
- ) |
|
4117 |
- assert result.error_exit(error="Cannot connect to the SSH agent"), ( |
|
4118 |
- "expected error exit and known error message" |
|
4119 |
- ) |
|
4120 |
- |
|
4121 |
- @Parametrize.TRY_RACE_FREE_IMPLEMENTATION |
|
4122 |
- def test_225d_store_config_fail_manual_read_only_file( |
|
4123 |
- self, |
|
4124 |
- try_race_free_implementation: bool, |
|
4125 |
- ) -> None: |
|
4126 |
- """Using a read-only configuration file with `--config` fails.""" |
|
4127 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
4128 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
4129 |
- # with-statements. |
|
4130 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
4131 |
- with contextlib.ExitStack() as stack: |
|
4132 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
4133 |
- stack.enter_context( |
|
4134 |
- pytest_machinery.isolated_vault_config( |
|
4135 |
- monkeypatch=monkeypatch, |
|
4136 |
- runner=runner, |
|
4137 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
4138 |
- ) |
|
4139 |
- ) |
|
4140 |
- callables.make_file_readonly( |
|
4141 |
- cli_helpers.config_filename(subsystem="vault"), |
|
4142 |
- try_race_free_implementation=try_race_free_implementation, |
|
4143 |
- ) |
|
4144 |
- result = runner.invoke( |
|
4145 |
- cli.derivepassphrase_vault, |
|
4146 |
- ["--config", "--length=15", "--", DUMMY_SERVICE], |
|
4147 |
- catch_exceptions=False, |
|
4148 |
- ) |
|
4149 |
- assert result.error_exit(error="Cannot store vault settings:"), ( |
|
4150 |
- "expected error exit and known error message" |
|
4151 |
- ) |
|
4152 |
- |
|
4153 |
- def test_225e_store_config_fail_manual_custom_error( |
|
4154 |
- self, |
|
4155 |
- ) -> None: |
|
4156 |
- """OS-erroring with `--config` fails.""" |
|
4157 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
4158 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
4159 |
- # with-statements. |
|
4160 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
4161 |
- with contextlib.ExitStack() as stack: |
|
4162 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
4163 |
- stack.enter_context( |
|
4164 |
- pytest_machinery.isolated_vault_config( |
|
4165 |
- monkeypatch=monkeypatch, |
|
4166 |
- runner=runner, |
|
4167 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
4168 |
- ) |
|
4169 |
- ) |
|
4170 |
- custom_error = "custom error message" |
|
4171 |
- |
|
4172 |
- def raiser(config: Any) -> None: |
|
4173 |
- del config |
|
4174 |
- raise RuntimeError(custom_error) |
|
4175 |
- |
|
4176 |
- monkeypatch.setattr(cli_helpers, "save_config", raiser) |
|
4177 |
- result = runner.invoke( |
|
4178 |
- cli.derivepassphrase_vault, |
|
4179 |
- ["--config", "--length=15", "--", DUMMY_SERVICE], |
|
4180 |
- catch_exceptions=False, |
|
4181 |
- ) |
|
4182 |
- assert result.error_exit(error=custom_error), ( |
|
4183 |
- "expected error exit and known error message" |
|
4184 |
- ) |
|
4185 |
- |
|
4186 |
- def test_225f_store_config_fail_unset_and_set_same_settings( |
|
4187 |
- self, |
|
4188 |
- ) -> None: |
|
4189 |
- """Issuing conflicting settings to `--config` fails.""" |
|
4190 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
4191 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
4192 |
- # with-statements. |
|
4193 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
4194 |
- with contextlib.ExitStack() as stack: |
|
4195 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
4196 |
- stack.enter_context( |
|
4197 |
- pytest_machinery.isolated_vault_config( |
|
4198 |
- monkeypatch=monkeypatch, |
|
4199 |
- runner=runner, |
|
4200 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
4201 |
- ) |
|
4202 |
- ) |
|
4203 |
- result = runner.invoke( |
|
4204 |
- cli.derivepassphrase_vault, |
|
4205 |
- [ |
|
4206 |
- "--config", |
|
4207 |
- "--unset=length", |
|
4208 |
- "--length=15", |
|
4209 |
- "--", |
|
4210 |
- DUMMY_SERVICE, |
|
4211 |
- ], |
|
4212 |
- catch_exceptions=False, |
|
4213 |
- ) |
|
4214 |
- assert result.error_exit( |
|
4215 |
- error="Attempted to unset and set --length at the same time." |
|
4216 |
- ), "expected error exit and known error message" |
|
4217 |
- |
|
4218 |
- def test_225g_store_config_fail_manual_ssh_agent_no_keys_loaded( |
|
4219 |
- self, |
|
4220 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
4221 |
- ) -> None: |
|
4222 |
- """Not holding any SSH keys during `--config --key` fails.""" |
|
4223 |
- del running_ssh_agent |
|
4224 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
4225 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
4226 |
- # with-statements. |
|
4227 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
4228 |
- with contextlib.ExitStack() as stack: |
|
4229 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
4230 |
- stack.enter_context( |
|
4231 |
- pytest_machinery.isolated_vault_config( |
|
4232 |
- monkeypatch=monkeypatch, |
|
4233 |
- runner=runner, |
|
4234 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
4235 |
- ) |
|
4236 |
- ) |
|
4237 |
- |
|
4238 |
- def func( |
|
4239 |
- *_args: Any, |
|
4240 |
- **_kwargs: Any, |
|
4241 |
- ) -> list[_types.SSHKeyCommentPair]: |
|
4242 |
- return [] |
|
4243 |
- |
|
4244 |
- monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func) |
|
4245 |
- result = runner.invoke( |
|
4246 |
- cli.derivepassphrase_vault, |
|
4247 |
- ["--key", "--config"], |
|
4248 |
- catch_exceptions=False, |
|
4249 |
- ) |
|
4250 |
- assert result.error_exit(error="no keys suitable"), ( |
|
4251 |
- "expected error exit and known error message" |
|
4252 |
- ) |
|
4253 |
- |
|
4254 |
- def test_225h_store_config_fail_manual_ssh_agent_runtime_error( |
|
4255 |
- self, |
|
4256 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
4257 |
- ) -> None: |
|
4258 |
- """The SSH agent erroring during `--config --key` fails.""" |
|
4259 |
- del running_ssh_agent |
|
4260 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
4261 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
4262 |
- # with-statements. |
|
4263 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
4264 |
- with contextlib.ExitStack() as stack: |
|
4265 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
4266 |
- stack.enter_context( |
|
4267 |
- pytest_machinery.isolated_vault_config( |
|
4268 |
- monkeypatch=monkeypatch, |
|
4269 |
- runner=runner, |
|
4270 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
4271 |
- ) |
|
4272 |
- ) |
|
4273 |
- |
|
4274 |
- def raiser(*_args: Any, **_kwargs: Any) -> None: |
|
4275 |
- raise ssh_agent.TrailingDataError() |
|
4276 |
- |
|
4277 |
- monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", raiser) |
|
4278 |
- result = runner.invoke( |
|
4279 |
- cli.derivepassphrase_vault, |
|
4280 |
- ["--key", "--config"], |
|
4281 |
- catch_exceptions=False, |
|
4282 |
- ) |
|
4283 |
- assert result.error_exit( |
|
4284 |
- error="violates the communication protocol." |
|
4285 |
- ), "expected error exit and known error message" |
|
4286 |
- |
|
4287 |
- def test_225i_store_config_fail_manual_ssh_agent_refuses( |
|
4288 |
- self, |
|
4289 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
4290 |
- ) -> None: |
|
4291 |
- """The SSH agent refusing during `--config --key` fails.""" |
|
4292 |
- del running_ssh_agent |
|
4293 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
4294 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
4295 |
- # with-statements. |
|
4296 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
4297 |
- with contextlib.ExitStack() as stack: |
|
4298 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
4299 |
- stack.enter_context( |
|
4300 |
- pytest_machinery.isolated_vault_config( |
|
4301 |
- monkeypatch=monkeypatch, |
|
4302 |
- runner=runner, |
|
4303 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
4304 |
- ) |
|
4305 |
- ) |
|
4306 |
- |
|
4307 |
- def func(*_args: Any, **_kwargs: Any) -> NoReturn: |
|
4308 |
- raise ssh_agent.SSHAgentFailedError( |
|
4309 |
- _types.SSH_AGENT.FAILURE, b"" |
|
4310 |
- ) |
|
4311 |
- |
|
4312 |
- monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func) |
|
4313 |
- result = runner.invoke( |
|
4314 |
- cli.derivepassphrase_vault, |
|
4315 |
- ["--key", "--config"], |
|
4316 |
- catch_exceptions=False, |
|
4317 |
- ) |
|
4318 |
- assert result.error_exit(error="refused to"), ( |
|
4319 |
- "expected error exit and known error message" |
|
4320 |
- ) |
|
4321 |
- |
|
4322 |
- def test_226_no_arguments(self) -> None: |
|
4323 |
- """Calling `derivepassphrase vault` without any arguments fails.""" |
|
4324 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
4325 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
4326 |
- # with-statements. |
|
4327 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
4328 |
- with contextlib.ExitStack() as stack: |
|
4329 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
4330 |
- stack.enter_context( |
|
4331 |
- pytest_machinery.isolated_config( |
|
4332 |
- monkeypatch=monkeypatch, |
|
4333 |
- runner=runner, |
|
4334 |
- ) |
|
4335 |
- ) |
|
4336 |
- result = runner.invoke( |
|
4337 |
- cli.derivepassphrase_vault, [], catch_exceptions=False |
|
4338 |
- ) |
|
4339 |
- assert result.error_exit( |
|
4340 |
- error="Deriving a passphrase requires a SERVICE" |
|
4341 |
- ), "expected error exit and known error message" |
|
4342 |
- |
|
4343 |
- def test_226a_no_passphrase_or_key( |
|
4344 |
- self, |
|
4345 |
- ) -> None: |
|
4346 |
- """Deriving a passphrase without a passphrase or key fails.""" |
|
4347 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
4348 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
4349 |
- # with-statements. |
|
4350 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
4351 |
- with contextlib.ExitStack() as stack: |
|
4352 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
4353 |
- stack.enter_context( |
|
4354 |
- pytest_machinery.isolated_config( |
|
4355 |
- monkeypatch=monkeypatch, |
|
4356 |
- runner=runner, |
|
4357 |
- ) |
|
4358 |
- ) |
|
4359 |
- result = runner.invoke( |
|
4360 |
- cli.derivepassphrase_vault, |
|
4361 |
- ["--", DUMMY_SERVICE], |
|
4362 |
- catch_exceptions=False, |
|
1733 |
+ @Parametrize.MODERN_EDITOR_INTERFACE |
|
1734 |
+ @hypothesis.settings( |
|
1735 |
+ suppress_health_check=[ |
|
1736 |
+ *hypothesis.settings().suppress_health_check, |
|
1737 |
+ hypothesis.HealthCheck.function_scoped_fixture, |
|
1738 |
+ ], |
|
4363 | 1739 |
) |
4364 |
- assert result.error_exit(error="No passphrase or key was given"), ( |
|
4365 |
- "expected error exit and known error message" |
|
1740 |
+ @hypothesis.given( |
|
1741 |
+ notes=strategies.text( |
|
1742 |
+ strategies.characters( |
|
1743 |
+ min_codepoint=32, max_codepoint=126, include_characters="\n" |
|
1744 |
+ ), |
|
1745 |
+ min_size=1, |
|
1746 |
+ max_size=512, |
|
1747 |
+ ).filter(str.strip), |
|
4366 | 1748 |
) |
4367 |
- |
|
4368 |
- def test_230_config_directory_nonexistant( |
|
1749 |
+ def test_220_edit_notes_successfully( |
|
4369 | 1750 |
self, |
1751 |
+ caplog: pytest.LogCaptureFixture, |
|
1752 |
+ modern_editor_interface: bool, |
|
1753 |
+ notes: str, |
|
4370 | 1754 |
) -> None: |
4371 |
- """Running without an existing config directory works. |
|
4372 |
- |
|
4373 |
- This is a regression test; see [issue\u00a0#6][] for context. |
|
4374 |
- |
|
4375 |
- [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6 |
|
1755 |
+ """Editing notes works.""" |
|
1756 |
+ marker = cli_messages.TranslatedString( |
|
1757 |
+ cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER |
|
1758 |
+ ) |
|
1759 |
+ edit_result = f""" |
|
4376 | 1760 |
|
1761 |
+{marker} |
|
1762 |
+{notes} |
|
4377 | 1763 |
""" |
1764 |
+ # Reset caplog between hypothesis runs. |
|
1765 |
+ caplog.clear() |
|
4378 | 1766 |
runner = machinery.CliRunner(mix_stderr=False) |
4379 | 1767 |
# TODO(the-13th-letter): Rewrite using parenthesized |
4380 | 1768 |
# with-statements. |
... | ... |
@@ -4382,45 +1770,89 @@ class TestCLI: |
4382 | 1770 |
with contextlib.ExitStack() as stack: |
4383 | 1771 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
4384 | 1772 |
stack.enter_context( |
4385 |
- pytest_machinery.isolated_config( |
|
1773 |
+ pytest_machinery.isolated_vault_config( |
|
4386 | 1774 |
monkeypatch=monkeypatch, |
4387 | 1775 |
runner=runner, |
1776 |
+ vault_config={ |
|
1777 |
+ "global": {"phrase": "abc"}, |
|
1778 |
+ "services": {"sv": {"notes": "Contents go here"}}, |
|
1779 |
+ }, |
|
4388 | 1780 |
) |
4389 | 1781 |
) |
4390 |
- with contextlib.suppress(FileNotFoundError): |
|
4391 |
- shutil.rmtree(cli_helpers.config_filename(subsystem=None)) |
|
1782 |
+ notes_backup_file = cli_helpers.config_filename( |
|
1783 |
+ subsystem="notes backup" |
|
1784 |
+ ) |
|
1785 |
+ notes_backup_file.write_text( |
|
1786 |
+ "These backup notes are left over from the previous session.", |
|
1787 |
+ encoding="UTF-8", |
|
1788 |
+ ) |
|
1789 |
+ monkeypatch.setattr(click, "edit", lambda *_a, **_kw: edit_result) |
|
4392 | 1790 |
result = runner.invoke( |
4393 | 1791 |
cli.derivepassphrase_vault, |
4394 |
- ["--config", "-p"], |
|
1792 |
+ [ |
|
1793 |
+ "--config", |
|
1794 |
+ "--notes", |
|
1795 |
+ "--modern-editor-interface" |
|
1796 |
+ if modern_editor_interface |
|
1797 |
+ else "--vault-legacy-editor-interface", |
|
1798 |
+ "--", |
|
1799 |
+ "sv", |
|
1800 |
+ ], |
|
4395 | 1801 |
catch_exceptions=False, |
4396 |
- input="abc\n", |
|
4397 | 1802 |
) |
4398 | 1803 |
assert result.clean_exit(), "expected clean exit" |
4399 |
- assert result.stderr == "Passphrase:", ( |
|
4400 |
- "program unexpectedly failed?!" |
|
1804 |
+ assert all(map(is_warning_line, result.stderr.splitlines(True))) |
|
1805 |
+ assert modern_editor_interface or machinery.warning_emitted( |
|
1806 |
+ "A backup copy of the old notes was saved", |
|
1807 |
+ caplog.record_tuples, |
|
1808 |
+ ), "expected known warning message in stderr" |
|
1809 |
+ assert ( |
|
1810 |
+ modern_editor_interface |
|
1811 |
+ or notes_backup_file.read_text(encoding="UTF-8") |
|
1812 |
+ == "Contents go here" |
|
4401 | 1813 |
) |
4402 | 1814 |
with cli_helpers.config_filename(subsystem="vault").open( |
4403 | 1815 |
encoding="UTF-8" |
4404 | 1816 |
) as infile: |
4405 |
- config_readback = json.load(infile) |
|
4406 |
- assert config_readback == { |
|
1817 |
+ config = json.load(infile) |
|
1818 |
+ assert config == { |
|
4407 | 1819 |
"global": {"phrase": "abc"}, |
4408 |
- "services": {}, |
|
4409 |
- }, "config mismatch" |
|
1820 |
+ "services": { |
|
1821 |
+ "sv": { |
|
1822 |
+ "notes": notes.strip() |
|
1823 |
+ if modern_editor_interface |
|
1824 |
+ else edit_result.strip() |
|
1825 |
+ } |
|
1826 |
+ }, |
|
1827 |
+ } |
|
4410 | 1828 |
|
4411 |
- def test_230a_config_directory_not_a_file( |
|
1829 |
+ @Parametrize.NOOP_EDIT_FUNCS |
|
1830 |
+ @hypothesis.given( |
|
1831 |
+ notes=strategies.text( |
|
1832 |
+ strategies.characters( |
|
1833 |
+ min_codepoint=32, max_codepoint=126, include_characters="\n" |
|
1834 |
+ ), |
|
1835 |
+ min_size=1, |
|
1836 |
+ max_size=512, |
|
1837 |
+ ).filter(str.strip), |
|
1838 |
+ ) |
|
1839 |
+ def test_221_edit_notes_noop( |
|
4412 | 1840 |
self, |
1841 |
+ edit_func_name: Literal["empty", "space"], |
|
1842 |
+ modern_editor_interface: bool, |
|
1843 |
+ notes: str, |
|
4413 | 1844 |
) -> None: |
4414 |
- """Erroring without an existing config directory errors normally. |
|
4415 |
- |
|
4416 |
- That is, the missing configuration directory does not cause any |
|
4417 |
- errors by itself. |
|
1845 |
+ """Abandoning edited notes works.""" |
|
4418 | 1846 |
|
4419 |
- This is a regression test; see [issue\u00a0#6][] for context. |
|
1847 |
+ def empty(text: str, *_args: Any, **_kwargs: Any) -> str: |
|
1848 |
+ del text |
|
1849 |
+ return "" |
|
4420 | 1850 |
|
4421 |
- [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6 |
|
1851 |
+ def space(text: str, *_args: Any, **_kwargs: Any) -> str: |
|
1852 |
+ del text |
|
1853 |
+ return " " + notes.strip() + "\n\n\n\n\n\n" |
|
4422 | 1854 |
|
4423 |
- """ |
|
1855 |
+ edit_funcs = {"empty": empty, "space": space} |
|
4424 | 1856 |
runner = machinery.CliRunner(mix_stderr=False) |
4425 | 1857 |
# TODO(the-13th-letter): Rewrite using parenthesized |
4426 | 1858 |
# with-statements. |
... | ... |
@@ -4428,77 +1860,88 @@ class TestCLI: |
4428 | 1860 |
with contextlib.ExitStack() as stack: |
4429 | 1861 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
4430 | 1862 |
stack.enter_context( |
4431 |
- pytest_machinery.isolated_config( |
|
1863 |
+ pytest_machinery.isolated_vault_config( |
|
4432 | 1864 |
monkeypatch=monkeypatch, |
4433 | 1865 |
runner=runner, |
1866 |
+ vault_config={ |
|
1867 |
+ "global": {"phrase": "abc"}, |
|
1868 |
+ "services": {"sv": {"notes": notes.strip()}}, |
|
1869 |
+ }, |
|
4434 | 1870 |
) |
4435 | 1871 |
) |
4436 |
- save_config_ = cli_helpers.save_config |
|
4437 |
- |
|
4438 |
- def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any: |
|
4439 |
- config_dir = cli_helpers.config_filename(subsystem=None) |
|
4440 |
- with contextlib.suppress(FileNotFoundError): |
|
4441 |
- shutil.rmtree(config_dir) |
|
4442 |
- config_dir.write_text("Obstruction!!\n") |
|
4443 |
- monkeypatch.setattr(cli_helpers, "save_config", save_config_) |
|
4444 |
- return save_config_(*args, **kwargs) |
|
4445 |
- |
|
4446 |
- monkeypatch.setattr( |
|
4447 |
- cli_helpers, "save_config", obstruct_config_saving |
|
1872 |
+ notes_backup_file = cli_helpers.config_filename( |
|
1873 |
+ subsystem="notes backup" |
|
1874 |
+ ) |
|
1875 |
+ notes_backup_file.write_text( |
|
1876 |
+ "These backup notes are left over from the previous session.", |
|
1877 |
+ encoding="UTF-8", |
|
4448 | 1878 |
) |
1879 |
+ monkeypatch.setattr(click, "edit", edit_funcs[edit_func_name]) |
|
4449 | 1880 |
result = runner.invoke( |
4450 | 1881 |
cli.derivepassphrase_vault, |
4451 |
- ["--config", "-p"], |
|
1882 |
+ [ |
|
1883 |
+ "--config", |
|
1884 |
+ "--notes", |
|
1885 |
+ "--modern-editor-interface" |
|
1886 |
+ if modern_editor_interface |
|
1887 |
+ else "--vault-legacy-editor-interface", |
|
1888 |
+ "--", |
|
1889 |
+ "sv", |
|
1890 |
+ ], |
|
4452 | 1891 |
catch_exceptions=False, |
4453 |
- input="abc\n", |
|
4454 |
- ) |
|
4455 |
- assert result.error_exit(error="Cannot store vault settings:"), ( |
|
4456 |
- "expected error exit and known error message" |
|
4457 |
- ) |
|
4458 |
- |
|
4459 |
- def test_230b_store_config_custom_error( |
|
4460 |
- self, |
|
4461 |
- ) -> None: |
|
4462 |
- """Storing the configuration reacts even to weird errors.""" |
|
4463 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
4464 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
4465 |
- # with-statements. |
|
4466 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
4467 |
- with contextlib.ExitStack() as stack: |
|
4468 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
4469 |
- stack.enter_context( |
|
4470 |
- pytest_machinery.isolated_config( |
|
4471 |
- monkeypatch=monkeypatch, |
|
4472 |
- runner=runner, |
|
4473 | 1892 |
) |
1893 |
+ assert result.clean_exit(empty_stderr=True) or result.error_exit( |
|
1894 |
+ error="the user aborted the request" |
|
1895 |
+ ), "expected clean exit" |
|
1896 |
+ assert ( |
|
1897 |
+ modern_editor_interface |
|
1898 |
+ or notes_backup_file.read_text(encoding="UTF-8") |
|
1899 |
+ == "These backup notes are left over from the previous session." |
|
4474 | 1900 |
) |
4475 |
- custom_error = "custom error message" |
|
4476 |
- |
|
4477 |
- def raiser(config: Any) -> None: |
|
4478 |
- del config |
|
4479 |
- raise RuntimeError(custom_error) |
|
1901 |
+ with cli_helpers.config_filename(subsystem="vault").open( |
|
1902 |
+ encoding="UTF-8" |
|
1903 |
+ ) as infile: |
|
1904 |
+ config = json.load(infile) |
|
1905 |
+ assert config == { |
|
1906 |
+ "global": {"phrase": "abc"}, |
|
1907 |
+ "services": {"sv": {"notes": notes.strip()}}, |
|
1908 |
+ } |
|
4480 | 1909 |
|
4481 |
- monkeypatch.setattr(cli_helpers, "save_config", raiser) |
|
4482 |
- result = runner.invoke( |
|
4483 |
- cli.derivepassphrase_vault, |
|
4484 |
- ["--config", "-p"], |
|
4485 |
- catch_exceptions=False, |
|
4486 |
- input="abc\n", |
|
1910 |
+ # TODO(the-13th-letter): Keep this behavior or not, with or without |
|
1911 |
+ # warning? |
|
1912 |
+ @Parametrize.MODERN_EDITOR_INTERFACE |
|
1913 |
+ @hypothesis.settings( |
|
1914 |
+ suppress_health_check=[ |
|
1915 |
+ *hypothesis.settings().suppress_health_check, |
|
1916 |
+ hypothesis.HealthCheck.function_scoped_fixture, |
|
1917 |
+ ], |
|
4487 | 1918 |
) |
4488 |
- assert result.error_exit(error=custom_error), ( |
|
4489 |
- "expected error exit and known error message" |
|
1919 |
+ @hypothesis.given( |
|
1920 |
+ notes=strategies.text( |
|
1921 |
+ strategies.characters( |
|
1922 |
+ min_codepoint=32, max_codepoint=126, include_characters="\n" |
|
1923 |
+ ), |
|
1924 |
+ min_size=1, |
|
1925 |
+ max_size=512, |
|
1926 |
+ ).filter(str.strip), |
|
4490 | 1927 |
) |
4491 |
- |
|
4492 |
- @Parametrize.UNICODE_NORMALIZATION_WARNING_INPUTS |
|
4493 |
- def test_300_unicode_normalization_form_warning( |
|
1928 |
+ def test_222_edit_notes_marker_removed( |
|
4494 | 1929 |
self, |
4495 | 1930 |
caplog: pytest.LogCaptureFixture, |
4496 |
- main_config: str, |
|
4497 |
- command_line: list[str], |
|
4498 |
- input: str | None, |
|
4499 |
- warning_message: str, |
|
1931 |
+ modern_editor_interface: bool, |
|
1932 |
+ notes: str, |
|
4500 | 1933 |
) -> None: |
4501 |
- """Using unnormalized Unicode passphrases warns.""" |
|
1934 |
+ """Removing the notes marker still saves the notes. |
|
1935 |
+ |
|
1936 |
+ TODO: Keep this behavior or not, with or without warning? |
|
1937 |
+ |
|
1938 |
+ """ |
|
1939 |
+ notes_marker = cli_messages.TranslatedString( |
|
1940 |
+ cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER |
|
1941 |
+ ) |
|
1942 |
+ hypothesis.assume(str(notes_marker) not in notes.strip()) |
|
1943 |
+ # Reset caplog between hypothesis runs. |
|
1944 |
+ caplog.clear() |
|
4502 | 1945 |
runner = machinery.CliRunner(mix_stderr=False) |
4503 | 1946 |
# TODO(the-13th-letter): Rewrite using parenthesized |
4504 | 1947 |
# with-statements. |
... | ... |
@@ -4510,33 +1953,72 @@ class TestCLI: |
4510 | 1953 |
monkeypatch=monkeypatch, |
4511 | 1954 |
runner=runner, |
4512 | 1955 |
vault_config={ |
4513 |
- "services": { |
|
4514 |
- DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy() |
|
4515 |
- } |
|
1956 |
+ "global": {"phrase": "abc"}, |
|
1957 |
+ "services": {"sv": {"notes": "Contents go here"}}, |
|
4516 | 1958 |
}, |
4517 |
- main_config_str=main_config, |
|
4518 | 1959 |
) |
4519 | 1960 |
) |
1961 |
+ notes_backup_file = cli_helpers.config_filename( |
|
1962 |
+ subsystem="notes backup" |
|
1963 |
+ ) |
|
1964 |
+ notes_backup_file.write_text( |
|
1965 |
+ "These backup notes are left over from the previous session.", |
|
1966 |
+ encoding="UTF-8", |
|
1967 |
+ ) |
|
1968 |
+ monkeypatch.setattr(click, "edit", lambda *_a, **_kw: notes) |
|
4520 | 1969 |
result = runner.invoke( |
4521 | 1970 |
cli.derivepassphrase_vault, |
4522 |
- ["--debug", *command_line], |
|
1971 |
+ [ |
|
1972 |
+ "--config", |
|
1973 |
+ "--notes", |
|
1974 |
+ "--modern-editor-interface" |
|
1975 |
+ if modern_editor_interface |
|
1976 |
+ else "--vault-legacy-editor-interface", |
|
1977 |
+ "--", |
|
1978 |
+ "sv", |
|
1979 |
+ ], |
|
4523 | 1980 |
catch_exceptions=False, |
4524 |
- input=input, |
|
4525 | 1981 |
) |
4526 | 1982 |
assert result.clean_exit(), "expected clean exit" |
4527 |
- assert machinery.warning_emitted( |
|
4528 |
- warning_message, caplog.record_tuples |
|
1983 |
+ assert not result.stderr or all( |
|
1984 |
+ map(is_warning_line, result.stderr.splitlines(True)) |
|
1985 |
+ ) |
|
1986 |
+ assert not caplog.record_tuples or machinery.warning_emitted( |
|
1987 |
+ "A backup copy of the old notes was saved", |
|
1988 |
+ caplog.record_tuples, |
|
4529 | 1989 |
), "expected known warning message in stderr" |
1990 |
+ assert ( |
|
1991 |
+ modern_editor_interface |
|
1992 |
+ or notes_backup_file.read_text(encoding="UTF-8") |
|
1993 |
+ == "Contents go here" |
|
1994 |
+ ) |
|
1995 |
+ with cli_helpers.config_filename(subsystem="vault").open( |
|
1996 |
+ encoding="UTF-8" |
|
1997 |
+ ) as infile: |
|
1998 |
+ config = json.load(infile) |
|
1999 |
+ assert config == { |
|
2000 |
+ "global": {"phrase": "abc"}, |
|
2001 |
+ "services": {"sv": {"notes": notes.strip()}}, |
|
2002 |
+ } |
|
4530 | 2003 |
|
4531 |
- @Parametrize.UNICODE_NORMALIZATION_ERROR_INPUTS |
|
4532 |
- def test_301_unicode_normalization_form_error( |
|
2004 |
+ @hypothesis.given( |
|
2005 |
+ notes=strategies.text( |
|
2006 |
+ strategies.characters( |
|
2007 |
+ min_codepoint=32, max_codepoint=126, include_characters="\n" |
|
2008 |
+ ), |
|
2009 |
+ min_size=1, |
|
2010 |
+ max_size=512, |
|
2011 |
+ ).filter(str.strip), |
|
2012 |
+ ) |
|
2013 |
+ def test_223_edit_notes_abort( |
|
4533 | 2014 |
self, |
4534 |
- main_config: str, |
|
4535 |
- command_line: list[str], |
|
4536 |
- input: str | None, |
|
4537 |
- error_message: str, |
|
2015 |
+ notes: str, |
|
4538 | 2016 |
) -> None: |
4539 |
- """Using unknown Unicode normalization forms fails.""" |
|
2017 |
+ """Aborting editing notes works. |
|
2018 |
+ |
|
2019 |
+ Aborting is only supported with the modern editor interface. |
|
2020 |
+ |
|
2021 |
+ """ |
|
4540 | 2022 |
runner = machinery.CliRunner(mix_stderr=False) |
4541 | 2023 |
# TODO(the-13th-letter): Rewrite using parenthesized |
4542 | 2024 |
# with-statements. |
... | ... |
@@ -4548,32 +2030,43 @@ class TestCLI: |
4548 | 2030 |
monkeypatch=monkeypatch, |
4549 | 2031 |
runner=runner, |
4550 | 2032 |
vault_config={ |
4551 |
- "services": { |
|
4552 |
- DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy() |
|
4553 |
- } |
|
2033 |
+ "global": {"phrase": "abc"}, |
|
2034 |
+ "services": {"sv": {"notes": notes.strip()}}, |
|
4554 | 2035 |
}, |
4555 |
- main_config_str=main_config, |
|
4556 | 2036 |
) |
4557 | 2037 |
) |
2038 |
+ monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "") |
|
4558 | 2039 |
result = runner.invoke( |
4559 | 2040 |
cli.derivepassphrase_vault, |
4560 |
- command_line, |
|
2041 |
+ [ |
|
2042 |
+ "--config", |
|
2043 |
+ "--notes", |
|
2044 |
+ "--modern-editor-interface", |
|
2045 |
+ "--", |
|
2046 |
+ "sv", |
|
2047 |
+ ], |
|
4561 | 2048 |
catch_exceptions=False, |
4562 |
- input=input, |
|
4563 | 2049 |
) |
4564 |
- assert result.error_exit( |
|
4565 |
- error="The user configuration file is invalid." |
|
4566 |
- ), "expected error exit and known error message" |
|
4567 |
- assert result.error_exit(error=error_message), ( |
|
4568 |
- "expected error exit and known error message" |
|
2050 |
+ assert result.error_exit(error="the user aborted the request"), ( |
|
2051 |
+ "expected known error message" |
|
4569 | 2052 |
) |
2053 |
+ with cli_helpers.config_filename(subsystem="vault").open( |
|
2054 |
+ encoding="UTF-8" |
|
2055 |
+ ) as infile: |
|
2056 |
+ config = json.load(infile) |
|
2057 |
+ assert config == { |
|
2058 |
+ "global": {"phrase": "abc"}, |
|
2059 |
+ "services": {"sv": {"notes": notes.strip()}}, |
|
2060 |
+ } |
|
4570 | 2061 |
|
4571 |
- @Parametrize.UNICODE_NORMALIZATION_COMMAND_LINES |
|
4572 |
- def test_301a_unicode_normalization_form_error_from_stored_config( |
|
2062 |
+ def test_223a_edit_empty_notes_abort( |
|
4573 | 2063 |
self, |
4574 |
- command_line: list[str], |
|
4575 | 2064 |
) -> None: |
4576 |
- """Using unknown Unicode normalization forms in the config fails.""" |
|
2065 |
+ """Aborting editing notes works even if no notes are stored yet. |
|
2066 |
+ |
|
2067 |
+ Aborting is only supported with the modern editor interface. |
|
2068 |
+ |
|
2069 |
+ """ |
|
4577 | 2070 |
runner = machinery.CliRunner(mix_stderr=False) |
4578 | 2071 |
# TODO(the-13th-letter): Rewrite using parenthesized |
4579 | 2072 |
# with-statements. |
... | ... |
@@ -4585,35 +2078,66 @@ class TestCLI: |
4585 | 2078 |
monkeypatch=monkeypatch, |
4586 | 2079 |
runner=runner, |
4587 | 2080 |
vault_config={ |
4588 |
- "services": { |
|
4589 |
- DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy() |
|
4590 |
- } |
|
2081 |
+ "global": {"phrase": "abc"}, |
|
2082 |
+ "services": {}, |
|
4591 | 2083 |
}, |
4592 |
- main_config_str=( |
|
4593 |
- "[vault]\ndefault-unicode-normalization-form = 'XXX'\n" |
|
4594 |
- ), |
|
4595 | 2084 |
) |
4596 | 2085 |
) |
2086 |
+ monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "") |
|
4597 | 2087 |
result = runner.invoke( |
4598 | 2088 |
cli.derivepassphrase_vault, |
4599 |
- command_line, |
|
4600 |
- input=DUMMY_PASSPHRASE, |
|
2089 |
+ [ |
|
2090 |
+ "--config", |
|
2091 |
+ "--notes", |
|
2092 |
+ "--modern-editor-interface", |
|
2093 |
+ "--", |
|
2094 |
+ "sv", |
|
2095 |
+ ], |
|
4601 | 2096 |
catch_exceptions=False, |
4602 | 2097 |
) |
4603 |
- assert result.error_exit( |
|
4604 |
- error="The user configuration file is invalid." |
|
4605 |
- ), "expected error exit and known error message" |
|
4606 |
- assert result.error_exit( |
|
4607 |
- error=( |
|
4608 |
- "Invalid value 'XXX' for config key " |
|
4609 |
- "vault.default-unicode-normalization-form" |
|
4610 |
- ), |
|
4611 |
- ), "expected error exit and known error message" |
|
2098 |
+ assert result.error_exit(error="the user aborted the request"), ( |
|
2099 |
+ "expected known error message" |
|
2100 |
+ ) |
|
2101 |
+ with cli_helpers.config_filename(subsystem="vault").open( |
|
2102 |
+ encoding="UTF-8" |
|
2103 |
+ ) as infile: |
|
2104 |
+ config = json.load(infile) |
|
2105 |
+ assert config == { |
|
2106 |
+ "global": {"phrase": "abc"}, |
|
2107 |
+ "services": {}, |
|
2108 |
+ } |
|
4612 | 2109 |
|
4613 |
- def test_310_bad_user_config_file( |
|
2110 |
+ @Parametrize.MODERN_EDITOR_INTERFACE |
|
2111 |
+ @hypothesis.settings( |
|
2112 |
+ suppress_health_check=[ |
|
2113 |
+ *hypothesis.settings().suppress_health_check, |
|
2114 |
+ hypothesis.HealthCheck.function_scoped_fixture, |
|
2115 |
+ ], |
|
2116 |
+ ) |
|
2117 |
+ @hypothesis.given( |
|
2118 |
+ notes=strategies.text( |
|
2119 |
+ strategies.characters( |
|
2120 |
+ min_codepoint=32, max_codepoint=126, include_characters="\n" |
|
2121 |
+ ), |
|
2122 |
+ max_size=512, |
|
2123 |
+ ), |
|
2124 |
+ ) |
|
2125 |
+ def test_223b_edit_notes_fail_config_option_missing( |
|
4614 | 2126 |
self, |
2127 |
+ caplog: pytest.LogCaptureFixture, |
|
2128 |
+ modern_editor_interface: bool, |
|
2129 |
+ notes: str, |
|
4615 | 2130 |
) -> None: |
4616 |
- """Loading a user configuration file in an invalid format fails.""" |
|
2131 |
+ """Editing notes fails (and warns) if `--config` is missing.""" |
|
2132 |
+ maybe_notes = {"notes": notes.strip()} if notes.strip() else {} |
|
2133 |
+ vault_config = { |
|
2134 |
+ "global": {"phrase": DUMMY_PASSPHRASE}, |
|
2135 |
+ "services": { |
|
2136 |
+ DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS} |
|
2137 |
+ }, |
|
2138 |
+ } |
|
2139 |
+ # Reset caplog between hypothesis runs. |
|
2140 |
+ caplog.clear() |
|
4617 | 2141 |
runner = machinery.CliRunner(mix_stderr=False) |
4618 | 2142 |
# TODO(the-13th-letter): Rewrite using parenthesized |
4619 | 2143 |
# with-statements. |
... | ... |
@@ -4624,24 +2148,73 @@ class TestCLI: |
4624 | 2148 |
pytest_machinery.isolated_vault_config( |
4625 | 2149 |
monkeypatch=monkeypatch, |
4626 | 2150 |
runner=runner, |
4627 |
- vault_config={"services": {}}, |
|
4628 |
- main_config_str="This file is not valid TOML.\n", |
|
2151 |
+ vault_config=vault_config, |
|
2152 |
+ ) |
|
2153 |
+ ) |
|
2154 |
+ EDIT_ATTEMPTED = "edit attempted!" # noqa: N806 |
|
2155 |
+ |
|
2156 |
+ def raiser(*_args: Any, **_kwargs: Any) -> NoReturn: |
|
2157 |
+ pytest.fail(EDIT_ATTEMPTED) |
|
2158 |
+ |
|
2159 |
+ notes_backup_file = cli_helpers.config_filename( |
|
2160 |
+ subsystem="notes backup" |
|
4629 | 2161 |
) |
2162 |
+ notes_backup_file.write_text( |
|
2163 |
+ "These backup notes are left over from the previous session.", |
|
2164 |
+ encoding="UTF-8", |
|
4630 | 2165 |
) |
2166 |
+ monkeypatch.setattr(click, "edit", raiser) |
|
4631 | 2167 |
result = runner.invoke( |
4632 | 2168 |
cli.derivepassphrase_vault, |
4633 |
- ["--phrase", "--", DUMMY_SERVICE], |
|
4634 |
- input=DUMMY_PASSPHRASE, |
|
2169 |
+ [ |
|
2170 |
+ "--notes", |
|
2171 |
+ "--modern-editor-interface" |
|
2172 |
+ if modern_editor_interface |
|
2173 |
+ else "--vault-legacy-editor-interface", |
|
2174 |
+ "--", |
|
2175 |
+ DUMMY_SERVICE, |
|
2176 |
+ ], |
|
4635 | 2177 |
catch_exceptions=False, |
4636 | 2178 |
) |
4637 |
- assert result.error_exit(error="Cannot load user config:"), ( |
|
4638 |
- "expected error exit and known error message" |
|
2179 |
+ assert result.clean_exit( |
|
2180 |
+ output=DUMMY_RESULT_PASSPHRASE.decode("ascii") |
|
2181 |
+ ), "expected clean exit" |
|
2182 |
+ assert result.stderr |
|
2183 |
+ assert notes.strip() in result.stderr |
|
2184 |
+ assert all( |
|
2185 |
+ is_warning_line(line) |
|
2186 |
+ for line in result.stderr.splitlines(True) |
|
2187 |
+ if line.startswith(f"{cli.PROG_NAME}: ") |
|
2188 |
+ ) |
|
2189 |
+ assert machinery.warning_emitted( |
|
2190 |
+ "Specifying --notes without --config is ineffective. " |
|
2191 |
+ "No notes will be edited.", |
|
2192 |
+ caplog.record_tuples, |
|
2193 |
+ ), "expected known warning message in stderr" |
|
2194 |
+ assert ( |
|
2195 |
+ modern_editor_interface |
|
2196 |
+ or notes_backup_file.read_text(encoding="UTF-8") |
|
2197 |
+ == "These backup notes are left over from the previous session." |
|
4639 | 2198 |
) |
2199 |
+ with cli_helpers.config_filename(subsystem="vault").open( |
|
2200 |
+ encoding="UTF-8" |
|
2201 |
+ ) as infile: |
|
2202 |
+ config = json.load(infile) |
|
2203 |
+ assert config == vault_config |
|
4640 | 2204 |
|
4641 |
- def test_311_bad_user_config_is_a_directory( |
|
2205 |
+ @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG |
|
2206 |
+ def test_224_store_config_good( |
|
4642 | 2207 |
self, |
2208 |
+ command_line: list[str], |
|
2209 |
+ input: str, |
|
2210 |
+ result_config: Any, |
|
4643 | 2211 |
) -> None: |
4644 |
- """Loading a user configuration file in an invalid format fails.""" |
|
2212 |
+ """Storing valid settings via `--config` works. |
|
2213 |
+ |
|
2214 |
+ The format also contains embedded newlines and indentation to make |
|
2215 |
+ the config more readable. |
|
2216 |
+ |
|
2217 |
+ """ |
|
4645 | 2218 |
runner = machinery.CliRunner(mix_stderr=False) |
4646 | 2219 |
# TODO(the-13th-letter): Rewrite using parenthesized |
4647 | 2220 |
# with-statements. |
... | ... |
@@ -4652,30 +2225,38 @@ class TestCLI: |
4652 | 2225 |
pytest_machinery.isolated_vault_config( |
4653 | 2226 |
monkeypatch=monkeypatch, |
4654 | 2227 |
runner=runner, |
4655 |
- vault_config={"services": {}}, |
|
4656 |
- main_config_str="", |
|
2228 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
4657 | 2229 |
) |
4658 | 2230 |
) |
4659 |
- user_config = cli_helpers.config_filename( |
|
4660 |
- subsystem="user configuration" |
|
2231 |
+ monkeypatch.setattr( |
|
2232 |
+ cli_helpers, |
|
2233 |
+ "get_suitable_ssh_keys", |
|
2234 |
+ callables.suitable_ssh_keys, |
|
4661 | 2235 |
) |
4662 |
- user_config.unlink() |
|
4663 |
- user_config.mkdir(parents=True, exist_ok=True) |
|
4664 | 2236 |
result = runner.invoke( |
4665 | 2237 |
cli.derivepassphrase_vault, |
4666 |
- ["--phrase", "--", DUMMY_SERVICE], |
|
4667 |
- input=DUMMY_PASSPHRASE, |
|
2238 |
+ ["--config", *command_line], |
|
4668 | 2239 |
catch_exceptions=False, |
2240 |
+ input=input, |
|
4669 | 2241 |
) |
4670 |
- assert result.error_exit(error="Cannot load user config:"), ( |
|
4671 |
- "expected error exit and known error message" |
|
2242 |
+ assert result.clean_exit(), "expected clean exit" |
|
2243 |
+ config_txt = cli_helpers.config_filename( |
|
2244 |
+ subsystem="vault" |
|
2245 |
+ ).read_text(encoding="UTF-8") |
|
2246 |
+ config = json.loads(config_txt) |
|
2247 |
+ assert config == result_config, ( |
|
2248 |
+ "stored config does not match expectation" |
|
4672 | 2249 |
) |
2250 |
+ assert_vault_config_is_indented_and_line_broken(config_txt) |
|
4673 | 2251 |
|
4674 |
- def test_400_missing_af_unix_support( |
|
2252 |
+ @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES |
|
2253 |
+ def test_225_store_config_fail( |
|
4675 | 2254 |
self, |
4676 |
- caplog: pytest.LogCaptureFixture, |
|
2255 |
+ command_line: list[str], |
|
2256 |
+ input: str, |
|
2257 |
+ err_text: str, |
|
4677 | 2258 |
) -> None: |
4678 |
- """Querying the SSH agent without `AF_UNIX` support fails.""" |
|
2259 |
+ """Storing invalid settings via `--config` fails.""" |
|
4679 | 2260 |
runner = machinery.CliRunner(mix_stderr=False) |
4680 | 2261 |
# TODO(the-13th-letter): Rewrite using parenthesized |
4681 | 2262 |
# with-statements. |
... | ... |
@@ -4689,58 +2270,27 @@ class TestCLI: |
4689 | 2270 |
vault_config={"global": {"phrase": "abc"}, "services": {}}, |
4690 | 2271 |
) |
4691 | 2272 |
) |
4692 |
- monkeypatch.setenv( |
|
4693 |
- "SSH_AUTH_SOCK", "the value doesn't even matter" |
|
4694 |
- ) |
|
4695 | 2273 |
monkeypatch.setattr( |
4696 |
- ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", ["posix"] |
|
2274 |
+ cli_helpers, |
|
2275 |
+ "get_suitable_ssh_keys", |
|
2276 |
+ callables.suitable_ssh_keys, |
|
4697 | 2277 |
) |
4698 |
- monkeypatch.delattr(socket, "AF_UNIX", raising=False) |
|
4699 | 2278 |
result = runner.invoke( |
4700 | 2279 |
cli.derivepassphrase_vault, |
4701 |
- ["--key", "--config"], |
|
2280 |
+ ["--config", *command_line], |
|
4702 | 2281 |
catch_exceptions=False, |
2282 |
+ input=input, |
|
4703 | 2283 |
) |
4704 |
- assert result.error_exit( |
|
4705 |
- error="does not support communicating with it" |
|
4706 |
- ), "expected error exit and known error message" |
|
4707 |
- assert machinery.warning_emitted( |
|
4708 |
- "Cannot connect to an SSH agent via UNIX domain sockets", |
|
4709 |
- caplog.record_tuples, |
|
4710 |
- ), "expected known warning message in stderr" |
|
4711 |
- |
|
4712 |
- |
|
4713 |
-class TestCLIUtils: |
|
4714 |
- """Tests for command-line utility functions.""" |
|
4715 |
- |
|
4716 |
- @Parametrize.BASE_CONFIG_VARIATIONS |
|
4717 |
- def test_100_load_config( |
|
4718 |
- self, |
|
4719 |
- config: Any, |
|
4720 |
- ) -> None: |
|
4721 |
- """[`cli_helpers.load_config`][] works for valid configurations.""" |
|
4722 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
4723 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
4724 |
- # with-statements. |
|
4725 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
4726 |
- with contextlib.ExitStack() as stack: |
|
4727 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
4728 |
- stack.enter_context( |
|
4729 |
- pytest_machinery.isolated_vault_config( |
|
4730 |
- monkeypatch=monkeypatch, |
|
4731 |
- runner=runner, |
|
4732 |
- vault_config=config, |
|
4733 |
- ) |
|
2284 |
+ assert result.error_exit(error=err_text), ( |
|
2285 |
+ "expected error exit and known error message" |
|
4734 | 2286 |
) |
4735 |
- config_filename = cli_helpers.config_filename(subsystem="vault") |
|
4736 |
- with config_filename.open(encoding="UTF-8") as fileobj: |
|
4737 |
- assert json.load(fileobj) == config |
|
4738 |
- assert cli_helpers.load_config() == config |
|
4739 | 2287 |
|
4740 |
- def test_110_save_bad_config( |
|
2288 |
+ def test_225a_store_config_fail_manual_no_ssh_key_selection( |
|
4741 | 2289 |
self, |
2290 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
4742 | 2291 |
) -> None: |
4743 |
- """[`cli_helpers.save_config`][] fails for bad configurations.""" |
|
2292 |
+ """Not selecting an SSH key during `--config --key` fails.""" |
|
2293 |
+ del running_ssh_agent |
|
4744 | 2294 |
runner = machinery.CliRunner(mix_stderr=False) |
4745 | 2295 |
# TODO(the-13th-letter): Rewrite using parenthesized |
4746 | 2296 |
# with-statements. |
... | ... |
@@ -4751,358 +2301,38 @@ class TestCLIUtils: |
4751 | 2301 |
pytest_machinery.isolated_vault_config( |
4752 | 2302 |
monkeypatch=monkeypatch, |
4753 | 2303 |
runner=runner, |
4754 |
- vault_config={}, |
|
2304 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
4755 | 2305 |
) |
4756 | 2306 |
) |
4757 |
- stack.enter_context( |
|
4758 |
- pytest.raises(ValueError, match="Invalid vault config") |
|
4759 |
- ) |
|
4760 |
- cli_helpers.save_config(None) # type: ignore[arg-type] |
|
4761 |
- |
|
4762 |
- def test_111_prompt_for_selection_multiple(self) -> None: |
|
4763 |
- """[`cli_helpers.prompt_for_selection`][] works in the "multiple" case.""" |
|
4764 |
- |
|
4765 |
- @click.command() |
|
4766 |
- @click.option("--heading", default="Our menu:") |
|
4767 |
- @click.argument("items", nargs=-1) |
|
4768 |
- def driver(heading: str, items: list[str]) -> None: |
|
4769 |
- # from https://montypython.fandom.com/wiki/Spam#The_menu |
|
4770 |
- items = items or [ |
|
4771 |
- "Egg and bacon", |
|
4772 |
- "Egg, sausage and bacon", |
|
4773 |
- "Egg and spam", |
|
4774 |
- "Egg, bacon and spam", |
|
4775 |
- "Egg, bacon, sausage and spam", |
|
4776 |
- "Spam, bacon, sausage and spam", |
|
4777 |
- "Spam, egg, spam, spam, bacon and spam", |
|
4778 |
- "Spam, spam, spam, egg and spam", |
|
4779 |
- ( |
|
4780 |
- "Spam, spam, spam, spam, spam, spam, baked beans, " |
|
4781 |
- "spam, spam, spam and spam" |
|
4782 |
- ), |
|
4783 |
- ( |
|
4784 |
- "Lobster thermidor aux crevettes with a mornay sauce " |
|
4785 |
- "garnished with truffle paté, brandy " |
|
4786 |
- "and a fried egg on top and spam" |
|
4787 |
- ), |
|
4788 |
- ] |
|
4789 |
- index = cli_helpers.prompt_for_selection(items, heading=heading) |
|
4790 |
- click.echo("A fine choice: ", nl=False) |
|
4791 |
- click.echo(items[index]) |
|
4792 |
- click.echo("(Note: Vikings strictly optional.)") |
|
4793 | 2307 |
|
4794 |
- runner = machinery.CliRunner(mix_stderr=True) |
|
4795 |
- result = runner.invoke(driver, [], input="9") |
|
4796 |
- assert result.clean_exit( |
|
4797 |
- output="""\ |
|
4798 |
-Our menu: |
|
4799 |
-[1] Egg and bacon |
|
4800 |
-[2] Egg, sausage and bacon |
|
4801 |
-[3] Egg and spam |
|
4802 |
-[4] Egg, bacon and spam |
|
4803 |
-[5] Egg, bacon, sausage and spam |
|
4804 |
-[6] Spam, bacon, sausage and spam |
|
4805 |
-[7] Spam, egg, spam, spam, bacon and spam |
|
4806 |
-[8] Spam, spam, spam, egg and spam |
|
4807 |
-[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam |
|
4808 |
-[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam |
|
4809 |
-Your selection? (1-10, leave empty to abort): 9 |
|
4810 |
-A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam |
|
4811 |
-(Note: Vikings strictly optional.) |
|
4812 |
-""" |
|
4813 |
- ), "expected clean exit" |
|
4814 |
- result = runner.invoke( |
|
4815 |
- driver, ["--heading="], input="\n", catch_exceptions=True |
|
4816 |
- ) |
|
4817 |
- assert result.error_exit(error=IndexError), ( |
|
4818 |
- "expected error exit and known error type" |
|
4819 |
- ) |
|
4820 |
- assert ( |
|
4821 |
- result.stdout |
|
4822 |
- == """\ |
|
4823 |
-[1] Egg and bacon |
|
4824 |
-[2] Egg, sausage and bacon |
|
4825 |
-[3] Egg and spam |
|
4826 |
-[4] Egg, bacon and spam |
|
4827 |
-[5] Egg, bacon, sausage and spam |
|
4828 |
-[6] Spam, bacon, sausage and spam |
|
4829 |
-[7] Spam, egg, spam, spam, bacon and spam |
|
4830 |
-[8] Spam, spam, spam, egg and spam |
|
4831 |
-[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam |
|
4832 |
-[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam |
|
4833 |
-Your selection? (1-10, leave empty to abort):\x20 |
|
4834 |
-""" |
|
4835 |
- ), "expected known output" |
|
4836 |
- # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the |
|
4837 |
- # click prompting machinery, meaning that the mixed output will |
|
4838 |
- # incorrectly contain a line break, contrary to what the |
|
4839 |
- # documentation for click.prompt prescribes. |
|
4840 |
- result = runner.invoke( |
|
4841 |
- driver, ["--heading="], input="", catch_exceptions=True |
|
4842 |
- ) |
|
4843 |
- assert result.error_exit(error=IndexError), ( |
|
4844 |
- "expected error exit and known error type" |
|
4845 |
- ) |
|
4846 |
- assert result.stdout in { |
|
4847 |
- """\ |
|
4848 |
-[1] Egg and bacon |
|
4849 |
-[2] Egg, sausage and bacon |
|
4850 |
-[3] Egg and spam |
|
4851 |
-[4] Egg, bacon and spam |
|
4852 |
-[5] Egg, bacon, sausage and spam |
|
4853 |
-[6] Spam, bacon, sausage and spam |
|
4854 |
-[7] Spam, egg, spam, spam, bacon and spam |
|
4855 |
-[8] Spam, spam, spam, egg and spam |
|
4856 |
-[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam |
|
4857 |
-[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam |
|
4858 |
-Your selection? (1-10, leave empty to abort):\x20 |
|
4859 |
-""", |
|
4860 |
- """\ |
|
4861 |
-[1] Egg and bacon |
|
4862 |
-[2] Egg, sausage and bacon |
|
4863 |
-[3] Egg and spam |
|
4864 |
-[4] Egg, bacon and spam |
|
4865 |
-[5] Egg, bacon, sausage and spam |
|
4866 |
-[6] Spam, bacon, sausage and spam |
|
4867 |
-[7] Spam, egg, spam, spam, bacon and spam |
|
4868 |
-[8] Spam, spam, spam, egg and spam |
|
4869 |
-[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam |
|
4870 |
-[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam |
|
4871 |
-Your selection? (1-10, leave empty to abort): """, |
|
4872 |
- }, "expected known output" |
|
4873 |
- |
|
4874 |
- def test_112_prompt_for_selection_single(self) -> None: |
|
4875 |
- """[`cli_helpers.prompt_for_selection`][] works in the "single" case.""" |
|
4876 |
- |
|
4877 |
- @click.command() |
|
4878 |
- @click.option("--item", default="baked beans") |
|
4879 |
- @click.argument("prompt") |
|
4880 |
- def driver(item: str, prompt: str) -> None: |
|
4881 |
- try: |
|
4882 |
- cli_helpers.prompt_for_selection( |
|
4883 |
- [item], heading="", single_choice_prompt=prompt |
|
4884 |
- ) |
|
4885 |
- except IndexError: |
|
4886 |
- click.echo("Boo.") |
|
4887 |
- raise |
|
4888 |
- else: |
|
4889 |
- click.echo("Great!") |
|
2308 |
+ def prompt_for_selection(*_args: Any, **_kwargs: Any) -> NoReturn: |
|
2309 |
+ raise IndexError(cli_helpers.EMPTY_SELECTION) |
|
4890 | 2310 |
|
4891 |
- runner = machinery.CliRunner(mix_stderr=True) |
|
4892 |
- result = runner.invoke( |
|
4893 |
- driver, ["Will replace with spam. Confirm, y/n?"], input="y" |
|
4894 |
- ) |
|
4895 |
- assert result.clean_exit( |
|
4896 |
- output="""\ |
|
4897 |
-[1] baked beans |
|
4898 |
-Will replace with spam. Confirm, y/n? y |
|
4899 |
-Great! |
|
4900 |
-""" |
|
4901 |
- ), "expected clean exit" |
|
4902 |
- result = runner.invoke( |
|
4903 |
- driver, |
|
4904 |
- ['Will replace with spam, okay? (Please say "y" or "n".)'], |
|
4905 |
- input="\n", |
|
4906 |
- ) |
|
4907 |
- assert result.error_exit(error=IndexError), ( |
|
4908 |
- "expected error exit and known error type" |
|
4909 |
- ) |
|
4910 |
- assert ( |
|
4911 |
- result.stdout |
|
4912 |
- == """\ |
|
4913 |
-[1] baked beans |
|
4914 |
-Will replace with spam, okay? (Please say "y" or "n".):\x20 |
|
4915 |
-Boo. |
|
4916 |
-""" |
|
4917 |
- ), "expected known output" |
|
4918 |
- # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the |
|
4919 |
- # click prompting machinery, meaning that the mixed output will |
|
4920 |
- # incorrectly contain a line break, contrary to what the |
|
4921 |
- # documentation for click.prompt prescribes. |
|
4922 |
- result = runner.invoke( |
|
4923 |
- driver, |
|
4924 |
- ['Will replace with spam, okay? (Please say "y" or "n".)'], |
|
4925 |
- input="", |
|
4926 |
- ) |
|
4927 |
- assert result.error_exit(error=IndexError), ( |
|
4928 |
- "expected error exit and known error type" |
|
4929 |
- ) |
|
4930 |
- assert result.stdout in { |
|
4931 |
- """\ |
|
4932 |
-[1] baked beans |
|
4933 |
-Will replace with spam, okay? (Please say "y" or "n".):\x20 |
|
4934 |
-Boo. |
|
4935 |
-""", |
|
4936 |
- """\ |
|
4937 |
-[1] baked beans |
|
4938 |
-Will replace with spam, okay? (Please say "y" or "n".): Boo. |
|
4939 |
-""", |
|
4940 |
- }, "expected known output" |
|
4941 |
- |
|
4942 |
- def test_113_prompt_for_passphrase( |
|
4943 |
- self, |
|
4944 |
- ) -> None: |
|
4945 |
- """[`cli_helpers.prompt_for_passphrase`][] works.""" |
|
4946 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
4947 | 2311 |
monkeypatch.setattr( |
4948 |
- click, |
|
4949 |
- "prompt", |
|
4950 |
- lambda *a, **kw: json.dumps({"args": a, "kwargs": kw}), |
|
4951 |
- ) |
|
4952 |
- res = json.loads(cli_helpers.prompt_for_passphrase()) |
|
4953 |
- err_msg = "missing arguments to passphrase prompt" |
|
4954 |
- assert "args" in res, err_msg |
|
4955 |
- assert "kwargs" in res, err_msg |
|
4956 |
- assert res["args"][:1] == ["Passphrase"], err_msg |
|
4957 |
- assert res["kwargs"].get("default") == "", err_msg |
|
4958 |
- assert not res["kwargs"].get("show_default", True), err_msg |
|
4959 |
- assert res["kwargs"].get("err"), err_msg |
|
4960 |
- assert res["kwargs"].get("hide_input"), err_msg |
|
4961 |
- |
|
4962 |
- def test_120_standard_logging_context_manager( |
|
4963 |
- self, |
|
4964 |
- caplog: pytest.LogCaptureFixture, |
|
4965 |
- capsys: pytest.CaptureFixture[str], |
|
4966 |
- ) -> None: |
|
4967 |
- """The standard logging context manager works. |
|
4968 |
- |
|
4969 |
- It registers its handlers, once, and emits formatted calls to |
|
4970 |
- standard error prefixed with the program name. |
|
4971 |
- |
|
4972 |
- """ |
|
4973 |
- prog_name = cli_machinery.StandardCLILogging.prog_name |
|
4974 |
- package_name = cli_machinery.StandardCLILogging.package_name |
|
4975 |
- logger = logging.getLogger(package_name) |
|
4976 |
- deprecation_logger = logging.getLogger(f"{package_name}.deprecation") |
|
4977 |
- logging_cm = cli_machinery.StandardCLILogging.ensure_standard_logging() |
|
4978 |
- with logging_cm: |
|
4979 |
- assert ( |
|
4980 |
- sum( |
|
4981 |
- 1 |
|
4982 |
- for h in logger.handlers |
|
4983 |
- if h is cli_machinery.StandardCLILogging.cli_handler |
|
4984 |
- ) |
|
4985 |
- == 1 |
|
4986 |
- ) |
|
4987 |
- logger.warning("message 1") |
|
4988 |
- with logging_cm: |
|
4989 |
- deprecation_logger.warning("message 2") |
|
4990 |
- assert ( |
|
4991 |
- sum( |
|
4992 |
- 1 |
|
4993 |
- for h in logger.handlers |
|
4994 |
- if h is cli_machinery.StandardCLILogging.cli_handler |
|
4995 |
- ) |
|
4996 |
- == 1 |
|
4997 |
- ) |
|
4998 |
- assert capsys.readouterr() == ( |
|
4999 |
- "", |
|
5000 |
- ( |
|
5001 |
- f"{prog_name}: Warning: message 1\n" |
|
5002 |
- f"{prog_name}: Deprecation warning: message 2\n" |
|
5003 |
- ), |
|
5004 |
- ) |
|
5005 |
- logger.warning("message 3") |
|
5006 |
- assert ( |
|
5007 |
- sum( |
|
5008 |
- 1 |
|
5009 |
- for h in logger.handlers |
|
5010 |
- if h is cli_machinery.StandardCLILogging.cli_handler |
|
2312 |
+ cli_helpers, "prompt_for_selection", prompt_for_selection |
|
5011 | 2313 |
) |
5012 |
- == 1 |
|
2314 |
+ # Also patch the list of suitable SSH keys, lest we be at |
|
2315 |
+ # the mercy of whatever SSH agent may be running. |
|
2316 |
+ monkeypatch.setattr( |
|
2317 |
+ cli_helpers, |
|
2318 |
+ "get_suitable_ssh_keys", |
|
2319 |
+ callables.suitable_ssh_keys, |
|
5013 | 2320 |
) |
5014 |
- assert capsys.readouterr() == ( |
|
5015 |
- "", |
|
5016 |
- f"{prog_name}: Warning: message 3\n", |
|
2321 |
+ result = runner.invoke( |
|
2322 |
+ cli.derivepassphrase_vault, |
|
2323 |
+ ["--key", "--config"], |
|
2324 |
+ catch_exceptions=False, |
|
5017 | 2325 |
) |
5018 |
- assert caplog.record_tuples == [ |
|
5019 |
- (package_name, logging.WARNING, "message 1"), |
|
5020 |
- (f"{package_name}.deprecation", logging.WARNING, "message 2"), |
|
5021 |
- (package_name, logging.WARNING, "message 3"), |
|
5022 |
- ] |
|
5023 |
- |
|
5024 |
- def test_121_standard_logging_warnings_context_manager( |
|
5025 |
- self, |
|
5026 |
- caplog: pytest.LogCaptureFixture, |
|
5027 |
- capsys: pytest.CaptureFixture[str], |
|
5028 |
- ) -> None: |
|
5029 |
- """The standard warnings logging context manager works. |
|
5030 |
- |
|
5031 |
- It registers its handlers, once, and emits formatted calls to |
|
5032 |
- standard error prefixed with the program name. It also adheres |
|
5033 |
- to the global warnings filter concerning which messages it |
|
5034 |
- actually emits to standard error. |
|
5035 |
- |
|
5036 |
- """ |
|
5037 |
- warnings_cm = ( |
|
5038 |
- cli_machinery.StandardCLILogging.ensure_standard_warnings_logging() |
|
2326 |
+ assert result.error_exit(error="the user aborted the request"), ( |
|
2327 |
+ "expected error exit and known error message" |
|
5039 | 2328 |
) |
5040 |
- THE_FUTURE = "the future will be here sooner than you think" # noqa: N806 |
|
5041 |
- JUST_TESTING = "just testing whether warnings work" # noqa: N806 |
|
5042 |
- with warnings_cm: |
|
5043 |
- assert ( |
|
5044 |
- sum( |
|
5045 |
- 1 |
|
5046 |
- for h in logging.getLogger("py.warnings").handlers |
|
5047 |
- if h is cli_machinery.StandardCLILogging.warnings_handler |
|
5048 |
- ) |
|
5049 |
- == 1 |
|
5050 |
- ) |
|
5051 |
- warnings.warn(UserWarning(JUST_TESTING), stacklevel=1) |
|
5052 |
- with warnings_cm: |
|
5053 |
- warnings.warn(FutureWarning(THE_FUTURE), stacklevel=1) |
|
5054 |
- _out, err = capsys.readouterr() |
|
5055 |
- err_lines = err.splitlines(True) |
|
5056 |
- assert any( |
|
5057 |
- f"UserWarning: {JUST_TESTING}" in line |
|
5058 |
- for line in err_lines |
|
5059 |
- ) |
|
5060 |
- assert any( |
|
5061 |
- f"FutureWarning: {THE_FUTURE}" in line |
|
5062 |
- for line in err_lines |
|
5063 |
- ) |
|
5064 |
- warnings.warn(UserWarning(JUST_TESTING), stacklevel=1) |
|
5065 |
- _out, err = capsys.readouterr() |
|
5066 |
- err_lines = err.splitlines(True) |
|
5067 |
- assert any( |
|
5068 |
- f"UserWarning: {JUST_TESTING}" in line for line in err_lines |
|
5069 |
- ) |
|
5070 |
- assert not any( |
|
5071 |
- f"FutureWarning: {THE_FUTURE}" in line for line in err_lines |
|
5072 |
- ) |
|
5073 |
- record_tuples = caplog.record_tuples |
|
5074 |
- assert [tup[:2] for tup in record_tuples] == [ |
|
5075 |
- ("py.warnings", logging.WARNING), |
|
5076 |
- ("py.warnings", logging.WARNING), |
|
5077 |
- ("py.warnings", logging.WARNING), |
|
5078 |
- ] |
|
5079 |
- assert f"UserWarning: {JUST_TESTING}" in record_tuples[0][2] |
|
5080 |
- assert f"FutureWarning: {THE_FUTURE}" in record_tuples[1][2] |
|
5081 |
- assert f"UserWarning: {JUST_TESTING}" in record_tuples[2][2] |
|
5082 | 2329 |
|
5083 |
- def export_as_sh_helper( |
|
2330 |
+ def test_225b_store_config_fail_manual_no_ssh_agent( |
|
5084 | 2331 |
self, |
5085 |
- config: Any, |
|
2332 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
5086 | 2333 |
) -> None: |
5087 |
- """Emits a config in sh(1) format, then reads it back to verify it. |
|
5088 |
- |
|
5089 |
- This function exports the configuration, sets up a new |
|
5090 |
- enviroment, then calls |
|
5091 |
- [`vault_config_exporter_shell_interpreter`][] on the export |
|
5092 |
- script, verifying that each command ran successfully and that |
|
5093 |
- the final configuration matches the initial one. |
|
5094 |
- |
|
5095 |
- Args: |
|
5096 |
- config: |
|
5097 |
- The configuration to emit and read back. |
|
5098 |
- |
|
5099 |
- """ |
|
5100 |
- prog_name_list = ("derivepassphrase", "vault") |
|
5101 |
- with io.StringIO() as outfile: |
|
5102 |
- cli_helpers.print_config_as_sh_script( |
|
5103 |
- config, outfile=outfile, prog_name_list=prog_name_list |
|
5104 |
- ) |
|
5105 |
- script = outfile.getvalue() |
|
2334 |
+ """Not running an SSH agent during `--config --key` fails.""" |
|
2335 |
+ del running_ssh_agent |
|
5106 | 2336 |
runner = machinery.CliRunner(mix_stderr=False) |
5107 | 2337 |
# TODO(the-13th-letter): Rewrite using parenthesized |
5108 | 2338 |
# with-statements. |
... | ... |
@@ -5113,266 +2343,25 @@ Will replace with spam, okay? (Please say "y" or "n".): Boo. |
5113 | 2343 |
pytest_machinery.isolated_vault_config( |
5114 | 2344 |
monkeypatch=monkeypatch, |
5115 | 2345 |
runner=runner, |
5116 |
- vault_config={"services": {}}, |
|
5117 |
- ) |
|
5118 |
- ) |
|
5119 |
- for result in vault_config_exporter_shell_interpreter(script): |
|
5120 |
- assert result.clean_exit() |
|
5121 |
- assert cli_helpers.load_config() == config |
|
5122 |
- |
|
5123 |
- @hypothesis.given( |
|
5124 |
- global_config_settable=hypothesis_machinery.vault_full_service_config(), |
|
5125 |
- global_config_importable=strategies.fixed_dictionaries( |
|
5126 |
- {}, |
|
5127 |
- optional={ |
|
5128 |
- "key": strategies.text( |
|
5129 |
- alphabet=strategies.characters( |
|
5130 |
- min_codepoint=32, |
|
5131 |
- max_codepoint=126, |
|
5132 |
- ), |
|
5133 |
- max_size=128, |
|
5134 |
- ), |
|
5135 |
- "phrase": strategies.text( |
|
5136 |
- alphabet=strategies.characters( |
|
5137 |
- min_codepoint=32, |
|
5138 |
- max_codepoint=126, |
|
5139 |
- ), |
|
5140 |
- max_size=64, |
|
5141 |
- ), |
|
5142 |
- }, |
|
5143 |
- ), |
|
2346 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
5144 | 2347 |
) |
5145 |
- def test_130a_export_as_sh_global( |
|
5146 |
- self, |
|
5147 |
- global_config_settable: _types.VaultConfigServicesSettings, |
|
5148 |
- global_config_importable: _types.VaultConfigServicesSettings, |
|
5149 |
- ) -> None: |
|
5150 |
- """Exporting configurations as sh(1) script works. |
|
5151 |
- |
|
5152 |
- Here, we check global-only configurations which use both |
|
5153 |
- settings settable via `--config` and settings requiring |
|
5154 |
- `--import`. |
|
5155 |
- |
|
5156 |
- The actual verification is done by [`export_as_sh_helper`][]. |
|
5157 |
- |
|
5158 |
- """ |
|
5159 |
- config: _types.VaultConfig = { |
|
5160 |
- "global": global_config_settable | global_config_importable, |
|
5161 |
- "services": {}, |
|
5162 |
- } |
|
5163 |
- assert _types.clean_up_falsy_vault_config_values(config) is not None |
|
5164 |
- assert _types.is_vault_config(config) |
|
5165 |
- return self.export_as_sh_helper(config) |
|
5166 |
- |
|
5167 |
- @hypothesis.given( |
|
5168 |
- global_config_importable=strategies.fixed_dictionaries( |
|
5169 |
- {}, |
|
5170 |
- optional={ |
|
5171 |
- "key": strategies.text( |
|
5172 |
- alphabet=strategies.characters( |
|
5173 |
- min_codepoint=32, |
|
5174 |
- max_codepoint=126, |
|
5175 |
- ), |
|
5176 |
- max_size=128, |
|
5177 |
- ), |
|
5178 |
- "phrase": strategies.text( |
|
5179 |
- alphabet=strategies.characters( |
|
5180 |
- min_codepoint=32, |
|
5181 |
- max_codepoint=126, |
|
5182 |
- ), |
|
5183 |
- max_size=64, |
|
5184 |
- ), |
|
5185 |
- }, |
|
5186 |
- ), |
|
5187 | 2348 |
) |
5188 |
- def test_130b_export_as_sh_global_only_imports( |
|
5189 |
- self, |
|
5190 |
- global_config_importable: _types.VaultConfigServicesSettings, |
|
5191 |
- ) -> None: |
|
5192 |
- """Exporting configurations as sh(1) script works. |
|
5193 |
- |
|
5194 |
- Here, we check global-only configurations which only use |
|
5195 |
- settings requiring `--import`. |
|
5196 |
- |
|
5197 |
- The actual verification is done by [`export_as_sh_helper`][]. |
|
5198 |
- |
|
5199 |
- """ |
|
5200 |
- config: _types.VaultConfig = { |
|
5201 |
- "global": global_config_importable, |
|
5202 |
- "services": {}, |
|
5203 |
- } |
|
5204 |
- assert _types.clean_up_falsy_vault_config_values(config) is not None |
|
5205 |
- assert _types.is_vault_config(config) |
|
5206 |
- if not config["global"]: |
|
5207 |
- config.pop("global") |
|
5208 |
- return self.export_as_sh_helper(config) |
|
5209 |
- |
|
5210 |
- @hypothesis.given( |
|
5211 |
- service_name=strategies.text( |
|
5212 |
- alphabet=strategies.characters( |
|
5213 |
- min_codepoint=32, |
|
5214 |
- max_codepoint=126, |
|
5215 |
- ), |
|
5216 |
- min_size=4, |
|
5217 |
- max_size=64, |
|
5218 |
- ), |
|
5219 |
- service_config_settable=hypothesis_machinery.vault_full_service_config(), |
|
5220 |
- service_config_importable=strategies.fixed_dictionaries( |
|
5221 |
- {}, |
|
5222 |
- optional={ |
|
5223 |
- "key": strategies.text( |
|
5224 |
- alphabet=strategies.characters( |
|
5225 |
- min_codepoint=32, |
|
5226 |
- max_codepoint=126, |
|
5227 |
- ), |
|
5228 |
- max_size=128, |
|
5229 |
- ), |
|
5230 |
- "phrase": strategies.text( |
|
5231 |
- alphabet=strategies.characters( |
|
5232 |
- min_codepoint=32, |
|
5233 |
- max_codepoint=126, |
|
5234 |
- ), |
|
5235 |
- max_size=64, |
|
5236 |
- ), |
|
5237 |
- "notes": strategies.text( |
|
5238 |
- alphabet=strategies.characters( |
|
5239 |
- min_codepoint=32, |
|
5240 |
- max_codepoint=126, |
|
5241 |
- include_characters=("\n", "\f", "\t"), |
|
5242 |
- ), |
|
5243 |
- max_size=256, |
|
5244 |
- ), |
|
5245 |
- }, |
|
5246 |
- ), |
|
2349 |
+ monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) |
|
2350 |
+ result = runner.invoke( |
|
2351 |
+ cli.derivepassphrase_vault, |
|
2352 |
+ ["--key", "--config"], |
|
2353 |
+ catch_exceptions=False, |
|
5247 | 2354 |
) |
5248 |
- def test_130c_export_as_sh_service( |
|
5249 |
- self, |
|
5250 |
- service_name: str, |
|
5251 |
- service_config_settable: _types.VaultConfigServicesSettings, |
|
5252 |
- service_config_importable: _types.VaultConfigServicesSettings, |
|
5253 |
- ) -> None: |
|
5254 |
- """Exporting configurations as sh(1) script works. |
|
5255 |
- |
|
5256 |
- Here, we check service-only configurations which use both |
|
5257 |
- settings settable via `--config` and settings requiring |
|
5258 |
- `--import`. |
|
5259 |
- |
|
5260 |
- The actual verification is done by [`export_as_sh_helper`][]. |
|
5261 |
- |
|
5262 |
- """ |
|
5263 |
- config: _types.VaultConfig = { |
|
5264 |
- "services": { |
|
5265 |
- service_name: ( |
|
5266 |
- service_config_settable | service_config_importable |
|
5267 |
- ), |
|
5268 |
- }, |
|
5269 |
- } |
|
5270 |
- assert _types.clean_up_falsy_vault_config_values(config) is not None |
|
5271 |
- assert _types.is_vault_config(config) |
|
5272 |
- return self.export_as_sh_helper(config) |
|
5273 |
- |
|
5274 |
- @hypothesis.given( |
|
5275 |
- service_name=strategies.text( |
|
5276 |
- alphabet=strategies.characters( |
|
5277 |
- min_codepoint=32, |
|
5278 |
- max_codepoint=126, |
|
5279 |
- ), |
|
5280 |
- min_size=4, |
|
5281 |
- max_size=64, |
|
5282 |
- ), |
|
5283 |
- service_config_importable=strategies.fixed_dictionaries( |
|
5284 |
- {}, |
|
5285 |
- optional={ |
|
5286 |
- "key": strategies.text( |
|
5287 |
- alphabet=strategies.characters( |
|
5288 |
- min_codepoint=32, |
|
5289 |
- max_codepoint=126, |
|
5290 |
- ), |
|
5291 |
- max_size=128, |
|
5292 |
- ), |
|
5293 |
- "phrase": strategies.text( |
|
5294 |
- alphabet=strategies.characters( |
|
5295 |
- min_codepoint=32, |
|
5296 |
- max_codepoint=126, |
|
5297 |
- ), |
|
5298 |
- max_size=64, |
|
5299 |
- ), |
|
5300 |
- "notes": strategies.text( |
|
5301 |
- alphabet=strategies.characters( |
|
5302 |
- min_codepoint=32, |
|
5303 |
- max_codepoint=126, |
|
5304 |
- include_characters=("\n", "\f", "\t"), |
|
5305 |
- ), |
|
5306 |
- max_size=256, |
|
5307 |
- ), |
|
5308 |
- }, |
|
5309 |
- ), |
|
2355 |
+ assert result.error_exit(error="Cannot find any running SSH agent"), ( |
|
2356 |
+ "expected error exit and known error message" |
|
5310 | 2357 |
) |
5311 |
- def test_130d_export_as_sh_service_only_imports( |
|
5312 |
- self, |
|
5313 |
- service_name: str, |
|
5314 |
- service_config_importable: _types.VaultConfigServicesSettings, |
|
5315 |
- ) -> None: |
|
5316 |
- """Exporting configurations as sh(1) script works. |
|
5317 |
- |
|
5318 |
- Here, we check service-only configurations which only use |
|
5319 |
- settings requiring `--import`. |
|
5320 |
- |
|
5321 |
- The actual verification is done by [`export_as_sh_helper`][]. |
|
5322 |
- |
|
5323 |
- """ |
|
5324 |
- config: _types.VaultConfig = { |
|
5325 |
- "services": { |
|
5326 |
- service_name: service_config_importable, |
|
5327 |
- }, |
|
5328 |
- } |
|
5329 |
- assert _types.clean_up_falsy_vault_config_values(config) is not None |
|
5330 |
- assert _types.is_vault_config(config) |
|
5331 |
- return self.export_as_sh_helper(config) |
|
5332 | 2358 |
|
5333 |
- # The Annoying OS appears to silently truncate spaces at the end of |
|
5334 |
- # filenames. |
|
5335 |
- @hypothesis.given( |
|
5336 |
- env_var=strategies.sampled_from(["TMPDIR", "TEMP", "TMP"]), |
|
5337 |
- suffix=strategies.builds( |
|
5338 |
- operator.add, |
|
5339 |
- strategies.text( |
|
5340 |
- tuple(" 0123456789abcdefghijklmnopqrstuvwxyz"), |
|
5341 |
- min_size=11, |
|
5342 |
- max_size=11, |
|
5343 |
- ), |
|
5344 |
- strategies.text( |
|
5345 |
- tuple("0123456789abcdefghijklmnopqrstuvwxyz"), |
|
5346 |
- min_size=1, |
|
5347 |
- max_size=1, |
|
5348 |
- ), |
|
5349 |
- ), |
|
5350 |
- ) |
|
5351 |
- @hypothesis.example(env_var="", suffix=".") |
|
5352 |
- def test_140a_get_tempdir( |
|
2359 |
+ def test_225c_store_config_fail_manual_bad_ssh_agent_connection( |
|
5353 | 2360 |
self, |
5354 |
- env_var: str, |
|
5355 |
- suffix: str, |
|
2361 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
5356 | 2362 |
) -> None: |
5357 |
- """[`cli_helpers.get_tempdir`][] returns a temporary directory. |
|
5358 |
- |
|
5359 |
- If it is not the same as the temporary directory determined by |
|
5360 |
- [`tempfile.gettempdir`][], then assert that |
|
5361 |
- `tempfile.gettempdir` returned the current directory and |
|
5362 |
- `cli_helpers.get_tempdir` returned the configuration directory. |
|
5363 |
- |
|
5364 |
- """ |
|
5365 |
- |
|
5366 |
- @contextlib.contextmanager |
|
5367 |
- def make_temporary_directory( |
|
5368 |
- path: pathlib.Path, |
|
5369 |
- ) -> Iterator[pathlib.Path]: |
|
5370 |
- try: |
|
5371 |
- path.mkdir() |
|
5372 |
- yield path |
|
5373 |
- finally: |
|
5374 |
- shutil.rmtree(path) |
|
5375 |
- |
|
2363 |
+ """Not running a reachable SSH agent during `--config --key` fails.""" |
|
2364 |
+ running_ssh_agent.require_external_address() |
|
5376 | 2365 |
runner = machinery.CliRunner(mix_stderr=False) |
5377 | 2366 |
# TODO(the-13th-letter): Rewrite using parenthesized |
5378 | 2367 |
# with-statements. |
... | ... |
@@ -5383,43 +2372,26 @@ Will replace with spam, okay? (Please say "y" or "n".): Boo. |
5383 | 2372 |
pytest_machinery.isolated_vault_config( |
5384 | 2373 |
monkeypatch=monkeypatch, |
5385 | 2374 |
runner=runner, |
5386 |
- vault_config={"services": {}}, |
|
2375 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2376 |
+ ) |
|
2377 |
+ ) |
|
2378 |
+ cwd = pathlib.Path.cwd().resolve() |
|
2379 |
+ monkeypatch.setenv("SSH_AUTH_SOCK", str(cwd)) |
|
2380 |
+ result = runner.invoke( |
|
2381 |
+ cli.derivepassphrase_vault, |
|
2382 |
+ ["--key", "--config"], |
|
2383 |
+ catch_exceptions=False, |
|
5387 | 2384 |
) |
2385 |
+ assert result.error_exit(error="Cannot connect to the SSH agent"), ( |
|
2386 |
+ "expected error exit and known error message" |
|
5388 | 2387 |
) |
5389 |
- old_tempdir = os.fsdecode(tempfile.gettempdir()) |
|
5390 |
- monkeypatch.delenv("TMPDIR", raising=False) |
|
5391 |
- monkeypatch.delenv("TEMP", raising=False) |
|
5392 |
- monkeypatch.delenv("TMP", raising=False) |
|
5393 |
- monkeypatch.setattr(tempfile, "tempdir", None) |
|
5394 |
- temp_path = pathlib.Path.cwd() / suffix |
|
5395 |
- if env_var: |
|
5396 |
- monkeypatch.setenv(env_var, os.fsdecode(temp_path)) |
|
5397 |
- stack.enter_context(make_temporary_directory(temp_path)) |
|
5398 |
- new_tempdir = os.fsdecode(tempfile.gettempdir()) |
|
5399 |
- hypothesis.assume( |
|
5400 |
- temp_path.resolve() == pathlib.Path.cwd().resolve() |
|
5401 |
- or old_tempdir != new_tempdir |
|
5402 |
- ) |
|
5403 |
- system_tempdir = os.fsdecode(tempfile.gettempdir()) |
|
5404 |
- our_tempdir = cli_helpers.get_tempdir() |
|
5405 |
- assert system_tempdir == os.fsdecode(our_tempdir) or ( |
|
5406 |
- # TODO(the-13th-letter): `pytest_machinery.isolated_config` |
|
5407 |
- # guarantees that `Path.cwd() == config_filename(None)`. |
|
5408 |
- # So this sub-branch ought to never trigger in our |
|
5409 |
- # tests. |
|
5410 |
- system_tempdir == os.getcwd() # noqa: PTH109 |
|
5411 |
- and our_tempdir == cli_helpers.config_filename(subsystem=None) |
|
5412 |
- ) |
|
5413 |
- assert not temp_path.exists(), f"temp path {temp_path} not cleaned up!" |
|
5414 |
- |
|
5415 |
- def test_140b_get_tempdir_force_default(self) -> None: |
|
5416 |
- """[`cli_helpers.get_tempdir`][] returns a temporary directory. |
|
5417 |
- |
|
5418 |
- If all candidates are mocked to fail for the standard temporary |
|
5419 |
- directory choices, then we return the `derivepassphrase` |
|
5420 |
- configuration directory. |
|
5421 | 2388 |
|
5422 |
- """ |
|
2389 |
+ @Parametrize.TRY_RACE_FREE_IMPLEMENTATION |
|
2390 |
+ def test_225d_store_config_fail_manual_read_only_file( |
|
2391 |
+ self, |
|
2392 |
+ try_race_free_implementation: bool, |
|
2393 |
+ ) -> None: |
|
2394 |
+ """Using a read-only configuration file with `--config` fails.""" |
|
5423 | 2395 |
runner = machinery.CliRunner(mix_stderr=False) |
5424 | 2396 |
# TODO(the-13th-letter): Rewrite using parenthesized |
5425 | 2397 |
# with-statements. |
... | ... |
@@ -5430,51 +2402,26 @@ Will replace with spam, okay? (Please say "y" or "n".): Boo. |
5430 | 2402 |
pytest_machinery.isolated_vault_config( |
5431 | 2403 |
monkeypatch=monkeypatch, |
5432 | 2404 |
runner=runner, |
5433 |
- vault_config={"services": {}}, |
|
2405 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
5434 | 2406 |
) |
5435 | 2407 |
) |
5436 |
- monkeypatch.delenv("TMPDIR", raising=False) |
|
5437 |
- monkeypatch.delenv("TEMP", raising=False) |
|
5438 |
- monkeypatch.delenv("TMP", raising=False) |
|
5439 |
- config_dir = cli_helpers.config_filename(subsystem=None) |
|
5440 |
- |
|
5441 |
- def is_dir_false( |
|
5442 |
- self: pathlib.Path, |
|
5443 |
- /, |
|
5444 |
- *, |
|
5445 |
- follow_symlinks: bool = False, |
|
5446 |
- ) -> bool: |
|
5447 |
- del self, follow_symlinks |
|
5448 |
- return False |
|
5449 |
- |
|
5450 |
- def is_dir_error( |
|
5451 |
- self: pathlib.Path, |
|
5452 |
- /, |
|
5453 |
- *, |
|
5454 |
- follow_symlinks: bool = False, |
|
5455 |
- ) -> bool: |
|
5456 |
- del follow_symlinks |
|
5457 |
- raise OSError( |
|
5458 |
- errno.EACCES, |
|
5459 |
- os.strerror(errno.EACCES), |
|
5460 |
- str(self), |
|
2408 |
+ callables.make_file_readonly( |
|
2409 |
+ cli_helpers.config_filename(subsystem="vault"), |
|
2410 |
+ try_race_free_implementation=try_race_free_implementation, |
|
2411 |
+ ) |
|
2412 |
+ result = runner.invoke( |
|
2413 |
+ cli.derivepassphrase_vault, |
|
2414 |
+ ["--config", "--length=15", "--", DUMMY_SERVICE], |
|
2415 |
+ catch_exceptions=False, |
|
2416 |
+ ) |
|
2417 |
+ assert result.error_exit(error="Cannot store vault settings:"), ( |
|
2418 |
+ "expected error exit and known error message" |
|
5461 | 2419 |
) |
5462 | 2420 |
|
5463 |
- monkeypatch.setattr(pathlib.Path, "is_dir", is_dir_false) |
|
5464 |
- assert cli_helpers.get_tempdir() == config_dir |
|
5465 |
- |
|
5466 |
- monkeypatch.setattr(pathlib.Path, "is_dir", is_dir_error) |
|
5467 |
- assert cli_helpers.get_tempdir() == config_dir |
|
5468 |
- |
|
5469 |
- @Parametrize.DELETE_CONFIG_INPUT |
|
5470 |
- def test_203_repeated_config_deletion( |
|
2421 |
+ def test_225e_store_config_fail_manual_custom_error( |
|
5471 | 2422 |
self, |
5472 |
- command_line: list[str], |
|
5473 |
- config: _types.VaultConfig, |
|
5474 |
- result_config: _types.VaultConfig, |
|
5475 | 2423 |
) -> None: |
5476 |
- """Repeatedly removing the same parts of a configuration works.""" |
|
5477 |
- for start_config in [config, result_config]: |
|
2424 |
+ """OS-erroring with `--config` fails.""" |
|
5478 | 2425 |
runner = machinery.CliRunner(mix_stderr=False) |
5479 | 2426 |
# TODO(the-13th-letter): Rewrite using parenthesized |
5480 | 2427 |
# with-statements. |
... | ... |
@@ -5485,149 +2432,29 @@ Will replace with spam, okay? (Please say "y" or "n".): Boo. |
5485 | 2432 |
pytest_machinery.isolated_vault_config( |
5486 | 2433 |
monkeypatch=monkeypatch, |
5487 | 2434 |
runner=runner, |
5488 |
- vault_config=start_config, |
|
2435 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
5489 | 2436 |
) |
5490 | 2437 |
) |
2438 |
+ custom_error = "custom error message" |
|
2439 |
+ |
|
2440 |
+ def raiser(config: Any) -> None: |
|
2441 |
+ del config |
|
2442 |
+ raise RuntimeError(custom_error) |
|
2443 |
+ |
|
2444 |
+ monkeypatch.setattr(cli_helpers, "save_config", raiser) |
|
5491 | 2445 |
result = runner.invoke( |
5492 | 2446 |
cli.derivepassphrase_vault, |
5493 |
- command_line, |
|
2447 |
+ ["--config", "--length=15", "--", DUMMY_SERVICE], |
|
5494 | 2448 |
catch_exceptions=False, |
5495 | 2449 |
) |
5496 |
- assert result.clean_exit(empty_stderr=True), ( |
|
5497 |
- "expected clean exit" |
|
5498 |
- ) |
|
5499 |
- with cli_helpers.config_filename(subsystem="vault").open( |
|
5500 |
- encoding="UTF-8" |
|
5501 |
- ) as infile: |
|
5502 |
- config_readback = json.load(infile) |
|
5503 |
- assert config_readback == result_config |
|
5504 |
- |
|
5505 |
- def test_204_phrase_from_key_manually(self) -> None: |
|
5506 |
- """The dummy service, key and config settings are consistent.""" |
|
5507 |
- assert ( |
|
5508 |
- vault.Vault( |
|
5509 |
- phrase=DUMMY_PHRASE_FROM_KEY1, **DUMMY_CONFIG_SETTINGS |
|
5510 |
- ).generate(DUMMY_SERVICE) |
|
5511 |
- == DUMMY_RESULT_KEY1 |
|
5512 |
- ) |
|
5513 |
- |
|
5514 |
- @Parametrize.VALIDATION_FUNCTION_INPUT |
|
5515 |
- def test_210a_validate_constraints_manually( |
|
5516 |
- self, |
|
5517 |
- vfunc: Callable[[click.Context, click.Parameter, Any], int | None], |
|
5518 |
- input: int, |
|
5519 |
- ) -> None: |
|
5520 |
- """Command-line argument constraint validation works.""" |
|
5521 |
- ctx = cli.derivepassphrase_vault.make_context(cli.PROG_NAME, []) |
|
5522 |
- param = cli.derivepassphrase_vault.params[0] |
|
5523 |
- assert vfunc(ctx, param, input) == input |
|
5524 |
- |
|
5525 |
- @Parametrize.CONNECTION_HINTS |
|
5526 |
- def test_227_get_suitable_ssh_keys( |
|
5527 |
- self, |
|
5528 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
5529 |
- conn_hint: str, |
|
5530 |
- ) -> None: |
|
5531 |
- """[`cli_helpers.get_suitable_ssh_keys`][] works.""" |
|
5532 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
5533 |
- monkeypatch.setattr( |
|
5534 |
- ssh_agent.SSHAgentClient, |
|
5535 |
- "list_keys", |
|
5536 |
- callables.list_keys, |
|
2450 |
+ assert result.error_exit(error=custom_error), ( |
|
2451 |
+ "expected error exit and known error message" |
|
5537 | 2452 |
) |
5538 |
- hint: ssh_agent.SSHAgentClient | _types.SSHAgentSocket | None |
|
5539 |
- # TODO(the-13th-letter): Rewrite using structural pattern |
|
5540 |
- # matching. |
|
5541 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
5542 |
- if conn_hint == "client": |
|
5543 |
- hint = ssh_agent.SSHAgentClient() |
|
5544 |
- elif conn_hint == "socket": |
|
5545 |
- if isinstance( |
|
5546 |
- running_ssh_agent.socket, str |
|
5547 |
- ): # pragma: no cover |
|
5548 |
- if not hasattr(socket, "AF_UNIX"): |
|
5549 |
- pytest.skip("socket module does not support AF_UNIX") |
|
5550 |
- # socket.AF_UNIX is not defined everywhere. |
|
5551 |
- hint = socket.socket(family=socket.AF_UNIX) # type: ignore[attr-defined] |
|
5552 |
- hint.connect(running_ssh_agent.socket) |
|
5553 |
- else: # pragma: no cover |
|
5554 |
- hint = running_ssh_agent.socket() |
|
5555 |
- else: |
|
5556 |
- assert conn_hint == "none" |
|
5557 |
- hint = None |
|
5558 |
- exception: Exception | None = None |
|
5559 |
- try: |
|
5560 |
- list(cli_helpers.get_suitable_ssh_keys(hint)) |
|
5561 |
- except RuntimeError: # pragma: no cover |
|
5562 |
- pass |
|
5563 |
- except Exception as e: # noqa: BLE001 # pragma: no cover |
|
5564 |
- exception = e |
|
5565 |
- finally: |
|
5566 |
- assert exception is None, ( |
|
5567 |
- "exception querying suitable SSH keys" |
|
5568 |
- ) |
|
5569 |
- |
|
5570 |
- @Parametrize.KEY_TO_PHRASE_SETTINGS |
|
5571 |
- def test_400_key_to_phrase( |
|
5572 |
- self, |
|
5573 |
- ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient, |
|
5574 |
- list_keys_action: ListKeysAction | None, |
|
5575 |
- system_support_action: SystemSupportAction | None, |
|
5576 |
- address_action: SocketAddressAction | None, |
|
5577 |
- sign_action: SignAction, |
|
5578 |
- pattern: str, |
|
5579 |
- ) -> None: |
|
5580 |
- """All errors in [`cli_helpers.key_to_phrase`][] are handled.""" |
|
5581 |
- |
|
5582 |
- class ErrCallback(BaseException): |
|
5583 |
- def __init__(self, *args: Any, **kwargs: Any) -> None: |
|
5584 |
- super().__init__(*args[:1]) |
|
5585 |
- self.args = args |
|
5586 |
- self.kwargs = kwargs |
|
5587 | 2453 |
|
5588 |
- def err(*args: Any, **_kwargs: Any) -> NoReturn: |
|
5589 |
- raise ErrCallback(*args, **_kwargs) |
|
5590 |
- |
|
5591 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
5592 |
- loaded_keys = list( |
|
5593 |
- ssh_agent_client_with_test_keys_loaded.list_keys() |
|
5594 |
- ) |
|
5595 |
- loaded_key = base64.standard_b64encode(loaded_keys[0][0]) |
|
5596 |
- monkeypatch.setattr(ssh_agent.SSHAgentClient, "sign", sign_action) |
|
5597 |
- if list_keys_action: |
|
5598 |
- monkeypatch.setattr( |
|
5599 |
- ssh_agent.SSHAgentClient, "list_keys", list_keys_action |
|
5600 |
- ) |
|
5601 |
- if address_action: |
|
5602 |
- address_action(monkeypatch) |
|
5603 |
- if system_support_action: |
|
5604 |
- system_support_action(monkeypatch) |
|
5605 |
- with pytest.raises(ErrCallback, match=pattern) as excinfo: |
|
5606 |
- cli_helpers.key_to_phrase(loaded_key, error_callback=err) |
|
5607 |
- if list_keys_action == ListKeysAction.FAIL_RUNTIME: |
|
5608 |
- assert excinfo.value.kwargs |
|
5609 |
- assert isinstance( |
|
5610 |
- excinfo.value.kwargs["exc_info"], |
|
5611 |
- ssh_agent.SSHAgentFailedError, |
|
5612 |
- ) |
|
5613 |
- assert excinfo.value.kwargs["exc_info"].__context__ is not None |
|
5614 |
- assert isinstance( |
|
5615 |
- excinfo.value.kwargs["exc_info"].__context__, |
|
5616 |
- ssh_agent.TrailingDataError, |
|
5617 |
- ) |
|
5618 |
- |
|
5619 |
- |
|
5620 |
-# TODO(the-13th-letter): Remove this class in v1.0. |
|
5621 |
-# https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#upgrading-to-v1.0 |
|
5622 |
-class TestCLITransition: |
|
5623 |
- """Transition tests for the command-line interface up to v1.0.""" |
|
5624 |
- |
|
5625 |
- @Parametrize.BASE_CONFIG_VARIATIONS |
|
5626 |
- def test_110_load_config_backup( |
|
2454 |
+ def test_225f_store_config_fail_unset_and_set_same_settings( |
|
5627 | 2455 |
self, |
5628 |
- config: Any, |
|
5629 | 2456 |
) -> None: |
5630 |
- """Loading the old settings file works.""" |
|
2457 |
+ """Issuing conflicting settings to `--config` fails.""" |
|
5631 | 2458 |
runner = machinery.CliRunner(mix_stderr=False) |
5632 | 2459 |
# TODO(the-13th-letter): Rewrite using parenthesized |
5633 | 2460 |
# with-statements. |
... | ... |
@@ -5635,22 +2462,33 @@ class TestCLITransition: |
5635 | 2462 |
with contextlib.ExitStack() as stack: |
5636 | 2463 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
5637 | 2464 |
stack.enter_context( |
5638 |
- pytest_machinery.isolated_config( |
|
2465 |
+ pytest_machinery.isolated_vault_config( |
|
5639 | 2466 |
monkeypatch=monkeypatch, |
5640 | 2467 |
runner=runner, |
2468 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2469 |
+ ) |
|
5641 | 2470 |
) |
2471 |
+ result = runner.invoke( |
|
2472 |
+ cli.derivepassphrase_vault, |
|
2473 |
+ [ |
|
2474 |
+ "--config", |
|
2475 |
+ "--unset=length", |
|
2476 |
+ "--length=15", |
|
2477 |
+ "--", |
|
2478 |
+ DUMMY_SERVICE, |
|
2479 |
+ ], |
|
2480 |
+ catch_exceptions=False, |
|
5642 | 2481 |
) |
5643 |
- cli_helpers.config_filename( |
|
5644 |
- subsystem="old settings.json" |
|
5645 |
- ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8") |
|
5646 |
- assert cli_helpers.migrate_and_load_old_config()[0] == config |
|
2482 |
+ assert result.error_exit( |
|
2483 |
+ error="Attempted to unset and set --length at the same time." |
|
2484 |
+ ), "expected error exit and known error message" |
|
5647 | 2485 |
|
5648 |
- @Parametrize.BASE_CONFIG_VARIATIONS |
|
5649 |
- def test_111_migrate_config( |
|
2486 |
+ def test_225g_store_config_fail_manual_ssh_agent_no_keys_loaded( |
|
5650 | 2487 |
self, |
5651 |
- config: Any, |
|
2488 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
5652 | 2489 |
) -> None: |
5653 |
- """Migrating the old settings file works.""" |
|
2490 |
+ """Not holding any SSH keys during `--config --key` fails.""" |
|
2491 |
+ del running_ssh_agent |
|
5654 | 2492 |
runner = machinery.CliRunner(mix_stderr=False) |
5655 | 2493 |
# TODO(the-13th-letter): Rewrite using parenthesized |
5656 | 2494 |
# with-statements. |
... | ... |
@@ -5658,22 +2496,35 @@ class TestCLITransition: |
5658 | 2496 |
with contextlib.ExitStack() as stack: |
5659 | 2497 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
5660 | 2498 |
stack.enter_context( |
5661 |
- pytest_machinery.isolated_config( |
|
2499 |
+ pytest_machinery.isolated_vault_config( |
|
5662 | 2500 |
monkeypatch=monkeypatch, |
5663 | 2501 |
runner=runner, |
2502 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2503 |
+ ) |
|
2504 |
+ ) |
|
2505 |
+ |
|
2506 |
+ def func( |
|
2507 |
+ *_args: Any, |
|
2508 |
+ **_kwargs: Any, |
|
2509 |
+ ) -> list[_types.SSHKeyCommentPair]: |
|
2510 |
+ return [] |
|
2511 |
+ |
|
2512 |
+ monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func) |
|
2513 |
+ result = runner.invoke( |
|
2514 |
+ cli.derivepassphrase_vault, |
|
2515 |
+ ["--key", "--config"], |
|
2516 |
+ catch_exceptions=False, |
|
5664 | 2517 |
) |
2518 |
+ assert result.error_exit(error="no keys suitable"), ( |
|
2519 |
+ "expected error exit and known error message" |
|
5665 | 2520 |
) |
5666 |
- cli_helpers.config_filename( |
|
5667 |
- subsystem="old settings.json" |
|
5668 |
- ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8") |
|
5669 |
- assert cli_helpers.migrate_and_load_old_config() == (config, None) |
|
5670 | 2521 |
|
5671 |
- @Parametrize.BASE_CONFIG_VARIATIONS |
|
5672 |
- def test_112_migrate_config_error( |
|
2522 |
+ def test_225h_store_config_fail_manual_ssh_agent_runtime_error( |
|
5673 | 2523 |
self, |
5674 |
- config: Any, |
|
2524 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
5675 | 2525 |
) -> None: |
5676 |
- """Migrating the old settings file atop a directory fails.""" |
|
2526 |
+ """The SSH agent erroring during `--config --key` fails.""" |
|
2527 |
+ del running_ssh_agent |
|
5677 | 2528 |
runner = machinery.CliRunner(mix_stderr=False) |
5678 | 2529 |
# TODO(the-13th-letter): Rewrite using parenthesized |
5679 | 2530 |
# with-statements. |
... | ... |
@@ -5681,29 +2532,32 @@ class TestCLITransition: |
5681 | 2532 |
with contextlib.ExitStack() as stack: |
5682 | 2533 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
5683 | 2534 |
stack.enter_context( |
5684 |
- pytest_machinery.isolated_config( |
|
2535 |
+ pytest_machinery.isolated_vault_config( |
|
5685 | 2536 |
monkeypatch=monkeypatch, |
5686 | 2537 |
runner=runner, |
2538 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
5687 | 2539 |
) |
5688 | 2540 |
) |
5689 |
- cli_helpers.config_filename( |
|
5690 |
- subsystem="old settings.json" |
|
5691 |
- ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8") |
|
5692 |
- cli_helpers.config_filename(subsystem="vault").mkdir( |
|
5693 |
- parents=True, exist_ok=True |
|
2541 |
+ |
|
2542 |
+ def raiser(*_args: Any, **_kwargs: Any) -> None: |
|
2543 |
+ raise ssh_agent.TrailingDataError() |
|
2544 |
+ |
|
2545 |
+ monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", raiser) |
|
2546 |
+ result = runner.invoke( |
|
2547 |
+ cli.derivepassphrase_vault, |
|
2548 |
+ ["--key", "--config"], |
|
2549 |
+ catch_exceptions=False, |
|
5694 | 2550 |
) |
5695 |
- config2, err = cli_helpers.migrate_and_load_old_config() |
|
5696 |
- assert config2 == config |
|
5697 |
- assert isinstance(err, OSError) |
|
5698 |
- # The Annoying OS uses EEXIST, other OSes use EISDIR. |
|
5699 |
- assert err.errno in {errno.EISDIR, errno.EEXIST} |
|
2551 |
+ assert result.error_exit( |
|
2552 |
+ error="violates the communication protocol." |
|
2553 |
+ ), "expected error exit and known error message" |
|
5700 | 2554 |
|
5701 |
- @Parametrize.BAD_CONFIGS |
|
5702 |
- def test_113_migrate_config_error_bad_config_value( |
|
2555 |
+ def test_225i_store_config_fail_manual_ssh_agent_refuses( |
|
5703 | 2556 |
self, |
5704 |
- config: Any, |
|
2557 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
5705 | 2558 |
) -> None: |
5706 |
- """Migrating an invalid old settings file fails.""" |
|
2559 |
+ """The SSH agent refusing during `--config --key` fails.""" |
|
2560 |
+ del running_ssh_agent |
|
5707 | 2561 |
runner = machinery.CliRunner(mix_stderr=False) |
5708 | 2562 |
# TODO(the-13th-letter): Rewrite using parenthesized |
5709 | 2563 |
# with-statements. |
... | ... |
@@ -5711,25 +2565,30 @@ class TestCLITransition: |
5711 | 2565 |
with contextlib.ExitStack() as stack: |
5712 | 2566 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
5713 | 2567 |
stack.enter_context( |
5714 |
- pytest_machinery.isolated_config( |
|
2568 |
+ pytest_machinery.isolated_vault_config( |
|
5715 | 2569 |
monkeypatch=monkeypatch, |
5716 | 2570 |
runner=runner, |
2571 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
5717 | 2572 |
) |
5718 | 2573 |
) |
5719 |
- cli_helpers.config_filename( |
|
5720 |
- subsystem="old settings.json" |
|
5721 |
- ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8") |
|
5722 |
- with pytest.raises( |
|
5723 |
- ValueError, match=cli_helpers.INVALID_VAULT_CONFIG |
|
5724 |
- ): |
|
5725 |
- cli_helpers.migrate_and_load_old_config() |
|
5726 | 2574 |
|
5727 |
- def test_200_forward_export_vault_path_parameter( |
|
5728 |
- self, |
|
5729 |
- caplog: pytest.LogCaptureFixture, |
|
5730 |
- ) -> None: |
|
5731 |
- """Forwarding arguments from "export" to "export vault" works.""" |
|
5732 |
- pytest.importorskip("cryptography", minversion="38.0") |
|
2575 |
+ def func(*_args: Any, **_kwargs: Any) -> NoReturn: |
|
2576 |
+ raise ssh_agent.SSHAgentFailedError( |
|
2577 |
+ _types.SSH_AGENT.FAILURE, b"" |
|
2578 |
+ ) |
|
2579 |
+ |
|
2580 |
+ monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func) |
|
2581 |
+ result = runner.invoke( |
|
2582 |
+ cli.derivepassphrase_vault, |
|
2583 |
+ ["--key", "--config"], |
|
2584 |
+ catch_exceptions=False, |
|
2585 |
+ ) |
|
2586 |
+ assert result.error_exit(error="refused to"), ( |
|
2587 |
+ "expected error exit and known error message" |
|
2588 |
+ ) |
|
2589 |
+ |
|
2590 |
+ def test_226_no_arguments(self) -> None: |
|
2591 |
+ """Calling `derivepassphrase vault` without any arguments fails.""" |
|
5733 | 2592 |
runner = machinery.CliRunner(mix_stderr=False) |
5734 | 2593 |
# TODO(the-13th-letter): Rewrite using parenthesized |
5735 | 2594 |
# with-statements. |
... | ... |
@@ -5737,33 +2596,22 @@ class TestCLITransition: |
5737 | 2596 |
with contextlib.ExitStack() as stack: |
5738 | 2597 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
5739 | 2598 |
stack.enter_context( |
5740 |
- pytest_machinery.isolated_vault_exporter_config( |
|
2599 |
+ pytest_machinery.isolated_config( |
|
5741 | 2600 |
monkeypatch=monkeypatch, |
5742 | 2601 |
runner=runner, |
5743 |
- vault_config=data.VAULT_V03_CONFIG, |
|
5744 |
- vault_key=data.VAULT_MASTER_KEY, |
|
5745 | 2602 |
) |
5746 | 2603 |
) |
5747 |
- monkeypatch.setenv("VAULT_KEY", data.VAULT_MASTER_KEY) |
|
5748 | 2604 |
result = runner.invoke( |
5749 |
- cli.derivepassphrase, |
|
5750 |
- ["export", "VAULT_PATH"], |
|
5751 |
- ) |
|
5752 |
- assert result.clean_exit(empty_stderr=False), "expected clean exit" |
|
5753 |
- assert machinery.deprecation_warning_emitted( |
|
5754 |
- "A subcommand will be required here in v1.0", caplog.record_tuples |
|
5755 |
- ) |
|
5756 |
- assert machinery.deprecation_warning_emitted( |
|
5757 |
- 'Defaulting to subcommand "vault"', caplog.record_tuples |
|
2605 |
+ cli.derivepassphrase_vault, [], catch_exceptions=False |
|
5758 | 2606 |
) |
5759 |
- assert json.loads(result.stdout) == data.VAULT_V03_CONFIG_DATA |
|
2607 |
+ assert result.error_exit( |
|
2608 |
+ error="Deriving a passphrase requires a SERVICE" |
|
2609 |
+ ), "expected error exit and known error message" |
|
5760 | 2610 |
|
5761 |
- def test_201_forward_export_vault_empty_commandline( |
|
2611 |
+ def test_226a_no_passphrase_or_key( |
|
5762 | 2612 |
self, |
5763 |
- caplog: pytest.LogCaptureFixture, |
|
5764 | 2613 |
) -> None: |
5765 |
- """Deferring from "export" to "export vault" works.""" |
|
5766 |
- pytest.importorskip("cryptography", minversion="38.0") |
|
2614 |
+ """Deriving a passphrase without a passphrase or key fails.""" |
|
5767 | 2615 |
runner = machinery.CliRunner(mix_stderr=False) |
5768 | 2616 |
# TODO(the-13th-letter): Rewrite using parenthesized |
5769 | 2617 |
# with-statements. |
... | ... |
@@ -5777,28 +2625,24 @@ class TestCLITransition: |
5777 | 2625 |
) |
5778 | 2626 |
) |
5779 | 2627 |
result = runner.invoke( |
5780 |
- cli.derivepassphrase, |
|
5781 |
- ["export"], |
|
5782 |
- ) |
|
5783 |
- assert machinery.deprecation_warning_emitted( |
|
5784 |
- "A subcommand will be required here in v1.0", caplog.record_tuples |
|
5785 |
- ) |
|
5786 |
- assert machinery.deprecation_warning_emitted( |
|
5787 |
- 'Defaulting to subcommand "vault"', caplog.record_tuples |
|
2628 |
+ cli.derivepassphrase_vault, |
|
2629 |
+ ["--", DUMMY_SERVICE], |
|
2630 |
+ catch_exceptions=False, |
|
5788 | 2631 |
) |
5789 |
- assert result.error_exit(error="Missing argument 'PATH'"), ( |
|
5790 |
- "expected error exit and known error type" |
|
2632 |
+ assert result.error_exit(error="No passphrase or key was given"), ( |
|
2633 |
+ "expected error exit and known error message" |
|
5791 | 2634 |
) |
5792 | 2635 |
|
5793 |
- @Parametrize.CHARSET_NAME |
|
5794 |
- def test_210_forward_vault_disable_character_set( |
|
2636 |
+ def test_230_config_directory_nonexistant( |
|
5795 | 2637 |
self, |
5796 |
- caplog: pytest.LogCaptureFixture, |
|
5797 |
- charset_name: str, |
|
5798 | 2638 |
) -> None: |
5799 |
- """Forwarding arguments from top-level to "vault" works.""" |
|
5800 |
- option = f"--{charset_name}" |
|
5801 |
- charset = vault.Vault.CHARSETS[charset_name].decode("ascii") |
|
2639 |
+ """Running without an existing config directory works. |
|
2640 |
+ |
|
2641 |
+ This is a regression test; see [issue\u00a0#6][] for context. |
|
2642 |
+ |
|
2643 |
+ [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6 |
|
2644 |
+ |
|
2645 |
+ """ |
|
5802 | 2646 |
runner = machinery.CliRunner(mix_stderr=False) |
5803 | 2647 |
# TODO(the-13th-letter): Rewrite using parenthesized |
5804 | 2648 |
# with-statements. |
... | ... |
@@ -5811,34 +2655,40 @@ class TestCLITransition: |
5811 | 2655 |
runner=runner, |
5812 | 2656 |
) |
5813 | 2657 |
) |
5814 |
- monkeypatch.setattr( |
|
5815 |
- cli_helpers, |
|
5816 |
- "prompt_for_passphrase", |
|
5817 |
- callables.auto_prompt, |
|
5818 |
- ) |
|
2658 |
+ with contextlib.suppress(FileNotFoundError): |
|
2659 |
+ shutil.rmtree(cli_helpers.config_filename(subsystem=None)) |
|
5819 | 2660 |
result = runner.invoke( |
5820 |
- cli.derivepassphrase, |
|
5821 |
- [option, "0", "-p", "--", DUMMY_SERVICE], |
|
5822 |
- input=DUMMY_PASSPHRASE, |
|
2661 |
+ cli.derivepassphrase_vault, |
|
2662 |
+ ["--config", "-p"], |
|
5823 | 2663 |
catch_exceptions=False, |
2664 |
+ input="abc\n", |
|
5824 | 2665 |
) |
5825 |
- assert result.clean_exit(empty_stderr=False), "expected clean exit" |
|
5826 |
- assert machinery.deprecation_warning_emitted( |
|
5827 |
- "A subcommand will be required here in v1.0", caplog.record_tuples |
|
5828 |
- ) |
|
5829 |
- assert machinery.deprecation_warning_emitted( |
|
5830 |
- 'Defaulting to subcommand "vault"', caplog.record_tuples |
|
5831 |
- ) |
|
5832 |
- for c in charset: |
|
5833 |
- assert c not in result.stdout, ( |
|
5834 |
- f"derived password contains forbidden character {c!r}" |
|
2666 |
+ assert result.clean_exit(), "expected clean exit" |
|
2667 |
+ assert result.stderr == "Passphrase:", ( |
|
2668 |
+ "program unexpectedly failed?!" |
|
5835 | 2669 |
) |
2670 |
+ with cli_helpers.config_filename(subsystem="vault").open( |
|
2671 |
+ encoding="UTF-8" |
|
2672 |
+ ) as infile: |
|
2673 |
+ config_readback = json.load(infile) |
|
2674 |
+ assert config_readback == { |
|
2675 |
+ "global": {"phrase": "abc"}, |
|
2676 |
+ "services": {}, |
|
2677 |
+ }, "config mismatch" |
|
2678 |
+ |
|
2679 |
+ def test_230a_config_directory_not_a_file( |
|
2680 |
+ self, |
|
2681 |
+ ) -> None: |
|
2682 |
+ """Erroring without an existing config directory errors normally. |
|
2683 |
+ |
|
2684 |
+ That is, the missing configuration directory does not cause any |
|
2685 |
+ errors by itself. |
|
2686 |
+ |
|
2687 |
+ This is a regression test; see [issue\u00a0#6][] for context. |
|
5836 | 2688 |
|
5837 |
- def test_211_forward_vault_empty_command_line( |
|
5838 |
- self, |
|
5839 |
- caplog: pytest.LogCaptureFixture, |
|
5840 |
- ) -> None: |
|
5841 |
- """Deferring from top-level to "vault" works.""" |
|
2689 |
+ [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6 |
|
2690 |
+ |
|
2691 |
+ """ |
|
5842 | 2692 |
runner = machinery.CliRunner(mix_stderr=False) |
5843 | 2693 |
# TODO(the-13th-letter): Rewrite using parenthesized |
5844 | 2694 |
# with-statements. |
... | ... |
@@ -5851,28 +2701,33 @@ class TestCLITransition: |
5851 | 2701 |
runner=runner, |
5852 | 2702 |
) |
5853 | 2703 |
) |
2704 |
+ save_config_ = cli_helpers.save_config |
|
2705 |
+ |
|
2706 |
+ def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any: |
|
2707 |
+ config_dir = cli_helpers.config_filename(subsystem=None) |
|
2708 |
+ with contextlib.suppress(FileNotFoundError): |
|
2709 |
+ shutil.rmtree(config_dir) |
|
2710 |
+ config_dir.write_text("Obstruction!!\n") |
|
2711 |
+ monkeypatch.setattr(cli_helpers, "save_config", save_config_) |
|
2712 |
+ return save_config_(*args, **kwargs) |
|
2713 |
+ |
|
2714 |
+ monkeypatch.setattr( |
|
2715 |
+ cli_helpers, "save_config", obstruct_config_saving |
|
2716 |
+ ) |
|
5854 | 2717 |
result = runner.invoke( |
5855 |
- cli.derivepassphrase, |
|
5856 |
- [], |
|
5857 |
- input=DUMMY_PASSPHRASE, |
|
2718 |
+ cli.derivepassphrase_vault, |
|
2719 |
+ ["--config", "-p"], |
|
5858 | 2720 |
catch_exceptions=False, |
2721 |
+ input="abc\n", |
|
5859 | 2722 |
) |
5860 |
- assert machinery.deprecation_warning_emitted( |
|
5861 |
- "A subcommand will be required here in v1.0", caplog.record_tuples |
|
5862 |
- ) |
|
5863 |
- assert machinery.deprecation_warning_emitted( |
|
5864 |
- 'Defaulting to subcommand "vault"', caplog.record_tuples |
|
2723 |
+ assert result.error_exit(error="Cannot store vault settings:"), ( |
|
2724 |
+ "expected error exit and known error message" |
|
5865 | 2725 |
) |
5866 |
- assert result.error_exit( |
|
5867 |
- error="Deriving a passphrase requires a SERVICE." |
|
5868 |
- ), "expected error exit and known error type" |
|
5869 | 2726 |
|
5870 |
- def test_300_export_using_old_config_file( |
|
2727 |
+ def test_230b_store_config_custom_error( |
|
5871 | 2728 |
self, |
5872 |
- caplog: pytest.LogCaptureFixture, |
|
5873 | 2729 |
) -> None: |
5874 |
- """Exporting from (and migrating) the old settings file works.""" |
|
5875 |
- caplog.set_level(logging.INFO) |
|
2730 |
+ """Storing the configuration reacts even to weird errors.""" |
|
5876 | 2731 |
runner = machinery.CliRunner(mix_stderr=False) |
5877 | 2732 |
# TODO(the-13th-letter): Rewrite using parenthesized |
5878 | 2733 |
# with-statements. |
... | ... |
@@ -5885,34 +2740,33 @@ class TestCLITransition: |
5885 | 2740 |
runner=runner, |
5886 | 2741 |
) |
5887 | 2742 |
) |
5888 |
- cli_helpers.config_filename( |
|
5889 |
- subsystem="old settings.json" |
|
5890 |
- ).write_text( |
|
5891 |
- json.dumps( |
|
5892 |
- {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}}, |
|
5893 |
- indent=2, |
|
5894 |
- ) |
|
5895 |
- + "\n", |
|
5896 |
- encoding="UTF-8", |
|
5897 |
- ) |
|
2743 |
+ custom_error = "custom error message" |
|
2744 |
+ |
|
2745 |
+ def raiser(config: Any) -> None: |
|
2746 |
+ del config |
|
2747 |
+ raise RuntimeError(custom_error) |
|
2748 |
+ |
|
2749 |
+ monkeypatch.setattr(cli_helpers, "save_config", raiser) |
|
5898 | 2750 |
result = runner.invoke( |
5899 | 2751 |
cli.derivepassphrase_vault, |
5900 |
- ["--export", "-"], |
|
2752 |
+ ["--config", "-p"], |
|
5901 | 2753 |
catch_exceptions=False, |
2754 |
+ input="abc\n", |
|
2755 |
+ ) |
|
2756 |
+ assert result.error_exit(error=custom_error), ( |
|
2757 |
+ "expected error exit and known error message" |
|
5902 | 2758 |
) |
5903 |
- assert result.clean_exit(), "expected clean exit" |
|
5904 |
- assert machinery.deprecation_warning_emitted( |
|
5905 |
- "v0.1-style config file", caplog.record_tuples |
|
5906 |
- ), "expected known warning message in stderr" |
|
5907 |
- assert machinery.deprecation_info_emitted( |
|
5908 |
- "Successfully migrated to ", caplog.record_tuples |
|
5909 |
- ), "expected known warning message in stderr" |
|
5910 | 2759 |
|
5911 |
- def test_300a_export_using_old_config_file_migration_error( |
|
2760 |
+ @Parametrize.UNICODE_NORMALIZATION_WARNING_INPUTS |
|
2761 |
+ def test_300_unicode_normalization_form_warning( |
|
5912 | 2762 |
self, |
5913 | 2763 |
caplog: pytest.LogCaptureFixture, |
2764 |
+ main_config: str, |
|
2765 |
+ command_line: list[str], |
|
2766 |
+ input: str | None, |
|
2767 |
+ warning_message: str, |
|
5914 | 2768 |
) -> None: |
5915 |
- """Exporting from (and not migrating) the old settings file fails.""" |
|
2769 |
+ """Using unnormalized Unicode passphrases warns.""" |
|
5916 | 2770 |
runner = machinery.CliRunner(mix_stderr=False) |
5917 | 2771 |
# TODO(the-13th-letter): Rewrite using parenthesized |
5918 | 2772 |
# with-statements. |
... | ... |
@@ -5920,49 +2774,37 @@ class TestCLITransition: |
5920 | 2774 |
with contextlib.ExitStack() as stack: |
5921 | 2775 |
monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
5922 | 2776 |
stack.enter_context( |
5923 |
- pytest_machinery.isolated_config( |
|
2777 |
+ pytest_machinery.isolated_vault_config( |
|
5924 | 2778 |
monkeypatch=monkeypatch, |
5925 | 2779 |
runner=runner, |
2780 |
+ vault_config={ |
|
2781 |
+ "services": { |
|
2782 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy() |
|
2783 |
+ } |
|
2784 |
+ }, |
|
2785 |
+ main_config_str=main_config, |
|
5926 | 2786 |
) |
5927 | 2787 |
) |
5928 |
- cli_helpers.config_filename( |
|
5929 |
- subsystem="old settings.json" |
|
5930 |
- ).write_text( |
|
5931 |
- json.dumps( |
|
5932 |
- {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}}, |
|
5933 |
- indent=2, |
|
5934 |
- ) |
|
5935 |
- + "\n", |
|
5936 |
- encoding="UTF-8", |
|
5937 |
- ) |
|
5938 |
- |
|
5939 |
- def raiser(*_args: Any, **_kwargs: Any) -> None: |
|
5940 |
- raise OSError( |
|
5941 |
- errno.EACCES, |
|
5942 |
- os.strerror(errno.EACCES), |
|
5943 |
- cli_helpers.config_filename(subsystem="vault"), |
|
5944 |
- ) |
|
5945 |
- |
|
5946 |
- monkeypatch.setattr(os, "replace", raiser) |
|
5947 |
- monkeypatch.setattr(pathlib.Path, "rename", raiser) |
|
5948 | 2788 |
result = runner.invoke( |
5949 | 2789 |
cli.derivepassphrase_vault, |
5950 |
- ["--export", "-"], |
|
2790 |
+ ["--debug", *command_line], |
|
5951 | 2791 |
catch_exceptions=False, |
2792 |
+ input=input, |
|
5952 | 2793 |
) |
5953 | 2794 |
assert result.clean_exit(), "expected clean exit" |
5954 |
- assert machinery.deprecation_warning_emitted( |
|
5955 |
- "v0.1-style config file", caplog.record_tuples |
|
5956 |
- ), "expected known warning message in stderr" |
|
5957 | 2795 |
assert machinery.warning_emitted( |
5958 |
- "Failed to migrate to ", caplog.record_tuples |
|
2796 |
+ warning_message, caplog.record_tuples |
|
5959 | 2797 |
), "expected known warning message in stderr" |
5960 | 2798 |
|
5961 |
- def test_400_completion_service_name_old_config_file( |
|
2799 |
+ @Parametrize.UNICODE_NORMALIZATION_ERROR_INPUTS |
|
2800 |
+ def test_301_unicode_normalization_form_error( |
|
5962 | 2801 |
self, |
2802 |
+ main_config: str, |
|
2803 |
+ command_line: list[str], |
|
2804 |
+ input: str | None, |
|
2805 |
+ error_message: str, |
|
5963 | 2806 |
) -> None: |
5964 |
- """Completing service names from the old settings file works.""" |
|
5965 |
- config = {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}} |
|
2807 |
+ """Using unknown Unicode normalization forms fails.""" |
|
5966 | 2808 |
runner = machinery.CliRunner(mix_stderr=False) |
5967 | 2809 |
# TODO(the-13th-letter): Rewrite using parenthesized |
5968 | 2810 |
# with-statements. |
... | ... |
@@ -5973,138 +2815,33 @@ class TestCLITransition: |
5973 | 2815 |
pytest_machinery.isolated_vault_config( |
5974 | 2816 |
monkeypatch=monkeypatch, |
5975 | 2817 |
runner=runner, |
5976 |
- vault_config=config, |
|
5977 |
- ) |
|
2818 |
+ vault_config={ |
|
2819 |
+ "services": { |
|
2820 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy() |
|
2821 |
+ } |
|
2822 |
+ }, |
|
2823 |
+ main_config_str=main_config, |
|
5978 | 2824 |
) |
5979 |
- old_name = cli_helpers.config_filename( |
|
5980 |
- subsystem="old settings.json" |
|
5981 | 2825 |
) |
5982 |
- new_name = cli_helpers.config_filename(subsystem="vault") |
|
5983 |
- old_name.unlink(missing_ok=True) |
|
5984 |
- new_name.rename(old_name) |
|
5985 |
- assert cli_helpers.shell_complete_service( |
|
5986 |
- click.Context(cli.derivepassphrase), |
|
5987 |
- click.Argument(["some_parameter"]), |
|
5988 |
- "", |
|
5989 |
- ) == [DUMMY_SERVICE] |
|
5990 |
- |
|
5991 |
- |
|
5992 |
-def completion_item( |
|
5993 |
- item: str | click.shell_completion.CompletionItem, |
|
5994 |
-) -> click.shell_completion.CompletionItem: |
|
5995 |
- """Convert a string to a completion item, if necessary.""" |
|
5996 |
- return ( |
|
5997 |
- click.shell_completion.CompletionItem(item, type="plain") |
|
5998 |
- if isinstance(item, str) |
|
5999 |
- else item |
|
2826 |
+ result = runner.invoke( |
|
2827 |
+ cli.derivepassphrase_vault, |
|
2828 |
+ command_line, |
|
2829 |
+ catch_exceptions=False, |
|
2830 |
+ input=input, |
|
6000 | 2831 |
) |
6001 |
- |
|
6002 |
- |
|
6003 |
-def assertable_item( |
|
6004 |
- item: str | click.shell_completion.CompletionItem, |
|
6005 |
-) -> tuple[str, Any, str | None]: |
|
6006 |
- """Convert a completion item into a pretty-printable item. |
|
6007 |
- |
|
6008 |
- Intended to make completion items introspectable in pytest's |
|
6009 |
- `assert` output. |
|
6010 |
- |
|
6011 |
- """ |
|
6012 |
- item = completion_item(item) |
|
6013 |
- return (item.type, item.value, item.help) |
|
6014 |
- |
|
6015 |
- |
|
6016 |
-class TestShellCompletion: |
|
6017 |
- """Tests for the shell completion machinery.""" |
|
6018 |
- |
|
6019 |
- class Completions: |
|
6020 |
- """A deferred completion call.""" |
|
6021 |
- |
|
6022 |
- def __init__( |
|
6023 |
- self, |
|
6024 |
- args: Sequence[str], |
|
6025 |
- incomplete: str, |
|
6026 |
- ) -> None: |
|
6027 |
- """Initialize the object. |
|
6028 |
- |
|
6029 |
- Args: |
|
6030 |
- args: |
|
6031 |
- The sequence of complete command-line arguments. |
|
6032 |
- incomplete: |
|
6033 |
- The final, incomplete, partial argument. |
|
6034 |
- |
|
6035 |
- """ |
|
6036 |
- self.args = tuple(args) |
|
6037 |
- self.incomplete = incomplete |
|
6038 |
- |
|
6039 |
- def __call__(self) -> Sequence[click.shell_completion.CompletionItem]: |
|
6040 |
- """Return the completion items.""" |
|
6041 |
- args = list(self.args) |
|
6042 |
- completion = click.shell_completion.ShellComplete( |
|
6043 |
- cli=cli.derivepassphrase, |
|
6044 |
- ctx_args={}, |
|
6045 |
- prog_name="derivepassphrase", |
|
6046 |
- complete_var="_DERIVEPASSPHRASE_COMPLETE", |
|
6047 |
- ) |
|
6048 |
- return completion.get_completions(args, self.incomplete) |
|
6049 |
- |
|
6050 |
- def get_words(self) -> Sequence[str]: |
|
6051 |
- """Return the completion items' values, as a sequence.""" |
|
6052 |
- return tuple(c.value for c in self()) |
|
6053 |
- |
|
6054 |
- @Parametrize.COMPLETABLE_ITEMS |
|
6055 |
- def test_100_is_completable_item( |
|
6056 |
- self, |
|
6057 |
- partial: str, |
|
6058 |
- is_completable: bool, |
|
6059 |
- ) -> None: |
|
6060 |
- """Our `_is_completable_item` predicate for service names works.""" |
|
6061 |
- assert cli_helpers.is_completable_item(partial) == is_completable |
|
6062 |
- |
|
6063 |
- @Parametrize.COMPLETABLE_OPTIONS |
|
6064 |
- def test_200_options( |
|
6065 |
- self, |
|
6066 |
- command_prefix: Sequence[str], |
|
6067 |
- incomplete: str, |
|
6068 |
- completions: AbstractSet[str], |
|
6069 |
- ) -> None: |
|
6070 |
- """Our completion machinery works for all commands' options.""" |
|
6071 |
- comp = self.Completions(command_prefix, incomplete) |
|
6072 |
- assert frozenset(comp.get_words()) == completions |
|
6073 |
- |
|
6074 |
- @Parametrize.COMPLETABLE_SUBCOMMANDS |
|
6075 |
- def test_201_subcommands( |
|
6076 |
- self, |
|
6077 |
- command_prefix: Sequence[str], |
|
6078 |
- incomplete: str, |
|
6079 |
- completions: AbstractSet[str], |
|
6080 |
- ) -> None: |
|
6081 |
- """Our completion machinery works for all commands' subcommands.""" |
|
6082 |
- comp = self.Completions(command_prefix, incomplete) |
|
6083 |
- assert frozenset(comp.get_words()) == completions |
|
6084 |
- |
|
6085 |
- @Parametrize.COMPLETABLE_PATH_ARGUMENT |
|
6086 |
- @Parametrize.INCOMPLETE |
|
6087 |
- def test_202_paths( |
|
6088 |
- self, |
|
6089 |
- command_prefix: Sequence[str], |
|
6090 |
- incomplete: str, |
|
6091 |
- ) -> None: |
|
6092 |
- """Our completion machinery works for all commands' paths.""" |
|
6093 |
- file = click.shell_completion.CompletionItem("", type="file") |
|
6094 |
- completions = frozenset({(file.type, file.value, file.help)}) |
|
6095 |
- comp = self.Completions(command_prefix, incomplete) |
|
6096 |
- assert ( |
|
6097 |
- frozenset((x.type, x.value, x.help) for x in comp()) == completions |
|
2832 |
+ assert result.error_exit( |
|
2833 |
+ error="The user configuration file is invalid." |
|
2834 |
+ ), "expected error exit and known error message" |
|
2835 |
+ assert result.error_exit(error=error_message), ( |
|
2836 |
+ "expected error exit and known error message" |
|
6098 | 2837 |
) |
6099 | 2838 |
|
6100 |
- @Parametrize.COMPLETABLE_SERVICE_NAMES |
|
6101 |
- def test_203_service_names( |
|
2839 |
+ @Parametrize.UNICODE_NORMALIZATION_COMMAND_LINES |
|
2840 |
+ def test_301a_unicode_normalization_form_error_from_stored_config( |
|
6102 | 2841 |
self, |
6103 |
- config: _types.VaultConfig, |
|
6104 |
- incomplete: str, |
|
6105 |
- completions: AbstractSet[str], |
|
2842 |
+ command_line: list[str], |
|
6106 | 2843 |
) -> None: |
6107 |
- """Our completion machinery works for vault service names.""" |
|
2844 |
+ """Using unknown Unicode normalization forms in the config fails.""" |
|
6108 | 2845 |
runner = machinery.CliRunner(mix_stderr=False) |
6109 | 2846 |
# TODO(the-13th-letter): Rewrite using parenthesized |
6110 | 2847 |
# with-statements. |
... | ... |
@@ -6115,28 +2852,36 @@ class TestShellCompletion: |
6115 | 2852 |
pytest_machinery.isolated_vault_config( |
6116 | 2853 |
monkeypatch=monkeypatch, |
6117 | 2854 |
runner=runner, |
6118 |
- vault_config=config, |
|
2855 |
+ vault_config={ |
|
2856 |
+ "services": { |
|
2857 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy() |
|
2858 |
+ } |
|
2859 |
+ }, |
|
2860 |
+ main_config_str=( |
|
2861 |
+ "[vault]\ndefault-unicode-normalization-form = 'XXX'\n" |
|
2862 |
+ ), |
|
2863 |
+ ) |
|
6119 | 2864 |
) |
2865 |
+ result = runner.invoke( |
|
2866 |
+ cli.derivepassphrase_vault, |
|
2867 |
+ command_line, |
|
2868 |
+ input=DUMMY_PASSPHRASE, |
|
2869 |
+ catch_exceptions=False, |
|
6120 | 2870 |
) |
6121 |
- comp = self.Completions(["vault"], incomplete) |
|
6122 |
- assert frozenset(comp.get_words()) == completions |
|
2871 |
+ assert result.error_exit( |
|
2872 |
+ error="The user configuration file is invalid." |
|
2873 |
+ ), "expected error exit and known error message" |
|
2874 |
+ assert result.error_exit( |
|
2875 |
+ error=( |
|
2876 |
+ "Invalid value 'XXX' for config key " |
|
2877 |
+ "vault.default-unicode-normalization-form" |
|
2878 |
+ ), |
|
2879 |
+ ), "expected error exit and known error message" |
|
6123 | 2880 |
|
6124 |
- @Parametrize.SHELL_FORMATTER |
|
6125 |
- @Parametrize.COMPLETION_FUNCTION_INPUTS |
|
6126 |
- def test_300_shell_completion_formatting( |
|
2881 |
+ def test_310_bad_user_config_file( |
|
6127 | 2882 |
self, |
6128 |
- shell: str, |
|
6129 |
- format_func: Callable[[click.shell_completion.CompletionItem], str], |
|
6130 |
- config: _types.VaultConfig, |
|
6131 |
- comp_func: Callable[ |
|
6132 |
- [click.Context, click.Parameter, str], |
|
6133 |
- list[str | click.shell_completion.CompletionItem], |
|
6134 |
- ], |
|
6135 |
- args: list[str], |
|
6136 |
- incomplete: str, |
|
6137 |
- results: list[str | click.shell_completion.CompletionItem], |
|
6138 | 2883 |
) -> None: |
6139 |
- """Custom completion functions work for all shells.""" |
|
2884 |
+ """Loading a user configuration file in an invalid format fails.""" |
|
6140 | 2885 |
runner = machinery.CliRunner(mix_stderr=False) |
6141 | 2886 |
# TODO(the-13th-letter): Rewrite using parenthesized |
6142 | 2887 |
# with-statements. |
... | ... |
@@ -6147,58 +2892,24 @@ class TestShellCompletion: |
6147 | 2892 |
pytest_machinery.isolated_vault_config( |
6148 | 2893 |
monkeypatch=monkeypatch, |
6149 | 2894 |
runner=runner, |
6150 |
- vault_config=config, |
|
6151 |
- ) |
|
6152 |
- ) |
|
6153 |
- expected_items = [assertable_item(item) for item in results] |
|
6154 |
- expected_string = "\n".join( |
|
6155 |
- format_func(completion_item(item)) for item in results |
|
6156 |
- ) |
|
6157 |
- manual_raw_items = comp_func( |
|
6158 |
- click.Context(cli.derivepassphrase), |
|
6159 |
- click.Argument(["sample_parameter"]), |
|
6160 |
- incomplete, |
|
6161 |
- ) |
|
6162 |
- manual_items = [assertable_item(item) for item in manual_raw_items] |
|
6163 |
- manual_string = "\n".join( |
|
6164 |
- format_func(completion_item(item)) for item in manual_raw_items |
|
2895 |
+ vault_config={"services": {}}, |
|
2896 |
+ main_config_str="This file is not valid TOML.\n", |
|
6165 | 2897 |
) |
6166 |
- assert manual_items == expected_items |
|
6167 |
- assert manual_string == expected_string |
|
6168 |
- comp_class = click.shell_completion.get_completion_class(shell) |
|
6169 |
- assert comp_class is not None |
|
6170 |
- comp = comp_class( |
|
6171 |
- cli.derivepassphrase, |
|
6172 |
- {}, |
|
6173 |
- "derivepassphrase", |
|
6174 |
- "_DERIVEPASSPHRASE_COMPLETE", |
|
6175 | 2898 |
) |
6176 |
- monkeypatch.setattr( |
|
6177 |
- comp, |
|
6178 |
- "get_completion_args", |
|
6179 |
- lambda *_a, **_kw: (args, incomplete), |
|
2899 |
+ result = runner.invoke( |
|
2900 |
+ cli.derivepassphrase_vault, |
|
2901 |
+ ["--phrase", "--", DUMMY_SERVICE], |
|
2902 |
+ input=DUMMY_PASSPHRASE, |
|
2903 |
+ catch_exceptions=False, |
|
6180 | 2904 |
) |
6181 |
- actual_raw_items = comp.get_completions( |
|
6182 |
- *comp.get_completion_args() |
|
2905 |
+ assert result.error_exit(error="Cannot load user config:"), ( |
|
2906 |
+ "expected error exit and known error message" |
|
6183 | 2907 |
) |
6184 |
- actual_items = [assertable_item(item) for item in actual_raw_items] |
|
6185 |
- actual_string = comp.complete() |
|
6186 |
- assert actual_items == expected_items |
|
6187 |
- assert actual_string == expected_string |
|
6188 | 2908 |
|
6189 |
- @Parametrize.CONFIG_SETTING_MODE |
|
6190 |
- @Parametrize.SERVICE_NAME_COMPLETION_INPUTS |
|
6191 |
- def test_400_incompletable_service_names( |
|
2909 |
+ def test_311_bad_user_config_is_a_directory( |
|
6192 | 2910 |
self, |
6193 |
- caplog: pytest.LogCaptureFixture, |
|
6194 |
- mode: Literal["config", "import"], |
|
6195 |
- config: _types.VaultConfig, |
|
6196 |
- key: str, |
|
6197 |
- incomplete: str, |
|
6198 |
- completions: AbstractSet[str], |
|
6199 | 2911 |
) -> None: |
6200 |
- """Completion skips incompletable items.""" |
|
6201 |
- vault_config = config if mode == "config" else {"services": {}} |
|
2912 |
+ """Loading a user configuration file in an invalid format fails.""" |
|
6202 | 2913 |
runner = machinery.CliRunner(mix_stderr=False) |
6203 | 2914 |
# TODO(the-13th-letter): Rewrite using parenthesized |
6204 | 2915 |
# with-statements. |
... | ... |
@@ -6209,37 +2920,30 @@ class TestShellCompletion: |
6209 | 2920 |
pytest_machinery.isolated_vault_config( |
6210 | 2921 |
monkeypatch=monkeypatch, |
6211 | 2922 |
runner=runner, |
6212 |
- vault_config=vault_config, |
|
2923 |
+ vault_config={"services": {}}, |
|
2924 |
+ main_config_str="", |
|
6213 | 2925 |
) |
6214 | 2926 |
) |
6215 |
- if mode == "config": |
|
6216 |
- result = runner.invoke( |
|
6217 |
- cli.derivepassphrase_vault, |
|
6218 |
- ["--config", "--length=10", "--", key], |
|
6219 |
- catch_exceptions=False, |
|
2927 |
+ user_config = cli_helpers.config_filename( |
|
2928 |
+ subsystem="user configuration" |
|
6220 | 2929 |
) |
6221 |
- else: |
|
2930 |
+ user_config.unlink() |
|
2931 |
+ user_config.mkdir(parents=True, exist_ok=True) |
|
6222 | 2932 |
result = runner.invoke( |
6223 | 2933 |
cli.derivepassphrase_vault, |
6224 |
- ["--import", "-"], |
|
2934 |
+ ["--phrase", "--", DUMMY_SERVICE], |
|
2935 |
+ input=DUMMY_PASSPHRASE, |
|
6225 | 2936 |
catch_exceptions=False, |
6226 |
- input=json.dumps(config), |
|
6227 | 2937 |
) |
6228 |
- assert result.clean_exit(), "expected clean exit" |
|
6229 |
- assert machinery.warning_emitted( |
|
6230 |
- "contains an ASCII control character", caplog.record_tuples |
|
6231 |
- ), "expected known warning message in stderr" |
|
6232 |
- assert machinery.warning_emitted( |
|
6233 |
- "not be available for completion", caplog.record_tuples |
|
6234 |
- ), "expected known warning message in stderr" |
|
6235 |
- assert cli_helpers.load_config() == config |
|
6236 |
- comp = self.Completions(["vault"], incomplete) |
|
6237 |
- assert frozenset(comp.get_words()) == completions |
|
2938 |
+ assert result.error_exit(error="Cannot load user config:"), ( |
|
2939 |
+ "expected error exit and known error message" |
|
2940 |
+ ) |
|
6238 | 2941 |
|
6239 |
- def test_410a_service_name_exceptions_not_found( |
|
2942 |
+ def test_400_missing_af_unix_support( |
|
6240 | 2943 |
self, |
2944 |
+ caplog: pytest.LogCaptureFixture, |
|
6241 | 2945 |
) -> None: |
6242 |
- """Service name completion quietly fails on missing configuration.""" |
|
2946 |
+ """Querying the SSH agent without `AF_UNIX` support fails.""" |
|
6243 | 2947 |
runner = machinery.CliRunner(mix_stderr=False) |
6244 | 2948 |
# TODO(the-13th-letter): Rewrite using parenthesized |
6245 | 2949 |
# with-statements. |
... | ... |
@@ -6250,48 +2954,25 @@ class TestShellCompletion: |
6250 | 2954 |
pytest_machinery.isolated_vault_config( |
6251 | 2955 |
monkeypatch=monkeypatch, |
6252 | 2956 |
runner=runner, |
6253 |
- vault_config={ |
|
6254 |
- "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS} |
|
6255 |
- }, |
|
6256 |
- ) |
|
6257 |
- ) |
|
6258 |
- cli_helpers.config_filename(subsystem="vault").unlink( |
|
6259 |
- missing_ok=True |
|
2957 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
6260 | 2958 |
) |
6261 |
- assert not cli_helpers.shell_complete_service( |
|
6262 |
- click.Context(cli.derivepassphrase), |
|
6263 |
- click.Argument(["some_parameter"]), |
|
6264 |
- "", |
|
6265 | 2959 |
) |
6266 |
- |
|
6267 |
- @Parametrize.SERVICE_NAME_EXCEPTIONS |
|
6268 |
- def test_410b_service_name_exceptions_custom_error( |
|
6269 |
- self, |
|
6270 |
- exc_type: type[Exception], |
|
6271 |
- ) -> None: |
|
6272 |
- """Service name completion quietly fails on configuration errors.""" |
|
6273 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
6274 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
6275 |
- # with-statements. |
|
6276 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
6277 |
- with contextlib.ExitStack() as stack: |
|
6278 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
6279 |
- stack.enter_context( |
|
6280 |
- pytest_machinery.isolated_vault_config( |
|
6281 |
- monkeypatch=monkeypatch, |
|
6282 |
- runner=runner, |
|
6283 |
- vault_config={ |
|
6284 |
- "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS} |
|
6285 |
- }, |
|
2960 |
+ monkeypatch.setenv( |
|
2961 |
+ "SSH_AUTH_SOCK", "the value doesn't even matter" |
|
6286 | 2962 |
) |
2963 |
+ monkeypatch.setattr( |
|
2964 |
+ ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", ["posix"] |
|
6287 | 2965 |
) |
6288 |
- |
|
6289 |
- def raiser(*_a: Any, **_kw: Any) -> NoReturn: |
|
6290 |
- raise exc_type("just being difficult") # noqa: EM101,TRY003 |
|
6291 |
- |
|
6292 |
- monkeypatch.setattr(cli_helpers, "load_config", raiser) |
|
6293 |
- assert not cli_helpers.shell_complete_service( |
|
6294 |
- click.Context(cli.derivepassphrase), |
|
6295 |
- click.Argument(["some_parameter"]), |
|
6296 |
- "", |
|
2966 |
+ monkeypatch.delattr(socket, "AF_UNIX", raising=False) |
|
2967 |
+ result = runner.invoke( |
|
2968 |
+ cli.derivepassphrase_vault, |
|
2969 |
+ ["--key", "--config"], |
|
2970 |
+ catch_exceptions=False, |
|
6297 | 2971 |
) |
2972 |
+ assert result.error_exit( |
|
2973 |
+ error="does not support communicating with it" |
|
2974 |
+ ), "expected error exit and known error message" |
|
2975 |
+ assert machinery.warning_emitted( |
|
2976 |
+ "Cannot connect to an SSH agent via UNIX domain sockets", |
|
2977 |
+ caplog.record_tuples, |
|
2978 |
+ ), "expected known warning message in stderr" |
... | ... |
@@ -0,0 +1,771 @@ |
1 |
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
|
2 |
+# |
|
3 |
+# SPDX-License-Identifier: Zlib |
|
4 |
+ |
|
5 |
+from __future__ import annotations |
|
6 |
+ |
|
7 |
+import contextlib |
|
8 |
+import enum |
|
9 |
+import re |
|
10 |
+import types |
|
11 |
+ |
|
12 |
+import exceptiongroup |
|
13 |
+import pytest |
|
14 |
+from typing_extensions import NamedTuple |
|
15 |
+ |
|
16 |
+from derivepassphrase import _types, cli, ssh_agent |
|
17 |
+from derivepassphrase._internals import cli_messages |
|
18 |
+from tests import machinery |
|
19 |
+from tests.machinery import pytest as pytest_machinery |
|
20 |
+ |
|
21 |
+ |
|
22 |
+class VersionOutputData(NamedTuple): |
|
23 |
+ derivation_schemes: dict[str, bool] |
|
24 |
+ foreign_configuration_formats: dict[str, bool] |
|
25 |
+ extras: frozenset[str] |
|
26 |
+ subcommands: frozenset[str] |
|
27 |
+ features: dict[str, bool] |
|
28 |
+ |
|
29 |
+ |
|
30 |
+class KnownLineType(str, enum.Enum): |
|
31 |
+ SUPPORTED_FOREIGN_CONFS = cli_messages.Label.SUPPORTED_FOREIGN_CONFIGURATION_FORMATS.value.singular.rstrip( |
|
32 |
+ ":" |
|
33 |
+ ) |
|
34 |
+ UNAVAILABLE_FOREIGN_CONFS = cli_messages.Label.UNAVAILABLE_FOREIGN_CONFIGURATION_FORMATS.value.singular.rstrip( |
|
35 |
+ ":" |
|
36 |
+ ) |
|
37 |
+ SUPPORTED_SCHEMES = ( |
|
38 |
+ cli_messages.Label.SUPPORTED_DERIVATION_SCHEMES.value.singular.rstrip( |
|
39 |
+ ":" |
|
40 |
+ ) |
|
41 |
+ ) |
|
42 |
+ UNAVAILABLE_SCHEMES = cli_messages.Label.UNAVAILABLE_DERIVATION_SCHEMES.value.singular.rstrip( |
|
43 |
+ ":" |
|
44 |
+ ) |
|
45 |
+ SUPPORTED_SUBCOMMANDS = ( |
|
46 |
+ cli_messages.Label.SUPPORTED_SUBCOMMANDS.value.singular.rstrip(":") |
|
47 |
+ ) |
|
48 |
+ SUPPORTED_FEATURES = ( |
|
49 |
+ cli_messages.Label.SUPPORTED_FEATURES.value.singular.rstrip(":") |
|
50 |
+ ) |
|
51 |
+ UNAVAILABLE_FEATURES = ( |
|
52 |
+ cli_messages.Label.UNAVAILABLE_FEATURES.value.singular.rstrip(":") |
|
53 |
+ ) |
|
54 |
+ ENABLED_EXTRAS = ( |
|
55 |
+ cli_messages.Label.ENABLED_PEP508_EXTRAS.value.singular.rstrip(":") |
|
56 |
+ ) |
|
57 |
+ |
|
58 |
+ |
|
59 |
+class Parametrize(types.SimpleNamespace): |
|
60 |
+ """Common test parametrizations.""" |
|
61 |
+ |
|
62 |
+ EAGER_ARGUMENTS = pytest.mark.parametrize( |
|
63 |
+ "arguments", |
|
64 |
+ [["--help"], ["--version"]], |
|
65 |
+ ids=["help", "version"], |
|
66 |
+ ) |
|
67 |
+ COMMAND_NON_EAGER_ARGUMENTS = pytest.mark.parametrize( |
|
68 |
+ ["command", "non_eager_arguments"], |
|
69 |
+ [ |
|
70 |
+ pytest.param( |
|
71 |
+ [], |
|
72 |
+ [], |
|
73 |
+ id="top-nothing", |
|
74 |
+ ), |
|
75 |
+ pytest.param( |
|
76 |
+ [], |
|
77 |
+ ["export"], |
|
78 |
+ id="top-export", |
|
79 |
+ ), |
|
80 |
+ pytest.param( |
|
81 |
+ ["export"], |
|
82 |
+ [], |
|
83 |
+ id="export-nothing", |
|
84 |
+ ), |
|
85 |
+ pytest.param( |
|
86 |
+ ["export"], |
|
87 |
+ ["vault"], |
|
88 |
+ id="export-vault", |
|
89 |
+ ), |
|
90 |
+ pytest.param( |
|
91 |
+ ["export", "vault"], |
|
92 |
+ [], |
|
93 |
+ id="export-vault-nothing", |
|
94 |
+ ), |
|
95 |
+ pytest.param( |
|
96 |
+ ["export", "vault"], |
|
97 |
+ ["--format", "this-format-doesnt-exist"], |
|
98 |
+ id="export-vault-args", |
|
99 |
+ ), |
|
100 |
+ pytest.param( |
|
101 |
+ ["vault"], |
|
102 |
+ [], |
|
103 |
+ id="vault-nothing", |
|
104 |
+ ), |
|
105 |
+ pytest.param( |
|
106 |
+ ["vault"], |
|
107 |
+ ["--export", "./"], |
|
108 |
+ id="vault-args", |
|
109 |
+ ), |
|
110 |
+ ], |
|
111 |
+ ) |
|
112 |
+ COLORFUL_COMMAND_INPUT = pytest.mark.parametrize( |
|
113 |
+ ["command_line", "input"], |
|
114 |
+ [ |
|
115 |
+ ( |
|
116 |
+ ["vault", "--import", "-"], |
|
117 |
+ '{"services": {"": {"length": 20}}}', |
|
118 |
+ ), |
|
119 |
+ ], |
|
120 |
+ ids=["cmd"], |
|
121 |
+ ) |
|
122 |
+ ISATTY = pytest.mark.parametrize( |
|
123 |
+ "isatty", |
|
124 |
+ [False, True], |
|
125 |
+ ids=["notty", "tty"], |
|
126 |
+ ) |
|
127 |
+ MASK_PROG_NAME = pytest.mark.parametrize("mask_prog_name", [False, True]) |
|
128 |
+ MASK_VERSION = pytest.mark.parametrize("mask_version", [False, True]) |
|
129 |
+ VERSION_OUTPUT_DATA = pytest.mark.parametrize( |
|
130 |
+ ["version_output", "prog_name", "version", "expected_parse"], |
|
131 |
+ [ |
|
132 |
+ pytest.param( |
|
133 |
+ """\ |
|
134 |
+derivepassphrase 0.4.0 |
|
135 |
+Using cryptography 44.0.0 |
|
136 |
+ |
|
137 |
+Supported foreign configuration formats: vault storeroom, vault v0.2, |
|
138 |
+ vault v0.3. |
|
139 |
+PEP 508 extras: export. |
|
140 |
+""", |
|
141 |
+ "derivepassphrase", |
|
142 |
+ "0.4.0", |
|
143 |
+ VersionOutputData( |
|
144 |
+ derivation_schemes={}, |
|
145 |
+ foreign_configuration_formats={ |
|
146 |
+ "vault storeroom": True, |
|
147 |
+ "vault v0.2": True, |
|
148 |
+ "vault v0.3": True, |
|
149 |
+ }, |
|
150 |
+ subcommands=frozenset(), |
|
151 |
+ features={}, |
|
152 |
+ extras=frozenset({"export"}), |
|
153 |
+ ), |
|
154 |
+ id="derivepassphrase-0.4.0-export", |
|
155 |
+ ), |
|
156 |
+ pytest.param( |
|
157 |
+ """\ |
|
158 |
+derivepassphrase 0.5 |
|
159 |
+ |
|
160 |
+Supported derivation schemes: vault. |
|
161 |
+Known foreign configuration formats: vault storeroom, vault v0.2, vault v0.3. |
|
162 |
+Supported subcommands: export, vault. |
|
163 |
+No PEP 508 extras are active. |
|
164 |
+""", |
|
165 |
+ "derivepassphrase", |
|
166 |
+ "0.5", |
|
167 |
+ VersionOutputData( |
|
168 |
+ derivation_schemes={"vault": True}, |
|
169 |
+ foreign_configuration_formats={ |
|
170 |
+ "vault storeroom": False, |
|
171 |
+ "vault v0.2": False, |
|
172 |
+ "vault v0.3": False, |
|
173 |
+ }, |
|
174 |
+ subcommands=frozenset({"export", "vault"}), |
|
175 |
+ features={}, |
|
176 |
+ extras=frozenset({}), |
|
177 |
+ ), |
|
178 |
+ id="derivepassphrase-0.5-plain", |
|
179 |
+ ), |
|
180 |
+ pytest.param( |
|
181 |
+ """\ |
|
182 |
+ |
|
183 |
+ |
|
184 |
+ |
|
185 |
+inventpassphrase -1.3 |
|
186 |
+Using not-a-library 7.12 |
|
187 |
+Copyright 2025 Nobody. All rights reserved. |
|
188 |
+ |
|
189 |
+Supported derivation schemes: nonsense. |
|
190 |
+Known derivation schemes: divination, /dev/random, |
|
191 |
+ geiger counter, |
|
192 |
+ crossword solver. |
|
193 |
+Supported foreign configuration formats: derivepassphrase, nonsense. |
|
194 |
+Known foreign configuration formats: divination v3.141592, |
|
195 |
+ /dev/random. |
|
196 |
+Supported subcommands: delete-all-files, dump-core. |
|
197 |
+Supported features: delete-while-open. |
|
198 |
+Known features: backups-are-nice-to-have. |
|
199 |
+PEP 508 extras: annoying-popups, delete-all-files, |
|
200 |
+ dump-core-depending-on-the-phase-of-the-moon. |
|
201 |
+ |
|
202 |
+ |
|
203 |
+ |
|
204 |
+""", |
|
205 |
+ "inventpassphrase", |
|
206 |
+ "-1.3", |
|
207 |
+ VersionOutputData( |
|
208 |
+ derivation_schemes={ |
|
209 |
+ "nonsense": True, |
|
210 |
+ "divination": False, |
|
211 |
+ "/dev/random": False, |
|
212 |
+ "geiger counter": False, |
|
213 |
+ "crossword solver": False, |
|
214 |
+ }, |
|
215 |
+ foreign_configuration_formats={ |
|
216 |
+ "derivepassphrase": True, |
|
217 |
+ "nonsense": True, |
|
218 |
+ "divination v3.141592": False, |
|
219 |
+ "/dev/random": False, |
|
220 |
+ }, |
|
221 |
+ subcommands=frozenset({"delete-all-files", "dump-core"}), |
|
222 |
+ features={ |
|
223 |
+ "delete-while-open": True, |
|
224 |
+ "backups-are-nice-to-have": False, |
|
225 |
+ }, |
|
226 |
+ extras=frozenset({ |
|
227 |
+ "annoying-popups", |
|
228 |
+ "delete-all-files", |
|
229 |
+ "dump-core-depending-on-the-phase-of-the-moon", |
|
230 |
+ }), |
|
231 |
+ ), |
|
232 |
+ id="inventpassphrase", |
|
233 |
+ ), |
|
234 |
+ ], |
|
235 |
+ ) |
|
236 |
+ """Sample data for [`parse_version_output`][].""" |
|
237 |
+ |
|
238 |
+ |
|
239 |
+def parse_version_output( # noqa: C901 |
|
240 |
+ version_output: str, |
|
241 |
+ /, |
|
242 |
+ *, |
|
243 |
+ prog_name: str | None = cli_messages.PROG_NAME, |
|
244 |
+ version: str | None = cli_messages.VERSION, |
|
245 |
+) -> VersionOutputData: |
|
246 |
+ r"""Parse the output of the `--version` option. |
|
247 |
+ |
|
248 |
+ The version output contains two paragraphs. The first paragraph |
|
249 |
+ details the version number, and the version number of any major |
|
250 |
+ libraries in use. The second paragraph details known and supported |
|
251 |
+ passphrase derivation schemes, foreign configuration formats, |
|
252 |
+ subcommands and PEP 508 package extras. For the schemes and |
|
253 |
+ formats, there is a "supported" line for supported items, and |
|
254 |
+ a "known" line for known but currently unsupported items (usually |
|
255 |
+ because of missing dependencies), either of which may be empty and |
|
256 |
+ thus omitted. For extras, only active items are shown, and there is |
|
257 |
+ a separate message for the "no extras active" case. Item lists may |
|
258 |
+ be spilled across multiple lines, but only at item boundaries, and |
|
259 |
+ the continuation lines are then indented. |
|
260 |
+ |
|
261 |
+ Args: |
|
262 |
+ version_output: |
|
263 |
+ The version output text to parse. |
|
264 |
+ prog_name: |
|
265 |
+ The program name to assert, defaulting to the true program |
|
266 |
+ name, `derivepassphrase`. Set to `None` to disable this |
|
267 |
+ check. |
|
268 |
+ version: |
|
269 |
+ The program version to assert, defaulting to the true |
|
270 |
+ current version of `derivepassphrase`. Set to `None` to |
|
271 |
+ disable this check. |
|
272 |
+ |
|
273 |
+ Examples: |
|
274 |
+ See [`Parametrize.VERSION_OUTPUT_DATA`][]. |
|
275 |
+ |
|
276 |
+ """ |
|
277 |
+ paragraphs: list[list[str]] = [] |
|
278 |
+ paragraph: list[str] = [] |
|
279 |
+ for line in version_output.splitlines(keepends=False): |
|
280 |
+ if not line.strip(): |
|
281 |
+ if paragraph: |
|
282 |
+ paragraphs.append(paragraph.copy()) |
|
283 |
+ paragraph.clear() |
|
284 |
+ elif paragraph and line.lstrip() != line: |
|
285 |
+ paragraph[-1] = f"{paragraph[-1]} {line.lstrip()}" |
|
286 |
+ else: |
|
287 |
+ paragraph.append(line) |
|
288 |
+ if paragraph: # pragma: no branch |
|
289 |
+ paragraphs.append(paragraph.copy()) |
|
290 |
+ paragraph.clear() |
|
291 |
+ assert paragraphs, ( |
|
292 |
+ f"expected at least one paragraph of version output: {paragraphs!r}" |
|
293 |
+ ) |
|
294 |
+ assert prog_name is None or prog_name in paragraphs[0][0], ( |
|
295 |
+ f"first version output line should mention " |
|
296 |
+ f"{prog_name}: {paragraphs[0][0]!r}" |
|
297 |
+ ) |
|
298 |
+ assert version is None or version in paragraphs[0][0], ( |
|
299 |
+ f"first version output line should mention the version number " |
|
300 |
+ f"{version}: {paragraphs[0][0]!r}" |
|
301 |
+ ) |
|
302 |
+ schemes: dict[str, bool] = {} |
|
303 |
+ formats: dict[str, bool] = {} |
|
304 |
+ subcommands: set[str] = set() |
|
305 |
+ extras: set[str] = set() |
|
306 |
+ features: dict[str, bool] = {} |
|
307 |
+ if len(paragraphs) < 2: # pragma: no cover |
|
308 |
+ return VersionOutputData( |
|
309 |
+ derivation_schemes=schemes, |
|
310 |
+ foreign_configuration_formats=formats, |
|
311 |
+ subcommands=frozenset(subcommands), |
|
312 |
+ extras=frozenset(extras), |
|
313 |
+ features=features, |
|
314 |
+ ) |
|
315 |
+ for line in paragraphs[1]: |
|
316 |
+ line_type, _, value = line.partition(":") |
|
317 |
+ if line_type == line: |
|
318 |
+ continue |
|
319 |
+ for item_ in re.split(r"(?:, *|.$)", value): |
|
320 |
+ item = item_.strip() |
|
321 |
+ if not item: |
|
322 |
+ continue |
|
323 |
+ if line_type == KnownLineType.SUPPORTED_FOREIGN_CONFS: |
|
324 |
+ formats[item] = True |
|
325 |
+ elif line_type == KnownLineType.UNAVAILABLE_FOREIGN_CONFS: |
|
326 |
+ formats[item] = False |
|
327 |
+ elif line_type == KnownLineType.SUPPORTED_SCHEMES: |
|
328 |
+ schemes[item] = True |
|
329 |
+ elif line_type == KnownLineType.UNAVAILABLE_SCHEMES: |
|
330 |
+ schemes[item] = False |
|
331 |
+ elif line_type == KnownLineType.SUPPORTED_SUBCOMMANDS: |
|
332 |
+ subcommands.add(item) |
|
333 |
+ elif line_type == KnownLineType.ENABLED_EXTRAS: |
|
334 |
+ extras.add(item) |
|
335 |
+ elif line_type == KnownLineType.SUPPORTED_FEATURES: |
|
336 |
+ features[item] = True |
|
337 |
+ elif line_type == KnownLineType.UNAVAILABLE_FEATURES: |
|
338 |
+ features[item] = False |
|
339 |
+ else: |
|
340 |
+ raise AssertionError( # noqa: TRY003 |
|
341 |
+ f"Unknown version info line type: {line_type!r}" # noqa: EM102 |
|
342 |
+ ) |
|
343 |
+ return VersionOutputData( |
|
344 |
+ derivation_schemes=schemes, |
|
345 |
+ foreign_configuration_formats=formats, |
|
346 |
+ subcommands=frozenset(subcommands), |
|
347 |
+ extras=frozenset(extras), |
|
348 |
+ features=features, |
|
349 |
+ ) |
|
350 |
+ |
|
351 |
+ |
|
352 |
+class TestAllCLI: |
|
353 |
+ """Tests uniformly for all command-line interfaces.""" |
|
354 |
+ |
|
355 |
+ @Parametrize.MASK_PROG_NAME |
|
356 |
+ @Parametrize.MASK_VERSION |
|
357 |
+ @Parametrize.VERSION_OUTPUT_DATA |
|
358 |
+ def test_001_parse_version_output( |
|
359 |
+ self, |
|
360 |
+ version_output: str, |
|
361 |
+ prog_name: str | None, |
|
362 |
+ version: str | None, |
|
363 |
+ mask_prog_name: bool, |
|
364 |
+ mask_version: bool, |
|
365 |
+ expected_parse: VersionOutputData, |
|
366 |
+ ) -> None: |
|
367 |
+ """The parsing machinery for expected version output data works.""" |
|
368 |
+ prog_name = None if mask_prog_name else prog_name |
|
369 |
+ version = None if mask_version else version |
|
370 |
+ assert ( |
|
371 |
+ parse_version_output( |
|
372 |
+ version_output, prog_name=prog_name, version=version |
|
373 |
+ ) |
|
374 |
+ == expected_parse |
|
375 |
+ ) |
|
376 |
+ |
|
377 |
+ # TODO(the-13th-letter): Do we actually need this? What should we |
|
378 |
+ # check for? |
|
379 |
+ def test_100_help_output(self) -> None: |
|
380 |
+ """The top-level help text mentions subcommands. |
|
381 |
+ |
|
382 |
+ TODO: Do we actually need this? What should we check for? |
|
383 |
+ |
|
384 |
+ """ |
|
385 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
386 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
387 |
+ # with-statements. |
|
388 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
389 |
+ with contextlib.ExitStack() as stack: |
|
390 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
391 |
+ stack.enter_context( |
|
392 |
+ pytest_machinery.isolated_config( |
|
393 |
+ monkeypatch=monkeypatch, |
|
394 |
+ runner=runner, |
|
395 |
+ ) |
|
396 |
+ ) |
|
397 |
+ result = runner.invoke( |
|
398 |
+ cli.derivepassphrase, ["--help"], catch_exceptions=False |
|
399 |
+ ) |
|
400 |
+ assert result.clean_exit( |
|
401 |
+ empty_stderr=True, output="currently implemented subcommands" |
|
402 |
+ ), "expected clean exit, and known help text" |
|
403 |
+ |
|
404 |
+ # TODO(the-13th-letter): Do we actually need this? What should we |
|
405 |
+ # check for? |
|
406 |
+ def test_101_help_output_export( |
|
407 |
+ self, |
|
408 |
+ ) -> None: |
|
409 |
+ """The "export" subcommand help text mentions subcommands. |
|
410 |
+ |
|
411 |
+ TODO: Do we actually need this? What should we check for? |
|
412 |
+ |
|
413 |
+ """ |
|
414 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
415 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
416 |
+ # with-statements. |
|
417 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
418 |
+ with contextlib.ExitStack() as stack: |
|
419 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
420 |
+ stack.enter_context( |
|
421 |
+ pytest_machinery.isolated_config( |
|
422 |
+ monkeypatch=monkeypatch, |
|
423 |
+ runner=runner, |
|
424 |
+ ) |
|
425 |
+ ) |
|
426 |
+ result = runner.invoke( |
|
427 |
+ cli.derivepassphrase, |
|
428 |
+ ["export", "--help"], |
|
429 |
+ catch_exceptions=False, |
|
430 |
+ ) |
|
431 |
+ assert result.clean_exit( |
|
432 |
+ empty_stderr=True, output="only available subcommand" |
|
433 |
+ ), "expected clean exit, and known help text" |
|
434 |
+ |
|
435 |
+ # TODO(the-13th-letter): Do we actually need this? What should we |
|
436 |
+ # check for? |
|
437 |
+ def test_102_help_output_export_vault( |
|
438 |
+ self, |
|
439 |
+ ) -> None: |
|
440 |
+ """The "export vault" subcommand help text has known content. |
|
441 |
+ |
|
442 |
+ TODO: Do we actually need this? What should we check for? |
|
443 |
+ |
|
444 |
+ """ |
|
445 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
446 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
447 |
+ # with-statements. |
|
448 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
449 |
+ with contextlib.ExitStack() as stack: |
|
450 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
451 |
+ stack.enter_context( |
|
452 |
+ pytest_machinery.isolated_config( |
|
453 |
+ monkeypatch=monkeypatch, |
|
454 |
+ runner=runner, |
|
455 |
+ ) |
|
456 |
+ ) |
|
457 |
+ result = runner.invoke( |
|
458 |
+ cli.derivepassphrase, |
|
459 |
+ ["export", "vault", "--help"], |
|
460 |
+ catch_exceptions=False, |
|
461 |
+ ) |
|
462 |
+ assert result.clean_exit( |
|
463 |
+ empty_stderr=True, output="Export a vault-native configuration" |
|
464 |
+ ), "expected clean exit, and known help text" |
|
465 |
+ |
|
466 |
+ # TODO(the-13th-letter): Do we actually need this? What should we |
|
467 |
+ # check for? |
|
468 |
+ def test_103_help_output_vault( |
|
469 |
+ self, |
|
470 |
+ ) -> None: |
|
471 |
+ """The "vault" subcommand help text has known content. |
|
472 |
+ |
|
473 |
+ TODO: Do we actually need this? What should we check for? |
|
474 |
+ |
|
475 |
+ """ |
|
476 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
477 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
478 |
+ # with-statements. |
|
479 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
480 |
+ with contextlib.ExitStack() as stack: |
|
481 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
482 |
+ stack.enter_context( |
|
483 |
+ pytest_machinery.isolated_config( |
|
484 |
+ monkeypatch=monkeypatch, |
|
485 |
+ runner=runner, |
|
486 |
+ ) |
|
487 |
+ ) |
|
488 |
+ result = runner.invoke( |
|
489 |
+ cli.derivepassphrase, |
|
490 |
+ ["vault", "--help"], |
|
491 |
+ catch_exceptions=False, |
|
492 |
+ ) |
|
493 |
+ assert result.clean_exit( |
|
494 |
+ empty_stderr=True, output="Passphrase generation:\n" |
|
495 |
+ ), "expected clean exit, and option groups in help text" |
|
496 |
+ assert result.clean_exit( |
|
497 |
+ empty_stderr=True, output="Use $VISUAL or $EDITOR to configure" |
|
498 |
+ ), "expected clean exit, and option group epilog in help text" |
|
499 |
+ |
|
500 |
+ @Parametrize.COMMAND_NON_EAGER_ARGUMENTS |
|
501 |
+ @Parametrize.EAGER_ARGUMENTS |
|
502 |
+ def test_200_eager_options( |
|
503 |
+ self, |
|
504 |
+ command: list[str], |
|
505 |
+ arguments: list[str], |
|
506 |
+ non_eager_arguments: list[str], |
|
507 |
+ ) -> None: |
|
508 |
+ """Eager options terminate option and argument processing.""" |
|
509 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
510 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
511 |
+ # with-statements. |
|
512 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
513 |
+ with contextlib.ExitStack() as stack: |
|
514 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
515 |
+ stack.enter_context( |
|
516 |
+ pytest_machinery.isolated_config( |
|
517 |
+ monkeypatch=monkeypatch, |
|
518 |
+ runner=runner, |
|
519 |
+ ) |
|
520 |
+ ) |
|
521 |
+ result = runner.invoke( |
|
522 |
+ cli.derivepassphrase, |
|
523 |
+ [*command, *arguments, *non_eager_arguments], |
|
524 |
+ catch_exceptions=False, |
|
525 |
+ ) |
|
526 |
+ assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
527 |
+ |
|
528 |
+ @Parametrize.ISATTY |
|
529 |
+ @Parametrize.COLORFUL_COMMAND_INPUT |
|
530 |
+ def test_201_automatic_color_mode( |
|
531 |
+ self, |
|
532 |
+ isatty: bool, |
|
533 |
+ command_line: list[str], |
|
534 |
+ input: str | None, |
|
535 |
+ ) -> None: |
|
536 |
+ """Auto-detect if color should be used. |
|
537 |
+ |
|
538 |
+ (The answer currently is always no. See the |
|
539 |
+ [`conventional-configurable-text-styling` wishlist |
|
540 |
+ entry](../wishlist/conventional-configurable-text-styling.md).) |
|
541 |
+ |
|
542 |
+ """ |
|
543 |
+ color = False |
|
544 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
545 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
546 |
+ # with-statements. |
|
547 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
548 |
+ with contextlib.ExitStack() as stack: |
|
549 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
550 |
+ stack.enter_context( |
|
551 |
+ pytest_machinery.isolated_config( |
|
552 |
+ monkeypatch=monkeypatch, |
|
553 |
+ runner=runner, |
|
554 |
+ ) |
|
555 |
+ ) |
|
556 |
+ result = runner.invoke( |
|
557 |
+ cli.derivepassphrase, |
|
558 |
+ command_line, |
|
559 |
+ input=input, |
|
560 |
+ catch_exceptions=False, |
|
561 |
+ color=isatty, |
|
562 |
+ ) |
|
563 |
+ assert ( |
|
564 |
+ not color |
|
565 |
+ or "\x1b[0m" in result.stderr |
|
566 |
+ or "\x1b[m" in result.stderr |
|
567 |
+ ), "Expected color, but found no ANSI reset sequence" |
|
568 |
+ assert color or "\x1b[" not in result.stderr, ( |
|
569 |
+ "Expected no color, but found an ANSI control sequence" |
|
570 |
+ ) |
|
571 |
+ |
|
572 |
+ def test_202a_derivepassphrase_version_option_output( |
|
573 |
+ self, |
|
574 |
+ ) -> None: |
|
575 |
+ """The version output states supported features. |
|
576 |
+ |
|
577 |
+ The version output is parsed using [`parse_version_output`][]. |
|
578 |
+ Format examples can be found in |
|
579 |
+ [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the |
|
580 |
+ top-level `derivepassphrase` command, the output should contain |
|
581 |
+ the known and supported derivation schemes, and a list of |
|
582 |
+ subcommands. |
|
583 |
+ |
|
584 |
+ """ |
|
585 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
586 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
587 |
+ # with-statements. |
|
588 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
589 |
+ with contextlib.ExitStack() as stack: |
|
590 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
591 |
+ stack.enter_context( |
|
592 |
+ pytest_machinery.isolated_config( |
|
593 |
+ monkeypatch=monkeypatch, |
|
594 |
+ runner=runner, |
|
595 |
+ ) |
|
596 |
+ ) |
|
597 |
+ result = runner.invoke( |
|
598 |
+ cli.derivepassphrase, |
|
599 |
+ ["--version"], |
|
600 |
+ catch_exceptions=False, |
|
601 |
+ ) |
|
602 |
+ assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
603 |
+ assert result.stdout.strip(), "expected version output" |
|
604 |
+ version_data = parse_version_output(result.stdout) |
|
605 |
+ actually_known_schemes = dict.fromkeys(_types.DerivationScheme, True) |
|
606 |
+ subcommands = set(_types.Subcommand) |
|
607 |
+ assert version_data.derivation_schemes == actually_known_schemes |
|
608 |
+ assert not version_data.foreign_configuration_formats |
|
609 |
+ assert version_data.subcommands == subcommands |
|
610 |
+ assert not version_data.features |
|
611 |
+ assert not version_data.extras |
|
612 |
+ |
|
613 |
+ def test_202b_export_version_option_output( |
|
614 |
+ self, |
|
615 |
+ ) -> None: |
|
616 |
+ """The version output states supported features. |
|
617 |
+ |
|
618 |
+ The version output is parsed using [`parse_version_output`][]. |
|
619 |
+ Format examples can be found in |
|
620 |
+ [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the |
|
621 |
+ `export` command, the output should contain the known foreign |
|
622 |
+ configuration formats (but not marked as supported), and a list |
|
623 |
+ of subcommands. |
|
624 |
+ |
|
625 |
+ """ |
|
626 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
627 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
628 |
+ # with-statements. |
|
629 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
630 |
+ with contextlib.ExitStack() as stack: |
|
631 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
632 |
+ stack.enter_context( |
|
633 |
+ pytest_machinery.isolated_config( |
|
634 |
+ monkeypatch=monkeypatch, |
|
635 |
+ runner=runner, |
|
636 |
+ ) |
|
637 |
+ ) |
|
638 |
+ result = runner.invoke( |
|
639 |
+ cli.derivepassphrase, |
|
640 |
+ ["export", "--version"], |
|
641 |
+ catch_exceptions=False, |
|
642 |
+ ) |
|
643 |
+ assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
644 |
+ assert result.stdout.strip(), "expected version output" |
|
645 |
+ version_data = parse_version_output(result.stdout) |
|
646 |
+ actually_known_formats: dict[str, bool] = { |
|
647 |
+ _types.ForeignConfigurationFormat.VAULT_STOREROOM: False, |
|
648 |
+ _types.ForeignConfigurationFormat.VAULT_V02: False, |
|
649 |
+ _types.ForeignConfigurationFormat.VAULT_V03: False, |
|
650 |
+ } |
|
651 |
+ subcommands = set(_types.ExportSubcommand) |
|
652 |
+ assert not version_data.derivation_schemes |
|
653 |
+ assert ( |
|
654 |
+ version_data.foreign_configuration_formats |
|
655 |
+ == actually_known_formats |
|
656 |
+ ) |
|
657 |
+ assert version_data.subcommands == subcommands |
|
658 |
+ assert not version_data.features |
|
659 |
+ assert not version_data.extras |
|
660 |
+ |
|
661 |
+ def test_202c_export_vault_version_option_output( |
|
662 |
+ self, |
|
663 |
+ ) -> None: |
|
664 |
+ """The version output states supported features. |
|
665 |
+ |
|
666 |
+ The version output is parsed using [`parse_version_output`][]. |
|
667 |
+ Format examples can be found in |
|
668 |
+ [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the |
|
669 |
+ `export vault` subcommand, the output should contain the |
|
670 |
+ vault-specific subset of the known or supported foreign |
|
671 |
+ configuration formats, and a list of available PEP 508 extras. |
|
672 |
+ |
|
673 |
+ """ |
|
674 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
675 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
676 |
+ # with-statements. |
|
677 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
678 |
+ with contextlib.ExitStack() as stack: |
|
679 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
680 |
+ stack.enter_context( |
|
681 |
+ pytest_machinery.isolated_config( |
|
682 |
+ monkeypatch=monkeypatch, |
|
683 |
+ runner=runner, |
|
684 |
+ ) |
|
685 |
+ ) |
|
686 |
+ result = runner.invoke( |
|
687 |
+ cli.derivepassphrase, |
|
688 |
+ ["export", "vault", "--version"], |
|
689 |
+ catch_exceptions=False, |
|
690 |
+ ) |
|
691 |
+ assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
692 |
+ assert result.stdout.strip(), "expected version output" |
|
693 |
+ version_data = parse_version_output(result.stdout) |
|
694 |
+ actually_known_formats: dict[str, bool] = {} |
|
695 |
+ actually_enabled_extras: set[str] = set() |
|
696 |
+ with contextlib.suppress(ModuleNotFoundError): |
|
697 |
+ from derivepassphrase.exporter import storeroom, vault_native # noqa: I001,PLC0415 |
|
698 |
+ |
|
699 |
+ actually_known_formats.update({ |
|
700 |
+ _types.ForeignConfigurationFormat.VAULT_STOREROOM: not storeroom.STUBBED, |
|
701 |
+ _types.ForeignConfigurationFormat.VAULT_V02: not vault_native.STUBBED, |
|
702 |
+ _types.ForeignConfigurationFormat.VAULT_V03: not vault_native.STUBBED, |
|
703 |
+ }) |
|
704 |
+ with contextlib.suppress(ModuleNotFoundError): |
|
705 |
+ import cryptography # noqa: F401,PLC0415 |
|
706 |
+ |
|
707 |
+ actually_enabled_extras.add(_types.PEP508Extra.EXPORT) |
|
708 |
+ assert not version_data.derivation_schemes |
|
709 |
+ assert ( |
|
710 |
+ version_data.foreign_configuration_formats |
|
711 |
+ == actually_known_formats |
|
712 |
+ ) |
|
713 |
+ assert not version_data.subcommands |
|
714 |
+ assert not version_data.features |
|
715 |
+ assert version_data.extras == actually_enabled_extras |
|
716 |
+ |
|
717 |
+ def test_202d_vault_version_option_output( |
|
718 |
+ self, |
|
719 |
+ ) -> None: |
|
720 |
+ """The version output states supported features. |
|
721 |
+ |
|
722 |
+ The version output is parsed using [`parse_version_output`][]. |
|
723 |
+ Format examples can be found in |
|
724 |
+ [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the |
|
725 |
+ vault command, the output should not contain anything beyond the |
|
726 |
+ first paragraph. |
|
727 |
+ |
|
728 |
+ """ |
|
729 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
730 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
731 |
+ # with-statements. |
|
732 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
733 |
+ with contextlib.ExitStack() as stack: |
|
734 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
735 |
+ stack.enter_context( |
|
736 |
+ pytest_machinery.isolated_config( |
|
737 |
+ monkeypatch=monkeypatch, |
|
738 |
+ runner=runner, |
|
739 |
+ ) |
|
740 |
+ ) |
|
741 |
+ result = runner.invoke( |
|
742 |
+ cli.derivepassphrase, |
|
743 |
+ ["vault", "--version"], |
|
744 |
+ catch_exceptions=False, |
|
745 |
+ ) |
|
746 |
+ assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
747 |
+ assert result.stdout.strip(), "expected version output" |
|
748 |
+ version_data = parse_version_output(result.stdout) |
|
749 |
+ |
|
750 |
+ ssh_key_supported = True |
|
751 |
+ |
|
752 |
+ def react_to_notimplementederror( |
|
753 |
+ _exc: BaseException, |
|
754 |
+ ) -> None: # pragma: no cover[unused] |
|
755 |
+ nonlocal ssh_key_supported |
|
756 |
+ ssh_key_supported = False |
|
757 |
+ |
|
758 |
+ with exceptiongroup.catch({ # noqa: SIM117 |
|
759 |
+ NotImplementedError: react_to_notimplementederror, |
|
760 |
+ Exception: lambda *_args: None, |
|
761 |
+ }): |
|
762 |
+ with ssh_agent.SSHAgentClient.ensure_agent_subcontext(): |
|
763 |
+ pass |
|
764 |
+ features: dict[str, bool] = { |
|
765 |
+ _types.Feature.SSH_KEY: ssh_key_supported, |
|
766 |
+ } |
|
767 |
+ assert not version_data.derivation_schemes |
|
768 |
+ assert not version_data.foreign_configuration_formats |
|
769 |
+ assert not version_data.subcommands |
|
770 |
+ assert version_data.features == features |
|
771 |
+ assert not version_data.extras |
... | ... |
@@ -0,0 +1,835 @@ |
1 |
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
|
2 |
+# |
|
3 |
+# SPDX-License-Identifier: Zlib |
|
4 |
+ |
|
5 |
+from __future__ import annotations |
|
6 |
+ |
|
7 |
+import contextlib |
|
8 |
+import json |
|
9 |
+import types |
|
10 |
+from typing import TYPE_CHECKING |
|
11 |
+ |
|
12 |
+import click.testing |
|
13 |
+import pytest |
|
14 |
+from typing_extensions import Any |
|
15 |
+ |
|
16 |
+from derivepassphrase import _types, cli |
|
17 |
+from derivepassphrase._internals import cli_helpers |
|
18 |
+from tests import data, machinery |
|
19 |
+from tests.machinery import pytest as pytest_machinery |
|
20 |
+ |
|
21 |
+if TYPE_CHECKING: |
|
22 |
+ from collections.abc import Callable, Sequence |
|
23 |
+ from collections.abc import Set as AbstractSet |
|
24 |
+ from typing import NoReturn |
|
25 |
+ |
|
26 |
+ from typing_extensions import Literal |
|
27 |
+ |
|
28 |
+DUMMY_SERVICE = data.DUMMY_SERVICE |
|
29 |
+DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS |
|
30 |
+ |
|
31 |
+ |
|
32 |
+def bash_format(item: click.shell_completion.CompletionItem) -> str: |
|
33 |
+ """A formatter for `bash`-style shell completion items. |
|
34 |
+ |
|
35 |
+ The format is `type,value`, and is dictated by [`click`][]. |
|
36 |
+ |
|
37 |
+ """ |
|
38 |
+ type, value = ( # noqa: A001 |
|
39 |
+ item.type, |
|
40 |
+ item.value, |
|
41 |
+ ) |
|
42 |
+ return f"{type},{value}" |
|
43 |
+ |
|
44 |
+ |
|
45 |
+def fish_format(item: click.shell_completion.CompletionItem) -> str: |
|
46 |
+ r"""A formatter for `fish`-style shell completion items. |
|
47 |
+ |
|
48 |
+ The format is `type,value<tab>help`, and is dictated by [`click`][]. |
|
49 |
+ |
|
50 |
+ """ |
|
51 |
+ type, value, help = ( # noqa: A001 |
|
52 |
+ item.type, |
|
53 |
+ item.value, |
|
54 |
+ item.help, |
|
55 |
+ ) |
|
56 |
+ return f"{type},{value}\t{help}" if help else f"{type},{value}" |
|
57 |
+ |
|
58 |
+ |
|
59 |
+def zsh_format(item: click.shell_completion.CompletionItem) -> str: |
|
60 |
+ r"""A formatter for `zsh`-style shell completion items. |
|
61 |
+ |
|
62 |
+ The format is `type<newline>value<newline>help<newline>`, and is |
|
63 |
+ dictated by [`click`][]. Upstream `click` currently (v8.2.0) does |
|
64 |
+ not deal with colons in the value correctly when the help text is |
|
65 |
+ non-degenerate. Our formatter here does, provided the upstream |
|
66 |
+ `zsh` completion script is used; see the |
|
67 |
+ [`cli_machinery.ZshComplete`][] class. A request is underway to |
|
68 |
+ merge this change into upstream `click`; see |
|
69 |
+ [`pallets/click#2846`][PR2846]. |
|
70 |
+ |
|
71 |
+ [PR2846]: https://github.com/pallets/click/pull/2846 |
|
72 |
+ |
|
73 |
+ """ |
|
74 |
+ empty_help = "_" |
|
75 |
+ help_, value = ( |
|
76 |
+ (item.help, item.value.replace(":", r"\:")) |
|
77 |
+ if item.help and item.help == empty_help |
|
78 |
+ else (empty_help, item.value) |
|
79 |
+ ) |
|
80 |
+ return f"{item.type}\n{value}\n{help_}" |
|
81 |
+ |
|
82 |
+ |
|
83 |
+def completion_item( |
|
84 |
+ item: str | click.shell_completion.CompletionItem, |
|
85 |
+) -> click.shell_completion.CompletionItem: |
|
86 |
+ """Convert a string to a completion item, if necessary.""" |
|
87 |
+ return ( |
|
88 |
+ click.shell_completion.CompletionItem(item, type="plain") |
|
89 |
+ if isinstance(item, str) |
|
90 |
+ else item |
|
91 |
+ ) |
|
92 |
+ |
|
93 |
+ |
|
94 |
+def assertable_item( |
|
95 |
+ item: str | click.shell_completion.CompletionItem, |
|
96 |
+) -> tuple[str, Any, str | None]: |
|
97 |
+ """Convert a completion item into a pretty-printable item. |
|
98 |
+ |
|
99 |
+ Intended to make completion items introspectable in pytest's |
|
100 |
+ `assert` output. |
|
101 |
+ |
|
102 |
+ """ |
|
103 |
+ item = completion_item(item) |
|
104 |
+ return (item.type, item.value, item.help) |
|
105 |
+ |
|
106 |
+ |
|
107 |
+class Parametrize(types.SimpleNamespace): |
|
108 |
+ """Common test parametrizations.""" |
|
109 |
+ |
|
110 |
+ COMPLETABLE_PATH_ARGUMENT = pytest.mark.parametrize( |
|
111 |
+ "command_prefix", |
|
112 |
+ [ |
|
113 |
+ pytest.param( |
|
114 |
+ ("export", "vault"), |
|
115 |
+ id="derivepassphrase-export-vault", |
|
116 |
+ ), |
|
117 |
+ pytest.param( |
|
118 |
+ ("vault", "--export"), |
|
119 |
+ id="derivepassphrase-vault--export", |
|
120 |
+ ), |
|
121 |
+ pytest.param( |
|
122 |
+ ("vault", "--import"), |
|
123 |
+ id="derivepassphrase-vault--import", |
|
124 |
+ ), |
|
125 |
+ ], |
|
126 |
+ ) |
|
127 |
+ COMPLETABLE_OPTIONS = pytest.mark.parametrize( |
|
128 |
+ ["command_prefix", "incomplete", "completions"], |
|
129 |
+ [ |
|
130 |
+ pytest.param( |
|
131 |
+ (), |
|
132 |
+ "-", |
|
133 |
+ frozenset({ |
|
134 |
+ "--help", |
|
135 |
+ "-h", |
|
136 |
+ "--version", |
|
137 |
+ "--debug", |
|
138 |
+ "--verbose", |
|
139 |
+ "-v", |
|
140 |
+ "--quiet", |
|
141 |
+ "-q", |
|
142 |
+ }), |
|
143 |
+ id="derivepassphrase", |
|
144 |
+ ), |
|
145 |
+ pytest.param( |
|
146 |
+ ("export",), |
|
147 |
+ "-", |
|
148 |
+ frozenset({ |
|
149 |
+ "--help", |
|
150 |
+ "-h", |
|
151 |
+ "--version", |
|
152 |
+ "--debug", |
|
153 |
+ "--verbose", |
|
154 |
+ "-v", |
|
155 |
+ "--quiet", |
|
156 |
+ "-q", |
|
157 |
+ }), |
|
158 |
+ id="derivepassphrase-export", |
|
159 |
+ ), |
|
160 |
+ pytest.param( |
|
161 |
+ ("export", "vault"), |
|
162 |
+ "-", |
|
163 |
+ frozenset({ |
|
164 |
+ "--help", |
|
165 |
+ "-h", |
|
166 |
+ "--version", |
|
167 |
+ "--debug", |
|
168 |
+ "--verbose", |
|
169 |
+ "-v", |
|
170 |
+ "--quiet", |
|
171 |
+ "-q", |
|
172 |
+ "--format", |
|
173 |
+ "-f", |
|
174 |
+ "--key", |
|
175 |
+ "-k", |
|
176 |
+ }), |
|
177 |
+ id="derivepassphrase-export-vault", |
|
178 |
+ ), |
|
179 |
+ pytest.param( |
|
180 |
+ ("vault",), |
|
181 |
+ "-", |
|
182 |
+ frozenset({ |
|
183 |
+ "--help", |
|
184 |
+ "-h", |
|
185 |
+ "--version", |
|
186 |
+ "--debug", |
|
187 |
+ "--verbose", |
|
188 |
+ "-v", |
|
189 |
+ "--quiet", |
|
190 |
+ "-q", |
|
191 |
+ "--phrase", |
|
192 |
+ "-p", |
|
193 |
+ "--key", |
|
194 |
+ "-k", |
|
195 |
+ "--length", |
|
196 |
+ "-l", |
|
197 |
+ "--repeat", |
|
198 |
+ "-r", |
|
199 |
+ "--upper", |
|
200 |
+ "--lower", |
|
201 |
+ "--number", |
|
202 |
+ "--space", |
|
203 |
+ "--dash", |
|
204 |
+ "--symbol", |
|
205 |
+ "--config", |
|
206 |
+ "-c", |
|
207 |
+ "--notes", |
|
208 |
+ "-n", |
|
209 |
+ "--delete", |
|
210 |
+ "-x", |
|
211 |
+ "--delete-globals", |
|
212 |
+ "--clear", |
|
213 |
+ "-X", |
|
214 |
+ "--export", |
|
215 |
+ "-e", |
|
216 |
+ "--import", |
|
217 |
+ "-i", |
|
218 |
+ "--overwrite-existing", |
|
219 |
+ "--merge-existing", |
|
220 |
+ "--unset", |
|
221 |
+ "--export-as", |
|
222 |
+ "--modern-editor-interface", |
|
223 |
+ "--vault-legacy-editor-interface", |
|
224 |
+ "--print-notes-before", |
|
225 |
+ "--print-notes-after", |
|
226 |
+ }), |
|
227 |
+ id="derivepassphrase-vault", |
|
228 |
+ ), |
|
229 |
+ ], |
|
230 |
+ ) |
|
231 |
+ COMPLETABLE_SUBCOMMANDS = pytest.mark.parametrize( |
|
232 |
+ ["command_prefix", "incomplete", "completions"], |
|
233 |
+ [ |
|
234 |
+ pytest.param( |
|
235 |
+ (), |
|
236 |
+ "", |
|
237 |
+ frozenset({"export", "vault"}), |
|
238 |
+ id="derivepassphrase", |
|
239 |
+ ), |
|
240 |
+ pytest.param( |
|
241 |
+ ("export",), |
|
242 |
+ "", |
|
243 |
+ frozenset({"vault"}), |
|
244 |
+ id="derivepassphrase-export", |
|
245 |
+ ), |
|
246 |
+ ], |
|
247 |
+ ) |
|
248 |
+ COMPLETION_FUNCTION_INPUTS = pytest.mark.parametrize( |
|
249 |
+ ["config", "comp_func", "args", "incomplete", "results"], |
|
250 |
+ [ |
|
251 |
+ pytest.param( |
|
252 |
+ {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}}, |
|
253 |
+ cli_helpers.shell_complete_service, |
|
254 |
+ ["vault"], |
|
255 |
+ "", |
|
256 |
+ [DUMMY_SERVICE], |
|
257 |
+ id="base_config-service", |
|
258 |
+ ), |
|
259 |
+ pytest.param( |
|
260 |
+ {"services": {}}, |
|
261 |
+ cli_helpers.shell_complete_service, |
|
262 |
+ ["vault"], |
|
263 |
+ "", |
|
264 |
+ [], |
|
265 |
+ id="empty_config-service", |
|
266 |
+ ), |
|
267 |
+ pytest.param( |
|
268 |
+ { |
|
269 |
+ "services": { |
|
270 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
271 |
+ "newline\nin\nname": DUMMY_CONFIG_SETTINGS.copy(), |
|
272 |
+ } |
|
273 |
+ }, |
|
274 |
+ cli_helpers.shell_complete_service, |
|
275 |
+ ["vault"], |
|
276 |
+ "", |
|
277 |
+ [DUMMY_SERVICE], |
|
278 |
+ id="incompletable_newline_config-service", |
|
279 |
+ ), |
|
280 |
+ pytest.param( |
|
281 |
+ { |
|
282 |
+ "services": { |
|
283 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
284 |
+ "backspace\bin\bname": DUMMY_CONFIG_SETTINGS.copy(), |
|
285 |
+ } |
|
286 |
+ }, |
|
287 |
+ cli_helpers.shell_complete_service, |
|
288 |
+ ["vault"], |
|
289 |
+ "", |
|
290 |
+ [DUMMY_SERVICE], |
|
291 |
+ id="incompletable_backspace_config-service", |
|
292 |
+ ), |
|
293 |
+ pytest.param( |
|
294 |
+ { |
|
295 |
+ "services": { |
|
296 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
297 |
+ "colon:in:name": DUMMY_CONFIG_SETTINGS.copy(), |
|
298 |
+ } |
|
299 |
+ }, |
|
300 |
+ cli_helpers.shell_complete_service, |
|
301 |
+ ["vault"], |
|
302 |
+ "", |
|
303 |
+ sorted([DUMMY_SERVICE, "colon:in:name"]), |
|
304 |
+ id="brittle_colon_config-service", |
|
305 |
+ ), |
|
306 |
+ pytest.param( |
|
307 |
+ { |
|
308 |
+ "services": { |
|
309 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
310 |
+ "colon:in:name": DUMMY_CONFIG_SETTINGS.copy(), |
|
311 |
+ "newline\nin\nname": DUMMY_CONFIG_SETTINGS.copy(), |
|
312 |
+ "backspace\bin\bname": DUMMY_CONFIG_SETTINGS.copy(), |
|
313 |
+ "nul\x00in\x00name": DUMMY_CONFIG_SETTINGS.copy(), |
|
314 |
+ "del\x7fin\x7fname": DUMMY_CONFIG_SETTINGS.copy(), |
|
315 |
+ } |
|
316 |
+ }, |
|
317 |
+ cli_helpers.shell_complete_service, |
|
318 |
+ ["vault"], |
|
319 |
+ "", |
|
320 |
+ sorted([DUMMY_SERVICE, "colon:in:name"]), |
|
321 |
+ id="brittle_incompletable_multi_config-service", |
|
322 |
+ ), |
|
323 |
+ pytest.param( |
|
324 |
+ {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}}, |
|
325 |
+ cli_helpers.shell_complete_path, |
|
326 |
+ ["vault", "--import"], |
|
327 |
+ "", |
|
328 |
+ [click.shell_completion.CompletionItem("", type="file")], |
|
329 |
+ id="base_config-path", |
|
330 |
+ ), |
|
331 |
+ pytest.param( |
|
332 |
+ {"services": {}}, |
|
333 |
+ cli_helpers.shell_complete_path, |
|
334 |
+ ["vault", "--import"], |
|
335 |
+ "", |
|
336 |
+ [click.shell_completion.CompletionItem("", type="file")], |
|
337 |
+ id="empty_config-path", |
|
338 |
+ ), |
|
339 |
+ ], |
|
340 |
+ ) |
|
341 |
+ COMPLETABLE_SERVICE_NAMES = pytest.mark.parametrize( |
|
342 |
+ ["config", "incomplete", "completions"], |
|
343 |
+ [ |
|
344 |
+ pytest.param( |
|
345 |
+ {"services": {}}, |
|
346 |
+ "", |
|
347 |
+ frozenset(), |
|
348 |
+ id="no_services", |
|
349 |
+ ), |
|
350 |
+ pytest.param( |
|
351 |
+ {"services": {}}, |
|
352 |
+ "partial", |
|
353 |
+ frozenset(), |
|
354 |
+ id="no_services_partial", |
|
355 |
+ ), |
|
356 |
+ pytest.param( |
|
357 |
+ {"services": {DUMMY_SERVICE: {"length": 10}}}, |
|
358 |
+ "", |
|
359 |
+ frozenset({DUMMY_SERVICE}), |
|
360 |
+ id="one_service", |
|
361 |
+ ), |
|
362 |
+ pytest.param( |
|
363 |
+ {"services": {DUMMY_SERVICE: {"length": 10}}}, |
|
364 |
+ DUMMY_SERVICE[:4], |
|
365 |
+ frozenset({DUMMY_SERVICE}), |
|
366 |
+ id="one_service_partial", |
|
367 |
+ ), |
|
368 |
+ pytest.param( |
|
369 |
+ {"services": {DUMMY_SERVICE: {"length": 10}}}, |
|
370 |
+ DUMMY_SERVICE[-4:], |
|
371 |
+ frozenset(), |
|
372 |
+ id="one_service_partial_miss", |
|
373 |
+ ), |
|
374 |
+ ], |
|
375 |
+ ) |
|
376 |
+ SERVICE_NAME_COMPLETION_INPUTS = pytest.mark.parametrize( |
|
377 |
+ ["config", "key", "incomplete", "completions"], |
|
378 |
+ [ |
|
379 |
+ pytest.param( |
|
380 |
+ { |
|
381 |
+ "services": { |
|
382 |
+ DUMMY_SERVICE: {"length": 10}, |
|
383 |
+ "newline\nin\nname": {"length": 10}, |
|
384 |
+ }, |
|
385 |
+ }, |
|
386 |
+ "newline\nin\nname", |
|
387 |
+ "", |
|
388 |
+ frozenset({DUMMY_SERVICE}), |
|
389 |
+ id="newline", |
|
390 |
+ ), |
|
391 |
+ pytest.param( |
|
392 |
+ { |
|
393 |
+ "services": { |
|
394 |
+ DUMMY_SERVICE: {"length": 10}, |
|
395 |
+ "newline\nin\nname": {"length": 10}, |
|
396 |
+ }, |
|
397 |
+ }, |
|
398 |
+ "newline\nin\nname", |
|
399 |
+ "serv", |
|
400 |
+ frozenset({DUMMY_SERVICE}), |
|
401 |
+ id="newline_partial_other", |
|
402 |
+ ), |
|
403 |
+ pytest.param( |
|
404 |
+ { |
|
405 |
+ "services": { |
|
406 |
+ DUMMY_SERVICE: {"length": 10}, |
|
407 |
+ "newline\nin\nname": {"length": 10}, |
|
408 |
+ }, |
|
409 |
+ }, |
|
410 |
+ "newline\nin\nname", |
|
411 |
+ "newline", |
|
412 |
+ frozenset({}), |
|
413 |
+ id="newline_partial_specific", |
|
414 |
+ ), |
|
415 |
+ pytest.param( |
|
416 |
+ { |
|
417 |
+ "services": { |
|
418 |
+ DUMMY_SERVICE: {"length": 10}, |
|
419 |
+ "nul\x00in\x00name": {"length": 10}, |
|
420 |
+ }, |
|
421 |
+ }, |
|
422 |
+ "nul\x00in\x00name", |
|
423 |
+ "", |
|
424 |
+ frozenset({DUMMY_SERVICE}), |
|
425 |
+ id="nul", |
|
426 |
+ ), |
|
427 |
+ pytest.param( |
|
428 |
+ { |
|
429 |
+ "services": { |
|
430 |
+ DUMMY_SERVICE: {"length": 10}, |
|
431 |
+ "nul\x00in\x00name": {"length": 10}, |
|
432 |
+ }, |
|
433 |
+ }, |
|
434 |
+ "nul\x00in\x00name", |
|
435 |
+ "serv", |
|
436 |
+ frozenset({DUMMY_SERVICE}), |
|
437 |
+ id="nul_partial_other", |
|
438 |
+ ), |
|
439 |
+ pytest.param( |
|
440 |
+ { |
|
441 |
+ "services": { |
|
442 |
+ DUMMY_SERVICE: {"length": 10}, |
|
443 |
+ "nul\x00in\x00name": {"length": 10}, |
|
444 |
+ }, |
|
445 |
+ }, |
|
446 |
+ "nul\x00in\x00name", |
|
447 |
+ "nul", |
|
448 |
+ frozenset({}), |
|
449 |
+ id="nul_partial_specific", |
|
450 |
+ ), |
|
451 |
+ pytest.param( |
|
452 |
+ { |
|
453 |
+ "services": { |
|
454 |
+ DUMMY_SERVICE: {"length": 10}, |
|
455 |
+ "backspace\bin\bname": {"length": 10}, |
|
456 |
+ }, |
|
457 |
+ }, |
|
458 |
+ "backspace\bin\bname", |
|
459 |
+ "", |
|
460 |
+ frozenset({DUMMY_SERVICE}), |
|
461 |
+ id="backspace", |
|
462 |
+ ), |
|
463 |
+ pytest.param( |
|
464 |
+ { |
|
465 |
+ "services": { |
|
466 |
+ DUMMY_SERVICE: {"length": 10}, |
|
467 |
+ "backspace\bin\bname": {"length": 10}, |
|
468 |
+ }, |
|
469 |
+ }, |
|
470 |
+ "backspace\bin\bname", |
|
471 |
+ "serv", |
|
472 |
+ frozenset({DUMMY_SERVICE}), |
|
473 |
+ id="backspace_partial_other", |
|
474 |
+ ), |
|
475 |
+ pytest.param( |
|
476 |
+ { |
|
477 |
+ "services": { |
|
478 |
+ DUMMY_SERVICE: {"length": 10}, |
|
479 |
+ "backspace\bin\bname": {"length": 10}, |
|
480 |
+ }, |
|
481 |
+ }, |
|
482 |
+ "backspace\bin\bname", |
|
483 |
+ "back", |
|
484 |
+ frozenset({}), |
|
485 |
+ id="backspace_partial_specific", |
|
486 |
+ ), |
|
487 |
+ pytest.param( |
|
488 |
+ { |
|
489 |
+ "services": { |
|
490 |
+ DUMMY_SERVICE: {"length": 10}, |
|
491 |
+ "del\x7fin\x7fname": {"length": 10}, |
|
492 |
+ }, |
|
493 |
+ }, |
|
494 |
+ "del\x7fin\x7fname", |
|
495 |
+ "", |
|
496 |
+ frozenset({DUMMY_SERVICE}), |
|
497 |
+ id="del", |
|
498 |
+ ), |
|
499 |
+ pytest.param( |
|
500 |
+ { |
|
501 |
+ "services": { |
|
502 |
+ DUMMY_SERVICE: {"length": 10}, |
|
503 |
+ "del\x7fin\x7fname": {"length": 10}, |
|
504 |
+ }, |
|
505 |
+ }, |
|
506 |
+ "del\x7fin\x7fname", |
|
507 |
+ "serv", |
|
508 |
+ frozenset({DUMMY_SERVICE}), |
|
509 |
+ id="del_partial_other", |
|
510 |
+ ), |
|
511 |
+ pytest.param( |
|
512 |
+ { |
|
513 |
+ "services": { |
|
514 |
+ DUMMY_SERVICE: {"length": 10}, |
|
515 |
+ "del\x7fin\x7fname": {"length": 10}, |
|
516 |
+ }, |
|
517 |
+ }, |
|
518 |
+ "del\x7fin\x7fname", |
|
519 |
+ "del", |
|
520 |
+ frozenset({}), |
|
521 |
+ id="del_partial_specific", |
|
522 |
+ ), |
|
523 |
+ ], |
|
524 |
+ ) |
|
525 |
+ SERVICE_NAME_EXCEPTIONS = pytest.mark.parametrize( |
|
526 |
+ "exc_type", [RuntimeError, KeyError, ValueError] |
|
527 |
+ ) |
|
528 |
+ INCOMPLETE = pytest.mark.parametrize("incomplete", ["", "partial"]) |
|
529 |
+ CONFIG_SETTING_MODE = pytest.mark.parametrize("mode", ["config", "import"]) |
|
530 |
+ COMPLETABLE_ITEMS = pytest.mark.parametrize( |
|
531 |
+ ["partial", "is_completable"], |
|
532 |
+ [ |
|
533 |
+ ("", True), |
|
534 |
+ (DUMMY_SERVICE, True), |
|
535 |
+ ("a\bn", False), |
|
536 |
+ ("\b", False), |
|
537 |
+ ("\x00", False), |
|
538 |
+ ("\x20", True), |
|
539 |
+ ("\x7f", False), |
|
540 |
+ ("service with spaces", True), |
|
541 |
+ ("service\nwith\nnewlines", False), |
|
542 |
+ ], |
|
543 |
+ ) |
|
544 |
+ SHELL_FORMATTER = pytest.mark.parametrize( |
|
545 |
+ ["shell", "format_func"], |
|
546 |
+ [ |
|
547 |
+ pytest.param("bash", bash_format, id="bash"), |
|
548 |
+ pytest.param("fish", fish_format, id="fish"), |
|
549 |
+ pytest.param("zsh", zsh_format, id="zsh"), |
|
550 |
+ ], |
|
551 |
+ ) |
|
552 |
+ |
|
553 |
+ |
|
554 |
+class TestShellCompletion: |
|
555 |
+ """Tests for the shell completion machinery.""" |
|
556 |
+ |
|
557 |
+ class Completions: |
|
558 |
+ """A deferred completion call.""" |
|
559 |
+ |
|
560 |
+ def __init__( |
|
561 |
+ self, |
|
562 |
+ args: Sequence[str], |
|
563 |
+ incomplete: str, |
|
564 |
+ ) -> None: |
|
565 |
+ """Initialize the object. |
|
566 |
+ |
|
567 |
+ Args: |
|
568 |
+ args: |
|
569 |
+ The sequence of complete command-line arguments. |
|
570 |
+ incomplete: |
|
571 |
+ The final, incomplete, partial argument. |
|
572 |
+ |
|
573 |
+ """ |
|
574 |
+ self.args = tuple(args) |
|
575 |
+ self.incomplete = incomplete |
|
576 |
+ |
|
577 |
+ def __call__(self) -> Sequence[click.shell_completion.CompletionItem]: |
|
578 |
+ """Return the completion items.""" |
|
579 |
+ args = list(self.args) |
|
580 |
+ completion = click.shell_completion.ShellComplete( |
|
581 |
+ cli=cli.derivepassphrase, |
|
582 |
+ ctx_args={}, |
|
583 |
+ prog_name="derivepassphrase", |
|
584 |
+ complete_var="_DERIVEPASSPHRASE_COMPLETE", |
|
585 |
+ ) |
|
586 |
+ return completion.get_completions(args, self.incomplete) |
|
587 |
+ |
|
588 |
+ def get_words(self) -> Sequence[str]: |
|
589 |
+ """Return the completion items' values, as a sequence.""" |
|
590 |
+ return tuple(c.value for c in self()) |
|
591 |
+ |
|
592 |
+ @Parametrize.COMPLETABLE_ITEMS |
|
593 |
+ def test_100_is_completable_item( |
|
594 |
+ self, |
|
595 |
+ partial: str, |
|
596 |
+ is_completable: bool, |
|
597 |
+ ) -> None: |
|
598 |
+ """Our `_is_completable_item` predicate for service names works.""" |
|
599 |
+ assert cli_helpers.is_completable_item(partial) == is_completable |
|
600 |
+ |
|
601 |
+ @Parametrize.COMPLETABLE_OPTIONS |
|
602 |
+ def test_200_options( |
|
603 |
+ self, |
|
604 |
+ command_prefix: Sequence[str], |
|
605 |
+ incomplete: str, |
|
606 |
+ completions: AbstractSet[str], |
|
607 |
+ ) -> None: |
|
608 |
+ """Our completion machinery works for all commands' options.""" |
|
609 |
+ comp = self.Completions(command_prefix, incomplete) |
|
610 |
+ assert frozenset(comp.get_words()) == completions |
|
611 |
+ |
|
612 |
+ @Parametrize.COMPLETABLE_SUBCOMMANDS |
|
613 |
+ def test_201_subcommands( |
|
614 |
+ self, |
|
615 |
+ command_prefix: Sequence[str], |
|
616 |
+ incomplete: str, |
|
617 |
+ completions: AbstractSet[str], |
|
618 |
+ ) -> None: |
|
619 |
+ """Our completion machinery works for all commands' subcommands.""" |
|
620 |
+ comp = self.Completions(command_prefix, incomplete) |
|
621 |
+ assert frozenset(comp.get_words()) == completions |
|
622 |
+ |
|
623 |
+ @Parametrize.COMPLETABLE_PATH_ARGUMENT |
|
624 |
+ @Parametrize.INCOMPLETE |
|
625 |
+ def test_202_paths( |
|
626 |
+ self, |
|
627 |
+ command_prefix: Sequence[str], |
|
628 |
+ incomplete: str, |
|
629 |
+ ) -> None: |
|
630 |
+ """Our completion machinery works for all commands' paths.""" |
|
631 |
+ file = click.shell_completion.CompletionItem("", type="file") |
|
632 |
+ completions = frozenset({(file.type, file.value, file.help)}) |
|
633 |
+ comp = self.Completions(command_prefix, incomplete) |
|
634 |
+ assert ( |
|
635 |
+ frozenset((x.type, x.value, x.help) for x in comp()) == completions |
|
636 |
+ ) |
|
637 |
+ |
|
638 |
+ @Parametrize.COMPLETABLE_SERVICE_NAMES |
|
639 |
+ def test_203_service_names( |
|
640 |
+ self, |
|
641 |
+ config: _types.VaultConfig, |
|
642 |
+ incomplete: str, |
|
643 |
+ completions: AbstractSet[str], |
|
644 |
+ ) -> None: |
|
645 |
+ """Our completion machinery works for vault service names.""" |
|
646 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
647 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
648 |
+ # with-statements. |
|
649 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
650 |
+ with contextlib.ExitStack() as stack: |
|
651 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
652 |
+ stack.enter_context( |
|
653 |
+ pytest_machinery.isolated_vault_config( |
|
654 |
+ monkeypatch=monkeypatch, |
|
655 |
+ runner=runner, |
|
656 |
+ vault_config=config, |
|
657 |
+ ) |
|
658 |
+ ) |
|
659 |
+ comp = self.Completions(["vault"], incomplete) |
|
660 |
+ assert frozenset(comp.get_words()) == completions |
|
661 |
+ |
|
662 |
+ @Parametrize.SHELL_FORMATTER |
|
663 |
+ @Parametrize.COMPLETION_FUNCTION_INPUTS |
|
664 |
+ def test_300_shell_completion_formatting( |
|
665 |
+ self, |
|
666 |
+ shell: str, |
|
667 |
+ format_func: Callable[[click.shell_completion.CompletionItem], str], |
|
668 |
+ config: _types.VaultConfig, |
|
669 |
+ comp_func: Callable[ |
|
670 |
+ [click.Context, click.Parameter, str], |
|
671 |
+ list[str | click.shell_completion.CompletionItem], |
|
672 |
+ ], |
|
673 |
+ args: list[str], |
|
674 |
+ incomplete: str, |
|
675 |
+ results: list[str | click.shell_completion.CompletionItem], |
|
676 |
+ ) -> None: |
|
677 |
+ """Custom completion functions work for all shells.""" |
|
678 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
679 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
680 |
+ # with-statements. |
|
681 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
682 |
+ with contextlib.ExitStack() as stack: |
|
683 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
684 |
+ stack.enter_context( |
|
685 |
+ pytest_machinery.isolated_vault_config( |
|
686 |
+ monkeypatch=monkeypatch, |
|
687 |
+ runner=runner, |
|
688 |
+ vault_config=config, |
|
689 |
+ ) |
|
690 |
+ ) |
|
691 |
+ expected_items = [assertable_item(item) for item in results] |
|
692 |
+ expected_string = "\n".join( |
|
693 |
+ format_func(completion_item(item)) for item in results |
|
694 |
+ ) |
|
695 |
+ manual_raw_items = comp_func( |
|
696 |
+ click.Context(cli.derivepassphrase), |
|
697 |
+ click.Argument(["sample_parameter"]), |
|
698 |
+ incomplete, |
|
699 |
+ ) |
|
700 |
+ manual_items = [assertable_item(item) for item in manual_raw_items] |
|
701 |
+ manual_string = "\n".join( |
|
702 |
+ format_func(completion_item(item)) for item in manual_raw_items |
|
703 |
+ ) |
|
704 |
+ assert manual_items == expected_items |
|
705 |
+ assert manual_string == expected_string |
|
706 |
+ comp_class = click.shell_completion.get_completion_class(shell) |
|
707 |
+ assert comp_class is not None |
|
708 |
+ comp = comp_class( |
|
709 |
+ cli.derivepassphrase, |
|
710 |
+ {}, |
|
711 |
+ "derivepassphrase", |
|
712 |
+ "_DERIVEPASSPHRASE_COMPLETE", |
|
713 |
+ ) |
|
714 |
+ monkeypatch.setattr( |
|
715 |
+ comp, |
|
716 |
+ "get_completion_args", |
|
717 |
+ lambda *_a, **_kw: (args, incomplete), |
|
718 |
+ ) |
|
719 |
+ actual_raw_items = comp.get_completions( |
|
720 |
+ *comp.get_completion_args() |
|
721 |
+ ) |
|
722 |
+ actual_items = [assertable_item(item) for item in actual_raw_items] |
|
723 |
+ actual_string = comp.complete() |
|
724 |
+ assert actual_items == expected_items |
|
725 |
+ assert actual_string == expected_string |
|
726 |
+ |
|
727 |
+ @Parametrize.CONFIG_SETTING_MODE |
|
728 |
+ @Parametrize.SERVICE_NAME_COMPLETION_INPUTS |
|
729 |
+ def test_400_incompletable_service_names( |
|
730 |
+ self, |
|
731 |
+ caplog: pytest.LogCaptureFixture, |
|
732 |
+ mode: Literal["config", "import"], |
|
733 |
+ config: _types.VaultConfig, |
|
734 |
+ key: str, |
|
735 |
+ incomplete: str, |
|
736 |
+ completions: AbstractSet[str], |
|
737 |
+ ) -> None: |
|
738 |
+ """Completion skips incompletable items.""" |
|
739 |
+ vault_config = config if mode == "config" else {"services": {}} |
|
740 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
741 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
742 |
+ # with-statements. |
|
743 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
744 |
+ with contextlib.ExitStack() as stack: |
|
745 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
746 |
+ stack.enter_context( |
|
747 |
+ pytest_machinery.isolated_vault_config( |
|
748 |
+ monkeypatch=monkeypatch, |
|
749 |
+ runner=runner, |
|
750 |
+ vault_config=vault_config, |
|
751 |
+ ) |
|
752 |
+ ) |
|
753 |
+ if mode == "config": |
|
754 |
+ result = runner.invoke( |
|
755 |
+ cli.derivepassphrase_vault, |
|
756 |
+ ["--config", "--length=10", "--", key], |
|
757 |
+ catch_exceptions=False, |
|
758 |
+ ) |
|
759 |
+ else: |
|
760 |
+ result = runner.invoke( |
|
761 |
+ cli.derivepassphrase_vault, |
|
762 |
+ ["--import", "-"], |
|
763 |
+ catch_exceptions=False, |
|
764 |
+ input=json.dumps(config), |
|
765 |
+ ) |
|
766 |
+ assert result.clean_exit(), "expected clean exit" |
|
767 |
+ assert machinery.warning_emitted( |
|
768 |
+ "contains an ASCII control character", caplog.record_tuples |
|
769 |
+ ), "expected known warning message in stderr" |
|
770 |
+ assert machinery.warning_emitted( |
|
771 |
+ "not be available for completion", caplog.record_tuples |
|
772 |
+ ), "expected known warning message in stderr" |
|
773 |
+ assert cli_helpers.load_config() == config |
|
774 |
+ comp = self.Completions(["vault"], incomplete) |
|
775 |
+ assert frozenset(comp.get_words()) == completions |
|
776 |
+ |
|
777 |
+ def test_410a_service_name_exceptions_not_found( |
|
778 |
+ self, |
|
779 |
+ ) -> None: |
|
780 |
+ """Service name completion quietly fails on missing configuration.""" |
|
781 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
782 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
783 |
+ # with-statements. |
|
784 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
785 |
+ with contextlib.ExitStack() as stack: |
|
786 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
787 |
+ stack.enter_context( |
|
788 |
+ pytest_machinery.isolated_vault_config( |
|
789 |
+ monkeypatch=monkeypatch, |
|
790 |
+ runner=runner, |
|
791 |
+ vault_config={ |
|
792 |
+ "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS} |
|
793 |
+ }, |
|
794 |
+ ) |
|
795 |
+ ) |
|
796 |
+ cli_helpers.config_filename(subsystem="vault").unlink( |
|
797 |
+ missing_ok=True |
|
798 |
+ ) |
|
799 |
+ assert not cli_helpers.shell_complete_service( |
|
800 |
+ click.Context(cli.derivepassphrase), |
|
801 |
+ click.Argument(["some_parameter"]), |
|
802 |
+ "", |
|
803 |
+ ) |
|
804 |
+ |
|
805 |
+ @Parametrize.SERVICE_NAME_EXCEPTIONS |
|
806 |
+ def test_410b_service_name_exceptions_custom_error( |
|
807 |
+ self, |
|
808 |
+ exc_type: type[Exception], |
|
809 |
+ ) -> None: |
|
810 |
+ """Service name completion quietly fails on configuration errors.""" |
|
811 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
812 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
813 |
+ # with-statements. |
|
814 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
815 |
+ with contextlib.ExitStack() as stack: |
|
816 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
817 |
+ stack.enter_context( |
|
818 |
+ pytest_machinery.isolated_vault_config( |
|
819 |
+ monkeypatch=monkeypatch, |
|
820 |
+ runner=runner, |
|
821 |
+ vault_config={ |
|
822 |
+ "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS} |
|
823 |
+ }, |
|
824 |
+ ) |
|
825 |
+ ) |
|
826 |
+ |
|
827 |
+ def raiser(*_a: Any, **_kw: Any) -> NoReturn: |
|
828 |
+ raise exc_type("just being difficult") # noqa: EM101,TRY003 |
|
829 |
+ |
|
830 |
+ monkeypatch.setattr(cli_helpers, "load_config", raiser) |
|
831 |
+ assert not cli_helpers.shell_complete_service( |
|
832 |
+ click.Context(cli.derivepassphrase), |
|
833 |
+ click.Argument(["some_parameter"]), |
|
834 |
+ "", |
|
835 |
+ ) |
... | ... |
@@ -0,0 +1,428 @@ |
1 |
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
|
2 |
+# |
|
3 |
+# SPDX-License-Identifier: Zlib |
|
4 |
+ |
|
5 |
+# TODO(the-13th-letter): Remove this module in v1.0. |
|
6 |
+# https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#upgrading-to-v1.0 |
|
7 |
+ |
|
8 |
+from __future__ import annotations |
|
9 |
+ |
|
10 |
+import contextlib |
|
11 |
+import errno |
|
12 |
+import json |
|
13 |
+import logging |
|
14 |
+import os |
|
15 |
+import pathlib |
|
16 |
+ |
|
17 |
+import click.testing |
|
18 |
+import pytest |
|
19 |
+from typing_extensions import Any |
|
20 |
+ |
|
21 |
+from derivepassphrase import cli, vault |
|
22 |
+from derivepassphrase._internals import ( |
|
23 |
+ cli_helpers, |
|
24 |
+) |
|
25 |
+from tests import data, machinery, test_derivepassphrase_cli |
|
26 |
+from tests.data import callables |
|
27 |
+from tests.machinery import pytest as pytest_machinery |
|
28 |
+from tests.test_derivepassphrase_cli import test_utils |
|
29 |
+ |
|
30 |
+DUMMY_SERVICE = data.DUMMY_SERVICE |
|
31 |
+DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE |
|
32 |
+DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS |
|
33 |
+ |
|
34 |
+ |
|
35 |
+class Parametrize( |
|
36 |
+ test_derivepassphrase_cli.Parametrize, test_utils.Parametrize |
|
37 |
+): |
|
38 |
+ """Common test parametrizations.""" |
|
39 |
+ |
|
40 |
+ BAD_CONFIGS = pytest.mark.parametrize( |
|
41 |
+ "config", |
|
42 |
+ [ |
|
43 |
+ {"global": "", "services": {}}, |
|
44 |
+ {"global": 0, "services": {}}, |
|
45 |
+ { |
|
46 |
+ "global": {"phrase": "abc"}, |
|
47 |
+ "services": False, |
|
48 |
+ }, |
|
49 |
+ { |
|
50 |
+ "global": {"phrase": "abc"}, |
|
51 |
+ "services": True, |
|
52 |
+ }, |
|
53 |
+ { |
|
54 |
+ "global": {"phrase": "abc"}, |
|
55 |
+ "services": None, |
|
56 |
+ }, |
|
57 |
+ ], |
|
58 |
+ ) |
|
59 |
+ |
|
60 |
+ |
|
61 |
+class TestCLITransition: |
|
62 |
+ """Transition tests for the command-line interface up to v1.0.""" |
|
63 |
+ |
|
64 |
+ @Parametrize.BASE_CONFIG_VARIATIONS |
|
65 |
+ def test_110_load_config_backup( |
|
66 |
+ self, |
|
67 |
+ config: Any, |
|
68 |
+ ) -> None: |
|
69 |
+ """Loading the old settings file works.""" |
|
70 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
71 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
72 |
+ # with-statements. |
|
73 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
74 |
+ with contextlib.ExitStack() as stack: |
|
75 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
76 |
+ stack.enter_context( |
|
77 |
+ pytest_machinery.isolated_config( |
|
78 |
+ monkeypatch=monkeypatch, |
|
79 |
+ runner=runner, |
|
80 |
+ ) |
|
81 |
+ ) |
|
82 |
+ cli_helpers.config_filename( |
|
83 |
+ subsystem="old settings.json" |
|
84 |
+ ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8") |
|
85 |
+ assert cli_helpers.migrate_and_load_old_config()[0] == config |
|
86 |
+ |
|
87 |
+ @Parametrize.BASE_CONFIG_VARIATIONS |
|
88 |
+ def test_111_migrate_config( |
|
89 |
+ self, |
|
90 |
+ config: Any, |
|
91 |
+ ) -> None: |
|
92 |
+ """Migrating the old settings file works.""" |
|
93 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
94 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
95 |
+ # with-statements. |
|
96 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
97 |
+ with contextlib.ExitStack() as stack: |
|
98 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
99 |
+ stack.enter_context( |
|
100 |
+ pytest_machinery.isolated_config( |
|
101 |
+ monkeypatch=monkeypatch, |
|
102 |
+ runner=runner, |
|
103 |
+ ) |
|
104 |
+ ) |
|
105 |
+ cli_helpers.config_filename( |
|
106 |
+ subsystem="old settings.json" |
|
107 |
+ ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8") |
|
108 |
+ assert cli_helpers.migrate_and_load_old_config() == (config, None) |
|
109 |
+ |
|
110 |
+ @Parametrize.BASE_CONFIG_VARIATIONS |
|
111 |
+ def test_112_migrate_config_error( |
|
112 |
+ self, |
|
113 |
+ config: Any, |
|
114 |
+ ) -> None: |
|
115 |
+ """Migrating the old settings file atop a directory fails.""" |
|
116 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
117 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
118 |
+ # with-statements. |
|
119 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
120 |
+ with contextlib.ExitStack() as stack: |
|
121 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
122 |
+ stack.enter_context( |
|
123 |
+ pytest_machinery.isolated_config( |
|
124 |
+ monkeypatch=monkeypatch, |
|
125 |
+ runner=runner, |
|
126 |
+ ) |
|
127 |
+ ) |
|
128 |
+ cli_helpers.config_filename( |
|
129 |
+ subsystem="old settings.json" |
|
130 |
+ ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8") |
|
131 |
+ cli_helpers.config_filename(subsystem="vault").mkdir( |
|
132 |
+ parents=True, exist_ok=True |
|
133 |
+ ) |
|
134 |
+ config2, err = cli_helpers.migrate_and_load_old_config() |
|
135 |
+ assert config2 == config |
|
136 |
+ assert isinstance(err, OSError) |
|
137 |
+ # The Annoying OS uses EEXIST, other OSes use EISDIR. |
|
138 |
+ assert err.errno in {errno.EISDIR, errno.EEXIST} |
|
139 |
+ |
|
140 |
+ @Parametrize.BAD_CONFIGS |
|
141 |
+ def test_113_migrate_config_error_bad_config_value( |
|
142 |
+ self, |
|
143 |
+ config: Any, |
|
144 |
+ ) -> None: |
|
145 |
+ """Migrating an invalid old settings file fails.""" |
|
146 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
147 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
148 |
+ # with-statements. |
|
149 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
150 |
+ with contextlib.ExitStack() as stack: |
|
151 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
152 |
+ stack.enter_context( |
|
153 |
+ pytest_machinery.isolated_config( |
|
154 |
+ monkeypatch=monkeypatch, |
|
155 |
+ runner=runner, |
|
156 |
+ ) |
|
157 |
+ ) |
|
158 |
+ cli_helpers.config_filename( |
|
159 |
+ subsystem="old settings.json" |
|
160 |
+ ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8") |
|
161 |
+ with pytest.raises( |
|
162 |
+ ValueError, match=cli_helpers.INVALID_VAULT_CONFIG |
|
163 |
+ ): |
|
164 |
+ cli_helpers.migrate_and_load_old_config() |
|
165 |
+ |
|
166 |
+ def test_200_forward_export_vault_path_parameter( |
|
167 |
+ self, |
|
168 |
+ caplog: pytest.LogCaptureFixture, |
|
169 |
+ ) -> None: |
|
170 |
+ """Forwarding arguments from "export" to "export vault" works.""" |
|
171 |
+ pytest.importorskip("cryptography", minversion="38.0") |
|
172 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
173 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
174 |
+ # with-statements. |
|
175 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
176 |
+ with contextlib.ExitStack() as stack: |
|
177 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
178 |
+ stack.enter_context( |
|
179 |
+ pytest_machinery.isolated_vault_exporter_config( |
|
180 |
+ monkeypatch=monkeypatch, |
|
181 |
+ runner=runner, |
|
182 |
+ vault_config=data.VAULT_V03_CONFIG, |
|
183 |
+ vault_key=data.VAULT_MASTER_KEY, |
|
184 |
+ ) |
|
185 |
+ ) |
|
186 |
+ monkeypatch.setenv("VAULT_KEY", data.VAULT_MASTER_KEY) |
|
187 |
+ result = runner.invoke( |
|
188 |
+ cli.derivepassphrase, |
|
189 |
+ ["export", "VAULT_PATH"], |
|
190 |
+ ) |
|
191 |
+ assert result.clean_exit(empty_stderr=False), "expected clean exit" |
|
192 |
+ assert machinery.deprecation_warning_emitted( |
|
193 |
+ "A subcommand will be required here in v1.0", caplog.record_tuples |
|
194 |
+ ) |
|
195 |
+ assert machinery.deprecation_warning_emitted( |
|
196 |
+ 'Defaulting to subcommand "vault"', caplog.record_tuples |
|
197 |
+ ) |
|
198 |
+ assert json.loads(result.stdout) == data.VAULT_V03_CONFIG_DATA |
|
199 |
+ |
|
200 |
+ def test_201_forward_export_vault_empty_commandline( |
|
201 |
+ self, |
|
202 |
+ caplog: pytest.LogCaptureFixture, |
|
203 |
+ ) -> None: |
|
204 |
+ """Deferring from "export" to "export vault" works.""" |
|
205 |
+ pytest.importorskip("cryptography", minversion="38.0") |
|
206 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
207 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
208 |
+ # with-statements. |
|
209 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
210 |
+ with contextlib.ExitStack() as stack: |
|
211 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
212 |
+ stack.enter_context( |
|
213 |
+ pytest_machinery.isolated_config( |
|
214 |
+ monkeypatch=monkeypatch, |
|
215 |
+ runner=runner, |
|
216 |
+ ) |
|
217 |
+ ) |
|
218 |
+ result = runner.invoke( |
|
219 |
+ cli.derivepassphrase, |
|
220 |
+ ["export"], |
|
221 |
+ ) |
|
222 |
+ assert machinery.deprecation_warning_emitted( |
|
223 |
+ "A subcommand will be required here in v1.0", caplog.record_tuples |
|
224 |
+ ) |
|
225 |
+ assert machinery.deprecation_warning_emitted( |
|
226 |
+ 'Defaulting to subcommand "vault"', caplog.record_tuples |
|
227 |
+ ) |
|
228 |
+ assert result.error_exit(error="Missing argument 'PATH'"), ( |
|
229 |
+ "expected error exit and known error type" |
|
230 |
+ ) |
|
231 |
+ |
|
232 |
+ @Parametrize.CHARSET_NAME |
|
233 |
+ def test_210_forward_vault_disable_character_set( |
|
234 |
+ self, |
|
235 |
+ caplog: pytest.LogCaptureFixture, |
|
236 |
+ charset_name: str, |
|
237 |
+ ) -> None: |
|
238 |
+ """Forwarding arguments from top-level to "vault" works.""" |
|
239 |
+ option = f"--{charset_name}" |
|
240 |
+ charset = vault.Vault.CHARSETS[charset_name].decode("ascii") |
|
241 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
242 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
243 |
+ # with-statements. |
|
244 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
245 |
+ with contextlib.ExitStack() as stack: |
|
246 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
247 |
+ stack.enter_context( |
|
248 |
+ pytest_machinery.isolated_config( |
|
249 |
+ monkeypatch=monkeypatch, |
|
250 |
+ runner=runner, |
|
251 |
+ ) |
|
252 |
+ ) |
|
253 |
+ monkeypatch.setattr( |
|
254 |
+ cli_helpers, |
|
255 |
+ "prompt_for_passphrase", |
|
256 |
+ callables.auto_prompt, |
|
257 |
+ ) |
|
258 |
+ result = runner.invoke( |
|
259 |
+ cli.derivepassphrase, |
|
260 |
+ [option, "0", "-p", "--", DUMMY_SERVICE], |
|
261 |
+ input=DUMMY_PASSPHRASE, |
|
262 |
+ catch_exceptions=False, |
|
263 |
+ ) |
|
264 |
+ assert result.clean_exit(empty_stderr=False), "expected clean exit" |
|
265 |
+ assert machinery.deprecation_warning_emitted( |
|
266 |
+ "A subcommand will be required here in v1.0", caplog.record_tuples |
|
267 |
+ ) |
|
268 |
+ assert machinery.deprecation_warning_emitted( |
|
269 |
+ 'Defaulting to subcommand "vault"', caplog.record_tuples |
|
270 |
+ ) |
|
271 |
+ for c in charset: |
|
272 |
+ assert c not in result.stdout, ( |
|
273 |
+ f"derived password contains forbidden character {c!r}" |
|
274 |
+ ) |
|
275 |
+ |
|
276 |
+ def test_211_forward_vault_empty_command_line( |
|
277 |
+ self, |
|
278 |
+ caplog: pytest.LogCaptureFixture, |
|
279 |
+ ) -> None: |
|
280 |
+ """Deferring from top-level to "vault" works.""" |
|
281 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
282 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
283 |
+ # with-statements. |
|
284 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
285 |
+ with contextlib.ExitStack() as stack: |
|
286 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
287 |
+ stack.enter_context( |
|
288 |
+ pytest_machinery.isolated_config( |
|
289 |
+ monkeypatch=monkeypatch, |
|
290 |
+ runner=runner, |
|
291 |
+ ) |
|
292 |
+ ) |
|
293 |
+ result = runner.invoke( |
|
294 |
+ cli.derivepassphrase, |
|
295 |
+ [], |
|
296 |
+ input=DUMMY_PASSPHRASE, |
|
297 |
+ catch_exceptions=False, |
|
298 |
+ ) |
|
299 |
+ assert machinery.deprecation_warning_emitted( |
|
300 |
+ "A subcommand will be required here in v1.0", caplog.record_tuples |
|
301 |
+ ) |
|
302 |
+ assert machinery.deprecation_warning_emitted( |
|
303 |
+ 'Defaulting to subcommand "vault"', caplog.record_tuples |
|
304 |
+ ) |
|
305 |
+ assert result.error_exit( |
|
306 |
+ error="Deriving a passphrase requires a SERVICE." |
|
307 |
+ ), "expected error exit and known error type" |
|
308 |
+ |
|
309 |
+ def test_300_export_using_old_config_file( |
|
310 |
+ self, |
|
311 |
+ caplog: pytest.LogCaptureFixture, |
|
312 |
+ ) -> None: |
|
313 |
+ """Exporting from (and migrating) the old settings file works.""" |
|
314 |
+ caplog.set_level(logging.INFO) |
|
315 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
316 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
317 |
+ # with-statements. |
|
318 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
319 |
+ with contextlib.ExitStack() as stack: |
|
320 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
321 |
+ stack.enter_context( |
|
322 |
+ pytest_machinery.isolated_config( |
|
323 |
+ monkeypatch=monkeypatch, |
|
324 |
+ runner=runner, |
|
325 |
+ ) |
|
326 |
+ ) |
|
327 |
+ cli_helpers.config_filename( |
|
328 |
+ subsystem="old settings.json" |
|
329 |
+ ).write_text( |
|
330 |
+ json.dumps( |
|
331 |
+ {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}}, |
|
332 |
+ indent=2, |
|
333 |
+ ) |
|
334 |
+ + "\n", |
|
335 |
+ encoding="UTF-8", |
|
336 |
+ ) |
|
337 |
+ result = runner.invoke( |
|
338 |
+ cli.derivepassphrase_vault, |
|
339 |
+ ["--export", "-"], |
|
340 |
+ catch_exceptions=False, |
|
341 |
+ ) |
|
342 |
+ assert result.clean_exit(), "expected clean exit" |
|
343 |
+ assert machinery.deprecation_warning_emitted( |
|
344 |
+ "v0.1-style config file", caplog.record_tuples |
|
345 |
+ ), "expected known warning message in stderr" |
|
346 |
+ assert machinery.deprecation_info_emitted( |
|
347 |
+ "Successfully migrated to ", caplog.record_tuples |
|
348 |
+ ), "expected known warning message in stderr" |
|
349 |
+ |
|
350 |
+ def test_300a_export_using_old_config_file_migration_error( |
|
351 |
+ self, |
|
352 |
+ caplog: pytest.LogCaptureFixture, |
|
353 |
+ ) -> None: |
|
354 |
+ """Exporting from (and not migrating) the old settings file fails.""" |
|
355 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
356 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
357 |
+ # with-statements. |
|
358 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
359 |
+ with contextlib.ExitStack() as stack: |
|
360 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
361 |
+ stack.enter_context( |
|
362 |
+ pytest_machinery.isolated_config( |
|
363 |
+ monkeypatch=monkeypatch, |
|
364 |
+ runner=runner, |
|
365 |
+ ) |
|
366 |
+ ) |
|
367 |
+ cli_helpers.config_filename( |
|
368 |
+ subsystem="old settings.json" |
|
369 |
+ ).write_text( |
|
370 |
+ json.dumps( |
|
371 |
+ {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}}, |
|
372 |
+ indent=2, |
|
373 |
+ ) |
|
374 |
+ + "\n", |
|
375 |
+ encoding="UTF-8", |
|
376 |
+ ) |
|
377 |
+ |
|
378 |
+ def raiser(*_args: Any, **_kwargs: Any) -> None: |
|
379 |
+ raise OSError( |
|
380 |
+ errno.EACCES, |
|
381 |
+ os.strerror(errno.EACCES), |
|
382 |
+ cli_helpers.config_filename(subsystem="vault"), |
|
383 |
+ ) |
|
384 |
+ |
|
385 |
+ monkeypatch.setattr(os, "replace", raiser) |
|
386 |
+ monkeypatch.setattr(pathlib.Path, "rename", raiser) |
|
387 |
+ result = runner.invoke( |
|
388 |
+ cli.derivepassphrase_vault, |
|
389 |
+ ["--export", "-"], |
|
390 |
+ catch_exceptions=False, |
|
391 |
+ ) |
|
392 |
+ assert result.clean_exit(), "expected clean exit" |
|
393 |
+ assert machinery.deprecation_warning_emitted( |
|
394 |
+ "v0.1-style config file", caplog.record_tuples |
|
395 |
+ ), "expected known warning message in stderr" |
|
396 |
+ assert machinery.warning_emitted( |
|
397 |
+ "Failed to migrate to ", caplog.record_tuples |
|
398 |
+ ), "expected known warning message in stderr" |
|
399 |
+ |
|
400 |
+ def test_400_completion_service_name_old_config_file( |
|
401 |
+ self, |
|
402 |
+ ) -> None: |
|
403 |
+ """Completing service names from the old settings file works.""" |
|
404 |
+ config = {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}} |
|
405 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
406 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
407 |
+ # with-statements. |
|
408 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
409 |
+ with contextlib.ExitStack() as stack: |
|
410 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
411 |
+ stack.enter_context( |
|
412 |
+ pytest_machinery.isolated_vault_config( |
|
413 |
+ monkeypatch=monkeypatch, |
|
414 |
+ runner=runner, |
|
415 |
+ vault_config=config, |
|
416 |
+ ) |
|
417 |
+ ) |
|
418 |
+ old_name = cli_helpers.config_filename( |
|
419 |
+ subsystem="old settings.json" |
|
420 |
+ ) |
|
421 |
+ new_name = cli_helpers.config_filename(subsystem="vault") |
|
422 |
+ old_name.unlink(missing_ok=True) |
|
423 |
+ new_name.rename(old_name) |
|
424 |
+ assert cli_helpers.shell_complete_service( |
|
425 |
+ click.Context(cli.derivepassphrase), |
|
426 |
+ click.Argument(["some_parameter"]), |
|
427 |
+ "", |
|
428 |
+ ) == [DUMMY_SERVICE] |
... | ... |
@@ -0,0 +1,1433 @@ |
1 |
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
|
2 |
+# |
|
3 |
+# SPDX-License-Identifier: Zlib |
|
4 |
+ |
|
5 |
+from __future__ import annotations |
|
6 |
+ |
|
7 |
+import base64 |
|
8 |
+import contextlib |
|
9 |
+import ctypes |
|
10 |
+import enum |
|
11 |
+import errno |
|
12 |
+import io |
|
13 |
+import json |
|
14 |
+import logging |
|
15 |
+import operator |
|
16 |
+import os |
|
17 |
+import pathlib |
|
18 |
+import shlex |
|
19 |
+import shutil |
|
20 |
+import socket |
|
21 |
+import tempfile |
|
22 |
+import types |
|
23 |
+import warnings |
|
24 |
+from typing import TYPE_CHECKING |
|
25 |
+ |
|
26 |
+import click.testing |
|
27 |
+import hypothesis |
|
28 |
+import pytest |
|
29 |
+from hypothesis import strategies |
|
30 |
+from typing_extensions import Any |
|
31 |
+ |
|
32 |
+from derivepassphrase import _types, cli, ssh_agent, vault |
|
33 |
+from derivepassphrase._internals import ( |
|
34 |
+ cli_helpers, |
|
35 |
+ cli_machinery, |
|
36 |
+) |
|
37 |
+from derivepassphrase.ssh_agent import socketprovider |
|
38 |
+from tests import data, machinery |
|
39 |
+from tests.data import callables |
|
40 |
+from tests.machinery import hypothesis as hypothesis_machinery |
|
41 |
+from tests.machinery import pytest as pytest_machinery |
|
42 |
+ |
|
43 |
+if TYPE_CHECKING: |
|
44 |
+ from collections.abc import Callable, Iterable, Iterator |
|
45 |
+ from typing import NoReturn |
|
46 |
+ |
|
47 |
+ |
|
48 |
+DUMMY_SERVICE = data.DUMMY_SERVICE |
|
49 |
+DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE |
|
50 |
+DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS |
|
51 |
+DUMMY_RESULT_PASSPHRASE = data.DUMMY_RESULT_PASSPHRASE |
|
52 |
+DUMMY_RESULT_KEY1 = data.DUMMY_RESULT_KEY1 |
|
53 |
+DUMMY_PHRASE_FROM_KEY1_RAW = data.DUMMY_PHRASE_FROM_KEY1_RAW |
|
54 |
+DUMMY_PHRASE_FROM_KEY1 = data.DUMMY_PHRASE_FROM_KEY1 |
|
55 |
+ |
|
56 |
+DUMMY_KEY1 = data.DUMMY_KEY1 |
|
57 |
+DUMMY_KEY1_B64 = data.DUMMY_KEY1_B64 |
|
58 |
+DUMMY_KEY2 = data.DUMMY_KEY2 |
|
59 |
+DUMMY_KEY2_B64 = data.DUMMY_KEY2_B64 |
|
60 |
+DUMMY_KEY3 = data.DUMMY_KEY3 |
|
61 |
+DUMMY_KEY3_B64 = data.DUMMY_KEY3_B64 |
|
62 |
+ |
|
63 |
+TEST_CONFIGS = data.TEST_CONFIGS |
|
64 |
+ |
|
65 |
+ |
|
66 |
+def vault_config_exporter_shell_interpreter( # noqa: C901 |
|
67 |
+ script: str | Iterable[str], |
|
68 |
+ /, |
|
69 |
+ *, |
|
70 |
+ prog_name_list: list[str] | None = None, |
|
71 |
+ command: click.BaseCommand | None = None, |
|
72 |
+ runner: machinery.CliRunner | None = None, |
|
73 |
+) -> Iterator[machinery.ReadableResult]: |
|
74 |
+ """A rudimentary sh(1) interpreter for `--export-as=sh` output. |
|
75 |
+ |
|
76 |
+ Assumes a script as emitted by `derivepassphrase vault |
|
77 |
+ --export-as=sh --export -` and interprets the calls to |
|
78 |
+ `derivepassphrase vault` within. (One call per line, skips all |
|
79 |
+ other lines.) Also has rudimentary support for (quoted) |
|
80 |
+ here-documents using `HERE` as the marker. |
|
81 |
+ |
|
82 |
+ """ |
|
83 |
+ if isinstance(script, str): # pragma: no cover |
|
84 |
+ script = script.splitlines(False) |
|
85 |
+ if prog_name_list is None: # pragma: no cover |
|
86 |
+ prog_name_list = ["derivepassphrase", "vault"] |
|
87 |
+ if command is None: # pragma: no cover |
|
88 |
+ command = cli.derivepassphrase_vault |
|
89 |
+ if runner is None: # pragma: no cover |
|
90 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
91 |
+ n = len(prog_name_list) |
|
92 |
+ it = iter(script) |
|
93 |
+ while True: |
|
94 |
+ try: |
|
95 |
+ raw_line = next(it) |
|
96 |
+ except StopIteration: |
|
97 |
+ break |
|
98 |
+ else: |
|
99 |
+ line = shlex.split(raw_line) |
|
100 |
+ input_buffer: list[str] = [] |
|
101 |
+ if line[:n] != prog_name_list: |
|
102 |
+ continue |
|
103 |
+ line[:n] = [] |
|
104 |
+ if line and line[-1] == "<<HERE": |
|
105 |
+ # naive HERE document support |
|
106 |
+ while True: |
|
107 |
+ try: |
|
108 |
+ raw_line = next(it) |
|
109 |
+ except StopIteration as exc: # pragma: no cover |
|
110 |
+ msg = "incomplete here document" |
|
111 |
+ raise EOFError(msg) from exc |
|
112 |
+ else: |
|
113 |
+ if raw_line == "HERE": |
|
114 |
+ break |
|
115 |
+ input_buffer.append(raw_line) |
|
116 |
+ line.pop() |
|
117 |
+ yield runner.invoke( |
|
118 |
+ command, |
|
119 |
+ line, |
|
120 |
+ catch_exceptions=False, |
|
121 |
+ input=("".join(x + "\n" for x in input_buffer) or None), |
|
122 |
+ ) |
|
123 |
+ |
|
124 |
+ |
|
125 |
+class ListKeysAction(str, enum.Enum): |
|
126 |
+ """Test fixture settings for [`ssh_agent.SSHAgentClient.list_keys`][]. |
|
127 |
+ |
|
128 |
+ Attributes: |
|
129 |
+ EMPTY: Return an empty key list. |
|
130 |
+ FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][]. |
|
131 |
+ FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][]. |
|
132 |
+ |
|
133 |
+ """ |
|
134 |
+ |
|
135 |
+ EMPTY = enum.auto() |
|
136 |
+ """""" |
|
137 |
+ FAIL = enum.auto() |
|
138 |
+ """""" |
|
139 |
+ FAIL_RUNTIME = enum.auto() |
|
140 |
+ """""" |
|
141 |
+ |
|
142 |
+ def __call__(self, *_args: Any, **_kwargs: Any) -> Any: |
|
143 |
+ """Execute the respective action.""" |
|
144 |
+ # TODO(the-13th-letter): Rewrite using structural pattern |
|
145 |
+ # matching. |
|
146 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
147 |
+ if self == self.EMPTY: |
|
148 |
+ return [] |
|
149 |
+ if self == self.FAIL: |
|
150 |
+ raise ssh_agent.SSHAgentFailedError( |
|
151 |
+ _types.SSH_AGENT.FAILURE.value, b"" |
|
152 |
+ ) |
|
153 |
+ if self == self.FAIL_RUNTIME: |
|
154 |
+ raise ssh_agent.TrailingDataError() |
|
155 |
+ raise AssertionError() |
|
156 |
+ |
|
157 |
+ |
|
158 |
+class SignAction(str, enum.Enum): |
|
159 |
+ """Test fixture settings for [`ssh_agent.SSHAgentClient.sign`][]. |
|
160 |
+ |
|
161 |
+ Attributes: |
|
162 |
+ FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][]. |
|
163 |
+ FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][]. |
|
164 |
+ |
|
165 |
+ """ |
|
166 |
+ |
|
167 |
+ FAIL = enum.auto() |
|
168 |
+ """""" |
|
169 |
+ FAIL_RUNTIME = enum.auto() |
|
170 |
+ """""" |
|
171 |
+ |
|
172 |
+ def __call__(self, *_args: Any, **_kwargs: Any) -> Any: |
|
173 |
+ """Execute the respective action.""" |
|
174 |
+ # TODO(the-13th-letter): Rewrite using structural pattern |
|
175 |
+ # matching. |
|
176 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
177 |
+ if self == self.FAIL: |
|
178 |
+ raise ssh_agent.SSHAgentFailedError( |
|
179 |
+ _types.SSH_AGENT.FAILURE.value, b"" |
|
180 |
+ ) |
|
181 |
+ if self == self.FAIL_RUNTIME: |
|
182 |
+ raise ssh_agent.TrailingDataError() |
|
183 |
+ raise AssertionError() |
|
184 |
+ |
|
185 |
+ |
|
186 |
+class SocketAddressAction(str, enum.Enum): |
|
187 |
+ """Test fixture settings for the SSH agent socket address. |
|
188 |
+ |
|
189 |
+ Attributes: |
|
190 |
+ MANGLE_ANNOYING_OS_NAMED_PIPE: |
|
191 |
+ Mangle the address for the Annoying OS named pipe endpoint. |
|
192 |
+ MANGLE_SSH_AUTH_SOCK: |
|
193 |
+ Mangle the address for the UNIX domain socket (the |
|
194 |
+ `SSH_AUTH_SOCK` environment variable). |
|
195 |
+ UNSET_ANNOYING_OS_NAMED_PIPE: |
|
196 |
+ Unset the address for the Annoying OS named pipe endpoint. |
|
197 |
+ UNSET_SSH_AUTH_SOCK: |
|
198 |
+ Unset the `SSH_AUTH_SOCK` environment variable (the address |
|
199 |
+ for the UNIX domain socket). |
|
200 |
+ |
|
201 |
+ """ |
|
202 |
+ |
|
203 |
+ MANGLE_ANNOYING_OS_NAMED_PIPE = enum.auto() |
|
204 |
+ """""" |
|
205 |
+ MANGLE_SSH_AUTH_SOCK = enum.auto() |
|
206 |
+ """""" |
|
207 |
+ UNSET_ANNOYING_OS_NAMED_PIPE = enum.auto() |
|
208 |
+ """""" |
|
209 |
+ UNSET_SSH_AUTH_SOCK = enum.auto() |
|
210 |
+ """""" |
|
211 |
+ |
|
212 |
+ def __call__( |
|
213 |
+ self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any |
|
214 |
+ ) -> None: |
|
215 |
+ """Execute the respective action.""" |
|
216 |
+ # TODO(the-13th-letter): Rewrite using structural pattern |
|
217 |
+ # matching. |
|
218 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
219 |
+ if self in { |
|
220 |
+ self.MANGLE_ANNOYING_OS_NAMED_PIPE, |
|
221 |
+ self.UNSET_ANNOYING_OS_NAMED_PIPE, |
|
222 |
+ }: # pragma: no cover [unused] |
|
223 |
+ pass |
|
224 |
+ elif self == self.MANGLE_SSH_AUTH_SOCK: |
|
225 |
+ monkeypatch.setenv( |
|
226 |
+ "SSH_AUTH_SOCK", os.environ["SSH_AUTH_SOCK"] + "~" |
|
227 |
+ ) |
|
228 |
+ elif self == self.UNSET_SSH_AUTH_SOCK: |
|
229 |
+ monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) |
|
230 |
+ else: |
|
231 |
+ raise AssertionError() |
|
232 |
+ |
|
233 |
+ |
|
234 |
+class SystemSupportAction(str, enum.Enum): |
|
235 |
+ """Test fixture settings for [`ssh_agent.SSHAgentClient`][] system support. |
|
236 |
+ |
|
237 |
+ Attributes: |
|
238 |
+ UNSET_AF_UNIX: |
|
239 |
+ Ensure lack of support for UNIX domain sockets. |
|
240 |
+ UNSET_AF_UNIX_AND_ENSURE_USE: |
|
241 |
+ Ensure lack of support for UNIX domain sockets, and that the |
|
242 |
+ agent will use this socket provider. |
|
243 |
+ UNSET_NATIVE: |
|
244 |
+ Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`. |
|
245 |
+ UNSET_NATIVE_AND_ENSURE_USE: |
|
246 |
+ Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`, and that the |
|
247 |
+ agent will use the native socket provider. |
|
248 |
+ UNSET_PROVIDER_LIST: |
|
249 |
+ Ensure an empty list of SSH agent socket providers. |
|
250 |
+ UNSET_WINDLL: |
|
251 |
+ Ensure lack of support for The Annoying OS named pipes. |
|
252 |
+ UNSET_WINDLL_AND_ENSURE_USE: |
|
253 |
+ Ensure lack of support for The Annoying OS named pipes, and |
|
254 |
+ that the agent will use this socket provider. |
|
255 |
+ |
|
256 |
+ """ |
|
257 |
+ |
|
258 |
+ UNSET_AF_UNIX = enum.auto() |
|
259 |
+ """""" |
|
260 |
+ UNSET_AF_UNIX_AND_ENSURE_USE = enum.auto() |
|
261 |
+ """""" |
|
262 |
+ UNSET_NATIVE = enum.auto() |
|
263 |
+ """""" |
|
264 |
+ UNSET_NATIVE_AND_ENSURE_USE = enum.auto() |
|
265 |
+ """""" |
|
266 |
+ UNSET_PROVIDER_LIST = enum.auto() |
|
267 |
+ """""" |
|
268 |
+ UNSET_WINDLL = enum.auto() |
|
269 |
+ """""" |
|
270 |
+ UNSET_WINDLL_AND_ENSURE_USE = enum.auto() |
|
271 |
+ """""" |
|
272 |
+ |
|
273 |
+ def __call__( |
|
274 |
+ self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any |
|
275 |
+ ) -> None: |
|
276 |
+ """Execute the respective action. |
|
277 |
+ |
|
278 |
+ Args: |
|
279 |
+ monkeypatch: The current monkeypatch context. |
|
280 |
+ |
|
281 |
+ """ |
|
282 |
+ # TODO(the-13th-letter): Rewrite using structural pattern |
|
283 |
+ # matching. |
|
284 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
285 |
+ if self == self.UNSET_PROVIDER_LIST: |
|
286 |
+ monkeypatch.setattr( |
|
287 |
+ ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", [] |
|
288 |
+ ) |
|
289 |
+ elif self in {self.UNSET_NATIVE, self.UNSET_NATIVE_AND_ENSURE_USE}: |
|
290 |
+ self.check_or_ensure_use( |
|
291 |
+ "native", |
|
292 |
+ monkeypatch=monkeypatch, |
|
293 |
+ ensure_use=(self == self.UNSET_NATIVE_AND_ENSURE_USE), |
|
294 |
+ ) |
|
295 |
+ monkeypatch.delattr(socket, "AF_UNIX", raising=False) |
|
296 |
+ monkeypatch.delattr(ctypes, "WinDLL", raising=False) |
|
297 |
+ monkeypatch.delattr(ctypes, "windll", raising=False) |
|
298 |
+ elif self in {self.UNSET_AF_UNIX, self.UNSET_AF_UNIX_AND_ENSURE_USE}: |
|
299 |
+ self.check_or_ensure_use( |
|
300 |
+ "posix", |
|
301 |
+ monkeypatch=monkeypatch, |
|
302 |
+ ensure_use=(self == self.UNSET_AF_UNIX_AND_ENSURE_USE), |
|
303 |
+ ) |
|
304 |
+ monkeypatch.delattr(socket, "AF_UNIX", raising=False) |
|
305 |
+ elif self in {self.UNSET_WINDLL, self.UNSET_WINDLL_AND_ENSURE_USE}: |
|
306 |
+ self.check_or_ensure_use( |
|
307 |
+ "the_annoying_os", |
|
308 |
+ monkeypatch=monkeypatch, |
|
309 |
+ ensure_use=(self == self.UNSET_WINDLL_AND_ENSURE_USE), |
|
310 |
+ ) |
|
311 |
+ monkeypatch.delattr(ctypes, "WinDLL", raising=False) |
|
312 |
+ monkeypatch.delattr(ctypes, "windll", raising=False) |
|
313 |
+ else: |
|
314 |
+ raise AssertionError() |
|
315 |
+ |
|
316 |
+ @staticmethod |
|
317 |
+ def check_or_ensure_use( |
|
318 |
+ provider: str, /, *, monkeypatch: pytest.MonkeyPatch, ensure_use: bool |
|
319 |
+ ) -> None: |
|
320 |
+ """Check that the named SSH agent socket provider will be used. |
|
321 |
+ |
|
322 |
+ Either ensure that the socket provider will definitely be used, |
|
323 |
+ or, upon detecting that it won't be used, skip the test. |
|
324 |
+ |
|
325 |
+ Args: |
|
326 |
+ provider: |
|
327 |
+ The provider to check for. |
|
328 |
+ ensure_use: |
|
329 |
+ If true, ensure that the socket provider will definitely |
|
330 |
+ be used. If false, then check for whether it will be |
|
331 |
+ used, and skip this test if not. |
|
332 |
+ monkeypatch: |
|
333 |
+ The monkeypatch context within which the fixture |
|
334 |
+ adjustments should be executed. |
|
335 |
+ |
|
336 |
+ """ |
|
337 |
+ if ensure_use: |
|
338 |
+ monkeypatch.setattr( |
|
339 |
+ ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", [provider] |
|
340 |
+ ) |
|
341 |
+ else: # pragma: no cover [external] |
|
342 |
+ # This branch operates completely on instrumented or on |
|
343 |
+ # externally defined, non-deterministic state. |
|
344 |
+ intended: ( |
|
345 |
+ _types.SSHAgentSocketProvider |
|
346 |
+ | socketprovider.NoSuchProviderError |
|
347 |
+ | None |
|
348 |
+ ) |
|
349 |
+ try: |
|
350 |
+ intended = socketprovider.SocketProvider.lookup(provider) |
|
351 |
+ except socketprovider.NoSuchProviderError as exc: |
|
352 |
+ intended = exc |
|
353 |
+ actual: ( |
|
354 |
+ _types.SSHAgentSocketProvider |
|
355 |
+ | socketprovider.NoSuchProviderError |
|
356 |
+ | None |
|
357 |
+ ) |
|
358 |
+ for name in ssh_agent.SSHAgentClient.SOCKET_PROVIDERS: |
|
359 |
+ try: |
|
360 |
+ actual = socketprovider.SocketProvider.lookup(name) |
|
361 |
+ except socketprovider.NoSuchProviderError as exc: |
|
362 |
+ actual = exc |
|
363 |
+ if actual is None: |
|
364 |
+ continue |
|
365 |
+ break |
|
366 |
+ else: |
|
367 |
+ actual = None |
|
368 |
+ if intended != actual: |
|
369 |
+ pytest.skip( |
|
370 |
+ f"{provider!r} SSH agent socket provider " |
|
371 |
+ f"is not currently in use" |
|
372 |
+ ) |
|
373 |
+ |
|
374 |
+ |
|
375 |
+class Parametrize(types.SimpleNamespace): |
|
376 |
+ """Common test parametrizations.""" |
|
377 |
+ |
|
378 |
+ DELETE_CONFIG_INPUT = pytest.mark.parametrize( |
|
379 |
+ ["command_line", "config", "result_config"], |
|
380 |
+ [ |
|
381 |
+ pytest.param( |
|
382 |
+ ["--delete-globals"], |
|
383 |
+ {"global": {"phrase": "abc"}, "services": {}}, |
|
384 |
+ {"services": {}}, |
|
385 |
+ id="globals", |
|
386 |
+ ), |
|
387 |
+ pytest.param( |
|
388 |
+ ["--delete", "--", DUMMY_SERVICE], |
|
389 |
+ { |
|
390 |
+ "global": {"phrase": "abc"}, |
|
391 |
+ "services": {DUMMY_SERVICE: {"notes": "..."}}, |
|
392 |
+ }, |
|
393 |
+ {"global": {"phrase": "abc"}, "services": {}}, |
|
394 |
+ id="service", |
|
395 |
+ ), |
|
396 |
+ pytest.param( |
|
397 |
+ ["--clear"], |
|
398 |
+ { |
|
399 |
+ "global": {"phrase": "abc"}, |
|
400 |
+ "services": {DUMMY_SERVICE: {"notes": "..."}}, |
|
401 |
+ }, |
|
402 |
+ {"services": {}}, |
|
403 |
+ id="all", |
|
404 |
+ ), |
|
405 |
+ ], |
|
406 |
+ ) |
|
407 |
+ BASE_CONFIG_VARIATIONS = pytest.mark.parametrize( |
|
408 |
+ "config", |
|
409 |
+ [ |
|
410 |
+ {"global": {"phrase": "my passphrase"}, "services": {}}, |
|
411 |
+ {"global": {"key": DUMMY_KEY1_B64}, "services": {}}, |
|
412 |
+ { |
|
413 |
+ "global": {"phrase": "abc"}, |
|
414 |
+ "services": {"sv": {"phrase": "my passphrase"}}, |
|
415 |
+ }, |
|
416 |
+ { |
|
417 |
+ "global": {"phrase": "abc"}, |
|
418 |
+ "services": {"sv": {"key": DUMMY_KEY1_B64}}, |
|
419 |
+ }, |
|
420 |
+ { |
|
421 |
+ "global": {"phrase": "abc"}, |
|
422 |
+ "services": {"sv": {"key": DUMMY_KEY1_B64, "length": 15}}, |
|
423 |
+ }, |
|
424 |
+ ], |
|
425 |
+ ) |
|
426 |
+ CONNECTION_HINTS = pytest.mark.parametrize( |
|
427 |
+ "conn_hint", ["none", "socket", "client"] |
|
428 |
+ ) |
|
429 |
+ KEY_TO_PHRASE_SETTINGS = pytest.mark.parametrize( |
|
430 |
+ [ |
|
431 |
+ "list_keys_action", |
|
432 |
+ "address_action", |
|
433 |
+ "system_support_action", |
|
434 |
+ "sign_action", |
|
435 |
+ "pattern", |
|
436 |
+ ], |
|
437 |
+ [ |
|
438 |
+ pytest.param( |
|
439 |
+ ListKeysAction.EMPTY, |
|
440 |
+ None, |
|
441 |
+ None, |
|
442 |
+ SignAction.FAIL, |
|
443 |
+ "not loaded into the agent", |
|
444 |
+ id="key-not-loaded", |
|
445 |
+ ), |
|
446 |
+ pytest.param( |
|
447 |
+ ListKeysAction.FAIL, |
|
448 |
+ None, |
|
449 |
+ None, |
|
450 |
+ SignAction.FAIL, |
|
451 |
+ "SSH agent failed to or refused to", |
|
452 |
+ id="list-keys-refused", |
|
453 |
+ ), |
|
454 |
+ pytest.param( |
|
455 |
+ ListKeysAction.FAIL_RUNTIME, |
|
456 |
+ None, |
|
457 |
+ None, |
|
458 |
+ SignAction.FAIL, |
|
459 |
+ "SSH agent failed to or refused to", |
|
460 |
+ id="list-keys-protocol-error", |
|
461 |
+ ), |
|
462 |
+ pytest.param( |
|
463 |
+ None, |
|
464 |
+ SocketAddressAction.UNSET_SSH_AUTH_SOCK, |
|
465 |
+ None, |
|
466 |
+ SignAction.FAIL, |
|
467 |
+ "Cannot find any running SSH agent", |
|
468 |
+ id="agent-address-missing", |
|
469 |
+ ), |
|
470 |
+ pytest.param( |
|
471 |
+ None, |
|
472 |
+ SocketAddressAction.MANGLE_SSH_AUTH_SOCK, |
|
473 |
+ None, |
|
474 |
+ SignAction.FAIL, |
|
475 |
+ "Cannot connect to the SSH agent", |
|
476 |
+ id="agent-address-mangled", |
|
477 |
+ ), |
|
478 |
+ pytest.param( |
|
479 |
+ None, |
|
480 |
+ None, |
|
481 |
+ SystemSupportAction.UNSET_NATIVE, |
|
482 |
+ SignAction.FAIL, |
|
483 |
+ "does not support communicating with it", |
|
484 |
+ id="no-agent-support", |
|
485 |
+ ), |
|
486 |
+ pytest.param( |
|
487 |
+ None, |
|
488 |
+ None, |
|
489 |
+ SystemSupportAction.UNSET_PROVIDER_LIST, |
|
490 |
+ SignAction.FAIL, |
|
491 |
+ "does not support communicating with it", |
|
492 |
+ id="no-agent-support", |
|
493 |
+ ), |
|
494 |
+ pytest.param( |
|
495 |
+ None, |
|
496 |
+ None, |
|
497 |
+ SystemSupportAction.UNSET_AF_UNIX_AND_ENSURE_USE, |
|
498 |
+ SignAction.FAIL, |
|
499 |
+ "does not support communicating with it", |
|
500 |
+ id="no-agent-support", |
|
501 |
+ ), |
|
502 |
+ pytest.param( |
|
503 |
+ None, |
|
504 |
+ None, |
|
505 |
+ SystemSupportAction.UNSET_WINDLL_AND_ENSURE_USE, |
|
506 |
+ SignAction.FAIL, |
|
507 |
+ "does not support communicating with it", |
|
508 |
+ id="no-agent-support", |
|
509 |
+ ), |
|
510 |
+ pytest.param( |
|
511 |
+ None, |
|
512 |
+ None, |
|
513 |
+ None, |
|
514 |
+ SignAction.FAIL_RUNTIME, |
|
515 |
+ "violates the communication protocol", |
|
516 |
+ id="sign-violates-protocol", |
|
517 |
+ ), |
|
518 |
+ ], |
|
519 |
+ ) |
|
520 |
+ VALIDATION_FUNCTION_INPUT = pytest.mark.parametrize( |
|
521 |
+ ["vfunc", "input"], |
|
522 |
+ [ |
|
523 |
+ (cli_machinery.validate_occurrence_constraint, 20), |
|
524 |
+ (cli_machinery.validate_length, 20), |
|
525 |
+ ], |
|
526 |
+ ) |
|
527 |
+ |
|
528 |
+ |
|
529 |
+class TestCLIUtils: |
|
530 |
+ """Tests for command-line utility functions.""" |
|
531 |
+ |
|
532 |
+ @Parametrize.BASE_CONFIG_VARIATIONS |
|
533 |
+ def test_100_load_config( |
|
534 |
+ self, |
|
535 |
+ config: Any, |
|
536 |
+ ) -> None: |
|
537 |
+ """[`cli_helpers.load_config`][] works for valid configurations.""" |
|
538 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
539 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
540 |
+ # with-statements. |
|
541 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
542 |
+ with contextlib.ExitStack() as stack: |
|
543 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
544 |
+ stack.enter_context( |
|
545 |
+ pytest_machinery.isolated_vault_config( |
|
546 |
+ monkeypatch=monkeypatch, |
|
547 |
+ runner=runner, |
|
548 |
+ vault_config=config, |
|
549 |
+ ) |
|
550 |
+ ) |
|
551 |
+ config_filename = cli_helpers.config_filename(subsystem="vault") |
|
552 |
+ with config_filename.open(encoding="UTF-8") as fileobj: |
|
553 |
+ assert json.load(fileobj) == config |
|
554 |
+ assert cli_helpers.load_config() == config |
|
555 |
+ |
|
556 |
+ def test_110_save_bad_config( |
|
557 |
+ self, |
|
558 |
+ ) -> None: |
|
559 |
+ """[`cli_helpers.save_config`][] fails for bad configurations.""" |
|
560 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
561 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
562 |
+ # with-statements. |
|
563 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
564 |
+ with contextlib.ExitStack() as stack: |
|
565 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
566 |
+ stack.enter_context( |
|
567 |
+ pytest_machinery.isolated_vault_config( |
|
568 |
+ monkeypatch=monkeypatch, |
|
569 |
+ runner=runner, |
|
570 |
+ vault_config={}, |
|
571 |
+ ) |
|
572 |
+ ) |
|
573 |
+ stack.enter_context( |
|
574 |
+ pytest.raises(ValueError, match="Invalid vault config") |
|
575 |
+ ) |
|
576 |
+ cli_helpers.save_config(None) # type: ignore[arg-type] |
|
577 |
+ |
|
578 |
+ def test_111_prompt_for_selection_multiple(self) -> None: |
|
579 |
+ """[`cli_helpers.prompt_for_selection`][] works in the "multiple" case.""" |
|
580 |
+ |
|
581 |
+ @click.command() |
|
582 |
+ @click.option("--heading", default="Our menu:") |
|
583 |
+ @click.argument("items", nargs=-1) |
|
584 |
+ def driver(heading: str, items: list[str]) -> None: |
|
585 |
+ # from https://montypython.fandom.com/wiki/Spam#The_menu |
|
586 |
+ items = items or [ |
|
587 |
+ "Egg and bacon", |
|
588 |
+ "Egg, sausage and bacon", |
|
589 |
+ "Egg and spam", |
|
590 |
+ "Egg, bacon and spam", |
|
591 |
+ "Egg, bacon, sausage and spam", |
|
592 |
+ "Spam, bacon, sausage and spam", |
|
593 |
+ "Spam, egg, spam, spam, bacon and spam", |
|
594 |
+ "Spam, spam, spam, egg and spam", |
|
595 |
+ ( |
|
596 |
+ "Spam, spam, spam, spam, spam, spam, baked beans, " |
|
597 |
+ "spam, spam, spam and spam" |
|
598 |
+ ), |
|
599 |
+ ( |
|
600 |
+ "Lobster thermidor aux crevettes with a mornay sauce " |
|
601 |
+ "garnished with truffle paté, brandy " |
|
602 |
+ "and a fried egg on top and spam" |
|
603 |
+ ), |
|
604 |
+ ] |
|
605 |
+ index = cli_helpers.prompt_for_selection(items, heading=heading) |
|
606 |
+ click.echo("A fine choice: ", nl=False) |
|
607 |
+ click.echo(items[index]) |
|
608 |
+ click.echo("(Note: Vikings strictly optional.)") |
|
609 |
+ |
|
610 |
+ runner = machinery.CliRunner(mix_stderr=True) |
|
611 |
+ result = runner.invoke(driver, [], input="9") |
|
612 |
+ assert result.clean_exit( |
|
613 |
+ output="""\ |
|
614 |
+Our menu: |
|
615 |
+[1] Egg and bacon |
|
616 |
+[2] Egg, sausage and bacon |
|
617 |
+[3] Egg and spam |
|
618 |
+[4] Egg, bacon and spam |
|
619 |
+[5] Egg, bacon, sausage and spam |
|
620 |
+[6] Spam, bacon, sausage and spam |
|
621 |
+[7] Spam, egg, spam, spam, bacon and spam |
|
622 |
+[8] Spam, spam, spam, egg and spam |
|
623 |
+[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam |
|
624 |
+[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam |
|
625 |
+Your selection? (1-10, leave empty to abort): 9 |
|
626 |
+A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam |
|
627 |
+(Note: Vikings strictly optional.) |
|
628 |
+""" |
|
629 |
+ ), "expected clean exit" |
|
630 |
+ result = runner.invoke( |
|
631 |
+ driver, ["--heading="], input="\n", catch_exceptions=True |
|
632 |
+ ) |
|
633 |
+ assert result.error_exit(error=IndexError), ( |
|
634 |
+ "expected error exit and known error type" |
|
635 |
+ ) |
|
636 |
+ assert ( |
|
637 |
+ result.stdout |
|
638 |
+ == """\ |
|
639 |
+[1] Egg and bacon |
|
640 |
+[2] Egg, sausage and bacon |
|
641 |
+[3] Egg and spam |
|
642 |
+[4] Egg, bacon and spam |
|
643 |
+[5] Egg, bacon, sausage and spam |
|
644 |
+[6] Spam, bacon, sausage and spam |
|
645 |
+[7] Spam, egg, spam, spam, bacon and spam |
|
646 |
+[8] Spam, spam, spam, egg and spam |
|
647 |
+[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam |
|
648 |
+[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam |
|
649 |
+Your selection? (1-10, leave empty to abort):\x20 |
|
650 |
+""" |
|
651 |
+ ), "expected known output" |
|
652 |
+ # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the |
|
653 |
+ # click prompting machinery, meaning that the mixed output will |
|
654 |
+ # incorrectly contain a line break, contrary to what the |
|
655 |
+ # documentation for click.prompt prescribes. |
|
656 |
+ result = runner.invoke( |
|
657 |
+ driver, ["--heading="], input="", catch_exceptions=True |
|
658 |
+ ) |
|
659 |
+ assert result.error_exit(error=IndexError), ( |
|
660 |
+ "expected error exit and known error type" |
|
661 |
+ ) |
|
662 |
+ assert result.stdout in { |
|
663 |
+ """\ |
|
664 |
+[1] Egg and bacon |
|
665 |
+[2] Egg, sausage and bacon |
|
666 |
+[3] Egg and spam |
|
667 |
+[4] Egg, bacon and spam |
|
668 |
+[5] Egg, bacon, sausage and spam |
|
669 |
+[6] Spam, bacon, sausage and spam |
|
670 |
+[7] Spam, egg, spam, spam, bacon and spam |
|
671 |
+[8] Spam, spam, spam, egg and spam |
|
672 |
+[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam |
|
673 |
+[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam |
|
674 |
+Your selection? (1-10, leave empty to abort):\x20 |
|
675 |
+""", |
|
676 |
+ """\ |
|
677 |
+[1] Egg and bacon |
|
678 |
+[2] Egg, sausage and bacon |
|
679 |
+[3] Egg and spam |
|
680 |
+[4] Egg, bacon and spam |
|
681 |
+[5] Egg, bacon, sausage and spam |
|
682 |
+[6] Spam, bacon, sausage and spam |
|
683 |
+[7] Spam, egg, spam, spam, bacon and spam |
|
684 |
+[8] Spam, spam, spam, egg and spam |
|
685 |
+[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam |
|
686 |
+[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam |
|
687 |
+Your selection? (1-10, leave empty to abort): """, |
|
688 |
+ }, "expected known output" |
|
689 |
+ |
|
690 |
+ def test_112_prompt_for_selection_single(self) -> None: |
|
691 |
+ """[`cli_helpers.prompt_for_selection`][] works in the "single" case.""" |
|
692 |
+ |
|
693 |
+ @click.command() |
|
694 |
+ @click.option("--item", default="baked beans") |
|
695 |
+ @click.argument("prompt") |
|
696 |
+ def driver(item: str, prompt: str) -> None: |
|
697 |
+ try: |
|
698 |
+ cli_helpers.prompt_for_selection( |
|
699 |
+ [item], heading="", single_choice_prompt=prompt |
|
700 |
+ ) |
|
701 |
+ except IndexError: |
|
702 |
+ click.echo("Boo.") |
|
703 |
+ raise |
|
704 |
+ else: |
|
705 |
+ click.echo("Great!") |
|
706 |
+ |
|
707 |
+ runner = machinery.CliRunner(mix_stderr=True) |
|
708 |
+ result = runner.invoke( |
|
709 |
+ driver, ["Will replace with spam. Confirm, y/n?"], input="y" |
|
710 |
+ ) |
|
711 |
+ assert result.clean_exit( |
|
712 |
+ output="""\ |
|
713 |
+[1] baked beans |
|
714 |
+Will replace with spam. Confirm, y/n? y |
|
715 |
+Great! |
|
716 |
+""" |
|
717 |
+ ), "expected clean exit" |
|
718 |
+ result = runner.invoke( |
|
719 |
+ driver, |
|
720 |
+ ['Will replace with spam, okay? (Please say "y" or "n".)'], |
|
721 |
+ input="\n", |
|
722 |
+ ) |
|
723 |
+ assert result.error_exit(error=IndexError), ( |
|
724 |
+ "expected error exit and known error type" |
|
725 |
+ ) |
|
726 |
+ assert ( |
|
727 |
+ result.stdout |
|
728 |
+ == """\ |
|
729 |
+[1] baked beans |
|
730 |
+Will replace with spam, okay? (Please say "y" or "n".):\x20 |
|
731 |
+Boo. |
|
732 |
+""" |
|
733 |
+ ), "expected known output" |
|
734 |
+ # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the |
|
735 |
+ # click prompting machinery, meaning that the mixed output will |
|
736 |
+ # incorrectly contain a line break, contrary to what the |
|
737 |
+ # documentation for click.prompt prescribes. |
|
738 |
+ result = runner.invoke( |
|
739 |
+ driver, |
|
740 |
+ ['Will replace with spam, okay? (Please say "y" or "n".)'], |
|
741 |
+ input="", |
|
742 |
+ ) |
|
743 |
+ assert result.error_exit(error=IndexError), ( |
|
744 |
+ "expected error exit and known error type" |
|
745 |
+ ) |
|
746 |
+ assert result.stdout in { |
|
747 |
+ """\ |
|
748 |
+[1] baked beans |
|
749 |
+Will replace with spam, okay? (Please say "y" or "n".):\x20 |
|
750 |
+Boo. |
|
751 |
+""", |
|
752 |
+ """\ |
|
753 |
+[1] baked beans |
|
754 |
+Will replace with spam, okay? (Please say "y" or "n".): Boo. |
|
755 |
+""", |
|
756 |
+ }, "expected known output" |
|
757 |
+ |
|
758 |
+ def test_113_prompt_for_passphrase( |
|
759 |
+ self, |
|
760 |
+ ) -> None: |
|
761 |
+ """[`cli_helpers.prompt_for_passphrase`][] works.""" |
|
762 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
763 |
+ monkeypatch.setattr( |
|
764 |
+ click, |
|
765 |
+ "prompt", |
|
766 |
+ lambda *a, **kw: json.dumps({"args": a, "kwargs": kw}), |
|
767 |
+ ) |
|
768 |
+ res = json.loads(cli_helpers.prompt_for_passphrase()) |
|
769 |
+ err_msg = "missing arguments to passphrase prompt" |
|
770 |
+ assert "args" in res, err_msg |
|
771 |
+ assert "kwargs" in res, err_msg |
|
772 |
+ assert res["args"][:1] == ["Passphrase"], err_msg |
|
773 |
+ assert res["kwargs"].get("default") == "", err_msg |
|
774 |
+ assert not res["kwargs"].get("show_default", True), err_msg |
|
775 |
+ assert res["kwargs"].get("err"), err_msg |
|
776 |
+ assert res["kwargs"].get("hide_input"), err_msg |
|
777 |
+ |
|
778 |
+ def test_120_standard_logging_context_manager( |
|
779 |
+ self, |
|
780 |
+ caplog: pytest.LogCaptureFixture, |
|
781 |
+ capsys: pytest.CaptureFixture[str], |
|
782 |
+ ) -> None: |
|
783 |
+ """The standard logging context manager works. |
|
784 |
+ |
|
785 |
+ It registers its handlers, once, and emits formatted calls to |
|
786 |
+ standard error prefixed with the program name. |
|
787 |
+ |
|
788 |
+ """ |
|
789 |
+ prog_name = cli_machinery.StandardCLILogging.prog_name |
|
790 |
+ package_name = cli_machinery.StandardCLILogging.package_name |
|
791 |
+ logger = logging.getLogger(package_name) |
|
792 |
+ deprecation_logger = logging.getLogger(f"{package_name}.deprecation") |
|
793 |
+ logging_cm = cli_machinery.StandardCLILogging.ensure_standard_logging() |
|
794 |
+ with logging_cm: |
|
795 |
+ assert ( |
|
796 |
+ sum( |
|
797 |
+ 1 |
|
798 |
+ for h in logger.handlers |
|
799 |
+ if h is cli_machinery.StandardCLILogging.cli_handler |
|
800 |
+ ) |
|
801 |
+ == 1 |
|
802 |
+ ) |
|
803 |
+ logger.warning("message 1") |
|
804 |
+ with logging_cm: |
|
805 |
+ deprecation_logger.warning("message 2") |
|
806 |
+ assert ( |
|
807 |
+ sum( |
|
808 |
+ 1 |
|
809 |
+ for h in logger.handlers |
|
810 |
+ if h is cli_machinery.StandardCLILogging.cli_handler |
|
811 |
+ ) |
|
812 |
+ == 1 |
|
813 |
+ ) |
|
814 |
+ assert capsys.readouterr() == ( |
|
815 |
+ "", |
|
816 |
+ ( |
|
817 |
+ f"{prog_name}: Warning: message 1\n" |
|
818 |
+ f"{prog_name}: Deprecation warning: message 2\n" |
|
819 |
+ ), |
|
820 |
+ ) |
|
821 |
+ logger.warning("message 3") |
|
822 |
+ assert ( |
|
823 |
+ sum( |
|
824 |
+ 1 |
|
825 |
+ for h in logger.handlers |
|
826 |
+ if h is cli_machinery.StandardCLILogging.cli_handler |
|
827 |
+ ) |
|
828 |
+ == 1 |
|
829 |
+ ) |
|
830 |
+ assert capsys.readouterr() == ( |
|
831 |
+ "", |
|
832 |
+ f"{prog_name}: Warning: message 3\n", |
|
833 |
+ ) |
|
834 |
+ assert caplog.record_tuples == [ |
|
835 |
+ (package_name, logging.WARNING, "message 1"), |
|
836 |
+ (f"{package_name}.deprecation", logging.WARNING, "message 2"), |
|
837 |
+ (package_name, logging.WARNING, "message 3"), |
|
838 |
+ ] |
|
839 |
+ |
|
840 |
+ def test_121_standard_logging_warnings_context_manager( |
|
841 |
+ self, |
|
842 |
+ caplog: pytest.LogCaptureFixture, |
|
843 |
+ capsys: pytest.CaptureFixture[str], |
|
844 |
+ ) -> None: |
|
845 |
+ """The standard warnings logging context manager works. |
|
846 |
+ |
|
847 |
+ It registers its handlers, once, and emits formatted calls to |
|
848 |
+ standard error prefixed with the program name. It also adheres |
|
849 |
+ to the global warnings filter concerning which messages it |
|
850 |
+ actually emits to standard error. |
|
851 |
+ |
|
852 |
+ """ |
|
853 |
+ warnings_cm = ( |
|
854 |
+ cli_machinery.StandardCLILogging.ensure_standard_warnings_logging() |
|
855 |
+ ) |
|
856 |
+ THE_FUTURE = "the future will be here sooner than you think" # noqa: N806 |
|
857 |
+ JUST_TESTING = "just testing whether warnings work" # noqa: N806 |
|
858 |
+ with warnings_cm: |
|
859 |
+ assert ( |
|
860 |
+ sum( |
|
861 |
+ 1 |
|
862 |
+ for h in logging.getLogger("py.warnings").handlers |
|
863 |
+ if h is cli_machinery.StandardCLILogging.warnings_handler |
|
864 |
+ ) |
|
865 |
+ == 1 |
|
866 |
+ ) |
|
867 |
+ warnings.warn(UserWarning(JUST_TESTING), stacklevel=1) |
|
868 |
+ with warnings_cm: |
|
869 |
+ warnings.warn(FutureWarning(THE_FUTURE), stacklevel=1) |
|
870 |
+ _out, err = capsys.readouterr() |
|
871 |
+ err_lines = err.splitlines(True) |
|
872 |
+ assert any( |
|
873 |
+ f"UserWarning: {JUST_TESTING}" in line |
|
874 |
+ for line in err_lines |
|
875 |
+ ) |
|
876 |
+ assert any( |
|
877 |
+ f"FutureWarning: {THE_FUTURE}" in line |
|
878 |
+ for line in err_lines |
|
879 |
+ ) |
|
880 |
+ warnings.warn(UserWarning(JUST_TESTING), stacklevel=1) |
|
881 |
+ _out, err = capsys.readouterr() |
|
882 |
+ err_lines = err.splitlines(True) |
|
883 |
+ assert any( |
|
884 |
+ f"UserWarning: {JUST_TESTING}" in line for line in err_lines |
|
885 |
+ ) |
|
886 |
+ assert not any( |
|
887 |
+ f"FutureWarning: {THE_FUTURE}" in line for line in err_lines |
|
888 |
+ ) |
|
889 |
+ record_tuples = caplog.record_tuples |
|
890 |
+ assert [tup[:2] for tup in record_tuples] == [ |
|
891 |
+ ("py.warnings", logging.WARNING), |
|
892 |
+ ("py.warnings", logging.WARNING), |
|
893 |
+ ("py.warnings", logging.WARNING), |
|
894 |
+ ] |
|
895 |
+ assert f"UserWarning: {JUST_TESTING}" in record_tuples[0][2] |
|
896 |
+ assert f"FutureWarning: {THE_FUTURE}" in record_tuples[1][2] |
|
897 |
+ assert f"UserWarning: {JUST_TESTING}" in record_tuples[2][2] |
|
898 |
+ |
|
899 |
+ def export_as_sh_helper( |
|
900 |
+ self, |
|
901 |
+ config: Any, |
|
902 |
+ ) -> None: |
|
903 |
+ """Emits a config in sh(1) format, then reads it back to verify it. |
|
904 |
+ |
|
905 |
+ This function exports the configuration, sets up a new |
|
906 |
+ enviroment, then calls |
|
907 |
+ [`vault_config_exporter_shell_interpreter`][] on the export |
|
908 |
+ script, verifying that each command ran successfully and that |
|
909 |
+ the final configuration matches the initial one. |
|
910 |
+ |
|
911 |
+ Args: |
|
912 |
+ config: |
|
913 |
+ The configuration to emit and read back. |
|
914 |
+ |
|
915 |
+ """ |
|
916 |
+ prog_name_list = ("derivepassphrase", "vault") |
|
917 |
+ with io.StringIO() as outfile: |
|
918 |
+ cli_helpers.print_config_as_sh_script( |
|
919 |
+ config, outfile=outfile, prog_name_list=prog_name_list |
|
920 |
+ ) |
|
921 |
+ script = outfile.getvalue() |
|
922 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
923 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
924 |
+ # with-statements. |
|
925 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
926 |
+ with contextlib.ExitStack() as stack: |
|
927 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
928 |
+ stack.enter_context( |
|
929 |
+ pytest_machinery.isolated_vault_config( |
|
930 |
+ monkeypatch=monkeypatch, |
|
931 |
+ runner=runner, |
|
932 |
+ vault_config={"services": {}}, |
|
933 |
+ ) |
|
934 |
+ ) |
|
935 |
+ for result in vault_config_exporter_shell_interpreter(script): |
|
936 |
+ assert result.clean_exit() |
|
937 |
+ assert cli_helpers.load_config() == config |
|
938 |
+ |
|
939 |
+ @hypothesis.given( |
|
940 |
+ global_config_settable=hypothesis_machinery.vault_full_service_config(), |
|
941 |
+ global_config_importable=strategies.fixed_dictionaries( |
|
942 |
+ {}, |
|
943 |
+ optional={ |
|
944 |
+ "key": strategies.text( |
|
945 |
+ alphabet=strategies.characters( |
|
946 |
+ min_codepoint=32, |
|
947 |
+ max_codepoint=126, |
|
948 |
+ ), |
|
949 |
+ max_size=128, |
|
950 |
+ ), |
|
951 |
+ "phrase": strategies.text( |
|
952 |
+ alphabet=strategies.characters( |
|
953 |
+ min_codepoint=32, |
|
954 |
+ max_codepoint=126, |
|
955 |
+ ), |
|
956 |
+ max_size=64, |
|
957 |
+ ), |
|
958 |
+ }, |
|
959 |
+ ), |
|
960 |
+ ) |
|
961 |
+ def test_130a_export_as_sh_global( |
|
962 |
+ self, |
|
963 |
+ global_config_settable: _types.VaultConfigServicesSettings, |
|
964 |
+ global_config_importable: _types.VaultConfigServicesSettings, |
|
965 |
+ ) -> None: |
|
966 |
+ """Exporting configurations as sh(1) script works. |
|
967 |
+ |
|
968 |
+ Here, we check global-only configurations which use both |
|
969 |
+ settings settable via `--config` and settings requiring |
|
970 |
+ `--import`. |
|
971 |
+ |
|
972 |
+ The actual verification is done by [`export_as_sh_helper`][]. |
|
973 |
+ |
|
974 |
+ """ |
|
975 |
+ config: _types.VaultConfig = { |
|
976 |
+ "global": global_config_settable | global_config_importable, |
|
977 |
+ "services": {}, |
|
978 |
+ } |
|
979 |
+ assert _types.clean_up_falsy_vault_config_values(config) is not None |
|
980 |
+ assert _types.is_vault_config(config) |
|
981 |
+ return self.export_as_sh_helper(config) |
|
982 |
+ |
|
983 |
+ @hypothesis.given( |
|
984 |
+ global_config_importable=strategies.fixed_dictionaries( |
|
985 |
+ {}, |
|
986 |
+ optional={ |
|
987 |
+ "key": strategies.text( |
|
988 |
+ alphabet=strategies.characters( |
|
989 |
+ min_codepoint=32, |
|
990 |
+ max_codepoint=126, |
|
991 |
+ ), |
|
992 |
+ max_size=128, |
|
993 |
+ ), |
|
994 |
+ "phrase": strategies.text( |
|
995 |
+ alphabet=strategies.characters( |
|
996 |
+ min_codepoint=32, |
|
997 |
+ max_codepoint=126, |
|
998 |
+ ), |
|
999 |
+ max_size=64, |
|
1000 |
+ ), |
|
1001 |
+ }, |
|
1002 |
+ ), |
|
1003 |
+ ) |
|
1004 |
+ def test_130b_export_as_sh_global_only_imports( |
|
1005 |
+ self, |
|
1006 |
+ global_config_importable: _types.VaultConfigServicesSettings, |
|
1007 |
+ ) -> None: |
|
1008 |
+ """Exporting configurations as sh(1) script works. |
|
1009 |
+ |
|
1010 |
+ Here, we check global-only configurations which only use |
|
1011 |
+ settings requiring `--import`. |
|
1012 |
+ |
|
1013 |
+ The actual verification is done by [`export_as_sh_helper`][]. |
|
1014 |
+ |
|
1015 |
+ """ |
|
1016 |
+ config: _types.VaultConfig = { |
|
1017 |
+ "global": global_config_importable, |
|
1018 |
+ "services": {}, |
|
1019 |
+ } |
|
1020 |
+ assert _types.clean_up_falsy_vault_config_values(config) is not None |
|
1021 |
+ assert _types.is_vault_config(config) |
|
1022 |
+ if not config["global"]: |
|
1023 |
+ config.pop("global") |
|
1024 |
+ return self.export_as_sh_helper(config) |
|
1025 |
+ |
|
1026 |
+ @hypothesis.given( |
|
1027 |
+ service_name=strategies.text( |
|
1028 |
+ alphabet=strategies.characters( |
|
1029 |
+ min_codepoint=32, |
|
1030 |
+ max_codepoint=126, |
|
1031 |
+ ), |
|
1032 |
+ min_size=4, |
|
1033 |
+ max_size=64, |
|
1034 |
+ ), |
|
1035 |
+ service_config_settable=hypothesis_machinery.vault_full_service_config(), |
|
1036 |
+ service_config_importable=strategies.fixed_dictionaries( |
|
1037 |
+ {}, |
|
1038 |
+ optional={ |
|
1039 |
+ "key": strategies.text( |
|
1040 |
+ alphabet=strategies.characters( |
|
1041 |
+ min_codepoint=32, |
|
1042 |
+ max_codepoint=126, |
|
1043 |
+ ), |
|
1044 |
+ max_size=128, |
|
1045 |
+ ), |
|
1046 |
+ "phrase": strategies.text( |
|
1047 |
+ alphabet=strategies.characters( |
|
1048 |
+ min_codepoint=32, |
|
1049 |
+ max_codepoint=126, |
|
1050 |
+ ), |
|
1051 |
+ max_size=64, |
|
1052 |
+ ), |
|
1053 |
+ "notes": strategies.text( |
|
1054 |
+ alphabet=strategies.characters( |
|
1055 |
+ min_codepoint=32, |
|
1056 |
+ max_codepoint=126, |
|
1057 |
+ include_characters=("\n", "\f", "\t"), |
|
1058 |
+ ), |
|
1059 |
+ max_size=256, |
|
1060 |
+ ), |
|
1061 |
+ }, |
|
1062 |
+ ), |
|
1063 |
+ ) |
|
1064 |
+ def test_130c_export_as_sh_service( |
|
1065 |
+ self, |
|
1066 |
+ service_name: str, |
|
1067 |
+ service_config_settable: _types.VaultConfigServicesSettings, |
|
1068 |
+ service_config_importable: _types.VaultConfigServicesSettings, |
|
1069 |
+ ) -> None: |
|
1070 |
+ """Exporting configurations as sh(1) script works. |
|
1071 |
+ |
|
1072 |
+ Here, we check service-only configurations which use both |
|
1073 |
+ settings settable via `--config` and settings requiring |
|
1074 |
+ `--import`. |
|
1075 |
+ |
|
1076 |
+ The actual verification is done by [`export_as_sh_helper`][]. |
|
1077 |
+ |
|
1078 |
+ """ |
|
1079 |
+ config: _types.VaultConfig = { |
|
1080 |
+ "services": { |
|
1081 |
+ service_name: ( |
|
1082 |
+ service_config_settable | service_config_importable |
|
1083 |
+ ), |
|
1084 |
+ }, |
|
1085 |
+ } |
|
1086 |
+ assert _types.clean_up_falsy_vault_config_values(config) is not None |
|
1087 |
+ assert _types.is_vault_config(config) |
|
1088 |
+ return self.export_as_sh_helper(config) |
|
1089 |
+ |
|
1090 |
+ @hypothesis.given( |
|
1091 |
+ service_name=strategies.text( |
|
1092 |
+ alphabet=strategies.characters( |
|
1093 |
+ min_codepoint=32, |
|
1094 |
+ max_codepoint=126, |
|
1095 |
+ ), |
|
1096 |
+ min_size=4, |
|
1097 |
+ max_size=64, |
|
1098 |
+ ), |
|
1099 |
+ service_config_importable=strategies.fixed_dictionaries( |
|
1100 |
+ {}, |
|
1101 |
+ optional={ |
|
1102 |
+ "key": strategies.text( |
|
1103 |
+ alphabet=strategies.characters( |
|
1104 |
+ min_codepoint=32, |
|
1105 |
+ max_codepoint=126, |
|
1106 |
+ ), |
|
1107 |
+ max_size=128, |
|
1108 |
+ ), |
|
1109 |
+ "phrase": strategies.text( |
|
1110 |
+ alphabet=strategies.characters( |
|
1111 |
+ min_codepoint=32, |
|
1112 |
+ max_codepoint=126, |
|
1113 |
+ ), |
|
1114 |
+ max_size=64, |
|
1115 |
+ ), |
|
1116 |
+ "notes": strategies.text( |
|
1117 |
+ alphabet=strategies.characters( |
|
1118 |
+ min_codepoint=32, |
|
1119 |
+ max_codepoint=126, |
|
1120 |
+ include_characters=("\n", "\f", "\t"), |
|
1121 |
+ ), |
|
1122 |
+ max_size=256, |
|
1123 |
+ ), |
|
1124 |
+ }, |
|
1125 |
+ ), |
|
1126 |
+ ) |
|
1127 |
+ def test_130d_export_as_sh_service_only_imports( |
|
1128 |
+ self, |
|
1129 |
+ service_name: str, |
|
1130 |
+ service_config_importable: _types.VaultConfigServicesSettings, |
|
1131 |
+ ) -> None: |
|
1132 |
+ """Exporting configurations as sh(1) script works. |
|
1133 |
+ |
|
1134 |
+ Here, we check service-only configurations which only use |
|
1135 |
+ settings requiring `--import`. |
|
1136 |
+ |
|
1137 |
+ The actual verification is done by [`export_as_sh_helper`][]. |
|
1138 |
+ |
|
1139 |
+ """ |
|
1140 |
+ config: _types.VaultConfig = { |
|
1141 |
+ "services": { |
|
1142 |
+ service_name: service_config_importable, |
|
1143 |
+ }, |
|
1144 |
+ } |
|
1145 |
+ assert _types.clean_up_falsy_vault_config_values(config) is not None |
|
1146 |
+ assert _types.is_vault_config(config) |
|
1147 |
+ return self.export_as_sh_helper(config) |
|
1148 |
+ |
|
1149 |
+ # The Annoying OS appears to silently truncate spaces at the end of |
|
1150 |
+ # filenames. |
|
1151 |
+ @hypothesis.given( |
|
1152 |
+ env_var=strategies.sampled_from(["TMPDIR", "TEMP", "TMP"]), |
|
1153 |
+ suffix=strategies.builds( |
|
1154 |
+ operator.add, |
|
1155 |
+ strategies.text( |
|
1156 |
+ tuple(" 0123456789abcdefghijklmnopqrstuvwxyz"), |
|
1157 |
+ min_size=11, |
|
1158 |
+ max_size=11, |
|
1159 |
+ ), |
|
1160 |
+ strategies.text( |
|
1161 |
+ tuple("0123456789abcdefghijklmnopqrstuvwxyz"), |
|
1162 |
+ min_size=1, |
|
1163 |
+ max_size=1, |
|
1164 |
+ ), |
|
1165 |
+ ), |
|
1166 |
+ ) |
|
1167 |
+ @hypothesis.example(env_var="", suffix=".") |
|
1168 |
+ def test_140a_get_tempdir( |
|
1169 |
+ self, |
|
1170 |
+ env_var: str, |
|
1171 |
+ suffix: str, |
|
1172 |
+ ) -> None: |
|
1173 |
+ """[`cli_helpers.get_tempdir`][] returns a temporary directory. |
|
1174 |
+ |
|
1175 |
+ If it is not the same as the temporary directory determined by |
|
1176 |
+ [`tempfile.gettempdir`][], then assert that |
|
1177 |
+ `tempfile.gettempdir` returned the current directory and |
|
1178 |
+ `cli_helpers.get_tempdir` returned the configuration directory. |
|
1179 |
+ |
|
1180 |
+ """ |
|
1181 |
+ |
|
1182 |
+ @contextlib.contextmanager |
|
1183 |
+ def make_temporary_directory( |
|
1184 |
+ path: pathlib.Path, |
|
1185 |
+ ) -> Iterator[pathlib.Path]: |
|
1186 |
+ try: |
|
1187 |
+ path.mkdir() |
|
1188 |
+ yield path |
|
1189 |
+ finally: |
|
1190 |
+ shutil.rmtree(path) |
|
1191 |
+ |
|
1192 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1193 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1194 |
+ # with-statements. |
|
1195 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1196 |
+ with contextlib.ExitStack() as stack: |
|
1197 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1198 |
+ stack.enter_context( |
|
1199 |
+ pytest_machinery.isolated_vault_config( |
|
1200 |
+ monkeypatch=monkeypatch, |
|
1201 |
+ runner=runner, |
|
1202 |
+ vault_config={"services": {}}, |
|
1203 |
+ ) |
|
1204 |
+ ) |
|
1205 |
+ old_tempdir = os.fsdecode(tempfile.gettempdir()) |
|
1206 |
+ monkeypatch.delenv("TMPDIR", raising=False) |
|
1207 |
+ monkeypatch.delenv("TEMP", raising=False) |
|
1208 |
+ monkeypatch.delenv("TMP", raising=False) |
|
1209 |
+ monkeypatch.setattr(tempfile, "tempdir", None) |
|
1210 |
+ temp_path = pathlib.Path.cwd() / suffix |
|
1211 |
+ if env_var: |
|
1212 |
+ monkeypatch.setenv(env_var, os.fsdecode(temp_path)) |
|
1213 |
+ stack.enter_context(make_temporary_directory(temp_path)) |
|
1214 |
+ new_tempdir = os.fsdecode(tempfile.gettempdir()) |
|
1215 |
+ hypothesis.assume( |
|
1216 |
+ temp_path.resolve() == pathlib.Path.cwd().resolve() |
|
1217 |
+ or old_tempdir != new_tempdir |
|
1218 |
+ ) |
|
1219 |
+ system_tempdir = os.fsdecode(tempfile.gettempdir()) |
|
1220 |
+ our_tempdir = cli_helpers.get_tempdir() |
|
1221 |
+ assert system_tempdir == os.fsdecode(our_tempdir) or ( |
|
1222 |
+ # TODO(the-13th-letter): `pytest_machinery.isolated_config` |
|
1223 |
+ # guarantees that `Path.cwd() == config_filename(None)`. |
|
1224 |
+ # So this sub-branch ought to never trigger in our |
|
1225 |
+ # tests. |
|
1226 |
+ system_tempdir == os.getcwd() # noqa: PTH109 |
|
1227 |
+ and our_tempdir == cli_helpers.config_filename(subsystem=None) |
|
1228 |
+ ) |
|
1229 |
+ assert not temp_path.exists(), f"temp path {temp_path} not cleaned up!" |
|
1230 |
+ |
|
1231 |
+ def test_140b_get_tempdir_force_default(self) -> None: |
|
1232 |
+ """[`cli_helpers.get_tempdir`][] returns a temporary directory. |
|
1233 |
+ |
|
1234 |
+ If all candidates are mocked to fail for the standard temporary |
|
1235 |
+ directory choices, then we return the `derivepassphrase` |
|
1236 |
+ configuration directory. |
|
1237 |
+ |
|
1238 |
+ """ |
|
1239 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1240 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1241 |
+ # with-statements. |
|
1242 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1243 |
+ with contextlib.ExitStack() as stack: |
|
1244 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1245 |
+ stack.enter_context( |
|
1246 |
+ pytest_machinery.isolated_vault_config( |
|
1247 |
+ monkeypatch=monkeypatch, |
|
1248 |
+ runner=runner, |
|
1249 |
+ vault_config={"services": {}}, |
|
1250 |
+ ) |
|
1251 |
+ ) |
|
1252 |
+ monkeypatch.delenv("TMPDIR", raising=False) |
|
1253 |
+ monkeypatch.delenv("TEMP", raising=False) |
|
1254 |
+ monkeypatch.delenv("TMP", raising=False) |
|
1255 |
+ config_dir = cli_helpers.config_filename(subsystem=None) |
|
1256 |
+ |
|
1257 |
+ def is_dir_false( |
|
1258 |
+ self: pathlib.Path, |
|
1259 |
+ /, |
|
1260 |
+ *, |
|
1261 |
+ follow_symlinks: bool = False, |
|
1262 |
+ ) -> bool: |
|
1263 |
+ del self, follow_symlinks |
|
1264 |
+ return False |
|
1265 |
+ |
|
1266 |
+ def is_dir_error( |
|
1267 |
+ self: pathlib.Path, |
|
1268 |
+ /, |
|
1269 |
+ *, |
|
1270 |
+ follow_symlinks: bool = False, |
|
1271 |
+ ) -> bool: |
|
1272 |
+ del follow_symlinks |
|
1273 |
+ raise OSError( |
|
1274 |
+ errno.EACCES, |
|
1275 |
+ os.strerror(errno.EACCES), |
|
1276 |
+ str(self), |
|
1277 |
+ ) |
|
1278 |
+ |
|
1279 |
+ monkeypatch.setattr(pathlib.Path, "is_dir", is_dir_false) |
|
1280 |
+ assert cli_helpers.get_tempdir() == config_dir |
|
1281 |
+ |
|
1282 |
+ monkeypatch.setattr(pathlib.Path, "is_dir", is_dir_error) |
|
1283 |
+ assert cli_helpers.get_tempdir() == config_dir |
|
1284 |
+ |
|
1285 |
+ @Parametrize.DELETE_CONFIG_INPUT |
|
1286 |
+ def test_203_repeated_config_deletion( |
|
1287 |
+ self, |
|
1288 |
+ command_line: list[str], |
|
1289 |
+ config: _types.VaultConfig, |
|
1290 |
+ result_config: _types.VaultConfig, |
|
1291 |
+ ) -> None: |
|
1292 |
+ """Repeatedly removing the same parts of a configuration works.""" |
|
1293 |
+ for start_config in [config, result_config]: |
|
1294 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1295 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1296 |
+ # with-statements. |
|
1297 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1298 |
+ with contextlib.ExitStack() as stack: |
|
1299 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1300 |
+ stack.enter_context( |
|
1301 |
+ pytest_machinery.isolated_vault_config( |
|
1302 |
+ monkeypatch=monkeypatch, |
|
1303 |
+ runner=runner, |
|
1304 |
+ vault_config=start_config, |
|
1305 |
+ ) |
|
1306 |
+ ) |
|
1307 |
+ result = runner.invoke( |
|
1308 |
+ cli.derivepassphrase_vault, |
|
1309 |
+ command_line, |
|
1310 |
+ catch_exceptions=False, |
|
1311 |
+ ) |
|
1312 |
+ assert result.clean_exit(empty_stderr=True), ( |
|
1313 |
+ "expected clean exit" |
|
1314 |
+ ) |
|
1315 |
+ with cli_helpers.config_filename(subsystem="vault").open( |
|
1316 |
+ encoding="UTF-8" |
|
1317 |
+ ) as infile: |
|
1318 |
+ config_readback = json.load(infile) |
|
1319 |
+ assert config_readback == result_config |
|
1320 |
+ |
|
1321 |
+ def test_204_phrase_from_key_manually(self) -> None: |
|
1322 |
+ """The dummy service, key and config settings are consistent.""" |
|
1323 |
+ assert ( |
|
1324 |
+ vault.Vault( |
|
1325 |
+ phrase=DUMMY_PHRASE_FROM_KEY1, **DUMMY_CONFIG_SETTINGS |
|
1326 |
+ ).generate(DUMMY_SERVICE) |
|
1327 |
+ == DUMMY_RESULT_KEY1 |
|
1328 |
+ ) |
|
1329 |
+ |
|
1330 |
+ @Parametrize.VALIDATION_FUNCTION_INPUT |
|
1331 |
+ def test_210a_validate_constraints_manually( |
|
1332 |
+ self, |
|
1333 |
+ vfunc: Callable[[click.Context, click.Parameter, Any], int | None], |
|
1334 |
+ input: int, |
|
1335 |
+ ) -> None: |
|
1336 |
+ """Command-line argument constraint validation works.""" |
|
1337 |
+ ctx = cli.derivepassphrase_vault.make_context(cli.PROG_NAME, []) |
|
1338 |
+ param = cli.derivepassphrase_vault.params[0] |
|
1339 |
+ assert vfunc(ctx, param, input) == input |
|
1340 |
+ |
|
1341 |
+ @Parametrize.CONNECTION_HINTS |
|
1342 |
+ def test_227_get_suitable_ssh_keys( |
|
1343 |
+ self, |
|
1344 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
1345 |
+ conn_hint: str, |
|
1346 |
+ ) -> None: |
|
1347 |
+ """[`cli_helpers.get_suitable_ssh_keys`][] works.""" |
|
1348 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
1349 |
+ monkeypatch.setattr( |
|
1350 |
+ ssh_agent.SSHAgentClient, |
|
1351 |
+ "list_keys", |
|
1352 |
+ callables.list_keys, |
|
1353 |
+ ) |
|
1354 |
+ hint: ssh_agent.SSHAgentClient | _types.SSHAgentSocket | None |
|
1355 |
+ # TODO(the-13th-letter): Rewrite using structural pattern |
|
1356 |
+ # matching. |
|
1357 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1358 |
+ if conn_hint == "client": |
|
1359 |
+ hint = ssh_agent.SSHAgentClient() |
|
1360 |
+ elif conn_hint == "socket": |
|
1361 |
+ if isinstance( |
|
1362 |
+ running_ssh_agent.socket, str |
|
1363 |
+ ): # pragma: no cover |
|
1364 |
+ if not hasattr(socket, "AF_UNIX"): |
|
1365 |
+ pytest.skip("socket module does not support AF_UNIX") |
|
1366 |
+ # socket.AF_UNIX is not defined everywhere. |
|
1367 |
+ hint = socket.socket(family=socket.AF_UNIX) # type: ignore[attr-defined] |
|
1368 |
+ hint.connect(running_ssh_agent.socket) |
|
1369 |
+ else: # pragma: no cover |
|
1370 |
+ hint = running_ssh_agent.socket() |
|
1371 |
+ else: |
|
1372 |
+ assert conn_hint == "none" |
|
1373 |
+ hint = None |
|
1374 |
+ exception: Exception | None = None |
|
1375 |
+ try: |
|
1376 |
+ list(cli_helpers.get_suitable_ssh_keys(hint)) |
|
1377 |
+ except RuntimeError: # pragma: no cover |
|
1378 |
+ pass |
|
1379 |
+ except Exception as e: # noqa: BLE001 # pragma: no cover |
|
1380 |
+ exception = e |
|
1381 |
+ finally: |
|
1382 |
+ assert exception is None, ( |
|
1383 |
+ "exception querying suitable SSH keys" |
|
1384 |
+ ) |
|
1385 |
+ |
|
1386 |
+ @Parametrize.KEY_TO_PHRASE_SETTINGS |
|
1387 |
+ def test_400_key_to_phrase( |
|
1388 |
+ self, |
|
1389 |
+ ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient, |
|
1390 |
+ list_keys_action: ListKeysAction | None, |
|
1391 |
+ system_support_action: SystemSupportAction | None, |
|
1392 |
+ address_action: SocketAddressAction | None, |
|
1393 |
+ sign_action: SignAction, |
|
1394 |
+ pattern: str, |
|
1395 |
+ ) -> None: |
|
1396 |
+ """All errors in [`cli_helpers.key_to_phrase`][] are handled.""" |
|
1397 |
+ |
|
1398 |
+ class ErrCallback(BaseException): |
|
1399 |
+ def __init__(self, *args: Any, **kwargs: Any) -> None: |
|
1400 |
+ super().__init__(*args[:1]) |
|
1401 |
+ self.args = args |
|
1402 |
+ self.kwargs = kwargs |
|
1403 |
+ |
|
1404 |
+ def err(*args: Any, **_kwargs: Any) -> NoReturn: |
|
1405 |
+ raise ErrCallback(*args, **_kwargs) |
|
1406 |
+ |
|
1407 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
1408 |
+ loaded_keys = list( |
|
1409 |
+ ssh_agent_client_with_test_keys_loaded.list_keys() |
|
1410 |
+ ) |
|
1411 |
+ loaded_key = base64.standard_b64encode(loaded_keys[0][0]) |
|
1412 |
+ monkeypatch.setattr(ssh_agent.SSHAgentClient, "sign", sign_action) |
|
1413 |
+ if list_keys_action: |
|
1414 |
+ monkeypatch.setattr( |
|
1415 |
+ ssh_agent.SSHAgentClient, "list_keys", list_keys_action |
|
1416 |
+ ) |
|
1417 |
+ if address_action: |
|
1418 |
+ address_action(monkeypatch) |
|
1419 |
+ if system_support_action: |
|
1420 |
+ system_support_action(monkeypatch) |
|
1421 |
+ with pytest.raises(ErrCallback, match=pattern) as excinfo: |
|
1422 |
+ cli_helpers.key_to_phrase(loaded_key, error_callback=err) |
|
1423 |
+ if list_keys_action == ListKeysAction.FAIL_RUNTIME: |
|
1424 |
+ assert excinfo.value.kwargs |
|
1425 |
+ assert isinstance( |
|
1426 |
+ excinfo.value.kwargs["exc_info"], |
|
1427 |
+ ssh_agent.SSHAgentFailedError, |
|
1428 |
+ ) |
|
1429 |
+ assert excinfo.value.kwargs["exc_info"].__context__ is not None |
|
1430 |
+ assert isinstance( |
|
1431 |
+ excinfo.value.kwargs["exc_info"].__context__, |
|
1432 |
+ ssh_agent.TrailingDataError, |
|
1433 |
+ ) |
|
0 | 1434 |