Marco Ricci commited on 2025-08-09 18:49:50
Zeige 8 geänderte Dateien mit 4829 Einfügungen und 4822 Löschungen.
If a test module is merely converted to a package, then `pytest` will no longer pick it up, because the name `__init__.py` does not match the test module name pattern anymore. So, rename them. Also set the `pytest` import mode to `importlib`, as per the documentation's suggestion. I haven't had any trouble with this *yet*, but now I have similarly named modules, so this might otherwise crop up in the future.
... | ... |
@@ -412,7 +412,7 @@ sqlite_cache = true |
412 | 412 |
enable_error_code = ['ignore-without-code'] |
413 | 413 |
|
414 | 414 |
[tool.pytest.ini_options] |
415 |
-addopts = '--doctest-modules --dist=worksteal' |
|
415 |
+addopts = '--doctest-modules --dist=worksteal --import-mode=importlib' |
|
416 | 416 |
pythonpath = ['src'] |
417 | 417 |
testpaths = ['src', 'tests'] |
418 | 418 |
xfail_strict = true |
... | ... |
@@ -1,2978 +1,3 @@ |
1 | 1 |
# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
2 | 2 |
# |
3 | 3 |
# SPDX-License-Identifier: Zlib |
4 |
- |
|
5 |
-from __future__ import annotations |
|
6 |
- |
|
7 |
-import contextlib |
|
8 |
-import copy |
|
9 |
-import errno |
|
10 |
-import json |
|
11 |
-import os |
|
12 |
-import pathlib |
|
13 |
-import shutil |
|
14 |
-import socket |
|
15 |
-import textwrap |
|
16 |
-import types |
|
17 |
-from typing import TYPE_CHECKING |
|
18 |
- |
|
19 |
-import click.testing |
|
20 |
-import hypothesis |
|
21 |
-import pytest |
|
22 |
-from hypothesis import strategies |
|
23 |
-from typing_extensions import Any, NamedTuple |
|
24 |
- |
|
25 |
-from derivepassphrase import _types, cli, ssh_agent, vault |
|
26 |
-from derivepassphrase._internals import ( |
|
27 |
- cli_helpers, |
|
28 |
- cli_messages, |
|
29 |
-) |
|
30 |
-from tests import data, machinery |
|
31 |
-from tests.data import callables |
|
32 |
-from tests.machinery import hypothesis as hypothesis_machinery |
|
33 |
-from tests.machinery import pytest as pytest_machinery |
|
34 |
- |
|
35 |
-if TYPE_CHECKING: |
|
36 |
- from typing import NoReturn |
|
37 |
- |
|
38 |
- from typing_extensions import Literal |
|
39 |
- |
|
40 |
-DUMMY_SERVICE = data.DUMMY_SERVICE |
|
41 |
-DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE |
|
42 |
-DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS |
|
43 |
-DUMMY_RESULT_PASSPHRASE = data.DUMMY_RESULT_PASSPHRASE |
|
44 |
-DUMMY_RESULT_KEY1 = data.DUMMY_RESULT_KEY1 |
|
45 |
-DUMMY_PHRASE_FROM_KEY1_RAW = data.DUMMY_PHRASE_FROM_KEY1_RAW |
|
46 |
-DUMMY_PHRASE_FROM_KEY1 = data.DUMMY_PHRASE_FROM_KEY1 |
|
47 |
- |
|
48 |
-DUMMY_KEY1 = data.DUMMY_KEY1 |
|
49 |
-DUMMY_KEY1_B64 = data.DUMMY_KEY1_B64 |
|
50 |
-DUMMY_KEY2 = data.DUMMY_KEY2 |
|
51 |
-DUMMY_KEY2_B64 = data.DUMMY_KEY2_B64 |
|
52 |
-DUMMY_KEY3 = data.DUMMY_KEY3 |
|
53 |
-DUMMY_KEY3_B64 = data.DUMMY_KEY3_B64 |
|
54 |
- |
|
55 |
-TEST_CONFIGS = data.TEST_CONFIGS |
|
56 |
- |
|
57 |
- |
|
58 |
-class IncompatibleConfiguration(NamedTuple): |
|
59 |
- other_options: list[tuple[str, ...]] |
|
60 |
- needs_service: bool | None |
|
61 |
- input: str | None |
|
62 |
- |
|
63 |
- |
|
64 |
-class SingleConfiguration(NamedTuple): |
|
65 |
- needs_service: bool | None |
|
66 |
- input: str | None |
|
67 |
- check_success: bool |
|
68 |
- |
|
69 |
- |
|
70 |
-class OptionCombination(NamedTuple): |
|
71 |
- options: list[str] |
|
72 |
- incompatible: bool |
|
73 |
- needs_service: bool | None |
|
74 |
- input: str | None |
|
75 |
- check_success: bool |
|
76 |
- |
|
77 |
- |
|
78 |
-PASSPHRASE_GENERATION_OPTIONS: list[tuple[str, ...]] = [ |
|
79 |
- ("--phrase",), |
|
80 |
- ("--key",), |
|
81 |
- ("--length", "20"), |
|
82 |
- ("--repeat", "20"), |
|
83 |
- ("--lower", "1"), |
|
84 |
- ("--upper", "1"), |
|
85 |
- ("--number", "1"), |
|
86 |
- ("--space", "1"), |
|
87 |
- ("--dash", "1"), |
|
88 |
- ("--symbol", "1"), |
|
89 |
-] |
|
90 |
-CONFIGURATION_OPTIONS: list[tuple[str, ...]] = [ |
|
91 |
- ("--notes",), |
|
92 |
- ("--config",), |
|
93 |
- ("--delete",), |
|
94 |
- ("--delete-globals",), |
|
95 |
- ("--clear",), |
|
96 |
-] |
|
97 |
-CONFIGURATION_COMMANDS: list[tuple[str, ...]] = [ |
|
98 |
- ("--delete",), |
|
99 |
- ("--delete-globals",), |
|
100 |
- ("--clear",), |
|
101 |
-] |
|
102 |
-STORAGE_OPTIONS: list[tuple[str, ...]] = [("--export", "-"), ("--import", "-")] |
|
103 |
-INCOMPATIBLE: dict[tuple[str, ...], IncompatibleConfiguration] = { |
|
104 |
- ("--phrase",): IncompatibleConfiguration( |
|
105 |
- [("--key",), *CONFIGURATION_COMMANDS, *STORAGE_OPTIONS], |
|
106 |
- True, |
|
107 |
- DUMMY_PASSPHRASE, |
|
108 |
- ), |
|
109 |
- ("--key",): IncompatibleConfiguration( |
|
110 |
- CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
111 |
- ), |
|
112 |
- ("--length", "20"): IncompatibleConfiguration( |
|
113 |
- CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
114 |
- ), |
|
115 |
- ("--repeat", "20"): IncompatibleConfiguration( |
|
116 |
- CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
117 |
- ), |
|
118 |
- ("--lower", "1"): IncompatibleConfiguration( |
|
119 |
- CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
120 |
- ), |
|
121 |
- ("--upper", "1"): IncompatibleConfiguration( |
|
122 |
- CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
123 |
- ), |
|
124 |
- ("--number", "1"): IncompatibleConfiguration( |
|
125 |
- CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
126 |
- ), |
|
127 |
- ("--space", "1"): IncompatibleConfiguration( |
|
128 |
- CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
129 |
- ), |
|
130 |
- ("--dash", "1"): IncompatibleConfiguration( |
|
131 |
- CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
132 |
- ), |
|
133 |
- ("--symbol", "1"): IncompatibleConfiguration( |
|
134 |
- CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
135 |
- ), |
|
136 |
- ("--notes",): IncompatibleConfiguration( |
|
137 |
- CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, None |
|
138 |
- ), |
|
139 |
- ("--config", "-p"): IncompatibleConfiguration( |
|
140 |
- [("--delete",), ("--delete-globals",), ("--clear",), *STORAGE_OPTIONS], |
|
141 |
- None, |
|
142 |
- DUMMY_PASSPHRASE, |
|
143 |
- ), |
|
144 |
- ("--delete",): IncompatibleConfiguration( |
|
145 |
- [("--delete-globals",), ("--clear",), *STORAGE_OPTIONS], True, None |
|
146 |
- ), |
|
147 |
- ("--delete-globals",): IncompatibleConfiguration( |
|
148 |
- [("--clear",), *STORAGE_OPTIONS], False, None |
|
149 |
- ), |
|
150 |
- ("--clear",): IncompatibleConfiguration(STORAGE_OPTIONS, False, None), |
|
151 |
- ("--export", "-"): IncompatibleConfiguration( |
|
152 |
- [("--import", "-")], False, None |
|
153 |
- ), |
|
154 |
- ("--import", "-"): IncompatibleConfiguration([], False, None), |
|
155 |
-} |
|
156 |
-SINGLES: dict[tuple[str, ...], SingleConfiguration] = { |
|
157 |
- ("--phrase",): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
158 |
- ("--key",): SingleConfiguration(True, None, False), |
|
159 |
- ("--length", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
160 |
- ("--repeat", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
161 |
- ("--lower", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
162 |
- ("--upper", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
163 |
- ("--number", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
164 |
- ("--space", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
165 |
- ("--dash", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
166 |
- ("--symbol", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
167 |
- ("--notes",): SingleConfiguration(True, None, False), |
|
168 |
- ("--config", "-p"): SingleConfiguration(None, DUMMY_PASSPHRASE, False), |
|
169 |
- ("--delete",): SingleConfiguration(True, None, False), |
|
170 |
- ("--delete-globals",): SingleConfiguration(False, None, True), |
|
171 |
- ("--clear",): SingleConfiguration(False, None, True), |
|
172 |
- ("--export", "-"): SingleConfiguration(False, None, True), |
|
173 |
- ("--import", "-"): SingleConfiguration(False, '{"services": {}}', True), |
|
174 |
-} |
|
175 |
-INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = [] |
|
176 |
-config: IncompatibleConfiguration | SingleConfiguration |
|
177 |
-for opt, config in INCOMPATIBLE.items(): |
|
178 |
- for opt2 in config.other_options: |
|
179 |
- INTERESTING_OPTION_COMBINATIONS.extend([ |
|
180 |
- OptionCombination( |
|
181 |
- options=list(opt + opt2), |
|
182 |
- incompatible=True, |
|
183 |
- needs_service=config.needs_service, |
|
184 |
- input=config.input, |
|
185 |
- check_success=False, |
|
186 |
- ), |
|
187 |
- OptionCombination( |
|
188 |
- options=list(opt2 + opt), |
|
189 |
- incompatible=True, |
|
190 |
- needs_service=config.needs_service, |
|
191 |
- input=config.input, |
|
192 |
- check_success=False, |
|
193 |
- ), |
|
194 |
- ]) |
|
195 |
-for opt, config in SINGLES.items(): |
|
196 |
- INTERESTING_OPTION_COMBINATIONS.append( |
|
197 |
- OptionCombination( |
|
198 |
- options=list(opt), |
|
199 |
- incompatible=False, |
|
200 |
- needs_service=config.needs_service, |
|
201 |
- input=config.input, |
|
202 |
- check_success=config.check_success, |
|
203 |
- ) |
|
204 |
- ) |
|
205 |
- |
|
206 |
- |
|
207 |
-def is_warning_line(line: str) -> bool: |
|
208 |
- """Return true if the line is a warning line.""" |
|
209 |
- return " Warning: " in line or " Deprecation warning: " in line |
|
210 |
- |
|
211 |
- |
|
212 |
-def is_harmless_config_import_warning(record: tuple[str, int, str]) -> bool: |
|
213 |
- """Return true if the warning is harmless, during config import.""" |
|
214 |
- possible_warnings = [ |
|
215 |
- "Replacing invalid value ", |
|
216 |
- "Removing ineffective setting ", |
|
217 |
- ( |
|
218 |
- "Setting a global passphrase is ineffective " |
|
219 |
- "because a key is also set." |
|
220 |
- ), |
|
221 |
- ( |
|
222 |
- "Setting a service passphrase is ineffective " |
|
223 |
- "because a key is also set:" |
|
224 |
- ), |
|
225 |
- ] |
|
226 |
- return any( |
|
227 |
- machinery.warning_emitted(w, [record]) for w in possible_warnings |
|
228 |
- ) |
|
229 |
- |
|
230 |
- |
|
231 |
-def assert_vault_config_is_indented_and_line_broken( |
|
232 |
- config_txt: str, |
|
233 |
- /, |
|
234 |
-) -> None: |
|
235 |
- """Return true if the vault configuration is indented and line broken. |
|
236 |
- |
|
237 |
- Indented and rewrapped vault configurations as produced by |
|
238 |
- `json.dump` contain the closing '}' of the '$.services' object |
|
239 |
- on a separate, indented line: |
|
240 |
- |
|
241 |
- ~~~~ |
|
242 |
- { |
|
243 |
- "services": { |
|
244 |
- ... |
|
245 |
- } <-- this brace here |
|
246 |
- } |
|
247 |
- ~~~~ |
|
248 |
- |
|
249 |
- or, if there are no services, then the indented line |
|
250 |
- |
|
251 |
- ~~~~ |
|
252 |
- "services": {} |
|
253 |
- ~~~~ |
|
254 |
- |
|
255 |
- Both variations may end with a comma if there are more top-level |
|
256 |
- keys. |
|
257 |
- |
|
258 |
- """ |
|
259 |
- known_indented_lines = { |
|
260 |
- "}", |
|
261 |
- "},", |
|
262 |
- '"services": {}', |
|
263 |
- '"services": {},', |
|
264 |
- } |
|
265 |
- assert any([ |
|
266 |
- line.strip() in known_indented_lines and line.startswith((" ", "\t")) |
|
267 |
- for line in config_txt.splitlines() |
|
268 |
- ]) |
|
269 |
- |
|
270 |
- |
|
271 |
-class Parametrize(types.SimpleNamespace): |
|
272 |
- """Common test parametrizations.""" |
|
273 |
- |
|
274 |
- CHARSET_NAME = pytest.mark.parametrize( |
|
275 |
- "charset_name", ["lower", "upper", "number", "space", "dash", "symbol"] |
|
276 |
- ) |
|
277 |
- UNICODE_NORMALIZATION_COMMAND_LINES = pytest.mark.parametrize( |
|
278 |
- "command_line", |
|
279 |
- [ |
|
280 |
- pytest.param( |
|
281 |
- ["--config", "--phrase"], |
|
282 |
- id="configure global passphrase", |
|
283 |
- ), |
|
284 |
- pytest.param( |
|
285 |
- ["--config", "--phrase", "--", "DUMMY_SERVICE"], |
|
286 |
- id="configure service passphrase", |
|
287 |
- ), |
|
288 |
- pytest.param( |
|
289 |
- ["--phrase", "--", DUMMY_SERVICE], |
|
290 |
- id="interactive passphrase", |
|
291 |
- ), |
|
292 |
- ], |
|
293 |
- ) |
|
294 |
- CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES = pytest.mark.parametrize( |
|
295 |
- ["command_line", "input", "err_text"], |
|
296 |
- [ |
|
297 |
- pytest.param( |
|
298 |
- [], |
|
299 |
- "", |
|
300 |
- "Cannot update the global settings without any given settings", |
|
301 |
- id="None", |
|
302 |
- ), |
|
303 |
- pytest.param( |
|
304 |
- ["--", "sv"], |
|
305 |
- "", |
|
306 |
- "Cannot update the service-specific settings without any given settings", |
|
307 |
- id="None-sv", |
|
308 |
- ), |
|
309 |
- pytest.param( |
|
310 |
- ["--phrase", "--", "sv"], |
|
311 |
- "\n", |
|
312 |
- "No passphrase was given", |
|
313 |
- id="phrase-sv", |
|
314 |
- ), |
|
315 |
- pytest.param( |
|
316 |
- ["--phrase", "--", "sv"], |
|
317 |
- "", |
|
318 |
- "No passphrase was given", |
|
319 |
- id="phrase-sv-eof", |
|
320 |
- ), |
|
321 |
- pytest.param( |
|
322 |
- ["--key"], |
|
323 |
- "\n", |
|
324 |
- "No SSH key was selected", |
|
325 |
- id="key-sv", |
|
326 |
- ), |
|
327 |
- pytest.param( |
|
328 |
- ["--key"], |
|
329 |
- "", |
|
330 |
- "No SSH key was selected", |
|
331 |
- id="key-sv-eof", |
|
332 |
- ), |
|
333 |
- ], |
|
334 |
- ) |
|
335 |
- CONFIG_EDITING_VIA_CONFIG_FLAG = pytest.mark.parametrize( |
|
336 |
- ["command_line", "input", "result_config"], |
|
337 |
- [ |
|
338 |
- pytest.param( |
|
339 |
- ["--phrase"], |
|
340 |
- "my passphrase\n", |
|
341 |
- {"global": {"phrase": "my passphrase"}, "services": {}}, |
|
342 |
- id="phrase", |
|
343 |
- ), |
|
344 |
- pytest.param( |
|
345 |
- ["--key"], |
|
346 |
- "1\n", |
|
347 |
- { |
|
348 |
- "global": {"key": DUMMY_KEY1_B64, "phrase": "abc"}, |
|
349 |
- "services": {}, |
|
350 |
- }, |
|
351 |
- id="key", |
|
352 |
- ), |
|
353 |
- pytest.param( |
|
354 |
- ["--phrase", "--", "sv"], |
|
355 |
- "my passphrase\n", |
|
356 |
- { |
|
357 |
- "global": {"phrase": "abc"}, |
|
358 |
- "services": {"sv": {"phrase": "my passphrase"}}, |
|
359 |
- }, |
|
360 |
- id="phrase-sv", |
|
361 |
- ), |
|
362 |
- pytest.param( |
|
363 |
- ["--key", "--", "sv"], |
|
364 |
- "1\n", |
|
365 |
- { |
|
366 |
- "global": {"phrase": "abc"}, |
|
367 |
- "services": {"sv": {"key": DUMMY_KEY1_B64}}, |
|
368 |
- }, |
|
369 |
- id="key-sv", |
|
370 |
- ), |
|
371 |
- pytest.param( |
|
372 |
- ["--key", "--length", "15", "--", "sv"], |
|
373 |
- "1\n", |
|
374 |
- { |
|
375 |
- "global": {"phrase": "abc"}, |
|
376 |
- "services": {"sv": {"key": DUMMY_KEY1_B64, "length": 15}}, |
|
377 |
- }, |
|
378 |
- id="key-length-sv", |
|
379 |
- ), |
|
380 |
- ], |
|
381 |
- ) |
|
382 |
- BASE_CONFIG_WITH_KEY_VARIATIONS = pytest.mark.parametrize( |
|
383 |
- "config", |
|
384 |
- [ |
|
385 |
- pytest.param( |
|
386 |
- { |
|
387 |
- "global": {"key": DUMMY_KEY1_B64}, |
|
388 |
- "services": {DUMMY_SERVICE: {}}, |
|
389 |
- }, |
|
390 |
- id="global_config", |
|
391 |
- ), |
|
392 |
- pytest.param( |
|
393 |
- {"services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}}}, |
|
394 |
- id="service_config", |
|
395 |
- ), |
|
396 |
- pytest.param( |
|
397 |
- { |
|
398 |
- "global": {"key": DUMMY_KEY1_B64}, |
|
399 |
- "services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}}, |
|
400 |
- }, |
|
401 |
- id="full_config", |
|
402 |
- ), |
|
403 |
- ], |
|
404 |
- ) |
|
405 |
- CONFIG_WITH_KEY = pytest.mark.parametrize( |
|
406 |
- "config", |
|
407 |
- [ |
|
408 |
- pytest.param( |
|
409 |
- { |
|
410 |
- "global": {"key": DUMMY_KEY1_B64}, |
|
411 |
- "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}, |
|
412 |
- }, |
|
413 |
- id="global", |
|
414 |
- ), |
|
415 |
- pytest.param( |
|
416 |
- { |
|
417 |
- "global": {"phrase": DUMMY_PASSPHRASE.rstrip("\n")}, |
|
418 |
- "services": { |
|
419 |
- DUMMY_SERVICE: { |
|
420 |
- "key": DUMMY_KEY1_B64, |
|
421 |
- **DUMMY_CONFIG_SETTINGS, |
|
422 |
- } |
|
423 |
- }, |
|
424 |
- }, |
|
425 |
- id="service", |
|
426 |
- ), |
|
427 |
- ], |
|
428 |
- ) |
|
429 |
- VALID_TEST_CONFIGS = pytest.mark.parametrize( |
|
430 |
- "config", |
|
431 |
- [conf.config for conf in TEST_CONFIGS if conf.is_valid()], |
|
432 |
- ) |
|
433 |
- KEY_OVERRIDING_IN_CONFIG = pytest.mark.parametrize( |
|
434 |
- ["config", "command_line"], |
|
435 |
- [ |
|
436 |
- pytest.param( |
|
437 |
- { |
|
438 |
- "global": {"key": DUMMY_KEY1_B64}, |
|
439 |
- "services": {}, |
|
440 |
- }, |
|
441 |
- ["--config", "-p"], |
|
442 |
- id="global", |
|
443 |
- ), |
|
444 |
- pytest.param( |
|
445 |
- { |
|
446 |
- "services": { |
|
447 |
- DUMMY_SERVICE: { |
|
448 |
- "key": DUMMY_KEY1_B64, |
|
449 |
- **DUMMY_CONFIG_SETTINGS, |
|
450 |
- }, |
|
451 |
- }, |
|
452 |
- }, |
|
453 |
- ["--config", "-p", "--", DUMMY_SERVICE], |
|
454 |
- id="service", |
|
455 |
- ), |
|
456 |
- pytest.param( |
|
457 |
- { |
|
458 |
- "global": {"key": DUMMY_KEY1_B64}, |
|
459 |
- "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}, |
|
460 |
- }, |
|
461 |
- ["--config", "-p", "--", DUMMY_SERVICE], |
|
462 |
- id="service-over-global", |
|
463 |
- ), |
|
464 |
- ], |
|
465 |
- ) |
|
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", |
|
476 |
- [ |
|
477 |
- [], |
|
478 |
- ["--export-as=sh"], |
|
479 |
- ], |
|
480 |
- ) |
|
481 |
- KEY_INDEX = pytest.mark.parametrize( |
|
482 |
- "key_index", [1, 2, 3], ids=lambda i: f"index{i}" |
|
483 |
- ) |
|
484 |
- UNICODE_NORMALIZATION_ERROR_INPUTS = pytest.mark.parametrize( |
|
485 |
- ["main_config", "command_line", "input", "error_message"], |
|
486 |
- [ |
|
487 |
- pytest.param( |
|
488 |
- textwrap.dedent(r""" |
|
489 |
- [vault] |
|
490 |
- default-unicode-normalization-form = 'XXX' |
|
491 |
- """), |
|
492 |
- ["--import", "-"], |
|
493 |
- json.dumps({ |
|
494 |
- "services": { |
|
495 |
- DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
496 |
- "with_normalization": {"phrase": "D\u00fcsseldorf"}, |
|
497 |
- }, |
|
498 |
- }), |
|
499 |
- ( |
|
500 |
- "Invalid value 'XXX' for config key " |
|
501 |
- "vault.default-unicode-normalization-form" |
|
502 |
- ), |
|
503 |
- id="global", |
|
504 |
- ), |
|
505 |
- pytest.param( |
|
506 |
- textwrap.dedent(r""" |
|
507 |
- [vault.unicode-normalization-form] |
|
508 |
- with_normalization = 'XXX' |
|
509 |
- """), |
|
510 |
- ["--import", "-"], |
|
511 |
- json.dumps({ |
|
512 |
- "services": { |
|
513 |
- DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
514 |
- "with_normalization": {"phrase": "D\u00fcsseldorf"}, |
|
515 |
- }, |
|
516 |
- }), |
|
517 |
- ( |
|
518 |
- "Invalid value 'XXX' for config key " |
|
519 |
- "vault.with_normalization.unicode-normalization-form" |
|
520 |
- ), |
|
521 |
- id="service", |
|
522 |
- ), |
|
523 |
- ], |
|
524 |
- ) |
|
525 |
- UNICODE_NORMALIZATION_WARNING_INPUTS = pytest.mark.parametrize( |
|
526 |
- ["main_config", "command_line", "input", "warning_message"], |
|
527 |
- [ |
|
528 |
- pytest.param( |
|
529 |
- "", |
|
530 |
- ["--import", "-"], |
|
531 |
- json.dumps({ |
|
532 |
- "global": {"phrase": "Du\u0308sseldorf"}, |
|
533 |
- "services": {}, |
|
534 |
- }), |
|
535 |
- "The $.global passphrase is not NFC-normalized", |
|
536 |
- id="global-NFC", |
|
537 |
- ), |
|
538 |
- pytest.param( |
|
539 |
- "", |
|
540 |
- ["--import", "-"], |
|
541 |
- json.dumps({ |
|
542 |
- "services": { |
|
543 |
- DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
544 |
- "weird entry name": {"phrase": "Du\u0308sseldorf"}, |
|
545 |
- } |
|
546 |
- }), |
|
547 |
- ( |
|
548 |
- 'The $.services["weird entry name"] passphrase ' |
|
549 |
- "is not NFC-normalized" |
|
550 |
- ), |
|
551 |
- id="service-weird-name-NFC", |
|
552 |
- ), |
|
553 |
- pytest.param( |
|
554 |
- "", |
|
555 |
- ["--config", "-p", "--", DUMMY_SERVICE], |
|
556 |
- "Du\u0308sseldorf", |
|
557 |
- ( |
|
558 |
- f"The $.services.{DUMMY_SERVICE} passphrase " |
|
559 |
- f"is not NFC-normalized" |
|
560 |
- ), |
|
561 |
- id="config-NFC", |
|
562 |
- ), |
|
563 |
- pytest.param( |
|
564 |
- "", |
|
565 |
- ["-p", "--", DUMMY_SERVICE], |
|
566 |
- "Du\u0308sseldorf", |
|
567 |
- "The interactive input passphrase is not NFC-normalized", |
|
568 |
- id="direct-input-NFC", |
|
569 |
- ), |
|
570 |
- pytest.param( |
|
571 |
- textwrap.dedent(r""" |
|
572 |
- [vault] |
|
573 |
- default-unicode-normalization-form = 'NFD' |
|
574 |
- """), |
|
575 |
- ["--import", "-"], |
|
576 |
- json.dumps({ |
|
577 |
- "global": { |
|
578 |
- "phrase": "D\u00fcsseldorf", |
|
579 |
- }, |
|
580 |
- "services": {}, |
|
581 |
- }), |
|
582 |
- "The $.global passphrase is not NFD-normalized", |
|
583 |
- id="global-NFD", |
|
584 |
- ), |
|
585 |
- pytest.param( |
|
586 |
- textwrap.dedent(r""" |
|
587 |
- [vault] |
|
588 |
- default-unicode-normalization-form = 'NFD' |
|
589 |
- """), |
|
590 |
- ["--import", "-"], |
|
591 |
- json.dumps({ |
|
592 |
- "services": { |
|
593 |
- DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
594 |
- "weird entry name": {"phrase": "D\u00fcsseldorf"}, |
|
595 |
- }, |
|
596 |
- }), |
|
597 |
- ( |
|
598 |
- 'The $.services["weird entry name"] passphrase ' |
|
599 |
- "is not NFD-normalized" |
|
600 |
- ), |
|
601 |
- id="service-weird-name-NFD", |
|
602 |
- ), |
|
603 |
- pytest.param( |
|
604 |
- textwrap.dedent(r""" |
|
605 |
- [vault.unicode-normalization-form] |
|
606 |
- 'weird entry name 2' = 'NFKD' |
|
607 |
- """), |
|
608 |
- ["--import", "-"], |
|
609 |
- json.dumps({ |
|
610 |
- "services": { |
|
611 |
- DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
612 |
- "weird entry name 1": {"phrase": "D\u00fcsseldorf"}, |
|
613 |
- "weird entry name 2": {"phrase": "D\u00fcsseldorf"}, |
|
614 |
- }, |
|
615 |
- }), |
|
616 |
- ( |
|
617 |
- 'The $.services["weird entry name 2"] passphrase ' |
|
618 |
- "is not NFKD-normalized" |
|
619 |
- ), |
|
620 |
- id="service-weird-name-2-NFKD", |
|
621 |
- ), |
|
622 |
- ], |
|
623 |
- ) |
|
624 |
- MODERN_EDITOR_INTERFACE = pytest.mark.parametrize( |
|
625 |
- "modern_editor_interface", [False, True], ids=["legacy", "modern"] |
|
626 |
- ) |
|
627 |
- NOTES_PLACEMENT = pytest.mark.parametrize( |
|
628 |
- ["notes_placement", "placement_args"], |
|
629 |
- [ |
|
630 |
- pytest.param("after", ["--print-notes-after"], id="after"), |
|
631 |
- pytest.param("before", ["--print-notes-before"], id="before"), |
|
632 |
- ], |
|
633 |
- ) |
|
634 |
- VAULT_CHARSET_OPTION = pytest.mark.parametrize( |
|
635 |
- "option", |
|
636 |
- [ |
|
637 |
- "--lower", |
|
638 |
- "--upper", |
|
639 |
- "--number", |
|
640 |
- "--space", |
|
641 |
- "--dash", |
|
642 |
- "--symbol", |
|
643 |
- "--repeat", |
|
644 |
- "--length", |
|
645 |
- ], |
|
646 |
- ) |
|
647 |
- OPTION_COMBINATIONS_INCOMPATIBLE = pytest.mark.parametrize( |
|
648 |
- ["options", "service"], |
|
649 |
- [ |
|
650 |
- pytest.param(o.options, o.needs_service, id=" ".join(o.options)) |
|
651 |
- for o in INTERESTING_OPTION_COMBINATIONS |
|
652 |
- if o.incompatible |
|
653 |
- ], |
|
654 |
- ) |
|
655 |
- OPTION_COMBINATIONS_SERVICE_NEEDED = pytest.mark.parametrize( |
|
656 |
- ["options", "service", "input", "check_success"], |
|
657 |
- [ |
|
658 |
- pytest.param( |
|
659 |
- o.options, |
|
660 |
- o.needs_service, |
|
661 |
- o.input, |
|
662 |
- o.check_success, |
|
663 |
- id=" ".join(o.options), |
|
664 |
- ) |
|
665 |
- for o in INTERESTING_OPTION_COMBINATIONS |
|
666 |
- if not o.incompatible |
|
667 |
- ], |
|
668 |
- ) |
|
669 |
- TRY_RACE_FREE_IMPLEMENTATION = pytest.mark.parametrize( |
|
670 |
- "try_race_free_implementation", [True, False] |
|
671 |
- ) |
|
672 |
- |
|
673 |
- |
|
674 |
-class TestCLI: |
|
675 |
- """Tests for the `derivepassphrase vault` command-line interface.""" |
|
676 |
- |
|
677 |
- def test_200_help_output( |
|
678 |
- self, |
|
679 |
- ) -> None: |
|
680 |
- """The `--help` option emits help text.""" |
|
681 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
682 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
683 |
- # with-statements. |
|
684 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
685 |
- with contextlib.ExitStack() as stack: |
|
686 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
687 |
- stack.enter_context( |
|
688 |
- pytest_machinery.isolated_config( |
|
689 |
- monkeypatch=monkeypatch, |
|
690 |
- runner=runner, |
|
691 |
- ) |
|
692 |
- ) |
|
693 |
- result = runner.invoke( |
|
694 |
- cli.derivepassphrase_vault, |
|
695 |
- ["--help"], |
|
696 |
- catch_exceptions=False, |
|
697 |
- ) |
|
698 |
- assert result.clean_exit( |
|
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" |
|
704 |
- |
|
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( |
|
708 |
- self, |
|
709 |
- ) -> None: |
|
710 |
- """The `--version` option emits version information.""" |
|
711 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
712 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
713 |
- # with-statements. |
|
714 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
715 |
- with contextlib.ExitStack() as stack: |
|
716 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
717 |
- stack.enter_context( |
|
718 |
- pytest_machinery.isolated_config( |
|
719 |
- monkeypatch=monkeypatch, |
|
720 |
- runner=runner, |
|
721 |
- ) |
|
722 |
- ) |
|
723 |
- result = runner.invoke( |
|
724 |
- cli.derivepassphrase_vault, |
|
725 |
- ["--version"], |
|
726 |
- catch_exceptions=False, |
|
727 |
- ) |
|
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 |
- ) |
|
734 |
- |
|
735 |
- @Parametrize.CHARSET_NAME |
|
736 |
- def test_201_disable_character_set( |
|
737 |
- self, |
|
738 |
- charset_name: str, |
|
739 |
- ) -> None: |
|
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") |
|
743 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
744 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
745 |
- # with-statements. |
|
746 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
747 |
- with contextlib.ExitStack() as stack: |
|
748 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
749 |
- stack.enter_context( |
|
750 |
- pytest_machinery.isolated_config( |
|
751 |
- monkeypatch=monkeypatch, |
|
752 |
- runner=runner, |
|
753 |
- ) |
|
754 |
- ) |
|
755 |
- monkeypatch.setattr( |
|
756 |
- cli_helpers, |
|
757 |
- "prompt_for_passphrase", |
|
758 |
- callables.auto_prompt, |
|
759 |
- ) |
|
760 |
- result = runner.invoke( |
|
761 |
- cli.derivepassphrase_vault, |
|
762 |
- [option, "0", "-p", "--", DUMMY_SERVICE], |
|
763 |
- input=DUMMY_PASSPHRASE, |
|
764 |
- catch_exceptions=False, |
|
765 |
- ) |
|
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 |
- ) |
|
771 |
- |
|
772 |
- def test_202_disable_repetition( |
|
773 |
- self, |
|
774 |
- ) -> None: |
|
775 |
- """Character repetition can be disabled on the command-line.""" |
|
776 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
777 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
778 |
- # with-statements. |
|
779 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
780 |
- with contextlib.ExitStack() as stack: |
|
781 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
782 |
- stack.enter_context( |
|
783 |
- pytest_machinery.isolated_config( |
|
784 |
- monkeypatch=monkeypatch, |
|
785 |
- runner=runner, |
|
786 |
- ) |
|
787 |
- ) |
|
788 |
- monkeypatch.setattr( |
|
789 |
- cli_helpers, |
|
790 |
- "prompt_for_passphrase", |
|
791 |
- callables.auto_prompt, |
|
792 |
- ) |
|
793 |
- result = runner.invoke( |
|
794 |
- cli.derivepassphrase_vault, |
|
795 |
- ["--repeat", "0", "-p", "--", DUMMY_SERVICE], |
|
796 |
- input=DUMMY_PASSPHRASE, |
|
797 |
- catch_exceptions=False, |
|
798 |
- ) |
|
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 |
- ) |
|
808 |
- |
|
809 |
- @Parametrize.CONFIG_WITH_KEY |
|
810 |
- def test_204a_key_from_config( |
|
811 |
- self, |
|
812 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
813 |
- config: _types.VaultConfig, |
|
814 |
- ) -> None: |
|
815 |
- """A stored configured SSH key will be used.""" |
|
816 |
- del running_ssh_agent |
|
817 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
818 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
819 |
- # with-statements. |
|
820 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
821 |
- with contextlib.ExitStack() as stack: |
|
822 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
823 |
- stack.enter_context( |
|
824 |
- pytest_machinery.isolated_vault_config( |
|
825 |
- monkeypatch=monkeypatch, |
|
826 |
- runner=runner, |
|
827 |
- vault_config=config, |
|
828 |
- ) |
|
829 |
- ) |
|
830 |
- monkeypatch.setattr( |
|
831 |
- vault.Vault, |
|
832 |
- "phrase_from_key", |
|
833 |
- callables.phrase_from_key, |
|
834 |
- ) |
|
835 |
- result = runner.invoke( |
|
836 |
- cli.derivepassphrase_vault, |
|
837 |
- ["--", DUMMY_SERVICE], |
|
838 |
- catch_exceptions=False, |
|
839 |
- ) |
|
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" |
|
851 |
- |
|
852 |
- def test_204b_key_from_command_line( |
|
853 |
- self, |
|
854 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
855 |
- ) -> None: |
|
856 |
- """An SSH key requested on the command-line will be used.""" |
|
857 |
- del running_ssh_agent |
|
858 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
859 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
860 |
- # with-statements. |
|
861 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
862 |
- with contextlib.ExitStack() as stack: |
|
863 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
864 |
- stack.enter_context( |
|
865 |
- pytest_machinery.isolated_vault_config( |
|
866 |
- monkeypatch=monkeypatch, |
|
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, |
|
877 |
- ) |
|
878 |
- monkeypatch.setattr( |
|
879 |
- vault.Vault, |
|
880 |
- "phrase_from_key", |
|
881 |
- callables.phrase_from_key, |
|
882 |
- ) |
|
883 |
- result = runner.invoke( |
|
884 |
- cli.derivepassphrase_vault, |
|
885 |
- ["-k", "--", DUMMY_SERVICE], |
|
886 |
- input="1\n", |
|
887 |
- catch_exceptions=False, |
|
888 |
- ) |
|
889 |
- assert result.clean_exit(), "expected clean exit" |
|
890 |
- assert result.stdout, "expected program output" |
|
891 |
- last_line = result.stdout.splitlines(True)[-1] |
|
892 |
- assert ( |
|
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" |
|
897 |
- ) |
|
898 |
- |
|
899 |
- @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS |
|
900 |
- @Parametrize.KEY_INDEX |
|
901 |
- def test_204c_key_override_on_command_line( |
|
902 |
- self, |
|
903 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
904 |
- config: dict[str, Any], |
|
905 |
- key_index: int, |
|
906 |
- ) -> None: |
|
907 |
- """A command-line SSH key will override the configured key.""" |
|
908 |
- del running_ssh_agent |
|
909 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
910 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
911 |
- # with-statements. |
|
912 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
913 |
- with contextlib.ExitStack() as stack: |
|
914 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
915 |
- stack.enter_context( |
|
916 |
- pytest_machinery.isolated_vault_config( |
|
917 |
- monkeypatch=monkeypatch, |
|
918 |
- runner=runner, |
|
919 |
- vault_config=config, |
|
920 |
- ) |
|
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 |
|
929 |
- ) |
|
930 |
- result = runner.invoke( |
|
931 |
- cli.derivepassphrase_vault, |
|
932 |
- ["-k", "--", DUMMY_SERVICE], |
|
933 |
- input=f"{key_index}\n", |
|
934 |
- ) |
|
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( |
|
943 |
- self, |
|
944 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
945 |
- ) -> None: |
|
946 |
- """A command-line passphrase will override the configured key.""" |
|
947 |
- del running_ssh_agent |
|
948 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
949 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
950 |
- # with-statements. |
|
951 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
952 |
- with contextlib.ExitStack() as stack: |
|
953 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
954 |
- stack.enter_context( |
|
955 |
- pytest_machinery.isolated_vault_config( |
|
956 |
- monkeypatch=monkeypatch, |
|
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 |
- ) |
|
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 |
|
976 |
- ) |
|
977 |
- result = runner.invoke( |
|
978 |
- cli.derivepassphrase_vault, |
|
979 |
- ["--", DUMMY_SERVICE], |
|
980 |
- catch_exceptions=False, |
|
981 |
- ) |
|
982 |
- assert result.clean_exit(), "expected clean exit" |
|
983 |
- assert result.stdout, "expected program output" |
|
984 |
- last_line = result.stdout.splitlines(True)[-1] |
|
985 |
- assert ( |
|
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" |
|
990 |
- ) |
|
991 |
- |
|
992 |
- @Parametrize.KEY_OVERRIDING_IN_CONFIG |
|
993 |
- def test_206_setting_phrase_thus_overriding_key_in_config( |
|
994 |
- self, |
|
995 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
996 |
- caplog: pytest.LogCaptureFixture, |
|
997 |
- config: _types.VaultConfig, |
|
998 |
- command_line: list[str], |
|
999 |
- ) -> None: |
|
1000 |
- """Configuring a passphrase atop an SSH key works, but warns.""" |
|
1001 |
- del running_ssh_agent |
|
1002 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1003 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1004 |
- # with-statements. |
|
1005 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1006 |
- with contextlib.ExitStack() as stack: |
|
1007 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1008 |
- stack.enter_context( |
|
1009 |
- pytest_machinery.isolated_vault_config( |
|
1010 |
- monkeypatch=monkeypatch, |
|
1011 |
- runner=runner, |
|
1012 |
- vault_config=config, |
|
1013 |
- ) |
|
1014 |
- ) |
|
1015 |
- monkeypatch.setattr( |
|
1016 |
- ssh_agent.SSHAgentClient, |
|
1017 |
- "list_keys", |
|
1018 |
- callables.list_keys, |
|
1019 |
- ) |
|
1020 |
- monkeypatch.setattr( |
|
1021 |
- ssh_agent.SSHAgentClient, "sign", callables.sign |
|
1022 |
- ) |
|
1023 |
- result = runner.invoke( |
|
1024 |
- cli.derivepassphrase_vault, |
|
1025 |
- command_line, |
|
1026 |
- input=DUMMY_PASSPHRASE, |
|
1027 |
- catch_exceptions=False, |
|
1028 |
- ) |
|
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" |
|
1045 |
- |
|
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( |
|
1057 |
- self, |
|
1058 |
- notes: str, |
|
1059 |
- ) -> None: |
|
1060 |
- """Service notes are printed, if they exist.""" |
|
1061 |
- hypothesis.assume("Error:" not in notes) |
|
1062 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1063 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1064 |
- # with-statements. |
|
1065 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1066 |
- with contextlib.ExitStack() as stack: |
|
1067 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1068 |
- stack.enter_context( |
|
1069 |
- pytest_machinery.isolated_vault_config( |
|
1070 |
- monkeypatch=monkeypatch, |
|
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 |
- }, |
|
1083 |
- ) |
|
1084 |
- ) |
|
1085 |
- result = runner.invoke( |
|
1086 |
- cli.derivepassphrase_vault, |
|
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" |
|
1100 |
- ) |
|
1101 |
- |
|
1102 |
- @Parametrize.VAULT_CHARSET_OPTION |
|
1103 |
- def test_210_invalid_argument_range( |
|
1104 |
- self, |
|
1105 |
- option: str, |
|
1106 |
- ) -> None: |
|
1107 |
- """Requesting invalidly many characters from a class fails.""" |
|
1108 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1109 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1110 |
- # with-statements. |
|
1111 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1112 |
- with contextlib.ExitStack() as stack: |
|
1113 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1114 |
- stack.enter_context( |
|
1115 |
- pytest_machinery.isolated_config( |
|
1116 |
- monkeypatch=monkeypatch, |
|
1117 |
- runner=runner, |
|
1118 |
- ) |
|
1119 |
- ) |
|
1120 |
- for value in "-42", "invalid": |
|
1121 |
- result = runner.invoke( |
|
1122 |
- cli.derivepassphrase_vault, |
|
1123 |
- [option, value, "-p", "--", DUMMY_SERVICE], |
|
1124 |
- input=DUMMY_PASSPHRASE, |
|
1125 |
- catch_exceptions=False, |
|
1126 |
- ) |
|
1127 |
- assert result.error_exit(error="Invalid value"), ( |
|
1128 |
- "expected error exit and known error message" |
|
1129 |
- ) |
|
1130 |
- |
|
1131 |
- @Parametrize.OPTION_COMBINATIONS_SERVICE_NEEDED |
|
1132 |
- def test_211_service_needed( |
|
1133 |
- self, |
|
1134 |
- options: list[str], |
|
1135 |
- service: bool | None, |
|
1136 |
- input: str | None, |
|
1137 |
- check_success: bool, |
|
1138 |
- ) -> None: |
|
1139 |
- """We require or forbid a service argument, depending on options.""" |
|
1140 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1141 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1142 |
- # with-statements. |
|
1143 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1144 |
- with contextlib.ExitStack() as stack: |
|
1145 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1146 |
- stack.enter_context( |
|
1147 |
- pytest_machinery.isolated_vault_config( |
|
1148 |
- monkeypatch=monkeypatch, |
|
1149 |
- runner=runner, |
|
1150 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
1151 |
- ) |
|
1152 |
- ) |
|
1153 |
- monkeypatch.setattr( |
|
1154 |
- cli_helpers, |
|
1155 |
- "prompt_for_passphrase", |
|
1156 |
- callables.auto_prompt, |
|
1157 |
- ) |
|
1158 |
- result = runner.invoke( |
|
1159 |
- cli.derivepassphrase_vault, |
|
1160 |
- options if service else [*options, "--", DUMMY_SERVICE], |
|
1161 |
- input=input, |
|
1162 |
- catch_exceptions=False, |
|
1163 |
- ) |
|
1164 |
- if service is not None: |
|
1165 |
- err_msg = ( |
|
1166 |
- " requires a SERVICE" |
|
1167 |
- if service |
|
1168 |
- else " does not take a SERVICE argument" |
|
1169 |
- ) |
|
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: |
|
1178 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1179 |
- # with-statements. |
|
1180 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1181 |
- with contextlib.ExitStack() as stack: |
|
1182 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1183 |
- stack.enter_context( |
|
1184 |
- pytest_machinery.isolated_vault_config( |
|
1185 |
- monkeypatch=monkeypatch, |
|
1186 |
- runner=runner, |
|
1187 |
- vault_config={ |
|
1188 |
- "global": {"phrase": "abc"}, |
|
1189 |
- "services": {}, |
|
1190 |
- }, |
|
1191 |
- ) |
|
1192 |
- ) |
|
1193 |
- monkeypatch.setattr( |
|
1194 |
- cli_helpers, |
|
1195 |
- "prompt_for_passphrase", |
|
1196 |
- callables.auto_prompt, |
|
1197 |
- ) |
|
1198 |
- result = runner.invoke( |
|
1199 |
- cli.derivepassphrase_vault, |
|
1200 |
- [*options, "--", DUMMY_SERVICE] if service else options, |
|
1201 |
- input=input, |
|
1202 |
- catch_exceptions=False, |
|
1203 |
- ) |
|
1204 |
- assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
1205 |
- |
|
1206 |
- def test_211a_empty_service_name_causes_warning( |
|
1207 |
- self, |
|
1208 |
- caplog: pytest.LogCaptureFixture, |
|
1209 |
- ) -> None: |
|
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 |
- |
|
1223 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1224 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1225 |
- # with-statements. |
|
1226 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1227 |
- with contextlib.ExitStack() as stack: |
|
1228 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1229 |
- stack.enter_context( |
|
1230 |
- pytest_machinery.isolated_vault_config( |
|
1231 |
- monkeypatch=monkeypatch, |
|
1232 |
- runner=runner, |
|
1233 |
- vault_config={"services": {}}, |
|
1234 |
- ) |
|
1235 |
- ) |
|
1236 |
- monkeypatch.setattr( |
|
1237 |
- cli_helpers, |
|
1238 |
- "prompt_for_passphrase", |
|
1239 |
- callables.auto_prompt, |
|
1240 |
- ) |
|
1241 |
- result = runner.invoke( |
|
1242 |
- cli.derivepassphrase_vault, |
|
1243 |
- ["--config", "--length=30", "--", ""], |
|
1244 |
- catch_exceptions=False, |
|
1245 |
- ) |
|
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" |
|
1250 |
- ) |
|
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" |
|
1271 |
- |
|
1272 |
- @Parametrize.OPTION_COMBINATIONS_INCOMPATIBLE |
|
1273 |
- def test_212_incompatible_options( |
|
1274 |
- self, |
|
1275 |
- options: list[str], |
|
1276 |
- service: bool | None, |
|
1277 |
- ) -> None: |
|
1278 |
- """Incompatible options are detected.""" |
|
1279 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1280 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1281 |
- # with-statements. |
|
1282 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1283 |
- with contextlib.ExitStack() as stack: |
|
1284 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1285 |
- stack.enter_context( |
|
1286 |
- pytest_machinery.isolated_config( |
|
1287 |
- monkeypatch=monkeypatch, |
|
1288 |
- runner=runner, |
|
1289 |
- ) |
|
1290 |
- ) |
|
1291 |
- result = runner.invoke( |
|
1292 |
- cli.derivepassphrase_vault, |
|
1293 |
- [*options, "--", DUMMY_SERVICE] if service else options, |
|
1294 |
- input=DUMMY_PASSPHRASE, |
|
1295 |
- catch_exceptions=False, |
|
1296 |
- ) |
|
1297 |
- assert result.error_exit(error="mutually exclusive with "), ( |
|
1298 |
- "expected error exit and known error message" |
|
1299 |
- ) |
|
1300 |
- |
|
1301 |
- @Parametrize.VALID_TEST_CONFIGS |
|
1302 |
- def test_213_import_config_success( |
|
1303 |
- self, |
|
1304 |
- caplog: pytest.LogCaptureFixture, |
|
1305 |
- config: Any, |
|
1306 |
- ) -> None: |
|
1307 |
- """Importing a configuration works.""" |
|
1308 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1309 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1310 |
- # with-statements. |
|
1311 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1312 |
- with contextlib.ExitStack() as stack: |
|
1313 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1314 |
- stack.enter_context( |
|
1315 |
- pytest_machinery.isolated_vault_config( |
|
1316 |
- monkeypatch=monkeypatch, |
|
1317 |
- runner=runner, |
|
1318 |
- vault_config={"services": {}}, |
|
1319 |
- ) |
|
1320 |
- ) |
|
1321 |
- result = runner.invoke( |
|
1322 |
- cli.derivepassphrase_vault, |
|
1323 |
- ["--import", "-"], |
|
1324 |
- input=json.dumps(config), |
|
1325 |
- catch_exceptions=False, |
|
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) |
|
1337 |
- |
|
1338 |
- @hypothesis.settings( |
|
1339 |
- suppress_health_check=[ |
|
1340 |
- *hypothesis.settings().suppress_health_check, |
|
1341 |
- hypothesis.HealthCheck.function_scoped_fixture, |
|
1342 |
- ], |
|
1343 |
- ) |
|
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 |
- ]) |
|
1349 |
- ) |
|
1350 |
- ) |
|
1351 |
- def test_213a_import_config_success( |
|
1352 |
- self, |
|
1353 |
- caplog: pytest.LogCaptureFixture, |
|
1354 |
- conf: data.VaultTestConfig, |
|
1355 |
- ) -> None: |
|
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() |
|
1366 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1367 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1368 |
- # with-statements. |
|
1369 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1370 |
- with contextlib.ExitStack() as stack: |
|
1371 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1372 |
- stack.enter_context( |
|
1373 |
- pytest_machinery.isolated_vault_config( |
|
1374 |
- monkeypatch=monkeypatch, |
|
1375 |
- runner=runner, |
|
1376 |
- vault_config={"services": {}}, |
|
1377 |
- ) |
|
1378 |
- ) |
|
1379 |
- result = runner.invoke( |
|
1380 |
- cli.derivepassphrase_vault, |
|
1381 |
- ["--import", "-"], |
|
1382 |
- input=json.dumps(config), |
|
1383 |
- catch_exceptions=False, |
|
1384 |
- ) |
|
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( |
|
1392 |
- map(is_harmless_config_import_warning, caplog.record_tuples) |
|
1393 |
- ), "unexpected error output" |
|
1394 |
- assert_vault_config_is_indented_and_line_broken(config_txt) |
|
1395 |
- |
|
1396 |
- def test_213b_import_bad_config_not_vault_config( |
|
1397 |
- self, |
|
1398 |
- ) -> None: |
|
1399 |
- """Importing an invalid config fails.""" |
|
1400 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1401 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1402 |
- # with-statements. |
|
1403 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1404 |
- with contextlib.ExitStack() as stack: |
|
1405 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1406 |
- stack.enter_context( |
|
1407 |
- pytest_machinery.isolated_config( |
|
1408 |
- monkeypatch=monkeypatch, |
|
1409 |
- runner=runner, |
|
1410 |
- ) |
|
1411 |
- ) |
|
1412 |
- result = runner.invoke( |
|
1413 |
- cli.derivepassphrase_vault, |
|
1414 |
- ["--import", "-"], |
|
1415 |
- input="null", |
|
1416 |
- catch_exceptions=False, |
|
1417 |
- ) |
|
1418 |
- assert result.error_exit(error="Invalid vault config"), ( |
|
1419 |
- "expected error exit and known error message" |
|
1420 |
- ) |
|
1421 |
- |
|
1422 |
- def test_213c_import_bad_config_not_json_data( |
|
1423 |
- self, |
|
1424 |
- ) -> None: |
|
1425 |
- """Importing an invalid config fails.""" |
|
1426 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1427 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1428 |
- # with-statements. |
|
1429 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1430 |
- with contextlib.ExitStack() as stack: |
|
1431 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1432 |
- stack.enter_context( |
|
1433 |
- pytest_machinery.isolated_config( |
|
1434 |
- monkeypatch=monkeypatch, |
|
1435 |
- runner=runner, |
|
1436 |
- ) |
|
1437 |
- ) |
|
1438 |
- result = runner.invoke( |
|
1439 |
- cli.derivepassphrase_vault, |
|
1440 |
- ["--import", "-"], |
|
1441 |
- input="This string is not valid JSON.", |
|
1442 |
- catch_exceptions=False, |
|
1443 |
- ) |
|
1444 |
- assert result.error_exit(error="cannot decode JSON"), ( |
|
1445 |
- "expected error exit and known error message" |
|
1446 |
- ) |
|
1447 |
- |
|
1448 |
- def test_213d_import_bad_config_not_a_file( |
|
1449 |
- self, |
|
1450 |
- ) -> None: |
|
1451 |
- """Importing an invalid config fails.""" |
|
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 |
- # |
|
1458 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1459 |
- # with-statements. |
|
1460 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1461 |
- with contextlib.ExitStack() as stack: |
|
1462 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1463 |
- stack.enter_context( |
|
1464 |
- pytest_machinery.isolated_vault_config( |
|
1465 |
- monkeypatch=monkeypatch, |
|
1466 |
- runner=runner, |
|
1467 |
- vault_config={"services": {}}, |
|
1468 |
- ) |
|
1469 |
- ) |
|
1470 |
- cli_helpers.config_filename(subsystem="vault").write_text( |
|
1471 |
- "This string is not valid JSON.\n", encoding="UTF-8" |
|
1472 |
- ) |
|
1473 |
- dname = cli_helpers.config_filename(subsystem=None) |
|
1474 |
- result = runner.invoke( |
|
1475 |
- cli.derivepassphrase_vault, |
|
1476 |
- ["--import", os.fsdecode(dname)], |
|
1477 |
- catch_exceptions=False, |
|
1478 |
- ) |
|
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)), ( |
|
1483 |
- "expected error exit and known error message" |
|
1484 |
- ) |
|
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) |
|
1494 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1495 |
- # with-statements. |
|
1496 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1497 |
- with contextlib.ExitStack() as stack: |
|
1498 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1499 |
- stack.enter_context( |
|
1500 |
- pytest_machinery.isolated_vault_config( |
|
1501 |
- monkeypatch=monkeypatch, |
|
1502 |
- runner=runner, |
|
1503 |
- vault_config=config, |
|
1504 |
- ) |
|
1505 |
- ) |
|
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) |
|
1511 |
- result = runner.invoke( |
|
1512 |
- cli.derivepassphrase_vault, |
|
1513 |
- ["--export", "-"], |
|
1514 |
- catch_exceptions=False, |
|
1515 |
- ) |
|
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) |
|
1526 |
- |
|
1527 |
- @Parametrize.EXPORT_FORMAT_OPTIONS |
|
1528 |
- def test_214a_export_settings_no_stored_settings( |
|
1529 |
- self, |
|
1530 |
- export_options: list[str], |
|
1531 |
- ) -> None: |
|
1532 |
- """Exporting the default, empty config works.""" |
|
1533 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1534 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1535 |
- # with-statements. |
|
1536 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1537 |
- with contextlib.ExitStack() as stack: |
|
1538 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1539 |
- stack.enter_context( |
|
1540 |
- pytest_machinery.isolated_config( |
|
1541 |
- monkeypatch=monkeypatch, |
|
1542 |
- runner=runner, |
|
1543 |
- ) |
|
1544 |
- ) |
|
1545 |
- cli_helpers.config_filename(subsystem="vault").unlink( |
|
1546 |
- missing_ok=True |
|
1547 |
- ) |
|
1548 |
- result = runner.invoke( |
|
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], |
|
1555 |
- catch_exceptions=False, |
|
1556 |
- ) |
|
1557 |
- assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
1558 |
- |
|
1559 |
- @Parametrize.EXPORT_FORMAT_OPTIONS |
|
1560 |
- def test_214b_export_settings_bad_stored_config( |
|
1561 |
- self, |
|
1562 |
- export_options: list[str], |
|
1563 |
- ) -> None: |
|
1564 |
- """Exporting an invalid config fails.""" |
|
1565 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1566 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1567 |
- # with-statements. |
|
1568 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1569 |
- with contextlib.ExitStack() as stack: |
|
1570 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1571 |
- stack.enter_context( |
|
1572 |
- pytest_machinery.isolated_vault_config( |
|
1573 |
- monkeypatch=monkeypatch, |
|
1574 |
- runner=runner, |
|
1575 |
- vault_config={}, |
|
1576 |
- ) |
|
1577 |
- ) |
|
1578 |
- result = runner.invoke( |
|
1579 |
- cli.derivepassphrase_vault, |
|
1580 |
- ["--export", "-", *export_options], |
|
1581 |
- input="null", |
|
1582 |
- catch_exceptions=False, |
|
1583 |
- ) |
|
1584 |
- assert result.error_exit(error="Cannot load vault settings:"), ( |
|
1585 |
- "expected error exit and known error message" |
|
1586 |
- ) |
|
1587 |
- |
|
1588 |
- @Parametrize.EXPORT_FORMAT_OPTIONS |
|
1589 |
- def test_214c_export_settings_not_a_file( |
|
1590 |
- self, |
|
1591 |
- export_options: list[str], |
|
1592 |
- ) -> None: |
|
1593 |
- """Exporting an invalid config fails.""" |
|
1594 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1595 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1596 |
- # with-statements. |
|
1597 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1598 |
- with contextlib.ExitStack() as stack: |
|
1599 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1600 |
- stack.enter_context( |
|
1601 |
- pytest_machinery.isolated_config( |
|
1602 |
- monkeypatch=monkeypatch, |
|
1603 |
- runner=runner, |
|
1604 |
- ) |
|
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) |
|
1609 |
- result = runner.invoke( |
|
1610 |
- cli.derivepassphrase_vault, |
|
1611 |
- ["--export", "-", *export_options], |
|
1612 |
- input="null", |
|
1613 |
- catch_exceptions=False, |
|
1614 |
- ) |
|
1615 |
- assert result.error_exit(error="Cannot load vault settings:"), ( |
|
1616 |
- "expected error exit and known error message" |
|
1617 |
- ) |
|
1618 |
- |
|
1619 |
- @Parametrize.EXPORT_FORMAT_OPTIONS |
|
1620 |
- def test_214d_export_settings_target_not_a_file( |
|
1621 |
- self, |
|
1622 |
- export_options: list[str], |
|
1623 |
- ) -> None: |
|
1624 |
- """Exporting an invalid config fails.""" |
|
1625 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1626 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1627 |
- # with-statements. |
|
1628 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1629 |
- with contextlib.ExitStack() as stack: |
|
1630 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1631 |
- stack.enter_context( |
|
1632 |
- pytest_machinery.isolated_config( |
|
1633 |
- monkeypatch=monkeypatch, |
|
1634 |
- runner=runner, |
|
1635 |
- ) |
|
1636 |
- ) |
|
1637 |
- dname = cli_helpers.config_filename(subsystem=None) |
|
1638 |
- result = runner.invoke( |
|
1639 |
- cli.derivepassphrase_vault, |
|
1640 |
- ["--export", os.fsdecode(dname), *export_options], |
|
1641 |
- input="null", |
|
1642 |
- catch_exceptions=False, |
|
1643 |
- ) |
|
1644 |
- assert result.error_exit(error="Cannot export vault settings:"), ( |
|
1645 |
- "expected error exit and known error message" |
|
1646 |
- ) |
|
1647 |
- |
|
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( |
|
1651 |
- self, |
|
1652 |
- export_options: list[str], |
|
1653 |
- ) -> None: |
|
1654 |
- """Exporting an invalid config fails.""" |
|
1655 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1656 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1657 |
- # with-statements. |
|
1658 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1659 |
- with contextlib.ExitStack() as stack: |
|
1660 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1661 |
- stack.enter_context( |
|
1662 |
- pytest_machinery.isolated_config( |
|
1663 |
- monkeypatch=monkeypatch, |
|
1664 |
- runner=runner, |
|
1665 |
- ) |
|
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") |
|
1671 |
- result = runner.invoke( |
|
1672 |
- cli.derivepassphrase_vault, |
|
1673 |
- ["--export", "-", *export_options], |
|
1674 |
- input="null", |
|
1675 |
- catch_exceptions=False, |
|
1676 |
- ) |
|
1677 |
- assert result.error_exit( |
|
1678 |
- error="Cannot load vault settings:" |
|
1679 |
- ) or result.error_exit(error="Cannot load user config:"), ( |
|
1680 |
- "expected error exit and known error message" |
|
1681 |
- ) |
|
1682 |
- |
|
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( |
|
1694 |
- self, |
|
1695 |
- notes_placement: Literal["before", "after"], |
|
1696 |
- placement_args: list[str], |
|
1697 |
- notes: str, |
|
1698 |
- ) -> None: |
|
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) |
|
1714 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1715 |
- # with-statements. |
|
1716 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1717 |
- with contextlib.ExitStack() as stack: |
|
1718 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1719 |
- stack.enter_context( |
|
1720 |
- pytest_machinery.isolated_vault_config( |
|
1721 |
- monkeypatch=monkeypatch, |
|
1722 |
- runner=runner, |
|
1723 |
- vault_config=vault_config, |
|
1724 |
- ) |
|
1725 |
- ) |
|
1726 |
- result = runner.invoke( |
|
1727 |
- cli.derivepassphrase_vault, |
|
1728 |
- [*placement_args, "--", DUMMY_SERVICE], |
|
1729 |
- catch_exceptions=False, |
|
1730 |
- ) |
|
1731 |
- assert result.clean_exit(output=expected), "expected clean exit" |
|
1732 |
- |
|
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 |
- ], |
|
1739 |
- ) |
|
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), |
|
1748 |
- ) |
|
1749 |
- def test_220_edit_notes_successfully( |
|
1750 |
- self, |
|
1751 |
- caplog: pytest.LogCaptureFixture, |
|
1752 |
- modern_editor_interface: bool, |
|
1753 |
- notes: str, |
|
1754 |
- ) -> None: |
|
1755 |
- """Editing notes works.""" |
|
1756 |
- marker = cli_messages.TranslatedString( |
|
1757 |
- cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER |
|
1758 |
- ) |
|
1759 |
- edit_result = f""" |
|
1760 |
- |
|
1761 |
-{marker} |
|
1762 |
-{notes} |
|
1763 |
-""" |
|
1764 |
- # Reset caplog between hypothesis runs. |
|
1765 |
- caplog.clear() |
|
1766 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1767 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1768 |
- # with-statements. |
|
1769 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1770 |
- with contextlib.ExitStack() as stack: |
|
1771 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1772 |
- stack.enter_context( |
|
1773 |
- pytest_machinery.isolated_vault_config( |
|
1774 |
- monkeypatch=monkeypatch, |
|
1775 |
- runner=runner, |
|
1776 |
- vault_config={ |
|
1777 |
- "global": {"phrase": "abc"}, |
|
1778 |
- "services": {"sv": {"notes": "Contents go here"}}, |
|
1779 |
- }, |
|
1780 |
- ) |
|
1781 |
- ) |
|
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) |
|
1790 |
- result = runner.invoke( |
|
1791 |
- cli.derivepassphrase_vault, |
|
1792 |
- [ |
|
1793 |
- "--config", |
|
1794 |
- "--notes", |
|
1795 |
- "--modern-editor-interface" |
|
1796 |
- if modern_editor_interface |
|
1797 |
- else "--vault-legacy-editor-interface", |
|
1798 |
- "--", |
|
1799 |
- "sv", |
|
1800 |
- ], |
|
1801 |
- catch_exceptions=False, |
|
1802 |
- ) |
|
1803 |
- assert result.clean_exit(), "expected clean exit" |
|
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" |
|
1813 |
- ) |
|
1814 |
- with cli_helpers.config_filename(subsystem="vault").open( |
|
1815 |
- encoding="UTF-8" |
|
1816 |
- ) as infile: |
|
1817 |
- config = json.load(infile) |
|
1818 |
- assert config == { |
|
1819 |
- "global": {"phrase": "abc"}, |
|
1820 |
- "services": { |
|
1821 |
- "sv": { |
|
1822 |
- "notes": notes.strip() |
|
1823 |
- if modern_editor_interface |
|
1824 |
- else edit_result.strip() |
|
1825 |
- } |
|
1826 |
- }, |
|
1827 |
- } |
|
1828 |
- |
|
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( |
|
1840 |
- self, |
|
1841 |
- edit_func_name: Literal["empty", "space"], |
|
1842 |
- modern_editor_interface: bool, |
|
1843 |
- notes: str, |
|
1844 |
- ) -> None: |
|
1845 |
- """Abandoning edited notes works.""" |
|
1846 |
- |
|
1847 |
- def empty(text: str, *_args: Any, **_kwargs: Any) -> str: |
|
1848 |
- del text |
|
1849 |
- return "" |
|
1850 |
- |
|
1851 |
- def space(text: str, *_args: Any, **_kwargs: Any) -> str: |
|
1852 |
- del text |
|
1853 |
- return " " + notes.strip() + "\n\n\n\n\n\n" |
|
1854 |
- |
|
1855 |
- edit_funcs = {"empty": empty, "space": space} |
|
1856 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1857 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1858 |
- # with-statements. |
|
1859 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1860 |
- with contextlib.ExitStack() as stack: |
|
1861 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1862 |
- stack.enter_context( |
|
1863 |
- pytest_machinery.isolated_vault_config( |
|
1864 |
- monkeypatch=monkeypatch, |
|
1865 |
- runner=runner, |
|
1866 |
- vault_config={ |
|
1867 |
- "global": {"phrase": "abc"}, |
|
1868 |
- "services": {"sv": {"notes": notes.strip()}}, |
|
1869 |
- }, |
|
1870 |
- ) |
|
1871 |
- ) |
|
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", |
|
1878 |
- ) |
|
1879 |
- monkeypatch.setattr(click, "edit", edit_funcs[edit_func_name]) |
|
1880 |
- result = runner.invoke( |
|
1881 |
- cli.derivepassphrase_vault, |
|
1882 |
- [ |
|
1883 |
- "--config", |
|
1884 |
- "--notes", |
|
1885 |
- "--modern-editor-interface" |
|
1886 |
- if modern_editor_interface |
|
1887 |
- else "--vault-legacy-editor-interface", |
|
1888 |
- "--", |
|
1889 |
- "sv", |
|
1890 |
- ], |
|
1891 |
- catch_exceptions=False, |
|
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." |
|
1900 |
- ) |
|
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 |
- } |
|
1909 |
- |
|
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 |
- ], |
|
1918 |
- ) |
|
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), |
|
1927 |
- ) |
|
1928 |
- def test_222_edit_notes_marker_removed( |
|
1929 |
- self, |
|
1930 |
- caplog: pytest.LogCaptureFixture, |
|
1931 |
- modern_editor_interface: bool, |
|
1932 |
- notes: str, |
|
1933 |
- ) -> None: |
|
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() |
|
1945 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
1946 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1947 |
- # with-statements. |
|
1948 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1949 |
- with contextlib.ExitStack() as stack: |
|
1950 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1951 |
- stack.enter_context( |
|
1952 |
- pytest_machinery.isolated_vault_config( |
|
1953 |
- monkeypatch=monkeypatch, |
|
1954 |
- runner=runner, |
|
1955 |
- vault_config={ |
|
1956 |
- "global": {"phrase": "abc"}, |
|
1957 |
- "services": {"sv": {"notes": "Contents go here"}}, |
|
1958 |
- }, |
|
1959 |
- ) |
|
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) |
|
1969 |
- result = runner.invoke( |
|
1970 |
- cli.derivepassphrase_vault, |
|
1971 |
- [ |
|
1972 |
- "--config", |
|
1973 |
- "--notes", |
|
1974 |
- "--modern-editor-interface" |
|
1975 |
- if modern_editor_interface |
|
1976 |
- else "--vault-legacy-editor-interface", |
|
1977 |
- "--", |
|
1978 |
- "sv", |
|
1979 |
- ], |
|
1980 |
- catch_exceptions=False, |
|
1981 |
- ) |
|
1982 |
- assert result.clean_exit(), "expected clean exit" |
|
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, |
|
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 |
- } |
|
2003 |
- |
|
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( |
|
2014 |
- self, |
|
2015 |
- notes: str, |
|
2016 |
- ) -> None: |
|
2017 |
- """Aborting editing notes works. |
|
2018 |
- |
|
2019 |
- Aborting is only supported with the modern editor interface. |
|
2020 |
- |
|
2021 |
- """ |
|
2022 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2023 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2024 |
- # with-statements. |
|
2025 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2026 |
- with contextlib.ExitStack() as stack: |
|
2027 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2028 |
- stack.enter_context( |
|
2029 |
- pytest_machinery.isolated_vault_config( |
|
2030 |
- monkeypatch=monkeypatch, |
|
2031 |
- runner=runner, |
|
2032 |
- vault_config={ |
|
2033 |
- "global": {"phrase": "abc"}, |
|
2034 |
- "services": {"sv": {"notes": notes.strip()}}, |
|
2035 |
- }, |
|
2036 |
- ) |
|
2037 |
- ) |
|
2038 |
- monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "") |
|
2039 |
- result = runner.invoke( |
|
2040 |
- cli.derivepassphrase_vault, |
|
2041 |
- [ |
|
2042 |
- "--config", |
|
2043 |
- "--notes", |
|
2044 |
- "--modern-editor-interface", |
|
2045 |
- "--", |
|
2046 |
- "sv", |
|
2047 |
- ], |
|
2048 |
- catch_exceptions=False, |
|
2049 |
- ) |
|
2050 |
- assert result.error_exit(error="the user aborted the request"), ( |
|
2051 |
- "expected known error message" |
|
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 |
- } |
|
2061 |
- |
|
2062 |
- def test_223a_edit_empty_notes_abort( |
|
2063 |
- self, |
|
2064 |
- ) -> None: |
|
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 |
- """ |
|
2070 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2071 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2072 |
- # with-statements. |
|
2073 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2074 |
- with contextlib.ExitStack() as stack: |
|
2075 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2076 |
- stack.enter_context( |
|
2077 |
- pytest_machinery.isolated_vault_config( |
|
2078 |
- monkeypatch=monkeypatch, |
|
2079 |
- runner=runner, |
|
2080 |
- vault_config={ |
|
2081 |
- "global": {"phrase": "abc"}, |
|
2082 |
- "services": {}, |
|
2083 |
- }, |
|
2084 |
- ) |
|
2085 |
- ) |
|
2086 |
- monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "") |
|
2087 |
- result = runner.invoke( |
|
2088 |
- cli.derivepassphrase_vault, |
|
2089 |
- [ |
|
2090 |
- "--config", |
|
2091 |
- "--notes", |
|
2092 |
- "--modern-editor-interface", |
|
2093 |
- "--", |
|
2094 |
- "sv", |
|
2095 |
- ], |
|
2096 |
- catch_exceptions=False, |
|
2097 |
- ) |
|
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 |
- } |
|
2109 |
- |
|
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( |
|
2126 |
- self, |
|
2127 |
- caplog: pytest.LogCaptureFixture, |
|
2128 |
- modern_editor_interface: bool, |
|
2129 |
- notes: str, |
|
2130 |
- ) -> None: |
|
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() |
|
2141 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2142 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2143 |
- # with-statements. |
|
2144 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2145 |
- with contextlib.ExitStack() as stack: |
|
2146 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2147 |
- stack.enter_context( |
|
2148 |
- pytest_machinery.isolated_vault_config( |
|
2149 |
- monkeypatch=monkeypatch, |
|
2150 |
- runner=runner, |
|
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" |
|
2161 |
- ) |
|
2162 |
- notes_backup_file.write_text( |
|
2163 |
- "These backup notes are left over from the previous session.", |
|
2164 |
- encoding="UTF-8", |
|
2165 |
- ) |
|
2166 |
- monkeypatch.setattr(click, "edit", raiser) |
|
2167 |
- result = runner.invoke( |
|
2168 |
- cli.derivepassphrase_vault, |
|
2169 |
- [ |
|
2170 |
- "--notes", |
|
2171 |
- "--modern-editor-interface" |
|
2172 |
- if modern_editor_interface |
|
2173 |
- else "--vault-legacy-editor-interface", |
|
2174 |
- "--", |
|
2175 |
- DUMMY_SERVICE, |
|
2176 |
- ], |
|
2177 |
- catch_exceptions=False, |
|
2178 |
- ) |
|
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." |
|
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 |
|
2204 |
- |
|
2205 |
- @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG |
|
2206 |
- def test_224_store_config_good( |
|
2207 |
- self, |
|
2208 |
- command_line: list[str], |
|
2209 |
- input: str, |
|
2210 |
- result_config: Any, |
|
2211 |
- ) -> None: |
|
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 |
- """ |
|
2218 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2219 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2220 |
- # with-statements. |
|
2221 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2222 |
- with contextlib.ExitStack() as stack: |
|
2223 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2224 |
- stack.enter_context( |
|
2225 |
- pytest_machinery.isolated_vault_config( |
|
2226 |
- monkeypatch=monkeypatch, |
|
2227 |
- runner=runner, |
|
2228 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2229 |
- ) |
|
2230 |
- ) |
|
2231 |
- monkeypatch.setattr( |
|
2232 |
- cli_helpers, |
|
2233 |
- "get_suitable_ssh_keys", |
|
2234 |
- callables.suitable_ssh_keys, |
|
2235 |
- ) |
|
2236 |
- result = runner.invoke( |
|
2237 |
- cli.derivepassphrase_vault, |
|
2238 |
- ["--config", *command_line], |
|
2239 |
- catch_exceptions=False, |
|
2240 |
- input=input, |
|
2241 |
- ) |
|
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" |
|
2249 |
- ) |
|
2250 |
- assert_vault_config_is_indented_and_line_broken(config_txt) |
|
2251 |
- |
|
2252 |
- @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES |
|
2253 |
- def test_225_store_config_fail( |
|
2254 |
- self, |
|
2255 |
- command_line: list[str], |
|
2256 |
- input: str, |
|
2257 |
- err_text: str, |
|
2258 |
- ) -> None: |
|
2259 |
- """Storing invalid settings via `--config` fails.""" |
|
2260 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2261 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2262 |
- # with-statements. |
|
2263 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2264 |
- with contextlib.ExitStack() as stack: |
|
2265 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2266 |
- stack.enter_context( |
|
2267 |
- pytest_machinery.isolated_vault_config( |
|
2268 |
- monkeypatch=monkeypatch, |
|
2269 |
- runner=runner, |
|
2270 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2271 |
- ) |
|
2272 |
- ) |
|
2273 |
- monkeypatch.setattr( |
|
2274 |
- cli_helpers, |
|
2275 |
- "get_suitable_ssh_keys", |
|
2276 |
- callables.suitable_ssh_keys, |
|
2277 |
- ) |
|
2278 |
- result = runner.invoke( |
|
2279 |
- cli.derivepassphrase_vault, |
|
2280 |
- ["--config", *command_line], |
|
2281 |
- catch_exceptions=False, |
|
2282 |
- input=input, |
|
2283 |
- ) |
|
2284 |
- assert result.error_exit(error=err_text), ( |
|
2285 |
- "expected error exit and known error message" |
|
2286 |
- ) |
|
2287 |
- |
|
2288 |
- def test_225a_store_config_fail_manual_no_ssh_key_selection( |
|
2289 |
- self, |
|
2290 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
2291 |
- ) -> None: |
|
2292 |
- """Not selecting an SSH key during `--config --key` fails.""" |
|
2293 |
- del running_ssh_agent |
|
2294 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2295 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2296 |
- # with-statements. |
|
2297 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2298 |
- with contextlib.ExitStack() as stack: |
|
2299 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2300 |
- stack.enter_context( |
|
2301 |
- pytest_machinery.isolated_vault_config( |
|
2302 |
- monkeypatch=monkeypatch, |
|
2303 |
- runner=runner, |
|
2304 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2305 |
- ) |
|
2306 |
- ) |
|
2307 |
- |
|
2308 |
- def prompt_for_selection(*_args: Any, **_kwargs: Any) -> NoReturn: |
|
2309 |
- raise IndexError(cli_helpers.EMPTY_SELECTION) |
|
2310 |
- |
|
2311 |
- monkeypatch.setattr( |
|
2312 |
- cli_helpers, "prompt_for_selection", prompt_for_selection |
|
2313 |
- ) |
|
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, |
|
2320 |
- ) |
|
2321 |
- result = runner.invoke( |
|
2322 |
- cli.derivepassphrase_vault, |
|
2323 |
- ["--key", "--config"], |
|
2324 |
- catch_exceptions=False, |
|
2325 |
- ) |
|
2326 |
- assert result.error_exit(error="the user aborted the request"), ( |
|
2327 |
- "expected error exit and known error message" |
|
2328 |
- ) |
|
2329 |
- |
|
2330 |
- def test_225b_store_config_fail_manual_no_ssh_agent( |
|
2331 |
- self, |
|
2332 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
2333 |
- ) -> None: |
|
2334 |
- """Not running an SSH agent during `--config --key` fails.""" |
|
2335 |
- del running_ssh_agent |
|
2336 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2337 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2338 |
- # with-statements. |
|
2339 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2340 |
- with contextlib.ExitStack() as stack: |
|
2341 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2342 |
- stack.enter_context( |
|
2343 |
- pytest_machinery.isolated_vault_config( |
|
2344 |
- monkeypatch=monkeypatch, |
|
2345 |
- runner=runner, |
|
2346 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2347 |
- ) |
|
2348 |
- ) |
|
2349 |
- monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) |
|
2350 |
- result = runner.invoke( |
|
2351 |
- cli.derivepassphrase_vault, |
|
2352 |
- ["--key", "--config"], |
|
2353 |
- catch_exceptions=False, |
|
2354 |
- ) |
|
2355 |
- assert result.error_exit(error="Cannot find any running SSH agent"), ( |
|
2356 |
- "expected error exit and known error message" |
|
2357 |
- ) |
|
2358 |
- |
|
2359 |
- def test_225c_store_config_fail_manual_bad_ssh_agent_connection( |
|
2360 |
- self, |
|
2361 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
2362 |
- ) -> None: |
|
2363 |
- """Not running a reachable SSH agent during `--config --key` fails.""" |
|
2364 |
- running_ssh_agent.require_external_address() |
|
2365 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2366 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2367 |
- # with-statements. |
|
2368 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2369 |
- with contextlib.ExitStack() as stack: |
|
2370 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2371 |
- stack.enter_context( |
|
2372 |
- pytest_machinery.isolated_vault_config( |
|
2373 |
- monkeypatch=monkeypatch, |
|
2374 |
- runner=runner, |
|
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, |
|
2384 |
- ) |
|
2385 |
- assert result.error_exit(error="Cannot connect to the SSH agent"), ( |
|
2386 |
- "expected error exit and known error message" |
|
2387 |
- ) |
|
2388 |
- |
|
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.""" |
|
2395 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2396 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2397 |
- # with-statements. |
|
2398 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2399 |
- with contextlib.ExitStack() as stack: |
|
2400 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2401 |
- stack.enter_context( |
|
2402 |
- pytest_machinery.isolated_vault_config( |
|
2403 |
- monkeypatch=monkeypatch, |
|
2404 |
- runner=runner, |
|
2405 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2406 |
- ) |
|
2407 |
- ) |
|
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" |
|
2419 |
- ) |
|
2420 |
- |
|
2421 |
- def test_225e_store_config_fail_manual_custom_error( |
|
2422 |
- self, |
|
2423 |
- ) -> None: |
|
2424 |
- """OS-erroring with `--config` fails.""" |
|
2425 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2426 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2427 |
- # with-statements. |
|
2428 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2429 |
- with contextlib.ExitStack() as stack: |
|
2430 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2431 |
- stack.enter_context( |
|
2432 |
- pytest_machinery.isolated_vault_config( |
|
2433 |
- monkeypatch=monkeypatch, |
|
2434 |
- runner=runner, |
|
2435 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2436 |
- ) |
|
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) |
|
2445 |
- result = runner.invoke( |
|
2446 |
- cli.derivepassphrase_vault, |
|
2447 |
- ["--config", "--length=15", "--", DUMMY_SERVICE], |
|
2448 |
- catch_exceptions=False, |
|
2449 |
- ) |
|
2450 |
- assert result.error_exit(error=custom_error), ( |
|
2451 |
- "expected error exit and known error message" |
|
2452 |
- ) |
|
2453 |
- |
|
2454 |
- def test_225f_store_config_fail_unset_and_set_same_settings( |
|
2455 |
- self, |
|
2456 |
- ) -> None: |
|
2457 |
- """Issuing conflicting settings to `--config` fails.""" |
|
2458 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2459 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2460 |
- # with-statements. |
|
2461 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2462 |
- with contextlib.ExitStack() as stack: |
|
2463 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2464 |
- stack.enter_context( |
|
2465 |
- pytest_machinery.isolated_vault_config( |
|
2466 |
- monkeypatch=monkeypatch, |
|
2467 |
- runner=runner, |
|
2468 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2469 |
- ) |
|
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, |
|
2481 |
- ) |
|
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" |
|
2485 |
- |
|
2486 |
- def test_225g_store_config_fail_manual_ssh_agent_no_keys_loaded( |
|
2487 |
- self, |
|
2488 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
2489 |
- ) -> None: |
|
2490 |
- """Not holding any SSH keys during `--config --key` fails.""" |
|
2491 |
- del running_ssh_agent |
|
2492 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2493 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2494 |
- # with-statements. |
|
2495 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2496 |
- with contextlib.ExitStack() as stack: |
|
2497 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2498 |
- stack.enter_context( |
|
2499 |
- pytest_machinery.isolated_vault_config( |
|
2500 |
- monkeypatch=monkeypatch, |
|
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, |
|
2517 |
- ) |
|
2518 |
- assert result.error_exit(error="no keys suitable"), ( |
|
2519 |
- "expected error exit and known error message" |
|
2520 |
- ) |
|
2521 |
- |
|
2522 |
- def test_225h_store_config_fail_manual_ssh_agent_runtime_error( |
|
2523 |
- self, |
|
2524 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
2525 |
- ) -> None: |
|
2526 |
- """The SSH agent erroring during `--config --key` fails.""" |
|
2527 |
- del running_ssh_agent |
|
2528 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2529 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2530 |
- # with-statements. |
|
2531 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2532 |
- with contextlib.ExitStack() as stack: |
|
2533 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2534 |
- stack.enter_context( |
|
2535 |
- pytest_machinery.isolated_vault_config( |
|
2536 |
- monkeypatch=monkeypatch, |
|
2537 |
- runner=runner, |
|
2538 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2539 |
- ) |
|
2540 |
- ) |
|
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, |
|
2550 |
- ) |
|
2551 |
- assert result.error_exit( |
|
2552 |
- error="violates the communication protocol." |
|
2553 |
- ), "expected error exit and known error message" |
|
2554 |
- |
|
2555 |
- def test_225i_store_config_fail_manual_ssh_agent_refuses( |
|
2556 |
- self, |
|
2557 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
2558 |
- ) -> None: |
|
2559 |
- """The SSH agent refusing during `--config --key` fails.""" |
|
2560 |
- del running_ssh_agent |
|
2561 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2562 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2563 |
- # with-statements. |
|
2564 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2565 |
- with contextlib.ExitStack() as stack: |
|
2566 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2567 |
- stack.enter_context( |
|
2568 |
- pytest_machinery.isolated_vault_config( |
|
2569 |
- monkeypatch=monkeypatch, |
|
2570 |
- runner=runner, |
|
2571 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2572 |
- ) |
|
2573 |
- ) |
|
2574 |
- |
|
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.""" |
|
2592 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2593 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2594 |
- # with-statements. |
|
2595 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2596 |
- with contextlib.ExitStack() as stack: |
|
2597 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2598 |
- stack.enter_context( |
|
2599 |
- pytest_machinery.isolated_config( |
|
2600 |
- monkeypatch=monkeypatch, |
|
2601 |
- runner=runner, |
|
2602 |
- ) |
|
2603 |
- ) |
|
2604 |
- result = runner.invoke( |
|
2605 |
- cli.derivepassphrase_vault, [], catch_exceptions=False |
|
2606 |
- ) |
|
2607 |
- assert result.error_exit( |
|
2608 |
- error="Deriving a passphrase requires a SERVICE" |
|
2609 |
- ), "expected error exit and known error message" |
|
2610 |
- |
|
2611 |
- def test_226a_no_passphrase_or_key( |
|
2612 |
- self, |
|
2613 |
- ) -> None: |
|
2614 |
- """Deriving a passphrase without a passphrase or key fails.""" |
|
2615 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2616 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2617 |
- # with-statements. |
|
2618 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2619 |
- with contextlib.ExitStack() as stack: |
|
2620 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2621 |
- stack.enter_context( |
|
2622 |
- pytest_machinery.isolated_config( |
|
2623 |
- monkeypatch=monkeypatch, |
|
2624 |
- runner=runner, |
|
2625 |
- ) |
|
2626 |
- ) |
|
2627 |
- result = runner.invoke( |
|
2628 |
- cli.derivepassphrase_vault, |
|
2629 |
- ["--", DUMMY_SERVICE], |
|
2630 |
- catch_exceptions=False, |
|
2631 |
- ) |
|
2632 |
- assert result.error_exit(error="No passphrase or key was given"), ( |
|
2633 |
- "expected error exit and known error message" |
|
2634 |
- ) |
|
2635 |
- |
|
2636 |
- def test_230_config_directory_nonexistant( |
|
2637 |
- self, |
|
2638 |
- ) -> None: |
|
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 |
- """ |
|
2646 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2647 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2648 |
- # with-statements. |
|
2649 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2650 |
- with contextlib.ExitStack() as stack: |
|
2651 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2652 |
- stack.enter_context( |
|
2653 |
- pytest_machinery.isolated_config( |
|
2654 |
- monkeypatch=monkeypatch, |
|
2655 |
- runner=runner, |
|
2656 |
- ) |
|
2657 |
- ) |
|
2658 |
- with contextlib.suppress(FileNotFoundError): |
|
2659 |
- shutil.rmtree(cli_helpers.config_filename(subsystem=None)) |
|
2660 |
- result = runner.invoke( |
|
2661 |
- cli.derivepassphrase_vault, |
|
2662 |
- ["--config", "-p"], |
|
2663 |
- catch_exceptions=False, |
|
2664 |
- input="abc\n", |
|
2665 |
- ) |
|
2666 |
- assert result.clean_exit(), "expected clean exit" |
|
2667 |
- assert result.stderr == "Passphrase:", ( |
|
2668 |
- "program unexpectedly failed?!" |
|
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. |
|
2688 |
- |
|
2689 |
- [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6 |
|
2690 |
- |
|
2691 |
- """ |
|
2692 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2693 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2694 |
- # with-statements. |
|
2695 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2696 |
- with contextlib.ExitStack() as stack: |
|
2697 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2698 |
- stack.enter_context( |
|
2699 |
- pytest_machinery.isolated_config( |
|
2700 |
- monkeypatch=monkeypatch, |
|
2701 |
- runner=runner, |
|
2702 |
- ) |
|
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 |
- ) |
|
2717 |
- result = runner.invoke( |
|
2718 |
- cli.derivepassphrase_vault, |
|
2719 |
- ["--config", "-p"], |
|
2720 |
- catch_exceptions=False, |
|
2721 |
- input="abc\n", |
|
2722 |
- ) |
|
2723 |
- assert result.error_exit(error="Cannot store vault settings:"), ( |
|
2724 |
- "expected error exit and known error message" |
|
2725 |
- ) |
|
2726 |
- |
|
2727 |
- def test_230b_store_config_custom_error( |
|
2728 |
- self, |
|
2729 |
- ) -> None: |
|
2730 |
- """Storing the configuration reacts even to weird errors.""" |
|
2731 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2732 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2733 |
- # with-statements. |
|
2734 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2735 |
- with contextlib.ExitStack() as stack: |
|
2736 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2737 |
- stack.enter_context( |
|
2738 |
- pytest_machinery.isolated_config( |
|
2739 |
- monkeypatch=monkeypatch, |
|
2740 |
- runner=runner, |
|
2741 |
- ) |
|
2742 |
- ) |
|
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) |
|
2750 |
- result = runner.invoke( |
|
2751 |
- cli.derivepassphrase_vault, |
|
2752 |
- ["--config", "-p"], |
|
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" |
|
2758 |
- ) |
|
2759 |
- |
|
2760 |
- @Parametrize.UNICODE_NORMALIZATION_WARNING_INPUTS |
|
2761 |
- def test_300_unicode_normalization_form_warning( |
|
2762 |
- self, |
|
2763 |
- caplog: pytest.LogCaptureFixture, |
|
2764 |
- main_config: str, |
|
2765 |
- command_line: list[str], |
|
2766 |
- input: str | None, |
|
2767 |
- warning_message: str, |
|
2768 |
- ) -> None: |
|
2769 |
- """Using unnormalized Unicode passphrases warns.""" |
|
2770 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2771 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2772 |
- # with-statements. |
|
2773 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2774 |
- with contextlib.ExitStack() as stack: |
|
2775 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2776 |
- stack.enter_context( |
|
2777 |
- pytest_machinery.isolated_vault_config( |
|
2778 |
- monkeypatch=monkeypatch, |
|
2779 |
- runner=runner, |
|
2780 |
- vault_config={ |
|
2781 |
- "services": { |
|
2782 |
- DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy() |
|
2783 |
- } |
|
2784 |
- }, |
|
2785 |
- main_config_str=main_config, |
|
2786 |
- ) |
|
2787 |
- ) |
|
2788 |
- result = runner.invoke( |
|
2789 |
- cli.derivepassphrase_vault, |
|
2790 |
- ["--debug", *command_line], |
|
2791 |
- catch_exceptions=False, |
|
2792 |
- input=input, |
|
2793 |
- ) |
|
2794 |
- assert result.clean_exit(), "expected clean exit" |
|
2795 |
- assert machinery.warning_emitted( |
|
2796 |
- warning_message, caplog.record_tuples |
|
2797 |
- ), "expected known warning message in stderr" |
|
2798 |
- |
|
2799 |
- @Parametrize.UNICODE_NORMALIZATION_ERROR_INPUTS |
|
2800 |
- def test_301_unicode_normalization_form_error( |
|
2801 |
- self, |
|
2802 |
- main_config: str, |
|
2803 |
- command_line: list[str], |
|
2804 |
- input: str | None, |
|
2805 |
- error_message: str, |
|
2806 |
- ) -> None: |
|
2807 |
- """Using unknown Unicode normalization forms fails.""" |
|
2808 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2809 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2810 |
- # with-statements. |
|
2811 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2812 |
- with contextlib.ExitStack() as stack: |
|
2813 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2814 |
- stack.enter_context( |
|
2815 |
- pytest_machinery.isolated_vault_config( |
|
2816 |
- monkeypatch=monkeypatch, |
|
2817 |
- runner=runner, |
|
2818 |
- vault_config={ |
|
2819 |
- "services": { |
|
2820 |
- DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy() |
|
2821 |
- } |
|
2822 |
- }, |
|
2823 |
- main_config_str=main_config, |
|
2824 |
- ) |
|
2825 |
- ) |
|
2826 |
- result = runner.invoke( |
|
2827 |
- cli.derivepassphrase_vault, |
|
2828 |
- command_line, |
|
2829 |
- catch_exceptions=False, |
|
2830 |
- input=input, |
|
2831 |
- ) |
|
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" |
|
2837 |
- ) |
|
2838 |
- |
|
2839 |
- @Parametrize.UNICODE_NORMALIZATION_COMMAND_LINES |
|
2840 |
- def test_301a_unicode_normalization_form_error_from_stored_config( |
|
2841 |
- self, |
|
2842 |
- command_line: list[str], |
|
2843 |
- ) -> None: |
|
2844 |
- """Using unknown Unicode normalization forms in the config fails.""" |
|
2845 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2846 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2847 |
- # with-statements. |
|
2848 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2849 |
- with contextlib.ExitStack() as stack: |
|
2850 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2851 |
- stack.enter_context( |
|
2852 |
- pytest_machinery.isolated_vault_config( |
|
2853 |
- monkeypatch=monkeypatch, |
|
2854 |
- runner=runner, |
|
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 |
- ) |
|
2864 |
- ) |
|
2865 |
- result = runner.invoke( |
|
2866 |
- cli.derivepassphrase_vault, |
|
2867 |
- command_line, |
|
2868 |
- input=DUMMY_PASSPHRASE, |
|
2869 |
- catch_exceptions=False, |
|
2870 |
- ) |
|
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" |
|
2880 |
- |
|
2881 |
- def test_310_bad_user_config_file( |
|
2882 |
- self, |
|
2883 |
- ) -> None: |
|
2884 |
- """Loading a user configuration file in an invalid format fails.""" |
|
2885 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2886 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2887 |
- # with-statements. |
|
2888 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2889 |
- with contextlib.ExitStack() as stack: |
|
2890 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2891 |
- stack.enter_context( |
|
2892 |
- pytest_machinery.isolated_vault_config( |
|
2893 |
- monkeypatch=monkeypatch, |
|
2894 |
- runner=runner, |
|
2895 |
- vault_config={"services": {}}, |
|
2896 |
- main_config_str="This file is not valid TOML.\n", |
|
2897 |
- ) |
|
2898 |
- ) |
|
2899 |
- result = runner.invoke( |
|
2900 |
- cli.derivepassphrase_vault, |
|
2901 |
- ["--phrase", "--", DUMMY_SERVICE], |
|
2902 |
- input=DUMMY_PASSPHRASE, |
|
2903 |
- catch_exceptions=False, |
|
2904 |
- ) |
|
2905 |
- assert result.error_exit(error="Cannot load user config:"), ( |
|
2906 |
- "expected error exit and known error message" |
|
2907 |
- ) |
|
2908 |
- |
|
2909 |
- def test_311_bad_user_config_is_a_directory( |
|
2910 |
- self, |
|
2911 |
- ) -> None: |
|
2912 |
- """Loading a user configuration file in an invalid format fails.""" |
|
2913 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2914 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2915 |
- # with-statements. |
|
2916 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2917 |
- with contextlib.ExitStack() as stack: |
|
2918 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2919 |
- stack.enter_context( |
|
2920 |
- pytest_machinery.isolated_vault_config( |
|
2921 |
- monkeypatch=monkeypatch, |
|
2922 |
- runner=runner, |
|
2923 |
- vault_config={"services": {}}, |
|
2924 |
- main_config_str="", |
|
2925 |
- ) |
|
2926 |
- ) |
|
2927 |
- user_config = cli_helpers.config_filename( |
|
2928 |
- subsystem="user configuration" |
|
2929 |
- ) |
|
2930 |
- user_config.unlink() |
|
2931 |
- user_config.mkdir(parents=True, exist_ok=True) |
|
2932 |
- result = runner.invoke( |
|
2933 |
- cli.derivepassphrase_vault, |
|
2934 |
- ["--phrase", "--", DUMMY_SERVICE], |
|
2935 |
- input=DUMMY_PASSPHRASE, |
|
2936 |
- catch_exceptions=False, |
|
2937 |
- ) |
|
2938 |
- assert result.error_exit(error="Cannot load user config:"), ( |
|
2939 |
- "expected error exit and known error message" |
|
2940 |
- ) |
|
2941 |
- |
|
2942 |
- def test_400_missing_af_unix_support( |
|
2943 |
- self, |
|
2944 |
- caplog: pytest.LogCaptureFixture, |
|
2945 |
- ) -> None: |
|
2946 |
- """Querying the SSH agent without `AF_UNIX` support fails.""" |
|
2947 |
- runner = machinery.CliRunner(mix_stderr=False) |
|
2948 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
2949 |
- # with-statements. |
|
2950 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2951 |
- with contextlib.ExitStack() as stack: |
|
2952 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2953 |
- stack.enter_context( |
|
2954 |
- pytest_machinery.isolated_vault_config( |
|
2955 |
- monkeypatch=monkeypatch, |
|
2956 |
- runner=runner, |
|
2957 |
- vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2958 |
- ) |
|
2959 |
- ) |
|
2960 |
- monkeypatch.setenv( |
|
2961 |
- "SSH_AUTH_SOCK", "the value doesn't even matter" |
|
2962 |
- ) |
|
2963 |
- monkeypatch.setattr( |
|
2964 |
- ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", ["posix"] |
|
2965 |
- ) |
|
2966 |
- monkeypatch.delattr(socket, "AF_UNIX", raising=False) |
|
2967 |
- result = runner.invoke( |
|
2968 |
- cli.derivepassphrase_vault, |
|
2969 |
- ["--key", "--config"], |
|
2970 |
- catch_exceptions=False, |
|
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,2978 @@ |
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 copy |
|
9 |
+import errno |
|
10 |
+import json |
|
11 |
+import os |
|
12 |
+import pathlib |
|
13 |
+import shutil |
|
14 |
+import socket |
|
15 |
+import textwrap |
|
16 |
+import types |
|
17 |
+from typing import TYPE_CHECKING |
|
18 |
+ |
|
19 |
+import click.testing |
|
20 |
+import hypothesis |
|
21 |
+import pytest |
|
22 |
+from hypothesis import strategies |
|
23 |
+from typing_extensions import Any, NamedTuple |
|
24 |
+ |
|
25 |
+from derivepassphrase import _types, cli, ssh_agent, vault |
|
26 |
+from derivepassphrase._internals import ( |
|
27 |
+ cli_helpers, |
|
28 |
+ cli_messages, |
|
29 |
+) |
|
30 |
+from tests import data, machinery |
|
31 |
+from tests.data import callables |
|
32 |
+from tests.machinery import hypothesis as hypothesis_machinery |
|
33 |
+from tests.machinery import pytest as pytest_machinery |
|
34 |
+ |
|
35 |
+if TYPE_CHECKING: |
|
36 |
+ from typing import NoReturn |
|
37 |
+ |
|
38 |
+ from typing_extensions import Literal |
|
39 |
+ |
|
40 |
+DUMMY_SERVICE = data.DUMMY_SERVICE |
|
41 |
+DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE |
|
42 |
+DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS |
|
43 |
+DUMMY_RESULT_PASSPHRASE = data.DUMMY_RESULT_PASSPHRASE |
|
44 |
+DUMMY_RESULT_KEY1 = data.DUMMY_RESULT_KEY1 |
|
45 |
+DUMMY_PHRASE_FROM_KEY1_RAW = data.DUMMY_PHRASE_FROM_KEY1_RAW |
|
46 |
+DUMMY_PHRASE_FROM_KEY1 = data.DUMMY_PHRASE_FROM_KEY1 |
|
47 |
+ |
|
48 |
+DUMMY_KEY1 = data.DUMMY_KEY1 |
|
49 |
+DUMMY_KEY1_B64 = data.DUMMY_KEY1_B64 |
|
50 |
+DUMMY_KEY2 = data.DUMMY_KEY2 |
|
51 |
+DUMMY_KEY2_B64 = data.DUMMY_KEY2_B64 |
|
52 |
+DUMMY_KEY3 = data.DUMMY_KEY3 |
|
53 |
+DUMMY_KEY3_B64 = data.DUMMY_KEY3_B64 |
|
54 |
+ |
|
55 |
+TEST_CONFIGS = data.TEST_CONFIGS |
|
56 |
+ |
|
57 |
+ |
|
58 |
+class IncompatibleConfiguration(NamedTuple): |
|
59 |
+ other_options: list[tuple[str, ...]] |
|
60 |
+ needs_service: bool | None |
|
61 |
+ input: str | None |
|
62 |
+ |
|
63 |
+ |
|
64 |
+class SingleConfiguration(NamedTuple): |
|
65 |
+ needs_service: bool | None |
|
66 |
+ input: str | None |
|
67 |
+ check_success: bool |
|
68 |
+ |
|
69 |
+ |
|
70 |
+class OptionCombination(NamedTuple): |
|
71 |
+ options: list[str] |
|
72 |
+ incompatible: bool |
|
73 |
+ needs_service: bool | None |
|
74 |
+ input: str | None |
|
75 |
+ check_success: bool |
|
76 |
+ |
|
77 |
+ |
|
78 |
+PASSPHRASE_GENERATION_OPTIONS: list[tuple[str, ...]] = [ |
|
79 |
+ ("--phrase",), |
|
80 |
+ ("--key",), |
|
81 |
+ ("--length", "20"), |
|
82 |
+ ("--repeat", "20"), |
|
83 |
+ ("--lower", "1"), |
|
84 |
+ ("--upper", "1"), |
|
85 |
+ ("--number", "1"), |
|
86 |
+ ("--space", "1"), |
|
87 |
+ ("--dash", "1"), |
|
88 |
+ ("--symbol", "1"), |
|
89 |
+] |
|
90 |
+CONFIGURATION_OPTIONS: list[tuple[str, ...]] = [ |
|
91 |
+ ("--notes",), |
|
92 |
+ ("--config",), |
|
93 |
+ ("--delete",), |
|
94 |
+ ("--delete-globals",), |
|
95 |
+ ("--clear",), |
|
96 |
+] |
|
97 |
+CONFIGURATION_COMMANDS: list[tuple[str, ...]] = [ |
|
98 |
+ ("--delete",), |
|
99 |
+ ("--delete-globals",), |
|
100 |
+ ("--clear",), |
|
101 |
+] |
|
102 |
+STORAGE_OPTIONS: list[tuple[str, ...]] = [("--export", "-"), ("--import", "-")] |
|
103 |
+INCOMPATIBLE: dict[tuple[str, ...], IncompatibleConfiguration] = { |
|
104 |
+ ("--phrase",): IncompatibleConfiguration( |
|
105 |
+ [("--key",), *CONFIGURATION_COMMANDS, *STORAGE_OPTIONS], |
|
106 |
+ True, |
|
107 |
+ DUMMY_PASSPHRASE, |
|
108 |
+ ), |
|
109 |
+ ("--key",): IncompatibleConfiguration( |
|
110 |
+ CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
111 |
+ ), |
|
112 |
+ ("--length", "20"): IncompatibleConfiguration( |
|
113 |
+ CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
114 |
+ ), |
|
115 |
+ ("--repeat", "20"): IncompatibleConfiguration( |
|
116 |
+ CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
117 |
+ ), |
|
118 |
+ ("--lower", "1"): IncompatibleConfiguration( |
|
119 |
+ CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
120 |
+ ), |
|
121 |
+ ("--upper", "1"): IncompatibleConfiguration( |
|
122 |
+ CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
123 |
+ ), |
|
124 |
+ ("--number", "1"): IncompatibleConfiguration( |
|
125 |
+ CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
126 |
+ ), |
|
127 |
+ ("--space", "1"): IncompatibleConfiguration( |
|
128 |
+ CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
129 |
+ ), |
|
130 |
+ ("--dash", "1"): IncompatibleConfiguration( |
|
131 |
+ CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
132 |
+ ), |
|
133 |
+ ("--symbol", "1"): IncompatibleConfiguration( |
|
134 |
+ CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE |
|
135 |
+ ), |
|
136 |
+ ("--notes",): IncompatibleConfiguration( |
|
137 |
+ CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, None |
|
138 |
+ ), |
|
139 |
+ ("--config", "-p"): IncompatibleConfiguration( |
|
140 |
+ [("--delete",), ("--delete-globals",), ("--clear",), *STORAGE_OPTIONS], |
|
141 |
+ None, |
|
142 |
+ DUMMY_PASSPHRASE, |
|
143 |
+ ), |
|
144 |
+ ("--delete",): IncompatibleConfiguration( |
|
145 |
+ [("--delete-globals",), ("--clear",), *STORAGE_OPTIONS], True, None |
|
146 |
+ ), |
|
147 |
+ ("--delete-globals",): IncompatibleConfiguration( |
|
148 |
+ [("--clear",), *STORAGE_OPTIONS], False, None |
|
149 |
+ ), |
|
150 |
+ ("--clear",): IncompatibleConfiguration(STORAGE_OPTIONS, False, None), |
|
151 |
+ ("--export", "-"): IncompatibleConfiguration( |
|
152 |
+ [("--import", "-")], False, None |
|
153 |
+ ), |
|
154 |
+ ("--import", "-"): IncompatibleConfiguration([], False, None), |
|
155 |
+} |
|
156 |
+SINGLES: dict[tuple[str, ...], SingleConfiguration] = { |
|
157 |
+ ("--phrase",): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
158 |
+ ("--key",): SingleConfiguration(True, None, False), |
|
159 |
+ ("--length", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
160 |
+ ("--repeat", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
161 |
+ ("--lower", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
162 |
+ ("--upper", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
163 |
+ ("--number", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
164 |
+ ("--space", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
165 |
+ ("--dash", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
166 |
+ ("--symbol", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), |
|
167 |
+ ("--notes",): SingleConfiguration(True, None, False), |
|
168 |
+ ("--config", "-p"): SingleConfiguration(None, DUMMY_PASSPHRASE, False), |
|
169 |
+ ("--delete",): SingleConfiguration(True, None, False), |
|
170 |
+ ("--delete-globals",): SingleConfiguration(False, None, True), |
|
171 |
+ ("--clear",): SingleConfiguration(False, None, True), |
|
172 |
+ ("--export", "-"): SingleConfiguration(False, None, True), |
|
173 |
+ ("--import", "-"): SingleConfiguration(False, '{"services": {}}', True), |
|
174 |
+} |
|
175 |
+INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = [] |
|
176 |
+config: IncompatibleConfiguration | SingleConfiguration |
|
177 |
+for opt, config in INCOMPATIBLE.items(): |
|
178 |
+ for opt2 in config.other_options: |
|
179 |
+ INTERESTING_OPTION_COMBINATIONS.extend([ |
|
180 |
+ OptionCombination( |
|
181 |
+ options=list(opt + opt2), |
|
182 |
+ incompatible=True, |
|
183 |
+ needs_service=config.needs_service, |
|
184 |
+ input=config.input, |
|
185 |
+ check_success=False, |
|
186 |
+ ), |
|
187 |
+ OptionCombination( |
|
188 |
+ options=list(opt2 + opt), |
|
189 |
+ incompatible=True, |
|
190 |
+ needs_service=config.needs_service, |
|
191 |
+ input=config.input, |
|
192 |
+ check_success=False, |
|
193 |
+ ), |
|
194 |
+ ]) |
|
195 |
+for opt, config in SINGLES.items(): |
|
196 |
+ INTERESTING_OPTION_COMBINATIONS.append( |
|
197 |
+ OptionCombination( |
|
198 |
+ options=list(opt), |
|
199 |
+ incompatible=False, |
|
200 |
+ needs_service=config.needs_service, |
|
201 |
+ input=config.input, |
|
202 |
+ check_success=config.check_success, |
|
203 |
+ ) |
|
204 |
+ ) |
|
205 |
+ |
|
206 |
+ |
|
207 |
+def is_warning_line(line: str) -> bool: |
|
208 |
+ """Return true if the line is a warning line.""" |
|
209 |
+ return " Warning: " in line or " Deprecation warning: " in line |
|
210 |
+ |
|
211 |
+ |
|
212 |
+def is_harmless_config_import_warning(record: tuple[str, int, str]) -> bool: |
|
213 |
+ """Return true if the warning is harmless, during config import.""" |
|
214 |
+ possible_warnings = [ |
|
215 |
+ "Replacing invalid value ", |
|
216 |
+ "Removing ineffective setting ", |
|
217 |
+ ( |
|
218 |
+ "Setting a global passphrase is ineffective " |
|
219 |
+ "because a key is also set." |
|
220 |
+ ), |
|
221 |
+ ( |
|
222 |
+ "Setting a service passphrase is ineffective " |
|
223 |
+ "because a key is also set:" |
|
224 |
+ ), |
|
225 |
+ ] |
|
226 |
+ return any( |
|
227 |
+ machinery.warning_emitted(w, [record]) for w in possible_warnings |
|
228 |
+ ) |
|
229 |
+ |
|
230 |
+ |
|
231 |
+def assert_vault_config_is_indented_and_line_broken( |
|
232 |
+ config_txt: str, |
|
233 |
+ /, |
|
234 |
+) -> None: |
|
235 |
+ """Return true if the vault configuration is indented and line broken. |
|
236 |
+ |
|
237 |
+ Indented and rewrapped vault configurations as produced by |
|
238 |
+ `json.dump` contain the closing '}' of the '$.services' object |
|
239 |
+ on a separate, indented line: |
|
240 |
+ |
|
241 |
+ ~~~~ |
|
242 |
+ { |
|
243 |
+ "services": { |
|
244 |
+ ... |
|
245 |
+ } <-- this brace here |
|
246 |
+ } |
|
247 |
+ ~~~~ |
|
248 |
+ |
|
249 |
+ or, if there are no services, then the indented line |
|
250 |
+ |
|
251 |
+ ~~~~ |
|
252 |
+ "services": {} |
|
253 |
+ ~~~~ |
|
254 |
+ |
|
255 |
+ Both variations may end with a comma if there are more top-level |
|
256 |
+ keys. |
|
257 |
+ |
|
258 |
+ """ |
|
259 |
+ known_indented_lines = { |
|
260 |
+ "}", |
|
261 |
+ "},", |
|
262 |
+ '"services": {}', |
|
263 |
+ '"services": {},', |
|
264 |
+ } |
|
265 |
+ assert any([ |
|
266 |
+ line.strip() in known_indented_lines and line.startswith((" ", "\t")) |
|
267 |
+ for line in config_txt.splitlines() |
|
268 |
+ ]) |
|
269 |
+ |
|
270 |
+ |
|
271 |
+class Parametrize(types.SimpleNamespace): |
|
272 |
+ """Common test parametrizations.""" |
|
273 |
+ |
|
274 |
+ CHARSET_NAME = pytest.mark.parametrize( |
|
275 |
+ "charset_name", ["lower", "upper", "number", "space", "dash", "symbol"] |
|
276 |
+ ) |
|
277 |
+ UNICODE_NORMALIZATION_COMMAND_LINES = pytest.mark.parametrize( |
|
278 |
+ "command_line", |
|
279 |
+ [ |
|
280 |
+ pytest.param( |
|
281 |
+ ["--config", "--phrase"], |
|
282 |
+ id="configure global passphrase", |
|
283 |
+ ), |
|
284 |
+ pytest.param( |
|
285 |
+ ["--config", "--phrase", "--", "DUMMY_SERVICE"], |
|
286 |
+ id="configure service passphrase", |
|
287 |
+ ), |
|
288 |
+ pytest.param( |
|
289 |
+ ["--phrase", "--", DUMMY_SERVICE], |
|
290 |
+ id="interactive passphrase", |
|
291 |
+ ), |
|
292 |
+ ], |
|
293 |
+ ) |
|
294 |
+ CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES = pytest.mark.parametrize( |
|
295 |
+ ["command_line", "input", "err_text"], |
|
296 |
+ [ |
|
297 |
+ pytest.param( |
|
298 |
+ [], |
|
299 |
+ "", |
|
300 |
+ "Cannot update the global settings without any given settings", |
|
301 |
+ id="None", |
|
302 |
+ ), |
|
303 |
+ pytest.param( |
|
304 |
+ ["--", "sv"], |
|
305 |
+ "", |
|
306 |
+ "Cannot update the service-specific settings without any given settings", |
|
307 |
+ id="None-sv", |
|
308 |
+ ), |
|
309 |
+ pytest.param( |
|
310 |
+ ["--phrase", "--", "sv"], |
|
311 |
+ "\n", |
|
312 |
+ "No passphrase was given", |
|
313 |
+ id="phrase-sv", |
|
314 |
+ ), |
|
315 |
+ pytest.param( |
|
316 |
+ ["--phrase", "--", "sv"], |
|
317 |
+ "", |
|
318 |
+ "No passphrase was given", |
|
319 |
+ id="phrase-sv-eof", |
|
320 |
+ ), |
|
321 |
+ pytest.param( |
|
322 |
+ ["--key"], |
|
323 |
+ "\n", |
|
324 |
+ "No SSH key was selected", |
|
325 |
+ id="key-sv", |
|
326 |
+ ), |
|
327 |
+ pytest.param( |
|
328 |
+ ["--key"], |
|
329 |
+ "", |
|
330 |
+ "No SSH key was selected", |
|
331 |
+ id="key-sv-eof", |
|
332 |
+ ), |
|
333 |
+ ], |
|
334 |
+ ) |
|
335 |
+ CONFIG_EDITING_VIA_CONFIG_FLAG = pytest.mark.parametrize( |
|
336 |
+ ["command_line", "input", "result_config"], |
|
337 |
+ [ |
|
338 |
+ pytest.param( |
|
339 |
+ ["--phrase"], |
|
340 |
+ "my passphrase\n", |
|
341 |
+ {"global": {"phrase": "my passphrase"}, "services": {}}, |
|
342 |
+ id="phrase", |
|
343 |
+ ), |
|
344 |
+ pytest.param( |
|
345 |
+ ["--key"], |
|
346 |
+ "1\n", |
|
347 |
+ { |
|
348 |
+ "global": {"key": DUMMY_KEY1_B64, "phrase": "abc"}, |
|
349 |
+ "services": {}, |
|
350 |
+ }, |
|
351 |
+ id="key", |
|
352 |
+ ), |
|
353 |
+ pytest.param( |
|
354 |
+ ["--phrase", "--", "sv"], |
|
355 |
+ "my passphrase\n", |
|
356 |
+ { |
|
357 |
+ "global": {"phrase": "abc"}, |
|
358 |
+ "services": {"sv": {"phrase": "my passphrase"}}, |
|
359 |
+ }, |
|
360 |
+ id="phrase-sv", |
|
361 |
+ ), |
|
362 |
+ pytest.param( |
|
363 |
+ ["--key", "--", "sv"], |
|
364 |
+ "1\n", |
|
365 |
+ { |
|
366 |
+ "global": {"phrase": "abc"}, |
|
367 |
+ "services": {"sv": {"key": DUMMY_KEY1_B64}}, |
|
368 |
+ }, |
|
369 |
+ id="key-sv", |
|
370 |
+ ), |
|
371 |
+ pytest.param( |
|
372 |
+ ["--key", "--length", "15", "--", "sv"], |
|
373 |
+ "1\n", |
|
374 |
+ { |
|
375 |
+ "global": {"phrase": "abc"}, |
|
376 |
+ "services": {"sv": {"key": DUMMY_KEY1_B64, "length": 15}}, |
|
377 |
+ }, |
|
378 |
+ id="key-length-sv", |
|
379 |
+ ), |
|
380 |
+ ], |
|
381 |
+ ) |
|
382 |
+ BASE_CONFIG_WITH_KEY_VARIATIONS = pytest.mark.parametrize( |
|
383 |
+ "config", |
|
384 |
+ [ |
|
385 |
+ pytest.param( |
|
386 |
+ { |
|
387 |
+ "global": {"key": DUMMY_KEY1_B64}, |
|
388 |
+ "services": {DUMMY_SERVICE: {}}, |
|
389 |
+ }, |
|
390 |
+ id="global_config", |
|
391 |
+ ), |
|
392 |
+ pytest.param( |
|
393 |
+ {"services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}}}, |
|
394 |
+ id="service_config", |
|
395 |
+ ), |
|
396 |
+ pytest.param( |
|
397 |
+ { |
|
398 |
+ "global": {"key": DUMMY_KEY1_B64}, |
|
399 |
+ "services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}}, |
|
400 |
+ }, |
|
401 |
+ id="full_config", |
|
402 |
+ ), |
|
403 |
+ ], |
|
404 |
+ ) |
|
405 |
+ CONFIG_WITH_KEY = pytest.mark.parametrize( |
|
406 |
+ "config", |
|
407 |
+ [ |
|
408 |
+ pytest.param( |
|
409 |
+ { |
|
410 |
+ "global": {"key": DUMMY_KEY1_B64}, |
|
411 |
+ "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}, |
|
412 |
+ }, |
|
413 |
+ id="global", |
|
414 |
+ ), |
|
415 |
+ pytest.param( |
|
416 |
+ { |
|
417 |
+ "global": {"phrase": DUMMY_PASSPHRASE.rstrip("\n")}, |
|
418 |
+ "services": { |
|
419 |
+ DUMMY_SERVICE: { |
|
420 |
+ "key": DUMMY_KEY1_B64, |
|
421 |
+ **DUMMY_CONFIG_SETTINGS, |
|
422 |
+ } |
|
423 |
+ }, |
|
424 |
+ }, |
|
425 |
+ id="service", |
|
426 |
+ ), |
|
427 |
+ ], |
|
428 |
+ ) |
|
429 |
+ VALID_TEST_CONFIGS = pytest.mark.parametrize( |
|
430 |
+ "config", |
|
431 |
+ [conf.config for conf in TEST_CONFIGS if conf.is_valid()], |
|
432 |
+ ) |
|
433 |
+ KEY_OVERRIDING_IN_CONFIG = pytest.mark.parametrize( |
|
434 |
+ ["config", "command_line"], |
|
435 |
+ [ |
|
436 |
+ pytest.param( |
|
437 |
+ { |
|
438 |
+ "global": {"key": DUMMY_KEY1_B64}, |
|
439 |
+ "services": {}, |
|
440 |
+ }, |
|
441 |
+ ["--config", "-p"], |
|
442 |
+ id="global", |
|
443 |
+ ), |
|
444 |
+ pytest.param( |
|
445 |
+ { |
|
446 |
+ "services": { |
|
447 |
+ DUMMY_SERVICE: { |
|
448 |
+ "key": DUMMY_KEY1_B64, |
|
449 |
+ **DUMMY_CONFIG_SETTINGS, |
|
450 |
+ }, |
|
451 |
+ }, |
|
452 |
+ }, |
|
453 |
+ ["--config", "-p", "--", DUMMY_SERVICE], |
|
454 |
+ id="service", |
|
455 |
+ ), |
|
456 |
+ pytest.param( |
|
457 |
+ { |
|
458 |
+ "global": {"key": DUMMY_KEY1_B64}, |
|
459 |
+ "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}, |
|
460 |
+ }, |
|
461 |
+ ["--config", "-p", "--", DUMMY_SERVICE], |
|
462 |
+ id="service-over-global", |
|
463 |
+ ), |
|
464 |
+ ], |
|
465 |
+ ) |
|
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", |
|
476 |
+ [ |
|
477 |
+ [], |
|
478 |
+ ["--export-as=sh"], |
|
479 |
+ ], |
|
480 |
+ ) |
|
481 |
+ KEY_INDEX = pytest.mark.parametrize( |
|
482 |
+ "key_index", [1, 2, 3], ids=lambda i: f"index{i}" |
|
483 |
+ ) |
|
484 |
+ UNICODE_NORMALIZATION_ERROR_INPUTS = pytest.mark.parametrize( |
|
485 |
+ ["main_config", "command_line", "input", "error_message"], |
|
486 |
+ [ |
|
487 |
+ pytest.param( |
|
488 |
+ textwrap.dedent(r""" |
|
489 |
+ [vault] |
|
490 |
+ default-unicode-normalization-form = 'XXX' |
|
491 |
+ """), |
|
492 |
+ ["--import", "-"], |
|
493 |
+ json.dumps({ |
|
494 |
+ "services": { |
|
495 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
496 |
+ "with_normalization": {"phrase": "D\u00fcsseldorf"}, |
|
497 |
+ }, |
|
498 |
+ }), |
|
499 |
+ ( |
|
500 |
+ "Invalid value 'XXX' for config key " |
|
501 |
+ "vault.default-unicode-normalization-form" |
|
502 |
+ ), |
|
503 |
+ id="global", |
|
504 |
+ ), |
|
505 |
+ pytest.param( |
|
506 |
+ textwrap.dedent(r""" |
|
507 |
+ [vault.unicode-normalization-form] |
|
508 |
+ with_normalization = 'XXX' |
|
509 |
+ """), |
|
510 |
+ ["--import", "-"], |
|
511 |
+ json.dumps({ |
|
512 |
+ "services": { |
|
513 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
514 |
+ "with_normalization": {"phrase": "D\u00fcsseldorf"}, |
|
515 |
+ }, |
|
516 |
+ }), |
|
517 |
+ ( |
|
518 |
+ "Invalid value 'XXX' for config key " |
|
519 |
+ "vault.with_normalization.unicode-normalization-form" |
|
520 |
+ ), |
|
521 |
+ id="service", |
|
522 |
+ ), |
|
523 |
+ ], |
|
524 |
+ ) |
|
525 |
+ UNICODE_NORMALIZATION_WARNING_INPUTS = pytest.mark.parametrize( |
|
526 |
+ ["main_config", "command_line", "input", "warning_message"], |
|
527 |
+ [ |
|
528 |
+ pytest.param( |
|
529 |
+ "", |
|
530 |
+ ["--import", "-"], |
|
531 |
+ json.dumps({ |
|
532 |
+ "global": {"phrase": "Du\u0308sseldorf"}, |
|
533 |
+ "services": {}, |
|
534 |
+ }), |
|
535 |
+ "The $.global passphrase is not NFC-normalized", |
|
536 |
+ id="global-NFC", |
|
537 |
+ ), |
|
538 |
+ pytest.param( |
|
539 |
+ "", |
|
540 |
+ ["--import", "-"], |
|
541 |
+ json.dumps({ |
|
542 |
+ "services": { |
|
543 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
544 |
+ "weird entry name": {"phrase": "Du\u0308sseldorf"}, |
|
545 |
+ } |
|
546 |
+ }), |
|
547 |
+ ( |
|
548 |
+ 'The $.services["weird entry name"] passphrase ' |
|
549 |
+ "is not NFC-normalized" |
|
550 |
+ ), |
|
551 |
+ id="service-weird-name-NFC", |
|
552 |
+ ), |
|
553 |
+ pytest.param( |
|
554 |
+ "", |
|
555 |
+ ["--config", "-p", "--", DUMMY_SERVICE], |
|
556 |
+ "Du\u0308sseldorf", |
|
557 |
+ ( |
|
558 |
+ f"The $.services.{DUMMY_SERVICE} passphrase " |
|
559 |
+ f"is not NFC-normalized" |
|
560 |
+ ), |
|
561 |
+ id="config-NFC", |
|
562 |
+ ), |
|
563 |
+ pytest.param( |
|
564 |
+ "", |
|
565 |
+ ["-p", "--", DUMMY_SERVICE], |
|
566 |
+ "Du\u0308sseldorf", |
|
567 |
+ "The interactive input passphrase is not NFC-normalized", |
|
568 |
+ id="direct-input-NFC", |
|
569 |
+ ), |
|
570 |
+ pytest.param( |
|
571 |
+ textwrap.dedent(r""" |
|
572 |
+ [vault] |
|
573 |
+ default-unicode-normalization-form = 'NFD' |
|
574 |
+ """), |
|
575 |
+ ["--import", "-"], |
|
576 |
+ json.dumps({ |
|
577 |
+ "global": { |
|
578 |
+ "phrase": "D\u00fcsseldorf", |
|
579 |
+ }, |
|
580 |
+ "services": {}, |
|
581 |
+ }), |
|
582 |
+ "The $.global passphrase is not NFD-normalized", |
|
583 |
+ id="global-NFD", |
|
584 |
+ ), |
|
585 |
+ pytest.param( |
|
586 |
+ textwrap.dedent(r""" |
|
587 |
+ [vault] |
|
588 |
+ default-unicode-normalization-form = 'NFD' |
|
589 |
+ """), |
|
590 |
+ ["--import", "-"], |
|
591 |
+ json.dumps({ |
|
592 |
+ "services": { |
|
593 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
594 |
+ "weird entry name": {"phrase": "D\u00fcsseldorf"}, |
|
595 |
+ }, |
|
596 |
+ }), |
|
597 |
+ ( |
|
598 |
+ 'The $.services["weird entry name"] passphrase ' |
|
599 |
+ "is not NFD-normalized" |
|
600 |
+ ), |
|
601 |
+ id="service-weird-name-NFD", |
|
602 |
+ ), |
|
603 |
+ pytest.param( |
|
604 |
+ textwrap.dedent(r""" |
|
605 |
+ [vault.unicode-normalization-form] |
|
606 |
+ 'weird entry name 2' = 'NFKD' |
|
607 |
+ """), |
|
608 |
+ ["--import", "-"], |
|
609 |
+ json.dumps({ |
|
610 |
+ "services": { |
|
611 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
612 |
+ "weird entry name 1": {"phrase": "D\u00fcsseldorf"}, |
|
613 |
+ "weird entry name 2": {"phrase": "D\u00fcsseldorf"}, |
|
614 |
+ }, |
|
615 |
+ }), |
|
616 |
+ ( |
|
617 |
+ 'The $.services["weird entry name 2"] passphrase ' |
|
618 |
+ "is not NFKD-normalized" |
|
619 |
+ ), |
|
620 |
+ id="service-weird-name-2-NFKD", |
|
621 |
+ ), |
|
622 |
+ ], |
|
623 |
+ ) |
|
624 |
+ MODERN_EDITOR_INTERFACE = pytest.mark.parametrize( |
|
625 |
+ "modern_editor_interface", [False, True], ids=["legacy", "modern"] |
|
626 |
+ ) |
|
627 |
+ NOTES_PLACEMENT = pytest.mark.parametrize( |
|
628 |
+ ["notes_placement", "placement_args"], |
|
629 |
+ [ |
|
630 |
+ pytest.param("after", ["--print-notes-after"], id="after"), |
|
631 |
+ pytest.param("before", ["--print-notes-before"], id="before"), |
|
632 |
+ ], |
|
633 |
+ ) |
|
634 |
+ VAULT_CHARSET_OPTION = pytest.mark.parametrize( |
|
635 |
+ "option", |
|
636 |
+ [ |
|
637 |
+ "--lower", |
|
638 |
+ "--upper", |
|
639 |
+ "--number", |
|
640 |
+ "--space", |
|
641 |
+ "--dash", |
|
642 |
+ "--symbol", |
|
643 |
+ "--repeat", |
|
644 |
+ "--length", |
|
645 |
+ ], |
|
646 |
+ ) |
|
647 |
+ OPTION_COMBINATIONS_INCOMPATIBLE = pytest.mark.parametrize( |
|
648 |
+ ["options", "service"], |
|
649 |
+ [ |
|
650 |
+ pytest.param(o.options, o.needs_service, id=" ".join(o.options)) |
|
651 |
+ for o in INTERESTING_OPTION_COMBINATIONS |
|
652 |
+ if o.incompatible |
|
653 |
+ ], |
|
654 |
+ ) |
|
655 |
+ OPTION_COMBINATIONS_SERVICE_NEEDED = pytest.mark.parametrize( |
|
656 |
+ ["options", "service", "input", "check_success"], |
|
657 |
+ [ |
|
658 |
+ pytest.param( |
|
659 |
+ o.options, |
|
660 |
+ o.needs_service, |
|
661 |
+ o.input, |
|
662 |
+ o.check_success, |
|
663 |
+ id=" ".join(o.options), |
|
664 |
+ ) |
|
665 |
+ for o in INTERESTING_OPTION_COMBINATIONS |
|
666 |
+ if not o.incompatible |
|
667 |
+ ], |
|
668 |
+ ) |
|
669 |
+ TRY_RACE_FREE_IMPLEMENTATION = pytest.mark.parametrize( |
|
670 |
+ "try_race_free_implementation", [True, False] |
|
671 |
+ ) |
|
672 |
+ |
|
673 |
+ |
|
674 |
+class TestCLI: |
|
675 |
+ """Tests for the `derivepassphrase vault` command-line interface.""" |
|
676 |
+ |
|
677 |
+ def test_200_help_output( |
|
678 |
+ self, |
|
679 |
+ ) -> None: |
|
680 |
+ """The `--help` option emits help text.""" |
|
681 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
682 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
683 |
+ # with-statements. |
|
684 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
685 |
+ with contextlib.ExitStack() as stack: |
|
686 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
687 |
+ stack.enter_context( |
|
688 |
+ pytest_machinery.isolated_config( |
|
689 |
+ monkeypatch=monkeypatch, |
|
690 |
+ runner=runner, |
|
691 |
+ ) |
|
692 |
+ ) |
|
693 |
+ result = runner.invoke( |
|
694 |
+ cli.derivepassphrase_vault, |
|
695 |
+ ["--help"], |
|
696 |
+ catch_exceptions=False, |
|
697 |
+ ) |
|
698 |
+ assert result.clean_exit( |
|
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" |
|
704 |
+ |
|
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( |
|
708 |
+ self, |
|
709 |
+ ) -> None: |
|
710 |
+ """The `--version` option emits version information.""" |
|
711 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
712 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
713 |
+ # with-statements. |
|
714 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
715 |
+ with contextlib.ExitStack() as stack: |
|
716 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
717 |
+ stack.enter_context( |
|
718 |
+ pytest_machinery.isolated_config( |
|
719 |
+ monkeypatch=monkeypatch, |
|
720 |
+ runner=runner, |
|
721 |
+ ) |
|
722 |
+ ) |
|
723 |
+ result = runner.invoke( |
|
724 |
+ cli.derivepassphrase_vault, |
|
725 |
+ ["--version"], |
|
726 |
+ catch_exceptions=False, |
|
727 |
+ ) |
|
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 |
+ ) |
|
734 |
+ |
|
735 |
+ @Parametrize.CHARSET_NAME |
|
736 |
+ def test_201_disable_character_set( |
|
737 |
+ self, |
|
738 |
+ charset_name: str, |
|
739 |
+ ) -> None: |
|
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") |
|
743 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
744 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
745 |
+ # with-statements. |
|
746 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
747 |
+ with contextlib.ExitStack() as stack: |
|
748 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
749 |
+ stack.enter_context( |
|
750 |
+ pytest_machinery.isolated_config( |
|
751 |
+ monkeypatch=monkeypatch, |
|
752 |
+ runner=runner, |
|
753 |
+ ) |
|
754 |
+ ) |
|
755 |
+ monkeypatch.setattr( |
|
756 |
+ cli_helpers, |
|
757 |
+ "prompt_for_passphrase", |
|
758 |
+ callables.auto_prompt, |
|
759 |
+ ) |
|
760 |
+ result = runner.invoke( |
|
761 |
+ cli.derivepassphrase_vault, |
|
762 |
+ [option, "0", "-p", "--", DUMMY_SERVICE], |
|
763 |
+ input=DUMMY_PASSPHRASE, |
|
764 |
+ catch_exceptions=False, |
|
765 |
+ ) |
|
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 |
+ ) |
|
771 |
+ |
|
772 |
+ def test_202_disable_repetition( |
|
773 |
+ self, |
|
774 |
+ ) -> None: |
|
775 |
+ """Character repetition can be disabled on the command-line.""" |
|
776 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
777 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
778 |
+ # with-statements. |
|
779 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
780 |
+ with contextlib.ExitStack() as stack: |
|
781 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
782 |
+ stack.enter_context( |
|
783 |
+ pytest_machinery.isolated_config( |
|
784 |
+ monkeypatch=monkeypatch, |
|
785 |
+ runner=runner, |
|
786 |
+ ) |
|
787 |
+ ) |
|
788 |
+ monkeypatch.setattr( |
|
789 |
+ cli_helpers, |
|
790 |
+ "prompt_for_passphrase", |
|
791 |
+ callables.auto_prompt, |
|
792 |
+ ) |
|
793 |
+ result = runner.invoke( |
|
794 |
+ cli.derivepassphrase_vault, |
|
795 |
+ ["--repeat", "0", "-p", "--", DUMMY_SERVICE], |
|
796 |
+ input=DUMMY_PASSPHRASE, |
|
797 |
+ catch_exceptions=False, |
|
798 |
+ ) |
|
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 |
+ ) |
|
808 |
+ |
|
809 |
+ @Parametrize.CONFIG_WITH_KEY |
|
810 |
+ def test_204a_key_from_config( |
|
811 |
+ self, |
|
812 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
813 |
+ config: _types.VaultConfig, |
|
814 |
+ ) -> None: |
|
815 |
+ """A stored configured SSH key will be used.""" |
|
816 |
+ del running_ssh_agent |
|
817 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
818 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
819 |
+ # with-statements. |
|
820 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
821 |
+ with contextlib.ExitStack() as stack: |
|
822 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
823 |
+ stack.enter_context( |
|
824 |
+ pytest_machinery.isolated_vault_config( |
|
825 |
+ monkeypatch=monkeypatch, |
|
826 |
+ runner=runner, |
|
827 |
+ vault_config=config, |
|
828 |
+ ) |
|
829 |
+ ) |
|
830 |
+ monkeypatch.setattr( |
|
831 |
+ vault.Vault, |
|
832 |
+ "phrase_from_key", |
|
833 |
+ callables.phrase_from_key, |
|
834 |
+ ) |
|
835 |
+ result = runner.invoke( |
|
836 |
+ cli.derivepassphrase_vault, |
|
837 |
+ ["--", DUMMY_SERVICE], |
|
838 |
+ catch_exceptions=False, |
|
839 |
+ ) |
|
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" |
|
851 |
+ |
|
852 |
+ def test_204b_key_from_command_line( |
|
853 |
+ self, |
|
854 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
855 |
+ ) -> None: |
|
856 |
+ """An SSH key requested on the command-line will be used.""" |
|
857 |
+ del running_ssh_agent |
|
858 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
859 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
860 |
+ # with-statements. |
|
861 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
862 |
+ with contextlib.ExitStack() as stack: |
|
863 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
864 |
+ stack.enter_context( |
|
865 |
+ pytest_machinery.isolated_vault_config( |
|
866 |
+ monkeypatch=monkeypatch, |
|
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, |
|
877 |
+ ) |
|
878 |
+ monkeypatch.setattr( |
|
879 |
+ vault.Vault, |
|
880 |
+ "phrase_from_key", |
|
881 |
+ callables.phrase_from_key, |
|
882 |
+ ) |
|
883 |
+ result = runner.invoke( |
|
884 |
+ cli.derivepassphrase_vault, |
|
885 |
+ ["-k", "--", DUMMY_SERVICE], |
|
886 |
+ input="1\n", |
|
887 |
+ catch_exceptions=False, |
|
888 |
+ ) |
|
889 |
+ assert result.clean_exit(), "expected clean exit" |
|
890 |
+ assert result.stdout, "expected program output" |
|
891 |
+ last_line = result.stdout.splitlines(True)[-1] |
|
892 |
+ assert ( |
|
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" |
|
897 |
+ ) |
|
898 |
+ |
|
899 |
+ @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS |
|
900 |
+ @Parametrize.KEY_INDEX |
|
901 |
+ def test_204c_key_override_on_command_line( |
|
902 |
+ self, |
|
903 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
904 |
+ config: dict[str, Any], |
|
905 |
+ key_index: int, |
|
906 |
+ ) -> None: |
|
907 |
+ """A command-line SSH key will override the configured key.""" |
|
908 |
+ del running_ssh_agent |
|
909 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
910 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
911 |
+ # with-statements. |
|
912 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
913 |
+ with contextlib.ExitStack() as stack: |
|
914 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
915 |
+ stack.enter_context( |
|
916 |
+ pytest_machinery.isolated_vault_config( |
|
917 |
+ monkeypatch=monkeypatch, |
|
918 |
+ runner=runner, |
|
919 |
+ vault_config=config, |
|
920 |
+ ) |
|
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 |
|
929 |
+ ) |
|
930 |
+ result = runner.invoke( |
|
931 |
+ cli.derivepassphrase_vault, |
|
932 |
+ ["-k", "--", DUMMY_SERVICE], |
|
933 |
+ input=f"{key_index}\n", |
|
934 |
+ ) |
|
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( |
|
943 |
+ self, |
|
944 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
945 |
+ ) -> None: |
|
946 |
+ """A command-line passphrase will override the configured key.""" |
|
947 |
+ del running_ssh_agent |
|
948 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
949 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
950 |
+ # with-statements. |
|
951 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
952 |
+ with contextlib.ExitStack() as stack: |
|
953 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
954 |
+ stack.enter_context( |
|
955 |
+ pytest_machinery.isolated_vault_config( |
|
956 |
+ monkeypatch=monkeypatch, |
|
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 |
+ ) |
|
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 |
|
976 |
+ ) |
|
977 |
+ result = runner.invoke( |
|
978 |
+ cli.derivepassphrase_vault, |
|
979 |
+ ["--", DUMMY_SERVICE], |
|
980 |
+ catch_exceptions=False, |
|
981 |
+ ) |
|
982 |
+ assert result.clean_exit(), "expected clean exit" |
|
983 |
+ assert result.stdout, "expected program output" |
|
984 |
+ last_line = result.stdout.splitlines(True)[-1] |
|
985 |
+ assert ( |
|
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" |
|
990 |
+ ) |
|
991 |
+ |
|
992 |
+ @Parametrize.KEY_OVERRIDING_IN_CONFIG |
|
993 |
+ def test_206_setting_phrase_thus_overriding_key_in_config( |
|
994 |
+ self, |
|
995 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
996 |
+ caplog: pytest.LogCaptureFixture, |
|
997 |
+ config: _types.VaultConfig, |
|
998 |
+ command_line: list[str], |
|
999 |
+ ) -> None: |
|
1000 |
+ """Configuring a passphrase atop an SSH key works, but warns.""" |
|
1001 |
+ del running_ssh_agent |
|
1002 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1003 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1004 |
+ # with-statements. |
|
1005 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1006 |
+ with contextlib.ExitStack() as stack: |
|
1007 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1008 |
+ stack.enter_context( |
|
1009 |
+ pytest_machinery.isolated_vault_config( |
|
1010 |
+ monkeypatch=monkeypatch, |
|
1011 |
+ runner=runner, |
|
1012 |
+ vault_config=config, |
|
1013 |
+ ) |
|
1014 |
+ ) |
|
1015 |
+ monkeypatch.setattr( |
|
1016 |
+ ssh_agent.SSHAgentClient, |
|
1017 |
+ "list_keys", |
|
1018 |
+ callables.list_keys, |
|
1019 |
+ ) |
|
1020 |
+ monkeypatch.setattr( |
|
1021 |
+ ssh_agent.SSHAgentClient, "sign", callables.sign |
|
1022 |
+ ) |
|
1023 |
+ result = runner.invoke( |
|
1024 |
+ cli.derivepassphrase_vault, |
|
1025 |
+ command_line, |
|
1026 |
+ input=DUMMY_PASSPHRASE, |
|
1027 |
+ catch_exceptions=False, |
|
1028 |
+ ) |
|
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" |
|
1045 |
+ |
|
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( |
|
1057 |
+ self, |
|
1058 |
+ notes: str, |
|
1059 |
+ ) -> None: |
|
1060 |
+ """Service notes are printed, if they exist.""" |
|
1061 |
+ hypothesis.assume("Error:" not in notes) |
|
1062 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1063 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1064 |
+ # with-statements. |
|
1065 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1066 |
+ with contextlib.ExitStack() as stack: |
|
1067 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1068 |
+ stack.enter_context( |
|
1069 |
+ pytest_machinery.isolated_vault_config( |
|
1070 |
+ monkeypatch=monkeypatch, |
|
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 |
+ }, |
|
1083 |
+ ) |
|
1084 |
+ ) |
|
1085 |
+ result = runner.invoke( |
|
1086 |
+ cli.derivepassphrase_vault, |
|
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" |
|
1100 |
+ ) |
|
1101 |
+ |
|
1102 |
+ @Parametrize.VAULT_CHARSET_OPTION |
|
1103 |
+ def test_210_invalid_argument_range( |
|
1104 |
+ self, |
|
1105 |
+ option: str, |
|
1106 |
+ ) -> None: |
|
1107 |
+ """Requesting invalidly many characters from a class fails.""" |
|
1108 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1109 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1110 |
+ # with-statements. |
|
1111 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1112 |
+ with contextlib.ExitStack() as stack: |
|
1113 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1114 |
+ stack.enter_context( |
|
1115 |
+ pytest_machinery.isolated_config( |
|
1116 |
+ monkeypatch=monkeypatch, |
|
1117 |
+ runner=runner, |
|
1118 |
+ ) |
|
1119 |
+ ) |
|
1120 |
+ for value in "-42", "invalid": |
|
1121 |
+ result = runner.invoke( |
|
1122 |
+ cli.derivepassphrase_vault, |
|
1123 |
+ [option, value, "-p", "--", DUMMY_SERVICE], |
|
1124 |
+ input=DUMMY_PASSPHRASE, |
|
1125 |
+ catch_exceptions=False, |
|
1126 |
+ ) |
|
1127 |
+ assert result.error_exit(error="Invalid value"), ( |
|
1128 |
+ "expected error exit and known error message" |
|
1129 |
+ ) |
|
1130 |
+ |
|
1131 |
+ @Parametrize.OPTION_COMBINATIONS_SERVICE_NEEDED |
|
1132 |
+ def test_211_service_needed( |
|
1133 |
+ self, |
|
1134 |
+ options: list[str], |
|
1135 |
+ service: bool | None, |
|
1136 |
+ input: str | None, |
|
1137 |
+ check_success: bool, |
|
1138 |
+ ) -> None: |
|
1139 |
+ """We require or forbid a service argument, depending on options.""" |
|
1140 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1141 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1142 |
+ # with-statements. |
|
1143 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1144 |
+ with contextlib.ExitStack() as stack: |
|
1145 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1146 |
+ stack.enter_context( |
|
1147 |
+ pytest_machinery.isolated_vault_config( |
|
1148 |
+ monkeypatch=monkeypatch, |
|
1149 |
+ runner=runner, |
|
1150 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
1151 |
+ ) |
|
1152 |
+ ) |
|
1153 |
+ monkeypatch.setattr( |
|
1154 |
+ cli_helpers, |
|
1155 |
+ "prompt_for_passphrase", |
|
1156 |
+ callables.auto_prompt, |
|
1157 |
+ ) |
|
1158 |
+ result = runner.invoke( |
|
1159 |
+ cli.derivepassphrase_vault, |
|
1160 |
+ options if service else [*options, "--", DUMMY_SERVICE], |
|
1161 |
+ input=input, |
|
1162 |
+ catch_exceptions=False, |
|
1163 |
+ ) |
|
1164 |
+ if service is not None: |
|
1165 |
+ err_msg = ( |
|
1166 |
+ " requires a SERVICE" |
|
1167 |
+ if service |
|
1168 |
+ else " does not take a SERVICE argument" |
|
1169 |
+ ) |
|
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: |
|
1178 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1179 |
+ # with-statements. |
|
1180 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1181 |
+ with contextlib.ExitStack() as stack: |
|
1182 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1183 |
+ stack.enter_context( |
|
1184 |
+ pytest_machinery.isolated_vault_config( |
|
1185 |
+ monkeypatch=monkeypatch, |
|
1186 |
+ runner=runner, |
|
1187 |
+ vault_config={ |
|
1188 |
+ "global": {"phrase": "abc"}, |
|
1189 |
+ "services": {}, |
|
1190 |
+ }, |
|
1191 |
+ ) |
|
1192 |
+ ) |
|
1193 |
+ monkeypatch.setattr( |
|
1194 |
+ cli_helpers, |
|
1195 |
+ "prompt_for_passphrase", |
|
1196 |
+ callables.auto_prompt, |
|
1197 |
+ ) |
|
1198 |
+ result = runner.invoke( |
|
1199 |
+ cli.derivepassphrase_vault, |
|
1200 |
+ [*options, "--", DUMMY_SERVICE] if service else options, |
|
1201 |
+ input=input, |
|
1202 |
+ catch_exceptions=False, |
|
1203 |
+ ) |
|
1204 |
+ assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
1205 |
+ |
|
1206 |
+ def test_211a_empty_service_name_causes_warning( |
|
1207 |
+ self, |
|
1208 |
+ caplog: pytest.LogCaptureFixture, |
|
1209 |
+ ) -> None: |
|
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 |
+ |
|
1223 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1224 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1225 |
+ # with-statements. |
|
1226 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1227 |
+ with contextlib.ExitStack() as stack: |
|
1228 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1229 |
+ stack.enter_context( |
|
1230 |
+ pytest_machinery.isolated_vault_config( |
|
1231 |
+ monkeypatch=monkeypatch, |
|
1232 |
+ runner=runner, |
|
1233 |
+ vault_config={"services": {}}, |
|
1234 |
+ ) |
|
1235 |
+ ) |
|
1236 |
+ monkeypatch.setattr( |
|
1237 |
+ cli_helpers, |
|
1238 |
+ "prompt_for_passphrase", |
|
1239 |
+ callables.auto_prompt, |
|
1240 |
+ ) |
|
1241 |
+ result = runner.invoke( |
|
1242 |
+ cli.derivepassphrase_vault, |
|
1243 |
+ ["--config", "--length=30", "--", ""], |
|
1244 |
+ catch_exceptions=False, |
|
1245 |
+ ) |
|
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" |
|
1250 |
+ ) |
|
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" |
|
1271 |
+ |
|
1272 |
+ @Parametrize.OPTION_COMBINATIONS_INCOMPATIBLE |
|
1273 |
+ def test_212_incompatible_options( |
|
1274 |
+ self, |
|
1275 |
+ options: list[str], |
|
1276 |
+ service: bool | None, |
|
1277 |
+ ) -> None: |
|
1278 |
+ """Incompatible options are detected.""" |
|
1279 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1280 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1281 |
+ # with-statements. |
|
1282 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1283 |
+ with contextlib.ExitStack() as stack: |
|
1284 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1285 |
+ stack.enter_context( |
|
1286 |
+ pytest_machinery.isolated_config( |
|
1287 |
+ monkeypatch=monkeypatch, |
|
1288 |
+ runner=runner, |
|
1289 |
+ ) |
|
1290 |
+ ) |
|
1291 |
+ result = runner.invoke( |
|
1292 |
+ cli.derivepassphrase_vault, |
|
1293 |
+ [*options, "--", DUMMY_SERVICE] if service else options, |
|
1294 |
+ input=DUMMY_PASSPHRASE, |
|
1295 |
+ catch_exceptions=False, |
|
1296 |
+ ) |
|
1297 |
+ assert result.error_exit(error="mutually exclusive with "), ( |
|
1298 |
+ "expected error exit and known error message" |
|
1299 |
+ ) |
|
1300 |
+ |
|
1301 |
+ @Parametrize.VALID_TEST_CONFIGS |
|
1302 |
+ def test_213_import_config_success( |
|
1303 |
+ self, |
|
1304 |
+ caplog: pytest.LogCaptureFixture, |
|
1305 |
+ config: Any, |
|
1306 |
+ ) -> None: |
|
1307 |
+ """Importing a configuration works.""" |
|
1308 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1309 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1310 |
+ # with-statements. |
|
1311 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1312 |
+ with contextlib.ExitStack() as stack: |
|
1313 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1314 |
+ stack.enter_context( |
|
1315 |
+ pytest_machinery.isolated_vault_config( |
|
1316 |
+ monkeypatch=monkeypatch, |
|
1317 |
+ runner=runner, |
|
1318 |
+ vault_config={"services": {}}, |
|
1319 |
+ ) |
|
1320 |
+ ) |
|
1321 |
+ result = runner.invoke( |
|
1322 |
+ cli.derivepassphrase_vault, |
|
1323 |
+ ["--import", "-"], |
|
1324 |
+ input=json.dumps(config), |
|
1325 |
+ catch_exceptions=False, |
|
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) |
|
1337 |
+ |
|
1338 |
+ @hypothesis.settings( |
|
1339 |
+ suppress_health_check=[ |
|
1340 |
+ *hypothesis.settings().suppress_health_check, |
|
1341 |
+ hypothesis.HealthCheck.function_scoped_fixture, |
|
1342 |
+ ], |
|
1343 |
+ ) |
|
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 |
+ ]) |
|
1349 |
+ ) |
|
1350 |
+ ) |
|
1351 |
+ def test_213a_import_config_success( |
|
1352 |
+ self, |
|
1353 |
+ caplog: pytest.LogCaptureFixture, |
|
1354 |
+ conf: data.VaultTestConfig, |
|
1355 |
+ ) -> None: |
|
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() |
|
1366 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1367 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1368 |
+ # with-statements. |
|
1369 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1370 |
+ with contextlib.ExitStack() as stack: |
|
1371 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1372 |
+ stack.enter_context( |
|
1373 |
+ pytest_machinery.isolated_vault_config( |
|
1374 |
+ monkeypatch=monkeypatch, |
|
1375 |
+ runner=runner, |
|
1376 |
+ vault_config={"services": {}}, |
|
1377 |
+ ) |
|
1378 |
+ ) |
|
1379 |
+ result = runner.invoke( |
|
1380 |
+ cli.derivepassphrase_vault, |
|
1381 |
+ ["--import", "-"], |
|
1382 |
+ input=json.dumps(config), |
|
1383 |
+ catch_exceptions=False, |
|
1384 |
+ ) |
|
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( |
|
1392 |
+ map(is_harmless_config_import_warning, caplog.record_tuples) |
|
1393 |
+ ), "unexpected error output" |
|
1394 |
+ assert_vault_config_is_indented_and_line_broken(config_txt) |
|
1395 |
+ |
|
1396 |
+ def test_213b_import_bad_config_not_vault_config( |
|
1397 |
+ self, |
|
1398 |
+ ) -> None: |
|
1399 |
+ """Importing an invalid config fails.""" |
|
1400 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1401 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1402 |
+ # with-statements. |
|
1403 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1404 |
+ with contextlib.ExitStack() as stack: |
|
1405 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1406 |
+ stack.enter_context( |
|
1407 |
+ pytest_machinery.isolated_config( |
|
1408 |
+ monkeypatch=monkeypatch, |
|
1409 |
+ runner=runner, |
|
1410 |
+ ) |
|
1411 |
+ ) |
|
1412 |
+ result = runner.invoke( |
|
1413 |
+ cli.derivepassphrase_vault, |
|
1414 |
+ ["--import", "-"], |
|
1415 |
+ input="null", |
|
1416 |
+ catch_exceptions=False, |
|
1417 |
+ ) |
|
1418 |
+ assert result.error_exit(error="Invalid vault config"), ( |
|
1419 |
+ "expected error exit and known error message" |
|
1420 |
+ ) |
|
1421 |
+ |
|
1422 |
+ def test_213c_import_bad_config_not_json_data( |
|
1423 |
+ self, |
|
1424 |
+ ) -> None: |
|
1425 |
+ """Importing an invalid config fails.""" |
|
1426 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1427 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1428 |
+ # with-statements. |
|
1429 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1430 |
+ with contextlib.ExitStack() as stack: |
|
1431 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1432 |
+ stack.enter_context( |
|
1433 |
+ pytest_machinery.isolated_config( |
|
1434 |
+ monkeypatch=monkeypatch, |
|
1435 |
+ runner=runner, |
|
1436 |
+ ) |
|
1437 |
+ ) |
|
1438 |
+ result = runner.invoke( |
|
1439 |
+ cli.derivepassphrase_vault, |
|
1440 |
+ ["--import", "-"], |
|
1441 |
+ input="This string is not valid JSON.", |
|
1442 |
+ catch_exceptions=False, |
|
1443 |
+ ) |
|
1444 |
+ assert result.error_exit(error="cannot decode JSON"), ( |
|
1445 |
+ "expected error exit and known error message" |
|
1446 |
+ ) |
|
1447 |
+ |
|
1448 |
+ def test_213d_import_bad_config_not_a_file( |
|
1449 |
+ self, |
|
1450 |
+ ) -> None: |
|
1451 |
+ """Importing an invalid config fails.""" |
|
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 |
+ # |
|
1458 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1459 |
+ # with-statements. |
|
1460 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1461 |
+ with contextlib.ExitStack() as stack: |
|
1462 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1463 |
+ stack.enter_context( |
|
1464 |
+ pytest_machinery.isolated_vault_config( |
|
1465 |
+ monkeypatch=monkeypatch, |
|
1466 |
+ runner=runner, |
|
1467 |
+ vault_config={"services": {}}, |
|
1468 |
+ ) |
|
1469 |
+ ) |
|
1470 |
+ cli_helpers.config_filename(subsystem="vault").write_text( |
|
1471 |
+ "This string is not valid JSON.\n", encoding="UTF-8" |
|
1472 |
+ ) |
|
1473 |
+ dname = cli_helpers.config_filename(subsystem=None) |
|
1474 |
+ result = runner.invoke( |
|
1475 |
+ cli.derivepassphrase_vault, |
|
1476 |
+ ["--import", os.fsdecode(dname)], |
|
1477 |
+ catch_exceptions=False, |
|
1478 |
+ ) |
|
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)), ( |
|
1483 |
+ "expected error exit and known error message" |
|
1484 |
+ ) |
|
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) |
|
1494 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1495 |
+ # with-statements. |
|
1496 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1497 |
+ with contextlib.ExitStack() as stack: |
|
1498 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1499 |
+ stack.enter_context( |
|
1500 |
+ pytest_machinery.isolated_vault_config( |
|
1501 |
+ monkeypatch=monkeypatch, |
|
1502 |
+ runner=runner, |
|
1503 |
+ vault_config=config, |
|
1504 |
+ ) |
|
1505 |
+ ) |
|
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) |
|
1511 |
+ result = runner.invoke( |
|
1512 |
+ cli.derivepassphrase_vault, |
|
1513 |
+ ["--export", "-"], |
|
1514 |
+ catch_exceptions=False, |
|
1515 |
+ ) |
|
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) |
|
1526 |
+ |
|
1527 |
+ @Parametrize.EXPORT_FORMAT_OPTIONS |
|
1528 |
+ def test_214a_export_settings_no_stored_settings( |
|
1529 |
+ self, |
|
1530 |
+ export_options: list[str], |
|
1531 |
+ ) -> None: |
|
1532 |
+ """Exporting the default, empty config works.""" |
|
1533 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1534 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1535 |
+ # with-statements. |
|
1536 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1537 |
+ with contextlib.ExitStack() as stack: |
|
1538 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1539 |
+ stack.enter_context( |
|
1540 |
+ pytest_machinery.isolated_config( |
|
1541 |
+ monkeypatch=monkeypatch, |
|
1542 |
+ runner=runner, |
|
1543 |
+ ) |
|
1544 |
+ ) |
|
1545 |
+ cli_helpers.config_filename(subsystem="vault").unlink( |
|
1546 |
+ missing_ok=True |
|
1547 |
+ ) |
|
1548 |
+ result = runner.invoke( |
|
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], |
|
1555 |
+ catch_exceptions=False, |
|
1556 |
+ ) |
|
1557 |
+ assert result.clean_exit(empty_stderr=True), "expected clean exit" |
|
1558 |
+ |
|
1559 |
+ @Parametrize.EXPORT_FORMAT_OPTIONS |
|
1560 |
+ def test_214b_export_settings_bad_stored_config( |
|
1561 |
+ self, |
|
1562 |
+ export_options: list[str], |
|
1563 |
+ ) -> None: |
|
1564 |
+ """Exporting an invalid config fails.""" |
|
1565 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1566 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1567 |
+ # with-statements. |
|
1568 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1569 |
+ with contextlib.ExitStack() as stack: |
|
1570 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1571 |
+ stack.enter_context( |
|
1572 |
+ pytest_machinery.isolated_vault_config( |
|
1573 |
+ monkeypatch=monkeypatch, |
|
1574 |
+ runner=runner, |
|
1575 |
+ vault_config={}, |
|
1576 |
+ ) |
|
1577 |
+ ) |
|
1578 |
+ result = runner.invoke( |
|
1579 |
+ cli.derivepassphrase_vault, |
|
1580 |
+ ["--export", "-", *export_options], |
|
1581 |
+ input="null", |
|
1582 |
+ catch_exceptions=False, |
|
1583 |
+ ) |
|
1584 |
+ assert result.error_exit(error="Cannot load vault settings:"), ( |
|
1585 |
+ "expected error exit and known error message" |
|
1586 |
+ ) |
|
1587 |
+ |
|
1588 |
+ @Parametrize.EXPORT_FORMAT_OPTIONS |
|
1589 |
+ def test_214c_export_settings_not_a_file( |
|
1590 |
+ self, |
|
1591 |
+ export_options: list[str], |
|
1592 |
+ ) -> None: |
|
1593 |
+ """Exporting an invalid config fails.""" |
|
1594 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1595 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1596 |
+ # with-statements. |
|
1597 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1598 |
+ with contextlib.ExitStack() as stack: |
|
1599 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1600 |
+ stack.enter_context( |
|
1601 |
+ pytest_machinery.isolated_config( |
|
1602 |
+ monkeypatch=monkeypatch, |
|
1603 |
+ runner=runner, |
|
1604 |
+ ) |
|
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) |
|
1609 |
+ result = runner.invoke( |
|
1610 |
+ cli.derivepassphrase_vault, |
|
1611 |
+ ["--export", "-", *export_options], |
|
1612 |
+ input="null", |
|
1613 |
+ catch_exceptions=False, |
|
1614 |
+ ) |
|
1615 |
+ assert result.error_exit(error="Cannot load vault settings:"), ( |
|
1616 |
+ "expected error exit and known error message" |
|
1617 |
+ ) |
|
1618 |
+ |
|
1619 |
+ @Parametrize.EXPORT_FORMAT_OPTIONS |
|
1620 |
+ def test_214d_export_settings_target_not_a_file( |
|
1621 |
+ self, |
|
1622 |
+ export_options: list[str], |
|
1623 |
+ ) -> None: |
|
1624 |
+ """Exporting an invalid config fails.""" |
|
1625 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1626 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1627 |
+ # with-statements. |
|
1628 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1629 |
+ with contextlib.ExitStack() as stack: |
|
1630 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1631 |
+ stack.enter_context( |
|
1632 |
+ pytest_machinery.isolated_config( |
|
1633 |
+ monkeypatch=monkeypatch, |
|
1634 |
+ runner=runner, |
|
1635 |
+ ) |
|
1636 |
+ ) |
|
1637 |
+ dname = cli_helpers.config_filename(subsystem=None) |
|
1638 |
+ result = runner.invoke( |
|
1639 |
+ cli.derivepassphrase_vault, |
|
1640 |
+ ["--export", os.fsdecode(dname), *export_options], |
|
1641 |
+ input="null", |
|
1642 |
+ catch_exceptions=False, |
|
1643 |
+ ) |
|
1644 |
+ assert result.error_exit(error="Cannot export vault settings:"), ( |
|
1645 |
+ "expected error exit and known error message" |
|
1646 |
+ ) |
|
1647 |
+ |
|
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( |
|
1651 |
+ self, |
|
1652 |
+ export_options: list[str], |
|
1653 |
+ ) -> None: |
|
1654 |
+ """Exporting an invalid config fails.""" |
|
1655 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1656 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1657 |
+ # with-statements. |
|
1658 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1659 |
+ with contextlib.ExitStack() as stack: |
|
1660 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1661 |
+ stack.enter_context( |
|
1662 |
+ pytest_machinery.isolated_config( |
|
1663 |
+ monkeypatch=monkeypatch, |
|
1664 |
+ runner=runner, |
|
1665 |
+ ) |
|
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") |
|
1671 |
+ result = runner.invoke( |
|
1672 |
+ cli.derivepassphrase_vault, |
|
1673 |
+ ["--export", "-", *export_options], |
|
1674 |
+ input="null", |
|
1675 |
+ catch_exceptions=False, |
|
1676 |
+ ) |
|
1677 |
+ assert result.error_exit( |
|
1678 |
+ error="Cannot load vault settings:" |
|
1679 |
+ ) or result.error_exit(error="Cannot load user config:"), ( |
|
1680 |
+ "expected error exit and known error message" |
|
1681 |
+ ) |
|
1682 |
+ |
|
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( |
|
1694 |
+ self, |
|
1695 |
+ notes_placement: Literal["before", "after"], |
|
1696 |
+ placement_args: list[str], |
|
1697 |
+ notes: str, |
|
1698 |
+ ) -> None: |
|
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) |
|
1714 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1715 |
+ # with-statements. |
|
1716 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1717 |
+ with contextlib.ExitStack() as stack: |
|
1718 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1719 |
+ stack.enter_context( |
|
1720 |
+ pytest_machinery.isolated_vault_config( |
|
1721 |
+ monkeypatch=monkeypatch, |
|
1722 |
+ runner=runner, |
|
1723 |
+ vault_config=vault_config, |
|
1724 |
+ ) |
|
1725 |
+ ) |
|
1726 |
+ result = runner.invoke( |
|
1727 |
+ cli.derivepassphrase_vault, |
|
1728 |
+ [*placement_args, "--", DUMMY_SERVICE], |
|
1729 |
+ catch_exceptions=False, |
|
1730 |
+ ) |
|
1731 |
+ assert result.clean_exit(output=expected), "expected clean exit" |
|
1732 |
+ |
|
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 |
+ ], |
|
1739 |
+ ) |
|
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), |
|
1748 |
+ ) |
|
1749 |
+ def test_220_edit_notes_successfully( |
|
1750 |
+ self, |
|
1751 |
+ caplog: pytest.LogCaptureFixture, |
|
1752 |
+ modern_editor_interface: bool, |
|
1753 |
+ notes: str, |
|
1754 |
+ ) -> None: |
|
1755 |
+ """Editing notes works.""" |
|
1756 |
+ marker = cli_messages.TranslatedString( |
|
1757 |
+ cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER |
|
1758 |
+ ) |
|
1759 |
+ edit_result = f""" |
|
1760 |
+ |
|
1761 |
+{marker} |
|
1762 |
+{notes} |
|
1763 |
+""" |
|
1764 |
+ # Reset caplog between hypothesis runs. |
|
1765 |
+ caplog.clear() |
|
1766 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1767 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1768 |
+ # with-statements. |
|
1769 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1770 |
+ with contextlib.ExitStack() as stack: |
|
1771 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1772 |
+ stack.enter_context( |
|
1773 |
+ pytest_machinery.isolated_vault_config( |
|
1774 |
+ monkeypatch=monkeypatch, |
|
1775 |
+ runner=runner, |
|
1776 |
+ vault_config={ |
|
1777 |
+ "global": {"phrase": "abc"}, |
|
1778 |
+ "services": {"sv": {"notes": "Contents go here"}}, |
|
1779 |
+ }, |
|
1780 |
+ ) |
|
1781 |
+ ) |
|
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) |
|
1790 |
+ result = runner.invoke( |
|
1791 |
+ cli.derivepassphrase_vault, |
|
1792 |
+ [ |
|
1793 |
+ "--config", |
|
1794 |
+ "--notes", |
|
1795 |
+ "--modern-editor-interface" |
|
1796 |
+ if modern_editor_interface |
|
1797 |
+ else "--vault-legacy-editor-interface", |
|
1798 |
+ "--", |
|
1799 |
+ "sv", |
|
1800 |
+ ], |
|
1801 |
+ catch_exceptions=False, |
|
1802 |
+ ) |
|
1803 |
+ assert result.clean_exit(), "expected clean exit" |
|
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" |
|
1813 |
+ ) |
|
1814 |
+ with cli_helpers.config_filename(subsystem="vault").open( |
|
1815 |
+ encoding="UTF-8" |
|
1816 |
+ ) as infile: |
|
1817 |
+ config = json.load(infile) |
|
1818 |
+ assert config == { |
|
1819 |
+ "global": {"phrase": "abc"}, |
|
1820 |
+ "services": { |
|
1821 |
+ "sv": { |
|
1822 |
+ "notes": notes.strip() |
|
1823 |
+ if modern_editor_interface |
|
1824 |
+ else edit_result.strip() |
|
1825 |
+ } |
|
1826 |
+ }, |
|
1827 |
+ } |
|
1828 |
+ |
|
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( |
|
1840 |
+ self, |
|
1841 |
+ edit_func_name: Literal["empty", "space"], |
|
1842 |
+ modern_editor_interface: bool, |
|
1843 |
+ notes: str, |
|
1844 |
+ ) -> None: |
|
1845 |
+ """Abandoning edited notes works.""" |
|
1846 |
+ |
|
1847 |
+ def empty(text: str, *_args: Any, **_kwargs: Any) -> str: |
|
1848 |
+ del text |
|
1849 |
+ return "" |
|
1850 |
+ |
|
1851 |
+ def space(text: str, *_args: Any, **_kwargs: Any) -> str: |
|
1852 |
+ del text |
|
1853 |
+ return " " + notes.strip() + "\n\n\n\n\n\n" |
|
1854 |
+ |
|
1855 |
+ edit_funcs = {"empty": empty, "space": space} |
|
1856 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1857 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1858 |
+ # with-statements. |
|
1859 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1860 |
+ with contextlib.ExitStack() as stack: |
|
1861 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1862 |
+ stack.enter_context( |
|
1863 |
+ pytest_machinery.isolated_vault_config( |
|
1864 |
+ monkeypatch=monkeypatch, |
|
1865 |
+ runner=runner, |
|
1866 |
+ vault_config={ |
|
1867 |
+ "global": {"phrase": "abc"}, |
|
1868 |
+ "services": {"sv": {"notes": notes.strip()}}, |
|
1869 |
+ }, |
|
1870 |
+ ) |
|
1871 |
+ ) |
|
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", |
|
1878 |
+ ) |
|
1879 |
+ monkeypatch.setattr(click, "edit", edit_funcs[edit_func_name]) |
|
1880 |
+ result = runner.invoke( |
|
1881 |
+ cli.derivepassphrase_vault, |
|
1882 |
+ [ |
|
1883 |
+ "--config", |
|
1884 |
+ "--notes", |
|
1885 |
+ "--modern-editor-interface" |
|
1886 |
+ if modern_editor_interface |
|
1887 |
+ else "--vault-legacy-editor-interface", |
|
1888 |
+ "--", |
|
1889 |
+ "sv", |
|
1890 |
+ ], |
|
1891 |
+ catch_exceptions=False, |
|
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." |
|
1900 |
+ ) |
|
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 |
+ } |
|
1909 |
+ |
|
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 |
+ ], |
|
1918 |
+ ) |
|
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), |
|
1927 |
+ ) |
|
1928 |
+ def test_222_edit_notes_marker_removed( |
|
1929 |
+ self, |
|
1930 |
+ caplog: pytest.LogCaptureFixture, |
|
1931 |
+ modern_editor_interface: bool, |
|
1932 |
+ notes: str, |
|
1933 |
+ ) -> None: |
|
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() |
|
1945 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
1946 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1947 |
+ # with-statements. |
|
1948 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1949 |
+ with contextlib.ExitStack() as stack: |
|
1950 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1951 |
+ stack.enter_context( |
|
1952 |
+ pytest_machinery.isolated_vault_config( |
|
1953 |
+ monkeypatch=monkeypatch, |
|
1954 |
+ runner=runner, |
|
1955 |
+ vault_config={ |
|
1956 |
+ "global": {"phrase": "abc"}, |
|
1957 |
+ "services": {"sv": {"notes": "Contents go here"}}, |
|
1958 |
+ }, |
|
1959 |
+ ) |
|
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) |
|
1969 |
+ result = runner.invoke( |
|
1970 |
+ cli.derivepassphrase_vault, |
|
1971 |
+ [ |
|
1972 |
+ "--config", |
|
1973 |
+ "--notes", |
|
1974 |
+ "--modern-editor-interface" |
|
1975 |
+ if modern_editor_interface |
|
1976 |
+ else "--vault-legacy-editor-interface", |
|
1977 |
+ "--", |
|
1978 |
+ "sv", |
|
1979 |
+ ], |
|
1980 |
+ catch_exceptions=False, |
|
1981 |
+ ) |
|
1982 |
+ assert result.clean_exit(), "expected clean exit" |
|
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, |
|
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 |
+ } |
|
2003 |
+ |
|
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( |
|
2014 |
+ self, |
|
2015 |
+ notes: str, |
|
2016 |
+ ) -> None: |
|
2017 |
+ """Aborting editing notes works. |
|
2018 |
+ |
|
2019 |
+ Aborting is only supported with the modern editor interface. |
|
2020 |
+ |
|
2021 |
+ """ |
|
2022 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2023 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2024 |
+ # with-statements. |
|
2025 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2026 |
+ with contextlib.ExitStack() as stack: |
|
2027 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2028 |
+ stack.enter_context( |
|
2029 |
+ pytest_machinery.isolated_vault_config( |
|
2030 |
+ monkeypatch=monkeypatch, |
|
2031 |
+ runner=runner, |
|
2032 |
+ vault_config={ |
|
2033 |
+ "global": {"phrase": "abc"}, |
|
2034 |
+ "services": {"sv": {"notes": notes.strip()}}, |
|
2035 |
+ }, |
|
2036 |
+ ) |
|
2037 |
+ ) |
|
2038 |
+ monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "") |
|
2039 |
+ result = runner.invoke( |
|
2040 |
+ cli.derivepassphrase_vault, |
|
2041 |
+ [ |
|
2042 |
+ "--config", |
|
2043 |
+ "--notes", |
|
2044 |
+ "--modern-editor-interface", |
|
2045 |
+ "--", |
|
2046 |
+ "sv", |
|
2047 |
+ ], |
|
2048 |
+ catch_exceptions=False, |
|
2049 |
+ ) |
|
2050 |
+ assert result.error_exit(error="the user aborted the request"), ( |
|
2051 |
+ "expected known error message" |
|
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 |
+ } |
|
2061 |
+ |
|
2062 |
+ def test_223a_edit_empty_notes_abort( |
|
2063 |
+ self, |
|
2064 |
+ ) -> None: |
|
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 |
+ """ |
|
2070 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2071 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2072 |
+ # with-statements. |
|
2073 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2074 |
+ with contextlib.ExitStack() as stack: |
|
2075 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2076 |
+ stack.enter_context( |
|
2077 |
+ pytest_machinery.isolated_vault_config( |
|
2078 |
+ monkeypatch=monkeypatch, |
|
2079 |
+ runner=runner, |
|
2080 |
+ vault_config={ |
|
2081 |
+ "global": {"phrase": "abc"}, |
|
2082 |
+ "services": {}, |
|
2083 |
+ }, |
|
2084 |
+ ) |
|
2085 |
+ ) |
|
2086 |
+ monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "") |
|
2087 |
+ result = runner.invoke( |
|
2088 |
+ cli.derivepassphrase_vault, |
|
2089 |
+ [ |
|
2090 |
+ "--config", |
|
2091 |
+ "--notes", |
|
2092 |
+ "--modern-editor-interface", |
|
2093 |
+ "--", |
|
2094 |
+ "sv", |
|
2095 |
+ ], |
|
2096 |
+ catch_exceptions=False, |
|
2097 |
+ ) |
|
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 |
+ } |
|
2109 |
+ |
|
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( |
|
2126 |
+ self, |
|
2127 |
+ caplog: pytest.LogCaptureFixture, |
|
2128 |
+ modern_editor_interface: bool, |
|
2129 |
+ notes: str, |
|
2130 |
+ ) -> None: |
|
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() |
|
2141 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2142 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2143 |
+ # with-statements. |
|
2144 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2145 |
+ with contextlib.ExitStack() as stack: |
|
2146 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2147 |
+ stack.enter_context( |
|
2148 |
+ pytest_machinery.isolated_vault_config( |
|
2149 |
+ monkeypatch=monkeypatch, |
|
2150 |
+ runner=runner, |
|
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" |
|
2161 |
+ ) |
|
2162 |
+ notes_backup_file.write_text( |
|
2163 |
+ "These backup notes are left over from the previous session.", |
|
2164 |
+ encoding="UTF-8", |
|
2165 |
+ ) |
|
2166 |
+ monkeypatch.setattr(click, "edit", raiser) |
|
2167 |
+ result = runner.invoke( |
|
2168 |
+ cli.derivepassphrase_vault, |
|
2169 |
+ [ |
|
2170 |
+ "--notes", |
|
2171 |
+ "--modern-editor-interface" |
|
2172 |
+ if modern_editor_interface |
|
2173 |
+ else "--vault-legacy-editor-interface", |
|
2174 |
+ "--", |
|
2175 |
+ DUMMY_SERVICE, |
|
2176 |
+ ], |
|
2177 |
+ catch_exceptions=False, |
|
2178 |
+ ) |
|
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." |
|
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 |
|
2204 |
+ |
|
2205 |
+ @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG |
|
2206 |
+ def test_224_store_config_good( |
|
2207 |
+ self, |
|
2208 |
+ command_line: list[str], |
|
2209 |
+ input: str, |
|
2210 |
+ result_config: Any, |
|
2211 |
+ ) -> None: |
|
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 |
+ """ |
|
2218 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2219 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2220 |
+ # with-statements. |
|
2221 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2222 |
+ with contextlib.ExitStack() as stack: |
|
2223 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2224 |
+ stack.enter_context( |
|
2225 |
+ pytest_machinery.isolated_vault_config( |
|
2226 |
+ monkeypatch=monkeypatch, |
|
2227 |
+ runner=runner, |
|
2228 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2229 |
+ ) |
|
2230 |
+ ) |
|
2231 |
+ monkeypatch.setattr( |
|
2232 |
+ cli_helpers, |
|
2233 |
+ "get_suitable_ssh_keys", |
|
2234 |
+ callables.suitable_ssh_keys, |
|
2235 |
+ ) |
|
2236 |
+ result = runner.invoke( |
|
2237 |
+ cli.derivepassphrase_vault, |
|
2238 |
+ ["--config", *command_line], |
|
2239 |
+ catch_exceptions=False, |
|
2240 |
+ input=input, |
|
2241 |
+ ) |
|
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" |
|
2249 |
+ ) |
|
2250 |
+ assert_vault_config_is_indented_and_line_broken(config_txt) |
|
2251 |
+ |
|
2252 |
+ @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES |
|
2253 |
+ def test_225_store_config_fail( |
|
2254 |
+ self, |
|
2255 |
+ command_line: list[str], |
|
2256 |
+ input: str, |
|
2257 |
+ err_text: str, |
|
2258 |
+ ) -> None: |
|
2259 |
+ """Storing invalid settings via `--config` fails.""" |
|
2260 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2261 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2262 |
+ # with-statements. |
|
2263 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2264 |
+ with contextlib.ExitStack() as stack: |
|
2265 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2266 |
+ stack.enter_context( |
|
2267 |
+ pytest_machinery.isolated_vault_config( |
|
2268 |
+ monkeypatch=monkeypatch, |
|
2269 |
+ runner=runner, |
|
2270 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2271 |
+ ) |
|
2272 |
+ ) |
|
2273 |
+ monkeypatch.setattr( |
|
2274 |
+ cli_helpers, |
|
2275 |
+ "get_suitable_ssh_keys", |
|
2276 |
+ callables.suitable_ssh_keys, |
|
2277 |
+ ) |
|
2278 |
+ result = runner.invoke( |
|
2279 |
+ cli.derivepassphrase_vault, |
|
2280 |
+ ["--config", *command_line], |
|
2281 |
+ catch_exceptions=False, |
|
2282 |
+ input=input, |
|
2283 |
+ ) |
|
2284 |
+ assert result.error_exit(error=err_text), ( |
|
2285 |
+ "expected error exit and known error message" |
|
2286 |
+ ) |
|
2287 |
+ |
|
2288 |
+ def test_225a_store_config_fail_manual_no_ssh_key_selection( |
|
2289 |
+ self, |
|
2290 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
2291 |
+ ) -> None: |
|
2292 |
+ """Not selecting an SSH key during `--config --key` fails.""" |
|
2293 |
+ del running_ssh_agent |
|
2294 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2295 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2296 |
+ # with-statements. |
|
2297 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2298 |
+ with contextlib.ExitStack() as stack: |
|
2299 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2300 |
+ stack.enter_context( |
|
2301 |
+ pytest_machinery.isolated_vault_config( |
|
2302 |
+ monkeypatch=monkeypatch, |
|
2303 |
+ runner=runner, |
|
2304 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2305 |
+ ) |
|
2306 |
+ ) |
|
2307 |
+ |
|
2308 |
+ def prompt_for_selection(*_args: Any, **_kwargs: Any) -> NoReturn: |
|
2309 |
+ raise IndexError(cli_helpers.EMPTY_SELECTION) |
|
2310 |
+ |
|
2311 |
+ monkeypatch.setattr( |
|
2312 |
+ cli_helpers, "prompt_for_selection", prompt_for_selection |
|
2313 |
+ ) |
|
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, |
|
2320 |
+ ) |
|
2321 |
+ result = runner.invoke( |
|
2322 |
+ cli.derivepassphrase_vault, |
|
2323 |
+ ["--key", "--config"], |
|
2324 |
+ catch_exceptions=False, |
|
2325 |
+ ) |
|
2326 |
+ assert result.error_exit(error="the user aborted the request"), ( |
|
2327 |
+ "expected error exit and known error message" |
|
2328 |
+ ) |
|
2329 |
+ |
|
2330 |
+ def test_225b_store_config_fail_manual_no_ssh_agent( |
|
2331 |
+ self, |
|
2332 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
2333 |
+ ) -> None: |
|
2334 |
+ """Not running an SSH agent during `--config --key` fails.""" |
|
2335 |
+ del running_ssh_agent |
|
2336 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2337 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2338 |
+ # with-statements. |
|
2339 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2340 |
+ with contextlib.ExitStack() as stack: |
|
2341 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2342 |
+ stack.enter_context( |
|
2343 |
+ pytest_machinery.isolated_vault_config( |
|
2344 |
+ monkeypatch=monkeypatch, |
|
2345 |
+ runner=runner, |
|
2346 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2347 |
+ ) |
|
2348 |
+ ) |
|
2349 |
+ monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) |
|
2350 |
+ result = runner.invoke( |
|
2351 |
+ cli.derivepassphrase_vault, |
|
2352 |
+ ["--key", "--config"], |
|
2353 |
+ catch_exceptions=False, |
|
2354 |
+ ) |
|
2355 |
+ assert result.error_exit(error="Cannot find any running SSH agent"), ( |
|
2356 |
+ "expected error exit and known error message" |
|
2357 |
+ ) |
|
2358 |
+ |
|
2359 |
+ def test_225c_store_config_fail_manual_bad_ssh_agent_connection( |
|
2360 |
+ self, |
|
2361 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
2362 |
+ ) -> None: |
|
2363 |
+ """Not running a reachable SSH agent during `--config --key` fails.""" |
|
2364 |
+ running_ssh_agent.require_external_address() |
|
2365 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2366 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2367 |
+ # with-statements. |
|
2368 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2369 |
+ with contextlib.ExitStack() as stack: |
|
2370 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2371 |
+ stack.enter_context( |
|
2372 |
+ pytest_machinery.isolated_vault_config( |
|
2373 |
+ monkeypatch=monkeypatch, |
|
2374 |
+ runner=runner, |
|
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, |
|
2384 |
+ ) |
|
2385 |
+ assert result.error_exit(error="Cannot connect to the SSH agent"), ( |
|
2386 |
+ "expected error exit and known error message" |
|
2387 |
+ ) |
|
2388 |
+ |
|
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.""" |
|
2395 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2396 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2397 |
+ # with-statements. |
|
2398 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2399 |
+ with contextlib.ExitStack() as stack: |
|
2400 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2401 |
+ stack.enter_context( |
|
2402 |
+ pytest_machinery.isolated_vault_config( |
|
2403 |
+ monkeypatch=monkeypatch, |
|
2404 |
+ runner=runner, |
|
2405 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2406 |
+ ) |
|
2407 |
+ ) |
|
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" |
|
2419 |
+ ) |
|
2420 |
+ |
|
2421 |
+ def test_225e_store_config_fail_manual_custom_error( |
|
2422 |
+ self, |
|
2423 |
+ ) -> None: |
|
2424 |
+ """OS-erroring with `--config` fails.""" |
|
2425 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2426 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2427 |
+ # with-statements. |
|
2428 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2429 |
+ with contextlib.ExitStack() as stack: |
|
2430 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2431 |
+ stack.enter_context( |
|
2432 |
+ pytest_machinery.isolated_vault_config( |
|
2433 |
+ monkeypatch=monkeypatch, |
|
2434 |
+ runner=runner, |
|
2435 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2436 |
+ ) |
|
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) |
|
2445 |
+ result = runner.invoke( |
|
2446 |
+ cli.derivepassphrase_vault, |
|
2447 |
+ ["--config", "--length=15", "--", DUMMY_SERVICE], |
|
2448 |
+ catch_exceptions=False, |
|
2449 |
+ ) |
|
2450 |
+ assert result.error_exit(error=custom_error), ( |
|
2451 |
+ "expected error exit and known error message" |
|
2452 |
+ ) |
|
2453 |
+ |
|
2454 |
+ def test_225f_store_config_fail_unset_and_set_same_settings( |
|
2455 |
+ self, |
|
2456 |
+ ) -> None: |
|
2457 |
+ """Issuing conflicting settings to `--config` fails.""" |
|
2458 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2459 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2460 |
+ # with-statements. |
|
2461 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2462 |
+ with contextlib.ExitStack() as stack: |
|
2463 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2464 |
+ stack.enter_context( |
|
2465 |
+ pytest_machinery.isolated_vault_config( |
|
2466 |
+ monkeypatch=monkeypatch, |
|
2467 |
+ runner=runner, |
|
2468 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2469 |
+ ) |
|
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, |
|
2481 |
+ ) |
|
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" |
|
2485 |
+ |
|
2486 |
+ def test_225g_store_config_fail_manual_ssh_agent_no_keys_loaded( |
|
2487 |
+ self, |
|
2488 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
2489 |
+ ) -> None: |
|
2490 |
+ """Not holding any SSH keys during `--config --key` fails.""" |
|
2491 |
+ del running_ssh_agent |
|
2492 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2493 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2494 |
+ # with-statements. |
|
2495 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2496 |
+ with contextlib.ExitStack() as stack: |
|
2497 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2498 |
+ stack.enter_context( |
|
2499 |
+ pytest_machinery.isolated_vault_config( |
|
2500 |
+ monkeypatch=monkeypatch, |
|
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, |
|
2517 |
+ ) |
|
2518 |
+ assert result.error_exit(error="no keys suitable"), ( |
|
2519 |
+ "expected error exit and known error message" |
|
2520 |
+ ) |
|
2521 |
+ |
|
2522 |
+ def test_225h_store_config_fail_manual_ssh_agent_runtime_error( |
|
2523 |
+ self, |
|
2524 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
2525 |
+ ) -> None: |
|
2526 |
+ """The SSH agent erroring during `--config --key` fails.""" |
|
2527 |
+ del running_ssh_agent |
|
2528 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2529 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2530 |
+ # with-statements. |
|
2531 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2532 |
+ with contextlib.ExitStack() as stack: |
|
2533 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2534 |
+ stack.enter_context( |
|
2535 |
+ pytest_machinery.isolated_vault_config( |
|
2536 |
+ monkeypatch=monkeypatch, |
|
2537 |
+ runner=runner, |
|
2538 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2539 |
+ ) |
|
2540 |
+ ) |
|
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, |
|
2550 |
+ ) |
|
2551 |
+ assert result.error_exit( |
|
2552 |
+ error="violates the communication protocol." |
|
2553 |
+ ), "expected error exit and known error message" |
|
2554 |
+ |
|
2555 |
+ def test_225i_store_config_fail_manual_ssh_agent_refuses( |
|
2556 |
+ self, |
|
2557 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
2558 |
+ ) -> None: |
|
2559 |
+ """The SSH agent refusing during `--config --key` fails.""" |
|
2560 |
+ del running_ssh_agent |
|
2561 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2562 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2563 |
+ # with-statements. |
|
2564 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2565 |
+ with contextlib.ExitStack() as stack: |
|
2566 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2567 |
+ stack.enter_context( |
|
2568 |
+ pytest_machinery.isolated_vault_config( |
|
2569 |
+ monkeypatch=monkeypatch, |
|
2570 |
+ runner=runner, |
|
2571 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2572 |
+ ) |
|
2573 |
+ ) |
|
2574 |
+ |
|
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.""" |
|
2592 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2593 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2594 |
+ # with-statements. |
|
2595 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2596 |
+ with contextlib.ExitStack() as stack: |
|
2597 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2598 |
+ stack.enter_context( |
|
2599 |
+ pytest_machinery.isolated_config( |
|
2600 |
+ monkeypatch=monkeypatch, |
|
2601 |
+ runner=runner, |
|
2602 |
+ ) |
|
2603 |
+ ) |
|
2604 |
+ result = runner.invoke( |
|
2605 |
+ cli.derivepassphrase_vault, [], catch_exceptions=False |
|
2606 |
+ ) |
|
2607 |
+ assert result.error_exit( |
|
2608 |
+ error="Deriving a passphrase requires a SERVICE" |
|
2609 |
+ ), "expected error exit and known error message" |
|
2610 |
+ |
|
2611 |
+ def test_226a_no_passphrase_or_key( |
|
2612 |
+ self, |
|
2613 |
+ ) -> None: |
|
2614 |
+ """Deriving a passphrase without a passphrase or key fails.""" |
|
2615 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2616 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2617 |
+ # with-statements. |
|
2618 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2619 |
+ with contextlib.ExitStack() as stack: |
|
2620 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2621 |
+ stack.enter_context( |
|
2622 |
+ pytest_machinery.isolated_config( |
|
2623 |
+ monkeypatch=monkeypatch, |
|
2624 |
+ runner=runner, |
|
2625 |
+ ) |
|
2626 |
+ ) |
|
2627 |
+ result = runner.invoke( |
|
2628 |
+ cli.derivepassphrase_vault, |
|
2629 |
+ ["--", DUMMY_SERVICE], |
|
2630 |
+ catch_exceptions=False, |
|
2631 |
+ ) |
|
2632 |
+ assert result.error_exit(error="No passphrase or key was given"), ( |
|
2633 |
+ "expected error exit and known error message" |
|
2634 |
+ ) |
|
2635 |
+ |
|
2636 |
+ def test_230_config_directory_nonexistant( |
|
2637 |
+ self, |
|
2638 |
+ ) -> None: |
|
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 |
+ """ |
|
2646 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2647 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2648 |
+ # with-statements. |
|
2649 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2650 |
+ with contextlib.ExitStack() as stack: |
|
2651 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2652 |
+ stack.enter_context( |
|
2653 |
+ pytest_machinery.isolated_config( |
|
2654 |
+ monkeypatch=monkeypatch, |
|
2655 |
+ runner=runner, |
|
2656 |
+ ) |
|
2657 |
+ ) |
|
2658 |
+ with contextlib.suppress(FileNotFoundError): |
|
2659 |
+ shutil.rmtree(cli_helpers.config_filename(subsystem=None)) |
|
2660 |
+ result = runner.invoke( |
|
2661 |
+ cli.derivepassphrase_vault, |
|
2662 |
+ ["--config", "-p"], |
|
2663 |
+ catch_exceptions=False, |
|
2664 |
+ input="abc\n", |
|
2665 |
+ ) |
|
2666 |
+ assert result.clean_exit(), "expected clean exit" |
|
2667 |
+ assert result.stderr == "Passphrase:", ( |
|
2668 |
+ "program unexpectedly failed?!" |
|
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. |
|
2688 |
+ |
|
2689 |
+ [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6 |
|
2690 |
+ |
|
2691 |
+ """ |
|
2692 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2693 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2694 |
+ # with-statements. |
|
2695 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2696 |
+ with contextlib.ExitStack() as stack: |
|
2697 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2698 |
+ stack.enter_context( |
|
2699 |
+ pytest_machinery.isolated_config( |
|
2700 |
+ monkeypatch=monkeypatch, |
|
2701 |
+ runner=runner, |
|
2702 |
+ ) |
|
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 |
+ ) |
|
2717 |
+ result = runner.invoke( |
|
2718 |
+ cli.derivepassphrase_vault, |
|
2719 |
+ ["--config", "-p"], |
|
2720 |
+ catch_exceptions=False, |
|
2721 |
+ input="abc\n", |
|
2722 |
+ ) |
|
2723 |
+ assert result.error_exit(error="Cannot store vault settings:"), ( |
|
2724 |
+ "expected error exit and known error message" |
|
2725 |
+ ) |
|
2726 |
+ |
|
2727 |
+ def test_230b_store_config_custom_error( |
|
2728 |
+ self, |
|
2729 |
+ ) -> None: |
|
2730 |
+ """Storing the configuration reacts even to weird errors.""" |
|
2731 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2732 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2733 |
+ # with-statements. |
|
2734 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2735 |
+ with contextlib.ExitStack() as stack: |
|
2736 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2737 |
+ stack.enter_context( |
|
2738 |
+ pytest_machinery.isolated_config( |
|
2739 |
+ monkeypatch=monkeypatch, |
|
2740 |
+ runner=runner, |
|
2741 |
+ ) |
|
2742 |
+ ) |
|
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) |
|
2750 |
+ result = runner.invoke( |
|
2751 |
+ cli.derivepassphrase_vault, |
|
2752 |
+ ["--config", "-p"], |
|
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" |
|
2758 |
+ ) |
|
2759 |
+ |
|
2760 |
+ @Parametrize.UNICODE_NORMALIZATION_WARNING_INPUTS |
|
2761 |
+ def test_300_unicode_normalization_form_warning( |
|
2762 |
+ self, |
|
2763 |
+ caplog: pytest.LogCaptureFixture, |
|
2764 |
+ main_config: str, |
|
2765 |
+ command_line: list[str], |
|
2766 |
+ input: str | None, |
|
2767 |
+ warning_message: str, |
|
2768 |
+ ) -> None: |
|
2769 |
+ """Using unnormalized Unicode passphrases warns.""" |
|
2770 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2771 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2772 |
+ # with-statements. |
|
2773 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2774 |
+ with contextlib.ExitStack() as stack: |
|
2775 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2776 |
+ stack.enter_context( |
|
2777 |
+ pytest_machinery.isolated_vault_config( |
|
2778 |
+ monkeypatch=monkeypatch, |
|
2779 |
+ runner=runner, |
|
2780 |
+ vault_config={ |
|
2781 |
+ "services": { |
|
2782 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy() |
|
2783 |
+ } |
|
2784 |
+ }, |
|
2785 |
+ main_config_str=main_config, |
|
2786 |
+ ) |
|
2787 |
+ ) |
|
2788 |
+ result = runner.invoke( |
|
2789 |
+ cli.derivepassphrase_vault, |
|
2790 |
+ ["--debug", *command_line], |
|
2791 |
+ catch_exceptions=False, |
|
2792 |
+ input=input, |
|
2793 |
+ ) |
|
2794 |
+ assert result.clean_exit(), "expected clean exit" |
|
2795 |
+ assert machinery.warning_emitted( |
|
2796 |
+ warning_message, caplog.record_tuples |
|
2797 |
+ ), "expected known warning message in stderr" |
|
2798 |
+ |
|
2799 |
+ @Parametrize.UNICODE_NORMALIZATION_ERROR_INPUTS |
|
2800 |
+ def test_301_unicode_normalization_form_error( |
|
2801 |
+ self, |
|
2802 |
+ main_config: str, |
|
2803 |
+ command_line: list[str], |
|
2804 |
+ input: str | None, |
|
2805 |
+ error_message: str, |
|
2806 |
+ ) -> None: |
|
2807 |
+ """Using unknown Unicode normalization forms fails.""" |
|
2808 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2809 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2810 |
+ # with-statements. |
|
2811 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2812 |
+ with contextlib.ExitStack() as stack: |
|
2813 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2814 |
+ stack.enter_context( |
|
2815 |
+ pytest_machinery.isolated_vault_config( |
|
2816 |
+ monkeypatch=monkeypatch, |
|
2817 |
+ runner=runner, |
|
2818 |
+ vault_config={ |
|
2819 |
+ "services": { |
|
2820 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy() |
|
2821 |
+ } |
|
2822 |
+ }, |
|
2823 |
+ main_config_str=main_config, |
|
2824 |
+ ) |
|
2825 |
+ ) |
|
2826 |
+ result = runner.invoke( |
|
2827 |
+ cli.derivepassphrase_vault, |
|
2828 |
+ command_line, |
|
2829 |
+ catch_exceptions=False, |
|
2830 |
+ input=input, |
|
2831 |
+ ) |
|
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" |
|
2837 |
+ ) |
|
2838 |
+ |
|
2839 |
+ @Parametrize.UNICODE_NORMALIZATION_COMMAND_LINES |
|
2840 |
+ def test_301a_unicode_normalization_form_error_from_stored_config( |
|
2841 |
+ self, |
|
2842 |
+ command_line: list[str], |
|
2843 |
+ ) -> None: |
|
2844 |
+ """Using unknown Unicode normalization forms in the config fails.""" |
|
2845 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2846 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2847 |
+ # with-statements. |
|
2848 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2849 |
+ with contextlib.ExitStack() as stack: |
|
2850 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2851 |
+ stack.enter_context( |
|
2852 |
+ pytest_machinery.isolated_vault_config( |
|
2853 |
+ monkeypatch=monkeypatch, |
|
2854 |
+ runner=runner, |
|
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 |
+ ) |
|
2864 |
+ ) |
|
2865 |
+ result = runner.invoke( |
|
2866 |
+ cli.derivepassphrase_vault, |
|
2867 |
+ command_line, |
|
2868 |
+ input=DUMMY_PASSPHRASE, |
|
2869 |
+ catch_exceptions=False, |
|
2870 |
+ ) |
|
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" |
|
2880 |
+ |
|
2881 |
+ def test_310_bad_user_config_file( |
|
2882 |
+ self, |
|
2883 |
+ ) -> None: |
|
2884 |
+ """Loading a user configuration file in an invalid format fails.""" |
|
2885 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2886 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2887 |
+ # with-statements. |
|
2888 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2889 |
+ with contextlib.ExitStack() as stack: |
|
2890 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2891 |
+ stack.enter_context( |
|
2892 |
+ pytest_machinery.isolated_vault_config( |
|
2893 |
+ monkeypatch=monkeypatch, |
|
2894 |
+ runner=runner, |
|
2895 |
+ vault_config={"services": {}}, |
|
2896 |
+ main_config_str="This file is not valid TOML.\n", |
|
2897 |
+ ) |
|
2898 |
+ ) |
|
2899 |
+ result = runner.invoke( |
|
2900 |
+ cli.derivepassphrase_vault, |
|
2901 |
+ ["--phrase", "--", DUMMY_SERVICE], |
|
2902 |
+ input=DUMMY_PASSPHRASE, |
|
2903 |
+ catch_exceptions=False, |
|
2904 |
+ ) |
|
2905 |
+ assert result.error_exit(error="Cannot load user config:"), ( |
|
2906 |
+ "expected error exit and known error message" |
|
2907 |
+ ) |
|
2908 |
+ |
|
2909 |
+ def test_311_bad_user_config_is_a_directory( |
|
2910 |
+ self, |
|
2911 |
+ ) -> None: |
|
2912 |
+ """Loading a user configuration file in an invalid format fails.""" |
|
2913 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2914 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2915 |
+ # with-statements. |
|
2916 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2917 |
+ with contextlib.ExitStack() as stack: |
|
2918 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2919 |
+ stack.enter_context( |
|
2920 |
+ pytest_machinery.isolated_vault_config( |
|
2921 |
+ monkeypatch=monkeypatch, |
|
2922 |
+ runner=runner, |
|
2923 |
+ vault_config={"services": {}}, |
|
2924 |
+ main_config_str="", |
|
2925 |
+ ) |
|
2926 |
+ ) |
|
2927 |
+ user_config = cli_helpers.config_filename( |
|
2928 |
+ subsystem="user configuration" |
|
2929 |
+ ) |
|
2930 |
+ user_config.unlink() |
|
2931 |
+ user_config.mkdir(parents=True, exist_ok=True) |
|
2932 |
+ result = runner.invoke( |
|
2933 |
+ cli.derivepassphrase_vault, |
|
2934 |
+ ["--phrase", "--", DUMMY_SERVICE], |
|
2935 |
+ input=DUMMY_PASSPHRASE, |
|
2936 |
+ catch_exceptions=False, |
|
2937 |
+ ) |
|
2938 |
+ assert result.error_exit(error="Cannot load user config:"), ( |
|
2939 |
+ "expected error exit and known error message" |
|
2940 |
+ ) |
|
2941 |
+ |
|
2942 |
+ def test_400_missing_af_unix_support( |
|
2943 |
+ self, |
|
2944 |
+ caplog: pytest.LogCaptureFixture, |
|
2945 |
+ ) -> None: |
|
2946 |
+ """Querying the SSH agent without `AF_UNIX` support fails.""" |
|
2947 |
+ runner = machinery.CliRunner(mix_stderr=False) |
|
2948 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
2949 |
+ # with-statements. |
|
2950 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
2951 |
+ with contextlib.ExitStack() as stack: |
|
2952 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
2953 |
+ stack.enter_context( |
|
2954 |
+ pytest_machinery.isolated_vault_config( |
|
2955 |
+ monkeypatch=monkeypatch, |
|
2956 |
+ runner=runner, |
|
2957 |
+ vault_config={"global": {"phrase": "abc"}, "services": {}}, |
|
2958 |
+ ) |
|
2959 |
+ ) |
|
2960 |
+ monkeypatch.setenv( |
|
2961 |
+ "SSH_AUTH_SOCK", "the value doesn't even matter" |
|
2962 |
+ ) |
|
2963 |
+ monkeypatch.setattr( |
|
2964 |
+ ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", ["posix"] |
|
2965 |
+ ) |
|
2966 |
+ monkeypatch.delattr(socket, "AF_UNIX", raising=False) |
|
2967 |
+ result = runner.invoke( |
|
2968 |
+ cli.derivepassphrase_vault, |
|
2969 |
+ ["--key", "--config"], |
|
2970 |
+ catch_exceptions=False, |
|
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" |
... | ... |
@@ -22,19 +22,17 @@ from derivepassphrase import cli, vault |
22 | 22 |
from derivepassphrase._internals import ( |
23 | 23 |
cli_helpers, |
24 | 24 |
) |
25 |
-from tests import data, machinery, test_derivepassphrase_cli |
|
25 |
+from tests import data, machinery |
|
26 | 26 |
from tests.data import callables |
27 | 27 |
from tests.machinery import pytest as pytest_machinery |
28 |
-from tests.test_derivepassphrase_cli import test_utils |
|
28 |
+from tests.test_derivepassphrase_cli import test_000_basic, test_utils |
|
29 | 29 |
|
30 | 30 |
DUMMY_SERVICE = data.DUMMY_SERVICE |
31 | 31 |
DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE |
32 | 32 |
DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS |
33 | 33 |
|
34 | 34 |
|
35 |
-class Parametrize( |
|
36 |
- test_derivepassphrase_cli.Parametrize, test_utils.Parametrize |
|
37 |
-): |
|
35 |
+class Parametrize(test_000_basic.Parametrize, test_utils.Parametrize): |
|
38 | 36 |
"""Common test parametrizations.""" |
39 | 37 |
|
40 | 38 |
BAD_CONFIGS = pytest.mark.parametrize( |
... | ... |
@@ -1,1686 +1,3 @@ |
1 | 1 |
# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
2 | 2 |
# |
3 | 3 |
# SPDX-License-Identifier: Zlib |
4 |
- |
|
5 |
-"""Test OpenSSH key loading and signing.""" |
|
6 |
- |
|
7 |
-from __future__ import annotations |
|
8 |
- |
|
9 |
-import base64 |
|
10 |
-import contextlib |
|
11 |
-import errno |
|
12 |
-import importlib.metadata |
|
13 |
-import io |
|
14 |
-import os |
|
15 |
-import pathlib |
|
16 |
-import re |
|
17 |
-import socket |
|
18 |
-import sys |
|
19 |
-import types |
|
20 |
-from typing import TYPE_CHECKING |
|
21 |
- |
|
22 |
-import click |
|
23 |
-import click.testing |
|
24 |
-import hypothesis |
|
25 |
-import pytest |
|
26 |
-from hypothesis import strategies |
|
27 |
- |
|
28 |
-from derivepassphrase import _types, ssh_agent, vault |
|
29 |
-from derivepassphrase._internals import cli_helpers |
|
30 |
-from derivepassphrase.ssh_agent import socketprovider |
|
31 |
-from tests import data, machinery |
|
32 |
-from tests.data import callables |
|
33 |
-from tests.machinery import pytest as pytest_machinery |
|
34 |
- |
|
35 |
-if TYPE_CHECKING: |
|
36 |
- from collections.abc import Iterable |
|
37 |
- |
|
38 |
- from typing_extensions import Any, Buffer, Literal |
|
39 |
- |
|
40 |
-if sys.version_info < (3, 11): |
|
41 |
- from exceptiongroup import ExceptionGroup |
|
42 |
- |
|
43 |
- |
|
44 |
-class Parametrize(types.SimpleNamespace): |
|
45 |
- BAD_ENTRY_POINTS = pytest.mark.parametrize( |
|
46 |
- "additional_entry_points", |
|
47 |
- [ |
|
48 |
- pytest.param( |
|
49 |
- [ |
|
50 |
- importlib.metadata.EntryPoint( |
|
51 |
- name=data.faulty_entry_callable.key, |
|
52 |
- group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME, |
|
53 |
- value="tests.data: faulty_entry_callable", |
|
54 |
- ), |
|
55 |
- ], |
|
56 |
- id="not-callable", |
|
57 |
- ), |
|
58 |
- pytest.param( |
|
59 |
- [ |
|
60 |
- importlib.metadata.EntryPoint( |
|
61 |
- name=data.faulty_entry_name_exists.key, |
|
62 |
- group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME, |
|
63 |
- value="tests.data: faulty_entry_name_exists", |
|
64 |
- ), |
|
65 |
- ], |
|
66 |
- id="name-already-exists", |
|
67 |
- ), |
|
68 |
- pytest.param( |
|
69 |
- [ |
|
70 |
- importlib.metadata.EntryPoint( |
|
71 |
- name=data.faulty_entry_alias_exists.key, |
|
72 |
- group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME, |
|
73 |
- value="tests.data: faulty_entry_alias_exists", |
|
74 |
- ), |
|
75 |
- ], |
|
76 |
- id="alias-already-exists", |
|
77 |
- ), |
|
78 |
- ], |
|
79 |
- ) |
|
80 |
- GOOD_ENTRY_POINTS = pytest.mark.parametrize( |
|
81 |
- "additional_entry_points", |
|
82 |
- [ |
|
83 |
- pytest.param( |
|
84 |
- [ |
|
85 |
- importlib.metadata.EntryPoint( |
|
86 |
- name=data.posix_entry.key, |
|
87 |
- group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME, |
|
88 |
- value="tests.data: posix_entry", |
|
89 |
- ), |
|
90 |
- importlib.metadata.EntryPoint( |
|
91 |
- name=data.the_annoying_os_entry.key, |
|
92 |
- group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME, |
|
93 |
- value="tests.data: the_annoying_os_entry", |
|
94 |
- ), |
|
95 |
- ], |
|
96 |
- id="existing-entries", |
|
97 |
- ), |
|
98 |
- pytest.param( |
|
99 |
- [ |
|
100 |
- importlib.metadata.EntryPoint( |
|
101 |
- name=callables.provider_entry1.key, |
|
102 |
- group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME, |
|
103 |
- value="tests.data.callables: provider_entry1", |
|
104 |
- ), |
|
105 |
- importlib.metadata.EntryPoint( |
|
106 |
- name=callables.provider_entry2.key, |
|
107 |
- group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME, |
|
108 |
- value="tests.data.callables: provider_entry2", |
|
109 |
- ), |
|
110 |
- ], |
|
111 |
- id="new-entries", |
|
112 |
- ), |
|
113 |
- ], |
|
114 |
- ) |
|
115 |
- STUBBED_AGENT_ADDRESSES = pytest.mark.parametrize( |
|
116 |
- ["address", "exception", "match"], |
|
117 |
- [ |
|
118 |
- pytest.param(None, KeyError, "SSH_AUTH_SOCK", id="unset"), |
|
119 |
- pytest.param("stub-ssh-agent:", None, "", id="standard"), |
|
120 |
- pytest.param( |
|
121 |
- str(pathlib.Path("~").expanduser()), |
|
122 |
- FileNotFoundError, |
|
123 |
- os.strerror(errno.ENOENT), |
|
124 |
- id="invalid-url", |
|
125 |
- ), |
|
126 |
- pytest.param( |
|
127 |
- "stub-ssh-agent:EPROTONOSUPPORT", |
|
128 |
- OSError, |
|
129 |
- os.strerror(errno.EPROTONOSUPPORT), |
|
130 |
- id="protocol-not-supported", |
|
131 |
- ), |
|
132 |
- pytest.param( |
|
133 |
- "stub-ssh-agent:ABCDEFGHIJKLMNOPQRSTUVWXYZ", |
|
134 |
- OSError, |
|
135 |
- os.strerror(errno.EINVAL), |
|
136 |
- id="invalid-error-code", |
|
137 |
- ), |
|
138 |
- ], |
|
139 |
- ) |
|
140 |
- EXISTING_REGISTRY_ENTRIES = pytest.mark.parametrize( |
|
141 |
- "existing", ["posix", "the_annoying_os"] |
|
142 |
- ) |
|
143 |
- SSH_STRING_EXCEPTIONS = pytest.mark.parametrize( |
|
144 |
- ["input", "exc_type", "exc_pattern"], |
|
145 |
- [ |
|
146 |
- pytest.param( |
|
147 |
- "some string", TypeError, "invalid payload type", id="str" |
|
148 |
- ), |
|
149 |
- ], |
|
150 |
- ) |
|
151 |
- UINT32_EXCEPTIONS = pytest.mark.parametrize( |
|
152 |
- ["input", "exc_type", "exc_pattern"], |
|
153 |
- [ |
|
154 |
- pytest.param( |
|
155 |
- 10000000000000000, |
|
156 |
- OverflowError, |
|
157 |
- "int too big to convert", |
|
158 |
- id="10000000000000000", |
|
159 |
- ), |
|
160 |
- pytest.param( |
|
161 |
- -1, |
|
162 |
- OverflowError, |
|
163 |
- "can't convert negative int to unsigned", |
|
164 |
- id="-1", |
|
165 |
- ), |
|
166 |
- ], |
|
167 |
- ) |
|
168 |
- SSH_UNSTRING_EXCEPTIONS = pytest.mark.parametrize( |
|
169 |
- ["input", "exc_type", "exc_pattern", "has_trailer", "parts"], |
|
170 |
- [ |
|
171 |
- pytest.param( |
|
172 |
- b"ssh", |
|
173 |
- ValueError, |
|
174 |
- "malformed SSH byte string", |
|
175 |
- False, |
|
176 |
- None, |
|
177 |
- id="unencoded", |
|
178 |
- ), |
|
179 |
- pytest.param( |
|
180 |
- b"\x00\x00\x00\x08ssh-rsa", |
|
181 |
- ValueError, |
|
182 |
- "malformed SSH byte string", |
|
183 |
- False, |
|
184 |
- None, |
|
185 |
- id="truncated", |
|
186 |
- ), |
|
187 |
- pytest.param( |
|
188 |
- b"\x00\x00\x00\x04XXX trailing text", |
|
189 |
- ValueError, |
|
190 |
- "malformed SSH byte string", |
|
191 |
- True, |
|
192 |
- (b"XXX ", b"trailing text"), |
|
193 |
- id="trailing-data", |
|
194 |
- ), |
|
195 |
- ], |
|
196 |
- ) |
|
197 |
- SSH_STRING_INPUT = pytest.mark.parametrize( |
|
198 |
- ["input", "expected"], |
|
199 |
- [ |
|
200 |
- pytest.param( |
|
201 |
- b"ssh-rsa", |
|
202 |
- b"\x00\x00\x00\x07ssh-rsa", |
|
203 |
- id="ssh-rsa", |
|
204 |
- ), |
|
205 |
- pytest.param( |
|
206 |
- b"ssh-ed25519", |
|
207 |
- b"\x00\x00\x00\x0bssh-ed25519", |
|
208 |
- id="ssh-ed25519", |
|
209 |
- ), |
|
210 |
- pytest.param( |
|
211 |
- ssh_agent.SSHAgentClient.string(b"ssh-ed25519"), |
|
212 |
- b"\x00\x00\x00\x0f\x00\x00\x00\x0bssh-ed25519", |
|
213 |
- id="string(ssh-ed25519)", |
|
214 |
- ), |
|
215 |
- ], |
|
216 |
- ) |
|
217 |
- SSH_UNSTRING_INPUT = pytest.mark.parametrize( |
|
218 |
- ["input", "expected"], |
|
219 |
- [ |
|
220 |
- pytest.param( |
|
221 |
- b"\x00\x00\x00\x07ssh-rsa", |
|
222 |
- b"ssh-rsa", |
|
223 |
- id="ssh-rsa", |
|
224 |
- ), |
|
225 |
- pytest.param( |
|
226 |
- ssh_agent.SSHAgentClient.string(b"ssh-ed25519"), |
|
227 |
- b"ssh-ed25519", |
|
228 |
- id="ssh-ed25519", |
|
229 |
- ), |
|
230 |
- ], |
|
231 |
- ) |
|
232 |
- UINT32_INPUT = pytest.mark.parametrize( |
|
233 |
- ["input", "expected"], |
|
234 |
- [ |
|
235 |
- pytest.param(16777216, b"\x01\x00\x00\x00", id="16777216"), |
|
236 |
- ], |
|
237 |
- ) |
|
238 |
- SIGN_ERROR_RESPONSES = pytest.mark.parametrize( |
|
239 |
- [ |
|
240 |
- "key", |
|
241 |
- "check", |
|
242 |
- "response_code", |
|
243 |
- "response", |
|
244 |
- "exc_type", |
|
245 |
- "exc_pattern", |
|
246 |
- ], |
|
247 |
- [ |
|
248 |
- pytest.param( |
|
249 |
- b"invalid-key", |
|
250 |
- True, |
|
251 |
- _types.SSH_AGENT.FAILURE, |
|
252 |
- b"", |
|
253 |
- KeyError, |
|
254 |
- "target SSH key not loaded into agent", |
|
255 |
- id="key-not-loaded", |
|
256 |
- ), |
|
257 |
- pytest.param( |
|
258 |
- data.SUPPORTED_KEYS["ed25519"].public_key_data, |
|
259 |
- True, |
|
260 |
- _types.SSH_AGENT.FAILURE, |
|
261 |
- b"", |
|
262 |
- ssh_agent.SSHAgentFailedError, |
|
263 |
- "failed to complete the request", |
|
264 |
- id="failed-to-complete", |
|
265 |
- ), |
|
266 |
- ], |
|
267 |
- ) |
|
268 |
- SSH_KEY_SELECTION = pytest.mark.parametrize( |
|
269 |
- ["key", "single"], |
|
270 |
- [ |
|
271 |
- (value.public_key_data, False) |
|
272 |
- for value in data.SUPPORTED_KEYS.values() |
|
273 |
- ] |
|
274 |
- + [(callables.list_keys_singleton()[0].key, True)], |
|
275 |
- ids=[*data.SUPPORTED_KEYS.keys(), "singleton"], |
|
276 |
- ) |
|
277 |
- SH_EXPORT_LINES = pytest.mark.parametrize( |
|
278 |
- ["line", "env_name", "value"], |
|
279 |
- [ |
|
280 |
- pytest.param( |
|
281 |
- "SSH_AUTH_SOCK=/tmp/pageant.user/pageant.27170; export SSH_AUTH_SOCK;", |
|
282 |
- "SSH_AUTH_SOCK", |
|
283 |
- "/tmp/pageant.user/pageant.27170", |
|
284 |
- id="value-export-semicolon-pageant", |
|
285 |
- ), |
|
286 |
- pytest.param( |
|
287 |
- "SSH_AUTH_SOCK=/tmp/ssh-3CSTC1W5M22A/agent.27270; export SSH_AUTH_SOCK;", |
|
288 |
- "SSH_AUTH_SOCK", |
|
289 |
- "/tmp/ssh-3CSTC1W5M22A/agent.27270", |
|
290 |
- id="value-export-semicolon-openssh", |
|
291 |
- ), |
|
292 |
- pytest.param( |
|
293 |
- "SSH_AUTH_SOCK=/tmp/pageant.user/pageant.27170; export SSH_AUTH_SOCK", |
|
294 |
- "SSH_AUTH_SOCK", |
|
295 |
- "/tmp/pageant.user/pageant.27170", |
|
296 |
- id="value-export-pageant", |
|
297 |
- ), |
|
298 |
- pytest.param( |
|
299 |
- "export SSH_AUTH_SOCK=/tmp/ssh-3CSTC1W5M22A/agent.27270;", |
|
300 |
- "SSH_AUTH_SOCK", |
|
301 |
- "/tmp/ssh-3CSTC1W5M22A/agent.27270", |
|
302 |
- id="export-value-semicolon-openssh", |
|
303 |
- ), |
|
304 |
- pytest.param( |
|
305 |
- "export SSH_AUTH_SOCK=/tmp/pageant.user/pageant.27170", |
|
306 |
- "SSH_AUTH_SOCK", |
|
307 |
- "/tmp/pageant.user/pageant.27170", |
|
308 |
- id="export-value-pageant", |
|
309 |
- ), |
|
310 |
- pytest.param( |
|
311 |
- "SSH_AGENT_PID=27170; export SSH_AGENT_PID;", |
|
312 |
- "SSH_AGENT_PID", |
|
313 |
- "27170", |
|
314 |
- id="pid-export-semicolon", |
|
315 |
- ), |
|
316 |
- pytest.param( |
|
317 |
- "SSH_AGENT_PID=27170; export SSH_AGENT_PID", |
|
318 |
- "SSH_AGENT_PID", |
|
319 |
- "27170", |
|
320 |
- id="pid-export", |
|
321 |
- ), |
|
322 |
- pytest.param( |
|
323 |
- "export SSH_AGENT_PID=27170;", |
|
324 |
- "SSH_AGENT_PID", |
|
325 |
- "27170", |
|
326 |
- id="export-pid-semicolon", |
|
327 |
- ), |
|
328 |
- pytest.param( |
|
329 |
- "export SSH_AGENT_PID=27170", |
|
330 |
- "SSH_AGENT_PID", |
|
331 |
- "27170", |
|
332 |
- id="export-pid", |
|
333 |
- ), |
|
334 |
- pytest.param( |
|
335 |
- "export VARIABLE=value; export OTHER_VARIABLE=other_value;", |
|
336 |
- "VARIABLE", |
|
337 |
- None, |
|
338 |
- id="export-too-much", |
|
339 |
- ), |
|
340 |
- pytest.param( |
|
341 |
- "VARIABLE=value", |
|
342 |
- "VARIABLE", |
|
343 |
- None, |
|
344 |
- id="no-export", |
|
345 |
- ), |
|
346 |
- ], |
|
347 |
- ) |
|
348 |
- INVALID_SSH_AGENT_MESSAGES = pytest.mark.parametrize( |
|
349 |
- "message", |
|
350 |
- [ |
|
351 |
- pytest.param(b"\x00\x00\x00\x00", id="empty-message"), |
|
352 |
- pytest.param(b"\x00\x00\x00\x0f\x0d", id="truncated-message"), |
|
353 |
- pytest.param( |
|
354 |
- b"\x00\x00\x00\x06\x1b\x00\x00\x00\x01\xff", |
|
355 |
- id="invalid-extension-name", |
|
356 |
- ), |
|
357 |
- pytest.param( |
|
358 |
- b"\x00\x00\x00\x11\x0d\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", |
|
359 |
- id="sign-with-trailing-data", |
|
360 |
- ), |
|
361 |
- ], |
|
362 |
- ) |
|
363 |
- UNSUPPORTED_SSH_AGENT_MESSAGES = pytest.mark.parametrize( |
|
364 |
- "message", |
|
365 |
- [ |
|
366 |
- pytest.param( |
|
367 |
- ssh_agent.SSHAgentClient.string( |
|
368 |
- b"".join([ |
|
369 |
- b"\x0d", |
|
370 |
- ssh_agent.SSHAgentClient.string( |
|
371 |
- data.ALL_KEYS["rsa"].public_key_data |
|
372 |
- ), |
|
373 |
- ssh_agent.SSHAgentClient.string(vault.Vault.UUID), |
|
374 |
- b"\x00\x00\x00\x02", |
|
375 |
- ]) |
|
376 |
- ), |
|
377 |
- id="sign-with-flags", |
|
378 |
- ), |
|
379 |
- pytest.param( |
|
380 |
- ssh_agent.SSHAgentClient.string( |
|
381 |
- b"".join([ |
|
382 |
- b"\x0d", |
|
383 |
- ssh_agent.SSHAgentClient.string( |
|
384 |
- data.ALL_KEYS["ed25519"].public_key_data |
|
385 |
- ), |
|
386 |
- b"\x00\x00\x00\x08\x00\x01\x02\x03\x04\x05\x06\x07", |
|
387 |
- b"\x00\x00\x00\x00", |
|
388 |
- ]) |
|
389 |
- ), |
|
390 |
- id="sign-with-nonstandard-passphrase", |
|
391 |
- ), |
|
392 |
- pytest.param( |
|
393 |
- ssh_agent.SSHAgentClient.string( |
|
394 |
- b"".join([ |
|
395 |
- b"\x0d", |
|
396 |
- ssh_agent.SSHAgentClient.string( |
|
397 |
- data.ALL_KEYS["dsa1024"].public_key_data |
|
398 |
- ), |
|
399 |
- ssh_agent.SSHAgentClient.string(vault.Vault.UUID), |
|
400 |
- b"\x00\x00\x00\x00", |
|
401 |
- ]) |
|
402 |
- ), |
|
403 |
- id="sign-key-no-expected-signature", |
|
404 |
- ), |
|
405 |
- pytest.param( |
|
406 |
- ssh_agent.SSHAgentClient.string( |
|
407 |
- b"".join([ |
|
408 |
- b"\x0d", |
|
409 |
- b"\x00\x00\x00\x00", |
|
410 |
- ssh_agent.SSHAgentClient.string(vault.Vault.UUID), |
|
411 |
- b"\x00\x00\x00\x00", |
|
412 |
- ]) |
|
413 |
- ), |
|
414 |
- id="sign-key-unregistered-test-key", |
|
415 |
- ), |
|
416 |
- ], |
|
417 |
- ) |
|
418 |
- PUBLIC_KEY_DATA = pytest.mark.parametrize( |
|
419 |
- "public_key_struct", |
|
420 |
- list(data.SUPPORTED_KEYS.values()), |
|
421 |
- ids=list(data.SUPPORTED_KEYS.keys()), |
|
422 |
- ) |
|
423 |
- REQUEST_ERROR_RESPONSES = pytest.mark.parametrize( |
|
424 |
- ["request_code", "response_code", "exc_type", "exc_pattern"], |
|
425 |
- [ |
|
426 |
- pytest.param( |
|
427 |
- _types.SSH_AGENTC.REQUEST_IDENTITIES, |
|
428 |
- _types.SSH_AGENT.SUCCESS, |
|
429 |
- ssh_agent.SSHAgentFailedError, |
|
430 |
- re.escape( |
|
431 |
- f"[Code {_types.SSH_AGENT.IDENTITIES_ANSWER.value}]" |
|
432 |
- ), |
|
433 |
- id="REQUEST_IDENTITIES-expect-SUCCESS", |
|
434 |
- ), |
|
435 |
- ], |
|
436 |
- ) |
|
437 |
- TRUNCATED_AGENT_RESPONSES = pytest.mark.parametrize( |
|
438 |
- "response", |
|
439 |
- [ |
|
440 |
- b"\x00\x00", |
|
441 |
- b"\x00\x00\x00\x1f some bytes missing", |
|
442 |
- ], |
|
443 |
- ids=["in-header", "in-body"], |
|
444 |
- ) |
|
445 |
- LIST_KEYS_ERROR_RESPONSES = pytest.mark.parametrize( |
|
446 |
- ["response_code", "response", "exc_type", "exc_pattern"], |
|
447 |
- [ |
|
448 |
- pytest.param( |
|
449 |
- _types.SSH_AGENT.FAILURE, |
|
450 |
- b"", |
|
451 |
- ssh_agent.SSHAgentFailedError, |
|
452 |
- "failed to complete the request", |
|
453 |
- id="failed-to-complete", |
|
454 |
- ), |
|
455 |
- pytest.param( |
|
456 |
- _types.SSH_AGENT.IDENTITIES_ANSWER, |
|
457 |
- b"\x00\x00\x00\x01", |
|
458 |
- EOFError, |
|
459 |
- "truncated response", |
|
460 |
- id="truncated-response", |
|
461 |
- ), |
|
462 |
- pytest.param( |
|
463 |
- _types.SSH_AGENT.IDENTITIES_ANSWER, |
|
464 |
- b"\x00\x00\x00\x00abc", |
|
465 |
- ssh_agent.TrailingDataError, |
|
466 |
- "Overlong response", |
|
467 |
- id="overlong-response", |
|
468 |
- ), |
|
469 |
- ], |
|
470 |
- ) |
|
471 |
- QUERY_EXTENSIONS_MALFORMED_RESPONSES = pytest.mark.parametrize( |
|
472 |
- "response_data", |
|
473 |
- [ |
|
474 |
- pytest.param(b"\xde\xad\xbe\xef", id="truncated"), |
|
475 |
- pytest.param( |
|
476 |
- b"\x00\x00\x00\x0fwrong extension", id="wrong-extension" |
|
477 |
- ), |
|
478 |
- pytest.param( |
|
479 |
- b"\x00\x00\x00\x05query\xde\xad\xbe\xef", id="with-trailer" |
|
480 |
- ), |
|
481 |
- pytest.param( |
|
482 |
- b"\x00\x00\x00\x05query\x00\x00\x00\x04ext1\x00\x00", |
|
483 |
- id="with-extra-fields", |
|
484 |
- ), |
|
485 |
- ], |
|
486 |
- ) |
|
487 |
- SUPPORTED_SSH_TEST_KEYS = pytest.mark.parametrize( |
|
488 |
- ["ssh_test_key_type", "ssh_test_key"], |
|
489 |
- list(data.SUPPORTED_KEYS.items()), |
|
490 |
- ids=data.SUPPORTED_KEYS.keys(), |
|
491 |
- ) |
|
492 |
- UNSUITABLE_SSH_TEST_KEYS = pytest.mark.parametrize( |
|
493 |
- ["ssh_test_key_type", "ssh_test_key"], |
|
494 |
- list(data.UNSUITABLE_KEYS.items()), |
|
495 |
- ids=data.UNSUITABLE_KEYS.keys(), |
|
496 |
- ) |
|
497 |
- RESOLVE_CHAINS = pytest.mark.parametrize( |
|
498 |
- ["terminal", "chain"], |
|
499 |
- [ |
|
500 |
- pytest.param("callable", ["a"], id="callable-1"), |
|
501 |
- pytest.param("callable", ["a", "b", "c", "d"], id="callable-4"), |
|
502 |
- pytest.param("alias", ["e"], id="alias-5"), |
|
503 |
- pytest.param("alias", ["e", "f", "g", "h", "i"], id="alias-5"), |
|
504 |
- pytest.param("unimplemented", ["j"], id="unimplemented-1"), |
|
505 |
- pytest.param("unimplemented", ["j", "k"], id="unimplemented-2"), |
|
506 |
- ], |
|
507 |
- ) |
|
508 |
- |
|
509 |
- |
|
510 |
-class TestTestingMachineryStubbedSSHAgentSocket: |
|
511 |
- """Test the stubbed SSH agent socket for the `ssh_agent` module tests.""" |
|
512 |
- |
|
513 |
- def test_100a_query_extensions_base(self) -> None: |
|
514 |
- """The base agent implements no extensions.""" |
|
515 |
- with contextlib.ExitStack() as stack: |
|
516 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
517 |
- monkeypatch.setenv( |
|
518 |
- "SSH_AUTH_SOCK", |
|
519 |
- machinery.StubbedSSHAgentSocketWithAddress.ADDRESS, |
|
520 |
- ) |
|
521 |
- agent = stack.enter_context( |
|
522 |
- machinery.StubbedSSHAgentSocketWithAddress() |
|
523 |
- ) |
|
524 |
- assert "query" not in agent.enabled_extensions |
|
525 |
- query_request = ( |
|
526 |
- # SSH string header |
|
527 |
- b"\x00\x00\x00\x0a" |
|
528 |
- # request code: SSH_AGENTC_EXTENSION |
|
529 |
- b"\x1b" |
|
530 |
- # payload: SSH string "query" |
|
531 |
- b"\x00\x00\x00\x05query" |
|
532 |
- ) |
|
533 |
- query_response = ( |
|
534 |
- # SSH string header |
|
535 |
- b"\x00\x00\x00\x01" |
|
536 |
- # response code: SSH_AGENT_FAILURE |
|
537 |
- b"\x05" |
|
538 |
- ) |
|
539 |
- agent.sendall(query_request) |
|
540 |
- assert agent.recv(1000) == query_response |
|
541 |
- |
|
542 |
- def test_100b_query_extensions_extended(self) -> None: |
|
543 |
- """The extended agent implements a known list of extensions.""" |
|
544 |
- with contextlib.ExitStack() as stack: |
|
545 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
546 |
- monkeypatch.setenv( |
|
547 |
- "SSH_AUTH_SOCK", |
|
548 |
- machinery.StubbedSSHAgentSocketWithAddress.ADDRESS, |
|
549 |
- ) |
|
550 |
- agent = stack.enter_context( |
|
551 |
- machinery.StubbedSSHAgentSocketWithAddressAndDeterministicDSA() |
|
552 |
- ) |
|
553 |
- assert "query" in agent.enabled_extensions |
|
554 |
- query_request = ( |
|
555 |
- # SSH string header |
|
556 |
- b"\x00\x00\x00\x0a" |
|
557 |
- # request code: SSH_AGENTC_EXTENSION |
|
558 |
- b"\x1b" |
|
559 |
- # payload: SSH string "query" |
|
560 |
- b"\x00\x00\x00\x05query" |
|
561 |
- ) |
|
562 |
- query_response = ( |
|
563 |
- # SSH string header |
|
564 |
- b"\x00\x00\x00\x40" |
|
565 |
- # response code: SSH_AGENT_EXTENSION_RESPONSE |
|
566 |
- b"\x1d" |
|
567 |
- # extension response: extension type ("query") |
|
568 |
- b"\x00\x00\x00\x05query" |
|
569 |
- # supported extension #1: query |
|
570 |
- b"\x00\x00\x00\x05query" |
|
571 |
- # supported extension #2: |
|
572 |
- # list-extended@putty.projects.tartarus.org |
|
573 |
- b"\x00\x00\x00\x29list-extended@putty.projects.tartarus.org" |
|
574 |
- ) |
|
575 |
- agent.sendall(query_request) |
|
576 |
- assert agent.recv(1000) == query_response |
|
577 |
- |
|
578 |
- def test_101_request_identities(self) -> None: |
|
579 |
- """The agent implements a known list of identities.""" |
|
580 |
- unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix |
|
581 |
- with machinery.StubbedSSHAgentSocket() as agent: |
|
582 |
- query_request = ( |
|
583 |
- # SSH string header |
|
584 |
- b"\x00\x00\x00\x01" |
|
585 |
- # request code: SSH_AGENTC_REQUEST_IDENTITIES |
|
586 |
- b"\x0b" |
|
587 |
- ) |
|
588 |
- agent.sendall(query_request) |
|
589 |
- message_length = int.from_bytes(agent.recv(4), "big") |
|
590 |
- orig_message: bytes | bytearray = bytearray( |
|
591 |
- agent.recv(message_length) |
|
592 |
- ) |
|
593 |
- assert ( |
|
594 |
- _types.SSH_AGENT(orig_message[0]) |
|
595 |
- == _types.SSH_AGENT.IDENTITIES_ANSWER |
|
596 |
- ) |
|
597 |
- identity_count = int.from_bytes(orig_message[1:5], "big") |
|
598 |
- message = bytes(orig_message[5:]) |
|
599 |
- for _ in range(identity_count): |
|
600 |
- key, message = unstring_prefix(message) |
|
601 |
- _comment, message = unstring_prefix(message) |
|
602 |
- assert key |
|
603 |
- assert key in { |
|
604 |
- k.public_key_data for k in data.ALL_KEYS.values() |
|
605 |
- } |
|
606 |
- assert not message |
|
607 |
- |
|
608 |
- @Parametrize.SUPPORTED_SSH_TEST_KEYS |
|
609 |
- def test_102_sign( |
|
610 |
- self, |
|
611 |
- ssh_test_key_type: str, |
|
612 |
- ssh_test_key: data.SSHTestKey, |
|
613 |
- ) -> None: |
|
614 |
- """The agent signs known key/message pairs.""" |
|
615 |
- del ssh_test_key_type |
|
616 |
- spec = data.SSHTestKeyDeterministicSignatureClass.SPEC |
|
617 |
- assert ssh_test_key.expected_signatures[spec].signature is not None |
|
618 |
- string = ssh_agent.SSHAgentClient.string |
|
619 |
- query_request = string( |
|
620 |
- # request code: SSH_AGENTC_SIGN_REQUEST |
|
621 |
- b"\x0d" |
|
622 |
- # key: SSH string of the public key |
|
623 |
- + string(ssh_test_key.public_key_data) |
|
624 |
- # payload: SSH string of the vault UUID |
|
625 |
- + string(vault.Vault.UUID) |
|
626 |
- # signing flags (uint32, empty) |
|
627 |
- + b"\x00\x00\x00\x00" |
|
628 |
- ) |
|
629 |
- query_response = string( |
|
630 |
- # response code: SSH_AGENT_SIGN_RESPONSE |
|
631 |
- b"\x0e" |
|
632 |
- # expected payload: the binary signature as recorded in the test key data structure |
|
633 |
- + string(ssh_test_key.expected_signatures[spec].signature) |
|
634 |
- ) |
|
635 |
- with machinery.StubbedSSHAgentSocket() as agent: |
|
636 |
- agent.sendall(query_request) |
|
637 |
- assert agent.recv(1000) == query_response |
|
638 |
- |
|
639 |
- def test_120_close_multiple(self) -> None: |
|
640 |
- """The agent can be closed repeatedly.""" |
|
641 |
- with machinery.StubbedSSHAgentSocket() as agent: |
|
642 |
- pass |
|
643 |
- with machinery.StubbedSSHAgentSocket() as agent: |
|
644 |
- pass |
|
645 |
- del agent |
|
646 |
- |
|
647 |
- def test_121_closed_agents_cannot_be_interacted_with(self) -> None: |
|
648 |
- """The agent can be closed repeatedly.""" |
|
649 |
- with machinery.StubbedSSHAgentSocket() as agent: |
|
650 |
- pass |
|
651 |
- query_request = ( |
|
652 |
- # SSH string header |
|
653 |
- b"\x00\x00\x00\x0a" |
|
654 |
- # request code: SSH_AGENTC_EXTENSION |
|
655 |
- b"\x1b" |
|
656 |
- # payload: SSH string "query" |
|
657 |
- b"\x00\x00\x00\x05query" |
|
658 |
- ) |
|
659 |
- query_response = b"" |
|
660 |
- with pytest.raises( |
|
661 |
- ValueError, |
|
662 |
- match=re.escape(machinery.StubbedSSHAgentSocket._SOCKET_IS_CLOSED), |
|
663 |
- ): |
|
664 |
- agent.sendall(query_request) |
|
665 |
- assert agent.recv(100) == query_response |
|
666 |
- |
|
667 |
- def test_122_no_recv_without_sendall(self) -> None: |
|
668 |
- """The agent requires a message before sending a response.""" |
|
669 |
- with machinery.StubbedSSHAgentSocket() as agent: # noqa: SIM117 |
|
670 |
- with pytest.raises( |
|
671 |
- AssertionError, |
|
672 |
- match=re.escape( |
|
673 |
- machinery.StubbedSSHAgentSocket._PROTOCOL_VIOLATION |
|
674 |
- ), |
|
675 |
- ): |
|
676 |
- agent.recv(100) |
|
677 |
- |
|
678 |
- @Parametrize.INVALID_SSH_AGENT_MESSAGES |
|
679 |
- def test_123_invalid_ssh_agent_messages( |
|
680 |
- self, |
|
681 |
- message: Buffer, |
|
682 |
- ) -> None: |
|
683 |
- """The agent responds with errors on invalid messages.""" |
|
684 |
- query_response = ( |
|
685 |
- # SSH string header |
|
686 |
- b"\x00\x00\x00\x01" |
|
687 |
- # response code: SSH_AGENT_FAILURE |
|
688 |
- b"\x05" |
|
689 |
- ) |
|
690 |
- with machinery.StubbedSSHAgentSocket() as agent: |
|
691 |
- agent.sendall(message) |
|
692 |
- assert agent.recv(100) == query_response |
|
693 |
- |
|
694 |
- @Parametrize.UNSUPPORTED_SSH_AGENT_MESSAGES |
|
695 |
- def test_124_unsupported_ssh_agent_messages( |
|
696 |
- self, |
|
697 |
- message: Buffer, |
|
698 |
- ) -> None: |
|
699 |
- """The agent responds with errors on unsupported messages.""" |
|
700 |
- query_response = ( |
|
701 |
- # SSH string header |
|
702 |
- b"\x00\x00\x00\x01" |
|
703 |
- # response code: SSH_AGENT_FAILURE |
|
704 |
- b"\x05" |
|
705 |
- ) |
|
706 |
- with machinery.StubbedSSHAgentSocket() as agent: |
|
707 |
- agent.sendall(message) |
|
708 |
- assert agent.recv(100) == query_response |
|
709 |
- |
|
710 |
- @Parametrize.STUBBED_AGENT_ADDRESSES |
|
711 |
- def test_125_addresses( |
|
712 |
- self, |
|
713 |
- address: str | None, |
|
714 |
- exception: type[Exception] | None, |
|
715 |
- match: str, |
|
716 |
- ) -> None: |
|
717 |
- """The agent accepts addresses.""" |
|
718 |
- with contextlib.ExitStack() as stack: |
|
719 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
720 |
- if address: |
|
721 |
- monkeypatch.setenv("SSH_AUTH_SOCK", address) |
|
722 |
- else: |
|
723 |
- monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) |
|
724 |
- if exception: |
|
725 |
- stack.enter_context( |
|
726 |
- pytest.raises(exception, match=re.escape(match)) |
|
727 |
- ) |
|
728 |
- machinery.StubbedSSHAgentSocketWithAddress() |
|
729 |
- |
|
730 |
- |
|
731 |
-class TestStaticFunctionality: |
|
732 |
- """Test the static functionality of the `ssh_agent` module.""" |
|
733 |
- |
|
734 |
- @staticmethod |
|
735 |
- def as_ssh_string(bytestring: bytes) -> bytes: |
|
736 |
- """Return an encoded SSH string from a bytestring. |
|
737 |
- |
|
738 |
- This is a helper function for hypothesis data generation. |
|
739 |
- |
|
740 |
- """ |
|
741 |
- return int.to_bytes(len(bytestring), 4, "big") + bytestring |
|
742 |
- |
|
743 |
- @staticmethod |
|
744 |
- def canonicalize1(data: bytes) -> bytes: |
|
745 |
- """Return an encoded SSH string from a bytestring. |
|
746 |
- |
|
747 |
- This is a helper function for hypothesis testing. |
|
748 |
- |
|
749 |
- References: |
|
750 |
- |
|
751 |
- * [David R. MacIver: Another invariant to test for |
|
752 |
- encoders][DECODE_ENCODE] |
|
753 |
- |
|
754 |
- [DECODE_ENCODE]: https://hypothesis.works/articles/canonical-serialization/ |
|
755 |
- |
|
756 |
- """ |
|
757 |
- return ssh_agent.SSHAgentClient.string( |
|
758 |
- ssh_agent.SSHAgentClient.unstring(data) |
|
759 |
- ) |
|
760 |
- |
|
761 |
- @staticmethod |
|
762 |
- def canonicalize2(data: bytes) -> bytes: |
|
763 |
- """Return an encoded SSH string from a bytestring. |
|
764 |
- |
|
765 |
- This is a helper function for hypothesis testing. |
|
766 |
- |
|
767 |
- References: |
|
768 |
- |
|
769 |
- * [David R. MacIver: Another invariant to test for |
|
770 |
- encoders][DECODE_ENCODE] |
|
771 |
- |
|
772 |
- [DECODE_ENCODE]: https://hypothesis.works/articles/canonical-serialization/ |
|
773 |
- |
|
774 |
- """ |
|
775 |
- unstringed, trailer = ssh_agent.SSHAgentClient.unstring_prefix(data) |
|
776 |
- assert not trailer |
|
777 |
- return ssh_agent.SSHAgentClient.string(unstringed) |
|
778 |
- |
|
779 |
- # TODO(the-13th-letter): Re-evaluate if this check is worth keeping. |
|
780 |
- # It cannot provide true tamper-resistence, but probably appears to. |
|
781 |
- @Parametrize.PUBLIC_KEY_DATA |
|
782 |
- def test_100_key_decoding( |
|
783 |
- self, |
|
784 |
- public_key_struct: data.SSHTestKey, |
|
785 |
- ) -> None: |
|
786 |
- """The [`tests.ALL_KEYS`][] public key data looks sane.""" |
|
787 |
- keydata = base64.b64decode( |
|
788 |
- public_key_struct.public_key.split(None, 2)[1] |
|
789 |
- ) |
|
790 |
- assert keydata == public_key_struct.public_key_data, ( |
|
791 |
- "recorded public key data doesn't match" |
|
792 |
- ) |
|
793 |
- |
|
794 |
- @Parametrize.SH_EXPORT_LINES |
|
795 |
- def test_190_sh_export_line_parsing( |
|
796 |
- self, line: str, env_name: str, value: str | None |
|
797 |
- ) -> None: |
|
798 |
- """[`tests.parse_sh_export_line`][] works.""" |
|
799 |
- if value is not None: |
|
800 |
- assert ( |
|
801 |
- callables.parse_sh_export_line(line, env_name=env_name) |
|
802 |
- == value |
|
803 |
- ) |
|
804 |
- else: |
|
805 |
- with pytest.raises(ValueError, match="Cannot parse sh line:"): |
|
806 |
- callables.parse_sh_export_line(line, env_name=env_name) |
|
807 |
- |
|
808 |
- def test_200_constructor_posix_no_ssh_auth_sock( |
|
809 |
- self, |
|
810 |
- skip_if_no_af_unix_support: None, |
|
811 |
- ) -> None: |
|
812 |
- """Abort if the running agent cannot be located on POSIX.""" |
|
813 |
- del skip_if_no_af_unix_support |
|
814 |
- posix_handler = socketprovider.SocketProvider.resolve("posix") |
|
815 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
816 |
- monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) |
|
817 |
- with pytest.raises( |
|
818 |
- KeyError, match="SSH_AUTH_SOCK environment variable" |
|
819 |
- ): |
|
820 |
- posix_handler() |
|
821 |
- |
|
822 |
- @Parametrize.UINT32_INPUT |
|
823 |
- def test_210_uint32(self, input: int, expected: bytes | bytearray) -> None: |
|
824 |
- """`uint32` encoding works.""" |
|
825 |
- uint32 = ssh_agent.SSHAgentClient.uint32 |
|
826 |
- assert uint32(input) == expected |
|
827 |
- |
|
828 |
- @hypothesis.given(strategies.integers(min_value=0, max_value=0xFFFFFFFF)) |
|
829 |
- @hypothesis.example(0xDEADBEEF).via("manual, pre-hypothesis example") |
|
830 |
- def test_210a_uint32_from_number(self, num: int) -> None: |
|
831 |
- """`uint32` encoding works, starting from numbers.""" |
|
832 |
- uint32 = ssh_agent.SSHAgentClient.uint32 |
|
833 |
- assert int.from_bytes(uint32(num), "big", signed=False) == num |
|
834 |
- |
|
835 |
- @hypothesis.given(strategies.binary(min_size=4, max_size=4)) |
|
836 |
- @hypothesis.example(b"\xde\xad\xbe\xef").via( |
|
837 |
- "manual, pre-hypothesis example" |
|
838 |
- ) |
|
839 |
- def test_210b_uint32_from_bytestring(self, bytestring: bytes) -> None: |
|
840 |
- """`uint32` encoding works, starting from length four byte strings.""" |
|
841 |
- uint32 = ssh_agent.SSHAgentClient.uint32 |
|
842 |
- assert ( |
|
843 |
- uint32(int.from_bytes(bytestring, "big", signed=False)) |
|
844 |
- == bytestring |
|
845 |
- ) |
|
846 |
- |
|
847 |
- @Parametrize.SSH_STRING_INPUT |
|
848 |
- def test_211_string( |
|
849 |
- self, input: bytes | bytearray, expected: bytes | bytearray |
|
850 |
- ) -> None: |
|
851 |
- """SSH string encoding works.""" |
|
852 |
- string = ssh_agent.SSHAgentClient.string |
|
853 |
- assert bytes(string(input)) == expected |
|
854 |
- |
|
855 |
- @hypothesis.given(strategies.binary(max_size=0x0001FFFF)) |
|
856 |
- @hypothesis.example(b"DEADBEEF" * 10000).via( |
|
857 |
- "manual, pre-hypothesis example with highest order bit set" |
|
858 |
- ) |
|
859 |
- def test_211a_string_from_bytestring(self, bytestring: bytes) -> None: |
|
860 |
- """SSH string encoding works, starting from a byte string.""" |
|
861 |
- res = ssh_agent.SSHAgentClient.string(bytestring) |
|
862 |
- assert res.startswith((b"\x00\x00", b"\x00\x01")) |
|
863 |
- assert int.from_bytes(res[:4], "big", signed=False) == len(bytestring) |
|
864 |
- assert res[4:] == bytestring |
|
865 |
- |
|
866 |
- @Parametrize.SSH_UNSTRING_INPUT |
|
867 |
- def test_212_unstring( |
|
868 |
- self, input: bytes | bytearray, expected: bytes | bytearray |
|
869 |
- ) -> None: |
|
870 |
- """SSH string decoding works.""" |
|
871 |
- unstring = ssh_agent.SSHAgentClient.unstring |
|
872 |
- unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix |
|
873 |
- assert bytes(unstring(input)) == expected |
|
874 |
- assert tuple(bytes(x) for x in unstring_prefix(input)) == ( |
|
875 |
- expected, |
|
876 |
- b"", |
|
877 |
- ) |
|
878 |
- |
|
879 |
- @hypothesis.given(strategies.binary(max_size=0x00FFFFFF)) |
|
880 |
- @hypothesis.example(b"\x00\x00\x00\x07ssh-rsa").via( |
|
881 |
- "manual, pre-hypothesis example to attempt to detect double-decoding" |
|
882 |
- ) |
|
883 |
- @hypothesis.example(b"\x00\x00\x00\x01").via( |
|
884 |
- "detect no-op encoding via ill-formed SSH string" |
|
885 |
- ) |
|
886 |
- def test_212a_unstring_of_string_of_data(self, bytestring: bytes) -> None: |
|
887 |
- """SSH string decoding of encoded SSH strings works. |
|
888 |
- |
|
889 |
- References: |
|
890 |
- |
|
891 |
- * [David R. MacIver: The Encode/Decode invariant][ENCODE_DECODE] |
|
892 |
- |
|
893 |
- [ENCODE_DECODE]: https://hypothesis.works/articles/encode-decode-invariant/ |
|
894 |
- |
|
895 |
- """ |
|
896 |
- string = ssh_agent.SSHAgentClient.string |
|
897 |
- unstring = ssh_agent.SSHAgentClient.unstring |
|
898 |
- unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix |
|
899 |
- encoded = string(bytestring) |
|
900 |
- assert unstring(encoded) == bytestring |
|
901 |
- assert unstring_prefix(encoded) == (bytestring, b"") |
|
902 |
- trailing_data = b" trailing data" |
|
903 |
- encoded2 = string(bytestring) + trailing_data |
|
904 |
- assert unstring_prefix(encoded2) == (bytestring, trailing_data) |
|
905 |
- |
|
906 |
- @hypothesis.given( |
|
907 |
- strategies.binary(max_size=0x00FFFFFF).map( |
|
908 |
- # Scoping issues, and the fact that staticmethod objects |
|
909 |
- # (before class finalization) are not callable, necessitate |
|
910 |
- # wrapping this staticmethod call in a lambda. |
|
911 |
- lambda x: TestStaticFunctionality.as_ssh_string(x) # noqa: PLW0108 |
|
912 |
- ), |
|
913 |
- ) |
|
914 |
- def test_212b_string_of_unstring_of_data(self, encoded: bytes) -> None: |
|
915 |
- """SSH string decoding of encoded SSH strings works. |
|
916 |
- |
|
917 |
- References: |
|
918 |
- |
|
919 |
- * [David R. MacIver: Another invariant to test for |
|
920 |
- encoders][DECODE_ENCODE] |
|
921 |
- |
|
922 |
- [DECODE_ENCODE]: https://hypothesis.works/articles/canonical-serialization/ |
|
923 |
- |
|
924 |
- """ |
|
925 |
- canonical_functions = [self.canonicalize1, self.canonicalize2] |
|
926 |
- for canon1 in canonical_functions: |
|
927 |
- for canon2 in canonical_functions: |
|
928 |
- assert canon1(encoded) == canon2(encoded) |
|
929 |
- assert canon1(canon2(encoded)) == canon1(encoded) |
|
930 |
- |
|
931 |
- def test_220_registry_resolve( |
|
932 |
- self, |
|
933 |
- ) -> None: |
|
934 |
- """Resolving entries in the socket provider registry works.""" |
|
935 |
- registry = socketprovider.SocketProvider.registry |
|
936 |
- resolve = socketprovider.SocketProvider.resolve |
|
937 |
- lookup = socketprovider.SocketProvider.lookup |
|
938 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
939 |
- monkeypatch.setitem(registry, "stub_agent", None) |
|
940 |
- assert callable(lookup("native")) |
|
941 |
- assert callable(resolve("native")) |
|
942 |
- assert lookup("stub_agent") is None |
|
943 |
- with pytest.raises(NotImplementedError): |
|
944 |
- resolve("stub_agent") |
|
945 |
- |
|
946 |
- @Parametrize.RESOLVE_CHAINS |
|
947 |
- def test_221_registry_resolve_chains( |
|
948 |
- self, |
|
949 |
- terminal: Literal["unimplemented", "alias", "callable"], |
|
950 |
- chain: list[str], |
|
951 |
- ) -> None: |
|
952 |
- """Resolving a chain of providers works.""" |
|
953 |
- registry = socketprovider.SocketProvider.registry |
|
954 |
- resolve = socketprovider.SocketProvider.resolve |
|
955 |
- lookup = socketprovider.SocketProvider.lookup |
|
956 |
- try: |
|
957 |
- implementation = resolve("native") |
|
958 |
- except NotImplementedError: # pragma: no cover |
|
959 |
- pytest.fail("Native SSH agent socket provider is unavailable?!") |
|
960 |
- # TODO(the-13th-letter): Rewrite using structural pattern matching. |
|
961 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
962 |
- target = ( |
|
963 |
- None |
|
964 |
- if terminal == "unimplemented" |
|
965 |
- else "native" |
|
966 |
- if terminal == "alias" |
|
967 |
- else implementation |
|
968 |
- ) |
|
969 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
970 |
- for link in chain: |
|
971 |
- monkeypatch.setitem(registry, link, target) |
|
972 |
- target = link |
|
973 |
- for link in chain: |
|
974 |
- assert lookup(link) == ( |
|
975 |
- implementation if terminal != "unimplemented" else None |
|
976 |
- ) |
|
977 |
- if terminal == "unimplemented": |
|
978 |
- with pytest.raises(NotImplementedError): |
|
979 |
- resolve(link) |
|
980 |
- else: |
|
981 |
- assert resolve(link) == implementation |
|
982 |
- |
|
983 |
- @hypothesis.given( |
|
984 |
- terminal=strategies.sampled_from([ |
|
985 |
- "unimplemented", |
|
986 |
- "alias", |
|
987 |
- "callable", |
|
988 |
- ]), |
|
989 |
- chain=strategies.lists( |
|
990 |
- strategies.sampled_from([ |
|
991 |
- "c1", |
|
992 |
- "c2", |
|
993 |
- "c3", |
|
994 |
- "c4", |
|
995 |
- "c5", |
|
996 |
- "c6", |
|
997 |
- "c7", |
|
998 |
- "c8", |
|
999 |
- "c9", |
|
1000 |
- "c10", |
|
1001 |
- ]), |
|
1002 |
- min_size=1, |
|
1003 |
- unique=True, |
|
1004 |
- ), |
|
1005 |
- ) |
|
1006 |
- def test_221a_registry_resolve_chains( |
|
1007 |
- self, |
|
1008 |
- terminal: Literal["unimplemented", "alias", "callable"], |
|
1009 |
- chain: list[str], |
|
1010 |
- ) -> None: |
|
1011 |
- """Resolving a chain of providers works.""" |
|
1012 |
- registry = socketprovider.SocketProvider.registry |
|
1013 |
- resolve = socketprovider.SocketProvider.resolve |
|
1014 |
- lookup = socketprovider.SocketProvider.lookup |
|
1015 |
- try: |
|
1016 |
- implementation = resolve("native") |
|
1017 |
- except NotImplementedError: # pragma: no cover |
|
1018 |
- hypothesis.note(f"{registry = }") |
|
1019 |
- pytest.fail("Native SSH agent socket provider is unavailable?!") |
|
1020 |
- # TODO(the-13th-letter): Rewrite using structural pattern matching. |
|
1021 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1022 |
- target = ( |
|
1023 |
- None |
|
1024 |
- if terminal == "unimplemented" |
|
1025 |
- else "native" |
|
1026 |
- if terminal == "alias" |
|
1027 |
- else implementation |
|
1028 |
- ) |
|
1029 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
1030 |
- for link in chain: |
|
1031 |
- monkeypatch.setitem(registry, link, target) |
|
1032 |
- target = link |
|
1033 |
- for link in chain: |
|
1034 |
- assert lookup(link) == ( |
|
1035 |
- implementation if terminal != "unimplemented" else None |
|
1036 |
- ) |
|
1037 |
- if terminal == "unimplemented": |
|
1038 |
- with pytest.raises(NotImplementedError): |
|
1039 |
- resolve(link) |
|
1040 |
- else: |
|
1041 |
- assert resolve(link) == implementation |
|
1042 |
- |
|
1043 |
- @Parametrize.GOOD_ENTRY_POINTS |
|
1044 |
- def test_230_find_all_socket_providers( |
|
1045 |
- self, |
|
1046 |
- additional_entry_points: list[importlib.metadata.EntryPoint], |
|
1047 |
- ) -> None: |
|
1048 |
- """Finding all SSH agent socket providers works.""" |
|
1049 |
- resolve = socketprovider.SocketProvider.resolve |
|
1050 |
- old_registry = socketprovider.SocketProvider.registry |
|
1051 |
- with pytest_machinery.faked_entry_point_list( |
|
1052 |
- additional_entry_points, remove_conflicting_entries=False |
|
1053 |
- ) as names: |
|
1054 |
- socketprovider.SocketProvider._find_all_ssh_agent_socket_providers() |
|
1055 |
- for name in names: |
|
1056 |
- assert name in socketprovider.SocketProvider.registry |
|
1057 |
- assert resolve(name) in { |
|
1058 |
- callables.provider_entry_provider, |
|
1059 |
- *old_registry.values(), |
|
1060 |
- } |
|
1061 |
- |
|
1062 |
- @Parametrize.BAD_ENTRY_POINTS |
|
1063 |
- def test_231_find_all_socket_providers_errors( |
|
1064 |
- self, |
|
1065 |
- additional_entry_points: list[importlib.metadata.EntryPoint], |
|
1066 |
- ) -> None: |
|
1067 |
- """Finding faulty SSH agent socket providers raises errors.""" |
|
1068 |
- with contextlib.ExitStack() as stack: |
|
1069 |
- stack.enter_context( |
|
1070 |
- pytest_machinery.faked_entry_point_list( |
|
1071 |
- additional_entry_points, remove_conflicting_entries=False |
|
1072 |
- ) |
|
1073 |
- ) |
|
1074 |
- stack.enter_context(pytest.raises(AssertionError)) |
|
1075 |
- socketprovider.SocketProvider._find_all_ssh_agent_socket_providers() |
|
1076 |
- |
|
1077 |
- @Parametrize.UINT32_EXCEPTIONS |
|
1078 |
- def test_310_uint32_exceptions( |
|
1079 |
- self, input: int, exc_type: type[Exception], exc_pattern: str |
|
1080 |
- ) -> None: |
|
1081 |
- """`uint32` encoding fails for out-of-bound values.""" |
|
1082 |
- uint32 = ssh_agent.SSHAgentClient.uint32 |
|
1083 |
- with pytest.raises(exc_type, match=exc_pattern): |
|
1084 |
- uint32(input) |
|
1085 |
- |
|
1086 |
- @Parametrize.SSH_STRING_EXCEPTIONS |
|
1087 |
- def test_311_string_exceptions( |
|
1088 |
- self, input: Any, exc_type: type[Exception], exc_pattern: str |
|
1089 |
- ) -> None: |
|
1090 |
- """SSH string encoding fails for non-strings.""" |
|
1091 |
- string = ssh_agent.SSHAgentClient.string |
|
1092 |
- with pytest.raises(exc_type, match=exc_pattern): |
|
1093 |
- string(input) |
|
1094 |
- |
|
1095 |
- @Parametrize.SSH_UNSTRING_EXCEPTIONS |
|
1096 |
- def test_312_unstring_exceptions( |
|
1097 |
- self, |
|
1098 |
- input: bytes | bytearray, |
|
1099 |
- exc_type: type[Exception], |
|
1100 |
- exc_pattern: str, |
|
1101 |
- has_trailer: bool, |
|
1102 |
- parts: tuple[bytes | bytearray, bytes | bytearray] | None, |
|
1103 |
- ) -> None: |
|
1104 |
- """SSH string decoding fails for invalid values.""" |
|
1105 |
- unstring = ssh_agent.SSHAgentClient.unstring |
|
1106 |
- unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix |
|
1107 |
- with pytest.raises(exc_type, match=exc_pattern): |
|
1108 |
- unstring(input) |
|
1109 |
- if has_trailer: |
|
1110 |
- assert tuple(bytes(x) for x in unstring_prefix(input)) == parts |
|
1111 |
- else: |
|
1112 |
- with pytest.raises(exc_type, match=exc_pattern): |
|
1113 |
- unstring_prefix(input) |
|
1114 |
- |
|
1115 |
- def test_320_registry_already_registered( |
|
1116 |
- self, |
|
1117 |
- ) -> None: |
|
1118 |
- """The registry forbids overwriting entries.""" |
|
1119 |
- registry = socketprovider.SocketProvider.registry.copy() |
|
1120 |
- resolve = socketprovider.SocketProvider.resolve |
|
1121 |
- register = socketprovider.SocketProvider.register |
|
1122 |
- the_annoying_os = resolve("the_annoying_os") |
|
1123 |
- posix = resolve("posix") |
|
1124 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
1125 |
- monkeypatch.setattr( |
|
1126 |
- socketprovider.SocketProvider, "registry", registry |
|
1127 |
- ) |
|
1128 |
- register("posix")(posix) |
|
1129 |
- register("the_annoying_os")(the_annoying_os) |
|
1130 |
- with pytest.raises(ValueError, match="already registered"): |
|
1131 |
- register("posix")(the_annoying_os) |
|
1132 |
- with pytest.raises(ValueError, match="already registered"): |
|
1133 |
- register("the_annoying_os")(posix) |
|
1134 |
- with pytest.raises(ValueError, match="already registered"): |
|
1135 |
- register("posix", "the_annoying_os_named_pipe")(posix) |
|
1136 |
- with pytest.raises(ValueError, match="already registered"): |
|
1137 |
- register("the_annoying_os", "unix_domain")(the_annoying_os) |
|
1138 |
- |
|
1139 |
- def test_321_registry_resolve_non_existant_entries( |
|
1140 |
- self, |
|
1141 |
- ) -> None: |
|
1142 |
- """Resolving a non-existant entry fails.""" |
|
1143 |
- new_registry = { |
|
1144 |
- "posix": socketprovider.SocketProvider.registry["posix"], |
|
1145 |
- "the_annoying_os": socketprovider.SocketProvider.registry[ |
|
1146 |
- "the_annoying_os" |
|
1147 |
- ], |
|
1148 |
- } |
|
1149 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
1150 |
- monkeypatch.setattr( |
|
1151 |
- socketprovider.SocketProvider, "registry", new_registry |
|
1152 |
- ) |
|
1153 |
- with pytest.raises(socketprovider.NoSuchProviderError): |
|
1154 |
- socketprovider.SocketProvider.resolve("native") |
|
1155 |
- |
|
1156 |
- def test_322_registry_register_new_entry( |
|
1157 |
- self, |
|
1158 |
- ) -> None: |
|
1159 |
- """Registering new entries works.""" |
|
1160 |
- |
|
1161 |
- def socket_provider() -> _types.SSHAgentSocket: |
|
1162 |
- raise AssertionError |
|
1163 |
- |
|
1164 |
- names = ["spam", "ham", "eggs", "parrot"] |
|
1165 |
- new_registry = { |
|
1166 |
- "posix": socketprovider.SocketProvider.registry["posix"], |
|
1167 |
- "the_annoying_os": socketprovider.SocketProvider.registry[ |
|
1168 |
- "the_annoying_os" |
|
1169 |
- ], |
|
1170 |
- } |
|
1171 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
1172 |
- monkeypatch.setattr( |
|
1173 |
- socketprovider.SocketProvider, "registry", new_registry |
|
1174 |
- ) |
|
1175 |
- assert not any( |
|
1176 |
- map(socketprovider.SocketProvider.registry.__contains__, names) |
|
1177 |
- ) |
|
1178 |
- assert ( |
|
1179 |
- socketprovider.SocketProvider.register(*names)(socket_provider) |
|
1180 |
- is socket_provider |
|
1181 |
- ) |
|
1182 |
- assert all( |
|
1183 |
- map(socketprovider.SocketProvider.registry.__contains__, names) |
|
1184 |
- ) |
|
1185 |
- assert all([ |
|
1186 |
- socketprovider.SocketProvider.resolve(n) is socket_provider |
|
1187 |
- for n in names |
|
1188 |
- ]) |
|
1189 |
- |
|
1190 |
- @Parametrize.EXISTING_REGISTRY_ENTRIES |
|
1191 |
- def test_323_registry_register_old_entry( |
|
1192 |
- self, |
|
1193 |
- existing: str, |
|
1194 |
- ) -> None: |
|
1195 |
- """Registering old entries works.""" |
|
1196 |
- |
|
1197 |
- provider = socketprovider.SocketProvider.resolve(existing) |
|
1198 |
- new_registry = { |
|
1199 |
- "posix": socketprovider.SocketProvider.registry["posix"], |
|
1200 |
- "the_annoying_os": socketprovider.SocketProvider.registry[ |
|
1201 |
- "the_annoying_os" |
|
1202 |
- ], |
|
1203 |
- "unix_domain": "posix", |
|
1204 |
- "the_annoying_os_named_pipe": "the_annoying_os", |
|
1205 |
- } |
|
1206 |
- names = [ |
|
1207 |
- k |
|
1208 |
- for k, v in socketprovider.SocketProvider.registry.items() |
|
1209 |
- if v == existing |
|
1210 |
- ] |
|
1211 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
1212 |
- monkeypatch.setattr( |
|
1213 |
- socketprovider.SocketProvider, "registry", new_registry |
|
1214 |
- ) |
|
1215 |
- assert not all( |
|
1216 |
- map(socketprovider.SocketProvider.registry.__contains__, names) |
|
1217 |
- ) |
|
1218 |
- assert ( |
|
1219 |
- socketprovider.SocketProvider.register(existing, *names)( |
|
1220 |
- provider |
|
1221 |
- ) |
|
1222 |
- is provider |
|
1223 |
- ) |
|
1224 |
- assert all( |
|
1225 |
- map(socketprovider.SocketProvider.registry.__contains__, names) |
|
1226 |
- ) |
|
1227 |
- assert all([ |
|
1228 |
- socketprovider.SocketProvider.resolve(n) is provider |
|
1229 |
- for n in [existing, *names] |
|
1230 |
- ]) |
|
1231 |
- |
|
1232 |
- |
|
1233 |
-class TestAgentInteraction: |
|
1234 |
- """Test actually talking to the SSH agent.""" |
|
1235 |
- |
|
1236 |
- @Parametrize.SUPPORTED_SSH_TEST_KEYS |
|
1237 |
- def test_200_sign_data_via_agent( |
|
1238 |
- self, |
|
1239 |
- ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient, |
|
1240 |
- ssh_test_key_type: str, |
|
1241 |
- ssh_test_key: data.SSHTestKey, |
|
1242 |
- ) -> None: |
|
1243 |
- """Signing data with specific SSH keys works. |
|
1244 |
- |
|
1245 |
- Single tests may abort early (skip) if the indicated key is not |
|
1246 |
- loaded in the agent. Presumably this means the key type is |
|
1247 |
- unsupported. |
|
1248 |
- |
|
1249 |
- """ |
|
1250 |
- client = ssh_agent_client_with_test_keys_loaded |
|
1251 |
- key_comment_pairs = {bytes(k): bytes(c) for k, c in client.list_keys()} |
|
1252 |
- public_key_data = ssh_test_key.public_key_data |
|
1253 |
- assert ( |
|
1254 |
- data.SSHTestKeyDeterministicSignatureClass.SPEC |
|
1255 |
- in ssh_test_key.expected_signatures |
|
1256 |
- ) |
|
1257 |
- sig = ssh_test_key.expected_signatures[ |
|
1258 |
- data.SSHTestKeyDeterministicSignatureClass.SPEC |
|
1259 |
- ] |
|
1260 |
- expected_signature = sig.signature |
|
1261 |
- derived_passphrase = sig.derived_passphrase |
|
1262 |
- if public_key_data not in key_comment_pairs: # pragma: no cover |
|
1263 |
- pytest.skip(f"prerequisite {ssh_test_key_type} SSH key not loaded") |
|
1264 |
- signature = bytes( |
|
1265 |
- client.sign(payload=vault.Vault.UUID, key=public_key_data) |
|
1266 |
- ) |
|
1267 |
- assert signature == expected_signature, ( |
|
1268 |
- f"SSH signature mismatch ({ssh_test_key_type})" |
|
1269 |
- ) |
|
1270 |
- signature2 = bytes( |
|
1271 |
- client.sign(payload=vault.Vault.UUID, key=public_key_data) |
|
1272 |
- ) |
|
1273 |
- assert signature2 == expected_signature, ( |
|
1274 |
- f"SSH signature mismatch ({ssh_test_key_type})" |
|
1275 |
- ) |
|
1276 |
- assert ( |
|
1277 |
- vault.Vault.phrase_from_key(public_key_data, conn=client) |
|
1278 |
- == derived_passphrase |
|
1279 |
- ), f"SSH signature mismatch ({ssh_test_key_type})" |
|
1280 |
- |
|
1281 |
- @Parametrize.UNSUITABLE_SSH_TEST_KEYS |
|
1282 |
- def test_201_sign_data_via_agent_unsupported( |
|
1283 |
- self, |
|
1284 |
- ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient, |
|
1285 |
- ssh_test_key_type: str, |
|
1286 |
- ssh_test_key: data.SSHTestKey, |
|
1287 |
- ) -> None: |
|
1288 |
- """Using an unsuitable key with [`vault.Vault`][] fails. |
|
1289 |
- |
|
1290 |
- Single tests may abort early (skip) if the indicated key is not |
|
1291 |
- loaded in the agent. Presumably this means the key type is |
|
1292 |
- unsupported. Single tests may also abort early if the agent |
|
1293 |
- ensures that the generally unsuitable key is actually suitable |
|
1294 |
- under this agent. |
|
1295 |
- |
|
1296 |
- """ |
|
1297 |
- client = ssh_agent_client_with_test_keys_loaded |
|
1298 |
- key_comment_pairs = {bytes(k): bytes(c) for k, c in client.list_keys()} |
|
1299 |
- public_key_data = ssh_test_key.public_key_data |
|
1300 |
- if public_key_data not in key_comment_pairs: # pragma: no cover |
|
1301 |
- pytest.skip(f"prerequisite {ssh_test_key_type} SSH key not loaded") |
|
1302 |
- assert not vault.Vault.is_suitable_ssh_key( |
|
1303 |
- public_key_data, client=None |
|
1304 |
- ), f"Expected {ssh_test_key_type} key to be unsuitable in general" |
|
1305 |
- if vault.Vault.is_suitable_ssh_key(public_key_data, client=client): |
|
1306 |
- pytest.skip( |
|
1307 |
- f"agent automatically ensures {ssh_test_key_type} key is suitable" |
|
1308 |
- ) |
|
1309 |
- with pytest.raises(ValueError, match="unsuitable SSH key"): |
|
1310 |
- vault.Vault.phrase_from_key(public_key_data, conn=client) |
|
1311 |
- |
|
1312 |
- @Parametrize.SSH_KEY_SELECTION |
|
1313 |
- def test_210_ssh_key_selector( |
|
1314 |
- self, |
|
1315 |
- monkeypatch: pytest.MonkeyPatch, |
|
1316 |
- ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient, |
|
1317 |
- key: bytes, |
|
1318 |
- single: bool, |
|
1319 |
- ) -> None: |
|
1320 |
- """The key selector presents exactly the suitable keys. |
|
1321 |
- |
|
1322 |
- "Suitable" here means suitability for this SSH agent |
|
1323 |
- specifically. |
|
1324 |
- |
|
1325 |
- """ |
|
1326 |
- client = ssh_agent_client_with_test_keys_loaded |
|
1327 |
- |
|
1328 |
- def key_is_suitable(key: bytes) -> bool: |
|
1329 |
- """Stub out [`vault.Vault.key_is_suitable`][].""" |
|
1330 |
- always = {v.public_key_data for v in data.SUPPORTED_KEYS.values()} |
|
1331 |
- dsa = { |
|
1332 |
- v.public_key_data |
|
1333 |
- for k, v in data.UNSUITABLE_KEYS.items() |
|
1334 |
- if k.startswith(("dsa", "ecdsa")) |
|
1335 |
- } |
|
1336 |
- return key in always or ( |
|
1337 |
- client.has_deterministic_dsa_signatures() and key in dsa |
|
1338 |
- ) |
|
1339 |
- |
|
1340 |
- # TODO(the-13th-letter): Handle the unlikely(?) case that only |
|
1341 |
- # one test key is loaded, but `single` is False. Rename the |
|
1342 |
- # `index` variable to `input`, store the `input` in there, and |
|
1343 |
- # make the definition of `text` in the else block dependent on |
|
1344 |
- # `n` being singular or non-singular. |
|
1345 |
- if single: |
|
1346 |
- monkeypatch.setattr( |
|
1347 |
- ssh_agent.SSHAgentClient, |
|
1348 |
- "list_keys", |
|
1349 |
- callables.list_keys_singleton, |
|
1350 |
- ) |
|
1351 |
- keys = [ |
|
1352 |
- pair.key |
|
1353 |
- for pair in callables.list_keys_singleton() |
|
1354 |
- if key_is_suitable(pair.key) |
|
1355 |
- ] |
|
1356 |
- index = "1" |
|
1357 |
- text = "Use this key? yes\n" |
|
1358 |
- else: |
|
1359 |
- monkeypatch.setattr( |
|
1360 |
- ssh_agent.SSHAgentClient, |
|
1361 |
- "list_keys", |
|
1362 |
- callables.list_keys, |
|
1363 |
- ) |
|
1364 |
- keys = [ |
|
1365 |
- pair.key |
|
1366 |
- for pair in callables.list_keys() |
|
1367 |
- if key_is_suitable(pair.key) |
|
1368 |
- ] |
|
1369 |
- index = str(1 + keys.index(key)) |
|
1370 |
- n = len(keys) |
|
1371 |
- text = f"Your selection? (1-{n}, leave empty to abort): {index}\n" |
|
1372 |
- b64_key = base64.standard_b64encode(key).decode("ASCII") |
|
1373 |
- |
|
1374 |
- @click.command() |
|
1375 |
- def driver() -> None: |
|
1376 |
- """Call [`cli_helpers.select_ssh_key`][] directly, as a command.""" |
|
1377 |
- key = cli_helpers.select_ssh_key(client) |
|
1378 |
- click.echo(base64.standard_b64encode(key).decode("ASCII")) |
|
1379 |
- |
|
1380 |
- # TODO(the-13th-letter): (Continued from above.) Update input |
|
1381 |
- # data to use `index`/`input` directly and unconditionally. |
|
1382 |
- runner = machinery.CliRunner(mix_stderr=True) |
|
1383 |
- result = runner.invoke( |
|
1384 |
- driver, |
|
1385 |
- [], |
|
1386 |
- input=("yes\n" if single else f"{index}\n"), |
|
1387 |
- catch_exceptions=True, |
|
1388 |
- ) |
|
1389 |
- for snippet in ("Suitable SSH keys:\n", text, f"\n{b64_key}\n"): |
|
1390 |
- assert result.clean_exit(output=snippet), "expected clean exit" |
|
1391 |
- |
|
1392 |
- def test_300_constructor_bad_running_agent( |
|
1393 |
- self, |
|
1394 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
1395 |
- ) -> None: |
|
1396 |
- """Fail if the agent address is invalid.""" |
|
1397 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
1398 |
- new_socket_name = ( |
|
1399 |
- running_ssh_agent.socket + "~" |
|
1400 |
- if isinstance(running_ssh_agent.socket, str) |
|
1401 |
- else "<invalid//address>" |
|
1402 |
- ) |
|
1403 |
- monkeypatch.setenv("SSH_AUTH_SOCK", new_socket_name) |
|
1404 |
- with pytest.raises(OSError): # noqa: PT011 |
|
1405 |
- ssh_agent.SSHAgentClient() |
|
1406 |
- |
|
1407 |
- def test_301_constructor_no_af_unix_support(self) -> None: |
|
1408 |
- """Fail without [`socket.AF_UNIX`][] support.""" |
|
1409 |
- assert "posix" in socketprovider.SocketProvider.registry |
|
1410 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
1411 |
- monkeypatch.setenv("SSH_AUTH_SOCK", "the value doesn't matter") |
|
1412 |
- monkeypatch.delattr(socket, "AF_UNIX", raising=False) |
|
1413 |
- with pytest.raises( |
|
1414 |
- NotImplementedError, |
|
1415 |
- match="UNIX domain sockets", |
|
1416 |
- ): |
|
1417 |
- ssh_agent.SSHAgentClient(socket="posix") |
|
1418 |
- |
|
1419 |
- def test_302_no_ssh_agent_socket_provider_available( |
|
1420 |
- self, |
|
1421 |
- ) -> None: |
|
1422 |
- """Fail if no SSH agent socket provider is available.""" |
|
1423 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
1424 |
- monkeypatch.setitem( |
|
1425 |
- socketprovider.SocketProvider.registry, "stub_agent", None |
|
1426 |
- ) |
|
1427 |
- with pytest.raises(ExceptionGroup) as excinfo: |
|
1428 |
- ssh_agent.SSHAgentClient( |
|
1429 |
- socket=["stub_agent", "stub_agent", "stub_agent"] |
|
1430 |
- ) |
|
1431 |
- assert all([ |
|
1432 |
- isinstance(e, NotImplementedError) |
|
1433 |
- for e in excinfo.value.exceptions |
|
1434 |
- ]) |
|
1435 |
- |
|
1436 |
- def test_303_explicit_socket( |
|
1437 |
- self, |
|
1438 |
- spawn_ssh_agent: data.SpawnedSSHAgentInfo, |
|
1439 |
- ) -> None: |
|
1440 |
- conn = spawn_ssh_agent.client._connection |
|
1441 |
- ssh_agent.SSHAgentClient(socket=conn) |
|
1442 |
- |
|
1443 |
- @Parametrize.TRUNCATED_AGENT_RESPONSES |
|
1444 |
- def test_310_truncated_server_response( |
|
1445 |
- self, |
|
1446 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
1447 |
- response: bytes, |
|
1448 |
- ) -> None: |
|
1449 |
- """Fail on truncated responses from the SSH agent.""" |
|
1450 |
- del running_ssh_agent |
|
1451 |
- client = ssh_agent.SSHAgentClient() |
|
1452 |
- response_stream = io.BytesIO(response) |
|
1453 |
- |
|
1454 |
- class PseudoSocket: |
|
1455 |
- def sendall(self, *args: Any, **kwargs: Any) -> Any: # noqa: ARG002 |
|
1456 |
- return None |
|
1457 |
- |
|
1458 |
- def recv(self, *args: Any, **kwargs: Any) -> Any: |
|
1459 |
- return response_stream.read(*args, **kwargs) |
|
1460 |
- |
|
1461 |
- pseudo_socket = PseudoSocket() |
|
1462 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
1463 |
- monkeypatch.setattr(client, "_connection", pseudo_socket) |
|
1464 |
- with pytest.raises(EOFError): |
|
1465 |
- client.request(255, b"") |
|
1466 |
- |
|
1467 |
- @Parametrize.LIST_KEYS_ERROR_RESPONSES |
|
1468 |
- def test_320_list_keys_error_responses( |
|
1469 |
- self, |
|
1470 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
1471 |
- response_code: _types.SSH_AGENT, |
|
1472 |
- response: bytes | bytearray, |
|
1473 |
- exc_type: type[Exception], |
|
1474 |
- exc_pattern: str, |
|
1475 |
- ) -> None: |
|
1476 |
- """Fail on problems during key listing. |
|
1477 |
- |
|
1478 |
- Known problems: |
|
1479 |
- |
|
1480 |
- - The agent refuses, or otherwise indicates the operation |
|
1481 |
- failed. |
|
1482 |
- - The agent response is truncated. |
|
1483 |
- - The agent response is overlong. |
|
1484 |
- |
|
1485 |
- """ |
|
1486 |
- del running_ssh_agent |
|
1487 |
- |
|
1488 |
- passed_response_code = response_code |
|
1489 |
- |
|
1490 |
- # TODO(the-13th-letter): Extract this mock function into a common |
|
1491 |
- # top-level "request" mock function. |
|
1492 |
- def request( |
|
1493 |
- request_code: int | _types.SSH_AGENTC, |
|
1494 |
- payload: bytes | bytearray, |
|
1495 |
- /, |
|
1496 |
- *, |
|
1497 |
- response_code: Iterable[int | _types.SSH_AGENT] |
|
1498 |
- | int |
|
1499 |
- | _types.SSH_AGENT |
|
1500 |
- | None = None, |
|
1501 |
- ) -> tuple[int, bytes | bytearray] | bytes | bytearray: |
|
1502 |
- del request_code |
|
1503 |
- del payload |
|
1504 |
- if isinstance( # pragma: no branch |
|
1505 |
- response_code, (int, _types.SSH_AGENT) |
|
1506 |
- ): |
|
1507 |
- response_code = frozenset({response_code}) |
|
1508 |
- if response_code is not None: # pragma: no branch |
|
1509 |
- response_code = frozenset({ |
|
1510 |
- c if isinstance(c, int) else c.value for c in response_code |
|
1511 |
- }) |
|
1512 |
- |
|
1513 |
- if not response_code: # pragma: no cover |
|
1514 |
- return (passed_response_code.value, response) |
|
1515 |
- if passed_response_code.value not in response_code: |
|
1516 |
- raise ssh_agent.SSHAgentFailedError( |
|
1517 |
- passed_response_code.value, response |
|
1518 |
- ) |
|
1519 |
- return response |
|
1520 |
- |
|
1521 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
1522 |
- client = ssh_agent.SSHAgentClient() |
|
1523 |
- monkeypatch.setattr(client, "request", request) |
|
1524 |
- with pytest.raises(exc_type, match=exc_pattern): |
|
1525 |
- client.list_keys() |
|
1526 |
- |
|
1527 |
- @Parametrize.SIGN_ERROR_RESPONSES |
|
1528 |
- def test_330_sign_error_responses( |
|
1529 |
- self, |
|
1530 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
1531 |
- key: bytes | bytearray, |
|
1532 |
- check: bool, |
|
1533 |
- response_code: _types.SSH_AGENT, |
|
1534 |
- response: bytes | bytearray, |
|
1535 |
- exc_type: type[Exception], |
|
1536 |
- exc_pattern: str, |
|
1537 |
- ) -> None: |
|
1538 |
- """Fail on problems during signing. |
|
1539 |
- |
|
1540 |
- Known problems: |
|
1541 |
- |
|
1542 |
- - The key is not loaded into the agent. |
|
1543 |
- - The agent refuses, or otherwise indicates the operation |
|
1544 |
- failed. |
|
1545 |
- |
|
1546 |
- """ |
|
1547 |
- del running_ssh_agent |
|
1548 |
- passed_response_code = response_code |
|
1549 |
- |
|
1550 |
- # TODO(the-13th-letter): Extract this mock function into a common |
|
1551 |
- # top-level "request" mock function. |
|
1552 |
- def request( |
|
1553 |
- request_code: int | _types.SSH_AGENTC, |
|
1554 |
- payload: bytes | bytearray, |
|
1555 |
- /, |
|
1556 |
- *, |
|
1557 |
- response_code: Iterable[int | _types.SSH_AGENT] |
|
1558 |
- | int |
|
1559 |
- | _types.SSH_AGENT |
|
1560 |
- | None = None, |
|
1561 |
- ) -> tuple[int, bytes | bytearray] | bytes | bytearray: |
|
1562 |
- del request_code |
|
1563 |
- del payload |
|
1564 |
- if isinstance( # pragma: no branch |
|
1565 |
- response_code, (int, _types.SSH_AGENT) |
|
1566 |
- ): |
|
1567 |
- response_code = frozenset({response_code}) |
|
1568 |
- if response_code is not None: # pragma: no branch |
|
1569 |
- response_code = frozenset({ |
|
1570 |
- c if isinstance(c, int) else c.value for c in response_code |
|
1571 |
- }) |
|
1572 |
- |
|
1573 |
- if not response_code: # pragma: no cover |
|
1574 |
- return (passed_response_code.value, response) |
|
1575 |
- if ( |
|
1576 |
- passed_response_code.value not in response_code |
|
1577 |
- ): # pragma: no branch |
|
1578 |
- raise ssh_agent.SSHAgentFailedError( |
|
1579 |
- passed_response_code.value, response |
|
1580 |
- ) |
|
1581 |
- return response # pragma: no cover |
|
1582 |
- |
|
1583 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
1584 |
- client = ssh_agent.SSHAgentClient() |
|
1585 |
- monkeypatch.setattr(client, "request", request) |
|
1586 |
- Pair = _types.SSHKeyCommentPair # noqa: N806 |
|
1587 |
- com = b"no comment" |
|
1588 |
- loaded_keys = [ |
|
1589 |
- Pair(v.public_key_data, com).toreadonly() |
|
1590 |
- for v in data.SUPPORTED_KEYS.values() |
|
1591 |
- ] |
|
1592 |
- monkeypatch.setattr(client, "list_keys", lambda: loaded_keys) |
|
1593 |
- with pytest.raises(exc_type, match=exc_pattern): |
|
1594 |
- client.sign(key, b"abc", check_if_key_loaded=check) |
|
1595 |
- |
|
1596 |
- @Parametrize.REQUEST_ERROR_RESPONSES |
|
1597 |
- def test_340_request_error_responses( |
|
1598 |
- self, |
|
1599 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
1600 |
- request_code: _types.SSH_AGENTC, |
|
1601 |
- response_code: _types.SSH_AGENT, |
|
1602 |
- exc_type: type[Exception], |
|
1603 |
- exc_pattern: str, |
|
1604 |
- ) -> None: |
|
1605 |
- """Fail on problems during signing. |
|
1606 |
- |
|
1607 |
- Known problems: |
|
1608 |
- |
|
1609 |
- - The key is not loaded into the agent. |
|
1610 |
- - The agent refuses, or otherwise indicates the operation |
|
1611 |
- failed. |
|
1612 |
- |
|
1613 |
- """ |
|
1614 |
- del running_ssh_agent |
|
1615 |
- |
|
1616 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1617 |
- # with-statements. |
|
1618 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1619 |
- with contextlib.ExitStack() as stack: |
|
1620 |
- stack.enter_context(pytest.raises(exc_type, match=exc_pattern)) |
|
1621 |
- client = stack.enter_context(ssh_agent.SSHAgentClient()) |
|
1622 |
- client.request(request_code, b"", response_code=response_code) |
|
1623 |
- |
|
1624 |
- @Parametrize.QUERY_EXTENSIONS_MALFORMED_RESPONSES |
|
1625 |
- def test_350_query_extensions_malformed_responses( |
|
1626 |
- self, |
|
1627 |
- monkeypatch: pytest.MonkeyPatch, |
|
1628 |
- running_ssh_agent: data.RunningSSHAgentInfo, |
|
1629 |
- response_data: bytes, |
|
1630 |
- ) -> None: |
|
1631 |
- """Fail on malformed responses while querying extensions.""" |
|
1632 |
- del running_ssh_agent |
|
1633 |
- |
|
1634 |
- # TODO(the-13th-letter): Extract this mock function into a common |
|
1635 |
- # top-level "request" mock function after removing the |
|
1636 |
- # payload-specific parts. |
|
1637 |
- def request( |
|
1638 |
- code: int | _types.SSH_AGENTC, |
|
1639 |
- payload: Buffer, |
|
1640 |
- /, |
|
1641 |
- *, |
|
1642 |
- response_code: ( |
|
1643 |
- Iterable[_types.SSH_AGENT | int] |
|
1644 |
- | _types.SSH_AGENT |
|
1645 |
- | int |
|
1646 |
- | None |
|
1647 |
- ) = None, |
|
1648 |
- ) -> tuple[int, bytes] | bytes: |
|
1649 |
- request_codes = { |
|
1650 |
- _types.SSH_AGENTC.EXTENSION, |
|
1651 |
- _types.SSH_AGENTC.EXTENSION.value, |
|
1652 |
- } |
|
1653 |
- assert code in request_codes |
|
1654 |
- response_codes = { |
|
1655 |
- _types.SSH_AGENT.EXTENSION_RESPONSE, |
|
1656 |
- _types.SSH_AGENT.EXTENSION_RESPONSE.value, |
|
1657 |
- _types.SSH_AGENT.SUCCESS, |
|
1658 |
- _types.SSH_AGENT.SUCCESS.value, |
|
1659 |
- } |
|
1660 |
- assert payload == b"\x00\x00\x00\x05query" |
|
1661 |
- if response_code is None: # pragma: no cover |
|
1662 |
- return ( |
|
1663 |
- _types.SSH_AGENT.EXTENSION_RESPONSE.value, |
|
1664 |
- response_data, |
|
1665 |
- ) |
|
1666 |
- if isinstance( # pragma: no cover |
|
1667 |
- response_code, (_types.SSH_AGENT, int) |
|
1668 |
- ): |
|
1669 |
- assert response_code in response_codes |
|
1670 |
- return response_data |
|
1671 |
- for single_code in response_code: # pragma: no cover |
|
1672 |
- assert single_code in response_codes |
|
1673 |
- return response_data # pragma: no cover |
|
1674 |
- |
|
1675 |
- # TODO(the-13th-letter): Rewrite using parenthesized |
|
1676 |
- # with-statements. |
|
1677 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1678 |
- with contextlib.ExitStack() as stack: |
|
1679 |
- monkeypatch2 = stack.enter_context(monkeypatch.context()) |
|
1680 |
- client = stack.enter_context(ssh_agent.SSHAgentClient()) |
|
1681 |
- monkeypatch2.setattr(client, "request", request) |
|
1682 |
- with pytest.raises( |
|
1683 |
- RuntimeError, |
|
1684 |
- match=r"Malformed response|does not match request", |
|
1685 |
- ): |
|
1686 |
- client.query_extensions() |
... | ... |
@@ -0,0 +1,1686 @@ |
1 |
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
|
2 |
+# |
|
3 |
+# SPDX-License-Identifier: Zlib |
|
4 |
+ |
|
5 |
+"""Test OpenSSH key loading and signing.""" |
|
6 |
+ |
|
7 |
+from __future__ import annotations |
|
8 |
+ |
|
9 |
+import base64 |
|
10 |
+import contextlib |
|
11 |
+import errno |
|
12 |
+import importlib.metadata |
|
13 |
+import io |
|
14 |
+import os |
|
15 |
+import pathlib |
|
16 |
+import re |
|
17 |
+import socket |
|
18 |
+import sys |
|
19 |
+import types |
|
20 |
+from typing import TYPE_CHECKING |
|
21 |
+ |
|
22 |
+import click |
|
23 |
+import click.testing |
|
24 |
+import hypothesis |
|
25 |
+import pytest |
|
26 |
+from hypothesis import strategies |
|
27 |
+ |
|
28 |
+from derivepassphrase import _types, ssh_agent, vault |
|
29 |
+from derivepassphrase._internals import cli_helpers |
|
30 |
+from derivepassphrase.ssh_agent import socketprovider |
|
31 |
+from tests import data, machinery |
|
32 |
+from tests.data import callables |
|
33 |
+from tests.machinery import pytest as pytest_machinery |
|
34 |
+ |
|
35 |
+if TYPE_CHECKING: |
|
36 |
+ from collections.abc import Iterable |
|
37 |
+ |
|
38 |
+ from typing_extensions import Any, Buffer, Literal |
|
39 |
+ |
|
40 |
+if sys.version_info < (3, 11): |
|
41 |
+ from exceptiongroup import ExceptionGroup |
|
42 |
+ |
|
43 |
+ |
|
44 |
+class Parametrize(types.SimpleNamespace): |
|
45 |
+ BAD_ENTRY_POINTS = pytest.mark.parametrize( |
|
46 |
+ "additional_entry_points", |
|
47 |
+ [ |
|
48 |
+ pytest.param( |
|
49 |
+ [ |
|
50 |
+ importlib.metadata.EntryPoint( |
|
51 |
+ name=data.faulty_entry_callable.key, |
|
52 |
+ group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME, |
|
53 |
+ value="tests.data: faulty_entry_callable", |
|
54 |
+ ), |
|
55 |
+ ], |
|
56 |
+ id="not-callable", |
|
57 |
+ ), |
|
58 |
+ pytest.param( |
|
59 |
+ [ |
|
60 |
+ importlib.metadata.EntryPoint( |
|
61 |
+ name=data.faulty_entry_name_exists.key, |
|
62 |
+ group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME, |
|
63 |
+ value="tests.data: faulty_entry_name_exists", |
|
64 |
+ ), |
|
65 |
+ ], |
|
66 |
+ id="name-already-exists", |
|
67 |
+ ), |
|
68 |
+ pytest.param( |
|
69 |
+ [ |
|
70 |
+ importlib.metadata.EntryPoint( |
|
71 |
+ name=data.faulty_entry_alias_exists.key, |
|
72 |
+ group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME, |
|
73 |
+ value="tests.data: faulty_entry_alias_exists", |
|
74 |
+ ), |
|
75 |
+ ], |
|
76 |
+ id="alias-already-exists", |
|
77 |
+ ), |
|
78 |
+ ], |
|
79 |
+ ) |
|
80 |
+ GOOD_ENTRY_POINTS = pytest.mark.parametrize( |
|
81 |
+ "additional_entry_points", |
|
82 |
+ [ |
|
83 |
+ pytest.param( |
|
84 |
+ [ |
|
85 |
+ importlib.metadata.EntryPoint( |
|
86 |
+ name=data.posix_entry.key, |
|
87 |
+ group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME, |
|
88 |
+ value="tests.data: posix_entry", |
|
89 |
+ ), |
|
90 |
+ importlib.metadata.EntryPoint( |
|
91 |
+ name=data.the_annoying_os_entry.key, |
|
92 |
+ group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME, |
|
93 |
+ value="tests.data: the_annoying_os_entry", |
|
94 |
+ ), |
|
95 |
+ ], |
|
96 |
+ id="existing-entries", |
|
97 |
+ ), |
|
98 |
+ pytest.param( |
|
99 |
+ [ |
|
100 |
+ importlib.metadata.EntryPoint( |
|
101 |
+ name=callables.provider_entry1.key, |
|
102 |
+ group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME, |
|
103 |
+ value="tests.data.callables: provider_entry1", |
|
104 |
+ ), |
|
105 |
+ importlib.metadata.EntryPoint( |
|
106 |
+ name=callables.provider_entry2.key, |
|
107 |
+ group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME, |
|
108 |
+ value="tests.data.callables: provider_entry2", |
|
109 |
+ ), |
|
110 |
+ ], |
|
111 |
+ id="new-entries", |
|
112 |
+ ), |
|
113 |
+ ], |
|
114 |
+ ) |
|
115 |
+ STUBBED_AGENT_ADDRESSES = pytest.mark.parametrize( |
|
116 |
+ ["address", "exception", "match"], |
|
117 |
+ [ |
|
118 |
+ pytest.param(None, KeyError, "SSH_AUTH_SOCK", id="unset"), |
|
119 |
+ pytest.param("stub-ssh-agent:", None, "", id="standard"), |
|
120 |
+ pytest.param( |
|
121 |
+ str(pathlib.Path("~").expanduser()), |
|
122 |
+ FileNotFoundError, |
|
123 |
+ os.strerror(errno.ENOENT), |
|
124 |
+ id="invalid-url", |
|
125 |
+ ), |
|
126 |
+ pytest.param( |
|
127 |
+ "stub-ssh-agent:EPROTONOSUPPORT", |
|
128 |
+ OSError, |
|
129 |
+ os.strerror(errno.EPROTONOSUPPORT), |
|
130 |
+ id="protocol-not-supported", |
|
131 |
+ ), |
|
132 |
+ pytest.param( |
|
133 |
+ "stub-ssh-agent:ABCDEFGHIJKLMNOPQRSTUVWXYZ", |
|
134 |
+ OSError, |
|
135 |
+ os.strerror(errno.EINVAL), |
|
136 |
+ id="invalid-error-code", |
|
137 |
+ ), |
|
138 |
+ ], |
|
139 |
+ ) |
|
140 |
+ EXISTING_REGISTRY_ENTRIES = pytest.mark.parametrize( |
|
141 |
+ "existing", ["posix", "the_annoying_os"] |
|
142 |
+ ) |
|
143 |
+ SSH_STRING_EXCEPTIONS = pytest.mark.parametrize( |
|
144 |
+ ["input", "exc_type", "exc_pattern"], |
|
145 |
+ [ |
|
146 |
+ pytest.param( |
|
147 |
+ "some string", TypeError, "invalid payload type", id="str" |
|
148 |
+ ), |
|
149 |
+ ], |
|
150 |
+ ) |
|
151 |
+ UINT32_EXCEPTIONS = pytest.mark.parametrize( |
|
152 |
+ ["input", "exc_type", "exc_pattern"], |
|
153 |
+ [ |
|
154 |
+ pytest.param( |
|
155 |
+ 10000000000000000, |
|
156 |
+ OverflowError, |
|
157 |
+ "int too big to convert", |
|
158 |
+ id="10000000000000000", |
|
159 |
+ ), |
|
160 |
+ pytest.param( |
|
161 |
+ -1, |
|
162 |
+ OverflowError, |
|
163 |
+ "can't convert negative int to unsigned", |
|
164 |
+ id="-1", |
|
165 |
+ ), |
|
166 |
+ ], |
|
167 |
+ ) |
|
168 |
+ SSH_UNSTRING_EXCEPTIONS = pytest.mark.parametrize( |
|
169 |
+ ["input", "exc_type", "exc_pattern", "has_trailer", "parts"], |
|
170 |
+ [ |
|
171 |
+ pytest.param( |
|
172 |
+ b"ssh", |
|
173 |
+ ValueError, |
|
174 |
+ "malformed SSH byte string", |
|
175 |
+ False, |
|
176 |
+ None, |
|
177 |
+ id="unencoded", |
|
178 |
+ ), |
|
179 |
+ pytest.param( |
|
180 |
+ b"\x00\x00\x00\x08ssh-rsa", |
|
181 |
+ ValueError, |
|
182 |
+ "malformed SSH byte string", |
|
183 |
+ False, |
|
184 |
+ None, |
|
185 |
+ id="truncated", |
|
186 |
+ ), |
|
187 |
+ pytest.param( |
|
188 |
+ b"\x00\x00\x00\x04XXX trailing text", |
|
189 |
+ ValueError, |
|
190 |
+ "malformed SSH byte string", |
|
191 |
+ True, |
|
192 |
+ (b"XXX ", b"trailing text"), |
|
193 |
+ id="trailing-data", |
|
194 |
+ ), |
|
195 |
+ ], |
|
196 |
+ ) |
|
197 |
+ SSH_STRING_INPUT = pytest.mark.parametrize( |
|
198 |
+ ["input", "expected"], |
|
199 |
+ [ |
|
200 |
+ pytest.param( |
|
201 |
+ b"ssh-rsa", |
|
202 |
+ b"\x00\x00\x00\x07ssh-rsa", |
|
203 |
+ id="ssh-rsa", |
|
204 |
+ ), |
|
205 |
+ pytest.param( |
|
206 |
+ b"ssh-ed25519", |
|
207 |
+ b"\x00\x00\x00\x0bssh-ed25519", |
|
208 |
+ id="ssh-ed25519", |
|
209 |
+ ), |
|
210 |
+ pytest.param( |
|
211 |
+ ssh_agent.SSHAgentClient.string(b"ssh-ed25519"), |
|
212 |
+ b"\x00\x00\x00\x0f\x00\x00\x00\x0bssh-ed25519", |
|
213 |
+ id="string(ssh-ed25519)", |
|
214 |
+ ), |
|
215 |
+ ], |
|
216 |
+ ) |
|
217 |
+ SSH_UNSTRING_INPUT = pytest.mark.parametrize( |
|
218 |
+ ["input", "expected"], |
|
219 |
+ [ |
|
220 |
+ pytest.param( |
|
221 |
+ b"\x00\x00\x00\x07ssh-rsa", |
|
222 |
+ b"ssh-rsa", |
|
223 |
+ id="ssh-rsa", |
|
224 |
+ ), |
|
225 |
+ pytest.param( |
|
226 |
+ ssh_agent.SSHAgentClient.string(b"ssh-ed25519"), |
|
227 |
+ b"ssh-ed25519", |
|
228 |
+ id="ssh-ed25519", |
|
229 |
+ ), |
|
230 |
+ ], |
|
231 |
+ ) |
|
232 |
+ UINT32_INPUT = pytest.mark.parametrize( |
|
233 |
+ ["input", "expected"], |
|
234 |
+ [ |
|
235 |
+ pytest.param(16777216, b"\x01\x00\x00\x00", id="16777216"), |
|
236 |
+ ], |
|
237 |
+ ) |
|
238 |
+ SIGN_ERROR_RESPONSES = pytest.mark.parametrize( |
|
239 |
+ [ |
|
240 |
+ "key", |
|
241 |
+ "check", |
|
242 |
+ "response_code", |
|
243 |
+ "response", |
|
244 |
+ "exc_type", |
|
245 |
+ "exc_pattern", |
|
246 |
+ ], |
|
247 |
+ [ |
|
248 |
+ pytest.param( |
|
249 |
+ b"invalid-key", |
|
250 |
+ True, |
|
251 |
+ _types.SSH_AGENT.FAILURE, |
|
252 |
+ b"", |
|
253 |
+ KeyError, |
|
254 |
+ "target SSH key not loaded into agent", |
|
255 |
+ id="key-not-loaded", |
|
256 |
+ ), |
|
257 |
+ pytest.param( |
|
258 |
+ data.SUPPORTED_KEYS["ed25519"].public_key_data, |
|
259 |
+ True, |
|
260 |
+ _types.SSH_AGENT.FAILURE, |
|
261 |
+ b"", |
|
262 |
+ ssh_agent.SSHAgentFailedError, |
|
263 |
+ "failed to complete the request", |
|
264 |
+ id="failed-to-complete", |
|
265 |
+ ), |
|
266 |
+ ], |
|
267 |
+ ) |
|
268 |
+ SSH_KEY_SELECTION = pytest.mark.parametrize( |
|
269 |
+ ["key", "single"], |
|
270 |
+ [ |
|
271 |
+ (value.public_key_data, False) |
|
272 |
+ for value in data.SUPPORTED_KEYS.values() |
|
273 |
+ ] |
|
274 |
+ + [(callables.list_keys_singleton()[0].key, True)], |
|
275 |
+ ids=[*data.SUPPORTED_KEYS.keys(), "singleton"], |
|
276 |
+ ) |
|
277 |
+ SH_EXPORT_LINES = pytest.mark.parametrize( |
|
278 |
+ ["line", "env_name", "value"], |
|
279 |
+ [ |
|
280 |
+ pytest.param( |
|
281 |
+ "SSH_AUTH_SOCK=/tmp/pageant.user/pageant.27170; export SSH_AUTH_SOCK;", |
|
282 |
+ "SSH_AUTH_SOCK", |
|
283 |
+ "/tmp/pageant.user/pageant.27170", |
|
284 |
+ id="value-export-semicolon-pageant", |
|
285 |
+ ), |
|
286 |
+ pytest.param( |
|
287 |
+ "SSH_AUTH_SOCK=/tmp/ssh-3CSTC1W5M22A/agent.27270; export SSH_AUTH_SOCK;", |
|
288 |
+ "SSH_AUTH_SOCK", |
|
289 |
+ "/tmp/ssh-3CSTC1W5M22A/agent.27270", |
|
290 |
+ id="value-export-semicolon-openssh", |
|
291 |
+ ), |
|
292 |
+ pytest.param( |
|
293 |
+ "SSH_AUTH_SOCK=/tmp/pageant.user/pageant.27170; export SSH_AUTH_SOCK", |
|
294 |
+ "SSH_AUTH_SOCK", |
|
295 |
+ "/tmp/pageant.user/pageant.27170", |
|
296 |
+ id="value-export-pageant", |
|
297 |
+ ), |
|
298 |
+ pytest.param( |
|
299 |
+ "export SSH_AUTH_SOCK=/tmp/ssh-3CSTC1W5M22A/agent.27270;", |
|
300 |
+ "SSH_AUTH_SOCK", |
|
301 |
+ "/tmp/ssh-3CSTC1W5M22A/agent.27270", |
|
302 |
+ id="export-value-semicolon-openssh", |
|
303 |
+ ), |
|
304 |
+ pytest.param( |
|
305 |
+ "export SSH_AUTH_SOCK=/tmp/pageant.user/pageant.27170", |
|
306 |
+ "SSH_AUTH_SOCK", |
|
307 |
+ "/tmp/pageant.user/pageant.27170", |
|
308 |
+ id="export-value-pageant", |
|
309 |
+ ), |
|
310 |
+ pytest.param( |
|
311 |
+ "SSH_AGENT_PID=27170; export SSH_AGENT_PID;", |
|
312 |
+ "SSH_AGENT_PID", |
|
313 |
+ "27170", |
|
314 |
+ id="pid-export-semicolon", |
|
315 |
+ ), |
|
316 |
+ pytest.param( |
|
317 |
+ "SSH_AGENT_PID=27170; export SSH_AGENT_PID", |
|
318 |
+ "SSH_AGENT_PID", |
|
319 |
+ "27170", |
|
320 |
+ id="pid-export", |
|
321 |
+ ), |
|
322 |
+ pytest.param( |
|
323 |
+ "export SSH_AGENT_PID=27170;", |
|
324 |
+ "SSH_AGENT_PID", |
|
325 |
+ "27170", |
|
326 |
+ id="export-pid-semicolon", |
|
327 |
+ ), |
|
328 |
+ pytest.param( |
|
329 |
+ "export SSH_AGENT_PID=27170", |
|
330 |
+ "SSH_AGENT_PID", |
|
331 |
+ "27170", |
|
332 |
+ id="export-pid", |
|
333 |
+ ), |
|
334 |
+ pytest.param( |
|
335 |
+ "export VARIABLE=value; export OTHER_VARIABLE=other_value;", |
|
336 |
+ "VARIABLE", |
|
337 |
+ None, |
|
338 |
+ id="export-too-much", |
|
339 |
+ ), |
|
340 |
+ pytest.param( |
|
341 |
+ "VARIABLE=value", |
|
342 |
+ "VARIABLE", |
|
343 |
+ None, |
|
344 |
+ id="no-export", |
|
345 |
+ ), |
|
346 |
+ ], |
|
347 |
+ ) |
|
348 |
+ INVALID_SSH_AGENT_MESSAGES = pytest.mark.parametrize( |
|
349 |
+ "message", |
|
350 |
+ [ |
|
351 |
+ pytest.param(b"\x00\x00\x00\x00", id="empty-message"), |
|
352 |
+ pytest.param(b"\x00\x00\x00\x0f\x0d", id="truncated-message"), |
|
353 |
+ pytest.param( |
|
354 |
+ b"\x00\x00\x00\x06\x1b\x00\x00\x00\x01\xff", |
|
355 |
+ id="invalid-extension-name", |
|
356 |
+ ), |
|
357 |
+ pytest.param( |
|
358 |
+ b"\x00\x00\x00\x11\x0d\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", |
|
359 |
+ id="sign-with-trailing-data", |
|
360 |
+ ), |
|
361 |
+ ], |
|
362 |
+ ) |
|
363 |
+ UNSUPPORTED_SSH_AGENT_MESSAGES = pytest.mark.parametrize( |
|
364 |
+ "message", |
|
365 |
+ [ |
|
366 |
+ pytest.param( |
|
367 |
+ ssh_agent.SSHAgentClient.string( |
|
368 |
+ b"".join([ |
|
369 |
+ b"\x0d", |
|
370 |
+ ssh_agent.SSHAgentClient.string( |
|
371 |
+ data.ALL_KEYS["rsa"].public_key_data |
|
372 |
+ ), |
|
373 |
+ ssh_agent.SSHAgentClient.string(vault.Vault.UUID), |
|
374 |
+ b"\x00\x00\x00\x02", |
|
375 |
+ ]) |
|
376 |
+ ), |
|
377 |
+ id="sign-with-flags", |
|
378 |
+ ), |
|
379 |
+ pytest.param( |
|
380 |
+ ssh_agent.SSHAgentClient.string( |
|
381 |
+ b"".join([ |
|
382 |
+ b"\x0d", |
|
383 |
+ ssh_agent.SSHAgentClient.string( |
|
384 |
+ data.ALL_KEYS["ed25519"].public_key_data |
|
385 |
+ ), |
|
386 |
+ b"\x00\x00\x00\x08\x00\x01\x02\x03\x04\x05\x06\x07", |
|
387 |
+ b"\x00\x00\x00\x00", |
|
388 |
+ ]) |
|
389 |
+ ), |
|
390 |
+ id="sign-with-nonstandard-passphrase", |
|
391 |
+ ), |
|
392 |
+ pytest.param( |
|
393 |
+ ssh_agent.SSHAgentClient.string( |
|
394 |
+ b"".join([ |
|
395 |
+ b"\x0d", |
|
396 |
+ ssh_agent.SSHAgentClient.string( |
|
397 |
+ data.ALL_KEYS["dsa1024"].public_key_data |
|
398 |
+ ), |
|
399 |
+ ssh_agent.SSHAgentClient.string(vault.Vault.UUID), |
|
400 |
+ b"\x00\x00\x00\x00", |
|
401 |
+ ]) |
|
402 |
+ ), |
|
403 |
+ id="sign-key-no-expected-signature", |
|
404 |
+ ), |
|
405 |
+ pytest.param( |
|
406 |
+ ssh_agent.SSHAgentClient.string( |
|
407 |
+ b"".join([ |
|
408 |
+ b"\x0d", |
|
409 |
+ b"\x00\x00\x00\x00", |
|
410 |
+ ssh_agent.SSHAgentClient.string(vault.Vault.UUID), |
|
411 |
+ b"\x00\x00\x00\x00", |
|
412 |
+ ]) |
|
413 |
+ ), |
|
414 |
+ id="sign-key-unregistered-test-key", |
|
415 |
+ ), |
|
416 |
+ ], |
|
417 |
+ ) |
|
418 |
+ PUBLIC_KEY_DATA = pytest.mark.parametrize( |
|
419 |
+ "public_key_struct", |
|
420 |
+ list(data.SUPPORTED_KEYS.values()), |
|
421 |
+ ids=list(data.SUPPORTED_KEYS.keys()), |
|
422 |
+ ) |
|
423 |
+ REQUEST_ERROR_RESPONSES = pytest.mark.parametrize( |
|
424 |
+ ["request_code", "response_code", "exc_type", "exc_pattern"], |
|
425 |
+ [ |
|
426 |
+ pytest.param( |
|
427 |
+ _types.SSH_AGENTC.REQUEST_IDENTITIES, |
|
428 |
+ _types.SSH_AGENT.SUCCESS, |
|
429 |
+ ssh_agent.SSHAgentFailedError, |
|
430 |
+ re.escape( |
|
431 |
+ f"[Code {_types.SSH_AGENT.IDENTITIES_ANSWER.value}]" |
|
432 |
+ ), |
|
433 |
+ id="REQUEST_IDENTITIES-expect-SUCCESS", |
|
434 |
+ ), |
|
435 |
+ ], |
|
436 |
+ ) |
|
437 |
+ TRUNCATED_AGENT_RESPONSES = pytest.mark.parametrize( |
|
438 |
+ "response", |
|
439 |
+ [ |
|
440 |
+ b"\x00\x00", |
|
441 |
+ b"\x00\x00\x00\x1f some bytes missing", |
|
442 |
+ ], |
|
443 |
+ ids=["in-header", "in-body"], |
|
444 |
+ ) |
|
445 |
+ LIST_KEYS_ERROR_RESPONSES = pytest.mark.parametrize( |
|
446 |
+ ["response_code", "response", "exc_type", "exc_pattern"], |
|
447 |
+ [ |
|
448 |
+ pytest.param( |
|
449 |
+ _types.SSH_AGENT.FAILURE, |
|
450 |
+ b"", |
|
451 |
+ ssh_agent.SSHAgentFailedError, |
|
452 |
+ "failed to complete the request", |
|
453 |
+ id="failed-to-complete", |
|
454 |
+ ), |
|
455 |
+ pytest.param( |
|
456 |
+ _types.SSH_AGENT.IDENTITIES_ANSWER, |
|
457 |
+ b"\x00\x00\x00\x01", |
|
458 |
+ EOFError, |
|
459 |
+ "truncated response", |
|
460 |
+ id="truncated-response", |
|
461 |
+ ), |
|
462 |
+ pytest.param( |
|
463 |
+ _types.SSH_AGENT.IDENTITIES_ANSWER, |
|
464 |
+ b"\x00\x00\x00\x00abc", |
|
465 |
+ ssh_agent.TrailingDataError, |
|
466 |
+ "Overlong response", |
|
467 |
+ id="overlong-response", |
|
468 |
+ ), |
|
469 |
+ ], |
|
470 |
+ ) |
|
471 |
+ QUERY_EXTENSIONS_MALFORMED_RESPONSES = pytest.mark.parametrize( |
|
472 |
+ "response_data", |
|
473 |
+ [ |
|
474 |
+ pytest.param(b"\xde\xad\xbe\xef", id="truncated"), |
|
475 |
+ pytest.param( |
|
476 |
+ b"\x00\x00\x00\x0fwrong extension", id="wrong-extension" |
|
477 |
+ ), |
|
478 |
+ pytest.param( |
|
479 |
+ b"\x00\x00\x00\x05query\xde\xad\xbe\xef", id="with-trailer" |
|
480 |
+ ), |
|
481 |
+ pytest.param( |
|
482 |
+ b"\x00\x00\x00\x05query\x00\x00\x00\x04ext1\x00\x00", |
|
483 |
+ id="with-extra-fields", |
|
484 |
+ ), |
|
485 |
+ ], |
|
486 |
+ ) |
|
487 |
+ SUPPORTED_SSH_TEST_KEYS = pytest.mark.parametrize( |
|
488 |
+ ["ssh_test_key_type", "ssh_test_key"], |
|
489 |
+ list(data.SUPPORTED_KEYS.items()), |
|
490 |
+ ids=data.SUPPORTED_KEYS.keys(), |
|
491 |
+ ) |
|
492 |
+ UNSUITABLE_SSH_TEST_KEYS = pytest.mark.parametrize( |
|
493 |
+ ["ssh_test_key_type", "ssh_test_key"], |
|
494 |
+ list(data.UNSUITABLE_KEYS.items()), |
|
495 |
+ ids=data.UNSUITABLE_KEYS.keys(), |
|
496 |
+ ) |
|
497 |
+ RESOLVE_CHAINS = pytest.mark.parametrize( |
|
498 |
+ ["terminal", "chain"], |
|
499 |
+ [ |
|
500 |
+ pytest.param("callable", ["a"], id="callable-1"), |
|
501 |
+ pytest.param("callable", ["a", "b", "c", "d"], id="callable-4"), |
|
502 |
+ pytest.param("alias", ["e"], id="alias-5"), |
|
503 |
+ pytest.param("alias", ["e", "f", "g", "h", "i"], id="alias-5"), |
|
504 |
+ pytest.param("unimplemented", ["j"], id="unimplemented-1"), |
|
505 |
+ pytest.param("unimplemented", ["j", "k"], id="unimplemented-2"), |
|
506 |
+ ], |
|
507 |
+ ) |
|
508 |
+ |
|
509 |
+ |
|
510 |
+class TestTestingMachineryStubbedSSHAgentSocket: |
|
511 |
+ """Test the stubbed SSH agent socket for the `ssh_agent` module tests.""" |
|
512 |
+ |
|
513 |
+ def test_100a_query_extensions_base(self) -> None: |
|
514 |
+ """The base agent implements no extensions.""" |
|
515 |
+ with contextlib.ExitStack() as stack: |
|
516 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
517 |
+ monkeypatch.setenv( |
|
518 |
+ "SSH_AUTH_SOCK", |
|
519 |
+ machinery.StubbedSSHAgentSocketWithAddress.ADDRESS, |
|
520 |
+ ) |
|
521 |
+ agent = stack.enter_context( |
|
522 |
+ machinery.StubbedSSHAgentSocketWithAddress() |
|
523 |
+ ) |
|
524 |
+ assert "query" not in agent.enabled_extensions |
|
525 |
+ query_request = ( |
|
526 |
+ # SSH string header |
|
527 |
+ b"\x00\x00\x00\x0a" |
|
528 |
+ # request code: SSH_AGENTC_EXTENSION |
|
529 |
+ b"\x1b" |
|
530 |
+ # payload: SSH string "query" |
|
531 |
+ b"\x00\x00\x00\x05query" |
|
532 |
+ ) |
|
533 |
+ query_response = ( |
|
534 |
+ # SSH string header |
|
535 |
+ b"\x00\x00\x00\x01" |
|
536 |
+ # response code: SSH_AGENT_FAILURE |
|
537 |
+ b"\x05" |
|
538 |
+ ) |
|
539 |
+ agent.sendall(query_request) |
|
540 |
+ assert agent.recv(1000) == query_response |
|
541 |
+ |
|
542 |
+ def test_100b_query_extensions_extended(self) -> None: |
|
543 |
+ """The extended agent implements a known list of extensions.""" |
|
544 |
+ with contextlib.ExitStack() as stack: |
|
545 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
546 |
+ monkeypatch.setenv( |
|
547 |
+ "SSH_AUTH_SOCK", |
|
548 |
+ machinery.StubbedSSHAgentSocketWithAddress.ADDRESS, |
|
549 |
+ ) |
|
550 |
+ agent = stack.enter_context( |
|
551 |
+ machinery.StubbedSSHAgentSocketWithAddressAndDeterministicDSA() |
|
552 |
+ ) |
|
553 |
+ assert "query" in agent.enabled_extensions |
|
554 |
+ query_request = ( |
|
555 |
+ # SSH string header |
|
556 |
+ b"\x00\x00\x00\x0a" |
|
557 |
+ # request code: SSH_AGENTC_EXTENSION |
|
558 |
+ b"\x1b" |
|
559 |
+ # payload: SSH string "query" |
|
560 |
+ b"\x00\x00\x00\x05query" |
|
561 |
+ ) |
|
562 |
+ query_response = ( |
|
563 |
+ # SSH string header |
|
564 |
+ b"\x00\x00\x00\x40" |
|
565 |
+ # response code: SSH_AGENT_EXTENSION_RESPONSE |
|
566 |
+ b"\x1d" |
|
567 |
+ # extension response: extension type ("query") |
|
568 |
+ b"\x00\x00\x00\x05query" |
|
569 |
+ # supported extension #1: query |
|
570 |
+ b"\x00\x00\x00\x05query" |
|
571 |
+ # supported extension #2: |
|
572 |
+ # list-extended@putty.projects.tartarus.org |
|
573 |
+ b"\x00\x00\x00\x29list-extended@putty.projects.tartarus.org" |
|
574 |
+ ) |
|
575 |
+ agent.sendall(query_request) |
|
576 |
+ assert agent.recv(1000) == query_response |
|
577 |
+ |
|
578 |
+ def test_101_request_identities(self) -> None: |
|
579 |
+ """The agent implements a known list of identities.""" |
|
580 |
+ unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix |
|
581 |
+ with machinery.StubbedSSHAgentSocket() as agent: |
|
582 |
+ query_request = ( |
|
583 |
+ # SSH string header |
|
584 |
+ b"\x00\x00\x00\x01" |
|
585 |
+ # request code: SSH_AGENTC_REQUEST_IDENTITIES |
|
586 |
+ b"\x0b" |
|
587 |
+ ) |
|
588 |
+ agent.sendall(query_request) |
|
589 |
+ message_length = int.from_bytes(agent.recv(4), "big") |
|
590 |
+ orig_message: bytes | bytearray = bytearray( |
|
591 |
+ agent.recv(message_length) |
|
592 |
+ ) |
|
593 |
+ assert ( |
|
594 |
+ _types.SSH_AGENT(orig_message[0]) |
|
595 |
+ == _types.SSH_AGENT.IDENTITIES_ANSWER |
|
596 |
+ ) |
|
597 |
+ identity_count = int.from_bytes(orig_message[1:5], "big") |
|
598 |
+ message = bytes(orig_message[5:]) |
|
599 |
+ for _ in range(identity_count): |
|
600 |
+ key, message = unstring_prefix(message) |
|
601 |
+ _comment, message = unstring_prefix(message) |
|
602 |
+ assert key |
|
603 |
+ assert key in { |
|
604 |
+ k.public_key_data for k in data.ALL_KEYS.values() |
|
605 |
+ } |
|
606 |
+ assert not message |
|
607 |
+ |
|
608 |
+ @Parametrize.SUPPORTED_SSH_TEST_KEYS |
|
609 |
+ def test_102_sign( |
|
610 |
+ self, |
|
611 |
+ ssh_test_key_type: str, |
|
612 |
+ ssh_test_key: data.SSHTestKey, |
|
613 |
+ ) -> None: |
|
614 |
+ """The agent signs known key/message pairs.""" |
|
615 |
+ del ssh_test_key_type |
|
616 |
+ spec = data.SSHTestKeyDeterministicSignatureClass.SPEC |
|
617 |
+ assert ssh_test_key.expected_signatures[spec].signature is not None |
|
618 |
+ string = ssh_agent.SSHAgentClient.string |
|
619 |
+ query_request = string( |
|
620 |
+ # request code: SSH_AGENTC_SIGN_REQUEST |
|
621 |
+ b"\x0d" |
|
622 |
+ # key: SSH string of the public key |
|
623 |
+ + string(ssh_test_key.public_key_data) |
|
624 |
+ # payload: SSH string of the vault UUID |
|
625 |
+ + string(vault.Vault.UUID) |
|
626 |
+ # signing flags (uint32, empty) |
|
627 |
+ + b"\x00\x00\x00\x00" |
|
628 |
+ ) |
|
629 |
+ query_response = string( |
|
630 |
+ # response code: SSH_AGENT_SIGN_RESPONSE |
|
631 |
+ b"\x0e" |
|
632 |
+ # expected payload: the binary signature as recorded in the test key data structure |
|
633 |
+ + string(ssh_test_key.expected_signatures[spec].signature) |
|
634 |
+ ) |
|
635 |
+ with machinery.StubbedSSHAgentSocket() as agent: |
|
636 |
+ agent.sendall(query_request) |
|
637 |
+ assert agent.recv(1000) == query_response |
|
638 |
+ |
|
639 |
+ def test_120_close_multiple(self) -> None: |
|
640 |
+ """The agent can be closed repeatedly.""" |
|
641 |
+ with machinery.StubbedSSHAgentSocket() as agent: |
|
642 |
+ pass |
|
643 |
+ with machinery.StubbedSSHAgentSocket() as agent: |
|
644 |
+ pass |
|
645 |
+ del agent |
|
646 |
+ |
|
647 |
+ def test_121_closed_agents_cannot_be_interacted_with(self) -> None: |
|
648 |
+ """The agent can be closed repeatedly.""" |
|
649 |
+ with machinery.StubbedSSHAgentSocket() as agent: |
|
650 |
+ pass |
|
651 |
+ query_request = ( |
|
652 |
+ # SSH string header |
|
653 |
+ b"\x00\x00\x00\x0a" |
|
654 |
+ # request code: SSH_AGENTC_EXTENSION |
|
655 |
+ b"\x1b" |
|
656 |
+ # payload: SSH string "query" |
|
657 |
+ b"\x00\x00\x00\x05query" |
|
658 |
+ ) |
|
659 |
+ query_response = b"" |
|
660 |
+ with pytest.raises( |
|
661 |
+ ValueError, |
|
662 |
+ match=re.escape(machinery.StubbedSSHAgentSocket._SOCKET_IS_CLOSED), |
|
663 |
+ ): |
|
664 |
+ agent.sendall(query_request) |
|
665 |
+ assert agent.recv(100) == query_response |
|
666 |
+ |
|
667 |
+ def test_122_no_recv_without_sendall(self) -> None: |
|
668 |
+ """The agent requires a message before sending a response.""" |
|
669 |
+ with machinery.StubbedSSHAgentSocket() as agent: # noqa: SIM117 |
|
670 |
+ with pytest.raises( |
|
671 |
+ AssertionError, |
|
672 |
+ match=re.escape( |
|
673 |
+ machinery.StubbedSSHAgentSocket._PROTOCOL_VIOLATION |
|
674 |
+ ), |
|
675 |
+ ): |
|
676 |
+ agent.recv(100) |
|
677 |
+ |
|
678 |
+ @Parametrize.INVALID_SSH_AGENT_MESSAGES |
|
679 |
+ def test_123_invalid_ssh_agent_messages( |
|
680 |
+ self, |
|
681 |
+ message: Buffer, |
|
682 |
+ ) -> None: |
|
683 |
+ """The agent responds with errors on invalid messages.""" |
|
684 |
+ query_response = ( |
|
685 |
+ # SSH string header |
|
686 |
+ b"\x00\x00\x00\x01" |
|
687 |
+ # response code: SSH_AGENT_FAILURE |
|
688 |
+ b"\x05" |
|
689 |
+ ) |
|
690 |
+ with machinery.StubbedSSHAgentSocket() as agent: |
|
691 |
+ agent.sendall(message) |
|
692 |
+ assert agent.recv(100) == query_response |
|
693 |
+ |
|
694 |
+ @Parametrize.UNSUPPORTED_SSH_AGENT_MESSAGES |
|
695 |
+ def test_124_unsupported_ssh_agent_messages( |
|
696 |
+ self, |
|
697 |
+ message: Buffer, |
|
698 |
+ ) -> None: |
|
699 |
+ """The agent responds with errors on unsupported messages.""" |
|
700 |
+ query_response = ( |
|
701 |
+ # SSH string header |
|
702 |
+ b"\x00\x00\x00\x01" |
|
703 |
+ # response code: SSH_AGENT_FAILURE |
|
704 |
+ b"\x05" |
|
705 |
+ ) |
|
706 |
+ with machinery.StubbedSSHAgentSocket() as agent: |
|
707 |
+ agent.sendall(message) |
|
708 |
+ assert agent.recv(100) == query_response |
|
709 |
+ |
|
710 |
+ @Parametrize.STUBBED_AGENT_ADDRESSES |
|
711 |
+ def test_125_addresses( |
|
712 |
+ self, |
|
713 |
+ address: str | None, |
|
714 |
+ exception: type[Exception] | None, |
|
715 |
+ match: str, |
|
716 |
+ ) -> None: |
|
717 |
+ """The agent accepts addresses.""" |
|
718 |
+ with contextlib.ExitStack() as stack: |
|
719 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
720 |
+ if address: |
|
721 |
+ monkeypatch.setenv("SSH_AUTH_SOCK", address) |
|
722 |
+ else: |
|
723 |
+ monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) |
|
724 |
+ if exception: |
|
725 |
+ stack.enter_context( |
|
726 |
+ pytest.raises(exception, match=re.escape(match)) |
|
727 |
+ ) |
|
728 |
+ machinery.StubbedSSHAgentSocketWithAddress() |
|
729 |
+ |
|
730 |
+ |
|
731 |
+class TestStaticFunctionality: |
|
732 |
+ """Test the static functionality of the `ssh_agent` module.""" |
|
733 |
+ |
|
734 |
+ @staticmethod |
|
735 |
+ def as_ssh_string(bytestring: bytes) -> bytes: |
|
736 |
+ """Return an encoded SSH string from a bytestring. |
|
737 |
+ |
|
738 |
+ This is a helper function for hypothesis data generation. |
|
739 |
+ |
|
740 |
+ """ |
|
741 |
+ return int.to_bytes(len(bytestring), 4, "big") + bytestring |
|
742 |
+ |
|
743 |
+ @staticmethod |
|
744 |
+ def canonicalize1(data: bytes) -> bytes: |
|
745 |
+ """Return an encoded SSH string from a bytestring. |
|
746 |
+ |
|
747 |
+ This is a helper function for hypothesis testing. |
|
748 |
+ |
|
749 |
+ References: |
|
750 |
+ |
|
751 |
+ * [David R. MacIver: Another invariant to test for |
|
752 |
+ encoders][DECODE_ENCODE] |
|
753 |
+ |
|
754 |
+ [DECODE_ENCODE]: https://hypothesis.works/articles/canonical-serialization/ |
|
755 |
+ |
|
756 |
+ """ |
|
757 |
+ return ssh_agent.SSHAgentClient.string( |
|
758 |
+ ssh_agent.SSHAgentClient.unstring(data) |
|
759 |
+ ) |
|
760 |
+ |
|
761 |
+ @staticmethod |
|
762 |
+ def canonicalize2(data: bytes) -> bytes: |
|
763 |
+ """Return an encoded SSH string from a bytestring. |
|
764 |
+ |
|
765 |
+ This is a helper function for hypothesis testing. |
|
766 |
+ |
|
767 |
+ References: |
|
768 |
+ |
|
769 |
+ * [David R. MacIver: Another invariant to test for |
|
770 |
+ encoders][DECODE_ENCODE] |
|
771 |
+ |
|
772 |
+ [DECODE_ENCODE]: https://hypothesis.works/articles/canonical-serialization/ |
|
773 |
+ |
|
774 |
+ """ |
|
775 |
+ unstringed, trailer = ssh_agent.SSHAgentClient.unstring_prefix(data) |
|
776 |
+ assert not trailer |
|
777 |
+ return ssh_agent.SSHAgentClient.string(unstringed) |
|
778 |
+ |
|
779 |
+ # TODO(the-13th-letter): Re-evaluate if this check is worth keeping. |
|
780 |
+ # It cannot provide true tamper-resistence, but probably appears to. |
|
781 |
+ @Parametrize.PUBLIC_KEY_DATA |
|
782 |
+ def test_100_key_decoding( |
|
783 |
+ self, |
|
784 |
+ public_key_struct: data.SSHTestKey, |
|
785 |
+ ) -> None: |
|
786 |
+ """The [`tests.ALL_KEYS`][] public key data looks sane.""" |
|
787 |
+ keydata = base64.b64decode( |
|
788 |
+ public_key_struct.public_key.split(None, 2)[1] |
|
789 |
+ ) |
|
790 |
+ assert keydata == public_key_struct.public_key_data, ( |
|
791 |
+ "recorded public key data doesn't match" |
|
792 |
+ ) |
|
793 |
+ |
|
794 |
+ @Parametrize.SH_EXPORT_LINES |
|
795 |
+ def test_190_sh_export_line_parsing( |
|
796 |
+ self, line: str, env_name: str, value: str | None |
|
797 |
+ ) -> None: |
|
798 |
+ """[`tests.parse_sh_export_line`][] works.""" |
|
799 |
+ if value is not None: |
|
800 |
+ assert ( |
|
801 |
+ callables.parse_sh_export_line(line, env_name=env_name) |
|
802 |
+ == value |
|
803 |
+ ) |
|
804 |
+ else: |
|
805 |
+ with pytest.raises(ValueError, match="Cannot parse sh line:"): |
|
806 |
+ callables.parse_sh_export_line(line, env_name=env_name) |
|
807 |
+ |
|
808 |
+ def test_200_constructor_posix_no_ssh_auth_sock( |
|
809 |
+ self, |
|
810 |
+ skip_if_no_af_unix_support: None, |
|
811 |
+ ) -> None: |
|
812 |
+ """Abort if the running agent cannot be located on POSIX.""" |
|
813 |
+ del skip_if_no_af_unix_support |
|
814 |
+ posix_handler = socketprovider.SocketProvider.resolve("posix") |
|
815 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
816 |
+ monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) |
|
817 |
+ with pytest.raises( |
|
818 |
+ KeyError, match="SSH_AUTH_SOCK environment variable" |
|
819 |
+ ): |
|
820 |
+ posix_handler() |
|
821 |
+ |
|
822 |
+ @Parametrize.UINT32_INPUT |
|
823 |
+ def test_210_uint32(self, input: int, expected: bytes | bytearray) -> None: |
|
824 |
+ """`uint32` encoding works.""" |
|
825 |
+ uint32 = ssh_agent.SSHAgentClient.uint32 |
|
826 |
+ assert uint32(input) == expected |
|
827 |
+ |
|
828 |
+ @hypothesis.given(strategies.integers(min_value=0, max_value=0xFFFFFFFF)) |
|
829 |
+ @hypothesis.example(0xDEADBEEF).via("manual, pre-hypothesis example") |
|
830 |
+ def test_210a_uint32_from_number(self, num: int) -> None: |
|
831 |
+ """`uint32` encoding works, starting from numbers.""" |
|
832 |
+ uint32 = ssh_agent.SSHAgentClient.uint32 |
|
833 |
+ assert int.from_bytes(uint32(num), "big", signed=False) == num |
|
834 |
+ |
|
835 |
+ @hypothesis.given(strategies.binary(min_size=4, max_size=4)) |
|
836 |
+ @hypothesis.example(b"\xde\xad\xbe\xef").via( |
|
837 |
+ "manual, pre-hypothesis example" |
|
838 |
+ ) |
|
839 |
+ def test_210b_uint32_from_bytestring(self, bytestring: bytes) -> None: |
|
840 |
+ """`uint32` encoding works, starting from length four byte strings.""" |
|
841 |
+ uint32 = ssh_agent.SSHAgentClient.uint32 |
|
842 |
+ assert ( |
|
843 |
+ uint32(int.from_bytes(bytestring, "big", signed=False)) |
|
844 |
+ == bytestring |
|
845 |
+ ) |
|
846 |
+ |
|
847 |
+ @Parametrize.SSH_STRING_INPUT |
|
848 |
+ def test_211_string( |
|
849 |
+ self, input: bytes | bytearray, expected: bytes | bytearray |
|
850 |
+ ) -> None: |
|
851 |
+ """SSH string encoding works.""" |
|
852 |
+ string = ssh_agent.SSHAgentClient.string |
|
853 |
+ assert bytes(string(input)) == expected |
|
854 |
+ |
|
855 |
+ @hypothesis.given(strategies.binary(max_size=0x0001FFFF)) |
|
856 |
+ @hypothesis.example(b"DEADBEEF" * 10000).via( |
|
857 |
+ "manual, pre-hypothesis example with highest order bit set" |
|
858 |
+ ) |
|
859 |
+ def test_211a_string_from_bytestring(self, bytestring: bytes) -> None: |
|
860 |
+ """SSH string encoding works, starting from a byte string.""" |
|
861 |
+ res = ssh_agent.SSHAgentClient.string(bytestring) |
|
862 |
+ assert res.startswith((b"\x00\x00", b"\x00\x01")) |
|
863 |
+ assert int.from_bytes(res[:4], "big", signed=False) == len(bytestring) |
|
864 |
+ assert res[4:] == bytestring |
|
865 |
+ |
|
866 |
+ @Parametrize.SSH_UNSTRING_INPUT |
|
867 |
+ def test_212_unstring( |
|
868 |
+ self, input: bytes | bytearray, expected: bytes | bytearray |
|
869 |
+ ) -> None: |
|
870 |
+ """SSH string decoding works.""" |
|
871 |
+ unstring = ssh_agent.SSHAgentClient.unstring |
|
872 |
+ unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix |
|
873 |
+ assert bytes(unstring(input)) == expected |
|
874 |
+ assert tuple(bytes(x) for x in unstring_prefix(input)) == ( |
|
875 |
+ expected, |
|
876 |
+ b"", |
|
877 |
+ ) |
|
878 |
+ |
|
879 |
+ @hypothesis.given(strategies.binary(max_size=0x00FFFFFF)) |
|
880 |
+ @hypothesis.example(b"\x00\x00\x00\x07ssh-rsa").via( |
|
881 |
+ "manual, pre-hypothesis example to attempt to detect double-decoding" |
|
882 |
+ ) |
|
883 |
+ @hypothesis.example(b"\x00\x00\x00\x01").via( |
|
884 |
+ "detect no-op encoding via ill-formed SSH string" |
|
885 |
+ ) |
|
886 |
+ def test_212a_unstring_of_string_of_data(self, bytestring: bytes) -> None: |
|
887 |
+ """SSH string decoding of encoded SSH strings works. |
|
888 |
+ |
|
889 |
+ References: |
|
890 |
+ |
|
891 |
+ * [David R. MacIver: The Encode/Decode invariant][ENCODE_DECODE] |
|
892 |
+ |
|
893 |
+ [ENCODE_DECODE]: https://hypothesis.works/articles/encode-decode-invariant/ |
|
894 |
+ |
|
895 |
+ """ |
|
896 |
+ string = ssh_agent.SSHAgentClient.string |
|
897 |
+ unstring = ssh_agent.SSHAgentClient.unstring |
|
898 |
+ unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix |
|
899 |
+ encoded = string(bytestring) |
|
900 |
+ assert unstring(encoded) == bytestring |
|
901 |
+ assert unstring_prefix(encoded) == (bytestring, b"") |
|
902 |
+ trailing_data = b" trailing data" |
|
903 |
+ encoded2 = string(bytestring) + trailing_data |
|
904 |
+ assert unstring_prefix(encoded2) == (bytestring, trailing_data) |
|
905 |
+ |
|
906 |
+ @hypothesis.given( |
|
907 |
+ strategies.binary(max_size=0x00FFFFFF).map( |
|
908 |
+ # Scoping issues, and the fact that staticmethod objects |
|
909 |
+ # (before class finalization) are not callable, necessitate |
|
910 |
+ # wrapping this staticmethod call in a lambda. |
|
911 |
+ lambda x: TestStaticFunctionality.as_ssh_string(x) # noqa: PLW0108 |
|
912 |
+ ), |
|
913 |
+ ) |
|
914 |
+ def test_212b_string_of_unstring_of_data(self, encoded: bytes) -> None: |
|
915 |
+ """SSH string decoding of encoded SSH strings works. |
|
916 |
+ |
|
917 |
+ References: |
|
918 |
+ |
|
919 |
+ * [David R. MacIver: Another invariant to test for |
|
920 |
+ encoders][DECODE_ENCODE] |
|
921 |
+ |
|
922 |
+ [DECODE_ENCODE]: https://hypothesis.works/articles/canonical-serialization/ |
|
923 |
+ |
|
924 |
+ """ |
|
925 |
+ canonical_functions = [self.canonicalize1, self.canonicalize2] |
|
926 |
+ for canon1 in canonical_functions: |
|
927 |
+ for canon2 in canonical_functions: |
|
928 |
+ assert canon1(encoded) == canon2(encoded) |
|
929 |
+ assert canon1(canon2(encoded)) == canon1(encoded) |
|
930 |
+ |
|
931 |
+ def test_220_registry_resolve( |
|
932 |
+ self, |
|
933 |
+ ) -> None: |
|
934 |
+ """Resolving entries in the socket provider registry works.""" |
|
935 |
+ registry = socketprovider.SocketProvider.registry |
|
936 |
+ resolve = socketprovider.SocketProvider.resolve |
|
937 |
+ lookup = socketprovider.SocketProvider.lookup |
|
938 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
939 |
+ monkeypatch.setitem(registry, "stub_agent", None) |
|
940 |
+ assert callable(lookup("native")) |
|
941 |
+ assert callable(resolve("native")) |
|
942 |
+ assert lookup("stub_agent") is None |
|
943 |
+ with pytest.raises(NotImplementedError): |
|
944 |
+ resolve("stub_agent") |
|
945 |
+ |
|
946 |
+ @Parametrize.RESOLVE_CHAINS |
|
947 |
+ def test_221_registry_resolve_chains( |
|
948 |
+ self, |
|
949 |
+ terminal: Literal["unimplemented", "alias", "callable"], |
|
950 |
+ chain: list[str], |
|
951 |
+ ) -> None: |
|
952 |
+ """Resolving a chain of providers works.""" |
|
953 |
+ registry = socketprovider.SocketProvider.registry |
|
954 |
+ resolve = socketprovider.SocketProvider.resolve |
|
955 |
+ lookup = socketprovider.SocketProvider.lookup |
|
956 |
+ try: |
|
957 |
+ implementation = resolve("native") |
|
958 |
+ except NotImplementedError: # pragma: no cover |
|
959 |
+ pytest.fail("Native SSH agent socket provider is unavailable?!") |
|
960 |
+ # TODO(the-13th-letter): Rewrite using structural pattern matching. |
|
961 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
962 |
+ target = ( |
|
963 |
+ None |
|
964 |
+ if terminal == "unimplemented" |
|
965 |
+ else "native" |
|
966 |
+ if terminal == "alias" |
|
967 |
+ else implementation |
|
968 |
+ ) |
|
969 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
970 |
+ for link in chain: |
|
971 |
+ monkeypatch.setitem(registry, link, target) |
|
972 |
+ target = link |
|
973 |
+ for link in chain: |
|
974 |
+ assert lookup(link) == ( |
|
975 |
+ implementation if terminal != "unimplemented" else None |
|
976 |
+ ) |
|
977 |
+ if terminal == "unimplemented": |
|
978 |
+ with pytest.raises(NotImplementedError): |
|
979 |
+ resolve(link) |
|
980 |
+ else: |
|
981 |
+ assert resolve(link) == implementation |
|
982 |
+ |
|
983 |
+ @hypothesis.given( |
|
984 |
+ terminal=strategies.sampled_from([ |
|
985 |
+ "unimplemented", |
|
986 |
+ "alias", |
|
987 |
+ "callable", |
|
988 |
+ ]), |
|
989 |
+ chain=strategies.lists( |
|
990 |
+ strategies.sampled_from([ |
|
991 |
+ "c1", |
|
992 |
+ "c2", |
|
993 |
+ "c3", |
|
994 |
+ "c4", |
|
995 |
+ "c5", |
|
996 |
+ "c6", |
|
997 |
+ "c7", |
|
998 |
+ "c8", |
|
999 |
+ "c9", |
|
1000 |
+ "c10", |
|
1001 |
+ ]), |
|
1002 |
+ min_size=1, |
|
1003 |
+ unique=True, |
|
1004 |
+ ), |
|
1005 |
+ ) |
|
1006 |
+ def test_221a_registry_resolve_chains( |
|
1007 |
+ self, |
|
1008 |
+ terminal: Literal["unimplemented", "alias", "callable"], |
|
1009 |
+ chain: list[str], |
|
1010 |
+ ) -> None: |
|
1011 |
+ """Resolving a chain of providers works.""" |
|
1012 |
+ registry = socketprovider.SocketProvider.registry |
|
1013 |
+ resolve = socketprovider.SocketProvider.resolve |
|
1014 |
+ lookup = socketprovider.SocketProvider.lookup |
|
1015 |
+ try: |
|
1016 |
+ implementation = resolve("native") |
|
1017 |
+ except NotImplementedError: # pragma: no cover |
|
1018 |
+ hypothesis.note(f"{registry = }") |
|
1019 |
+ pytest.fail("Native SSH agent socket provider is unavailable?!") |
|
1020 |
+ # TODO(the-13th-letter): Rewrite using structural pattern matching. |
|
1021 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1022 |
+ target = ( |
|
1023 |
+ None |
|
1024 |
+ if terminal == "unimplemented" |
|
1025 |
+ else "native" |
|
1026 |
+ if terminal == "alias" |
|
1027 |
+ else implementation |
|
1028 |
+ ) |
|
1029 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
1030 |
+ for link in chain: |
|
1031 |
+ monkeypatch.setitem(registry, link, target) |
|
1032 |
+ target = link |
|
1033 |
+ for link in chain: |
|
1034 |
+ assert lookup(link) == ( |
|
1035 |
+ implementation if terminal != "unimplemented" else None |
|
1036 |
+ ) |
|
1037 |
+ if terminal == "unimplemented": |
|
1038 |
+ with pytest.raises(NotImplementedError): |
|
1039 |
+ resolve(link) |
|
1040 |
+ else: |
|
1041 |
+ assert resolve(link) == implementation |
|
1042 |
+ |
|
1043 |
+ @Parametrize.GOOD_ENTRY_POINTS |
|
1044 |
+ def test_230_find_all_socket_providers( |
|
1045 |
+ self, |
|
1046 |
+ additional_entry_points: list[importlib.metadata.EntryPoint], |
|
1047 |
+ ) -> None: |
|
1048 |
+ """Finding all SSH agent socket providers works.""" |
|
1049 |
+ resolve = socketprovider.SocketProvider.resolve |
|
1050 |
+ old_registry = socketprovider.SocketProvider.registry |
|
1051 |
+ with pytest_machinery.faked_entry_point_list( |
|
1052 |
+ additional_entry_points, remove_conflicting_entries=False |
|
1053 |
+ ) as names: |
|
1054 |
+ socketprovider.SocketProvider._find_all_ssh_agent_socket_providers() |
|
1055 |
+ for name in names: |
|
1056 |
+ assert name in socketprovider.SocketProvider.registry |
|
1057 |
+ assert resolve(name) in { |
|
1058 |
+ callables.provider_entry_provider, |
|
1059 |
+ *old_registry.values(), |
|
1060 |
+ } |
|
1061 |
+ |
|
1062 |
+ @Parametrize.BAD_ENTRY_POINTS |
|
1063 |
+ def test_231_find_all_socket_providers_errors( |
|
1064 |
+ self, |
|
1065 |
+ additional_entry_points: list[importlib.metadata.EntryPoint], |
|
1066 |
+ ) -> None: |
|
1067 |
+ """Finding faulty SSH agent socket providers raises errors.""" |
|
1068 |
+ with contextlib.ExitStack() as stack: |
|
1069 |
+ stack.enter_context( |
|
1070 |
+ pytest_machinery.faked_entry_point_list( |
|
1071 |
+ additional_entry_points, remove_conflicting_entries=False |
|
1072 |
+ ) |
|
1073 |
+ ) |
|
1074 |
+ stack.enter_context(pytest.raises(AssertionError)) |
|
1075 |
+ socketprovider.SocketProvider._find_all_ssh_agent_socket_providers() |
|
1076 |
+ |
|
1077 |
+ @Parametrize.UINT32_EXCEPTIONS |
|
1078 |
+ def test_310_uint32_exceptions( |
|
1079 |
+ self, input: int, exc_type: type[Exception], exc_pattern: str |
|
1080 |
+ ) -> None: |
|
1081 |
+ """`uint32` encoding fails for out-of-bound values.""" |
|
1082 |
+ uint32 = ssh_agent.SSHAgentClient.uint32 |
|
1083 |
+ with pytest.raises(exc_type, match=exc_pattern): |
|
1084 |
+ uint32(input) |
|
1085 |
+ |
|
1086 |
+ @Parametrize.SSH_STRING_EXCEPTIONS |
|
1087 |
+ def test_311_string_exceptions( |
|
1088 |
+ self, input: Any, exc_type: type[Exception], exc_pattern: str |
|
1089 |
+ ) -> None: |
|
1090 |
+ """SSH string encoding fails for non-strings.""" |
|
1091 |
+ string = ssh_agent.SSHAgentClient.string |
|
1092 |
+ with pytest.raises(exc_type, match=exc_pattern): |
|
1093 |
+ string(input) |
|
1094 |
+ |
|
1095 |
+ @Parametrize.SSH_UNSTRING_EXCEPTIONS |
|
1096 |
+ def test_312_unstring_exceptions( |
|
1097 |
+ self, |
|
1098 |
+ input: bytes | bytearray, |
|
1099 |
+ exc_type: type[Exception], |
|
1100 |
+ exc_pattern: str, |
|
1101 |
+ has_trailer: bool, |
|
1102 |
+ parts: tuple[bytes | bytearray, bytes | bytearray] | None, |
|
1103 |
+ ) -> None: |
|
1104 |
+ """SSH string decoding fails for invalid values.""" |
|
1105 |
+ unstring = ssh_agent.SSHAgentClient.unstring |
|
1106 |
+ unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix |
|
1107 |
+ with pytest.raises(exc_type, match=exc_pattern): |
|
1108 |
+ unstring(input) |
|
1109 |
+ if has_trailer: |
|
1110 |
+ assert tuple(bytes(x) for x in unstring_prefix(input)) == parts |
|
1111 |
+ else: |
|
1112 |
+ with pytest.raises(exc_type, match=exc_pattern): |
|
1113 |
+ unstring_prefix(input) |
|
1114 |
+ |
|
1115 |
+ def test_320_registry_already_registered( |
|
1116 |
+ self, |
|
1117 |
+ ) -> None: |
|
1118 |
+ """The registry forbids overwriting entries.""" |
|
1119 |
+ registry = socketprovider.SocketProvider.registry.copy() |
|
1120 |
+ resolve = socketprovider.SocketProvider.resolve |
|
1121 |
+ register = socketprovider.SocketProvider.register |
|
1122 |
+ the_annoying_os = resolve("the_annoying_os") |
|
1123 |
+ posix = resolve("posix") |
|
1124 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
1125 |
+ monkeypatch.setattr( |
|
1126 |
+ socketprovider.SocketProvider, "registry", registry |
|
1127 |
+ ) |
|
1128 |
+ register("posix")(posix) |
|
1129 |
+ register("the_annoying_os")(the_annoying_os) |
|
1130 |
+ with pytest.raises(ValueError, match="already registered"): |
|
1131 |
+ register("posix")(the_annoying_os) |
|
1132 |
+ with pytest.raises(ValueError, match="already registered"): |
|
1133 |
+ register("the_annoying_os")(posix) |
|
1134 |
+ with pytest.raises(ValueError, match="already registered"): |
|
1135 |
+ register("posix", "the_annoying_os_named_pipe")(posix) |
|
1136 |
+ with pytest.raises(ValueError, match="already registered"): |
|
1137 |
+ register("the_annoying_os", "unix_domain")(the_annoying_os) |
|
1138 |
+ |
|
1139 |
+ def test_321_registry_resolve_non_existant_entries( |
|
1140 |
+ self, |
|
1141 |
+ ) -> None: |
|
1142 |
+ """Resolving a non-existant entry fails.""" |
|
1143 |
+ new_registry = { |
|
1144 |
+ "posix": socketprovider.SocketProvider.registry["posix"], |
|
1145 |
+ "the_annoying_os": socketprovider.SocketProvider.registry[ |
|
1146 |
+ "the_annoying_os" |
|
1147 |
+ ], |
|
1148 |
+ } |
|
1149 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
1150 |
+ monkeypatch.setattr( |
|
1151 |
+ socketprovider.SocketProvider, "registry", new_registry |
|
1152 |
+ ) |
|
1153 |
+ with pytest.raises(socketprovider.NoSuchProviderError): |
|
1154 |
+ socketprovider.SocketProvider.resolve("native") |
|
1155 |
+ |
|
1156 |
+ def test_322_registry_register_new_entry( |
|
1157 |
+ self, |
|
1158 |
+ ) -> None: |
|
1159 |
+ """Registering new entries works.""" |
|
1160 |
+ |
|
1161 |
+ def socket_provider() -> _types.SSHAgentSocket: |
|
1162 |
+ raise AssertionError |
|
1163 |
+ |
|
1164 |
+ names = ["spam", "ham", "eggs", "parrot"] |
|
1165 |
+ new_registry = { |
|
1166 |
+ "posix": socketprovider.SocketProvider.registry["posix"], |
|
1167 |
+ "the_annoying_os": socketprovider.SocketProvider.registry[ |
|
1168 |
+ "the_annoying_os" |
|
1169 |
+ ], |
|
1170 |
+ } |
|
1171 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
1172 |
+ monkeypatch.setattr( |
|
1173 |
+ socketprovider.SocketProvider, "registry", new_registry |
|
1174 |
+ ) |
|
1175 |
+ assert not any( |
|
1176 |
+ map(socketprovider.SocketProvider.registry.__contains__, names) |
|
1177 |
+ ) |
|
1178 |
+ assert ( |
|
1179 |
+ socketprovider.SocketProvider.register(*names)(socket_provider) |
|
1180 |
+ is socket_provider |
|
1181 |
+ ) |
|
1182 |
+ assert all( |
|
1183 |
+ map(socketprovider.SocketProvider.registry.__contains__, names) |
|
1184 |
+ ) |
|
1185 |
+ assert all([ |
|
1186 |
+ socketprovider.SocketProvider.resolve(n) is socket_provider |
|
1187 |
+ for n in names |
|
1188 |
+ ]) |
|
1189 |
+ |
|
1190 |
+ @Parametrize.EXISTING_REGISTRY_ENTRIES |
|
1191 |
+ def test_323_registry_register_old_entry( |
|
1192 |
+ self, |
|
1193 |
+ existing: str, |
|
1194 |
+ ) -> None: |
|
1195 |
+ """Registering old entries works.""" |
|
1196 |
+ |
|
1197 |
+ provider = socketprovider.SocketProvider.resolve(existing) |
|
1198 |
+ new_registry = { |
|
1199 |
+ "posix": socketprovider.SocketProvider.registry["posix"], |
|
1200 |
+ "the_annoying_os": socketprovider.SocketProvider.registry[ |
|
1201 |
+ "the_annoying_os" |
|
1202 |
+ ], |
|
1203 |
+ "unix_domain": "posix", |
|
1204 |
+ "the_annoying_os_named_pipe": "the_annoying_os", |
|
1205 |
+ } |
|
1206 |
+ names = [ |
|
1207 |
+ k |
|
1208 |
+ for k, v in socketprovider.SocketProvider.registry.items() |
|
1209 |
+ if v == existing |
|
1210 |
+ ] |
|
1211 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
1212 |
+ monkeypatch.setattr( |
|
1213 |
+ socketprovider.SocketProvider, "registry", new_registry |
|
1214 |
+ ) |
|
1215 |
+ assert not all( |
|
1216 |
+ map(socketprovider.SocketProvider.registry.__contains__, names) |
|
1217 |
+ ) |
|
1218 |
+ assert ( |
|
1219 |
+ socketprovider.SocketProvider.register(existing, *names)( |
|
1220 |
+ provider |
|
1221 |
+ ) |
|
1222 |
+ is provider |
|
1223 |
+ ) |
|
1224 |
+ assert all( |
|
1225 |
+ map(socketprovider.SocketProvider.registry.__contains__, names) |
|
1226 |
+ ) |
|
1227 |
+ assert all([ |
|
1228 |
+ socketprovider.SocketProvider.resolve(n) is provider |
|
1229 |
+ for n in [existing, *names] |
|
1230 |
+ ]) |
|
1231 |
+ |
|
1232 |
+ |
|
1233 |
+class TestAgentInteraction: |
|
1234 |
+ """Test actually talking to the SSH agent.""" |
|
1235 |
+ |
|
1236 |
+ @Parametrize.SUPPORTED_SSH_TEST_KEYS |
|
1237 |
+ def test_200_sign_data_via_agent( |
|
1238 |
+ self, |
|
1239 |
+ ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient, |
|
1240 |
+ ssh_test_key_type: str, |
|
1241 |
+ ssh_test_key: data.SSHTestKey, |
|
1242 |
+ ) -> None: |
|
1243 |
+ """Signing data with specific SSH keys works. |
|
1244 |
+ |
|
1245 |
+ Single tests may abort early (skip) if the indicated key is not |
|
1246 |
+ loaded in the agent. Presumably this means the key type is |
|
1247 |
+ unsupported. |
|
1248 |
+ |
|
1249 |
+ """ |
|
1250 |
+ client = ssh_agent_client_with_test_keys_loaded |
|
1251 |
+ key_comment_pairs = {bytes(k): bytes(c) for k, c in client.list_keys()} |
|
1252 |
+ public_key_data = ssh_test_key.public_key_data |
|
1253 |
+ assert ( |
|
1254 |
+ data.SSHTestKeyDeterministicSignatureClass.SPEC |
|
1255 |
+ in ssh_test_key.expected_signatures |
|
1256 |
+ ) |
|
1257 |
+ sig = ssh_test_key.expected_signatures[ |
|
1258 |
+ data.SSHTestKeyDeterministicSignatureClass.SPEC |
|
1259 |
+ ] |
|
1260 |
+ expected_signature = sig.signature |
|
1261 |
+ derived_passphrase = sig.derived_passphrase |
|
1262 |
+ if public_key_data not in key_comment_pairs: # pragma: no cover |
|
1263 |
+ pytest.skip(f"prerequisite {ssh_test_key_type} SSH key not loaded") |
|
1264 |
+ signature = bytes( |
|
1265 |
+ client.sign(payload=vault.Vault.UUID, key=public_key_data) |
|
1266 |
+ ) |
|
1267 |
+ assert signature == expected_signature, ( |
|
1268 |
+ f"SSH signature mismatch ({ssh_test_key_type})" |
|
1269 |
+ ) |
|
1270 |
+ signature2 = bytes( |
|
1271 |
+ client.sign(payload=vault.Vault.UUID, key=public_key_data) |
|
1272 |
+ ) |
|
1273 |
+ assert signature2 == expected_signature, ( |
|
1274 |
+ f"SSH signature mismatch ({ssh_test_key_type})" |
|
1275 |
+ ) |
|
1276 |
+ assert ( |
|
1277 |
+ vault.Vault.phrase_from_key(public_key_data, conn=client) |
|
1278 |
+ == derived_passphrase |
|
1279 |
+ ), f"SSH signature mismatch ({ssh_test_key_type})" |
|
1280 |
+ |
|
1281 |
+ @Parametrize.UNSUITABLE_SSH_TEST_KEYS |
|
1282 |
+ def test_201_sign_data_via_agent_unsupported( |
|
1283 |
+ self, |
|
1284 |
+ ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient, |
|
1285 |
+ ssh_test_key_type: str, |
|
1286 |
+ ssh_test_key: data.SSHTestKey, |
|
1287 |
+ ) -> None: |
|
1288 |
+ """Using an unsuitable key with [`vault.Vault`][] fails. |
|
1289 |
+ |
|
1290 |
+ Single tests may abort early (skip) if the indicated key is not |
|
1291 |
+ loaded in the agent. Presumably this means the key type is |
|
1292 |
+ unsupported. Single tests may also abort early if the agent |
|
1293 |
+ ensures that the generally unsuitable key is actually suitable |
|
1294 |
+ under this agent. |
|
1295 |
+ |
|
1296 |
+ """ |
|
1297 |
+ client = ssh_agent_client_with_test_keys_loaded |
|
1298 |
+ key_comment_pairs = {bytes(k): bytes(c) for k, c in client.list_keys()} |
|
1299 |
+ public_key_data = ssh_test_key.public_key_data |
|
1300 |
+ if public_key_data not in key_comment_pairs: # pragma: no cover |
|
1301 |
+ pytest.skip(f"prerequisite {ssh_test_key_type} SSH key not loaded") |
|
1302 |
+ assert not vault.Vault.is_suitable_ssh_key( |
|
1303 |
+ public_key_data, client=None |
|
1304 |
+ ), f"Expected {ssh_test_key_type} key to be unsuitable in general" |
|
1305 |
+ if vault.Vault.is_suitable_ssh_key(public_key_data, client=client): |
|
1306 |
+ pytest.skip( |
|
1307 |
+ f"agent automatically ensures {ssh_test_key_type} key is suitable" |
|
1308 |
+ ) |
|
1309 |
+ with pytest.raises(ValueError, match="unsuitable SSH key"): |
|
1310 |
+ vault.Vault.phrase_from_key(public_key_data, conn=client) |
|
1311 |
+ |
|
1312 |
+ @Parametrize.SSH_KEY_SELECTION |
|
1313 |
+ def test_210_ssh_key_selector( |
|
1314 |
+ self, |
|
1315 |
+ monkeypatch: pytest.MonkeyPatch, |
|
1316 |
+ ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient, |
|
1317 |
+ key: bytes, |
|
1318 |
+ single: bool, |
|
1319 |
+ ) -> None: |
|
1320 |
+ """The key selector presents exactly the suitable keys. |
|
1321 |
+ |
|
1322 |
+ "Suitable" here means suitability for this SSH agent |
|
1323 |
+ specifically. |
|
1324 |
+ |
|
1325 |
+ """ |
|
1326 |
+ client = ssh_agent_client_with_test_keys_loaded |
|
1327 |
+ |
|
1328 |
+ def key_is_suitable(key: bytes) -> bool: |
|
1329 |
+ """Stub out [`vault.Vault.key_is_suitable`][].""" |
|
1330 |
+ always = {v.public_key_data for v in data.SUPPORTED_KEYS.values()} |
|
1331 |
+ dsa = { |
|
1332 |
+ v.public_key_data |
|
1333 |
+ for k, v in data.UNSUITABLE_KEYS.items() |
|
1334 |
+ if k.startswith(("dsa", "ecdsa")) |
|
1335 |
+ } |
|
1336 |
+ return key in always or ( |
|
1337 |
+ client.has_deterministic_dsa_signatures() and key in dsa |
|
1338 |
+ ) |
|
1339 |
+ |
|
1340 |
+ # TODO(the-13th-letter): Handle the unlikely(?) case that only |
|
1341 |
+ # one test key is loaded, but `single` is False. Rename the |
|
1342 |
+ # `index` variable to `input`, store the `input` in there, and |
|
1343 |
+ # make the definition of `text` in the else block dependent on |
|
1344 |
+ # `n` being singular or non-singular. |
|
1345 |
+ if single: |
|
1346 |
+ monkeypatch.setattr( |
|
1347 |
+ ssh_agent.SSHAgentClient, |
|
1348 |
+ "list_keys", |
|
1349 |
+ callables.list_keys_singleton, |
|
1350 |
+ ) |
|
1351 |
+ keys = [ |
|
1352 |
+ pair.key |
|
1353 |
+ for pair in callables.list_keys_singleton() |
|
1354 |
+ if key_is_suitable(pair.key) |
|
1355 |
+ ] |
|
1356 |
+ index = "1" |
|
1357 |
+ text = "Use this key? yes\n" |
|
1358 |
+ else: |
|
1359 |
+ monkeypatch.setattr( |
|
1360 |
+ ssh_agent.SSHAgentClient, |
|
1361 |
+ "list_keys", |
|
1362 |
+ callables.list_keys, |
|
1363 |
+ ) |
|
1364 |
+ keys = [ |
|
1365 |
+ pair.key |
|
1366 |
+ for pair in callables.list_keys() |
|
1367 |
+ if key_is_suitable(pair.key) |
|
1368 |
+ ] |
|
1369 |
+ index = str(1 + keys.index(key)) |
|
1370 |
+ n = len(keys) |
|
1371 |
+ text = f"Your selection? (1-{n}, leave empty to abort): {index}\n" |
|
1372 |
+ b64_key = base64.standard_b64encode(key).decode("ASCII") |
|
1373 |
+ |
|
1374 |
+ @click.command() |
|
1375 |
+ def driver() -> None: |
|
1376 |
+ """Call [`cli_helpers.select_ssh_key`][] directly, as a command.""" |
|
1377 |
+ key = cli_helpers.select_ssh_key(client) |
|
1378 |
+ click.echo(base64.standard_b64encode(key).decode("ASCII")) |
|
1379 |
+ |
|
1380 |
+ # TODO(the-13th-letter): (Continued from above.) Update input |
|
1381 |
+ # data to use `index`/`input` directly and unconditionally. |
|
1382 |
+ runner = machinery.CliRunner(mix_stderr=True) |
|
1383 |
+ result = runner.invoke( |
|
1384 |
+ driver, |
|
1385 |
+ [], |
|
1386 |
+ input=("yes\n" if single else f"{index}\n"), |
|
1387 |
+ catch_exceptions=True, |
|
1388 |
+ ) |
|
1389 |
+ for snippet in ("Suitable SSH keys:\n", text, f"\n{b64_key}\n"): |
|
1390 |
+ assert result.clean_exit(output=snippet), "expected clean exit" |
|
1391 |
+ |
|
1392 |
+ def test_300_constructor_bad_running_agent( |
|
1393 |
+ self, |
|
1394 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
1395 |
+ ) -> None: |
|
1396 |
+ """Fail if the agent address is invalid.""" |
|
1397 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
1398 |
+ new_socket_name = ( |
|
1399 |
+ running_ssh_agent.socket + "~" |
|
1400 |
+ if isinstance(running_ssh_agent.socket, str) |
|
1401 |
+ else "<invalid//address>" |
|
1402 |
+ ) |
|
1403 |
+ monkeypatch.setenv("SSH_AUTH_SOCK", new_socket_name) |
|
1404 |
+ with pytest.raises(OSError): # noqa: PT011 |
|
1405 |
+ ssh_agent.SSHAgentClient() |
|
1406 |
+ |
|
1407 |
+ def test_301_constructor_no_af_unix_support(self) -> None: |
|
1408 |
+ """Fail without [`socket.AF_UNIX`][] support.""" |
|
1409 |
+ assert "posix" in socketprovider.SocketProvider.registry |
|
1410 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
1411 |
+ monkeypatch.setenv("SSH_AUTH_SOCK", "the value doesn't matter") |
|
1412 |
+ monkeypatch.delattr(socket, "AF_UNIX", raising=False) |
|
1413 |
+ with pytest.raises( |
|
1414 |
+ NotImplementedError, |
|
1415 |
+ match="UNIX domain sockets", |
|
1416 |
+ ): |
|
1417 |
+ ssh_agent.SSHAgentClient(socket="posix") |
|
1418 |
+ |
|
1419 |
+ def test_302_no_ssh_agent_socket_provider_available( |
|
1420 |
+ self, |
|
1421 |
+ ) -> None: |
|
1422 |
+ """Fail if no SSH agent socket provider is available.""" |
|
1423 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
1424 |
+ monkeypatch.setitem( |
|
1425 |
+ socketprovider.SocketProvider.registry, "stub_agent", None |
|
1426 |
+ ) |
|
1427 |
+ with pytest.raises(ExceptionGroup) as excinfo: |
|
1428 |
+ ssh_agent.SSHAgentClient( |
|
1429 |
+ socket=["stub_agent", "stub_agent", "stub_agent"] |
|
1430 |
+ ) |
|
1431 |
+ assert all([ |
|
1432 |
+ isinstance(e, NotImplementedError) |
|
1433 |
+ for e in excinfo.value.exceptions |
|
1434 |
+ ]) |
|
1435 |
+ |
|
1436 |
+ def test_303_explicit_socket( |
|
1437 |
+ self, |
|
1438 |
+ spawn_ssh_agent: data.SpawnedSSHAgentInfo, |
|
1439 |
+ ) -> None: |
|
1440 |
+ conn = spawn_ssh_agent.client._connection |
|
1441 |
+ ssh_agent.SSHAgentClient(socket=conn) |
|
1442 |
+ |
|
1443 |
+ @Parametrize.TRUNCATED_AGENT_RESPONSES |
|
1444 |
+ def test_310_truncated_server_response( |
|
1445 |
+ self, |
|
1446 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
1447 |
+ response: bytes, |
|
1448 |
+ ) -> None: |
|
1449 |
+ """Fail on truncated responses from the SSH agent.""" |
|
1450 |
+ del running_ssh_agent |
|
1451 |
+ client = ssh_agent.SSHAgentClient() |
|
1452 |
+ response_stream = io.BytesIO(response) |
|
1453 |
+ |
|
1454 |
+ class PseudoSocket: |
|
1455 |
+ def sendall(self, *args: Any, **kwargs: Any) -> Any: # noqa: ARG002 |
|
1456 |
+ return None |
|
1457 |
+ |
|
1458 |
+ def recv(self, *args: Any, **kwargs: Any) -> Any: |
|
1459 |
+ return response_stream.read(*args, **kwargs) |
|
1460 |
+ |
|
1461 |
+ pseudo_socket = PseudoSocket() |
|
1462 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
1463 |
+ monkeypatch.setattr(client, "_connection", pseudo_socket) |
|
1464 |
+ with pytest.raises(EOFError): |
|
1465 |
+ client.request(255, b"") |
|
1466 |
+ |
|
1467 |
+ @Parametrize.LIST_KEYS_ERROR_RESPONSES |
|
1468 |
+ def test_320_list_keys_error_responses( |
|
1469 |
+ self, |
|
1470 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
1471 |
+ response_code: _types.SSH_AGENT, |
|
1472 |
+ response: bytes | bytearray, |
|
1473 |
+ exc_type: type[Exception], |
|
1474 |
+ exc_pattern: str, |
|
1475 |
+ ) -> None: |
|
1476 |
+ """Fail on problems during key listing. |
|
1477 |
+ |
|
1478 |
+ Known problems: |
|
1479 |
+ |
|
1480 |
+ - The agent refuses, or otherwise indicates the operation |
|
1481 |
+ failed. |
|
1482 |
+ - The agent response is truncated. |
|
1483 |
+ - The agent response is overlong. |
|
1484 |
+ |
|
1485 |
+ """ |
|
1486 |
+ del running_ssh_agent |
|
1487 |
+ |
|
1488 |
+ passed_response_code = response_code |
|
1489 |
+ |
|
1490 |
+ # TODO(the-13th-letter): Extract this mock function into a common |
|
1491 |
+ # top-level "request" mock function. |
|
1492 |
+ def request( |
|
1493 |
+ request_code: int | _types.SSH_AGENTC, |
|
1494 |
+ payload: bytes | bytearray, |
|
1495 |
+ /, |
|
1496 |
+ *, |
|
1497 |
+ response_code: Iterable[int | _types.SSH_AGENT] |
|
1498 |
+ | int |
|
1499 |
+ | _types.SSH_AGENT |
|
1500 |
+ | None = None, |
|
1501 |
+ ) -> tuple[int, bytes | bytearray] | bytes | bytearray: |
|
1502 |
+ del request_code |
|
1503 |
+ del payload |
|
1504 |
+ if isinstance( # pragma: no branch |
|
1505 |
+ response_code, (int, _types.SSH_AGENT) |
|
1506 |
+ ): |
|
1507 |
+ response_code = frozenset({response_code}) |
|
1508 |
+ if response_code is not None: # pragma: no branch |
|
1509 |
+ response_code = frozenset({ |
|
1510 |
+ c if isinstance(c, int) else c.value for c in response_code |
|
1511 |
+ }) |
|
1512 |
+ |
|
1513 |
+ if not response_code: # pragma: no cover |
|
1514 |
+ return (passed_response_code.value, response) |
|
1515 |
+ if passed_response_code.value not in response_code: |
|
1516 |
+ raise ssh_agent.SSHAgentFailedError( |
|
1517 |
+ passed_response_code.value, response |
|
1518 |
+ ) |
|
1519 |
+ return response |
|
1520 |
+ |
|
1521 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
1522 |
+ client = ssh_agent.SSHAgentClient() |
|
1523 |
+ monkeypatch.setattr(client, "request", request) |
|
1524 |
+ with pytest.raises(exc_type, match=exc_pattern): |
|
1525 |
+ client.list_keys() |
|
1526 |
+ |
|
1527 |
+ @Parametrize.SIGN_ERROR_RESPONSES |
|
1528 |
+ def test_330_sign_error_responses( |
|
1529 |
+ self, |
|
1530 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
1531 |
+ key: bytes | bytearray, |
|
1532 |
+ check: bool, |
|
1533 |
+ response_code: _types.SSH_AGENT, |
|
1534 |
+ response: bytes | bytearray, |
|
1535 |
+ exc_type: type[Exception], |
|
1536 |
+ exc_pattern: str, |
|
1537 |
+ ) -> None: |
|
1538 |
+ """Fail on problems during signing. |
|
1539 |
+ |
|
1540 |
+ Known problems: |
|
1541 |
+ |
|
1542 |
+ - The key is not loaded into the agent. |
|
1543 |
+ - The agent refuses, or otherwise indicates the operation |
|
1544 |
+ failed. |
|
1545 |
+ |
|
1546 |
+ """ |
|
1547 |
+ del running_ssh_agent |
|
1548 |
+ passed_response_code = response_code |
|
1549 |
+ |
|
1550 |
+ # TODO(the-13th-letter): Extract this mock function into a common |
|
1551 |
+ # top-level "request" mock function. |
|
1552 |
+ def request( |
|
1553 |
+ request_code: int | _types.SSH_AGENTC, |
|
1554 |
+ payload: bytes | bytearray, |
|
1555 |
+ /, |
|
1556 |
+ *, |
|
1557 |
+ response_code: Iterable[int | _types.SSH_AGENT] |
|
1558 |
+ | int |
|
1559 |
+ | _types.SSH_AGENT |
|
1560 |
+ | None = None, |
|
1561 |
+ ) -> tuple[int, bytes | bytearray] | bytes | bytearray: |
|
1562 |
+ del request_code |
|
1563 |
+ del payload |
|
1564 |
+ if isinstance( # pragma: no branch |
|
1565 |
+ response_code, (int, _types.SSH_AGENT) |
|
1566 |
+ ): |
|
1567 |
+ response_code = frozenset({response_code}) |
|
1568 |
+ if response_code is not None: # pragma: no branch |
|
1569 |
+ response_code = frozenset({ |
|
1570 |
+ c if isinstance(c, int) else c.value for c in response_code |
|
1571 |
+ }) |
|
1572 |
+ |
|
1573 |
+ if not response_code: # pragma: no cover |
|
1574 |
+ return (passed_response_code.value, response) |
|
1575 |
+ if ( |
|
1576 |
+ passed_response_code.value not in response_code |
|
1577 |
+ ): # pragma: no branch |
|
1578 |
+ raise ssh_agent.SSHAgentFailedError( |
|
1579 |
+ passed_response_code.value, response |
|
1580 |
+ ) |
|
1581 |
+ return response # pragma: no cover |
|
1582 |
+ |
|
1583 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
1584 |
+ client = ssh_agent.SSHAgentClient() |
|
1585 |
+ monkeypatch.setattr(client, "request", request) |
|
1586 |
+ Pair = _types.SSHKeyCommentPair # noqa: N806 |
|
1587 |
+ com = b"no comment" |
|
1588 |
+ loaded_keys = [ |
|
1589 |
+ Pair(v.public_key_data, com).toreadonly() |
|
1590 |
+ for v in data.SUPPORTED_KEYS.values() |
|
1591 |
+ ] |
|
1592 |
+ monkeypatch.setattr(client, "list_keys", lambda: loaded_keys) |
|
1593 |
+ with pytest.raises(exc_type, match=exc_pattern): |
|
1594 |
+ client.sign(key, b"abc", check_if_key_loaded=check) |
|
1595 |
+ |
|
1596 |
+ @Parametrize.REQUEST_ERROR_RESPONSES |
|
1597 |
+ def test_340_request_error_responses( |
|
1598 |
+ self, |
|
1599 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
1600 |
+ request_code: _types.SSH_AGENTC, |
|
1601 |
+ response_code: _types.SSH_AGENT, |
|
1602 |
+ exc_type: type[Exception], |
|
1603 |
+ exc_pattern: str, |
|
1604 |
+ ) -> None: |
|
1605 |
+ """Fail on problems during signing. |
|
1606 |
+ |
|
1607 |
+ Known problems: |
|
1608 |
+ |
|
1609 |
+ - The key is not loaded into the agent. |
|
1610 |
+ - The agent refuses, or otherwise indicates the operation |
|
1611 |
+ failed. |
|
1612 |
+ |
|
1613 |
+ """ |
|
1614 |
+ del running_ssh_agent |
|
1615 |
+ |
|
1616 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1617 |
+ # with-statements. |
|
1618 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1619 |
+ with contextlib.ExitStack() as stack: |
|
1620 |
+ stack.enter_context(pytest.raises(exc_type, match=exc_pattern)) |
|
1621 |
+ client = stack.enter_context(ssh_agent.SSHAgentClient()) |
|
1622 |
+ client.request(request_code, b"", response_code=response_code) |
|
1623 |
+ |
|
1624 |
+ @Parametrize.QUERY_EXTENSIONS_MALFORMED_RESPONSES |
|
1625 |
+ def test_350_query_extensions_malformed_responses( |
|
1626 |
+ self, |
|
1627 |
+ monkeypatch: pytest.MonkeyPatch, |
|
1628 |
+ running_ssh_agent: data.RunningSSHAgentInfo, |
|
1629 |
+ response_data: bytes, |
|
1630 |
+ ) -> None: |
|
1631 |
+ """Fail on malformed responses while querying extensions.""" |
|
1632 |
+ del running_ssh_agent |
|
1633 |
+ |
|
1634 |
+ # TODO(the-13th-letter): Extract this mock function into a common |
|
1635 |
+ # top-level "request" mock function after removing the |
|
1636 |
+ # payload-specific parts. |
|
1637 |
+ def request( |
|
1638 |
+ code: int | _types.SSH_AGENTC, |
|
1639 |
+ payload: Buffer, |
|
1640 |
+ /, |
|
1641 |
+ *, |
|
1642 |
+ response_code: ( |
|
1643 |
+ Iterable[_types.SSH_AGENT | int] |
|
1644 |
+ | _types.SSH_AGENT |
|
1645 |
+ | int |
|
1646 |
+ | None |
|
1647 |
+ ) = None, |
|
1648 |
+ ) -> tuple[int, bytes] | bytes: |
|
1649 |
+ request_codes = { |
|
1650 |
+ _types.SSH_AGENTC.EXTENSION, |
|
1651 |
+ _types.SSH_AGENTC.EXTENSION.value, |
|
1652 |
+ } |
|
1653 |
+ assert code in request_codes |
|
1654 |
+ response_codes = { |
|
1655 |
+ _types.SSH_AGENT.EXTENSION_RESPONSE, |
|
1656 |
+ _types.SSH_AGENT.EXTENSION_RESPONSE.value, |
|
1657 |
+ _types.SSH_AGENT.SUCCESS, |
|
1658 |
+ _types.SSH_AGENT.SUCCESS.value, |
|
1659 |
+ } |
|
1660 |
+ assert payload == b"\x00\x00\x00\x05query" |
|
1661 |
+ if response_code is None: # pragma: no cover |
|
1662 |
+ return ( |
|
1663 |
+ _types.SSH_AGENT.EXTENSION_RESPONSE.value, |
|
1664 |
+ response_data, |
|
1665 |
+ ) |
|
1666 |
+ if isinstance( # pragma: no cover |
|
1667 |
+ response_code, (_types.SSH_AGENT, int) |
|
1668 |
+ ): |
|
1669 |
+ assert response_code in response_codes |
|
1670 |
+ return response_data |
|
1671 |
+ for single_code in response_code: # pragma: no cover |
|
1672 |
+ assert single_code in response_codes |
|
1673 |
+ return response_data # pragma: no cover |
|
1674 |
+ |
|
1675 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
1676 |
+ # with-statements. |
|
1677 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1678 |
+ with contextlib.ExitStack() as stack: |
|
1679 |
+ monkeypatch2 = stack.enter_context(monkeypatch.context()) |
|
1680 |
+ client = stack.enter_context(ssh_agent.SSHAgentClient()) |
|
1681 |
+ monkeypatch2.setattr(client, "request", request) |
|
1682 |
+ with pytest.raises( |
|
1683 |
+ RuntimeError, |
|
1684 |
+ match=r"Malformed response|does not match request", |
|
1685 |
+ ): |
|
1686 |
+ client.query_extensions() |
... | ... |
@@ -1,161 +1,3 @@ |
1 | 1 |
# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
2 | 2 |
# |
3 | 3 |
# SPDX-License-Identifier: Zlib |
4 |
- |
|
5 |
-from __future__ import annotations |
|
6 |
- |
|
7 |
-import copy |
|
8 |
-import types |
|
9 |
- |
|
10 |
-import hypothesis |
|
11 |
-import pytest |
|
12 |
-from hypothesis import strategies |
|
13 |
- |
|
14 |
-from derivepassphrase import _types |
|
15 |
-from tests import data |
|
16 |
-from tests.machinery import hypothesis as hypothesis_machinery |
|
17 |
- |
|
18 |
- |
|
19 |
-class Parametrize(types.SimpleNamespace): |
|
20 |
- VALID_VAULT_TEST_CONFIGS = pytest.mark.parametrize( |
|
21 |
- "test_config", |
|
22 |
- [ |
|
23 |
- conf |
|
24 |
- for conf in data.TEST_CONFIGS |
|
25 |
- if conf.validation_settings in {None, (True,)} |
|
26 |
- ], |
|
27 |
- ids=data.VaultTestConfig._test_id, |
|
28 |
- ) |
|
29 |
- VAULT_TEST_CONFIGS = pytest.mark.parametrize( |
|
30 |
- "test_config", |
|
31 |
- data.TEST_CONFIGS, |
|
32 |
- ids=data.VaultTestConfig._test_id, |
|
33 |
- ) |
|
34 |
- |
|
35 |
- |
|
36 |
-@Parametrize.VALID_VAULT_TEST_CONFIGS |
|
37 |
-def test_200_is_vault_config(test_config: data.VaultTestConfig) -> None: |
|
38 |
- """Is this vault configuration recognized as valid/invalid? |
|
39 |
- |
|
40 |
- Check all test configurations that do not need custom validation |
|
41 |
- settings. |
|
42 |
- |
|
43 |
- This primarily tests the [`_types.is_vault_config`][] and |
|
44 |
- [`_types.clean_up_falsy_vault_config_values`][] functions. |
|
45 |
- |
|
46 |
- """ |
|
47 |
- obj, comment, _ = test_config |
|
48 |
- obj = copy.deepcopy(obj) |
|
49 |
- _types.clean_up_falsy_vault_config_values(obj) |
|
50 |
- assert _types.is_vault_config(obj) == (not comment), ( |
|
51 |
- "failed to complain about: " + comment |
|
52 |
- if comment |
|
53 |
- else "failed on valid example" |
|
54 |
- ) |
|
55 |
- |
|
56 |
- |
|
57 |
-@hypothesis.given( |
|
58 |
- test_config=hypothesis_machinery.smudged_vault_test_config( |
|
59 |
- config=strategies.sampled_from([ |
|
60 |
- conf for conf in data.TEST_CONFIGS if conf.is_valid() |
|
61 |
- ]) |
|
62 |
- ) |
|
63 |
-) |
|
64 |
-def test_200a_is_vault_config_smudged( |
|
65 |
- test_config: data.VaultTestConfig, |
|
66 |
-) -> None: |
|
67 |
- """Is this vault configuration recognized as valid/invalid? |
|
68 |
- |
|
69 |
- Generate test data via hypothesis by smudging all valid test |
|
70 |
- configurations. |
|
71 |
- |
|
72 |
- This primarily tests the [`_types.is_vault_config`][] and |
|
73 |
- [`_types.clean_up_falsy_vault_config_values`][] functions. |
|
74 |
- |
|
75 |
- """ |
|
76 |
- obj_, comment, _ = test_config |
|
77 |
- obj = copy.deepcopy(obj_) |
|
78 |
- did_cleanup = _types.clean_up_falsy_vault_config_values(obj) |
|
79 |
- assert _types.is_vault_config(obj) == (not comment), ( |
|
80 |
- "failed to complain about: " + comment |
|
81 |
- if comment |
|
82 |
- else "failed on valid example" |
|
83 |
- ) |
|
84 |
- assert did_cleanup is None or bool(did_cleanup) == (obj != obj_), ( |
|
85 |
- "mismatched report on cleanup work" |
|
86 |
- ) |
|
87 |
- |
|
88 |
- |
|
89 |
-@Parametrize.VAULT_TEST_CONFIGS |
|
90 |
-def test_400_validate_vault_config( |
|
91 |
- test_config: data.VaultTestConfig, |
|
92 |
-) -> None: |
|
93 |
- """Validate this vault configuration. |
|
94 |
- |
|
95 |
- Check all test configurations, including those with non-standard |
|
96 |
- validation settings. |
|
97 |
- |
|
98 |
- This primarily tests the [`_types.validate_vault_config`][] and |
|
99 |
- [`_types.clean_up_falsy_vault_config_values`][] functions. |
|
100 |
- |
|
101 |
- """ |
|
102 |
- obj, comment, validation_settings = test_config |
|
103 |
- (allow_unknown_settings,) = validation_settings or (True,) |
|
104 |
- obj = copy.deepcopy(obj) |
|
105 |
- _types.clean_up_falsy_vault_config_values(obj) |
|
106 |
- if comment: |
|
107 |
- with pytest.raises((TypeError, ValueError)): |
|
108 |
- _types.validate_vault_config( |
|
109 |
- obj, |
|
110 |
- allow_unknown_settings=allow_unknown_settings, |
|
111 |
- ) |
|
112 |
- else: |
|
113 |
- try: |
|
114 |
- _types.validate_vault_config( |
|
115 |
- obj, |
|
116 |
- allow_unknown_settings=allow_unknown_settings, |
|
117 |
- ) |
|
118 |
- except (TypeError, ValueError) as exc: # pragma: no cover |
|
119 |
- assert not exc, "failed to validate valid example" # noqa: PT017 |
|
120 |
- |
|
121 |
- |
|
122 |
-@hypothesis.given( |
|
123 |
- test_config=hypothesis_machinery.smudged_vault_test_config( |
|
124 |
- config=strategies.sampled_from([ |
|
125 |
- conf for conf in data.TEST_CONFIGS if conf.is_smudgable() |
|
126 |
- ]) |
|
127 |
- ) |
|
128 |
-) |
|
129 |
-def test_400a_validate_vault_config_smudged( |
|
130 |
- test_config: data.VaultTestConfig, |
|
131 |
-) -> None: |
|
132 |
- """Validate this vault configuration. |
|
133 |
- |
|
134 |
- Generate test data via hypothesis by smudging all smudgable test |
|
135 |
- configurations. |
|
136 |
- |
|
137 |
- This primarily tests the [`_types.validate_vault_config`][] and |
|
138 |
- [`_types.clean_up_falsy_vault_config_values`][] functions. |
|
139 |
- |
|
140 |
- """ |
|
141 |
- obj_, comment, validation_settings = test_config |
|
142 |
- (allow_unknown_settings,) = validation_settings or (True,) |
|
143 |
- obj = copy.deepcopy(obj_) |
|
144 |
- did_cleanup = _types.clean_up_falsy_vault_config_values(obj) |
|
145 |
- if comment: |
|
146 |
- with pytest.raises((TypeError, ValueError)): |
|
147 |
- _types.validate_vault_config( |
|
148 |
- obj, |
|
149 |
- allow_unknown_settings=allow_unknown_settings, |
|
150 |
- ) |
|
151 |
- else: |
|
152 |
- try: |
|
153 |
- _types.validate_vault_config( |
|
154 |
- obj, |
|
155 |
- allow_unknown_settings=allow_unknown_settings, |
|
156 |
- ) |
|
157 |
- except (TypeError, ValueError) as exc: # pragma: no cover |
|
158 |
- assert not exc, "failed to validate valid example" # noqa: PT017 |
|
159 |
- assert did_cleanup is None or bool(did_cleanup) == (obj != obj_), ( |
|
160 |
- "mismatched report on cleanup work" |
|
161 |
- ) |
... | ... |
@@ -0,0 +1,161 @@ |
1 |
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
|
2 |
+# |
|
3 |
+# SPDX-License-Identifier: Zlib |
|
4 |
+ |
|
5 |
+from __future__ import annotations |
|
6 |
+ |
|
7 |
+import copy |
|
8 |
+import types |
|
9 |
+ |
|
10 |
+import hypothesis |
|
11 |
+import pytest |
|
12 |
+from hypothesis import strategies |
|
13 |
+ |
|
14 |
+from derivepassphrase import _types |
|
15 |
+from tests import data |
|
16 |
+from tests.machinery import hypothesis as hypothesis_machinery |
|
17 |
+ |
|
18 |
+ |
|
19 |
+class Parametrize(types.SimpleNamespace): |
|
20 |
+ VALID_VAULT_TEST_CONFIGS = pytest.mark.parametrize( |
|
21 |
+ "test_config", |
|
22 |
+ [ |
|
23 |
+ conf |
|
24 |
+ for conf in data.TEST_CONFIGS |
|
25 |
+ if conf.validation_settings in {None, (True,)} |
|
26 |
+ ], |
|
27 |
+ ids=data.VaultTestConfig._test_id, |
|
28 |
+ ) |
|
29 |
+ VAULT_TEST_CONFIGS = pytest.mark.parametrize( |
|
30 |
+ "test_config", |
|
31 |
+ data.TEST_CONFIGS, |
|
32 |
+ ids=data.VaultTestConfig._test_id, |
|
33 |
+ ) |
|
34 |
+ |
|
35 |
+ |
|
36 |
+@Parametrize.VALID_VAULT_TEST_CONFIGS |
|
37 |
+def test_200_is_vault_config(test_config: data.VaultTestConfig) -> None: |
|
38 |
+ """Is this vault configuration recognized as valid/invalid? |
|
39 |
+ |
|
40 |
+ Check all test configurations that do not need custom validation |
|
41 |
+ settings. |
|
42 |
+ |
|
43 |
+ This primarily tests the [`_types.is_vault_config`][] and |
|
44 |
+ [`_types.clean_up_falsy_vault_config_values`][] functions. |
|
45 |
+ |
|
46 |
+ """ |
|
47 |
+ obj, comment, _ = test_config |
|
48 |
+ obj = copy.deepcopy(obj) |
|
49 |
+ _types.clean_up_falsy_vault_config_values(obj) |
|
50 |
+ assert _types.is_vault_config(obj) == (not comment), ( |
|
51 |
+ "failed to complain about: " + comment |
|
52 |
+ if comment |
|
53 |
+ else "failed on valid example" |
|
54 |
+ ) |
|
55 |
+ |
|
56 |
+ |
|
57 |
+@hypothesis.given( |
|
58 |
+ test_config=hypothesis_machinery.smudged_vault_test_config( |
|
59 |
+ config=strategies.sampled_from([ |
|
60 |
+ conf for conf in data.TEST_CONFIGS if conf.is_valid() |
|
61 |
+ ]) |
|
62 |
+ ) |
|
63 |
+) |
|
64 |
+def test_200a_is_vault_config_smudged( |
|
65 |
+ test_config: data.VaultTestConfig, |
|
66 |
+) -> None: |
|
67 |
+ """Is this vault configuration recognized as valid/invalid? |
|
68 |
+ |
|
69 |
+ Generate test data via hypothesis by smudging all valid test |
|
70 |
+ configurations. |
|
71 |
+ |
|
72 |
+ This primarily tests the [`_types.is_vault_config`][] and |
|
73 |
+ [`_types.clean_up_falsy_vault_config_values`][] functions. |
|
74 |
+ |
|
75 |
+ """ |
|
76 |
+ obj_, comment, _ = test_config |
|
77 |
+ obj = copy.deepcopy(obj_) |
|
78 |
+ did_cleanup = _types.clean_up_falsy_vault_config_values(obj) |
|
79 |
+ assert _types.is_vault_config(obj) == (not comment), ( |
|
80 |
+ "failed to complain about: " + comment |
|
81 |
+ if comment |
|
82 |
+ else "failed on valid example" |
|
83 |
+ ) |
|
84 |
+ assert did_cleanup is None or bool(did_cleanup) == (obj != obj_), ( |
|
85 |
+ "mismatched report on cleanup work" |
|
86 |
+ ) |
|
87 |
+ |
|
88 |
+ |
|
89 |
+@Parametrize.VAULT_TEST_CONFIGS |
|
90 |
+def test_400_validate_vault_config( |
|
91 |
+ test_config: data.VaultTestConfig, |
|
92 |
+) -> None: |
|
93 |
+ """Validate this vault configuration. |
|
94 |
+ |
|
95 |
+ Check all test configurations, including those with non-standard |
|
96 |
+ validation settings. |
|
97 |
+ |
|
98 |
+ This primarily tests the [`_types.validate_vault_config`][] and |
|
99 |
+ [`_types.clean_up_falsy_vault_config_values`][] functions. |
|
100 |
+ |
|
101 |
+ """ |
|
102 |
+ obj, comment, validation_settings = test_config |
|
103 |
+ (allow_unknown_settings,) = validation_settings or (True,) |
|
104 |
+ obj = copy.deepcopy(obj) |
|
105 |
+ _types.clean_up_falsy_vault_config_values(obj) |
|
106 |
+ if comment: |
|
107 |
+ with pytest.raises((TypeError, ValueError)): |
|
108 |
+ _types.validate_vault_config( |
|
109 |
+ obj, |
|
110 |
+ allow_unknown_settings=allow_unknown_settings, |
|
111 |
+ ) |
|
112 |
+ else: |
|
113 |
+ try: |
|
114 |
+ _types.validate_vault_config( |
|
115 |
+ obj, |
|
116 |
+ allow_unknown_settings=allow_unknown_settings, |
|
117 |
+ ) |
|
118 |
+ except (TypeError, ValueError) as exc: # pragma: no cover |
|
119 |
+ assert not exc, "failed to validate valid example" # noqa: PT017 |
|
120 |
+ |
|
121 |
+ |
|
122 |
+@hypothesis.given( |
|
123 |
+ test_config=hypothesis_machinery.smudged_vault_test_config( |
|
124 |
+ config=strategies.sampled_from([ |
|
125 |
+ conf for conf in data.TEST_CONFIGS if conf.is_smudgable() |
|
126 |
+ ]) |
|
127 |
+ ) |
|
128 |
+) |
|
129 |
+def test_400a_validate_vault_config_smudged( |
|
130 |
+ test_config: data.VaultTestConfig, |
|
131 |
+) -> None: |
|
132 |
+ """Validate this vault configuration. |
|
133 |
+ |
|
134 |
+ Generate test data via hypothesis by smudging all smudgable test |
|
135 |
+ configurations. |
|
136 |
+ |
|
137 |
+ This primarily tests the [`_types.validate_vault_config`][] and |
|
138 |
+ [`_types.clean_up_falsy_vault_config_values`][] functions. |
|
139 |
+ |
|
140 |
+ """ |
|
141 |
+ obj_, comment, validation_settings = test_config |
|
142 |
+ (allow_unknown_settings,) = validation_settings or (True,) |
|
143 |
+ obj = copy.deepcopy(obj_) |
|
144 |
+ did_cleanup = _types.clean_up_falsy_vault_config_values(obj) |
|
145 |
+ if comment: |
|
146 |
+ with pytest.raises((TypeError, ValueError)): |
|
147 |
+ _types.validate_vault_config( |
|
148 |
+ obj, |
|
149 |
+ allow_unknown_settings=allow_unknown_settings, |
|
150 |
+ ) |
|
151 |
+ else: |
|
152 |
+ try: |
|
153 |
+ _types.validate_vault_config( |
|
154 |
+ obj, |
|
155 |
+ allow_unknown_settings=allow_unknown_settings, |
|
156 |
+ ) |
|
157 |
+ except (TypeError, ValueError) as exc: # pragma: no cover |
|
158 |
+ assert not exc, "failed to validate valid example" # noqa: PT017 |
|
159 |
+ assert did_cleanup is None or bool(did_cleanup) == (obj != obj_), ( |
|
160 |
+ "mismatched report on cleanup work" |
|
161 |
+ ) |
|
0 | 162 |