9207fef5eb8663a3246f18239c9b648fbdd3a090
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1) # SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
2) #
3) # SPDX-License-Identifier: Zlib
4) 
Marco Ricci Add module docstrings for t...

Marco Ricci authored 3 months ago

5) """Tests for the `derivepassphrase` command-line interface: internal utility functions."""
6) 
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

7) from __future__ import annotations
8) 
9) import base64
10) import contextlib
11) import ctypes
12) import enum
13) import errno
14) import io
15) import json
16) import logging
17) import operator
18) import os
19) import pathlib
20) import shlex
21) import shutil
22) import socket
23) import tempfile
24) import types
25) import warnings
26) from typing import TYPE_CHECKING
27) 
Marco Ricci Fix some test names, import...

Marco Ricci authored 2 months ago

28) import click
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

29) import hypothesis
30) import pytest
31) from hypothesis import strategies
32) 
33) from derivepassphrase import _types, cli, ssh_agent, vault
34) from derivepassphrase._internals import (
35)     cli_helpers,
36)     cli_machinery,
37) )
38) from derivepassphrase.ssh_agent import socketprovider
39) from tests import data, machinery
40) from tests.data import callables
41) from tests.machinery import hypothesis as hypothesis_machinery
42) from tests.machinery import pytest as pytest_machinery
43) 
44) if TYPE_CHECKING:
45)     from collections.abc import Callable, Iterable, Iterator
46)     from typing import NoReturn
47) 
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

48)     from typing_extensions import Any
49) 
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

50) 
51) DUMMY_SERVICE = data.DUMMY_SERVICE
52) DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
53) DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS
Marco Ricci Fix typing of the dummy vau...

Marco Ricci authored 3 months ago

54) DUMMY_CONFIG_SETTINGS_AS_CONSTRUCTOR_ARGS = (
55)     data.DUMMY_CONFIG_SETTINGS_AS_CONSTRUCTOR_ARGS
56) )
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

57) DUMMY_RESULT_PASSPHRASE = data.DUMMY_RESULT_PASSPHRASE
58) DUMMY_RESULT_KEY1 = data.DUMMY_RESULT_KEY1
59) DUMMY_PHRASE_FROM_KEY1_RAW = data.DUMMY_PHRASE_FROM_KEY1_RAW
60) DUMMY_PHRASE_FROM_KEY1 = data.DUMMY_PHRASE_FROM_KEY1
61) 
62) DUMMY_KEY1 = data.DUMMY_KEY1
63) DUMMY_KEY1_B64 = data.DUMMY_KEY1_B64
64) DUMMY_KEY2 = data.DUMMY_KEY2
65) DUMMY_KEY2_B64 = data.DUMMY_KEY2_B64
66) DUMMY_KEY3 = data.DUMMY_KEY3
67) DUMMY_KEY3_B64 = data.DUMMY_KEY3_B64
68) 
69) TEST_CONFIGS = data.TEST_CONFIGS
70) 
71) 
72) def vault_config_exporter_shell_interpreter(  # noqa: C901
73)     script: str | Iterable[str],
74)     /,
75)     *,
76)     prog_name_list: list[str] | None = None,
Marco Ricci Fix type errors due to clic...

Marco Ricci authored 3 months ago

77)     # The click.BaseCommand abstract base class, previously the base
78)     # class of click.Command and click.Group, was removed in click
79)     # 8.2.0 without a transition period, and click.Command instated
80)     # as the common base class instead.  To keep some degree of
81)     # compatibility with both old click and new click, we explicitly
82)     # list the (somewhat) concrete base classes we actually care
83)     # about here.
84)     command: click.Command | click.Group | None = None,
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

85)     runner: machinery.CliRunner | None = None,
86) ) -> Iterator[machinery.ReadableResult]:
87)     """A rudimentary sh(1) interpreter for `--export-as=sh` output.
88) 
89)     Assumes a script as emitted by `derivepassphrase vault
90)     --export-as=sh --export -` and interprets the calls to
91)     `derivepassphrase vault` within.  (One call per line, skips all
92)     other lines.)  Also has rudimentary support for (quoted)
93)     here-documents using `HERE` as the marker.
94) 
95)     """
96)     if isinstance(script, str):  # pragma: no cover
97)         script = script.splitlines(False)
98)     if prog_name_list is None:  # pragma: no cover
99)         prog_name_list = ["derivepassphrase", "vault"]
100)     if command is None:  # pragma: no cover
101)         command = cli.derivepassphrase_vault
102)     if runner is None:  # pragma: no cover
103)         runner = machinery.CliRunner(mix_stderr=False)
104)     n = len(prog_name_list)
105)     it = iter(script)
106)     while True:
107)         try:
108)             raw_line = next(it)
109)         except StopIteration:
110)             break
111)         else:
112)             line = shlex.split(raw_line)
113)         input_buffer: list[str] = []
114)         if line[:n] != prog_name_list:
115)             continue
116)         line[:n] = []
117)         if line and line[-1] == "<<HERE":
118)             # naive HERE document support
119)             while True:
120)                 try:
121)                     raw_line = next(it)
122)                 except StopIteration as exc:  # pragma: no cover
123)                     msg = "incomplete here document"
124)                     raise EOFError(msg) from exc
125)                 else:
126)                     if raw_line == "HERE":
127)                         break
128)                     input_buffer.append(raw_line)
129)             line.pop()
130)         yield runner.invoke(
131)             command,
132)             line,
133)             catch_exceptions=False,
134)             input=("".join(x + "\n" for x in input_buffer) or None),
135)         )
136) 
137) 
138) class ListKeysAction(str, enum.Enum):
139)     """Test fixture settings for [`ssh_agent.SSHAgentClient.list_keys`][].
140) 
141)     Attributes:
142)         EMPTY: Return an empty key list.
143)         FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][].
144)         FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][].
145) 
146)     """
147) 
148)     EMPTY = enum.auto()
149)     """"""
150)     FAIL = enum.auto()
151)     """"""
152)     FAIL_RUNTIME = enum.auto()
153)     """"""
154) 
155)     def __call__(self, *_args: Any, **_kwargs: Any) -> Any:
156)         """Execute the respective action."""
157)         # TODO(the-13th-letter): Rewrite using structural pattern
158)         # matching.
159)         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
160)         if self == self.EMPTY:
161)             return []
162)         if self == self.FAIL:
163)             raise ssh_agent.SSHAgentFailedError(
164)                 _types.SSH_AGENT.FAILURE.value, b""
165)             )
166)         if self == self.FAIL_RUNTIME:
167)             raise ssh_agent.TrailingDataError()
168)         raise AssertionError()
169) 
170) 
171) class SignAction(str, enum.Enum):
172)     """Test fixture settings for [`ssh_agent.SSHAgentClient.sign`][].
173) 
174)     Attributes:
175)         FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][].
176)         FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][].
177) 
178)     """
179) 
180)     FAIL = enum.auto()
181)     """"""
182)     FAIL_RUNTIME = enum.auto()
183)     """"""
184) 
185)     def __call__(self, *_args: Any, **_kwargs: Any) -> Any:
186)         """Execute the respective action."""
187)         # TODO(the-13th-letter): Rewrite using structural pattern
188)         # matching.
189)         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
190)         if self == self.FAIL:
191)             raise ssh_agent.SSHAgentFailedError(
192)                 _types.SSH_AGENT.FAILURE.value, b""
193)             )
194)         if self == self.FAIL_RUNTIME:
195)             raise ssh_agent.TrailingDataError()
196)         raise AssertionError()
197) 
198) 
199) class SocketAddressAction(str, enum.Enum):
200)     """Test fixture settings for the SSH agent socket address.
201) 
202)     Attributes:
Marco Ricci Rename testing symbols in a...

Marco Ricci authored 2 months ago

203)         MANGLE_ADDRESS:
204)             Mangle the socket address.
205) 
206)             For UNIX domain sockets, mangle the `SSH_AUTH_SOCK`
207)             environment variable.
Marco Ricci Introduce SSH agent interfa...

Marco Ricci authored 2 months ago

208) 
209)             For Windows named pipes, skip the test.  (Addresses are
210)             fixed, so cannot be mangled.)
Marco Ricci Rename testing symbols in a...

Marco Ricci authored 2 months ago

211)         UNSET_ADDRESS:
212)             Unset the socket address.
213) 
214)             For UNIX domain sockets, unset the `SSH_AUTH_SOCK`
215)             environment variable.
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

216) 
Marco Ricci Introduce SSH agent interfa...

Marco Ricci authored 2 months ago

217)             For Windows named pipes, skip the test.  (Adresses are
218)             fixed, so cannot be unset.)
219) 
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

220)     """
221) 
Marco Ricci Rename testing symbols in a...

Marco Ricci authored 2 months ago

222)     MANGLE_ADDRESS = enum.auto()
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

223)     """"""
Marco Ricci Rename testing symbols in a...

Marco Ricci authored 2 months ago

224)     UNSET_ADDRESS = enum.auto()
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

225)     """"""
226) 
227)     def __call__(
228)         self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any
229)     ) -> None:
230)         """Execute the respective action."""
231)         # TODO(the-13th-letter): Rewrite using structural pattern
232)         # matching.
233)         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
Marco Ricci Rename testing symbols in a...

Marco Ricci authored 2 months ago

234)         if self == self.MANGLE_ADDRESS:
Marco Ricci Introduce SSH agent interfa...

Marco Ricci authored 2 months ago

235) 
Marco Ricci Add more coverage and linti...

Marco Ricci authored 2 months ago

236)             def mangled_address(
237)                 *_args: Any, **_kwargs: Any
238)             ) -> NoReturn:  # pragma: no cover [failsafe]
Marco Ricci Introduce SSH agent interfa...

Marco Ricci authored 2 months ago

239)                 raise OSError(errno.ENOENT, os.strerror(errno.ENOENT))
240) 
241)             monkeypatch.setattr(
242)                 socketprovider.WindowsNamedPipeHandle,
243)                 "for_pageant",
244)                 mangled_address,
245)             )
246)             monkeypatch.setattr(
247)                 socketprovider.WindowsNamedPipeHandle,
248)                 "for_openssh",
249)                 mangled_address,
250)             )
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

251)             monkeypatch.setenv(
Marco Ricci Fix some testing edge cases...

Marco Ricci authored 2 months ago

252)                 "SSH_AUTH_SOCK",
253)                 os.environ["SSH_AUTH_SOCK"] + "~"
254)                 if "SSH_AUTH_SOCK" in os.environ
Marco Ricci Use the "correct" wrong SSH...

Marco Ricci authored 1 month ago

255)                 else socketprovider.PIPE_PREFIX
256)                 if os.name == "nt"
Marco Ricci Fix some testing edge cases...

Marco Ricci authored 2 months ago

257)                 else "/",
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

258)             )
Marco Ricci Rename testing symbols in a...

Marco Ricci authored 2 months ago

259)         elif self == self.UNSET_ADDRESS:
Marco Ricci Introduce SSH agent interfa...

Marco Ricci authored 2 months ago

260) 
Marco Ricci Add more coverage and linti...

Marco Ricci authored 2 months ago

261)             def no_address(
262)                 *_args: Any, **_kwargs: Any
263)             ) -> NoReturn:  # pragma: no cover [failsafe]
264)                 raise KeyError("SSH_AUTH_SOCK")  # noqa: EM101
Marco Ricci Introduce SSH agent interfa...

Marco Ricci authored 2 months ago

265) 
266)             monkeypatch.setattr(
267)                 socketprovider.WindowsNamedPipeHandle,
268)                 "for_pageant",
269)                 no_address,
270)             )
271)             monkeypatch.setattr(
272)                 socketprovider.WindowsNamedPipeHandle,
273)                 "for_openssh",
274)                 no_address,
275)             )
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

276)             monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
277)         else:
278)             raise AssertionError()
279) 
280) 
281) class SystemSupportAction(str, enum.Enum):
282)     """Test fixture settings for [`ssh_agent.SSHAgentClient`][] system support.
283) 
284)     Attributes:
285)         UNSET_AF_UNIX:
286)             Ensure lack of support for UNIX domain sockets.
287)         UNSET_AF_UNIX_AND_ENSURE_USE:
288)             Ensure lack of support for UNIX domain sockets, and that the
289)             agent will use this socket provider.
290)         UNSET_NATIVE:
291)             Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`.
292)         UNSET_NATIVE_AND_ENSURE_USE:
293)             Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`, and that the
294)             agent will use the native socket provider.
295)         UNSET_PROVIDER_LIST:
296)             Ensure an empty list of SSH agent socket providers.
297)         UNSET_WINDLL:
Marco Ricci Settle on the terminology "...

Marco Ricci authored 2 months ago

298)             Ensure lack of support for Windows named pipes.
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

299)         UNSET_WINDLL_AND_ENSURE_USE:
Marco Ricci Settle on the terminology "...

Marco Ricci authored 2 months ago

300)             Ensure lack of support for Windows named pipes, and that the
301)             agent will use this socket provider.
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

302) 
303)     """
304) 
305)     UNSET_AF_UNIX = enum.auto()
306)     """"""
307)     UNSET_AF_UNIX_AND_ENSURE_USE = enum.auto()
308)     """"""
309)     UNSET_NATIVE = enum.auto()
310)     """"""
311)     UNSET_NATIVE_AND_ENSURE_USE = enum.auto()
312)     """"""
313)     UNSET_PROVIDER_LIST = enum.auto()
314)     """"""
315)     UNSET_WINDLL = enum.auto()
316)     """"""
317)     UNSET_WINDLL_AND_ENSURE_USE = enum.auto()
318)     """"""
319) 
320)     def __call__(
321)         self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any
322)     ) -> None:
323)         """Execute the respective action.
324) 
325)         Args:
326)             monkeypatch: The current monkeypatch context.
327) 
328)         """
329)         # TODO(the-13th-letter): Rewrite using structural pattern
330)         # matching.
331)         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
332)         if self == self.UNSET_PROVIDER_LIST:
333)             monkeypatch.setattr(
334)                 ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", []
335)             )
336)         elif self in {self.UNSET_NATIVE, self.UNSET_NATIVE_AND_ENSURE_USE}:
337)             self.check_or_ensure_use(
Marco Ricci Turn the built in SSH agent...

Marco Ricci authored 1 month ago

338)                 _types.BuiltinSSHAgentSocketProvider.NATIVE,
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

339)                 monkeypatch=monkeypatch,
340)                 ensure_use=(self == self.UNSET_NATIVE_AND_ENSURE_USE),
341)             )
342)             monkeypatch.delattr(socket, "AF_UNIX", raising=False)
343)             monkeypatch.delattr(ctypes, "WinDLL", raising=False)
344)             monkeypatch.delattr(ctypes, "windll", raising=False)
345)         elif self in {self.UNSET_AF_UNIX, self.UNSET_AF_UNIX_AND_ENSURE_USE}:
346)             self.check_or_ensure_use(
Marco Ricci Turn the built in SSH agent...

Marco Ricci authored 1 month ago

347)                 _types.BuiltinSSHAgentSocketProvider.POSIX,
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

348)                 monkeypatch=monkeypatch,
349)                 ensure_use=(self == self.UNSET_AF_UNIX_AND_ENSURE_USE),
350)             )
351)             monkeypatch.delattr(socket, "AF_UNIX", raising=False)
352)         elif self in {self.UNSET_WINDLL, self.UNSET_WINDLL_AND_ENSURE_USE}:
353)             self.check_or_ensure_use(
Marco Ricci Turn the built in SSH agent...

Marco Ricci authored 1 month ago

354)                 _types.BuiltinSSHAgentSocketProvider.WINDOWS,
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

355)                 monkeypatch=monkeypatch,
356)                 ensure_use=(self == self.UNSET_WINDLL_AND_ENSURE_USE),
357)             )
358)             monkeypatch.delattr(ctypes, "WinDLL", raising=False)
359)             monkeypatch.delattr(ctypes, "windll", raising=False)
360)         else:
361)             raise AssertionError()
362) 
363)     @staticmethod
364)     def check_or_ensure_use(
365)         provider: str, /, *, monkeypatch: pytest.MonkeyPatch, ensure_use: bool
366)     ) -> None:
367)         """Check that the named SSH agent socket provider will be used.
368) 
369)         Either ensure that the socket provider will definitely be used,
370)         or, upon detecting that it won't be used, skip the test.
371) 
372)         Args:
373)             provider:
374)                 The provider to check for.
375)             ensure_use:
376)                 If true, ensure that the socket provider will definitely
377)                 be used.  If false, then check for whether it will be
378)                 used, and skip this test if not.
379)             monkeypatch:
380)                 The monkeypatch context within which the fixture
381)                 adjustments should be executed.
382) 
383)         """
384)         if ensure_use:
385)             monkeypatch.setattr(
386)                 ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", [provider]
387)             )
388)         else:  # pragma: no cover [external]
389)             # This branch operates completely on instrumented or on
390)             # externally defined, non-deterministic state.
391)             intended: (
392)                 _types.SSHAgentSocketProvider
393)                 | socketprovider.NoSuchProviderError
394)                 | None
395)             )
396)             try:
397)                 intended = socketprovider.SocketProvider.lookup(provider)
398)             except socketprovider.NoSuchProviderError as exc:
399)                 intended = exc
400)             actual: (
401)                 _types.SSHAgentSocketProvider
402)                 | socketprovider.NoSuchProviderError
403)                 | None
404)             )
405)             for name in ssh_agent.SSHAgentClient.SOCKET_PROVIDERS:
406)                 try:
407)                     actual = socketprovider.SocketProvider.lookup(name)
408)                 except socketprovider.NoSuchProviderError as exc:
409)                     actual = exc
410)                 if actual is None:
411)                     continue
412)                 break
413)             else:
414)                 actual = None
415)             if intended != actual:
416)                 pytest.skip(
417)                     f"{provider!r} SSH agent socket provider "
418)                     f"is not currently in use"
419)                 )
420) 
421) 
422) class Parametrize(types.SimpleNamespace):
423)     """Common test parametrizations."""
424) 
425)     DELETE_CONFIG_INPUT = pytest.mark.parametrize(
426)         ["command_line", "config", "result_config"],
427)         [
428)             pytest.param(
429)                 ["--delete-globals"],
430)                 {"global": {"phrase": "abc"}, "services": {}},
431)                 {"services": {}},
432)                 id="globals",
433)             ),
434)             pytest.param(
435)                 ["--delete", "--", DUMMY_SERVICE],
436)                 {
437)                     "global": {"phrase": "abc"},
438)                     "services": {DUMMY_SERVICE: {"notes": "..."}},
439)                 },
440)                 {"global": {"phrase": "abc"}, "services": {}},
441)                 id="service",
442)             ),
443)             pytest.param(
444)                 ["--clear"],
445)                 {
446)                     "global": {"phrase": "abc"},
447)                     "services": {DUMMY_SERVICE: {"notes": "..."}},
448)                 },
449)                 {"services": {}},
450)                 id="all",
451)             ),
452)         ],
453)     )
454)     BASE_CONFIG_VARIATIONS = pytest.mark.parametrize(
455)         "config",
456)         [
457)             {"global": {"phrase": "my passphrase"}, "services": {}},
458)             {"global": {"key": DUMMY_KEY1_B64}, "services": {}},
459)             {
460)                 "global": {"phrase": "abc"},
461)                 "services": {"sv": {"phrase": "my passphrase"}},
462)             },
463)             {
464)                 "global": {"phrase": "abc"},
465)                 "services": {"sv": {"key": DUMMY_KEY1_B64}},
466)             },
467)             {
468)                 "global": {"phrase": "abc"},
469)                 "services": {"sv": {"key": DUMMY_KEY1_B64, "length": 15}},
470)             },
471)         ],
472)     )
473)     CONNECTION_HINTS = pytest.mark.parametrize(
474)         "conn_hint", ["none", "socket", "client"]
475)     )
476)     KEY_TO_PHRASE_SETTINGS = pytest.mark.parametrize(
477)         [
478)             "list_keys_action",
479)             "address_action",
480)             "system_support_action",
481)             "sign_action",
482)             "pattern",
Marco Ricci Add missing `warning_callba...

Marco Ricci authored 3 months ago

483)             "warnings_patterns",
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

484)             "tests_construction_failure",
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

485)         ],
486)         [
487)             pytest.param(
488)                 ListKeysAction.EMPTY,
489)                 None,
490)                 None,
491)                 SignAction.FAIL,
492)                 "not loaded into the agent",
Marco Ricci Add missing `warning_callba...

Marco Ricci authored 3 months ago

493)                 [],
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

494)                 False,
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

495)                 id="key-not-loaded",
496)             ),
497)             pytest.param(
498)                 ListKeysAction.FAIL,
499)                 None,
500)                 None,
501)                 SignAction.FAIL,
502)                 "SSH agent failed to or refused to",
Marco Ricci Add missing `warning_callba...

Marco Ricci authored 3 months ago

503)                 [],
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

504)                 False,
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

505)                 id="list-keys-refused",
506)             ),
507)             pytest.param(
508)                 ListKeysAction.FAIL_RUNTIME,
509)                 None,
510)                 None,
511)                 SignAction.FAIL,
512)                 "SSH agent failed to or refused to",
Marco Ricci Add missing `warning_callba...

Marco Ricci authored 3 months ago

513)                 [],
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

514)                 False,
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

515)                 id="list-keys-protocol-error",
516)             ),
517)             pytest.param(
518)                 None,
Marco Ricci Rename testing symbols in a...

Marco Ricci authored 2 months ago

519)                 SocketAddressAction.MANGLE_ADDRESS,
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

520)                 None,
521)                 SignAction.FAIL,
Marco Ricci Fix some testing edge cases...

Marco Ricci authored 2 months ago

522)                 "Cannot connect to the SSH agent",
Marco Ricci Add missing `warning_callba...

Marco Ricci authored 3 months ago

523)                 [],
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

524)                 True,
Marco Ricci Fix some testing edge cases...

Marco Ricci authored 2 months ago

525)                 id="agent-address-mangled",
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

526)             ),
527)             pytest.param(
528)                 None,
Marco Ricci Rename testing symbols in a...

Marco Ricci authored 2 months ago

529)                 SocketAddressAction.UNSET_ADDRESS,
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

530)                 None,
531)                 SignAction.FAIL,
Marco Ricci Fix some testing edge cases...

Marco Ricci authored 2 months ago

532)                 "Cannot find any running SSH agent",
Marco Ricci Add missing `warning_callba...

Marco Ricci authored 3 months ago

533)                 [],
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

534)                 True,
Marco Ricci Fix some testing edge cases...

Marco Ricci authored 2 months ago

535)                 id="agent-address-missing",
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

536)             ),
537)             pytest.param(
538)                 None,
539)                 None,
540)                 SystemSupportAction.UNSET_NATIVE,
541)                 SignAction.FAIL,
542)                 "does not support communicating with it",
Marco Ricci Add missing `warning_callba...

Marco Ricci authored 3 months ago

543)                 [],
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

544)                 True,
Marco Ricci Fix some test names, import...

Marco Ricci authored 2 months ago

545)                 id="no-native-agent-available",
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

546)             ),
547)             pytest.param(
548)                 None,
549)                 None,
550)                 SystemSupportAction.UNSET_PROVIDER_LIST,
551)                 SignAction.FAIL,
552)                 "does not support communicating with it",
Marco Ricci Add missing `warning_callba...

Marco Ricci authored 3 months ago

553)                 [],
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

554)                 True,
Marco Ricci Fix some test names, import...

Marco Ricci authored 2 months ago

555)                 id="no-agents-in-agent-provider-list",
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

556)             ),
557)             pytest.param(
558)                 None,
559)                 None,
560)                 SystemSupportAction.UNSET_AF_UNIX_AND_ENSURE_USE,
561)                 SignAction.FAIL,
562)                 "does not support communicating with it",
Marco Ricci Add missing `warning_callba...

Marco Ricci authored 3 months ago

563)                 ["Cannot connect to an SSH agent via UNIX domain sockets"],
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

564)                 True,
Marco Ricci Fix some test names, import...

Marco Ricci authored 2 months ago

565)                 id="no-unix-domain-sockets",
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

566)             ),
567)             pytest.param(
568)                 None,
569)                 None,
570)                 SystemSupportAction.UNSET_WINDLL_AND_ENSURE_USE,
571)                 SignAction.FAIL,
572)                 "does not support communicating with it",
Marco Ricci Add missing `warning_callba...

Marco Ricci authored 3 months ago

573)                 ["Cannot connect to an SSH agent via Windows named pipes"],
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

574)                 True,
Marco Ricci Fix some test names, import...

Marco Ricci authored 2 months ago

575)                 id="no-windows-named-pipes",
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

576)             ),
577)             pytest.param(
578)                 None,
579)                 None,
580)                 None,
581)                 SignAction.FAIL_RUNTIME,
582)                 "violates the communication protocol",
Marco Ricci Add missing `warning_callba...

Marco Ricci authored 3 months ago

583)                 [],
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

584)                 False,
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

585)                 id="sign-violates-protocol",
586)             ),
587)         ],
588)     )
589)     VALIDATION_FUNCTION_INPUT = pytest.mark.parametrize(
590)         ["vfunc", "input"],
591)         [
592)             (cli_machinery.validate_occurrence_constraint, 20),
593)             (cli_machinery.validate_length, 20),
594)         ],
595)     )
596) 
597) 
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

598) class TestConfigLoadingSaving:
599)     """Tests for the config loading and saving utility functions."""
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

600) 
601)     @Parametrize.BASE_CONFIG_VARIATIONS
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

602)     def test_load(
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

603)         self,
604)         config: Any,
605)     ) -> None:
606)         """[`cli_helpers.load_config`][] works for valid configurations."""
607)         runner = machinery.CliRunner(mix_stderr=False)
608)         # TODO(the-13th-letter): Rewrite using parenthesized
609)         # with-statements.
610)         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
611)         with contextlib.ExitStack() as stack:
612)             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
613)             stack.enter_context(
614)                 pytest_machinery.isolated_vault_config(
615)                     monkeypatch=monkeypatch,
616)                     runner=runner,
617)                     vault_config=config,
618)                 )
619)             )
620)             config_filename = cli_helpers.config_filename(subsystem="vault")
621)             with config_filename.open(encoding="UTF-8") as fileobj:
622)                 assert json.load(fileobj) == config
623)             assert cli_helpers.load_config() == config
624) 
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

625)     def test_save_bad_config(
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

626)         self,
627)     ) -> None:
628)         """[`cli_helpers.save_config`][] fails for bad configurations."""
629)         runner = machinery.CliRunner(mix_stderr=False)
630)         # TODO(the-13th-letter): Rewrite using parenthesized
631)         # with-statements.
632)         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
633)         with contextlib.ExitStack() as stack:
634)             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
635)             stack.enter_context(
636)                 pytest_machinery.isolated_vault_config(
637)                     monkeypatch=monkeypatch,
638)                     runner=runner,
639)                     vault_config={},
640)                 )
641)             )
642)             stack.enter_context(
643)                 pytest.raises(ValueError, match="Invalid vault config")
644)             )
645)             cli_helpers.save_config(None)  # type: ignore[arg-type]
646) 
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

647) 
648) class TestPrompting:
649)     """Tests for the prompting utility functions."""
650) 
651)     def test_selection_multiple(self) -> None:
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

652)         """[`cli_helpers.prompt_for_selection`][] works in the "multiple" case."""
653) 
654)         @click.command()
655)         @click.option("--heading", default="Our menu:")
656)         @click.argument("items", nargs=-1)
657)         def driver(heading: str, items: list[str]) -> None:
658)             # from https://montypython.fandom.com/wiki/Spam#The_menu
659)             items = items or [
660)                 "Egg and bacon",
661)                 "Egg, sausage and bacon",
662)                 "Egg and spam",
663)                 "Egg, bacon and spam",
664)                 "Egg, bacon, sausage and spam",
665)                 "Spam, bacon, sausage and spam",
666)                 "Spam, egg, spam, spam, bacon and spam",
667)                 "Spam, spam, spam, egg and spam",
668)                 (
669)                     "Spam, spam, spam, spam, spam, spam, baked beans, "
670)                     "spam, spam, spam and spam"
671)                 ),
672)                 (
673)                     "Lobster thermidor aux crevettes with a mornay sauce "
674)                     "garnished with truffle paté, brandy "
675)                     "and a fried egg on top and spam"
676)                 ),
677)             ]
678)             index = cli_helpers.prompt_for_selection(items, heading=heading)
679)             click.echo("A fine choice: ", nl=False)
680)             click.echo(items[index])
681)             click.echo("(Note: Vikings strictly optional.)")
682) 
683)         runner = machinery.CliRunner(mix_stderr=True)
684)         result = runner.invoke(driver, [], input="9")
685)         assert result.clean_exit(
686)             output="""\
687) Our menu:
688) [1] Egg and bacon
689) [2] Egg, sausage and bacon
690) [3] Egg and spam
691) [4] Egg, bacon and spam
692) [5] Egg, bacon, sausage and spam
693) [6] Spam, bacon, sausage and spam
694) [7] Spam, egg, spam, spam, bacon and spam
695) [8] Spam, spam, spam, egg and spam
696) [9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
697) [10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
698) Your selection? (1-10, leave empty to abort): 9
699) A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
700) (Note: Vikings strictly optional.)
701) """
702)         ), "expected clean exit"
703)         result = runner.invoke(
704)             driver, ["--heading="], input="\n", catch_exceptions=True
705)         )
706)         assert result.error_exit(error=IndexError), (
707)             "expected error exit and known error type"
708)         )
709)         assert (
710)             result.stdout
711)             == """\
712) [1] Egg and bacon
713) [2] Egg, sausage and bacon
714) [3] Egg and spam
715) [4] Egg, bacon and spam
716) [5] Egg, bacon, sausage and spam
717) [6] Spam, bacon, sausage and spam
718) [7] Spam, egg, spam, spam, bacon and spam
719) [8] Spam, spam, spam, egg and spam
720) [9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
721) [10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
722) Your selection? (1-10, leave empty to abort):\x20
723) """
724)         ), "expected known output"
725)         # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the
726)         # click prompting machinery, meaning that the mixed output will
727)         # incorrectly contain a line break, contrary to what the
728)         # documentation for click.prompt prescribes.
729)         result = runner.invoke(
730)             driver, ["--heading="], input="", catch_exceptions=True
731)         )
732)         assert result.error_exit(error=IndexError), (
733)             "expected error exit and known error type"
734)         )
735)         assert result.stdout in {
736)             """\
737) [1] Egg and bacon
738) [2] Egg, sausage and bacon
739) [3] Egg and spam
740) [4] Egg, bacon and spam
741) [5] Egg, bacon, sausage and spam
742) [6] Spam, bacon, sausage and spam
743) [7] Spam, egg, spam, spam, bacon and spam
744) [8] Spam, spam, spam, egg and spam
745) [9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
746) [10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
747) Your selection? (1-10, leave empty to abort):\x20
748) """,
749)             """\
750) [1] Egg and bacon
751) [2] Egg, sausage and bacon
752) [3] Egg and spam
753) [4] Egg, bacon and spam
754) [5] Egg, bacon, sausage and spam
755) [6] Spam, bacon, sausage and spam
756) [7] Spam, egg, spam, spam, bacon and spam
757) [8] Spam, spam, spam, egg and spam
758) [9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
759) [10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
760) Your selection? (1-10, leave empty to abort): """,
761)         }, "expected known output"
762) 
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

763)     def test_selection_single(self) -> None:
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

764)         """[`cli_helpers.prompt_for_selection`][] works in the "single" case."""
765) 
766)         @click.command()
767)         @click.option("--item", default="baked beans")
768)         @click.argument("prompt")
769)         def driver(item: str, prompt: str) -> None:
770)             try:
771)                 cli_helpers.prompt_for_selection(
772)                     [item], heading="", single_choice_prompt=prompt
773)                 )
774)             except IndexError:
775)                 click.echo("Boo.")
776)                 raise
777)             else:
778)                 click.echo("Great!")
779) 
780)         runner = machinery.CliRunner(mix_stderr=True)
781)         result = runner.invoke(
782)             driver, ["Will replace with spam. Confirm, y/n?"], input="y"
783)         )
784)         assert result.clean_exit(
785)             output="""\
786) [1] baked beans
787) Will replace with spam. Confirm, y/n? y
788) Great!
789) """
790)         ), "expected clean exit"
791)         result = runner.invoke(
792)             driver,
793)             ['Will replace with spam, okay? (Please say "y" or "n".)'],
794)             input="\n",
795)         )
796)         assert result.error_exit(error=IndexError), (
797)             "expected error exit and known error type"
798)         )
799)         assert (
800)             result.stdout
801)             == """\
802) [1] baked beans
803) Will replace with spam, okay? (Please say "y" or "n".):\x20
804) Boo.
805) """
806)         ), "expected known output"
807)         # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the
808)         # click prompting machinery, meaning that the mixed output will
809)         # incorrectly contain a line break, contrary to what the
810)         # documentation for click.prompt prescribes.
811)         result = runner.invoke(
812)             driver,
813)             ['Will replace with spam, okay? (Please say "y" or "n".)'],
814)             input="",
815)         )
816)         assert result.error_exit(error=IndexError), (
817)             "expected error exit and known error type"
818)         )
819)         assert result.stdout in {
820)             """\
821) [1] baked beans
822) Will replace with spam, okay? (Please say "y" or "n".):\x20
823) Boo.
824) """,
825)             """\
826) [1] baked beans
827) Will replace with spam, okay? (Please say "y" or "n".): Boo.
828) """,
829)         }, "expected known output"
830) 
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

831)     def test_passphrase(
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

832)         self,
833)     ) -> None:
834)         """[`cli_helpers.prompt_for_passphrase`][] works."""
835)         with pytest.MonkeyPatch.context() as monkeypatch:
836)             monkeypatch.setattr(
837)                 click,
838)                 "prompt",
839)                 lambda *a, **kw: json.dumps({"args": a, "kwargs": kw}),
840)             )
841)             res = json.loads(cli_helpers.prompt_for_passphrase())
842)         err_msg = "missing arguments to passphrase prompt"
843)         assert "args" in res, err_msg
844)         assert "kwargs" in res, err_msg
845)         assert res["args"][:1] == ["Passphrase"], err_msg
846)         assert res["kwargs"].get("default") == "", err_msg
847)         assert not res["kwargs"].get("show_default", True), err_msg
848)         assert res["kwargs"].get("err"), err_msg
849)         assert res["kwargs"].get("hide_input"), err_msg
850) 
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

851) 
852) class TestLoggingMachinery:
853)     """Tests for the logging utility functions."""
854) 
855)     def test_standard_logging_context_manager(
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

856)         self,
857)         caplog: pytest.LogCaptureFixture,
858)         capsys: pytest.CaptureFixture[str],
859)     ) -> None:
860)         """The standard logging context manager works.
861) 
862)         It registers its handlers, once, and emits formatted calls to
863)         standard error prefixed with the program name.
864) 
865)         """
866)         prog_name = cli_machinery.StandardCLILogging.prog_name
867)         package_name = cli_machinery.StandardCLILogging.package_name
868)         logger = logging.getLogger(package_name)
869)         deprecation_logger = logging.getLogger(f"{package_name}.deprecation")
870)         logging_cm = cli_machinery.StandardCLILogging.ensure_standard_logging()
871)         with logging_cm:
872)             assert (
873)                 sum(
874)                     1
875)                     for h in logger.handlers
876)                     if h is cli_machinery.StandardCLILogging.cli_handler
877)                 )
878)                 == 1
879)             )
880)             logger.warning("message 1")
881)             with logging_cm:
882)                 deprecation_logger.warning("message 2")
883)                 assert (
884)                     sum(
885)                         1
886)                         for h in logger.handlers
887)                         if h is cli_machinery.StandardCLILogging.cli_handler
888)                     )
889)                     == 1
890)                 )
891)                 assert capsys.readouterr() == (
892)                     "",
893)                     (
894)                         f"{prog_name}: Warning: message 1\n"
895)                         f"{prog_name}: Deprecation warning: message 2\n"
896)                     ),
897)                 )
898)             logger.warning("message 3")
899)             assert (
900)                 sum(
901)                     1
902)                     for h in logger.handlers
903)                     if h is cli_machinery.StandardCLILogging.cli_handler
904)                 )
905)                 == 1
906)             )
907)             assert capsys.readouterr() == (
908)                 "",
909)                 f"{prog_name}: Warning: message 3\n",
910)             )
911)             assert caplog.record_tuples == [
912)                 (package_name, logging.WARNING, "message 1"),
913)                 (f"{package_name}.deprecation", logging.WARNING, "message 2"),
914)                 (package_name, logging.WARNING, "message 3"),
915)             ]
916) 
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

917)     def test_warnings_context_manager(
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

918)         self,
919)         caplog: pytest.LogCaptureFixture,
920)         capsys: pytest.CaptureFixture[str],
921)     ) -> None:
922)         """The standard warnings logging context manager works.
923) 
924)         It registers its handlers, once, and emits formatted calls to
925)         standard error prefixed with the program name.  It also adheres
926)         to the global warnings filter concerning which messages it
927)         actually emits to standard error.
928) 
929)         """
930)         warnings_cm = (
931)             cli_machinery.StandardCLILogging.ensure_standard_warnings_logging()
932)         )
933)         THE_FUTURE = "the future will be here sooner than you think"  # noqa: N806
934)         JUST_TESTING = "just testing whether warnings work"  # noqa: N806
935)         with warnings_cm:
936)             assert (
937)                 sum(
938)                     1
939)                     for h in logging.getLogger("py.warnings").handlers
940)                     if h is cli_machinery.StandardCLILogging.warnings_handler
941)                 )
942)                 == 1
943)             )
944)             warnings.warn(UserWarning(JUST_TESTING), stacklevel=1)
945)             with warnings_cm:
946)                 warnings.warn(FutureWarning(THE_FUTURE), stacklevel=1)
947)                 _out, err = capsys.readouterr()
948)                 err_lines = err.splitlines(True)
949)                 assert any(
950)                     f"UserWarning: {JUST_TESTING}" in line
951)                     for line in err_lines
952)                 )
953)                 assert any(
954)                     f"FutureWarning: {THE_FUTURE}" in line
955)                     for line in err_lines
956)                 )
957)             warnings.warn(UserWarning(JUST_TESTING), stacklevel=1)
958)             _out, err = capsys.readouterr()
959)             err_lines = err.splitlines(True)
960)             assert any(
961)                 f"UserWarning: {JUST_TESTING}" in line for line in err_lines
962)             )
963)             assert not any(
964)                 f"FutureWarning: {THE_FUTURE}" in line for line in err_lines
965)             )
966)             record_tuples = caplog.record_tuples
967)             assert [tup[:2] for tup in record_tuples] == [
968)                 ("py.warnings", logging.WARNING),
969)                 ("py.warnings", logging.WARNING),
970)                 ("py.warnings", logging.WARNING),
971)             ]
972)             assert f"UserWarning: {JUST_TESTING}" in record_tuples[0][2]
973)             assert f"FutureWarning: {THE_FUTURE}" in record_tuples[1][2]
974)             assert f"UserWarning: {JUST_TESTING}" in record_tuples[2][2]
975) 
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

976) 
977) class TestExportConfigAsShellScript:
978)     """Tests the utility functions for exporting configs in `sh` format."""
979) 
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

980)     def export_as_sh_helper(
981)         self,
982)         config: Any,
983)     ) -> None:
984)         """Emits a config in sh(1) format, then reads it back to verify it.
985) 
986)         This function exports the configuration, sets up a new
987)         enviroment, then calls
988)         [`vault_config_exporter_shell_interpreter`][] on the export
989)         script, verifying that each command ran successfully and that
990)         the final configuration matches the initial one.
991) 
992)         Args:
993)             config:
994)                 The configuration to emit and read back.
995) 
996)         """
997)         prog_name_list = ("derivepassphrase", "vault")
998)         with io.StringIO() as outfile:
999)             cli_helpers.print_config_as_sh_script(
1000)                 config, outfile=outfile, prog_name_list=prog_name_list
1001)             )
1002)             script = outfile.getvalue()
1003)         runner = machinery.CliRunner(mix_stderr=False)
1004)         # TODO(the-13th-letter): Rewrite using parenthesized
1005)         # with-statements.
1006)         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1007)         with contextlib.ExitStack() as stack:
1008)             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1009)             stack.enter_context(
1010)                 pytest_machinery.isolated_vault_config(
1011)                     monkeypatch=monkeypatch,
1012)                     runner=runner,
1013)                     vault_config={"services": {}},
1014)                 )
1015)             )
1016)             for result in vault_config_exporter_shell_interpreter(script):
1017)                 assert result.clean_exit()
1018)             assert cli_helpers.load_config() == config
1019) 
1020)     @hypothesis.given(
1021)         global_config_settable=hypothesis_machinery.vault_full_service_config(),
1022)         global_config_importable=strategies.fixed_dictionaries(
1023)             {},
1024)             optional={
1025)                 "key": strategies.text(
1026)                     alphabet=strategies.characters(
1027)                         min_codepoint=32,
1028)                         max_codepoint=126,
1029)                     ),
1030)                     max_size=128,
1031)                 ),
1032)                 "phrase": strategies.text(
1033)                     alphabet=strategies.characters(
1034)                         min_codepoint=32,
1035)                         max_codepoint=126,
1036)                     ),
1037)                     max_size=64,
1038)                 ),
1039)             },
1040)         ),
1041)     )
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

1042)     def test_export_as_sh_global(
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1043)         self,
1044)         global_config_settable: _types.VaultConfigServicesSettings,
1045)         global_config_importable: _types.VaultConfigServicesSettings,
1046)     ) -> None:
1047)         """Exporting configurations as sh(1) script works.
1048) 
1049)         Here, we check global-only configurations which use both
1050)         settings settable via `--config` and settings requiring
1051)         `--import`.
1052) 
1053)         The actual verification is done by [`export_as_sh_helper`][].
1054) 
1055)         """
1056)         config: _types.VaultConfig = {
1057)             "global": global_config_settable | global_config_importable,
1058)             "services": {},
1059)         }
1060)         assert _types.clean_up_falsy_vault_config_values(config) is not None
1061)         assert _types.is_vault_config(config)
1062)         return self.export_as_sh_helper(config)
1063) 
1064)     @hypothesis.given(
1065)         global_config_importable=strategies.fixed_dictionaries(
1066)             {},
1067)             optional={
1068)                 "key": strategies.text(
1069)                     alphabet=strategies.characters(
1070)                         min_codepoint=32,
1071)                         max_codepoint=126,
1072)                     ),
1073)                     max_size=128,
1074)                 ),
1075)                 "phrase": strategies.text(
1076)                     alphabet=strategies.characters(
1077)                         min_codepoint=32,
1078)                         max_codepoint=126,
1079)                     ),
1080)                     max_size=64,
1081)                 ),
1082)             },
1083)         ),
1084)     )
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

1085)     def test_export_as_sh_global_only_imports(
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1086)         self,
1087)         global_config_importable: _types.VaultConfigServicesSettings,
1088)     ) -> None:
1089)         """Exporting configurations as sh(1) script works.
1090) 
1091)         Here, we check global-only configurations which only use
1092)         settings requiring `--import`.
1093) 
1094)         The actual verification is done by [`export_as_sh_helper`][].
1095) 
1096)         """
1097)         config: _types.VaultConfig = {
1098)             "global": global_config_importable,
1099)             "services": {},
1100)         }
1101)         assert _types.clean_up_falsy_vault_config_values(config) is not None
1102)         assert _types.is_vault_config(config)
1103)         if not config["global"]:
1104)             config.pop("global")
1105)         return self.export_as_sh_helper(config)
1106) 
1107)     @hypothesis.given(
1108)         service_name=strategies.text(
1109)             alphabet=strategies.characters(
1110)                 min_codepoint=32,
1111)                 max_codepoint=126,
1112)             ),
1113)             min_size=4,
1114)             max_size=64,
1115)         ),
1116)         service_config_settable=hypothesis_machinery.vault_full_service_config(),
1117)         service_config_importable=strategies.fixed_dictionaries(
1118)             {},
1119)             optional={
1120)                 "key": strategies.text(
1121)                     alphabet=strategies.characters(
1122)                         min_codepoint=32,
1123)                         max_codepoint=126,
1124)                     ),
1125)                     max_size=128,
1126)                 ),
1127)                 "phrase": strategies.text(
1128)                     alphabet=strategies.characters(
1129)                         min_codepoint=32,
1130)                         max_codepoint=126,
1131)                     ),
1132)                     max_size=64,
1133)                 ),
1134)                 "notes": strategies.text(
1135)                     alphabet=strategies.characters(
1136)                         min_codepoint=32,
1137)                         max_codepoint=126,
1138)                         include_characters=("\n", "\f", "\t"),
1139)                     ),
1140)                     max_size=256,
1141)                 ),
1142)             },
1143)         ),
1144)     )
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

1145)     def test_export_as_sh_service(
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1146)         self,
1147)         service_name: str,
1148)         service_config_settable: _types.VaultConfigServicesSettings,
1149)         service_config_importable: _types.VaultConfigServicesSettings,
1150)     ) -> None:
1151)         """Exporting configurations as sh(1) script works.
1152) 
1153)         Here, we check service-only configurations which use both
1154)         settings settable via `--config` and settings requiring
1155)         `--import`.
1156) 
1157)         The actual verification is done by [`export_as_sh_helper`][].
1158) 
1159)         """
1160)         config: _types.VaultConfig = {
1161)             "services": {
1162)                 service_name: (
1163)                     service_config_settable | service_config_importable
1164)                 ),
1165)             },
1166)         }
1167)         assert _types.clean_up_falsy_vault_config_values(config) is not None
1168)         assert _types.is_vault_config(config)
1169)         return self.export_as_sh_helper(config)
1170) 
1171)     @hypothesis.given(
1172)         service_name=strategies.text(
1173)             alphabet=strategies.characters(
1174)                 min_codepoint=32,
1175)                 max_codepoint=126,
1176)             ),
1177)             min_size=4,
1178)             max_size=64,
1179)         ),
1180)         service_config_importable=strategies.fixed_dictionaries(
1181)             {},
1182)             optional={
1183)                 "key": strategies.text(
1184)                     alphabet=strategies.characters(
1185)                         min_codepoint=32,
1186)                         max_codepoint=126,
1187)                     ),
1188)                     max_size=128,
1189)                 ),
1190)                 "phrase": strategies.text(
1191)                     alphabet=strategies.characters(
1192)                         min_codepoint=32,
1193)                         max_codepoint=126,
1194)                     ),
1195)                     max_size=64,
1196)                 ),
1197)                 "notes": strategies.text(
1198)                     alphabet=strategies.characters(
1199)                         min_codepoint=32,
1200)                         max_codepoint=126,
1201)                         include_characters=("\n", "\f", "\t"),
1202)                     ),
1203)                     max_size=256,
1204)                 ),
1205)             },
1206)         ),
1207)     )
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

1208)     def test_export_as_sh_service_only_imports(
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1209)         self,
1210)         service_name: str,
1211)         service_config_importable: _types.VaultConfigServicesSettings,
1212)     ) -> None:
1213)         """Exporting configurations as sh(1) script works.
1214) 
1215)         Here, we check service-only configurations which only use
1216)         settings requiring `--import`.
1217) 
1218)         The actual verification is done by [`export_as_sh_helper`][].
1219) 
1220)         """
1221)         config: _types.VaultConfig = {
1222)             "services": {
1223)                 service_name: service_config_importable,
1224)             },
1225)         }
1226)         assert _types.clean_up_falsy_vault_config_values(config) is not None
1227)         assert _types.is_vault_config(config)
1228)         return self.export_as_sh_helper(config)
1229) 
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

1230) 
1231) class TestTempdir:
1232)     """Tests for the temporary directory handling utility functions."""
1233) 
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1234)     # The Annoying OS appears to silently truncate spaces at the end of
1235)     # filenames.
1236)     @hypothesis.given(
1237)         env_var=strategies.sampled_from(["TMPDIR", "TEMP", "TMP"]),
1238)         suffix=strategies.builds(
1239)             operator.add,
1240)             strategies.text(
1241)                 tuple(" 0123456789abcdefghijklmnopqrstuvwxyz"),
1242)                 min_size=11,
1243)                 max_size=11,
1244)             ),
1245)             strategies.text(
1246)                 tuple("0123456789abcdefghijklmnopqrstuvwxyz"),
1247)                 min_size=1,
1248)                 max_size=1,
1249)             ),
1250)         ),
1251)     )
1252)     @hypothesis.example(env_var="", suffix=".")
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

1253)     def test_get_tempdir(
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1254)         self,
1255)         env_var: str,
1256)         suffix: str,
1257)     ) -> None:
1258)         """[`cli_helpers.get_tempdir`][] returns a temporary directory.
1259) 
1260)         If it is not the same as the temporary directory determined by
1261)         [`tempfile.gettempdir`][], then assert that
1262)         `tempfile.gettempdir` returned the current directory and
1263)         `cli_helpers.get_tempdir` returned the configuration directory.
1264) 
1265)         """
1266) 
1267)         @contextlib.contextmanager
1268)         def make_temporary_directory(
1269)             path: pathlib.Path,
1270)         ) -> Iterator[pathlib.Path]:
1271)             try:
1272)                 path.mkdir()
1273)                 yield path
1274)             finally:
1275)                 shutil.rmtree(path)
1276) 
1277)         runner = machinery.CliRunner(mix_stderr=False)
1278)         # TODO(the-13th-letter): Rewrite using parenthesized
1279)         # with-statements.
1280)         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1281)         with contextlib.ExitStack() as stack:
1282)             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1283)             stack.enter_context(
1284)                 pytest_machinery.isolated_vault_config(
1285)                     monkeypatch=monkeypatch,
1286)                     runner=runner,
1287)                     vault_config={"services": {}},
1288)                 )
1289)             )
1290)             old_tempdir = os.fsdecode(tempfile.gettempdir())
1291)             monkeypatch.delenv("TMPDIR", raising=False)
1292)             monkeypatch.delenv("TEMP", raising=False)
1293)             monkeypatch.delenv("TMP", raising=False)
1294)             monkeypatch.setattr(tempfile, "tempdir", None)
1295)             temp_path = pathlib.Path.cwd() / suffix
1296)             if env_var:
1297)                 monkeypatch.setenv(env_var, os.fsdecode(temp_path))
1298)                 stack.enter_context(make_temporary_directory(temp_path))
1299)             new_tempdir = os.fsdecode(tempfile.gettempdir())
1300)             hypothesis.assume(
1301)                 temp_path.resolve() == pathlib.Path.cwd().resolve()
1302)                 or old_tempdir != new_tempdir
1303)             )
1304)             system_tempdir = os.fsdecode(tempfile.gettempdir())
1305)             our_tempdir = cli_helpers.get_tempdir()
1306)             assert system_tempdir == os.fsdecode(our_tempdir) or (
1307)                 # TODO(the-13th-letter): `pytest_machinery.isolated_config`
1308)                 # guarantees that `Path.cwd() == config_filename(None)`.
1309)                 # So this sub-branch ought to never trigger in our
1310)                 # tests.
1311)                 system_tempdir == os.getcwd()  # noqa: PTH109
1312)                 and our_tempdir == cli_helpers.config_filename(subsystem=None)
1313)             )
1314)         assert not temp_path.exists(), f"temp path {temp_path} not cleaned up!"
1315) 
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

1316)     def test_get_tempdir_force_default(self) -> None:
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1317)         """[`cli_helpers.get_tempdir`][] returns a temporary directory.
1318) 
1319)         If all candidates are mocked to fail for the standard temporary
1320)         directory choices, then we return the `derivepassphrase`
1321)         configuration directory.
1322) 
1323)         """
1324)         runner = machinery.CliRunner(mix_stderr=False)
1325)         # TODO(the-13th-letter): Rewrite using parenthesized
1326)         # with-statements.
1327)         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1328)         with contextlib.ExitStack() as stack:
1329)             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1330)             stack.enter_context(
1331)                 pytest_machinery.isolated_vault_config(
1332)                     monkeypatch=monkeypatch,
1333)                     runner=runner,
1334)                     vault_config={"services": {}},
1335)                 )
1336)             )
1337)             monkeypatch.delenv("TMPDIR", raising=False)
1338)             monkeypatch.delenv("TEMP", raising=False)
1339)             monkeypatch.delenv("TMP", raising=False)
1340)             config_dir = cli_helpers.config_filename(subsystem=None)
1341) 
1342)             def is_dir_false(
1343)                 self: pathlib.Path,
1344)                 /,
1345)                 *,
1346)                 follow_symlinks: bool = False,
1347)             ) -> bool:
1348)                 del self, follow_symlinks
1349)                 return False
1350) 
1351)             def is_dir_error(
1352)                 self: pathlib.Path,
1353)                 /,
1354)                 *,
1355)                 follow_symlinks: bool = False,
1356)             ) -> bool:
1357)                 del follow_symlinks
1358)                 raise OSError(
1359)                     errno.EACCES,
1360)                     os.strerror(errno.EACCES),
1361)                     str(self),
1362)                 )
1363) 
1364)             monkeypatch.setattr(pathlib.Path, "is_dir", is_dir_false)
1365)             assert cli_helpers.get_tempdir() == config_dir
1366) 
1367)             monkeypatch.setattr(pathlib.Path, "is_dir", is_dir_error)
1368)             assert cli_helpers.get_tempdir() == config_dir
1369) 
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

1370) 
1371) # TODO(the-13th-letter): Use a better class name, or consider keeping them
1372) # as top-level functions.
1373) class TestMisc:
1374)     """Miscellaneous tests for the command-line utility functions."""
1375) 
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1376)     @Parametrize.DELETE_CONFIG_INPUT
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

1377)     def test_repeated_config_deletion(
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1378)         self,
1379)         command_line: list[str],
1380)         config: _types.VaultConfig,
1381)         result_config: _types.VaultConfig,
1382)     ) -> None:
1383)         """Repeatedly removing the same parts of a configuration works."""
1384)         for start_config in [config, result_config]:
1385)             runner = machinery.CliRunner(mix_stderr=False)
1386)             # TODO(the-13th-letter): Rewrite using parenthesized
1387)             # with-statements.
1388)             # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1389)             with contextlib.ExitStack() as stack:
1390)                 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1391)                 stack.enter_context(
1392)                     pytest_machinery.isolated_vault_config(
1393)                         monkeypatch=monkeypatch,
1394)                         runner=runner,
1395)                         vault_config=start_config,
1396)                     )
1397)                 )
1398)                 result = runner.invoke(
1399)                     cli.derivepassphrase_vault,
1400)                     command_line,
1401)                     catch_exceptions=False,
1402)                 )
1403)                 assert result.clean_exit(empty_stderr=True), (
1404)                     "expected clean exit"
1405)                 )
1406)                 with cli_helpers.config_filename(subsystem="vault").open(
1407)                     encoding="UTF-8"
1408)                 ) as infile:
1409)                     config_readback = json.load(infile)
1410)                 assert config_readback == result_config
1411) 
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

1412)     def test_phrase_from_key_manually(self) -> None:
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1413)         """The dummy service, key and config settings are consistent."""
1414)         assert (
1415)             vault.Vault(
Marco Ricci Fix typing of the dummy vau...

Marco Ricci authored 3 months ago

1416)                 phrase=DUMMY_PHRASE_FROM_KEY1,
1417)                 **DUMMY_CONFIG_SETTINGS_AS_CONSTRUCTOR_ARGS,
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1418)             ).generate(DUMMY_SERVICE)
1419)             == DUMMY_RESULT_KEY1
1420)         )
1421) 
1422)     @Parametrize.VALIDATION_FUNCTION_INPUT
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

1423)     def test_validate_constraints_manually(
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1424)         self,
1425)         vfunc: Callable[[click.Context, click.Parameter, Any], int | None],
1426)         input: int,
1427)     ) -> None:
1428)         """Command-line argument constraint validation works."""
1429)         ctx = cli.derivepassphrase_vault.make_context(cli.PROG_NAME, [])
1430)         param = cli.derivepassphrase_vault.params[0]
1431)         assert vfunc(ctx, param, input) == input
1432) 
1433)     @Parametrize.CONNECTION_HINTS
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

1434)     def test_get_suitable_ssh_keys(
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1435)         self,
1436)         running_ssh_agent: data.RunningSSHAgentInfo,
1437)         conn_hint: str,
1438)     ) -> None:
1439)         """[`cli_helpers.get_suitable_ssh_keys`][] works."""
1440)         with pytest.MonkeyPatch.context() as monkeypatch:
1441)             monkeypatch.setattr(
1442)                 ssh_agent.SSHAgentClient,
1443)                 "list_keys",
1444)                 callables.list_keys,
1445)             )
1446)             hint: ssh_agent.SSHAgentClient | _types.SSHAgentSocket | None
1447)             # TODO(the-13th-letter): Rewrite using structural pattern
1448)             # matching.
1449)             # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1450)             if conn_hint == "client":
1451)                 hint = ssh_agent.SSHAgentClient()
1452)             elif conn_hint == "socket":
1453)                 if isinstance(
1454)                     running_ssh_agent.socket, str
1455)                 ):  # pragma: no cover
1456)                     if not hasattr(socket, "AF_UNIX"):
1457)                         pytest.skip("socket module does not support AF_UNIX")
1458)                     # socket.AF_UNIX is not defined everywhere.
1459)                     hint = socket.socket(family=socket.AF_UNIX)  # type: ignore[attr-defined]
1460)                     hint.connect(running_ssh_agent.socket)
1461)                 else:  # pragma: no cover
1462)                     hint = running_ssh_agent.socket()
1463)             else:
1464)                 assert conn_hint == "none"
1465)                 hint = None
1466)             exception: Exception | None = None
1467)             try:
1468)                 list(cli_helpers.get_suitable_ssh_keys(hint))
1469)             except RuntimeError:  # pragma: no cover
1470)                 pass
1471)             except Exception as e:  # noqa: BLE001 # pragma: no cover
1472)                 exception = e
1473)             finally:
1474)                 assert exception is None, (
1475)                     "exception querying suitable SSH keys"
1476)                 )
1477) 
1478)     @Parametrize.KEY_TO_PHRASE_SETTINGS
Marco Ricci Regroup the CLI and SSH age...

Marco Ricci authored 7 months ago

1479)     def test_key_to_phrase(
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1480)         self,
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

1481)         request: pytest.FixtureRequest,
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1482)         ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
1483)         list_keys_action: ListKeysAction | None,
1484)         system_support_action: SystemSupportAction | None,
1485)         address_action: SocketAddressAction | None,
1486)         sign_action: SignAction,
1487)         pattern: str,
Marco Ricci Add missing `warning_callba...

Marco Ricci authored 3 months ago

1488)         warnings_patterns: list[str],
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

1489)         tests_construction_failure: bool,
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1490)     ) -> None:
1491)         """All errors in [`cli_helpers.key_to_phrase`][] are handled."""
1492) 
Marco Ricci Add missing `warning_callba...

Marco Ricci authored 3 months ago

1493)         captured_warnings: list[str] = []
1494) 
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1495)         class ErrCallback(BaseException):
1496)             def __init__(self, *args: Any, **kwargs: Any) -> None:
1497)                 super().__init__(*args[:1])
1498)                 self.args = args
1499)                 self.kwargs = kwargs
1500) 
Marco Ricci Add missing `warning_callba...

Marco Ricci authored 3 months ago

1501)         def err(*args: Any, **kwargs: Any) -> NoReturn:
1502)             raise ErrCallback(*args, **kwargs)
1503) 
1504)         def warn(*args: Any) -> None:
1505)             if args:  # pragma: no branch
1506)                 captured_warnings.append(str(args[0]))
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1507) 
1508)         with pytest.MonkeyPatch.context() as monkeypatch:
1509)             loaded_keys = list(
1510)                 ssh_agent_client_with_test_keys_loaded.list_keys()
1511)             )
1512)             loaded_key = base64.standard_b64encode(loaded_keys[0][0])
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

1513) 
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1514)             monkeypatch.setattr(ssh_agent.SSHAgentClient, "sign", sign_action)
1515)             if list_keys_action:
1516)                 monkeypatch.setattr(
1517)                     ssh_agent.SSHAgentClient, "list_keys", list_keys_action
1518)                 )
1519)             if address_action:
1520)                 address_action(monkeypatch)
1521)             if system_support_action:
1522)                 system_support_action(monkeypatch)
Marco Ricci Work around non-reentrant S...

Marco Ricci authored 1 month ago

1523) 
1524)             if not tests_construction_failure:
1525)                 pytest_machinery.ensure_singleton_client_if_non_reentrant_agent(
1526)                     request=request,
1527)                     monkeypatch=monkeypatch,
1528)                     client=ssh_agent_client_with_test_keys_loaded,
1529)                 )
1530) 
Marco Ricci Split the CLI tests into on...

Marco Ricci authored 7 months ago

1531)             with pytest.raises(ErrCallback, match=pattern) as excinfo:
Marco Ricci Add missing `warning_callba...

Marco Ricci authored 3 months ago

1532)                 cli_helpers.key_to_phrase(
1533)                     loaded_key, error_callback=err, warning_callback=warn
1534)                 )
1535) 
1536)             for pat in warnings_patterns:
1537)                 assert any([pat in string for string in captured_warnings]), (
1538)                     f"expected some warning message to match {pat}"
1539)                 )