Coverage for tests\test_derivepassphrase_cli.py: 98.941%

1851 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-23 12:17 +0200

1# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> 

2# 

3# SPDX-License-Identifier: Zlib 

4 

5from __future__ import annotations 

6 

7import base64 

8import contextlib 

9import copy 

10import ctypes 

11import enum 

12import errno 

13import io 

14import json 

15import logging 

16import operator 

17import os 

18import pathlib 

19import queue 

20import re 

21import shlex 

22import shutil 

23import socket 

24import tempfile 

25import textwrap 

26import types 

27import warnings 

28from typing import TYPE_CHECKING, cast 

29 

30import click.testing 

31import hypothesis 

32import pytest 

33from hypothesis import stateful, strategies 

34from typing_extensions import Any, NamedTuple, TypeAlias 

35 

36import tests 

37from derivepassphrase import _types, cli, ssh_agent, vault 

38from derivepassphrase._internals import ( 

39 cli_helpers, 

40 cli_machinery, 

41 cli_messages, 

42) 

43from derivepassphrase.ssh_agent import socketprovider 

44 

45if TYPE_CHECKING: 

46 import multiprocessing 

47 from collections.abc import Callable, Iterable, Iterator, Sequence 

48 from collections.abc import Set as AbstractSet 

49 from typing import NoReturn 

50 

51 from typing_extensions import Literal 

52 

53DUMMY_SERVICE = tests.DUMMY_SERVICE 

54DUMMY_PASSPHRASE = tests.DUMMY_PASSPHRASE 

55DUMMY_CONFIG_SETTINGS = tests.DUMMY_CONFIG_SETTINGS 

56DUMMY_RESULT_PASSPHRASE = tests.DUMMY_RESULT_PASSPHRASE 

57DUMMY_RESULT_KEY1 = tests.DUMMY_RESULT_KEY1 

58DUMMY_PHRASE_FROM_KEY1_RAW = tests.DUMMY_PHRASE_FROM_KEY1_RAW 

59DUMMY_PHRASE_FROM_KEY1 = tests.DUMMY_PHRASE_FROM_KEY1 

60 

61DUMMY_KEY1 = tests.DUMMY_KEY1 

62DUMMY_KEY1_B64 = tests.DUMMY_KEY1_B64 

63DUMMY_KEY2 = tests.DUMMY_KEY2 

64DUMMY_KEY2_B64 = tests.DUMMY_KEY2_B64 

65DUMMY_KEY3 = tests.DUMMY_KEY3 

66DUMMY_KEY3_B64 = tests.DUMMY_KEY3_B64 

67 

68TEST_CONFIGS = tests.TEST_CONFIGS 

69 

70 

71class IncompatibleConfiguration(NamedTuple): 

72 other_options: list[tuple[str, ...]] 

73 needs_service: bool | None 

74 input: str | None 

75 

76 

77class SingleConfiguration(NamedTuple): 

78 needs_service: bool | None 

79 input: str | None 

80 check_success: bool 

81 

82 

83class OptionCombination(NamedTuple): 

84 options: list[str] 

85 incompatible: bool 

86 needs_service: bool | None 

87 input: str | None 

88 check_success: bool 

89 

90 

91class VersionOutputData(NamedTuple): 

92 derivation_schemes: dict[str, bool] 

93 foreign_configuration_formats: dict[str, bool] 

94 extras: frozenset[str] 

95 subcommands: frozenset[str] 

96 features: dict[str, bool] 

97 

98 

99class KnownLineType(str, enum.Enum): 

100 SUPPORTED_FOREIGN_CONFS = cli_messages.Label.SUPPORTED_FOREIGN_CONFIGURATION_FORMATS.value.singular.rstrip( 

101 ':' 

102 ) 

103 UNAVAILABLE_FOREIGN_CONFS = cli_messages.Label.UNAVAILABLE_FOREIGN_CONFIGURATION_FORMATS.value.singular.rstrip( 

104 ':' 

105 ) 

106 SUPPORTED_SCHEMES = ( 

107 cli_messages.Label.SUPPORTED_DERIVATION_SCHEMES.value.singular.rstrip( 

108 ':' 

109 ) 

110 ) 

111 UNAVAILABLE_SCHEMES = cli_messages.Label.UNAVAILABLE_DERIVATION_SCHEMES.value.singular.rstrip( 

112 ':' 

113 ) 

114 SUPPORTED_SUBCOMMANDS = ( 

115 cli_messages.Label.SUPPORTED_SUBCOMMANDS.value.singular.rstrip(':') 

116 ) 

117 SUPPORTED_FEATURES = ( 

118 cli_messages.Label.SUPPORTED_FEATURES.value.singular.rstrip(':') 

119 ) 

120 UNAVAILABLE_FEATURES = ( 

121 cli_messages.Label.UNAVAILABLE_FEATURES.value.singular.rstrip(':') 

122 ) 

123 ENABLED_EXTRAS = ( 

124 cli_messages.Label.ENABLED_PEP508_EXTRAS.value.singular.rstrip(':') 

125 ) 

126 

127 

128PASSPHRASE_GENERATION_OPTIONS: list[tuple[str, ...]] = [ 

129 ('--phrase',), 

130 ('--key',), 

131 ('--length', '20'), 

132 ('--repeat', '20'), 

133 ('--lower', '1'), 

134 ('--upper', '1'), 

135 ('--number', '1'), 

136 ('--space', '1'), 

137 ('--dash', '1'), 

138 ('--symbol', '1'), 

139] 

140CONFIGURATION_OPTIONS: list[tuple[str, ...]] = [ 

141 ('--notes',), 

142 ('--config',), 

143 ('--delete',), 

144 ('--delete-globals',), 

145 ('--clear',), 

146] 

147CONFIGURATION_COMMANDS: list[tuple[str, ...]] = [ 

148 ('--delete',), 

149 ('--delete-globals',), 

150 ('--clear',), 

151] 

152STORAGE_OPTIONS: list[tuple[str, ...]] = [('--export', '-'), ('--import', '-')] 

153INCOMPATIBLE: dict[tuple[str, ...], IncompatibleConfiguration] = { 

154 ('--phrase',): IncompatibleConfiguration( 

155 [('--key',), *CONFIGURATION_COMMANDS, *STORAGE_OPTIONS], 

156 True, 

157 DUMMY_PASSPHRASE, 

158 ), 

159 ('--key',): IncompatibleConfiguration( 

160 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

161 ), 

162 ('--length', '20'): IncompatibleConfiguration( 

163 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

164 ), 

165 ('--repeat', '20'): IncompatibleConfiguration( 

166 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

167 ), 

168 ('--lower', '1'): IncompatibleConfiguration( 

169 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

170 ), 

171 ('--upper', '1'): IncompatibleConfiguration( 

172 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

173 ), 

174 ('--number', '1'): IncompatibleConfiguration( 

175 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

176 ), 

177 ('--space', '1'): IncompatibleConfiguration( 

178 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

179 ), 

180 ('--dash', '1'): IncompatibleConfiguration( 

181 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

182 ), 

183 ('--symbol', '1'): IncompatibleConfiguration( 

184 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

185 ), 

186 ('--notes',): IncompatibleConfiguration( 

187 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, None 

188 ), 

189 ('--config', '-p'): IncompatibleConfiguration( 

190 [('--delete',), ('--delete-globals',), ('--clear',), *STORAGE_OPTIONS], 

191 None, 

192 DUMMY_PASSPHRASE, 

193 ), 

194 ('--delete',): IncompatibleConfiguration( 

195 [('--delete-globals',), ('--clear',), *STORAGE_OPTIONS], True, None 

196 ), 

197 ('--delete-globals',): IncompatibleConfiguration( 

198 [('--clear',), *STORAGE_OPTIONS], False, None 

199 ), 

200 ('--clear',): IncompatibleConfiguration(STORAGE_OPTIONS, False, None), 

201 ('--export', '-'): IncompatibleConfiguration( 

202 [('--import', '-')], False, None 

203 ), 

204 ('--import', '-'): IncompatibleConfiguration([], False, None), 

205} 

206SINGLES: dict[tuple[str, ...], SingleConfiguration] = { 

207 ('--phrase',): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

208 ('--key',): SingleConfiguration(True, None, False), 

209 ('--length', '20'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

210 ('--repeat', '20'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

211 ('--lower', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

212 ('--upper', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

213 ('--number', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

214 ('--space', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

215 ('--dash', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

216 ('--symbol', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

217 ('--notes',): SingleConfiguration(True, None, False), 

218 ('--config', '-p'): SingleConfiguration(None, DUMMY_PASSPHRASE, False), 

219 ('--delete',): SingleConfiguration(True, None, False), 

220 ('--delete-globals',): SingleConfiguration(False, None, True), 

221 ('--clear',): SingleConfiguration(False, None, True), 

222 ('--export', '-'): SingleConfiguration(False, None, True), 

223 ('--import', '-'): SingleConfiguration(False, '{"services": {}}', True), 

224} 

225INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = [] 

226config: IncompatibleConfiguration | SingleConfiguration 

227for opt, config in INCOMPATIBLE.items(): 

228 for opt2 in config.other_options: 

229 INTERESTING_OPTION_COMBINATIONS.extend([ 

230 OptionCombination( 

231 options=list(opt + opt2), 

232 incompatible=True, 

233 needs_service=config.needs_service, 

234 input=config.input, 

235 check_success=False, 

236 ), 

237 OptionCombination( 

238 options=list(opt2 + opt), 

239 incompatible=True, 

240 needs_service=config.needs_service, 

241 input=config.input, 

242 check_success=False, 

243 ), 

244 ]) 

245for opt, config in SINGLES.items(): 

246 INTERESTING_OPTION_COMBINATIONS.append( 

247 OptionCombination( 

248 options=list(opt), 

249 incompatible=False, 

250 needs_service=config.needs_service, 

251 input=config.input, 

252 check_success=config.check_success, 

253 ) 

254 ) 

255 

256 

257def is_warning_line(line: str) -> bool: 

258 """Return true if the line is a warning line.""" 

259 return ' Warning: ' in line or ' Deprecation warning: ' in line 1vrso

260 

261 

262def is_harmless_config_import_warning(record: tuple[str, int, str]) -> bool: 

263 """Return true if the warning is harmless, during config import.""" 

264 possible_warnings = [ 1vtCw

265 'Replacing invalid value ', 

266 'Removing ineffective setting ', 

267 ( 

268 'Setting a global passphrase is ineffective ' 

269 'because a key is also set.' 

270 ), 

271 ( 

272 'Setting a service passphrase is ineffective ' 

273 'because a key is also set:' 

274 ), 

275 ] 

276 return any(tests.warning_emitted(w, [record]) for w in possible_warnings) 1vtCw

277 

278 

279def assert_vault_config_is_indented_and_line_broken( 

280 config_txt: str, 

281 /, 

282) -> None: 

283 """Return true if the vault configuration is indented and line broken. 

284 

285 Indented and rewrapped vault configurations as produced by 

286 `json.dump` contain the closing '}' of the '$.services' object 

287 on a separate, indented line: 

288 

289 ~~~~ 

290 { 

291 "services": { 

292 ... 

293 } <-- this brace here 

294 } 

295 ~~~~ 

296 

297 or, if there are no services, then the indented line 

298 

299 ~~~~ 

300 "services": {} 

301 ~~~~ 

302 

303 Both variations may end with a comma if there are more top-level 

304 keys. 

305 

306 """ 

307 known_indented_lines = { 1CDFw

308 '}', 

309 '},', 

310 '"services": {}', 

311 '"services": {},', 

312 } 

313 assert any([ 1CDFw

314 line.strip() in known_indented_lines and line.startswith((' ', '\t')) 

315 for line in config_txt.splitlines() 

316 ]) 

317 

318 

319def vault_config_exporter_shell_interpreter( # noqa: C901 

320 script: str | Iterable[str], 

321 /, 

322 *, 

323 prog_name_list: list[str] | None = None, 

324 command: click.BaseCommand | None = None, 

325 runner: tests.CliRunner | None = None, 

326) -> Iterator[tests.ReadableResult]: 

327 """A rudimentary sh(1) interpreter for `--export-as=sh` output. 

328 

329 Assumes a script as emitted by `derivepassphrase vault 

330 --export-as=sh --export -` and interprets the calls to 

331 `derivepassphrase vault` within. (One call per line, skips all 

332 other lines.) Also has rudimentary support for (quoted) 

333 here-documents using `HERE` as the marker. 

334 

335 """ 

336 if isinstance(script, str): # pragma: no cover 1jikl

337 script = script.splitlines(False) 1jikl

338 if prog_name_list is None: # pragma: no cover 1jikl

339 prog_name_list = ['derivepassphrase', 'vault'] 1jikl

340 if command is None: # pragma: no cover 1jikl

341 command = cli.derivepassphrase_vault 1jikl

342 if runner is None: # pragma: no cover 1jikl

343 runner = tests.CliRunner(mix_stderr=False) 1jikl

344 n = len(prog_name_list) 1jikl

345 it = iter(script) 1jikl

346 while True: 1jikl

347 try: 1jikl

348 raw_line = next(it) 1jikl

349 except StopIteration: 1jikl

350 break 1jikl

351 else: 

352 line = shlex.split(raw_line) 1jikl

353 input_buffer: list[str] = [] 1jikl

354 if line[:n] != prog_name_list: 1jikl

355 continue 1jikl

356 line[:n] = [] 1jikl

357 if line and line[-1] == '<<HERE': 1jikl

358 # naive HERE document support 

359 while True: 1jikl

360 try: 1jikl

361 raw_line = next(it) 1jikl

362 except StopIteration as exc: # pragma: no cover 

363 msg = 'incomplete here document' 

364 raise EOFError(msg) from exc 

365 else: 

366 if raw_line == 'HERE': 1jikl

367 break 1jikl

368 input_buffer.append(raw_line) 1jikl

369 line.pop() 1jikl

370 yield runner.invoke( 1jikl

371 command, 

372 line, 

373 catch_exceptions=False, 

374 input=(''.join(x + '\n' for x in input_buffer) or None), 

375 ) 

376 

377 

378def parse_version_output( # noqa: C901 

379 version_output: str, 

380 /, 

381 *, 

382 prog_name: str | None = cli_messages.PROG_NAME, 

383 version: str | None = cli_messages.VERSION, 

384) -> VersionOutputData: 

385 r"""Parse the output of the `--version` option. 

386 

387 The version output contains two paragraphs. The first paragraph 

388 details the version number, and the version number of any major 

389 libraries in use. The second paragraph details known and supported 

390 passphrase derivation schemes, foreign configuration formats, 

391 subcommands and PEP 508 package extras. For the schemes and 

392 formats, there is a "supported" line for supported items, and 

393 a "known" line for known but currently unsupported items (usually 

394 because of missing dependencies), either of which may be empty and 

395 thus omitted. For extras, only active items are shown, and there is 

396 a separate message for the "no extras active" case. Item lists may 

397 be spilled across multiple lines, but only at item boundaries, and 

398 the continuation lines are then indented. 

399 

400 Args: 

401 version_output: 

402 The version output text to parse. 

403 prog_name: 

404 The program name to assert, defaulting to the true program 

405 name, `derivepassphrase`. Set to `None` to disable this 

406 check. 

407 version: 

408 The program version to assert, defaulting to the true 

409 current version of `derivepassphrase`. Set to `None` to 

410 disable this check. 

411 

412 Examples: 

413 See [`Parametrize.VERSION_OUTPUT_DATA`][]. 

414 

415 """ 

416 paragraphs: list[list[str]] = [] 1hgedf

417 paragraph: list[str] = [] 1hgedf

418 for line in version_output.splitlines(keepends=False): 1hgedf

419 if not line.strip(): 1hgedf

420 if paragraph: 1hgedf

421 paragraphs.append(paragraph.copy()) 1hgedf

422 paragraph.clear() 1hgedf

423 elif paragraph and line.lstrip() != line: 1hgedf

424 paragraph[-1] = f'{paragraph[-1]} {line.lstrip()}' 1hed

425 else: 

426 paragraph.append(line) 1hgedf

427 if paragraph: # pragma: no branch 1hgedf

428 paragraphs.append(paragraph.copy()) 1hgedf

429 paragraph.clear() 1hgedf

430 assert paragraphs, ( 1hgedf

431 f'expected at least one paragraph of version output: {paragraphs!r}' 

432 ) 

433 assert prog_name is None or prog_name in paragraphs[0][0], ( 1hgedf

434 f'first version output line should mention ' 

435 f'{prog_name}: {paragraphs[0][0]!r}' 

436 ) 

437 assert version is None or version in paragraphs[0][0], ( 1hgedf

438 f'first version output line should mention the version number ' 

439 f'{version}: {paragraphs[0][0]!r}' 

440 ) 

441 schemes: dict[str, bool] = {} 1hgedf

442 formats: dict[str, bool] = {} 1hgedf

443 subcommands: set[str] = set() 1hgedf

444 extras: set[str] = set() 1hgedf

445 features: dict[str, bool] = {} 1hgedf

446 if len(paragraphs) < 2: # pragma: no cover 1hgedf

447 return VersionOutputData( 

448 derivation_schemes=schemes, 

449 foreign_configuration_formats=formats, 

450 subcommands=frozenset(subcommands), 

451 extras=frozenset(extras), 

452 features=features, 

453 ) 

454 for line in paragraphs[1]: 1hgedf

455 line_type, _, value = line.partition(':') 1hgedf

456 if line_type == line: 1hgedf

457 continue 1h

458 for item_ in re.split(r'(?:, *|.$)', value): 1hgedf

459 item = item_.strip() 1hgedf

460 if not item: 1hgedf

461 continue 1hgedf

462 if line_type == KnownLineType.SUPPORTED_FOREIGN_CONFS: 1hgedf

463 formats[item] = True 1hd

464 elif line_type == KnownLineType.UNAVAILABLE_FOREIGN_CONFS: 1hgedf

465 formats[item] = False 1hed

466 elif line_type == KnownLineType.SUPPORTED_SCHEMES: 1hgedf

467 schemes[item] = True 1hg

468 elif line_type == KnownLineType.UNAVAILABLE_SCHEMES: 1hgedf

469 schemes[item] = False 1h

470 elif line_type == KnownLineType.SUPPORTED_SUBCOMMANDS: 1hgedf

471 subcommands.add(item) 1hge

472 elif line_type == KnownLineType.ENABLED_EXTRAS: 1hdf

473 extras.add(item) 1hd

474 elif line_type == KnownLineType.SUPPORTED_FEATURES: 1hf

475 features[item] = True 1h

476 elif line_type == KnownLineType.UNAVAILABLE_FEATURES: 1hf

477 features[item] = False 1hf

478 else: 

479 raise AssertionError( # noqa: TRY003 

480 f'Unknown version info line type: {line_type!r}' # noqa: EM102 

481 ) 

482 return VersionOutputData( 1hgedf

483 derivation_schemes=schemes, 

484 foreign_configuration_formats=formats, 

485 subcommands=frozenset(subcommands), 

486 extras=frozenset(extras), 

487 features=features, 

488 ) 

489 

490 

491def bash_format(item: click.shell_completion.CompletionItem) -> str: 

492 """A formatter for `bash`-style shell completion items. 

493 

494 The format is `type,value`, and is dictated by [`click`][]. 

495 

496 """ 

497 type, value = ( # noqa: A001 1m

498 item.type, 

499 item.value, 

500 ) 

501 return f'{type},{value}' 1m

502 

503 

504def fish_format(item: click.shell_completion.CompletionItem) -> str: 

505 r"""A formatter for `fish`-style shell completion items. 

506 

507 The format is `type,value<tab>help`, and is dictated by [`click`][]. 

508 

509 """ 

510 type, value, help = ( # noqa: A001 1m

511 item.type, 

512 item.value, 

513 item.help, 

514 ) 

515 return f'{type},{value}\t{help}' if help else f'{type},{value}' 1m

516 

517 

518def zsh_format(item: click.shell_completion.CompletionItem) -> str: 

519 r"""A formatter for `zsh`-style shell completion items. 

520 

521 The format is `type<newline>value<newline>help<newline>`, and is 

522 dictated by [`click`][]. Upstream `click` currently (v8.2.0) does 

523 not deal with colons in the value correctly when the help text is 

524 non-degenerate. Our formatter here does, provided the upstream 

525 `zsh` completion script is used; see the 

526 [`cli_machinery.ZshComplete`][] class. A request is underway to 

527 merge this change into upstream `click`; see 

528 [`pallets/click#2846`][PR2846]. 

529 

530 [PR2846]: https://github.com/pallets/click/pull/2846 

531 

532 """ 

533 empty_help = '_' 1m

534 help_, value = ( 1m

535 (item.help, item.value.replace(':', r'\:')) 

536 if item.help and item.help == empty_help 

537 else (empty_help, item.value) 

538 ) 

539 return f'{item.type}\n{value}\n{help_}' 1m

540 

541 

542class ListKeysAction(str, enum.Enum): 

543 """Test fixture settings for [`ssh_agent.SSHAgentClient.list_keys`][]. 

544 

545 Attributes: 

546 EMPTY: Return an empty key list. 

547 FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][]. 

548 FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][]. 

549 

550 """ 

551 

552 EMPTY = enum.auto() 

553 """""" 

554 FAIL = enum.auto() 

555 """""" 

556 FAIL_RUNTIME = enum.auto() 

557 """""" 

558 

559 def __call__(self, *_args: Any, **_kwargs: Any) -> Any: 

560 """Execute the respective action.""" 

561 # TODO(the-13th-letter): Rewrite using structural pattern 

562 # matching. 

563 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

564 if self == self.EMPTY: 1c

565 return [] 1c

566 if self == self.FAIL: 1c

567 raise ssh_agent.SSHAgentFailedError( 1c

568 _types.SSH_AGENT.FAILURE.value, b'' 

569 ) 

570 if self == self.FAIL_RUNTIME: 1c

571 raise ssh_agent.TrailingDataError() 1c

572 raise AssertionError() 

573 

574 

575class SignAction(str, enum.Enum): 

576 """Test fixture settings for [`ssh_agent.SSHAgentClient.sign`][]. 

577 

578 Attributes: 

579 FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][]. 

580 FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][]. 

581 

582 """ 

583 

584 FAIL = enum.auto() 

585 """""" 

586 FAIL_RUNTIME = enum.auto() 

587 """""" 

588 

589 def __call__(self, *_args: Any, **_kwargs: Any) -> Any: 

590 """Execute the respective action.""" 

591 # TODO(the-13th-letter): Rewrite using structural pattern 

592 # matching. 

593 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

594 if self == self.FAIL: 1c

595 raise ssh_agent.SSHAgentFailedError( 1c

596 _types.SSH_AGENT.FAILURE.value, b'' 

597 ) 

598 if self == self.FAIL_RUNTIME: 1c

599 raise ssh_agent.TrailingDataError() 1c

600 raise AssertionError() 

601 

602 

603class SocketAddressAction(str, enum.Enum): 

604 """Test fixture settings for the SSH agent socket address. 

605 

606 Attributes: 

607 MANGLE_ANNOYING_OS_NAMED_PIPE: 

608 Mangle the address for the Annoying OS named pipe endpoint. 

609 MANGLE_SSH_AUTH_SOCK: 

610 Mangle the address for the UNIX domain socket (the 

611 `SSH_AUTH_SOCK` environment variable). 

612 UNSET_ANNOYING_OS_NAMED_PIPE: 

613 Unset the address for the Annoying OS named pipe endpoint. 

614 UNSET_SSH_AUTH_SOCK: 

615 Unset the `SSH_AUTH_SOCK` environment variable (the address 

616 for the UNIX domain socket). 

617 

618 """ 

619 

620 MANGLE_ANNOYING_OS_NAMED_PIPE = enum.auto() 

621 """""" 

622 MANGLE_SSH_AUTH_SOCK = enum.auto() 

623 """""" 

624 UNSET_ANNOYING_OS_NAMED_PIPE = enum.auto() 

625 """""" 

626 UNSET_SSH_AUTH_SOCK = enum.auto() 

627 """""" 

628 

629 def __call__( 

630 self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any 

631 ) -> None: 

632 """Execute the respective action.""" 

633 # TODO(the-13th-letter): Rewrite using structural pattern 

634 # matching. 

635 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

636 if self in { 1c

637 self.MANGLE_ANNOYING_OS_NAMED_PIPE, 

638 self.UNSET_ANNOYING_OS_NAMED_PIPE, 

639 }: # pragma: no cover 

640 # Not implemented yet. 

641 pass 

642 elif self == self.MANGLE_SSH_AUTH_SOCK: 1c

643 monkeypatch.setenv( 1c

644 'SSH_AUTH_SOCK', os.environ['SSH_AUTH_SOCK'] + '~' 

645 ) 

646 elif self == self.UNSET_SSH_AUTH_SOCK: 1c

647 monkeypatch.delenv('SSH_AUTH_SOCK', raising=False) 1c

648 else: 

649 raise AssertionError() 

650 

651 

652class SystemSupportAction(str, enum.Enum): 

653 """Test fixture settings for [`ssh_agent.SSHAgentClient`][] system support. 

654 

655 Attributes: 

656 UNSET_AF_UNIX: 

657 Ensure lack of support for UNIX domain sockets. 

658 UNSET_AF_UNIX_AND_ENSURE_USE: 

659 Ensure lack of support for UNIX domain sockets, and that the 

660 agent will use this socket provider. 

661 UNSET_NATIVE: 

662 Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`. 

663 UNSET_NATIVE_AND_ENSURE_USE: 

664 Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`, and that the 

665 agent will use the native socket provider. 

666 UNSET_PROVIDER_LIST: 

667 Ensure an empty list of SSH agent socket providers. 

668 UNSET_WINDLL: 

669 Ensure lack of support for The Annoying OS named pipes. 

670 UNSET_WINDLL_AND_ENSURE_USE: 

671 Ensure lack of support for The Annoying OS named pipes, and 

672 that the agent will use this socket provider. 

673 

674 """ 

675 

676 UNSET_AF_UNIX = enum.auto() 

677 """""" 

678 UNSET_AF_UNIX_AND_ENSURE_USE = enum.auto() 

679 """""" 

680 UNSET_NATIVE = enum.auto() 

681 """""" 

682 UNSET_NATIVE_AND_ENSURE_USE = enum.auto() 

683 """""" 

684 UNSET_PROVIDER_LIST = enum.auto() 

685 """""" 

686 UNSET_WINDLL = enum.auto() 

687 """""" 

688 UNSET_WINDLL_AND_ENSURE_USE = enum.auto() 

689 """""" 

690 

691 def __call__( 

692 self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any 

693 ) -> None: 

694 """Execute the respective action. 

695 

696 Args: 

697 monkeypatch: The current monkeypatch context. 

698 

699 """ 

700 # TODO(the-13th-letter): Rewrite using structural pattern 

701 # matching. 

702 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

703 if self == self.UNSET_PROVIDER_LIST: 1c

704 monkeypatch.setattr( 1c

705 ssh_agent.SSHAgentClient, 'SOCKET_PROVIDERS', [] 

706 ) 

707 elif self in {self.UNSET_NATIVE, self.UNSET_NATIVE_AND_ENSURE_USE}: 1c

708 self.check_or_ensure_use( 1c

709 'native', 

710 monkeypatch=monkeypatch, 

711 ensure_use=(self == self.UNSET_NATIVE_AND_ENSURE_USE), 

712 ) 

713 monkeypatch.delattr(socket, 'AF_UNIX', raising=False) 

714 monkeypatch.delattr(ctypes, 'WinDLL', raising=False) 

715 monkeypatch.delattr(ctypes, 'windll', raising=False) 

716 elif self in {self.UNSET_AF_UNIX, self.UNSET_AF_UNIX_AND_ENSURE_USE}: 1c

717 self.check_or_ensure_use( 1c

718 'posix', 

719 monkeypatch=monkeypatch, 

720 ensure_use=(self == self.UNSET_AF_UNIX_AND_ENSURE_USE), 

721 ) 

722 monkeypatch.delattr(socket, 'AF_UNIX', raising=False) 1c

723 elif self in {self.UNSET_WINDLL, self.UNSET_WINDLL_AND_ENSURE_USE}: 1c

724 self.check_or_ensure_use( 1c

725 'the_annoying_os', 

726 monkeypatch=monkeypatch, 

727 ensure_use=(self == self.UNSET_WINDLL_AND_ENSURE_USE), 

728 ) 

729 monkeypatch.delattr(ctypes, 'WinDLL', raising=False) 1c

730 monkeypatch.delattr(ctypes, 'windll', raising=False) 1c

731 else: 

732 raise AssertionError() 

733 

734 @staticmethod 

735 def check_or_ensure_use( 

736 provider: str, /, *, monkeypatch: pytest.MonkeyPatch, ensure_use: bool 

737 ) -> None: 

738 """Check that the named SSH agent socket provider will be used. 

739 

740 Either ensure that the socket provider will definitely be used, 

741 or, upon detecting that it won't be used, skip the test. 

742 

743 Args: 

744 provider: 

745 The provider to check for. 

746 ensure_use: 

747 If true, ensure that the socket provider will definitely 

748 be used. If false, then check for whether it will be 

749 used, and skip this test if not. 

750 monkeypatch: 

751 The monkeypatch context within which the fixture 

752 adjustments should be executed. 

753 

754 """ 

755 if ensure_use: 1c

756 monkeypatch.setattr( 1c

757 ssh_agent.SSHAgentClient, 'SOCKET_PROVIDERS', [provider] 

758 ) 

759 else: # pragma: no cover 

760 # This branch operates completely on instrumented or on 

761 # externally defined, non-deterministic state. So forego 

762 # any coverage measurement. 

763 intended: ( 

764 _types.SSHAgentSocketProvider 

765 | socketprovider.NoSuchProviderError 

766 | None 

767 ) 

768 try: 1c

769 intended = socketprovider.SocketProvider.resolve(provider) 1c

770 except socketprovider.NoSuchProviderError as exc: 

771 intended = exc 

772 except NotImplementedError: 

773 intended = None 

774 actual: ( 

775 _types.SSHAgentSocketProvider 

776 | socketprovider.NoSuchProviderError 

777 | None 

778 ) 

779 for name in ssh_agent.SSHAgentClient.SOCKET_PROVIDERS: 1c

780 try: 1c

781 actual = socketprovider.SocketProvider.resolve(name) 1c

782 except socketprovider.NoSuchProviderError as exc: 

783 actual = exc 

784 except NotImplementedError: 

785 continue 

786 break 1c

787 else: 

788 actual = None 

789 if intended != actual: 1c

790 pytest.skip( 1c

791 f'{provider!r} SSH agent socket provider ' 

792 f'is not currently in use' 

793 ) 

794 

795 

796class Parametrize(types.SimpleNamespace): 

797 """Common test parametrizations.""" 

798 

799 EAGER_ARGUMENTS = pytest.mark.parametrize( 

800 'arguments', 

801 [['--help'], ['--version']], 

802 ids=['help', 'version'], 

803 ) 

804 CHARSET_NAME = pytest.mark.parametrize( 

805 'charset_name', ['lower', 'upper', 'number', 'space', 'dash', 'symbol'] 

806 ) 

807 COMMAND_NON_EAGER_ARGUMENTS = pytest.mark.parametrize( 

808 ['command', 'non_eager_arguments'], 

809 [ 

810 pytest.param( 

811 [], 

812 [], 

813 id='top-nothing', 

814 ), 

815 pytest.param( 

816 [], 

817 ['export'], 

818 id='top-export', 

819 ), 

820 pytest.param( 

821 ['export'], 

822 [], 

823 id='export-nothing', 

824 ), 

825 pytest.param( 

826 ['export'], 

827 ['vault'], 

828 id='export-vault', 

829 ), 

830 pytest.param( 

831 ['export', 'vault'], 

832 [], 

833 id='export-vault-nothing', 

834 ), 

835 pytest.param( 

836 ['export', 'vault'], 

837 ['--format', 'this-format-doesnt-exist'], 

838 id='export-vault-args', 

839 ), 

840 pytest.param( 

841 ['vault'], 

842 [], 

843 id='vault-nothing', 

844 ), 

845 pytest.param( 

846 ['vault'], 

847 ['--export', './'], 

848 id='vault-args', 

849 ), 

850 ], 

851 ) 

852 UNICODE_NORMALIZATION_COMMAND_LINES = pytest.mark.parametrize( 

853 'command_line', 

854 [ 

855 pytest.param( 

856 ['--config', '--phrase'], 

857 id='configure global passphrase', 

858 ), 

859 pytest.param( 

860 ['--config', '--phrase', '--', 'DUMMY_SERVICE'], 

861 id='configure service passphrase', 

862 ), 

863 pytest.param( 

864 ['--phrase', '--', DUMMY_SERVICE], 

865 id='interactive passphrase', 

866 ), 

867 ], 

868 ) 

869 DELETE_CONFIG_INPUT = pytest.mark.parametrize( 

870 ['command_line', 'config', 'result_config'], 

871 [ 

872 pytest.param( 

873 ['--delete-globals'], 

874 {'global': {'phrase': 'abc'}, 'services': {}}, 

875 {'services': {}}, 

876 id='globals', 

877 ), 

878 pytest.param( 

879 ['--delete', '--', DUMMY_SERVICE], 

880 { 

881 'global': {'phrase': 'abc'}, 

882 'services': {DUMMY_SERVICE: {'notes': '...'}}, 

883 }, 

884 {'global': {'phrase': 'abc'}, 'services': {}}, 

885 id='service', 

886 ), 

887 pytest.param( 

888 ['--clear'], 

889 { 

890 'global': {'phrase': 'abc'}, 

891 'services': {DUMMY_SERVICE: {'notes': '...'}}, 

892 }, 

893 {'services': {}}, 

894 id='all', 

895 ), 

896 ], 

897 ) 

898 COLORFUL_COMMAND_INPUT = pytest.mark.parametrize( 

899 ['command_line', 'input'], 

900 [ 

901 ( 

902 ['vault', '--import', '-'], 

903 '{"services": {"": {"length": 20}}}', 

904 ), 

905 ], 

906 ids=['cmd'], 

907 ) 

908 CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES = pytest.mark.parametrize( 

909 ['command_line', 'input', 'err_text'], 

910 [ 

911 pytest.param( 

912 [], 

913 '', 

914 'Cannot update the global settings without any given settings', 

915 id='None', 

916 ), 

917 pytest.param( 

918 ['--', 'sv'], 

919 '', 

920 'Cannot update the service-specific settings without any given settings', 

921 id='None-sv', 

922 ), 

923 pytest.param( 

924 ['--phrase', '--', 'sv'], 

925 '\n', 

926 'No passphrase was given', 

927 id='phrase-sv', 

928 ), 

929 pytest.param( 

930 ['--phrase', '--', 'sv'], 

931 '', 

932 'No passphrase was given', 

933 id='phrase-sv-eof', 

934 ), 

935 pytest.param( 

936 ['--key'], 

937 '\n', 

938 'No SSH key was selected', 

939 id='key-sv', 

940 ), 

941 pytest.param( 

942 ['--key'], 

943 '', 

944 'No SSH key was selected', 

945 id='key-sv-eof', 

946 ), 

947 ], 

948 ) 

949 CONFIG_EDITING_VIA_CONFIG_FLAG = pytest.mark.parametrize( 

950 ['command_line', 'input', 'result_config'], 

951 [ 

952 pytest.param( 

953 ['--phrase'], 

954 'my passphrase\n', 

955 {'global': {'phrase': 'my passphrase'}, 'services': {}}, 

956 id='phrase', 

957 ), 

958 pytest.param( 

959 ['--key'], 

960 '1\n', 

961 { 

962 'global': {'key': DUMMY_KEY1_B64, 'phrase': 'abc'}, 

963 'services': {}, 

964 }, 

965 id='key', 

966 ), 

967 pytest.param( 

968 ['--phrase', '--', 'sv'], 

969 'my passphrase\n', 

970 { 

971 'global': {'phrase': 'abc'}, 

972 'services': {'sv': {'phrase': 'my passphrase'}}, 

973 }, 

974 id='phrase-sv', 

975 ), 

976 pytest.param( 

977 ['--key', '--', 'sv'], 

978 '1\n', 

979 { 

980 'global': {'phrase': 'abc'}, 

981 'services': {'sv': {'key': DUMMY_KEY1_B64}}, 

982 }, 

983 id='key-sv', 

984 ), 

985 pytest.param( 

986 ['--key', '--length', '15', '--', 'sv'], 

987 '1\n', 

988 { 

989 'global': {'phrase': 'abc'}, 

990 'services': {'sv': {'key': DUMMY_KEY1_B64, 'length': 15}}, 

991 }, 

992 id='key-length-sv', 

993 ), 

994 ], 

995 ) 

996 COMPLETABLE_PATH_ARGUMENT = pytest.mark.parametrize( 

997 'command_prefix', 

998 [ 

999 pytest.param( 

1000 ('export', 'vault'), 

1001 id='derivepassphrase-export-vault', 

1002 ), 

1003 pytest.param( 

1004 ('vault', '--export'), 

1005 id='derivepassphrase-vault--export', 

1006 ), 

1007 pytest.param( 

1008 ('vault', '--import'), 

1009 id='derivepassphrase-vault--import', 

1010 ), 

1011 ], 

1012 ) 

1013 COMPLETABLE_OPTIONS = pytest.mark.parametrize( 

1014 ['command_prefix', 'incomplete', 'completions'], 

1015 [ 

1016 pytest.param( 

1017 (), 

1018 '-', 

1019 frozenset({ 

1020 '--help', 

1021 '-h', 

1022 '--version', 

1023 '--debug', 

1024 '--verbose', 

1025 '-v', 

1026 '--quiet', 

1027 '-q', 

1028 }), 

1029 id='derivepassphrase', 

1030 ), 

1031 pytest.param( 

1032 ('export',), 

1033 '-', 

1034 frozenset({ 

1035 '--help', 

1036 '-h', 

1037 '--version', 

1038 '--debug', 

1039 '--verbose', 

1040 '-v', 

1041 '--quiet', 

1042 '-q', 

1043 }), 

1044 id='derivepassphrase-export', 

1045 ), 

1046 pytest.param( 

1047 ('export', 'vault'), 

1048 '-', 

1049 frozenset({ 

1050 '--help', 

1051 '-h', 

1052 '--version', 

1053 '--debug', 

1054 '--verbose', 

1055 '-v', 

1056 '--quiet', 

1057 '-q', 

1058 '--format', 

1059 '-f', 

1060 '--key', 

1061 '-k', 

1062 }), 

1063 id='derivepassphrase-export-vault', 

1064 ), 

1065 pytest.param( 

1066 ('vault',), 

1067 '-', 

1068 frozenset({ 

1069 '--help', 

1070 '-h', 

1071 '--version', 

1072 '--debug', 

1073 '--verbose', 

1074 '-v', 

1075 '--quiet', 

1076 '-q', 

1077 '--phrase', 

1078 '-p', 

1079 '--key', 

1080 '-k', 

1081 '--length', 

1082 '-l', 

1083 '--repeat', 

1084 '-r', 

1085 '--upper', 

1086 '--lower', 

1087 '--number', 

1088 '--space', 

1089 '--dash', 

1090 '--symbol', 

1091 '--config', 

1092 '-c', 

1093 '--notes', 

1094 '-n', 

1095 '--delete', 

1096 '-x', 

1097 '--delete-globals', 

1098 '--clear', 

1099 '-X', 

1100 '--export', 

1101 '-e', 

1102 '--import', 

1103 '-i', 

1104 '--overwrite-existing', 

1105 '--merge-existing', 

1106 '--unset', 

1107 '--export-as', 

1108 '--modern-editor-interface', 

1109 '--vault-legacy-editor-interface', 

1110 '--print-notes-before', 

1111 '--print-notes-after', 

1112 }), 

1113 id='derivepassphrase-vault', 

1114 ), 

1115 ], 

1116 ) 

1117 COMPLETABLE_SUBCOMMANDS = pytest.mark.parametrize( 

1118 ['command_prefix', 'incomplete', 'completions'], 

1119 [ 

1120 pytest.param( 

1121 (), 

1122 '', 

1123 frozenset({'export', 'vault'}), 

1124 id='derivepassphrase', 

1125 ), 

1126 pytest.param( 

1127 ('export',), 

1128 '', 

1129 frozenset({'vault'}), 

1130 id='derivepassphrase-export', 

1131 ), 

1132 ], 

1133 ) 

1134 BAD_CONFIGS = pytest.mark.parametrize( 

1135 'config', 

1136 [ 

1137 {'global': '', 'services': {}}, 

1138 {'global': 0, 'services': {}}, 

1139 { 

1140 'global': {'phrase': 'abc'}, 

1141 'services': False, 

1142 }, 

1143 { 

1144 'global': {'phrase': 'abc'}, 

1145 'services': True, 

1146 }, 

1147 { 

1148 'global': {'phrase': 'abc'}, 

1149 'services': None, 

1150 }, 

1151 ], 

1152 ) 

1153 BASE_CONFIG_VARIATIONS = pytest.mark.parametrize( 

1154 'config', 

1155 [ 

1156 {'global': {'phrase': 'my passphrase'}, 'services': {}}, 

1157 {'global': {'key': DUMMY_KEY1_B64}, 'services': {}}, 

1158 { 

1159 'global': {'phrase': 'abc'}, 

1160 'services': {'sv': {'phrase': 'my passphrase'}}, 

1161 }, 

1162 { 

1163 'global': {'phrase': 'abc'}, 

1164 'services': {'sv': {'key': DUMMY_KEY1_B64}}, 

1165 }, 

1166 { 

1167 'global': {'phrase': 'abc'}, 

1168 'services': {'sv': {'key': DUMMY_KEY1_B64, 'length': 15}}, 

1169 }, 

1170 ], 

1171 ) 

1172 BASE_CONFIG_WITH_KEY_VARIATIONS = pytest.mark.parametrize( 

1173 'config', 

1174 [ 

1175 pytest.param( 

1176 { 

1177 'global': {'key': DUMMY_KEY1_B64}, 

1178 'services': {DUMMY_SERVICE: {}}, 

1179 }, 

1180 id='global_config', 

1181 ), 

1182 pytest.param( 

1183 {'services': {DUMMY_SERVICE: {'key': DUMMY_KEY2_B64}}}, 

1184 id='service_config', 

1185 ), 

1186 pytest.param( 

1187 { 

1188 'global': {'key': DUMMY_KEY1_B64}, 

1189 'services': {DUMMY_SERVICE: {'key': DUMMY_KEY2_B64}}, 

1190 }, 

1191 id='full_config', 

1192 ), 

1193 ], 

1194 ) 

1195 CONFIG_WITH_KEY = pytest.mark.parametrize( 

1196 'config', 

1197 [ 

1198 pytest.param( 

1199 { 

1200 'global': {'key': DUMMY_KEY1_B64}, 

1201 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}, 

1202 }, 

1203 id='global', 

1204 ), 

1205 pytest.param( 

1206 { 

1207 'global': {'phrase': DUMMY_PASSPHRASE.rstrip('\n')}, 

1208 'services': { 

1209 DUMMY_SERVICE: { 

1210 'key': DUMMY_KEY1_B64, 

1211 **DUMMY_CONFIG_SETTINGS, 

1212 } 

1213 }, 

1214 }, 

1215 id='service', 

1216 ), 

1217 ], 

1218 ) 

1219 VALID_TEST_CONFIGS = pytest.mark.parametrize( 

1220 'config', 

1221 [ 

1222 conf.config 

1223 for conf in TEST_CONFIGS 

1224 if tests.is_valid_test_config(conf) 

1225 ], 

1226 ) 

1227 KEY_OVERRIDING_IN_CONFIG = pytest.mark.parametrize( 

1228 ['config', 'command_line'], 

1229 [ 

1230 pytest.param( 

1231 { 

1232 'global': {'key': DUMMY_KEY1_B64}, 

1233 'services': {}, 

1234 }, 

1235 ['--config', '-p'], 

1236 id='global', 

1237 ), 

1238 pytest.param( 

1239 { 

1240 'services': { 

1241 DUMMY_SERVICE: { 

1242 'key': DUMMY_KEY1_B64, 

1243 **DUMMY_CONFIG_SETTINGS, 

1244 }, 

1245 }, 

1246 }, 

1247 ['--config', '-p', '--', DUMMY_SERVICE], 

1248 id='service', 

1249 ), 

1250 pytest.param( 

1251 { 

1252 'global': {'key': DUMMY_KEY1_B64}, 

1253 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}, 

1254 }, 

1255 ['--config', '-p', '--', DUMMY_SERVICE], 

1256 id='service-over-global', 

1257 ), 

1258 ], 

1259 ) 

1260 COMPLETION_FUNCTION_INPUTS = pytest.mark.parametrize( 

1261 ['config', 'comp_func', 'args', 'incomplete', 'results'], 

1262 [ 

1263 pytest.param( 

1264 {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}}, 

1265 cli_helpers.shell_complete_service, 

1266 ['vault'], 

1267 '', 

1268 [DUMMY_SERVICE], 

1269 id='base_config-service', 

1270 ), 

1271 pytest.param( 

1272 {'services': {}}, 

1273 cli_helpers.shell_complete_service, 

1274 ['vault'], 

1275 '', 

1276 [], 

1277 id='empty_config-service', 

1278 ), 

1279 pytest.param( 

1280 { 

1281 'services': { 

1282 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

1283 'newline\nin\nname': DUMMY_CONFIG_SETTINGS.copy(), 

1284 } 

1285 }, 

1286 cli_helpers.shell_complete_service, 

1287 ['vault'], 

1288 '', 

1289 [DUMMY_SERVICE], 

1290 id='incompletable_newline_config-service', 

1291 ), 

1292 pytest.param( 

1293 { 

1294 'services': { 

1295 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

1296 'backspace\bin\bname': DUMMY_CONFIG_SETTINGS.copy(), 

1297 } 

1298 }, 

1299 cli_helpers.shell_complete_service, 

1300 ['vault'], 

1301 '', 

1302 [DUMMY_SERVICE], 

1303 id='incompletable_backspace_config-service', 

1304 ), 

1305 pytest.param( 

1306 { 

1307 'services': { 

1308 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

1309 'colon:in:name': DUMMY_CONFIG_SETTINGS.copy(), 

1310 } 

1311 }, 

1312 cli_helpers.shell_complete_service, 

1313 ['vault'], 

1314 '', 

1315 sorted([DUMMY_SERVICE, 'colon:in:name']), 

1316 id='brittle_colon_config-service', 

1317 ), 

1318 pytest.param( 

1319 { 

1320 'services': { 

1321 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

1322 'colon:in:name': DUMMY_CONFIG_SETTINGS.copy(), 

1323 'newline\nin\nname': DUMMY_CONFIG_SETTINGS.copy(), 

1324 'backspace\bin\bname': DUMMY_CONFIG_SETTINGS.copy(), 

1325 'nul\x00in\x00name': DUMMY_CONFIG_SETTINGS.copy(), 

1326 'del\x7fin\x7fname': DUMMY_CONFIG_SETTINGS.copy(), 

1327 } 

1328 }, 

1329 cli_helpers.shell_complete_service, 

1330 ['vault'], 

1331 '', 

1332 sorted([DUMMY_SERVICE, 'colon:in:name']), 

1333 id='brittle_incompletable_multi_config-service', 

1334 ), 

1335 pytest.param( 

1336 {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}}, 

1337 cli_helpers.shell_complete_path, 

1338 ['vault', '--import'], 

1339 '', 

1340 [click.shell_completion.CompletionItem('', type='file')], 

1341 id='base_config-path', 

1342 ), 

1343 pytest.param( 

1344 {'services': {}}, 

1345 cli_helpers.shell_complete_path, 

1346 ['vault', '--import'], 

1347 '', 

1348 [click.shell_completion.CompletionItem('', type='file')], 

1349 id='empty_config-path', 

1350 ), 

1351 ], 

1352 ) 

1353 COMPLETABLE_SERVICE_NAMES = pytest.mark.parametrize( 

1354 ['config', 'incomplete', 'completions'], 

1355 [ 

1356 pytest.param( 

1357 {'services': {}}, 

1358 '', 

1359 frozenset(), 

1360 id='no_services', 

1361 ), 

1362 pytest.param( 

1363 {'services': {}}, 

1364 'partial', 

1365 frozenset(), 

1366 id='no_services_partial', 

1367 ), 

1368 pytest.param( 

1369 {'services': {DUMMY_SERVICE: {'length': 10}}}, 

1370 '', 

1371 frozenset({DUMMY_SERVICE}), 

1372 id='one_service', 

1373 ), 

1374 pytest.param( 

1375 {'services': {DUMMY_SERVICE: {'length': 10}}}, 

1376 DUMMY_SERVICE[:4], 

1377 frozenset({DUMMY_SERVICE}), 

1378 id='one_service_partial', 

1379 ), 

1380 pytest.param( 

1381 {'services': {DUMMY_SERVICE: {'length': 10}}}, 

1382 DUMMY_SERVICE[-4:], 

1383 frozenset(), 

1384 id='one_service_partial_miss', 

1385 ), 

1386 ], 

1387 ) 

1388 SERVICE_NAME_COMPLETION_INPUTS = pytest.mark.parametrize( 

1389 ['config', 'key', 'incomplete', 'completions'], 

1390 [ 

1391 pytest.param( 

1392 { 

1393 'services': { 

1394 DUMMY_SERVICE: {'length': 10}, 

1395 'newline\nin\nname': {'length': 10}, 

1396 }, 

1397 }, 

1398 'newline\nin\nname', 

1399 '', 

1400 frozenset({DUMMY_SERVICE}), 

1401 id='newline', 

1402 ), 

1403 pytest.param( 

1404 { 

1405 'services': { 

1406 DUMMY_SERVICE: {'length': 10}, 

1407 'newline\nin\nname': {'length': 10}, 

1408 }, 

1409 }, 

1410 'newline\nin\nname', 

1411 'serv', 

1412 frozenset({DUMMY_SERVICE}), 

1413 id='newline_partial_other', 

1414 ), 

1415 pytest.param( 

1416 { 

1417 'services': { 

1418 DUMMY_SERVICE: {'length': 10}, 

1419 'newline\nin\nname': {'length': 10}, 

1420 }, 

1421 }, 

1422 'newline\nin\nname', 

1423 'newline', 

1424 frozenset({}), 

1425 id='newline_partial_specific', 

1426 ), 

1427 pytest.param( 

1428 { 

1429 'services': { 

1430 DUMMY_SERVICE: {'length': 10}, 

1431 'nul\x00in\x00name': {'length': 10}, 

1432 }, 

1433 }, 

1434 'nul\x00in\x00name', 

1435 '', 

1436 frozenset({DUMMY_SERVICE}), 

1437 id='nul', 

1438 ), 

1439 pytest.param( 

1440 { 

1441 'services': { 

1442 DUMMY_SERVICE: {'length': 10}, 

1443 'nul\x00in\x00name': {'length': 10}, 

1444 }, 

1445 }, 

1446 'nul\x00in\x00name', 

1447 'serv', 

1448 frozenset({DUMMY_SERVICE}), 

1449 id='nul_partial_other', 

1450 ), 

1451 pytest.param( 

1452 { 

1453 'services': { 

1454 DUMMY_SERVICE: {'length': 10}, 

1455 'nul\x00in\x00name': {'length': 10}, 

1456 }, 

1457 }, 

1458 'nul\x00in\x00name', 

1459 'nul', 

1460 frozenset({}), 

1461 id='nul_partial_specific', 

1462 ), 

1463 pytest.param( 

1464 { 

1465 'services': { 

1466 DUMMY_SERVICE: {'length': 10}, 

1467 'backspace\bin\bname': {'length': 10}, 

1468 }, 

1469 }, 

1470 'backspace\bin\bname', 

1471 '', 

1472 frozenset({DUMMY_SERVICE}), 

1473 id='backspace', 

1474 ), 

1475 pytest.param( 

1476 { 

1477 'services': { 

1478 DUMMY_SERVICE: {'length': 10}, 

1479 'backspace\bin\bname': {'length': 10}, 

1480 }, 

1481 }, 

1482 'backspace\bin\bname', 

1483 'serv', 

1484 frozenset({DUMMY_SERVICE}), 

1485 id='backspace_partial_other', 

1486 ), 

1487 pytest.param( 

1488 { 

1489 'services': { 

1490 DUMMY_SERVICE: {'length': 10}, 

1491 'backspace\bin\bname': {'length': 10}, 

1492 }, 

1493 }, 

1494 'backspace\bin\bname', 

1495 'back', 

1496 frozenset({}), 

1497 id='backspace_partial_specific', 

1498 ), 

1499 pytest.param( 

1500 { 

1501 'services': { 

1502 DUMMY_SERVICE: {'length': 10}, 

1503 'del\x7fin\x7fname': {'length': 10}, 

1504 }, 

1505 }, 

1506 'del\x7fin\x7fname', 

1507 '', 

1508 frozenset({DUMMY_SERVICE}), 

1509 id='del', 

1510 ), 

1511 pytest.param( 

1512 { 

1513 'services': { 

1514 DUMMY_SERVICE: {'length': 10}, 

1515 'del\x7fin\x7fname': {'length': 10}, 

1516 }, 

1517 }, 

1518 'del\x7fin\x7fname', 

1519 'serv', 

1520 frozenset({DUMMY_SERVICE}), 

1521 id='del_partial_other', 

1522 ), 

1523 pytest.param( 

1524 { 

1525 'services': { 

1526 DUMMY_SERVICE: {'length': 10}, 

1527 'del\x7fin\x7fname': {'length': 10}, 

1528 }, 

1529 }, 

1530 'del\x7fin\x7fname', 

1531 'del', 

1532 frozenset({}), 

1533 id='del_partial_specific', 

1534 ), 

1535 ], 

1536 ) 

1537 CONNECTION_HINTS = pytest.mark.parametrize( 

1538 'conn_hint', ['none', 'socket', 'client'] 

1539 ) 

1540 NOOP_EDIT_FUNCS = pytest.mark.parametrize( 

1541 ['edit_func_name', 'modern_editor_interface'], 

1542 [ 

1543 pytest.param('empty', True, id='empty'), 

1544 pytest.param('space', False, id='space-legacy'), 

1545 pytest.param('space', True, id='space-modern'), 

1546 ], 

1547 ) 

1548 SERVICE_NAME_EXCEPTIONS = pytest.mark.parametrize( 

1549 'exc_type', [RuntimeError, KeyError, ValueError] 

1550 ) 

1551 EXPORT_FORMAT_OPTIONS = pytest.mark.parametrize( 

1552 'export_options', 

1553 [ 

1554 [], 

1555 ['--export-as=sh'], 

1556 ], 

1557 ) 

1558 INCOMPLETE = pytest.mark.parametrize('incomplete', ['', 'partial']) 

1559 ISATTY = pytest.mark.parametrize( 

1560 'isatty', 

1561 [False, True], 

1562 ids=['notty', 'tty'], 

1563 ) 

1564 KEY_INDEX = pytest.mark.parametrize( 

1565 'key_index', [1, 2, 3], ids=lambda i: f'index{i}' 

1566 ) 

1567 KEY_TO_PHRASE_SETTINGS = pytest.mark.parametrize( 

1568 [ 

1569 'list_keys_action', 

1570 'address_action', 

1571 'system_support_action', 

1572 'sign_action', 

1573 'pattern', 

1574 ], 

1575 [ 

1576 pytest.param( 

1577 ListKeysAction.EMPTY, 

1578 None, 

1579 None, 

1580 SignAction.FAIL, 

1581 'not loaded into the agent', 

1582 id='key-not-loaded', 

1583 ), 

1584 pytest.param( 

1585 ListKeysAction.FAIL, 

1586 None, 

1587 None, 

1588 SignAction.FAIL, 

1589 'SSH agent failed to or refused to', 

1590 id='list-keys-refused', 

1591 ), 

1592 pytest.param( 

1593 ListKeysAction.FAIL_RUNTIME, 

1594 None, 

1595 None, 

1596 SignAction.FAIL, 

1597 'SSH agent failed to or refused to', 

1598 id='list-keys-protocol-error', 

1599 ), 

1600 pytest.param( 

1601 None, 

1602 SocketAddressAction.UNSET_SSH_AUTH_SOCK, 

1603 None, 

1604 SignAction.FAIL, 

1605 'Cannot find any running SSH agent', 

1606 id='agent-address-missing', 

1607 ), 

1608 pytest.param( 

1609 None, 

1610 SocketAddressAction.MANGLE_SSH_AUTH_SOCK, 

1611 None, 

1612 SignAction.FAIL, 

1613 'Cannot connect to the SSH agent', 

1614 id='agent-address-mangled', 

1615 ), 

1616 pytest.param( 

1617 None, 

1618 None, 

1619 SystemSupportAction.UNSET_NATIVE, 

1620 SignAction.FAIL, 

1621 'does not support communicating with it', 

1622 id='no-agent-support', 

1623 ), 

1624 pytest.param( 

1625 None, 

1626 None, 

1627 SystemSupportAction.UNSET_PROVIDER_LIST, 

1628 SignAction.FAIL, 

1629 'does not support communicating with it', 

1630 id='no-agent-support', 

1631 ), 

1632 pytest.param( 

1633 None, 

1634 None, 

1635 SystemSupportAction.UNSET_AF_UNIX_AND_ENSURE_USE, 

1636 SignAction.FAIL, 

1637 'does not support communicating with it', 

1638 id='no-agent-support', 

1639 ), 

1640 pytest.param( 

1641 None, 

1642 None, 

1643 SystemSupportAction.UNSET_WINDLL_AND_ENSURE_USE, 

1644 SignAction.FAIL, 

1645 'does not support communicating with it', 

1646 id='no-agent-support', 

1647 ), 

1648 pytest.param( 

1649 None, 

1650 None, 

1651 None, 

1652 SignAction.FAIL_RUNTIME, 

1653 'violates the communication protocol', 

1654 id='sign-violates-protocol', 

1655 ), 

1656 ], 

1657 ) 

1658 UNICODE_NORMALIZATION_ERROR_INPUTS = pytest.mark.parametrize( 

1659 ['main_config', 'command_line', 'input', 'error_message'], 

1660 [ 

1661 pytest.param( 

1662 textwrap.dedent(r""" 

1663 [vault] 

1664 default-unicode-normalization-form = 'XXX' 

1665 """), 

1666 ['--import', '-'], 

1667 json.dumps({ 

1668 'services': { 

1669 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

1670 'with_normalization': {'phrase': 'D\u00fcsseldorf'}, 

1671 }, 

1672 }), 

1673 ( 

1674 "Invalid value 'XXX' for config key " 

1675 'vault.default-unicode-normalization-form' 

1676 ), 

1677 id='global', 

1678 ), 

1679 pytest.param( 

1680 textwrap.dedent(r""" 

1681 [vault.unicode-normalization-form] 

1682 with_normalization = 'XXX' 

1683 """), 

1684 ['--import', '-'], 

1685 json.dumps({ 

1686 'services': { 

1687 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

1688 'with_normalization': {'phrase': 'D\u00fcsseldorf'}, 

1689 }, 

1690 }), 

1691 ( 

1692 "Invalid value 'XXX' for config key " 

1693 'vault.with_normalization.unicode-normalization-form' 

1694 ), 

1695 id='service', 

1696 ), 

1697 ], 

1698 ) 

1699 UNICODE_NORMALIZATION_WARNING_INPUTS = pytest.mark.parametrize( 

1700 ['main_config', 'command_line', 'input', 'warning_message'], 

1701 [ 

1702 pytest.param( 

1703 '', 

1704 ['--import', '-'], 

1705 json.dumps({ 

1706 'global': {'phrase': 'Du\u0308sseldorf'}, 

1707 'services': {}, 

1708 }), 

1709 'The $.global passphrase is not NFC-normalized', 

1710 id='global-NFC', 

1711 ), 

1712 pytest.param( 

1713 '', 

1714 ['--import', '-'], 

1715 json.dumps({ 

1716 'services': { 

1717 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

1718 'weird entry name': {'phrase': 'Du\u0308sseldorf'}, 

1719 } 

1720 }), 

1721 ( 

1722 'The $.services["weird entry name"] passphrase ' 

1723 'is not NFC-normalized' 

1724 ), 

1725 id='service-weird-name-NFC', 

1726 ), 

1727 pytest.param( 

1728 '', 

1729 ['--config', '-p', '--', DUMMY_SERVICE], 

1730 'Du\u0308sseldorf', 

1731 ( 

1732 f'The $.services.{DUMMY_SERVICE} passphrase ' 

1733 f'is not NFC-normalized' 

1734 ), 

1735 id='config-NFC', 

1736 ), 

1737 pytest.param( 

1738 '', 

1739 ['-p', '--', DUMMY_SERVICE], 

1740 'Du\u0308sseldorf', 

1741 'The interactive input passphrase is not NFC-normalized', 

1742 id='direct-input-NFC', 

1743 ), 

1744 pytest.param( 

1745 textwrap.dedent(r""" 

1746 [vault] 

1747 default-unicode-normalization-form = 'NFD' 

1748 """), 

1749 ['--import', '-'], 

1750 json.dumps({ 

1751 'global': { 

1752 'phrase': 'D\u00fcsseldorf', 

1753 }, 

1754 'services': {}, 

1755 }), 

1756 'The $.global passphrase is not NFD-normalized', 

1757 id='global-NFD', 

1758 ), 

1759 pytest.param( 

1760 textwrap.dedent(r""" 

1761 [vault] 

1762 default-unicode-normalization-form = 'NFD' 

1763 """), 

1764 ['--import', '-'], 

1765 json.dumps({ 

1766 'services': { 

1767 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

1768 'weird entry name': {'phrase': 'D\u00fcsseldorf'}, 

1769 }, 

1770 }), 

1771 ( 

1772 'The $.services["weird entry name"] passphrase ' 

1773 'is not NFD-normalized' 

1774 ), 

1775 id='service-weird-name-NFD', 

1776 ), 

1777 pytest.param( 

1778 textwrap.dedent(r""" 

1779 [vault.unicode-normalization-form] 

1780 'weird entry name 2' = 'NFKD' 

1781 """), 

1782 ['--import', '-'], 

1783 json.dumps({ 

1784 'services': { 

1785 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

1786 'weird entry name 1': {'phrase': 'D\u00fcsseldorf'}, 

1787 'weird entry name 2': {'phrase': 'D\u00fcsseldorf'}, 

1788 }, 

1789 }), 

1790 ( 

1791 'The $.services["weird entry name 2"] passphrase ' 

1792 'is not NFKD-normalized' 

1793 ), 

1794 id='service-weird-name-2-NFKD', 

1795 ), 

1796 ], 

1797 ) 

1798 MASK_PROG_NAME = pytest.mark.parametrize('mask_prog_name', [False, True]) 

1799 MASK_VERSION = pytest.mark.parametrize('mask_version', [False, True]) 

1800 CONFIG_SETTING_MODE = pytest.mark.parametrize('mode', ['config', 'import']) 

1801 MODERN_EDITOR_INTERFACE = pytest.mark.parametrize( 

1802 'modern_editor_interface', [False, True], ids=['legacy', 'modern'] 

1803 ) 

1804 NOTES_PLACEMENT = pytest.mark.parametrize( 

1805 ['notes_placement', 'placement_args'], 

1806 [ 

1807 pytest.param('after', ['--print-notes-after'], id='after'), 

1808 pytest.param('before', ['--print-notes-before'], id='before'), 

1809 ], 

1810 ) 

1811 VAULT_CHARSET_OPTION = pytest.mark.parametrize( 

1812 'option', 

1813 [ 

1814 '--lower', 

1815 '--upper', 

1816 '--number', 

1817 '--space', 

1818 '--dash', 

1819 '--symbol', 

1820 '--repeat', 

1821 '--length', 

1822 ], 

1823 ) 

1824 OPTION_COMBINATIONS_INCOMPATIBLE = pytest.mark.parametrize( 

1825 ['options', 'service'], 

1826 [ 

1827 pytest.param(o.options, o.needs_service, id=' '.join(o.options)) 

1828 for o in INTERESTING_OPTION_COMBINATIONS 

1829 if o.incompatible 

1830 ], 

1831 ) 

1832 OPTION_COMBINATIONS_SERVICE_NEEDED = pytest.mark.parametrize( 

1833 ['options', 'service', 'input', 'check_success'], 

1834 [ 

1835 pytest.param( 

1836 o.options, 

1837 o.needs_service, 

1838 o.input, 

1839 o.check_success, 

1840 id=' '.join(o.options), 

1841 ) 

1842 for o in INTERESTING_OPTION_COMBINATIONS 

1843 if not o.incompatible 

1844 ], 

1845 ) 

1846 COMPLETABLE_ITEMS = pytest.mark.parametrize( 

1847 ['partial', 'is_completable'], 

1848 [ 

1849 ('', True), 

1850 (DUMMY_SERVICE, True), 

1851 ('a\bn', False), 

1852 ('\b', False), 

1853 ('\x00', False), 

1854 ('\x20', True), 

1855 ('\x7f', False), 

1856 ('service with spaces', True), 

1857 ('service\nwith\nnewlines', False), 

1858 ], 

1859 ) 

1860 SHELL_FORMATTER = pytest.mark.parametrize( 

1861 ['shell', 'format_func'], 

1862 [ 

1863 pytest.param('bash', bash_format, id='bash'), 

1864 pytest.param('fish', fish_format, id='fish'), 

1865 pytest.param('zsh', zsh_format, id='zsh'), 

1866 ], 

1867 ) 

1868 TRY_RACE_FREE_IMPLEMENTATION = pytest.mark.parametrize( 

1869 'try_race_free_implementation', [True, False] 

1870 ) 

1871 VERSION_OUTPUT_DATA = pytest.mark.parametrize( 

1872 ['version_output', 'prog_name', 'version', 'expected_parse'], 

1873 [ 

1874 pytest.param( 

1875 """\ 

1876derivepassphrase 0.4.0 

1877Using cryptography 44.0.0 

1878 

1879Supported foreign configuration formats: vault storeroom, vault v0.2, 

1880 vault v0.3. 

1881PEP 508 extras: export. 

1882""", 

1883 'derivepassphrase', 

1884 '0.4.0', 

1885 VersionOutputData( 

1886 derivation_schemes={}, 

1887 foreign_configuration_formats={ 

1888 'vault storeroom': True, 

1889 'vault v0.2': True, 

1890 'vault v0.3': True, 

1891 }, 

1892 subcommands=frozenset(), 

1893 features={}, 

1894 extras=frozenset({'export'}), 

1895 ), 

1896 id='derivepassphrase-0.4.0-export', 

1897 ), 

1898 pytest.param( 

1899 """\ 

1900derivepassphrase 0.5 

1901 

1902Supported derivation schemes: vault. 

1903Known foreign configuration formats: vault storeroom, vault v0.2, vault v0.3. 

1904Supported subcommands: export, vault. 

1905No PEP 508 extras are active. 

1906""", 

1907 'derivepassphrase', 

1908 '0.5', 

1909 VersionOutputData( 

1910 derivation_schemes={'vault': True}, 

1911 foreign_configuration_formats={ 

1912 'vault storeroom': False, 

1913 'vault v0.2': False, 

1914 'vault v0.3': False, 

1915 }, 

1916 subcommands=frozenset({'export', 'vault'}), 

1917 features={}, 

1918 extras=frozenset({}), 

1919 ), 

1920 id='derivepassphrase-0.5-plain', 

1921 ), 

1922 pytest.param( 

1923 """\ 

1924 

1925 

1926 

1927inventpassphrase -1.3 

1928Using not-a-library 7.12 

1929Copyright 2025 Nobody. All rights reserved. 

1930 

1931Supported derivation schemes: nonsense. 

1932Known derivation schemes: divination, /dev/random, 

1933 geiger counter, 

1934 crossword solver. 

1935Supported foreign configuration formats: derivepassphrase, nonsense. 

1936Known foreign configuration formats: divination v3.141592, 

1937 /dev/random. 

1938Supported subcommands: delete-all-files, dump-core. 

1939Supported features: delete-while-open. 

1940Known features: backups-are-nice-to-have. 

1941PEP 508 extras: annoying-popups, delete-all-files, 

1942 dump-core-depending-on-the-phase-of-the-moon. 

1943 

1944 

1945 

1946""", 

1947 'inventpassphrase', 

1948 '-1.3', 

1949 VersionOutputData( 

1950 derivation_schemes={ 

1951 'nonsense': True, 

1952 'divination': False, 

1953 '/dev/random': False, 

1954 'geiger counter': False, 

1955 'crossword solver': False, 

1956 }, 

1957 foreign_configuration_formats={ 

1958 'derivepassphrase': True, 

1959 'nonsense': True, 

1960 'divination v3.141592': False, 

1961 '/dev/random': False, 

1962 }, 

1963 subcommands=frozenset({'delete-all-files', 'dump-core'}), 

1964 features={ 

1965 'delete-while-open': True, 

1966 'backups-are-nice-to-have': False, 

1967 }, 

1968 extras=frozenset({ 

1969 'annoying-popups', 

1970 'delete-all-files', 

1971 'dump-core-depending-on-the-phase-of-the-moon', 

1972 }), 

1973 ), 

1974 id='inventpassphrase', 

1975 ), 

1976 ], 

1977 ) 

1978 """Sample data for [`parse_version_output`][].""" 

1979 VALIDATION_FUNCTION_INPUT = pytest.mark.parametrize( 

1980 ['vfunc', 'input'], 

1981 [ 

1982 (cli_machinery.validate_occurrence_constraint, 20), 

1983 (cli_machinery.validate_length, 20), 

1984 ], 

1985 ) 

1986 

1987 

1988class TestAllCLI: 

1989 """Tests uniformly for all command-line interfaces.""" 

1990 

1991 @Parametrize.MASK_PROG_NAME 

1992 @Parametrize.MASK_VERSION 

1993 @Parametrize.VERSION_OUTPUT_DATA 

1994 def test_001_parse_version_output( 

1995 self, 

1996 version_output: str, 

1997 prog_name: str | None, 

1998 version: str | None, 

1999 mask_prog_name: bool, 

2000 mask_version: bool, 

2001 expected_parse: VersionOutputData, 

2002 ) -> None: 

2003 """The parsing machinery for expected version output data works.""" 

2004 prog_name = None if mask_prog_name else prog_name 1h

2005 version = None if mask_version else version 1h

2006 assert ( 1h

2007 parse_version_output( 

2008 version_output, prog_name=prog_name, version=version 

2009 ) 

2010 == expected_parse 

2011 ) 

2012 

2013 # TODO(the-13th-letter): Do we actually need this? What should we 

2014 # check for? 

2015 def test_100_help_output(self) -> None: 

2016 """The top-level help text mentions subcommands. 

2017 

2018 TODO: Do we actually need this? What should we check for? 

2019 

2020 """ 

2021 runner = tests.CliRunner(mix_stderr=False) 1_

2022 # TODO(the-13th-letter): Rewrite using parenthesized 

2023 # with-statements. 

2024 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2025 with contextlib.ExitStack() as stack: 1_

2026 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1_

2027 stack.enter_context( 1_

2028 tests.isolated_config( 

2029 monkeypatch=monkeypatch, 

2030 runner=runner, 

2031 ) 

2032 ) 

2033 result = runner.invoke( 1_

2034 cli.derivepassphrase, ['--help'], catch_exceptions=False 

2035 ) 

2036 assert result.clean_exit( 1_

2037 empty_stderr=True, output='currently implemented subcommands' 

2038 ), 'expected clean exit, and known help text' 

2039 

2040 # TODO(the-13th-letter): Do we actually need this? What should we 

2041 # check for? 

2042 def test_101_help_output_export( 

2043 self, 

2044 ) -> None: 

2045 """The "export" subcommand help text mentions subcommands. 

2046 

2047 TODO: Do we actually need this? What should we check for? 

2048 

2049 """ 

2050 runner = tests.CliRunner(mix_stderr=False) 1`

2051 # TODO(the-13th-letter): Rewrite using parenthesized 

2052 # with-statements. 

2053 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2054 with contextlib.ExitStack() as stack: 1`

2055 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1`

2056 stack.enter_context( 1`

2057 tests.isolated_config( 

2058 monkeypatch=monkeypatch, 

2059 runner=runner, 

2060 ) 

2061 ) 

2062 result = runner.invoke( 1`

2063 cli.derivepassphrase, 

2064 ['export', '--help'], 

2065 catch_exceptions=False, 

2066 ) 

2067 assert result.clean_exit( 1`

2068 empty_stderr=True, output='only available subcommand' 

2069 ), 'expected clean exit, and known help text' 

2070 

2071 # TODO(the-13th-letter): Do we actually need this? What should we 

2072 # check for? 

2073 def test_102_help_output_export_vault( 

2074 self, 

2075 ) -> None: 

2076 """The "export vault" subcommand help text has known content. 

2077 

2078 TODO: Do we actually need this? What should we check for? 

2079 

2080 """ 

2081 runner = tests.CliRunner(mix_stderr=False) 1{

2082 # TODO(the-13th-letter): Rewrite using parenthesized 

2083 # with-statements. 

2084 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2085 with contextlib.ExitStack() as stack: 1{

2086 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1{

2087 stack.enter_context( 1{

2088 tests.isolated_config( 

2089 monkeypatch=monkeypatch, 

2090 runner=runner, 

2091 ) 

2092 ) 

2093 result = runner.invoke( 1{

2094 cli.derivepassphrase, 

2095 ['export', 'vault', '--help'], 

2096 catch_exceptions=False, 

2097 ) 

2098 assert result.clean_exit( 1{

2099 empty_stderr=True, output='Export a vault-native configuration' 

2100 ), 'expected clean exit, and known help text' 

2101 

2102 # TODO(the-13th-letter): Do we actually need this? What should we 

2103 # check for? 

2104 def test_103_help_output_vault( 

2105 self, 

2106 ) -> None: 

2107 """The "vault" subcommand help text has known content. 

2108 

2109 TODO: Do we actually need this? What should we check for? 

2110 

2111 """ 

2112 runner = tests.CliRunner(mix_stderr=False) 1,

2113 # TODO(the-13th-letter): Rewrite using parenthesized 

2114 # with-statements. 

2115 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2116 with contextlib.ExitStack() as stack: 1,

2117 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1,

2118 stack.enter_context( 1,

2119 tests.isolated_config( 

2120 monkeypatch=monkeypatch, 

2121 runner=runner, 

2122 ) 

2123 ) 

2124 result = runner.invoke( 1,

2125 cli.derivepassphrase, 

2126 ['vault', '--help'], 

2127 catch_exceptions=False, 

2128 ) 

2129 assert result.clean_exit( 1,

2130 empty_stderr=True, output='Passphrase generation:\n' 

2131 ), 'expected clean exit, and option groups in help text' 

2132 assert result.clean_exit( 1,

2133 empty_stderr=True, output='Use $VISUAL or $EDITOR to configure' 

2134 ), 'expected clean exit, and option group epilog in help text' 

2135 

2136 @Parametrize.COMMAND_NON_EAGER_ARGUMENTS 

2137 @Parametrize.EAGER_ARGUMENTS 

2138 def test_200_eager_options( 

2139 self, 

2140 command: list[str], 

2141 arguments: list[str], 

2142 non_eager_arguments: list[str], 

2143 ) -> None: 

2144 """Eager options terminate option and argument processing.""" 

2145 runner = tests.CliRunner(mix_stderr=False) 1|

2146 # TODO(the-13th-letter): Rewrite using parenthesized 

2147 # with-statements. 

2148 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2149 with contextlib.ExitStack() as stack: 1|

2150 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1|

2151 stack.enter_context( 1|

2152 tests.isolated_config( 

2153 monkeypatch=monkeypatch, 

2154 runner=runner, 

2155 ) 

2156 ) 

2157 result = runner.invoke( 1|

2158 cli.derivepassphrase, 

2159 [*command, *arguments, *non_eager_arguments], 

2160 catch_exceptions=False, 

2161 ) 

2162 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1|

2163 

2164 @Parametrize.ISATTY 

2165 @Parametrize.COLORFUL_COMMAND_INPUT 

2166 def test_201_automatic_color_mode( 

2167 self, 

2168 isatty: bool, 

2169 command_line: list[str], 

2170 input: str | None, 

2171 ) -> None: 

2172 """Auto-detect if color should be used. 

2173 

2174 (The answer currently is always no. See the 

2175 [`conventional-configurable-text-styling` wishlist 

2176 entry](../wishlist/conventional-configurable-text-styling.md).) 

2177 

2178 """ 

2179 color = False 1#

2180 runner = tests.CliRunner(mix_stderr=False) 1#

2181 # TODO(the-13th-letter): Rewrite using parenthesized 

2182 # with-statements. 

2183 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2184 with contextlib.ExitStack() as stack: 1#

2185 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1#

2186 stack.enter_context( 1#

2187 tests.isolated_config( 

2188 monkeypatch=monkeypatch, 

2189 runner=runner, 

2190 ) 

2191 ) 

2192 result = runner.invoke( 1#

2193 cli.derivepassphrase, 

2194 command_line, 

2195 input=input, 

2196 catch_exceptions=False, 

2197 color=isatty, 

2198 ) 

2199 assert ( 1#

2200 not color 

2201 or '\x1b[0m' in result.stderr 

2202 or '\x1b[m' in result.stderr 

2203 ), 'Expected color, but found no ANSI reset sequence' 

2204 assert color or '\x1b[' not in result.stderr, ( 1#

2205 'Expected no color, but found an ANSI control sequence' 

2206 ) 

2207 

2208 def test_202a_derivepassphrase_version_option_output( 

2209 self, 

2210 ) -> None: 

2211 """The version output states supported features. 

2212 

2213 The version output is parsed using [`parse_version_output`][]. 

2214 Format examples can be found in 

2215 [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the 

2216 top-level `derivepassphrase` command, the output should contain 

2217 the known and supported derivation schemes, and a list of 

2218 subcommands. 

2219 

2220 """ 

2221 runner = tests.CliRunner(mix_stderr=False) 1g

2222 # TODO(the-13th-letter): Rewrite using parenthesized 

2223 # with-statements. 

2224 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2225 with contextlib.ExitStack() as stack: 1g

2226 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1g

2227 stack.enter_context( 1g

2228 tests.isolated_config( 

2229 monkeypatch=monkeypatch, 

2230 runner=runner, 

2231 ) 

2232 ) 

2233 result = runner.invoke( 1g

2234 cli.derivepassphrase, 

2235 ['--version'], 

2236 catch_exceptions=False, 

2237 ) 

2238 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1g

2239 assert result.stdout.strip(), 'expected version output' 1g

2240 version_data = parse_version_output(result.stdout) 1g

2241 actually_known_schemes = dict.fromkeys(_types.DerivationScheme, True) 1g

2242 subcommands = set(_types.Subcommand) 1g

2243 assert version_data.derivation_schemes == actually_known_schemes 1g

2244 assert not version_data.foreign_configuration_formats 1g

2245 assert version_data.subcommands == subcommands 1g

2246 assert not version_data.features 1g

2247 assert not version_data.extras 1g

2248 

2249 def test_202b_export_version_option_output( 

2250 self, 

2251 ) -> None: 

2252 """The version output states supported features. 

2253 

2254 The version output is parsed using [`parse_version_output`][]. 

2255 Format examples can be found in 

2256 [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the 

2257 `export` command, the output should contain the known foreign 

2258 configuration formats (but not marked as supported), and a list 

2259 of subcommands. 

2260 

2261 """ 

2262 runner = tests.CliRunner(mix_stderr=False) 1e

2263 # TODO(the-13th-letter): Rewrite using parenthesized 

2264 # with-statements. 

2265 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2266 with contextlib.ExitStack() as stack: 1e

2267 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1e

2268 stack.enter_context( 1e

2269 tests.isolated_config( 

2270 monkeypatch=monkeypatch, 

2271 runner=runner, 

2272 ) 

2273 ) 

2274 result = runner.invoke( 1e

2275 cli.derivepassphrase, 

2276 ['export', '--version'], 

2277 catch_exceptions=False, 

2278 ) 

2279 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1e

2280 assert result.stdout.strip(), 'expected version output' 1e

2281 version_data = parse_version_output(result.stdout) 1e

2282 actually_known_formats: dict[str, bool] = { 1e

2283 _types.ForeignConfigurationFormat.VAULT_STOREROOM: False, 

2284 _types.ForeignConfigurationFormat.VAULT_V02: False, 

2285 _types.ForeignConfigurationFormat.VAULT_V03: False, 

2286 } 

2287 subcommands = set(_types.ExportSubcommand) 1e

2288 assert not version_data.derivation_schemes 1e

2289 assert ( 1e

2290 version_data.foreign_configuration_formats 

2291 == actually_known_formats 

2292 ) 

2293 assert version_data.subcommands == subcommands 1e

2294 assert not version_data.features 1e

2295 assert not version_data.extras 1e

2296 

2297 def test_202c_export_vault_version_option_output( 

2298 self, 

2299 ) -> None: 

2300 """The version output states supported features. 

2301 

2302 The version output is parsed using [`parse_version_output`][]. 

2303 Format examples can be found in 

2304 [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the 

2305 `export vault` subcommand, the output should contain the 

2306 vault-specific subset of the known or supported foreign 

2307 configuration formats, and a list of available PEP 508 extras. 

2308 

2309 """ 

2310 runner = tests.CliRunner(mix_stderr=False) 1d

2311 # TODO(the-13th-letter): Rewrite using parenthesized 

2312 # with-statements. 

2313 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2314 with contextlib.ExitStack() as stack: 1d

2315 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1d

2316 stack.enter_context( 1d

2317 tests.isolated_config( 

2318 monkeypatch=monkeypatch, 

2319 runner=runner, 

2320 ) 

2321 ) 

2322 result = runner.invoke( 1d

2323 cli.derivepassphrase, 

2324 ['export', 'vault', '--version'], 

2325 catch_exceptions=False, 

2326 ) 

2327 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1d

2328 assert result.stdout.strip(), 'expected version output' 1d

2329 version_data = parse_version_output(result.stdout) 1d

2330 actually_known_formats: dict[str, bool] = {} 1d

2331 actually_enabled_extras: set[str] = set() 1d

2332 with contextlib.suppress(ModuleNotFoundError): 1d

2333 from derivepassphrase.exporter import storeroom, vault_native # noqa: I001,PLC0415 1d

2334 

2335 actually_known_formats.update({ 1d

2336 _types.ForeignConfigurationFormat.VAULT_STOREROOM: not storeroom.STUBBED, 

2337 _types.ForeignConfigurationFormat.VAULT_V02: not vault_native.STUBBED, 

2338 _types.ForeignConfigurationFormat.VAULT_V03: not vault_native.STUBBED, 

2339 }) 

2340 if not storeroom.STUBBED and not vault_native.STUBBED: 1d

2341 actually_enabled_extras.add(_types.PEP508Extra.EXPORT) 1d

2342 assert not version_data.derivation_schemes 1d

2343 assert ( 1d

2344 version_data.foreign_configuration_formats 

2345 == actually_known_formats 

2346 ) 

2347 assert not version_data.subcommands 1d

2348 assert not version_data.features 1d

2349 assert version_data.extras == actually_enabled_extras 1d

2350 

2351 def test_202d_vault_version_option_output( 

2352 self, 

2353 ) -> None: 

2354 """The version output states supported features. 

2355 

2356 The version output is parsed using [`parse_version_output`][]. 

2357 Format examples can be found in 

2358 [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the 

2359 vault command, the output should not contain anything beyond the 

2360 first paragraph. 

2361 

2362 """ 

2363 runner = tests.CliRunner(mix_stderr=False) 1f

2364 # TODO(the-13th-letter): Rewrite using parenthesized 

2365 # with-statements. 

2366 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2367 with contextlib.ExitStack() as stack: 1f

2368 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1f

2369 stack.enter_context( 1f

2370 tests.isolated_config( 

2371 monkeypatch=monkeypatch, 

2372 runner=runner, 

2373 ) 

2374 ) 

2375 result = runner.invoke( 1f

2376 cli.derivepassphrase, 

2377 ['vault', '--version'], 

2378 catch_exceptions=False, 

2379 ) 

2380 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1f

2381 assert result.stdout.strip(), 'expected version output' 1f

2382 version_data = parse_version_output(result.stdout) 1f

2383 features: dict[str, bool] = { 1f

2384 _types.Feature.SSH_KEY: hasattr(socket, 'AF_UNIX'), 

2385 } 

2386 assert not version_data.derivation_schemes 1f

2387 assert not version_data.foreign_configuration_formats 1f

2388 assert not version_data.subcommands 1f

2389 assert version_data.features == features 1f

2390 assert not version_data.extras 1f

2391 

2392 

2393class TestCLI: 

2394 """Tests for the `derivepassphrase vault` command-line interface.""" 

2395 

2396 def test_200_help_output( 

2397 self, 

2398 ) -> None: 

2399 """The `--help` option emits help text.""" 

2400 runner = tests.CliRunner(mix_stderr=False) 1-

2401 # TODO(the-13th-letter): Rewrite using parenthesized 

2402 # with-statements. 

2403 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2404 with contextlib.ExitStack() as stack: 1-

2405 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1-

2406 stack.enter_context( 1-

2407 tests.isolated_config( 

2408 monkeypatch=monkeypatch, 

2409 runner=runner, 

2410 ) 

2411 ) 

2412 result = runner.invoke( 1-

2413 cli.derivepassphrase_vault, 

2414 ['--help'], 

2415 catch_exceptions=False, 

2416 ) 

2417 assert result.clean_exit( 1-

2418 empty_stderr=True, output='Passphrase generation:\n' 

2419 ), 'expected clean exit, and option groups in help text' 

2420 assert result.clean_exit( 1-

2421 empty_stderr=True, output='Use $VISUAL or $EDITOR to configure' 

2422 ), 'expected clean exit, and option group epilog in help text' 

2423 

2424 # TODO(the-13th-letter): Remove this test once 

2425 # TestAllCLI.test_202_version_option_output no longer xfails. 

2426 def test_200a_version_output( 

2427 self, 

2428 ) -> None: 

2429 """The `--version` option emits version information.""" 

2430 runner = tests.CliRunner(mix_stderr=False) 1.

2431 # TODO(the-13th-letter): Rewrite using parenthesized 

2432 # with-statements. 

2433 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2434 with contextlib.ExitStack() as stack: 1.

2435 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1.

2436 stack.enter_context( 1.

2437 tests.isolated_config( 

2438 monkeypatch=monkeypatch, 

2439 runner=runner, 

2440 ) 

2441 ) 

2442 result = runner.invoke( 1.

2443 cli.derivepassphrase_vault, 

2444 ['--version'], 

2445 catch_exceptions=False, 

2446 ) 

2447 assert result.clean_exit(empty_stderr=True, output=cli.PROG_NAME), ( 1.

2448 'expected clean exit, and program name in version text' 

2449 ) 

2450 assert result.clean_exit(empty_stderr=True, output=cli.VERSION), ( 1.

2451 'expected clean exit, and version in help text' 

2452 ) 

2453 

2454 @Parametrize.CHARSET_NAME 

2455 def test_201_disable_character_set( 

2456 self, 

2457 charset_name: str, 

2458 ) -> None: 

2459 """Named character classes can be disabled on the command-line.""" 

2460 option = f'--{charset_name}' 1Q

2461 charset = vault.Vault.CHARSETS[charset_name].decode('ascii') 1Q

2462 runner = tests.CliRunner(mix_stderr=False) 1Q

2463 # TODO(the-13th-letter): Rewrite using parenthesized 

2464 # with-statements. 

2465 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2466 with contextlib.ExitStack() as stack: 1Q

2467 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1Q

2468 stack.enter_context( 1Q

2469 tests.isolated_config( 

2470 monkeypatch=monkeypatch, 

2471 runner=runner, 

2472 ) 

2473 ) 

2474 monkeypatch.setattr( 1Q

2475 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt 

2476 ) 

2477 result = runner.invoke( 1Q

2478 cli.derivepassphrase_vault, 

2479 [option, '0', '-p', '--', DUMMY_SERVICE], 

2480 input=DUMMY_PASSPHRASE, 

2481 catch_exceptions=False, 

2482 ) 

2483 assert result.clean_exit(empty_stderr=True), 'expected clean exit:' 1Q

2484 for c in charset: 1Q

2485 assert c not in result.stdout, ( 1Q

2486 f'derived password contains forbidden character {c!r}' 

2487 ) 

2488 

2489 def test_202_disable_repetition( 

2490 self, 

2491 ) -> None: 

2492 """Character repetition can be disabled on the command-line.""" 

2493 runner = tests.CliRunner(mix_stderr=False) 1W

2494 # TODO(the-13th-letter): Rewrite using parenthesized 

2495 # with-statements. 

2496 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2497 with contextlib.ExitStack() as stack: 1W

2498 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1W

2499 stack.enter_context( 1W

2500 tests.isolated_config( 

2501 monkeypatch=monkeypatch, 

2502 runner=runner, 

2503 ) 

2504 ) 

2505 monkeypatch.setattr( 1W

2506 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt 

2507 ) 

2508 result = runner.invoke( 1W

2509 cli.derivepassphrase_vault, 

2510 ['--repeat', '0', '-p', '--', DUMMY_SERVICE], 

2511 input=DUMMY_PASSPHRASE, 

2512 catch_exceptions=False, 

2513 ) 

2514 assert result.clean_exit(empty_stderr=True), ( 1W

2515 'expected clean exit and empty stderr' 

2516 ) 

2517 passphrase = result.stdout.rstrip('\r\n') 1W

2518 for i in range(len(passphrase) - 1): 1W

2519 assert passphrase[i : i + 1] != passphrase[i + 1 : i + 2], ( 1W

2520 f'derived password contains repeated character ' 

2521 f'at position {i}: {result.stdout!r}' 

2522 ) 

2523 

2524 @Parametrize.CONFIG_WITH_KEY 

2525 def test_204a_key_from_config( 

2526 self, 

2527 running_ssh_agent: tests.RunningSSHAgentInfo, 

2528 config: _types.VaultConfig, 

2529 ) -> None: 

2530 """A stored configured SSH key will be used.""" 

2531 del running_ssh_agent 1R

2532 runner = tests.CliRunner(mix_stderr=False) 1R

2533 # TODO(the-13th-letter): Rewrite using parenthesized 

2534 # with-statements. 

2535 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2536 with contextlib.ExitStack() as stack: 1R

2537 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1R

2538 stack.enter_context( 1R

2539 tests.isolated_vault_config( 

2540 monkeypatch=monkeypatch, 

2541 runner=runner, 

2542 vault_config=config, 

2543 ) 

2544 ) 

2545 monkeypatch.setattr( 1R

2546 vault.Vault, 'phrase_from_key', tests.phrase_from_key 

2547 ) 

2548 result = runner.invoke( 1R

2549 cli.derivepassphrase_vault, 

2550 ['--', DUMMY_SERVICE], 

2551 catch_exceptions=False, 

2552 ) 

2553 assert result.clean_exit(empty_stderr=True), ( 1R

2554 'expected clean exit and empty stderr' 

2555 ) 

2556 assert result.stdout 1R

2557 assert ( 1R

2558 result.stdout.rstrip('\n').encode('UTF-8') 

2559 != DUMMY_RESULT_PASSPHRASE 

2560 ), 'known false output: phrase-based instead of key-based' 

2561 assert ( 1R

2562 result.stdout.rstrip('\n').encode('UTF-8') == DUMMY_RESULT_KEY1 

2563 ), 'expected known output' 

2564 

2565 def test_204b_key_from_command_line( 

2566 self, 

2567 running_ssh_agent: tests.RunningSSHAgentInfo, 

2568 ) -> None: 

2569 """An SSH key requested on the command-line will be used.""" 

2570 del running_ssh_agent 1G

2571 runner = tests.CliRunner(mix_stderr=False) 1G

2572 # TODO(the-13th-letter): Rewrite using parenthesized 

2573 # with-statements. 

2574 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2575 with contextlib.ExitStack() as stack: 1G

2576 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1G

2577 stack.enter_context( 1G

2578 tests.isolated_vault_config( 

2579 monkeypatch=monkeypatch, 

2580 runner=runner, 

2581 vault_config={ 

2582 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS} 

2583 }, 

2584 ) 

2585 ) 

2586 monkeypatch.setattr( 1G

2587 cli_helpers, 'get_suitable_ssh_keys', tests.suitable_ssh_keys 

2588 ) 

2589 monkeypatch.setattr( 1G

2590 vault.Vault, 'phrase_from_key', tests.phrase_from_key 

2591 ) 

2592 result = runner.invoke( 1G

2593 cli.derivepassphrase_vault, 

2594 ['-k', '--', DUMMY_SERVICE], 

2595 input='1\n', 

2596 catch_exceptions=False, 

2597 ) 

2598 assert result.clean_exit(), 'expected clean exit' 1G

2599 assert result.stdout, 'expected program output' 1G

2600 last_line = result.stdout.splitlines(True)[-1] 1G

2601 assert ( 1G

2602 last_line.rstrip('\n').encode('UTF-8') != DUMMY_RESULT_PASSPHRASE 

2603 ), 'known false output: phrase-based instead of key-based' 

2604 assert last_line.rstrip('\n').encode('UTF-8') == DUMMY_RESULT_KEY1, ( 1G

2605 'expected known output' 

2606 ) 

2607 

2608 @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS 

2609 @Parametrize.KEY_INDEX 

2610 def test_204c_key_override_on_command_line( 

2611 self, 

2612 running_ssh_agent: tests.RunningSSHAgentInfo, 

2613 config: dict[str, Any], 

2614 key_index: int, 

2615 ) -> None: 

2616 """A command-line SSH key will override the configured key.""" 

2617 del running_ssh_agent 1L

2618 runner = tests.CliRunner(mix_stderr=False) 1L

2619 # TODO(the-13th-letter): Rewrite using parenthesized 

2620 # with-statements. 

2621 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2622 with contextlib.ExitStack() as stack: 1L

2623 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1L

2624 stack.enter_context( 1L

2625 tests.isolated_vault_config( 

2626 monkeypatch=monkeypatch, 

2627 runner=runner, 

2628 vault_config=config, 

2629 ) 

2630 ) 

2631 monkeypatch.setattr( 1L

2632 ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys 

2633 ) 

2634 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', tests.sign) 1L

2635 result = runner.invoke( 1L

2636 cli.derivepassphrase_vault, 

2637 ['-k', '--', DUMMY_SERVICE], 

2638 input=f'{key_index}\n', 

2639 ) 

2640 assert result.clean_exit(), 'expected clean exit' 1L

2641 assert result.stdout, 'expected program output' 1L

2642 assert result.stderr, 'expected stderr' 1L

2643 assert 'Error:' not in result.stderr, ( 1L

2644 'expected no error messages on stderr' 

2645 ) 

2646 

2647 def test_205_service_phrase_if_key_in_global_config( 

2648 self, 

2649 running_ssh_agent: tests.RunningSSHAgentInfo, 

2650 ) -> None: 

2651 """A command-line passphrase will override the configured key.""" 

2652 del running_ssh_agent 1H

2653 runner = tests.CliRunner(mix_stderr=False) 1H

2654 # TODO(the-13th-letter): Rewrite using parenthesized 

2655 # with-statements. 

2656 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2657 with contextlib.ExitStack() as stack: 1H

2658 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1H

2659 stack.enter_context( 1H

2660 tests.isolated_vault_config( 

2661 monkeypatch=monkeypatch, 

2662 runner=runner, 

2663 vault_config={ 

2664 'global': {'key': DUMMY_KEY1_B64}, 

2665 'services': { 

2666 DUMMY_SERVICE: { 

2667 'phrase': DUMMY_PASSPHRASE.rstrip('\n'), 

2668 **DUMMY_CONFIG_SETTINGS, 

2669 } 

2670 }, 

2671 }, 

2672 ) 

2673 ) 

2674 monkeypatch.setattr( 1H

2675 ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys 

2676 ) 

2677 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', tests.sign) 1H

2678 result = runner.invoke( 1H

2679 cli.derivepassphrase_vault, 

2680 ['--', DUMMY_SERVICE], 

2681 catch_exceptions=False, 

2682 ) 

2683 assert result.clean_exit(), 'expected clean exit' 1H

2684 assert result.stdout, 'expected program output' 1H

2685 last_line = result.stdout.splitlines(True)[-1] 1H

2686 assert ( 1H

2687 last_line.rstrip('\n').encode('UTF-8') != DUMMY_RESULT_PASSPHRASE 

2688 ), 'known false output: phrase-based instead of key-based' 

2689 assert last_line.rstrip('\n').encode('UTF-8') == DUMMY_RESULT_KEY1, ( 1H

2690 'expected known output' 

2691 ) 

2692 

2693 @Parametrize.KEY_OVERRIDING_IN_CONFIG 

2694 def test_206_setting_phrase_thus_overriding_key_in_config( 

2695 self, 

2696 running_ssh_agent: tests.RunningSSHAgentInfo, 

2697 caplog: pytest.LogCaptureFixture, 

2698 config: _types.VaultConfig, 

2699 command_line: list[str], 

2700 ) -> None: 

2701 """Configuring a passphrase atop an SSH key works, but warns.""" 

2702 del running_ssh_agent 1v

2703 runner = tests.CliRunner(mix_stderr=False) 1v

2704 # TODO(the-13th-letter): Rewrite using parenthesized 

2705 # with-statements. 

2706 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2707 with contextlib.ExitStack() as stack: 1v

2708 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1v

2709 stack.enter_context( 1v

2710 tests.isolated_vault_config( 

2711 monkeypatch=monkeypatch, 

2712 runner=runner, 

2713 vault_config=config, 

2714 ) 

2715 ) 

2716 monkeypatch.setattr( 1v

2717 ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys 

2718 ) 

2719 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', tests.sign) 1v

2720 result = runner.invoke( 1v

2721 cli.derivepassphrase_vault, 

2722 command_line, 

2723 input=DUMMY_PASSPHRASE, 

2724 catch_exceptions=False, 

2725 ) 

2726 assert result.clean_exit(), 'expected clean exit' 1v

2727 assert not result.stdout.strip(), 'expected no program output' 1v

2728 assert result.stderr, 'expected known error output' 1v

2729 err_lines = result.stderr.splitlines(False) 1v

2730 assert err_lines[0].startswith('Passphrase:') 1v

2731 assert tests.warning_emitted( 1v

2732 'Setting a service passphrase is ineffective ', 

2733 caplog.record_tuples, 

2734 ) or tests.warning_emitted( 

2735 'Setting a global passphrase is ineffective ', 

2736 caplog.record_tuples, 

2737 ), 'expected known warning message' 

2738 assert all(map(is_warning_line, result.stderr.splitlines(True))) 1v

2739 assert all( 1v

2740 map(is_harmless_config_import_warning, caplog.record_tuples) 

2741 ), 'unexpected error output' 

2742 

2743 @hypothesis.given( 

2744 notes=strategies.text( 

2745 strategies.characters( 

2746 min_codepoint=32, 

2747 max_codepoint=126, 

2748 include_characters='\n', 

2749 ), 

2750 max_size=256, 

2751 ), 

2752 ) 

2753 def test_207_service_with_notes_actually_prints_notes( 

2754 self, 

2755 notes: str, 

2756 ) -> None: 

2757 """Service notes are printed, if they exist.""" 

2758 hypothesis.assume('Error:' not in notes) 1M

2759 runner = tests.CliRunner(mix_stderr=False) 1M

2760 # TODO(the-13th-letter): Rewrite using parenthesized 

2761 # with-statements. 

2762 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2763 with contextlib.ExitStack() as stack: 1M

2764 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1M

2765 stack.enter_context( 1M

2766 tests.isolated_vault_config( 

2767 monkeypatch=monkeypatch, 

2768 runner=runner, 

2769 vault_config={ 

2770 'global': { 

2771 'phrase': DUMMY_PASSPHRASE, 

2772 }, 

2773 'services': { 

2774 DUMMY_SERVICE: { 

2775 'notes': notes, 

2776 **DUMMY_CONFIG_SETTINGS, 

2777 }, 

2778 }, 

2779 }, 

2780 ) 

2781 ) 

2782 result = runner.invoke( 1M

2783 cli.derivepassphrase_vault, 

2784 ['--', DUMMY_SERVICE], 

2785 ) 

2786 assert result.clean_exit(), 'expected clean exit' 1M

2787 assert result.stdout, 'expected program output' 1M

2788 assert result.stdout.strip() == DUMMY_RESULT_PASSPHRASE.decode( 1M

2789 'ascii' 

2790 ), 'expected known program output' 

2791 assert result.stderr or not notes.strip(), 'expected stderr' 1M

2792 assert 'Error:' not in result.stderr, ( 1M

2793 'expected no error messages on stderr' 

2794 ) 

2795 assert result.stderr.strip() == notes.strip(), ( 1M

2796 'expected known stderr contents' 

2797 ) 

2798 

2799 @Parametrize.VAULT_CHARSET_OPTION 

2800 def test_210_invalid_argument_range( 

2801 self, 

2802 option: str, 

2803 ) -> None: 

2804 """Requesting invalidly many characters from a class fails.""" 

2805 runner = tests.CliRunner(mix_stderr=False) 1/

2806 # TODO(the-13th-letter): Rewrite using parenthesized 

2807 # with-statements. 

2808 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2809 with contextlib.ExitStack() as stack: 1/

2810 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1/

2811 stack.enter_context( 1/

2812 tests.isolated_config( 

2813 monkeypatch=monkeypatch, 

2814 runner=runner, 

2815 ) 

2816 ) 

2817 for value in '-42', 'invalid': 1/

2818 result = runner.invoke( 1/

2819 cli.derivepassphrase_vault, 

2820 [option, value, '-p', '--', DUMMY_SERVICE], 

2821 input=DUMMY_PASSPHRASE, 

2822 catch_exceptions=False, 

2823 ) 

2824 assert result.error_exit(error='Invalid value'), ( 1/

2825 'expected error exit and known error message' 

2826 ) 

2827 

2828 @Parametrize.OPTION_COMBINATIONS_SERVICE_NEEDED 

2829 def test_211_service_needed( 

2830 self, 

2831 options: list[str], 

2832 service: bool | None, 

2833 input: str | None, 

2834 check_success: bool, 

2835 ) -> None: 

2836 """We require or forbid a service argument, depending on options.""" 

2837 runner = tests.CliRunner(mix_stderr=False) 1A

2838 # TODO(the-13th-letter): Rewrite using parenthesized 

2839 # with-statements. 

2840 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2841 with contextlib.ExitStack() as stack: 1A

2842 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1A

2843 stack.enter_context( 1A

2844 tests.isolated_vault_config( 

2845 monkeypatch=monkeypatch, 

2846 runner=runner, 

2847 vault_config={'global': {'phrase': 'abc'}, 'services': {}}, 

2848 ) 

2849 ) 

2850 monkeypatch.setattr( 1A

2851 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt 

2852 ) 

2853 result = runner.invoke( 1A

2854 cli.derivepassphrase_vault, 

2855 options if service else [*options, '--', DUMMY_SERVICE], 

2856 input=input, 

2857 catch_exceptions=False, 

2858 ) 

2859 if service is not None: 1A

2860 err_msg = ( 1A

2861 ' requires a SERVICE' 

2862 if service 

2863 else ' does not take a SERVICE argument' 

2864 ) 

2865 assert result.error_exit(error=err_msg), ( 1A

2866 'expected error exit and known error message' 

2867 ) 

2868 else: 

2869 assert result.clean_exit(empty_stderr=True), ( 1A

2870 'expected clean exit' 

2871 ) 

2872 if check_success: 1A

2873 # TODO(the-13th-letter): Rewrite using parenthesized 

2874 # with-statements. 

2875 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2876 with contextlib.ExitStack() as stack: 1A

2877 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1A

2878 stack.enter_context( 1A

2879 tests.isolated_vault_config( 

2880 monkeypatch=monkeypatch, 

2881 runner=runner, 

2882 vault_config={ 

2883 'global': {'phrase': 'abc'}, 

2884 'services': {}, 

2885 }, 

2886 ) 

2887 ) 

2888 monkeypatch.setattr( 1A

2889 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt 

2890 ) 

2891 result = runner.invoke( 1A

2892 cli.derivepassphrase_vault, 

2893 [*options, '--', DUMMY_SERVICE] if service else options, 

2894 input=input, 

2895 catch_exceptions=False, 

2896 ) 

2897 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1A

2898 

2899 def test_211a_empty_service_name_causes_warning( 

2900 self, 

2901 caplog: pytest.LogCaptureFixture, 

2902 ) -> None: 

2903 """Using an empty service name (where permissible) warns. 

2904 

2905 Only the `--config` option can optionally take a service name. 

2906 

2907 """ 

2908 

2909 def is_expected_warning(record: tuple[str, int, str]) -> bool: 1t

2910 return is_harmless_config_import_warning( 1t

2911 record 

2912 ) or tests.warning_emitted( 

2913 'An empty SERVICE is not supported by vault(1)', [record] 

2914 ) 

2915 

2916 runner = tests.CliRunner(mix_stderr=False) 1t

2917 # TODO(the-13th-letter): Rewrite using parenthesized 

2918 # with-statements. 

2919 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2920 with contextlib.ExitStack() as stack: 1t

2921 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1t

2922 stack.enter_context( 1t

2923 tests.isolated_vault_config( 

2924 monkeypatch=monkeypatch, 

2925 runner=runner, 

2926 vault_config={'services': {}}, 

2927 ) 

2928 ) 

2929 monkeypatch.setattr( 1t

2930 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt 

2931 ) 

2932 result = runner.invoke( 1t

2933 cli.derivepassphrase_vault, 

2934 ['--config', '--length=30', '--', ''], 

2935 catch_exceptions=False, 

2936 ) 

2937 assert result.clean_exit(empty_stderr=False), 'expected clean exit' 1t

2938 assert result.stderr is not None, 'expected known error output' 1t

2939 assert all(map(is_expected_warning, caplog.record_tuples)), ( 1t

2940 'expected known error output' 

2941 ) 

2942 assert cli_helpers.load_config() == { 1t

2943 'global': {'length': 30}, 

2944 'services': {}, 

2945 }, 'requested configuration change was not applied' 

2946 caplog.clear() 1t

2947 result = runner.invoke( 1t

2948 cli.derivepassphrase_vault, 

2949 ['--import', '-'], 

2950 input=json.dumps({'services': {'': {'length': 40}}}), 

2951 catch_exceptions=False, 

2952 ) 

2953 assert result.clean_exit(empty_stderr=False), 'expected clean exit' 1t

2954 assert result.stderr is not None, 'expected known error output' 1t

2955 assert all(map(is_expected_warning, caplog.record_tuples)), ( 1t

2956 'expected known error output' 

2957 ) 

2958 assert cli_helpers.load_config() == { 1t

2959 'global': {'length': 30}, 

2960 'services': {'': {'length': 40}}, 

2961 }, 'requested configuration change was not applied' 

2962 

2963 @Parametrize.OPTION_COMBINATIONS_INCOMPATIBLE 

2964 def test_212_incompatible_options( 

2965 self, 

2966 options: list[str], 

2967 service: bool | None, 

2968 ) -> None: 

2969 """Incompatible options are detected.""" 

2970 runner = tests.CliRunner(mix_stderr=False) 1}

2971 # TODO(the-13th-letter): Rewrite using parenthesized 

2972 # with-statements. 

2973 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

2974 with contextlib.ExitStack() as stack: 1}

2975 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1}

2976 stack.enter_context( 1}

2977 tests.isolated_config( 

2978 monkeypatch=monkeypatch, 

2979 runner=runner, 

2980 ) 

2981 ) 

2982 result = runner.invoke( 1}

2983 cli.derivepassphrase_vault, 

2984 [*options, '--', DUMMY_SERVICE] if service else options, 

2985 input=DUMMY_PASSPHRASE, 

2986 catch_exceptions=False, 

2987 ) 

2988 assert result.error_exit(error='mutually exclusive with '), ( 1}

2989 'expected error exit and known error message' 

2990 ) 

2991 

2992 @Parametrize.VALID_TEST_CONFIGS 

2993 def test_213_import_config_success( 

2994 self, 

2995 caplog: pytest.LogCaptureFixture, 

2996 config: Any, 

2997 ) -> None: 

2998 """Importing a configuration works.""" 

2999 runner = tests.CliRunner(mix_stderr=False) 1C

3000 # TODO(the-13th-letter): Rewrite using parenthesized 

3001 # with-statements. 

3002 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3003 with contextlib.ExitStack() as stack: 1C

3004 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1C

3005 stack.enter_context( 1C

3006 tests.isolated_vault_config( 

3007 monkeypatch=monkeypatch, 

3008 runner=runner, 

3009 vault_config={'services': {}}, 

3010 ) 

3011 ) 

3012 result = runner.invoke( 1C

3013 cli.derivepassphrase_vault, 

3014 ['--import', '-'], 

3015 input=json.dumps(config), 

3016 catch_exceptions=False, 

3017 ) 

3018 config_txt = cli_helpers.config_filename( 1C

3019 subsystem='vault' 

3020 ).read_text(encoding='UTF-8') 

3021 config2 = json.loads(config_txt) 1C

3022 assert result.clean_exit(empty_stderr=False), 'expected clean exit' 1C

3023 assert config2 == config, 'config not imported correctly' 1C

3024 assert not result.stderr or all( # pragma: no branch 1C

3025 map(is_harmless_config_import_warning, caplog.record_tuples) 

3026 ), 'unexpected error output' 

3027 assert_vault_config_is_indented_and_line_broken(config_txt) 1C

3028 

3029 @hypothesis.settings( 

3030 suppress_health_check=[ 

3031 *hypothesis.settings().suppress_health_check, 

3032 hypothesis.HealthCheck.function_scoped_fixture, 

3033 ], 

3034 ) 

3035 @hypothesis.given( 

3036 conf=tests.smudged_vault_test_config( 

3037 strategies.sampled_from([ 

3038 conf 

3039 for conf in tests.TEST_CONFIGS 

3040 if tests.is_valid_test_config(conf) 

3041 ]) 

3042 ) 

3043 ) 

3044 def test_213a_import_config_success( 

3045 self, 

3046 caplog: pytest.LogCaptureFixture, 

3047 conf: tests.VaultTestConfig, 

3048 ) -> None: 

3049 """Importing a smudged configuration works. 

3050 

3051 Tested via hypothesis. 

3052 

3053 """ 

3054 config = conf.config 1w

3055 config2 = copy.deepcopy(config) 1w

3056 _types.clean_up_falsy_vault_config_values(config2) 1w

3057 # Reset caplog between hypothesis runs. 

3058 caplog.clear() 1w

3059 runner = tests.CliRunner(mix_stderr=False) 1w

3060 # TODO(the-13th-letter): Rewrite using parenthesized 

3061 # with-statements. 

3062 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3063 with contextlib.ExitStack() as stack: 1w

3064 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1w

3065 stack.enter_context( 1w

3066 tests.isolated_vault_config( 

3067 monkeypatch=monkeypatch, 

3068 runner=runner, 

3069 vault_config={'services': {}}, 

3070 ) 

3071 ) 

3072 result = runner.invoke( 1w

3073 cli.derivepassphrase_vault, 

3074 ['--import', '-'], 

3075 input=json.dumps(config), 

3076 catch_exceptions=False, 

3077 ) 

3078 config_txt = cli_helpers.config_filename( 1w

3079 subsystem='vault' 

3080 ).read_text(encoding='UTF-8') 

3081 config3 = json.loads(config_txt) 1w

3082 assert result.clean_exit(empty_stderr=False), 'expected clean exit' 1w

3083 assert config3 == config2, 'config not imported correctly' 1w

3084 assert not result.stderr or all( 1w

3085 map(is_harmless_config_import_warning, caplog.record_tuples) 

3086 ), 'unexpected error output' 

3087 assert_vault_config_is_indented_and_line_broken(config_txt) 1w

3088 

3089 def test_213b_import_bad_config_not_vault_config( 

3090 self, 

3091 ) -> None: 

3092 """Importing an invalid config fails.""" 

3093 runner = tests.CliRunner(mix_stderr=False) 1~

3094 # TODO(the-13th-letter): Rewrite using parenthesized 

3095 # with-statements. 

3096 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3097 with contextlib.ExitStack() as stack: 1~

3098 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1~

3099 stack.enter_context( 1~

3100 tests.isolated_config( 

3101 monkeypatch=monkeypatch, 

3102 runner=runner, 

3103 ) 

3104 ) 

3105 result = runner.invoke( 1~

3106 cli.derivepassphrase_vault, 

3107 ['--import', '-'], 

3108 input='null', 

3109 catch_exceptions=False, 

3110 ) 

3111 assert result.error_exit(error='Invalid vault config'), ( 1~

3112 'expected error exit and known error message' 

3113 ) 

3114 

3115 def test_213c_import_bad_config_not_json_data( 

3116 self, 

3117 ) -> None: 

3118 """Importing an invalid config fails.""" 

3119 runner = tests.CliRunner(mix_stderr=False) 2ab

3120 # TODO(the-13th-letter): Rewrite using parenthesized 

3121 # with-statements. 

3122 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3123 with contextlib.ExitStack() as stack: 2ab

3124 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2ab

3125 stack.enter_context( 2ab

3126 tests.isolated_config( 

3127 monkeypatch=monkeypatch, 

3128 runner=runner, 

3129 ) 

3130 ) 

3131 result = runner.invoke( 2ab

3132 cli.derivepassphrase_vault, 

3133 ['--import', '-'], 

3134 input='This string is not valid JSON.', 

3135 catch_exceptions=False, 

3136 ) 

3137 assert result.error_exit(error='cannot decode JSON'), ( 2ab

3138 'expected error exit and known error message' 

3139 ) 

3140 

3141 def test_213d_import_bad_config_not_a_file( 

3142 self, 

3143 ) -> None: 

3144 """Importing an invalid config fails.""" 

3145 runner = tests.CliRunner(mix_stderr=False) 1$

3146 # `isolated_vault_config` ensures the configuration is valid 

3147 # JSON. So, to pass an actual broken configuration, we must 

3148 # open the configuration file ourselves afterwards, inside the 

3149 # context. 

3150 # 

3151 # TODO(the-13th-letter): Rewrite using parenthesized 

3152 # with-statements. 

3153 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3154 with contextlib.ExitStack() as stack: 1$

3155 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1$

3156 stack.enter_context( 1$

3157 tests.isolated_vault_config( 

3158 monkeypatch=monkeypatch, 

3159 runner=runner, 

3160 vault_config={'services': {}}, 

3161 ) 

3162 ) 

3163 cli_helpers.config_filename(subsystem='vault').write_text( 1$

3164 'This string is not valid JSON.\n', encoding='UTF-8' 

3165 ) 

3166 dname = cli_helpers.config_filename(subsystem=None) 1$

3167 result = runner.invoke( 1$

3168 cli.derivepassphrase_vault, 

3169 ['--import', os.fsdecode(dname)], 

3170 catch_exceptions=False, 

3171 ) 

3172 # The Annoying OS uses EACCES, other OSes use EISDIR. 

3173 assert result.error_exit( 1$

3174 error=os.strerror(errno.EISDIR) 

3175 ) or result.error_exit(error=os.strerror(errno.EACCES)), ( 

3176 'expected error exit and known error message' 

3177 ) 

3178 

3179 @Parametrize.VALID_TEST_CONFIGS 

3180 def test_214_export_config_success( 

3181 self, 

3182 caplog: pytest.LogCaptureFixture, 

3183 config: Any, 

3184 ) -> None: 

3185 """Exporting a configuration works.""" 

3186 runner = tests.CliRunner(mix_stderr=False) 1D

3187 # TODO(the-13th-letter): Rewrite using parenthesized 

3188 # with-statements. 

3189 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3190 with contextlib.ExitStack() as stack: 1D

3191 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1D

3192 stack.enter_context( 1D

3193 tests.isolated_vault_config( 

3194 monkeypatch=monkeypatch, 

3195 runner=runner, 

3196 vault_config=config, 

3197 ) 

3198 ) 

3199 with cli_helpers.config_filename(subsystem='vault').open( 1D

3200 'w', encoding='UTF-8' 

3201 ) as outfile: 

3202 # Ensure the config is written on one line. 

3203 json.dump(config, outfile, indent=None) 1D

3204 result = runner.invoke( 1D

3205 cli.derivepassphrase_vault, 

3206 ['--export', '-'], 

3207 catch_exceptions=False, 

3208 ) 

3209 with cli_helpers.config_filename(subsystem='vault').open( 1D

3210 encoding='UTF-8' 

3211 ) as infile: 

3212 config2 = json.load(infile) 1D

3213 assert result.clean_exit(empty_stderr=False), 'expected clean exit' 1D

3214 assert config2 == config, 'config not imported correctly' 1D

3215 assert not result.stderr or all( # pragma: no branch 1D

3216 map(is_harmless_config_import_warning, caplog.record_tuples) 

3217 ), 'unexpected error output' 

3218 assert_vault_config_is_indented_and_line_broken(result.stdout) 1D

3219 

3220 @Parametrize.EXPORT_FORMAT_OPTIONS 

3221 def test_214a_export_settings_no_stored_settings( 

3222 self, 

3223 export_options: list[str], 

3224 ) -> None: 

3225 """Exporting the default, empty config works.""" 

3226 runner = tests.CliRunner(mix_stderr=False) 1:

3227 # TODO(the-13th-letter): Rewrite using parenthesized 

3228 # with-statements. 

3229 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3230 with contextlib.ExitStack() as stack: 1:

3231 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1:

3232 stack.enter_context( 1:

3233 tests.isolated_config( 

3234 monkeypatch=monkeypatch, 

3235 runner=runner, 

3236 ) 

3237 ) 

3238 cli_helpers.config_filename(subsystem='vault').unlink( 1:

3239 missing_ok=True 

3240 ) 

3241 result = runner.invoke( 1:

3242 # Test parent context navigation by not calling 

3243 # `cli.derivepassphrase_vault` directly. Used e.g. in 

3244 # the `--export-as=sh` section to autoconstruct the 

3245 # program name correctly. 

3246 cli.derivepassphrase, 

3247 ['vault', '--export', '-', *export_options], 

3248 catch_exceptions=False, 

3249 ) 

3250 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1:

3251 

3252 @Parametrize.EXPORT_FORMAT_OPTIONS 

3253 def test_214b_export_settings_bad_stored_config( 

3254 self, 

3255 export_options: list[str], 

3256 ) -> None: 

3257 """Exporting an invalid config fails.""" 

3258 runner = tests.CliRunner(mix_stderr=False) 2bb

3259 # TODO(the-13th-letter): Rewrite using parenthesized 

3260 # with-statements. 

3261 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3262 with contextlib.ExitStack() as stack: 2bb

3263 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2bb

3264 stack.enter_context( 2bb

3265 tests.isolated_vault_config( 

3266 monkeypatch=monkeypatch, 

3267 runner=runner, 

3268 vault_config={}, 

3269 ) 

3270 ) 

3271 result = runner.invoke( 2bb

3272 cli.derivepassphrase_vault, 

3273 ['--export', '-', *export_options], 

3274 input='null', 

3275 catch_exceptions=False, 

3276 ) 

3277 assert result.error_exit(error='Cannot load vault settings:'), ( 2bb

3278 'expected error exit and known error message' 

3279 ) 

3280 

3281 @Parametrize.EXPORT_FORMAT_OPTIONS 

3282 def test_214c_export_settings_not_a_file( 

3283 self, 

3284 export_options: list[str], 

3285 ) -> None: 

3286 """Exporting an invalid config fails.""" 

3287 runner = tests.CliRunner(mix_stderr=False) 17

3288 # TODO(the-13th-letter): Rewrite using parenthesized 

3289 # with-statements. 

3290 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3291 with contextlib.ExitStack() as stack: 17

3292 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 17

3293 stack.enter_context( 17

3294 tests.isolated_config( 

3295 monkeypatch=monkeypatch, 

3296 runner=runner, 

3297 ) 

3298 ) 

3299 config_file = cli_helpers.config_filename(subsystem='vault') 17

3300 config_file.unlink(missing_ok=True) 17

3301 config_file.mkdir(parents=True, exist_ok=True) 17

3302 result = runner.invoke( 17

3303 cli.derivepassphrase_vault, 

3304 ['--export', '-', *export_options], 

3305 input='null', 

3306 catch_exceptions=False, 

3307 ) 

3308 assert result.error_exit(error='Cannot load vault settings:'), ( 17

3309 'expected error exit and known error message' 

3310 ) 

3311 

3312 @Parametrize.EXPORT_FORMAT_OPTIONS 

3313 def test_214d_export_settings_target_not_a_file( 

3314 self, 

3315 export_options: list[str], 

3316 ) -> None: 

3317 """Exporting an invalid config fails.""" 

3318 runner = tests.CliRunner(mix_stderr=False) 1;

3319 # TODO(the-13th-letter): Rewrite using parenthesized 

3320 # with-statements. 

3321 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3322 with contextlib.ExitStack() as stack: 1;

3323 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1;

3324 stack.enter_context( 1;

3325 tests.isolated_config( 

3326 monkeypatch=monkeypatch, 

3327 runner=runner, 

3328 ) 

3329 ) 

3330 dname = cli_helpers.config_filename(subsystem=None) 1;

3331 result = runner.invoke( 1;

3332 cli.derivepassphrase_vault, 

3333 ['--export', os.fsdecode(dname), *export_options], 

3334 input='null', 

3335 catch_exceptions=False, 

3336 ) 

3337 assert result.error_exit(error='Cannot export vault settings:'), ( 1;

3338 'expected error exit and known error message' 

3339 ) 

3340 

3341 @tests.skip_if_on_the_annoying_os 

3342 @Parametrize.EXPORT_FORMAT_OPTIONS 

3343 def test_214e_export_settings_settings_directory_not_a_directory( 

3344 self, 

3345 export_options: list[str], 

3346 ) -> None: 

3347 """Exporting an invalid config fails.""" 

3348 runner = tests.CliRunner(mix_stderr=False) 

3349 # TODO(the-13th-letter): Rewrite using parenthesized 

3350 # with-statements. 

3351 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3352 with contextlib.ExitStack() as stack: 

3353 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 

3354 stack.enter_context( 

3355 tests.isolated_config( 

3356 monkeypatch=monkeypatch, 

3357 runner=runner, 

3358 ) 

3359 ) 

3360 config_dir = cli_helpers.config_filename(subsystem=None) 

3361 with contextlib.suppress(FileNotFoundError): 

3362 shutil.rmtree(config_dir) 

3363 config_dir.write_text('Obstruction!!\n') 

3364 result = runner.invoke( 

3365 cli.derivepassphrase_vault, 

3366 ['--export', '-', *export_options], 

3367 input='null', 

3368 catch_exceptions=False, 

3369 ) 

3370 assert result.error_exit( 

3371 error='Cannot load vault settings:' 

3372 ) or result.error_exit(error='Cannot load user config:'), ( 

3373 'expected error exit and known error message' 

3374 ) 

3375 

3376 @Parametrize.NOTES_PLACEMENT 

3377 @hypothesis.given( 1aN

3378 notes=strategies.text( 

3379 strategies.characters( 

3380 min_codepoint=32, max_codepoint=126, include_characters='\n' 

3381 ), 

3382 min_size=1, 

3383 max_size=512, 

3384 ).filter(str.strip), 

3385 ) 

3386 def test_215_notes_placement( 

3387 self, 

3388 notes_placement: Literal['before', 'after'], 

3389 placement_args: list[str], 

3390 notes: str, 

3391 ) -> None: 

3392 notes = notes.strip() 1N

3393 maybe_notes = {'notes': notes} if notes else {} 1N

3394 vault_config = { 1N

3395 'global': {'phrase': DUMMY_PASSPHRASE}, 

3396 'services': { 

3397 DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS} 

3398 }, 

3399 } 

3400 result_phrase = DUMMY_RESULT_PASSPHRASE.decode('ascii') 1N

3401 expected = ( 1N

3402 f'{notes}\n\n{result_phrase}\n' 

3403 if notes_placement == 'before' 

3404 else f'{result_phrase}\n\n{notes}\n\n' 

3405 ) 

3406 runner = tests.CliRunner(mix_stderr=True) 1N

3407 # TODO(the-13th-letter): Rewrite using parenthesized 

3408 # with-statements. 

3409 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3410 with contextlib.ExitStack() as stack: 1N

3411 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1N

3412 stack.enter_context( 1N

3413 tests.isolated_vault_config( 

3414 monkeypatch=monkeypatch, 

3415 runner=runner, 

3416 vault_config=vault_config, 

3417 ) 

3418 ) 

3419 result = runner.invoke( 1N

3420 cli.derivepassphrase_vault, 

3421 [*placement_args, '--', DUMMY_SERVICE], 

3422 catch_exceptions=False, 

3423 ) 

3424 assert result.clean_exit(output=expected), 'expected clean exit' 1N

3425 

3426 @Parametrize.MODERN_EDITOR_INTERFACE 

3427 @hypothesis.settings( 1ar

3428 suppress_health_check=[ 

3429 *hypothesis.settings().suppress_health_check, 

3430 hypothesis.HealthCheck.function_scoped_fixture, 

3431 ], 

3432 ) 

3433 @hypothesis.given( 

3434 notes=strategies.text( 

3435 strategies.characters( 

3436 min_codepoint=32, max_codepoint=126, include_characters='\n' 

3437 ), 

3438 min_size=1, 

3439 max_size=512, 

3440 ).filter(str.strip), 

3441 ) 

3442 def test_220_edit_notes_successfully( 

3443 self, 

3444 caplog: pytest.LogCaptureFixture, 

3445 modern_editor_interface: bool, 

3446 notes: str, 

3447 ) -> None: 

3448 """Editing notes works.""" 

3449 marker = cli_messages.TranslatedString( 1r

3450 cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER 

3451 ) 

3452 edit_result = f""" 1r

3453 

3454{marker} 

3455{notes} 

3456""" 

3457 # Reset caplog between hypothesis runs. 

3458 caplog.clear() 1r

3459 runner = tests.CliRunner(mix_stderr=False) 1r

3460 # TODO(the-13th-letter): Rewrite using parenthesized 

3461 # with-statements. 

3462 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3463 with contextlib.ExitStack() as stack: 1r

3464 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1r

3465 stack.enter_context( 1r

3466 tests.isolated_vault_config( 

3467 monkeypatch=monkeypatch, 

3468 runner=runner, 

3469 vault_config={ 

3470 'global': {'phrase': 'abc'}, 

3471 'services': {'sv': {'notes': 'Contents go here'}}, 

3472 }, 

3473 ) 

3474 ) 

3475 notes_backup_file = cli_helpers.config_filename( 1r

3476 subsystem='notes backup' 

3477 ) 

3478 notes_backup_file.write_text( 1r

3479 'These backup notes are left over from the previous session.', 

3480 encoding='UTF-8', 

3481 ) 

3482 monkeypatch.setattr(click, 'edit', lambda *_a, **_kw: edit_result) 1r

3483 result = runner.invoke( 1r

3484 cli.derivepassphrase_vault, 

3485 [ 

3486 '--config', 

3487 '--notes', 

3488 '--modern-editor-interface' 

3489 if modern_editor_interface 

3490 else '--vault-legacy-editor-interface', 

3491 '--', 

3492 'sv', 

3493 ], 

3494 catch_exceptions=False, 

3495 ) 

3496 assert result.clean_exit(), 'expected clean exit' 1r

3497 assert all(map(is_warning_line, result.stderr.splitlines(True))) 1r

3498 assert modern_editor_interface or tests.warning_emitted( 1r

3499 'A backup copy of the old notes was saved', 

3500 caplog.record_tuples, 

3501 ), 'expected known warning message in stderr' 

3502 assert ( 1r

3503 modern_editor_interface 

3504 or notes_backup_file.read_text(encoding='UTF-8') 

3505 == 'Contents go here' 

3506 ) 

3507 with cli_helpers.config_filename(subsystem='vault').open( 1r

3508 encoding='UTF-8' 

3509 ) as infile: 

3510 config = json.load(infile) 1r

3511 assert config == { 1r

3512 'global': {'phrase': 'abc'}, 

3513 'services': { 

3514 'sv': { 

3515 'notes': notes.strip() 

3516 if modern_editor_interface 

3517 else edit_result.strip() 

3518 } 

3519 }, 

3520 } 

3521 

3522 @Parametrize.NOOP_EDIT_FUNCS 

3523 @hypothesis.given( 1aq

3524 notes=strategies.text( 

3525 strategies.characters( 

3526 min_codepoint=32, max_codepoint=126, include_characters='\n' 

3527 ), 

3528 min_size=1, 

3529 max_size=512, 

3530 ).filter(str.strip), 

3531 ) 

3532 def test_221_edit_notes_noop( 

3533 self, 

3534 edit_func_name: Literal['empty', 'space'], 

3535 modern_editor_interface: bool, 

3536 notes: str, 

3537 ) -> None: 

3538 """Abandoning edited notes works.""" 

3539 

3540 def empty(text: str, *_args: Any, **_kwargs: Any) -> str: 1q

3541 del text 1q

3542 return '' 1q

3543 

3544 def space(text: str, *_args: Any, **_kwargs: Any) -> str: 1q

3545 del text 1q

3546 return ' ' + notes.strip() + '\n\n\n\n\n\n' 1q

3547 

3548 edit_funcs = {'empty': empty, 'space': space} 1q

3549 runner = tests.CliRunner(mix_stderr=False) 1q

3550 # TODO(the-13th-letter): Rewrite using parenthesized 

3551 # with-statements. 

3552 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3553 with contextlib.ExitStack() as stack: 1q

3554 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1q

3555 stack.enter_context( 1q

3556 tests.isolated_vault_config( 

3557 monkeypatch=monkeypatch, 

3558 runner=runner, 

3559 vault_config={ 

3560 'global': {'phrase': 'abc'}, 

3561 'services': {'sv': {'notes': notes.strip()}}, 

3562 }, 

3563 ) 

3564 ) 

3565 notes_backup_file = cli_helpers.config_filename( 1q

3566 subsystem='notes backup' 

3567 ) 

3568 notes_backup_file.write_text( 1q

3569 'These backup notes are left over from the previous session.', 

3570 encoding='UTF-8', 

3571 ) 

3572 monkeypatch.setattr(click, 'edit', edit_funcs[edit_func_name]) 1q

3573 result = runner.invoke( 1q

3574 cli.derivepassphrase_vault, 

3575 [ 

3576 '--config', 

3577 '--notes', 

3578 '--modern-editor-interface' 

3579 if modern_editor_interface 

3580 else '--vault-legacy-editor-interface', 

3581 '--', 

3582 'sv', 

3583 ], 

3584 catch_exceptions=False, 

3585 ) 

3586 assert result.clean_exit(empty_stderr=True) or result.error_exit( 1q

3587 error='the user aborted the request' 

3588 ), 'expected clean exit' 

3589 assert ( 1q

3590 modern_editor_interface 

3591 or notes_backup_file.read_text(encoding='UTF-8') 

3592 == 'These backup notes are left over from the previous session.' 

3593 ) 

3594 with cli_helpers.config_filename(subsystem='vault').open( 1q

3595 encoding='UTF-8' 

3596 ) as infile: 

3597 config = json.load(infile) 1q

3598 assert config == { 1q

3599 'global': {'phrase': 'abc'}, 

3600 'services': {'sv': {'notes': notes.strip()}}, 

3601 } 

3602 

3603 # TODO(the-13th-letter): Keep this behavior or not, with or without 

3604 # warning? 

3605 @Parametrize.MODERN_EDITOR_INTERFACE 

3606 @hypothesis.settings( 1as

3607 suppress_health_check=[ 

3608 *hypothesis.settings().suppress_health_check, 

3609 hypothesis.HealthCheck.function_scoped_fixture, 

3610 ], 

3611 ) 

3612 @hypothesis.given( 

3613 notes=strategies.text( 

3614 strategies.characters( 

3615 min_codepoint=32, max_codepoint=126, include_characters='\n' 

3616 ), 

3617 min_size=1, 

3618 max_size=512, 

3619 ).filter(str.strip), 

3620 ) 

3621 def test_222_edit_notes_marker_removed( 

3622 self, 

3623 caplog: pytest.LogCaptureFixture, 

3624 modern_editor_interface: bool, 

3625 notes: str, 

3626 ) -> None: 

3627 """Removing the notes marker still saves the notes. 

3628 

3629 TODO: Keep this behavior or not, with or without warning? 

3630 

3631 """ 

3632 notes_marker = cli_messages.TranslatedString( 1s

3633 cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER 

3634 ) 

3635 hypothesis.assume(str(notes_marker) not in notes.strip()) 1s

3636 # Reset caplog between hypothesis runs. 

3637 caplog.clear() 1s

3638 runner = tests.CliRunner(mix_stderr=False) 1s

3639 # TODO(the-13th-letter): Rewrite using parenthesized 

3640 # with-statements. 

3641 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3642 with contextlib.ExitStack() as stack: 1s

3643 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1s

3644 stack.enter_context( 1s

3645 tests.isolated_vault_config( 

3646 monkeypatch=monkeypatch, 

3647 runner=runner, 

3648 vault_config={ 

3649 'global': {'phrase': 'abc'}, 

3650 'services': {'sv': {'notes': 'Contents go here'}}, 

3651 }, 

3652 ) 

3653 ) 

3654 notes_backup_file = cli_helpers.config_filename( 1s

3655 subsystem='notes backup' 

3656 ) 

3657 notes_backup_file.write_text( 1s

3658 'These backup notes are left over from the previous session.', 

3659 encoding='UTF-8', 

3660 ) 

3661 monkeypatch.setattr(click, 'edit', lambda *_a, **_kw: notes) 1s

3662 result = runner.invoke( 1s

3663 cli.derivepassphrase_vault, 

3664 [ 

3665 '--config', 

3666 '--notes', 

3667 '--modern-editor-interface' 

3668 if modern_editor_interface 

3669 else '--vault-legacy-editor-interface', 

3670 '--', 

3671 'sv', 

3672 ], 

3673 catch_exceptions=False, 

3674 ) 

3675 assert result.clean_exit(), 'expected clean exit' 1s

3676 assert not result.stderr or all( 1s

3677 map(is_warning_line, result.stderr.splitlines(True)) 

3678 ) 

3679 assert not caplog.record_tuples or tests.warning_emitted( 1s

3680 'A backup copy of the old notes was saved', 

3681 caplog.record_tuples, 

3682 ), 'expected known warning message in stderr' 

3683 assert ( 1s

3684 modern_editor_interface 

3685 or notes_backup_file.read_text(encoding='UTF-8') 

3686 == 'Contents go here' 

3687 ) 

3688 with cli_helpers.config_filename(subsystem='vault').open( 1s

3689 encoding='UTF-8' 

3690 ) as infile: 

3691 config = json.load(infile) 1s

3692 assert config == { 1s

3693 'global': {'phrase': 'abc'}, 

3694 'services': {'sv': {'notes': notes.strip()}}, 

3695 } 

3696 

3697 @hypothesis.given( 

3698 notes=strategies.text( 

3699 strategies.characters( 

3700 min_codepoint=32, max_codepoint=126, include_characters='\n' 

3701 ), 

3702 min_size=1, 

3703 max_size=512, 

3704 ).filter(str.strip), 

3705 ) 

3706 def test_223_edit_notes_abort( 

3707 self, 

3708 notes: str, 

3709 ) -> None: 

3710 """Aborting editing notes works. 

3711 

3712 Aborting is only supported with the modern editor interface. 

3713 

3714 """ 

3715 runner = tests.CliRunner(mix_stderr=False) 1X

3716 # TODO(the-13th-letter): Rewrite using parenthesized 

3717 # with-statements. 

3718 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3719 with contextlib.ExitStack() as stack: 1X

3720 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1X

3721 stack.enter_context( 1X

3722 tests.isolated_vault_config( 

3723 monkeypatch=monkeypatch, 

3724 runner=runner, 

3725 vault_config={ 

3726 'global': {'phrase': 'abc'}, 

3727 'services': {'sv': {'notes': notes.strip()}}, 

3728 }, 

3729 ) 

3730 ) 

3731 monkeypatch.setattr(click, 'edit', lambda *_a, **_kw: '') 1X

3732 result = runner.invoke( 1X

3733 cli.derivepassphrase_vault, 

3734 [ 

3735 '--config', 

3736 '--notes', 

3737 '--modern-editor-interface', 

3738 '--', 

3739 'sv', 

3740 ], 

3741 catch_exceptions=False, 

3742 ) 

3743 assert result.error_exit(error='the user aborted the request'), ( 1X

3744 'expected known error message' 

3745 ) 

3746 with cli_helpers.config_filename(subsystem='vault').open( 1X

3747 encoding='UTF-8' 

3748 ) as infile: 

3749 config = json.load(infile) 1X

3750 assert config == { 1X

3751 'global': {'phrase': 'abc'}, 

3752 'services': {'sv': {'notes': notes.strip()}}, 

3753 } 

3754 

3755 def test_223a_edit_empty_notes_abort( 

3756 self, 

3757 ) -> None: 

3758 """Aborting editing notes works even if no notes are stored yet. 

3759 

3760 Aborting is only supported with the modern editor interface. 

3761 

3762 """ 

3763 runner = tests.CliRunner(mix_stderr=False) 1Y

3764 # TODO(the-13th-letter): Rewrite using parenthesized 

3765 # with-statements. 

3766 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3767 with contextlib.ExitStack() as stack: 1Y

3768 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1Y

3769 stack.enter_context( 1Y

3770 tests.isolated_vault_config( 

3771 monkeypatch=monkeypatch, 

3772 runner=runner, 

3773 vault_config={ 

3774 'global': {'phrase': 'abc'}, 

3775 'services': {}, 

3776 }, 

3777 ) 

3778 ) 

3779 monkeypatch.setattr(click, 'edit', lambda *_a, **_kw: '') 1Y

3780 result = runner.invoke( 1Y

3781 cli.derivepassphrase_vault, 

3782 [ 

3783 '--config', 

3784 '--notes', 

3785 '--modern-editor-interface', 

3786 '--', 

3787 'sv', 

3788 ], 

3789 catch_exceptions=False, 

3790 ) 

3791 assert result.error_exit(error='the user aborted the request'), ( 1Y

3792 'expected known error message' 

3793 ) 

3794 with cli_helpers.config_filename(subsystem='vault').open( 1Y

3795 encoding='UTF-8' 

3796 ) as infile: 

3797 config = json.load(infile) 1Y

3798 assert config == { 1Y

3799 'global': {'phrase': 'abc'}, 

3800 'services': {}, 

3801 } 

3802 

3803 @Parametrize.MODERN_EDITOR_INTERFACE 

3804 @hypothesis.settings( 1ao

3805 suppress_health_check=[ 

3806 *hypothesis.settings().suppress_health_check, 

3807 hypothesis.HealthCheck.function_scoped_fixture, 

3808 ], 

3809 ) 

3810 @hypothesis.given( 

3811 notes=strategies.text( 

3812 strategies.characters( 

3813 min_codepoint=32, max_codepoint=126, include_characters='\n' 

3814 ), 

3815 max_size=512, 

3816 ), 

3817 ) 

3818 def test_223b_edit_notes_fail_config_option_missing( 

3819 self, 

3820 caplog: pytest.LogCaptureFixture, 

3821 modern_editor_interface: bool, 

3822 notes: str, 

3823 ) -> None: 

3824 """Editing notes fails (and warns) if `--config` is missing.""" 

3825 maybe_notes = {'notes': notes.strip()} if notes.strip() else {} 1o

3826 vault_config = { 1o

3827 'global': {'phrase': DUMMY_PASSPHRASE}, 

3828 'services': { 

3829 DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS} 

3830 }, 

3831 } 

3832 # Reset caplog between hypothesis runs. 

3833 caplog.clear() 1o

3834 runner = tests.CliRunner(mix_stderr=False) 1o

3835 # TODO(the-13th-letter): Rewrite using parenthesized 

3836 # with-statements. 

3837 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3838 with contextlib.ExitStack() as stack: 1o

3839 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1o

3840 stack.enter_context( 1o

3841 tests.isolated_vault_config( 

3842 monkeypatch=monkeypatch, 

3843 runner=runner, 

3844 vault_config=vault_config, 

3845 ) 

3846 ) 

3847 EDIT_ATTEMPTED = 'edit attempted!' # noqa: N806 1o

3848 

3849 def raiser(*_args: Any, **_kwargs: Any) -> NoReturn: 1o

3850 pytest.fail(EDIT_ATTEMPTED) 

3851 

3852 notes_backup_file = cli_helpers.config_filename( 1o

3853 subsystem='notes backup' 

3854 ) 

3855 notes_backup_file.write_text( 1o

3856 'These backup notes are left over from the previous session.', 

3857 encoding='UTF-8', 

3858 ) 

3859 monkeypatch.setattr(click, 'edit', raiser) 1o

3860 result = runner.invoke( 1o

3861 cli.derivepassphrase_vault, 

3862 [ 

3863 '--notes', 

3864 '--modern-editor-interface' 

3865 if modern_editor_interface 

3866 else '--vault-legacy-editor-interface', 

3867 '--', 

3868 DUMMY_SERVICE, 

3869 ], 

3870 catch_exceptions=False, 

3871 ) 

3872 assert result.clean_exit( 1o

3873 output=DUMMY_RESULT_PASSPHRASE.decode('ascii') 

3874 ), 'expected clean exit' 

3875 assert result.stderr 1o

3876 assert notes.strip() in result.stderr 1o

3877 assert all( 1o

3878 is_warning_line(line) 

3879 for line in result.stderr.splitlines(True) 

3880 if line.startswith(f'{cli.PROG_NAME}: ') 

3881 ) 

3882 assert tests.warning_emitted( 1o

3883 'Specifying --notes without --config is ineffective. ' 

3884 'No notes will be edited.', 

3885 caplog.record_tuples, 

3886 ), 'expected known warning message in stderr' 

3887 assert ( 1o

3888 modern_editor_interface 

3889 or notes_backup_file.read_text(encoding='UTF-8') 

3890 == 'These backup notes are left over from the previous session.' 

3891 ) 

3892 with cli_helpers.config_filename(subsystem='vault').open( 1o

3893 encoding='UTF-8' 

3894 ) as infile: 

3895 config = json.load(infile) 1o

3896 assert config == vault_config 1o

3897 

3898 @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG 

3899 def test_224_store_config_good( 

3900 self, 

3901 command_line: list[str], 

3902 input: str, 

3903 result_config: Any, 

3904 ) -> None: 

3905 """Storing valid settings via `--config` works. 

3906 

3907 The format also contains embedded newlines and indentation to make 

3908 the config more readable. 

3909 

3910 """ 

3911 runner = tests.CliRunner(mix_stderr=False) 1F

3912 # TODO(the-13th-letter): Rewrite using parenthesized 

3913 # with-statements. 

3914 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3915 with contextlib.ExitStack() as stack: 1F

3916 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1F

3917 stack.enter_context( 1F

3918 tests.isolated_vault_config( 

3919 monkeypatch=monkeypatch, 

3920 runner=runner, 

3921 vault_config={'global': {'phrase': 'abc'}, 'services': {}}, 

3922 ) 

3923 ) 

3924 monkeypatch.setattr( 1F

3925 cli_helpers, 'get_suitable_ssh_keys', tests.suitable_ssh_keys 

3926 ) 

3927 result = runner.invoke( 1F

3928 cli.derivepassphrase_vault, 

3929 ['--config', *command_line], 

3930 catch_exceptions=False, 

3931 input=input, 

3932 ) 

3933 assert result.clean_exit(), 'expected clean exit' 1F

3934 config_txt = cli_helpers.config_filename( 1F

3935 subsystem='vault' 

3936 ).read_text(encoding='UTF-8') 

3937 config = json.loads(config_txt) 1F

3938 assert config == result_config, ( 1F

3939 'stored config does not match expectation' 

3940 ) 

3941 assert_vault_config_is_indented_and_line_broken(config_txt) 1F

3942 

3943 @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES 

3944 def test_225_store_config_fail( 

3945 self, 

3946 command_line: list[str], 

3947 input: str, 

3948 err_text: str, 

3949 ) -> None: 

3950 """Storing invalid settings via `--config` fails.""" 

3951 runner = tests.CliRunner(mix_stderr=False) 1=

3952 # TODO(the-13th-letter): Rewrite using parenthesized 

3953 # with-statements. 

3954 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3955 with contextlib.ExitStack() as stack: 1=

3956 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1=

3957 stack.enter_context( 1=

3958 tests.isolated_vault_config( 

3959 monkeypatch=monkeypatch, 

3960 runner=runner, 

3961 vault_config={'global': {'phrase': 'abc'}, 'services': {}}, 

3962 ) 

3963 ) 

3964 monkeypatch.setattr( 1=

3965 cli_helpers, 'get_suitable_ssh_keys', tests.suitable_ssh_keys 

3966 ) 

3967 result = runner.invoke( 1=

3968 cli.derivepassphrase_vault, 

3969 ['--config', *command_line], 

3970 catch_exceptions=False, 

3971 input=input, 

3972 ) 

3973 assert result.error_exit(error=err_text), ( 1=

3974 'expected error exit and known error message' 

3975 ) 

3976 

3977 # NOTE: test_225a_store_config_fail_manual_no_ssh_key_selection was 

3978 # removed, because the code path it tested -- catching error returns 

3979 # from cli_helpers.select_ssh_key -- no longer exists: the error 

3980 # checks are now *inside* the select_ssh_key call, and are already 

3981 # tested elsewhere. 

3982 # 

3983 # Original docstring: "Not selecting an SSH key during `--config 

3984 # --key` fails." 

3985 

3986 def test_225b_store_config_fail_manual_no_ssh_agent( 

3987 self, 

3988 running_ssh_agent: tests.RunningSSHAgentInfo, 

3989 ) -> None: 

3990 """Not running an SSH agent during `--config --key` fails.""" 

3991 del running_ssh_agent 1%

3992 runner = tests.CliRunner(mix_stderr=False) 1%

3993 # TODO(the-13th-letter): Rewrite using parenthesized 

3994 # with-statements. 

3995 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

3996 with contextlib.ExitStack() as stack: 1%

3997 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1%

3998 stack.enter_context( 1%

3999 tests.isolated_vault_config( 

4000 monkeypatch=monkeypatch, 

4001 runner=runner, 

4002 vault_config={'global': {'phrase': 'abc'}, 'services': {}}, 

4003 ) 

4004 ) 

4005 monkeypatch.delenv('SSH_AUTH_SOCK', raising=False) 1%

4006 result = runner.invoke( 1%

4007 cli.derivepassphrase_vault, 

4008 ['--key', '--config'], 

4009 catch_exceptions=False, 

4010 ) 

4011 assert result.error_exit(error='Cannot find any running SSH agent'), ( 1%

4012 'expected error exit and known error message' 

4013 ) 

4014 

4015 def test_225c_store_config_fail_manual_bad_ssh_agent_connection( 

4016 self, 

4017 running_ssh_agent: tests.RunningSSHAgentInfo, 

4018 ) -> None: 

4019 """Not running a reachable SSH agent during `--config --key` fails.""" 

4020 running_ssh_agent.require_external_address() 2lb

4021 runner = tests.CliRunner(mix_stderr=False) 

4022 # TODO(the-13th-letter): Rewrite using parenthesized 

4023 # with-statements. 

4024 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4025 with contextlib.ExitStack() as stack: 

4026 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 

4027 stack.enter_context( 

4028 tests.isolated_vault_config( 

4029 monkeypatch=monkeypatch, 

4030 runner=runner, 

4031 vault_config={'global': {'phrase': 'abc'}, 'services': {}}, 

4032 ) 

4033 ) 

4034 cwd = pathlib.Path.cwd().resolve() 

4035 monkeypatch.setenv('SSH_AUTH_SOCK', str(cwd)) 

4036 result = runner.invoke( 

4037 cli.derivepassphrase_vault, 

4038 ['--key', '--config'], 

4039 catch_exceptions=False, 

4040 ) 

4041 assert result.error_exit(error='Cannot connect to the SSH agent'), ( 

4042 'expected error exit and known error message' 

4043 ) 

4044 

4045 @Parametrize.TRY_RACE_FREE_IMPLEMENTATION 

4046 def test_225d_store_config_fail_manual_read_only_file( 

4047 self, 

4048 try_race_free_implementation: bool, 

4049 ) -> None: 

4050 """Using a read-only configuration file with `--config` fails.""" 

4051 runner = tests.CliRunner(mix_stderr=False) 1?

4052 # TODO(the-13th-letter): Rewrite using parenthesized 

4053 # with-statements. 

4054 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4055 with contextlib.ExitStack() as stack: 1?

4056 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1?

4057 stack.enter_context( 1?

4058 tests.isolated_vault_config( 

4059 monkeypatch=monkeypatch, 

4060 runner=runner, 

4061 vault_config={'global': {'phrase': 'abc'}, 'services': {}}, 

4062 ) 

4063 ) 

4064 tests.make_file_readonly( 1?

4065 cli_helpers.config_filename(subsystem='vault'), 

4066 try_race_free_implementation=try_race_free_implementation, 

4067 ) 

4068 result = runner.invoke( 1?

4069 cli.derivepassphrase_vault, 

4070 ['--config', '--length=15', '--', DUMMY_SERVICE], 

4071 catch_exceptions=False, 

4072 ) 

4073 assert result.error_exit(error='Cannot store vault settings:'), ( 1?

4074 'expected error exit and known error message' 

4075 ) 

4076 

4077 def test_225e_store_config_fail_manual_custom_error( 

4078 self, 

4079 ) -> None: 

4080 """OS-erroring with `--config` fails.""" 

4081 runner = tests.CliRunner(mix_stderr=False) 1S

4082 # TODO(the-13th-letter): Rewrite using parenthesized 

4083 # with-statements. 

4084 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4085 with contextlib.ExitStack() as stack: 1S

4086 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1S

4087 stack.enter_context( 1S

4088 tests.isolated_vault_config( 

4089 monkeypatch=monkeypatch, 

4090 runner=runner, 

4091 vault_config={'global': {'phrase': 'abc'}, 'services': {}}, 

4092 ) 

4093 ) 

4094 custom_error = 'custom error message' 1S

4095 

4096 def raiser(config: Any) -> None: 1S

4097 del config 1S

4098 raise RuntimeError(custom_error) 1S

4099 

4100 monkeypatch.setattr(cli_helpers, 'save_config', raiser) 1S

4101 result = runner.invoke( 1S

4102 cli.derivepassphrase_vault, 

4103 ['--config', '--length=15', '--', DUMMY_SERVICE], 

4104 catch_exceptions=False, 

4105 ) 

4106 assert result.error_exit(error=custom_error), ( 1S

4107 'expected error exit and known error message' 

4108 ) 

4109 

4110 def test_225f_store_config_fail_unset_and_set_same_settings( 

4111 self, 

4112 ) -> None: 

4113 """Issuing conflicting settings to `--config` fails.""" 

4114 runner = tests.CliRunner(mix_stderr=False) 2cb

4115 # TODO(the-13th-letter): Rewrite using parenthesized 

4116 # with-statements. 

4117 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4118 with contextlib.ExitStack() as stack: 2cb

4119 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2cb

4120 stack.enter_context( 2cb

4121 tests.isolated_vault_config( 

4122 monkeypatch=monkeypatch, 

4123 runner=runner, 

4124 vault_config={'global': {'phrase': 'abc'}, 'services': {}}, 

4125 ) 

4126 ) 

4127 result = runner.invoke( 2cb

4128 cli.derivepassphrase_vault, 

4129 [ 

4130 '--config', 

4131 '--unset=length', 

4132 '--length=15', 

4133 '--', 

4134 DUMMY_SERVICE, 

4135 ], 

4136 catch_exceptions=False, 

4137 ) 

4138 assert result.error_exit( 2cb

4139 error='Attempted to unset and set --length at the same time.' 

4140 ), 'expected error exit and known error message' 

4141 

4142 def test_225g_store_config_fail_manual_ssh_agent_no_keys_loaded( 

4143 self, 

4144 running_ssh_agent: tests.RunningSSHAgentInfo, 

4145 ) -> None: 

4146 """Not holding any SSH keys during `--config --key` fails.""" 

4147 del running_ssh_agent 1Z

4148 runner = tests.CliRunner(mix_stderr=False) 1Z

4149 # TODO(the-13th-letter): Rewrite using parenthesized 

4150 # with-statements. 

4151 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4152 with contextlib.ExitStack() as stack: 1Z

4153 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1Z

4154 stack.enter_context( 1Z

4155 tests.isolated_vault_config( 

4156 monkeypatch=monkeypatch, 

4157 runner=runner, 

4158 vault_config={'global': {'phrase': 'abc'}, 'services': {}}, 

4159 ) 

4160 ) 

4161 

4162 def func( 1Z

4163 *_args: Any, 

4164 **_kwargs: Any, 

4165 ) -> list[_types.SSHKeyCommentPair]: 

4166 return [] 1Z

4167 

4168 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'list_keys', func) 1Z

4169 result = runner.invoke( 1Z

4170 cli.derivepassphrase_vault, 

4171 ['--key', '--config'], 

4172 catch_exceptions=False, 

4173 ) 

4174 assert result.error_exit(error='no keys suitable'), ( 1Z

4175 'expected error exit and known error message' 

4176 ) 

4177 

4178 def test_225h_store_config_fail_manual_ssh_agent_runtime_error( 

4179 self, 

4180 running_ssh_agent: tests.RunningSSHAgentInfo, 

4181 ) -> None: 

4182 """The SSH agent erroring during `--config --key` fails.""" 

4183 del running_ssh_agent 10

4184 runner = tests.CliRunner(mix_stderr=False) 10

4185 # TODO(the-13th-letter): Rewrite using parenthesized 

4186 # with-statements. 

4187 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4188 with contextlib.ExitStack() as stack: 10

4189 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 10

4190 stack.enter_context( 10

4191 tests.isolated_vault_config( 

4192 monkeypatch=monkeypatch, 

4193 runner=runner, 

4194 vault_config={'global': {'phrase': 'abc'}, 'services': {}}, 

4195 ) 

4196 ) 

4197 

4198 def raiser(*_args: Any, **_kwargs: Any) -> None: 10

4199 raise ssh_agent.TrailingDataError() 10

4200 

4201 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'list_keys', raiser) 10

4202 result = runner.invoke( 10

4203 cli.derivepassphrase_vault, 

4204 ['--key', '--config'], 

4205 catch_exceptions=False, 

4206 ) 

4207 assert result.error_exit( 10

4208 error='violates the communication protocol.' 

4209 ), 'expected error exit and known error message' 

4210 

4211 def test_225i_store_config_fail_manual_ssh_agent_refuses( 

4212 self, 

4213 running_ssh_agent: tests.RunningSSHAgentInfo, 

4214 ) -> None: 

4215 """The SSH agent refusing during `--config --key` fails.""" 

4216 del running_ssh_agent 11

4217 runner = tests.CliRunner(mix_stderr=False) 11

4218 # TODO(the-13th-letter): Rewrite using parenthesized 

4219 # with-statements. 

4220 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4221 with contextlib.ExitStack() as stack: 11

4222 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 11

4223 stack.enter_context( 11

4224 tests.isolated_vault_config( 

4225 monkeypatch=monkeypatch, 

4226 runner=runner, 

4227 vault_config={'global': {'phrase': 'abc'}, 'services': {}}, 

4228 ) 

4229 ) 

4230 

4231 def func(*_args: Any, **_kwargs: Any) -> NoReturn: 11

4232 raise ssh_agent.SSHAgentFailedError( 11

4233 _types.SSH_AGENT.FAILURE, b'' 

4234 ) 

4235 

4236 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'list_keys', func) 11

4237 result = runner.invoke( 11

4238 cli.derivepassphrase_vault, 

4239 ['--key', '--config'], 

4240 catch_exceptions=False, 

4241 ) 

4242 assert result.error_exit(error='refused to'), ( 11

4243 'expected error exit and known error message' 

4244 ) 

4245 

4246 def test_226_no_arguments(self) -> None: 

4247 """Calling `derivepassphrase vault` without any arguments fails.""" 

4248 runner = tests.CliRunner(mix_stderr=False) 2db

4249 # TODO(the-13th-letter): Rewrite using parenthesized 

4250 # with-statements. 

4251 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4252 with contextlib.ExitStack() as stack: 2db

4253 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2db

4254 stack.enter_context( 2db

4255 tests.isolated_config( 

4256 monkeypatch=monkeypatch, 

4257 runner=runner, 

4258 ) 

4259 ) 

4260 result = runner.invoke( 2db

4261 cli.derivepassphrase_vault, [], catch_exceptions=False 

4262 ) 

4263 assert result.error_exit( 2db

4264 error='Deriving a passphrase requires a SERVICE' 

4265 ), 'expected error exit and known error message' 

4266 

4267 def test_226a_no_passphrase_or_key( 

4268 self, 

4269 ) -> None: 

4270 """Deriving a passphrase without a passphrase or key fails.""" 

4271 runner = tests.CliRunner(mix_stderr=False) 2eb

4272 # TODO(the-13th-letter): Rewrite using parenthesized 

4273 # with-statements. 

4274 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4275 with contextlib.ExitStack() as stack: 2eb

4276 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2eb

4277 stack.enter_context( 2eb

4278 tests.isolated_config( 

4279 monkeypatch=monkeypatch, 

4280 runner=runner, 

4281 ) 

4282 ) 

4283 result = runner.invoke( 2eb

4284 cli.derivepassphrase_vault, 

4285 ['--', DUMMY_SERVICE], 

4286 catch_exceptions=False, 

4287 ) 

4288 assert result.error_exit(error='No passphrase or key was given'), ( 2eb

4289 'expected error exit and known error message' 

4290 ) 

4291 

4292 def test_230_config_directory_nonexistant( 

4293 self, 

4294 ) -> None: 

4295 """Running without an existing config directory works. 

4296 

4297 This is a regression test; see [issue\u00a0#6][] for context. 

4298 

4299 [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6 

4300 

4301 """ 

4302 runner = tests.CliRunner(mix_stderr=False) 1O

4303 # TODO(the-13th-letter): Rewrite using parenthesized 

4304 # with-statements. 

4305 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4306 with contextlib.ExitStack() as stack: 1O

4307 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1O

4308 stack.enter_context( 1O

4309 tests.isolated_config( 

4310 monkeypatch=monkeypatch, 

4311 runner=runner, 

4312 ) 

4313 ) 

4314 with contextlib.suppress(FileNotFoundError): 1O

4315 shutil.rmtree(cli_helpers.config_filename(subsystem=None)) 1O

4316 result = runner.invoke( 1O

4317 cli.derivepassphrase_vault, 

4318 ['--config', '-p'], 

4319 catch_exceptions=False, 

4320 input='abc\n', 

4321 ) 

4322 assert result.clean_exit(), 'expected clean exit' 1O

4323 assert result.stderr == 'Passphrase:', ( 1O

4324 'program unexpectedly failed?!' 

4325 ) 

4326 with cli_helpers.config_filename(subsystem='vault').open( 1O

4327 encoding='UTF-8' 

4328 ) as infile: 

4329 config_readback = json.load(infile) 1O

4330 assert config_readback == { 1O

4331 'global': {'phrase': 'abc'}, 

4332 'services': {}, 

4333 }, 'config mismatch' 

4334 

4335 def test_230a_config_directory_not_a_file( 

4336 self, 

4337 ) -> None: 

4338 """Erroring without an existing config directory errors normally. 

4339 

4340 That is, the missing configuration directory does not cause any 

4341 errors by itself. 

4342 

4343 This is a regression test; see [issue\u00a0#6][] for context. 

4344 

4345 [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6 

4346 

4347 """ 

4348 runner = tests.CliRunner(mix_stderr=False) 1E

4349 # TODO(the-13th-letter): Rewrite using parenthesized 

4350 # with-statements. 

4351 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4352 with contextlib.ExitStack() as stack: 1E

4353 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1E

4354 stack.enter_context( 1E

4355 tests.isolated_config( 

4356 monkeypatch=monkeypatch, 

4357 runner=runner, 

4358 ) 

4359 ) 

4360 save_config_ = cli_helpers.save_config 1E

4361 

4362 def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any: 1E

4363 config_dir = cli_helpers.config_filename(subsystem=None) 1E

4364 with contextlib.suppress(FileNotFoundError): 1E

4365 shutil.rmtree(config_dir) 1E

4366 config_dir.write_text('Obstruction!!\n') 1E

4367 monkeypatch.setattr(cli_helpers, 'save_config', save_config_) 1E

4368 return save_config_(*args, **kwargs) 1E

4369 

4370 monkeypatch.setattr( 1E

4371 cli_helpers, 'save_config', obstruct_config_saving 

4372 ) 

4373 result = runner.invoke( 1E

4374 cli.derivepassphrase_vault, 

4375 ['--config', '-p'], 

4376 catch_exceptions=False, 

4377 input='abc\n', 

4378 ) 

4379 assert result.error_exit(error='Cannot store vault settings:'), ( 1E

4380 'expected error exit and known error message' 

4381 ) 

4382 

4383 def test_230b_store_config_custom_error( 

4384 self, 

4385 ) -> None: 

4386 """Storing the configuration reacts even to weird errors.""" 

4387 runner = tests.CliRunner(mix_stderr=False) 1T

4388 # TODO(the-13th-letter): Rewrite using parenthesized 

4389 # with-statements. 

4390 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4391 with contextlib.ExitStack() as stack: 1T

4392 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1T

4393 stack.enter_context( 1T

4394 tests.isolated_config( 

4395 monkeypatch=monkeypatch, 

4396 runner=runner, 

4397 ) 

4398 ) 

4399 custom_error = 'custom error message' 1T

4400 

4401 def raiser(config: Any) -> None: 1T

4402 del config 1T

4403 raise RuntimeError(custom_error) 1T

4404 

4405 monkeypatch.setattr(cli_helpers, 'save_config', raiser) 1T

4406 result = runner.invoke( 1T

4407 cli.derivepassphrase_vault, 

4408 ['--config', '-p'], 

4409 catch_exceptions=False, 

4410 input='abc\n', 

4411 ) 

4412 assert result.error_exit(error=custom_error), ( 1T

4413 'expected error exit and known error message' 

4414 ) 

4415 

4416 @Parametrize.UNICODE_NORMALIZATION_WARNING_INPUTS 

4417 def test_300_unicode_normalization_form_warning( 

4418 self, 

4419 caplog: pytest.LogCaptureFixture, 

4420 main_config: str, 

4421 command_line: list[str], 

4422 input: str | None, 

4423 warning_message: str, 

4424 ) -> None: 

4425 """Using unnormalized Unicode passphrases warns.""" 

4426 runner = tests.CliRunner(mix_stderr=False) 1@

4427 # TODO(the-13th-letter): Rewrite using parenthesized 

4428 # with-statements. 

4429 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4430 with contextlib.ExitStack() as stack: 1@

4431 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1@

4432 stack.enter_context( 1@

4433 tests.isolated_vault_config( 

4434 monkeypatch=monkeypatch, 

4435 runner=runner, 

4436 vault_config={ 

4437 'services': { 

4438 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy() 

4439 } 

4440 }, 

4441 main_config_str=main_config, 

4442 ) 

4443 ) 

4444 result = runner.invoke( 1@

4445 cli.derivepassphrase_vault, 

4446 ['--debug', *command_line], 

4447 catch_exceptions=False, 

4448 input=input, 

4449 ) 

4450 assert result.clean_exit(), 'expected clean exit' 1@

4451 assert tests.warning_emitted(warning_message, caplog.record_tuples), ( 1@

4452 'expected known warning message in stderr' 

4453 ) 

4454 

4455 @Parametrize.UNICODE_NORMALIZATION_ERROR_INPUTS 

4456 def test_301_unicode_normalization_form_error( 

4457 self, 

4458 main_config: str, 

4459 command_line: list[str], 

4460 input: str | None, 

4461 error_message: str, 

4462 ) -> None: 

4463 """Using unknown Unicode normalization forms fails.""" 

4464 runner = tests.CliRunner(mix_stderr=False) 1[

4465 # TODO(the-13th-letter): Rewrite using parenthesized 

4466 # with-statements. 

4467 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4468 with contextlib.ExitStack() as stack: 1[

4469 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1[

4470 stack.enter_context( 1[

4471 tests.isolated_vault_config( 

4472 monkeypatch=monkeypatch, 

4473 runner=runner, 

4474 vault_config={ 

4475 'services': { 

4476 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy() 

4477 } 

4478 }, 

4479 main_config_str=main_config, 

4480 ) 

4481 ) 

4482 result = runner.invoke( 1[

4483 cli.derivepassphrase_vault, 

4484 command_line, 

4485 catch_exceptions=False, 

4486 input=input, 

4487 ) 

4488 assert result.error_exit( 1[

4489 error='The user configuration file is invalid.' 

4490 ), 'expected error exit and known error message' 

4491 assert result.error_exit(error=error_message), ( 1[

4492 'expected error exit and known error message' 

4493 ) 

4494 

4495 @Parametrize.UNICODE_NORMALIZATION_COMMAND_LINES 

4496 def test_301a_unicode_normalization_form_error_from_stored_config( 

4497 self, 

4498 command_line: list[str], 

4499 ) -> None: 

4500 """Using unknown Unicode normalization forms in the config fails.""" 

4501 runner = tests.CliRunner(mix_stderr=False) 1]

4502 # TODO(the-13th-letter): Rewrite using parenthesized 

4503 # with-statements. 

4504 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4505 with contextlib.ExitStack() as stack: 1]

4506 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1]

4507 stack.enter_context( 1]

4508 tests.isolated_vault_config( 

4509 monkeypatch=monkeypatch, 

4510 runner=runner, 

4511 vault_config={ 

4512 'services': { 

4513 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy() 

4514 } 

4515 }, 

4516 main_config_str=( 

4517 "[vault]\ndefault-unicode-normalization-form = 'XXX'\n" 

4518 ), 

4519 ) 

4520 ) 

4521 result = runner.invoke( 1]

4522 cli.derivepassphrase_vault, 

4523 command_line, 

4524 input=DUMMY_PASSPHRASE, 

4525 catch_exceptions=False, 

4526 ) 

4527 assert result.error_exit( 1]

4528 error='The user configuration file is invalid.' 

4529 ), 'expected error exit and known error message' 

4530 assert result.error_exit( 1]

4531 error=( 

4532 "Invalid value 'XXX' for config key " 

4533 'vault.default-unicode-normalization-form' 

4534 ), 

4535 ), 'expected error exit and known error message' 

4536 

4537 def test_310_bad_user_config_file( 

4538 self, 

4539 ) -> None: 

4540 """Loading a user configuration file in an invalid format fails.""" 

4541 runner = tests.CliRunner(mix_stderr=False) 2fb

4542 # TODO(the-13th-letter): Rewrite using parenthesized 

4543 # with-statements. 

4544 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4545 with contextlib.ExitStack() as stack: 2fb

4546 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2fb

4547 stack.enter_context( 2fb

4548 tests.isolated_vault_config( 

4549 monkeypatch=monkeypatch, 

4550 runner=runner, 

4551 vault_config={'services': {}}, 

4552 main_config_str='This file is not valid TOML.\n', 

4553 ) 

4554 ) 

4555 result = runner.invoke( 2fb

4556 cli.derivepassphrase_vault, 

4557 ['--phrase', '--', DUMMY_SERVICE], 

4558 input=DUMMY_PASSPHRASE, 

4559 catch_exceptions=False, 

4560 ) 

4561 assert result.error_exit(error='Cannot load user config:'), ( 2fb

4562 'expected error exit and known error message' 

4563 ) 

4564 

4565 def test_311_bad_user_config_is_a_directory( 

4566 self, 

4567 ) -> None: 

4568 """Loading a user configuration file in an invalid format fails.""" 

4569 runner = tests.CliRunner(mix_stderr=False) 18

4570 # TODO(the-13th-letter): Rewrite using parenthesized 

4571 # with-statements. 

4572 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4573 with contextlib.ExitStack() as stack: 18

4574 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 18

4575 stack.enter_context( 18

4576 tests.isolated_vault_config( 

4577 monkeypatch=monkeypatch, 

4578 runner=runner, 

4579 vault_config={'services': {}}, 

4580 main_config_str='', 

4581 ) 

4582 ) 

4583 user_config = cli_helpers.config_filename( 18

4584 subsystem='user configuration' 

4585 ) 

4586 user_config.unlink() 18

4587 user_config.mkdir(parents=True, exist_ok=True) 18

4588 result = runner.invoke( 18

4589 cli.derivepassphrase_vault, 

4590 ['--phrase', '--', DUMMY_SERVICE], 

4591 input=DUMMY_PASSPHRASE, 

4592 catch_exceptions=False, 

4593 ) 

4594 assert result.error_exit(error='Cannot load user config:'), ( 18

4595 'expected error exit and known error message' 

4596 ) 

4597 

4598 def test_400_missing_af_unix_support( 

4599 self, 

4600 caplog: pytest.LogCaptureFixture, 

4601 ) -> None: 

4602 """Querying the SSH agent without `AF_UNIX` support fails.""" 

4603 runner = tests.CliRunner(mix_stderr=False) 12

4604 # TODO(the-13th-letter): Rewrite using parenthesized 

4605 # with-statements. 

4606 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4607 with contextlib.ExitStack() as stack: 12

4608 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 12

4609 stack.enter_context( 12

4610 tests.isolated_vault_config( 

4611 monkeypatch=monkeypatch, 

4612 runner=runner, 

4613 vault_config={'global': {'phrase': 'abc'}, 'services': {}}, 

4614 ) 

4615 ) 

4616 monkeypatch.setenv( 12

4617 'SSH_AUTH_SOCK', "the value doesn't even matter" 

4618 ) 

4619 monkeypatch.setattr( 12

4620 ssh_agent.SSHAgentClient, 'SOCKET_PROVIDERS', ['posix'] 

4621 ) 

4622 monkeypatch.delattr(socket, 'AF_UNIX', raising=False) 12

4623 result = runner.invoke( 12

4624 cli.derivepassphrase_vault, 

4625 ['--key', '--config'], 

4626 catch_exceptions=False, 

4627 ) 

4628 assert result.error_exit( 12

4629 error='does not support communicating with it' 

4630 ), 'expected error exit and known error message' 

4631 assert tests.warning_emitted( 12

4632 'Cannot connect to an SSH agent via UNIX domain sockets', 

4633 caplog.record_tuples, 

4634 ), 'expected known warning message in stderr' 

4635 

4636 

4637class TestCLIUtils: 

4638 """Tests for command-line utility functions.""" 

4639 

4640 @Parametrize.BASE_CONFIG_VARIATIONS 

4641 def test_100_load_config( 

4642 self, 

4643 config: Any, 

4644 ) -> None: 

4645 """[`cli_helpers.load_config`][] works for valid configurations.""" 

4646 runner = tests.CliRunner(mix_stderr=False) 1'

4647 # TODO(the-13th-letter): Rewrite using parenthesized 

4648 # with-statements. 

4649 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4650 with contextlib.ExitStack() as stack: 1'

4651 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1'

4652 stack.enter_context( 1'

4653 tests.isolated_vault_config( 

4654 monkeypatch=monkeypatch, 

4655 runner=runner, 

4656 vault_config=config, 

4657 ) 

4658 ) 

4659 config_filename = cli_helpers.config_filename(subsystem='vault') 1'

4660 with config_filename.open(encoding='UTF-8') as fileobj: 1'

4661 assert json.load(fileobj) == config 1'

4662 assert cli_helpers.load_config() == config 1'

4663 

4664 def test_110_save_bad_config( 

4665 self, 

4666 ) -> None: 

4667 """[`cli_helpers.save_config`][] fails for bad configurations.""" 

4668 runner = tests.CliRunner(mix_stderr=False) 2gb

4669 # TODO(the-13th-letter): Rewrite using parenthesized 

4670 # with-statements. 

4671 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

4672 with contextlib.ExitStack() as stack: 2gb

4673 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2gb

4674 stack.enter_context( 2gb

4675 tests.isolated_vault_config( 

4676 monkeypatch=monkeypatch, 

4677 runner=runner, 

4678 vault_config={}, 

4679 ) 

4680 ) 

4681 stack.enter_context( 2gb

4682 pytest.raises(ValueError, match='Invalid vault config') 

4683 ) 

4684 cli_helpers.save_config(None) # type: ignore[arg-type] 2gb

4685 

4686 def test_111_prompt_for_selection_multiple(self) -> None: 

4687 """[`cli_helpers.prompt_for_selection`][] works in the "multiple" case.""" 

4688 

4689 @click.command() 1y

4690 @click.option('--heading', default='Our menu:') 1y

4691 @click.argument('items', nargs=-1) 1y

4692 def driver(heading: str, items: list[str]) -> None: 1y

4693 # from https://montypython.fandom.com/wiki/Spam#The_menu 

4694 items = items or [ 1y

4695 'Egg and bacon', 

4696 'Egg, sausage and bacon', 

4697 'Egg and spam', 

4698 'Egg, bacon and spam', 

4699 'Egg, bacon, sausage and spam', 

4700 'Spam, bacon, sausage and spam', 

4701 'Spam, egg, spam, spam, bacon and spam', 

4702 'Spam, spam, spam, egg and spam', 

4703 ( 

4704 'Spam, spam, spam, spam, spam, spam, baked beans, ' 

4705 'spam, spam, spam and spam' 

4706 ), 

4707 ( 

4708 'Lobster thermidor aux crevettes with a mornay sauce ' 

4709 'garnished with truffle paté, brandy ' 

4710 'and a fried egg on top and spam' 

4711 ), 

4712 ] 

4713 index = cli_helpers.prompt_for_selection(items, heading=heading) 1y

4714 click.echo('A fine choice: ', nl=False) 1y

4715 click.echo(items[index]) 1y

4716 click.echo('(Note: Vikings strictly optional.)') 1y

4717 

4718 runner = tests.CliRunner(mix_stderr=True) 1y

4719 result = runner.invoke(driver, [], input='9') 1y

4720 assert result.clean_exit( 1y

4721 output="""\ 

4722Our menu: 

4723[1] Egg and bacon 

4724[2] Egg, sausage and bacon 

4725[3] Egg and spam 

4726[4] Egg, bacon and spam 

4727[5] Egg, bacon, sausage and spam 

4728[6] Spam, bacon, sausage and spam 

4729[7] Spam, egg, spam, spam, bacon and spam 

4730[8] Spam, spam, spam, egg and spam 

4731[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam 

4732[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam 

4733Your selection? (1-10, leave empty to abort): 9 

4734A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam 

4735(Note: Vikings strictly optional.) 

4736""" 

4737 ), 'expected clean exit' 

4738 result = runner.invoke( 1y

4739 driver, ['--heading='], input='\n', catch_exceptions=True 

4740 ) 

4741 assert result.error_exit(error=IndexError), ( 1y

4742 'expected error exit and known error type' 

4743 ) 

4744 assert ( 1y

4745 result.stdout 

4746 == """\ 

4747[1] Egg and bacon 

4748[2] Egg, sausage and bacon 

4749[3] Egg and spam 

4750[4] Egg, bacon and spam 

4751[5] Egg, bacon, sausage and spam 

4752[6] Spam, bacon, sausage and spam 

4753[7] Spam, egg, spam, spam, bacon and spam 

4754[8] Spam, spam, spam, egg and spam 

4755[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam 

4756[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam 

4757Your selection? (1-10, leave empty to abort):\x20 

4758""" 

4759 ), 'expected known output' 

4760 # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the 

4761 # click prompting machinery, meaning that the mixed output will 

4762 # incorrectly contain a line break, contrary to what the 

4763 # documentation for click.prompt prescribes. 

4764 result = runner.invoke( 1y

4765 driver, ['--heading='], input='', catch_exceptions=True 

4766 ) 

4767 assert result.error_exit(error=IndexError), ( 1y

4768 'expected error exit and known error type' 

4769 ) 

4770 assert result.stdout in { 1y

4771 """\ 

4772[1] Egg and bacon 

4773[2] Egg, sausage and bacon 

4774[3] Egg and spam 

4775[4] Egg, bacon and spam 

4776[5] Egg, bacon, sausage and spam 

4777[6] Spam, bacon, sausage and spam 

4778[7] Spam, egg, spam, spam, bacon and spam 

4779[8] Spam, spam, spam, egg and spam 

4780[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam 

4781[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam 

4782Your selection? (1-10, leave empty to abort):\x20 

4783""", 

4784 """\ 

4785[1] Egg and bacon 

4786[2] Egg, sausage and bacon 

4787[3] Egg and spam 

4788[4] Egg, bacon and spam 

4789[5] Egg, bacon, sausage and spam 

4790[6] Spam, bacon, sausage and spam 

4791[7] Spam, egg, spam, spam, bacon and spam 

4792[8] Spam, spam, spam, egg and spam 

4793[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam 

4794[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam 

4795Your selection? (1-10, leave empty to abort): """, 

4796 }, 'expected known output' 

4797 

4798 def test_112_prompt_for_selection_single(self) -> None: 

4799 """[`cli_helpers.prompt_for_selection`][] works in the "single" case.""" 

4800 

4801 @click.command() 1x

4802 @click.option('--item', default='baked beans') 1x

4803 @click.argument('prompt') 1x

4804 def driver(item: str, prompt: str) -> None: 1x

4805 try: 1x

4806 cli_helpers.prompt_for_selection( 1x

4807 [item], heading='', single_choice_prompt=prompt 

4808 ) 

4809 except IndexError: 1x

4810 click.echo('Boo.') 1x

4811 raise 1x

4812 else: 

4813 click.echo('Great!') 1x

4814 

4815 runner = tests.CliRunner(mix_stderr=True) 1x

4816 result = runner.invoke( 1x

4817 driver, ['Will replace with spam. Confirm, y/n?'], input='y' 

4818 ) 

4819 assert result.clean_exit( 1x

4820 output="""\ 

4821[1] baked beans 

4822Will replace with spam. Confirm, y/n? y 

4823Great! 

4824""" 

4825 ), 'expected clean exit' 

4826 result = runner.invoke( 1x

4827 driver, 

4828 ['Will replace with spam, okay? (Please say "y" or "n".)'], 

4829 input='\n', 

4830 ) 

4831 assert result.error_exit(error=IndexError), ( 1x

4832 'expected error exit and known error type' 

4833 ) 

4834 assert ( 1x

4835 result.stdout 

4836 == """\ 

4837[1] baked beans 

4838Will replace with spam, okay? (Please say "y" or "n".):\x20 

4839Boo. 

4840""" 

4841 ), 'expected known output' 

4842 # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the 

4843 # click prompting machinery, meaning that the mixed output will 

4844 # incorrectly contain a line break, contrary to what the 

4845 # documentation for click.prompt prescribes. 

4846 result = runner.invoke( 1x

4847 driver, 

4848 ['Will replace with spam, okay? (Please say "y" or "n".)'], 

4849 input='', 

4850 ) 

4851 assert result.error_exit(error=IndexError), ( 1x

4852 'expected error exit and known error type' 

4853 ) 

4854 assert result.stdout in { 1x

4855 """\ 

4856[1] baked beans 

4857Will replace with spam, okay? (Please say "y" or "n".):\x20 

4858Boo. 

4859""", 

4860 """\ 

4861[1] baked beans 

4862Will replace with spam, okay? (Please say "y" or "n".): Boo. 

4863""", 

4864 }, 'expected known output' 

4865 

4866 def test_113_prompt_for_passphrase( 

4867 self, 

4868 ) -> None: 

4869 """[`cli_helpers.prompt_for_passphrase`][] works.""" 

4870 with pytest.MonkeyPatch.context() as monkeypatch: 1U

4871 monkeypatch.setattr( 1U

4872 click, 

4873 'prompt', 

4874 lambda *a, **kw: json.dumps({'args': a, 'kwargs': kw}), 

4875 ) 

4876 res = json.loads(cli_helpers.prompt_for_passphrase()) 1U

4877 err_msg = 'missing arguments to passphrase prompt' 1U

4878 assert 'args' in res, err_msg 1U

4879 assert 'kwargs' in res, err_msg 1U

4880 assert res['args'][:1] == ['Passphrase'], err_msg 1U

4881 assert res['kwargs'].get('default') == '', err_msg 1U

4882 assert not res['kwargs'].get('show_default', True), err_msg 1U

4883 assert res['kwargs'].get('err'), err_msg 1U

4884 assert res['kwargs'].get('hide_input'), err_msg 1U

4885 

4886 def test_120_standard_logging_context_manager( 

4887 self, 

4888 caplog: pytest.LogCaptureFixture, 

4889 capsys: pytest.CaptureFixture[str], 

4890 ) -> None: 

4891 """The standard logging context manager works. 

4892 

4893 It registers its handlers, once, and emits formatted calls to 

4894 standard error prefixed with the program name. 

4895 

4896 """ 

4897 prog_name = cli_machinery.StandardCLILogging.prog_name 1B

4898 package_name = cli_machinery.StandardCLILogging.package_name 1B

4899 logger = logging.getLogger(package_name) 1B

4900 deprecation_logger = logging.getLogger(f'{package_name}.deprecation') 1B

4901 logging_cm = cli_machinery.StandardCLILogging.ensure_standard_logging() 1B

4902 with logging_cm: 1B

4903 assert ( 1B

4904 sum( 

4905 1 

4906 for h in logger.handlers 

4907 if h is cli_machinery.StandardCLILogging.cli_handler 

4908 ) 

4909 == 1 

4910 ) 

4911 logger.warning('message 1') 1B

4912 with logging_cm: 1B

4913 deprecation_logger.warning('message 2') 1B

4914 assert ( 1B

4915 sum( 

4916 1 

4917 for h in logger.handlers 

4918 if h is cli_machinery.StandardCLILogging.cli_handler 

4919 ) 

4920 == 1 

4921 ) 

4922 assert capsys.readouterr() == ( 1B

4923 '', 

4924 ( 

4925 f'{prog_name}: Warning: message 1\n' 

4926 f'{prog_name}: Deprecation warning: message 2\n' 

4927 ), 

4928 ) 

4929 logger.warning('message 3') 1B

4930 assert ( 1B

4931 sum( 

4932 1 

4933 for h in logger.handlers 

4934 if h is cli_machinery.StandardCLILogging.cli_handler 

4935 ) 

4936 == 1 

4937 ) 

4938 assert capsys.readouterr() == ( 1B

4939 '', 

4940 f'{prog_name}: Warning: message 3\n', 

4941 ) 

4942 assert caplog.record_tuples == [ 1B

4943 (package_name, logging.WARNING, 'message 1'), 

4944 (f'{package_name}.deprecation', logging.WARNING, 'message 2'), 

4945 (package_name, logging.WARNING, 'message 3'), 

4946 ] 

4947 

4948 def test_121_standard_logging_warnings_context_manager( 

4949 self, 

4950 caplog: pytest.LogCaptureFixture, 

4951 capsys: pytest.CaptureFixture[str], 

4952 ) -> None: 

4953 """The standard warnings logging context manager works. 

4954 

4955 It registers its handlers, once, and emits formatted calls to 

4956 standard error prefixed with the program name. It also adheres 

4957 to the global warnings filter concerning which messages it 

4958 actually emits to standard error. 

4959 

4960 """ 

4961 warnings_cm = ( 1p

4962 cli_machinery.StandardCLILogging.ensure_standard_warnings_logging() 

4963 ) 

4964 THE_FUTURE = 'the future will be here sooner than you think' # noqa: N806 1p

4965 JUST_TESTING = 'just testing whether warnings work' # noqa: N806 1p

4966 with warnings_cm: 1p

4967 assert ( 1p

4968 sum( 

4969 1 

4970 for h in logging.getLogger('py.warnings').handlers 

4971 if h is cli_machinery.StandardCLILogging.warnings_handler 

4972 ) 

4973 == 1 

4974 ) 

4975 warnings.warn(UserWarning(JUST_TESTING), stacklevel=1) 1p

4976 with warnings_cm: 1p

4977 warnings.warn(FutureWarning(THE_FUTURE), stacklevel=1) 1p

4978 _out, err = capsys.readouterr() 1p

4979 err_lines = err.splitlines(True) 1p

4980 assert any( 1p

4981 f'UserWarning: {JUST_TESTING}' in line 

4982 for line in err_lines 

4983 ) 

4984 assert any( 1p

4985 f'FutureWarning: {THE_FUTURE}' in line 

4986 for line in err_lines 

4987 ) 

4988 warnings.warn(UserWarning(JUST_TESTING), stacklevel=1) 1p

4989 _out, err = capsys.readouterr() 1p

4990 err_lines = err.splitlines(True) 1p

4991 assert any( 1p

4992 f'UserWarning: {JUST_TESTING}' in line for line in err_lines 

4993 ) 

4994 assert not any( 1p

4995 f'FutureWarning: {THE_FUTURE}' in line for line in err_lines 

4996 ) 

4997 record_tuples = caplog.record_tuples 1p

4998 assert [tup[:2] for tup in record_tuples] == [ 1p

4999 ('py.warnings', logging.WARNING), 

5000 ('py.warnings', logging.WARNING), 

5001 ('py.warnings', logging.WARNING), 

5002 ] 

5003 assert f'UserWarning: {JUST_TESTING}' in record_tuples[0][2] 1p

5004 assert f'FutureWarning: {THE_FUTURE}' in record_tuples[1][2] 1p

5005 assert f'UserWarning: {JUST_TESTING}' in record_tuples[2][2] 1p

5006 

5007 def export_as_sh_helper( 

5008 self, 

5009 config: Any, 

5010 ) -> None: 

5011 """Emits a config in sh(1) format, then reads it back to verify it. 

5012 

5013 This function exports the configuration, sets up a new 

5014 enviroment, then calls 

5015 [`vault_config_exporter_shell_interpreter`][] on the export 

5016 script, verifying that each command ran successfully and that 

5017 the final configuration matches the initial one. 

5018 

5019 Args: 

5020 config: 

5021 The configuration to emit and read back. 

5022 

5023 """ 

5024 prog_name_list = ('derivepassphrase', 'vault') 1jikl

5025 with io.StringIO() as outfile: 1jikl

5026 cli_helpers.print_config_as_sh_script( 1jikl

5027 config, outfile=outfile, prog_name_list=prog_name_list 

5028 ) 

5029 script = outfile.getvalue() 1jikl

5030 runner = tests.CliRunner(mix_stderr=False) 1jikl

5031 # TODO(the-13th-letter): Rewrite using parenthesized 

5032 # with-statements. 

5033 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5034 with contextlib.ExitStack() as stack: 1jikl

5035 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1jikl

5036 stack.enter_context( 1jikl

5037 tests.isolated_vault_config( 

5038 monkeypatch=monkeypatch, 

5039 runner=runner, 

5040 vault_config={'services': {}}, 

5041 ) 

5042 ) 

5043 for result in vault_config_exporter_shell_interpreter(script): 1jikl

5044 assert result.clean_exit() 1jikl

5045 assert cli_helpers.load_config() == config 1jikl

5046 

5047 @hypothesis.given( 

5048 global_config_settable=tests.vault_full_service_config(), 

5049 global_config_importable=strategies.fixed_dictionaries( 

5050 {}, 

5051 optional={ 

5052 'key': strategies.text( 

5053 alphabet=strategies.characters( 

5054 min_codepoint=32, 

5055 max_codepoint=126, 

5056 ), 

5057 max_size=128, 

5058 ), 

5059 'phrase': strategies.text( 

5060 alphabet=strategies.characters( 

5061 min_codepoint=32, 

5062 max_codepoint=126, 

5063 ), 

5064 max_size=64, 

5065 ), 

5066 }, 

5067 ), 

5068 ) 

5069 def test_130a_export_as_sh_global( 

5070 self, 

5071 global_config_settable: _types.VaultConfigServicesSettings, 

5072 global_config_importable: _types.VaultConfigServicesSettings, 

5073 ) -> None: 

5074 """Exporting configurations as sh(1) script works. 

5075 

5076 Here, we check global-only configurations which use both 

5077 settings settable via `--config` and settings requiring 

5078 `--import`. 

5079 

5080 The actual verification is done by [`export_as_sh_helper`][]. 

5081 

5082 """ 

5083 config: _types.VaultConfig = { 1j

5084 'global': global_config_settable | global_config_importable, 

5085 'services': {}, 

5086 } 

5087 assert _types.clean_up_falsy_vault_config_values(config) is not None 1j

5088 assert _types.is_vault_config(config) 1j

5089 return self.export_as_sh_helper(config) 1j

5090 

5091 @hypothesis.given( 

5092 global_config_importable=strategies.fixed_dictionaries( 

5093 {}, 

5094 optional={ 

5095 'key': strategies.text( 

5096 alphabet=strategies.characters( 

5097 min_codepoint=32, 

5098 max_codepoint=126, 

5099 ), 

5100 max_size=128, 

5101 ), 

5102 'phrase': strategies.text( 

5103 alphabet=strategies.characters( 

5104 min_codepoint=32, 

5105 max_codepoint=126, 

5106 ), 

5107 max_size=64, 

5108 ), 

5109 }, 

5110 ), 

5111 ) 

5112 def test_130b_export_as_sh_global_only_imports( 

5113 self, 

5114 global_config_importable: _types.VaultConfigServicesSettings, 

5115 ) -> None: 

5116 """Exporting configurations as sh(1) script works. 

5117 

5118 Here, we check global-only configurations which only use 

5119 settings requiring `--import`. 

5120 

5121 The actual verification is done by [`export_as_sh_helper`][]. 

5122 

5123 """ 

5124 config: _types.VaultConfig = { 1i

5125 'global': global_config_importable, 

5126 'services': {}, 

5127 } 

5128 assert _types.clean_up_falsy_vault_config_values(config) is not None 1i

5129 assert _types.is_vault_config(config) 1i

5130 if not config['global']: 1i

5131 config.pop('global') 1i

5132 return self.export_as_sh_helper(config) 1i

5133 

5134 @hypothesis.given( 

5135 service_name=strategies.text( 

5136 alphabet=strategies.characters( 

5137 min_codepoint=32, 

5138 max_codepoint=126, 

5139 ), 

5140 min_size=4, 

5141 max_size=64, 

5142 ), 

5143 service_config_settable=tests.vault_full_service_config(), 

5144 service_config_importable=strategies.fixed_dictionaries( 

5145 {}, 

5146 optional={ 

5147 'key': strategies.text( 

5148 alphabet=strategies.characters( 

5149 min_codepoint=32, 

5150 max_codepoint=126, 

5151 ), 

5152 max_size=128, 

5153 ), 

5154 'phrase': strategies.text( 

5155 alphabet=strategies.characters( 

5156 min_codepoint=32, 

5157 max_codepoint=126, 

5158 ), 

5159 max_size=64, 

5160 ), 

5161 'notes': strategies.text( 

5162 alphabet=strategies.characters( 

5163 min_codepoint=32, 

5164 max_codepoint=126, 

5165 include_characters=('\n', '\f', '\t'), 

5166 ), 

5167 max_size=256, 

5168 ), 

5169 }, 

5170 ), 

5171 ) 

5172 def test_130c_export_as_sh_service( 

5173 self, 

5174 service_name: str, 

5175 service_config_settable: _types.VaultConfigServicesSettings, 

5176 service_config_importable: _types.VaultConfigServicesSettings, 

5177 ) -> None: 

5178 """Exporting configurations as sh(1) script works. 

5179 

5180 Here, we check service-only configurations which use both 

5181 settings settable via `--config` and settings requiring 

5182 `--import`. 

5183 

5184 The actual verification is done by [`export_as_sh_helper`][]. 

5185 

5186 """ 

5187 config: _types.VaultConfig = { 1k

5188 'services': { 

5189 service_name: ( 

5190 service_config_settable | service_config_importable 

5191 ), 

5192 }, 

5193 } 

5194 assert _types.clean_up_falsy_vault_config_values(config) is not None 1k

5195 assert _types.is_vault_config(config) 1k

5196 return self.export_as_sh_helper(config) 1k

5197 

5198 @hypothesis.given( 

5199 service_name=strategies.text( 

5200 alphabet=strategies.characters( 

5201 min_codepoint=32, 

5202 max_codepoint=126, 

5203 ), 

5204 min_size=4, 

5205 max_size=64, 

5206 ), 

5207 service_config_importable=strategies.fixed_dictionaries( 

5208 {}, 

5209 optional={ 

5210 'key': strategies.text( 

5211 alphabet=strategies.characters( 

5212 min_codepoint=32, 

5213 max_codepoint=126, 

5214 ), 

5215 max_size=128, 

5216 ), 

5217 'phrase': strategies.text( 

5218 alphabet=strategies.characters( 

5219 min_codepoint=32, 

5220 max_codepoint=126, 

5221 ), 

5222 max_size=64, 

5223 ), 

5224 'notes': strategies.text( 

5225 alphabet=strategies.characters( 

5226 min_codepoint=32, 

5227 max_codepoint=126, 

5228 include_characters=('\n', '\f', '\t'), 

5229 ), 

5230 max_size=256, 

5231 ), 

5232 }, 

5233 ), 

5234 ) 

5235 def test_130d_export_as_sh_service_only_imports( 

5236 self, 

5237 service_name: str, 

5238 service_config_importable: _types.VaultConfigServicesSettings, 

5239 ) -> None: 

5240 """Exporting configurations as sh(1) script works. 

5241 

5242 Here, we check service-only configurations which only use 

5243 settings requiring `--import`. 

5244 

5245 The actual verification is done by [`export_as_sh_helper`][]. 

5246 

5247 """ 

5248 config: _types.VaultConfig = { 1l

5249 'services': { 

5250 service_name: service_config_importable, 

5251 }, 

5252 } 

5253 assert _types.clean_up_falsy_vault_config_values(config) is not None 1l

5254 assert _types.is_vault_config(config) 1l

5255 return self.export_as_sh_helper(config) 1l

5256 

5257 # The Annoying OS appears to silently truncate spaces at the end of 

5258 # filenames. 

5259 @hypothesis.given( 

5260 env_var=strategies.sampled_from(['TMPDIR', 'TEMP', 'TMP']), 

5261 suffix=strategies.builds( 

5262 operator.add, 

5263 strategies.text( 

5264 tuple(' 0123456789abcdefghijklmnopqrstuvwxyz'), 

5265 min_size=11, 

5266 max_size=11, 

5267 ), 

5268 strategies.text( 

5269 tuple('0123456789abcdefghijklmnopqrstuvwxyz'), 

5270 min_size=1, 

5271 max_size=1, 

5272 ), 

5273 ), 

5274 ) 

5275 @hypothesis.example(env_var='', suffix='.') 

5276 def test_140a_get_tempdir( 

5277 self, 

5278 env_var: str, 

5279 suffix: str, 

5280 ) -> None: 

5281 """[`cli_helpers.get_tempdir`][] returns a temporary directory. 

5282 

5283 If it is not the same as the temporary directory determined by 

5284 [`tempfile.gettempdir`][], then assert that 

5285 `tempfile.gettempdir` returned the current directory and 

5286 `cli_helpers.get_tempdir` returned the configuration directory. 

5287 

5288 """ 

5289 

5290 @contextlib.contextmanager 1n

5291 def make_temporary_directory( 1n

5292 path: pathlib.Path, 

5293 ) -> Iterator[pathlib.Path]: 

5294 try: 1n

5295 path.mkdir() 1n

5296 yield path 1n

5297 finally: 

5298 shutil.rmtree(path) 1n

5299 

5300 runner = tests.CliRunner(mix_stderr=False) 1n

5301 # TODO(the-13th-letter): Rewrite using parenthesized 

5302 # with-statements. 

5303 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5304 with contextlib.ExitStack() as stack: 1n

5305 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1n

5306 stack.enter_context( 1n

5307 tests.isolated_vault_config( 

5308 monkeypatch=monkeypatch, 

5309 runner=runner, 

5310 vault_config={'services': {}}, 

5311 ) 

5312 ) 

5313 old_tempdir = os.fsdecode(tempfile.gettempdir()) 1n

5314 monkeypatch.delenv('TMPDIR', raising=False) 1n

5315 monkeypatch.delenv('TEMP', raising=False) 1n

5316 monkeypatch.delenv('TMP', raising=False) 1n

5317 monkeypatch.setattr(tempfile, 'tempdir', None) 1n

5318 temp_path = pathlib.Path.cwd() / suffix 1n

5319 if env_var: 1n

5320 monkeypatch.setenv(env_var, os.fsdecode(temp_path)) 1n

5321 stack.enter_context(make_temporary_directory(temp_path)) 1n

5322 new_tempdir = os.fsdecode(tempfile.gettempdir()) 1n

5323 hypothesis.assume( 1n

5324 temp_path.resolve() == pathlib.Path.cwd().resolve() 

5325 or old_tempdir != new_tempdir 

5326 ) 

5327 system_tempdir = os.fsdecode(tempfile.gettempdir()) 1n

5328 our_tempdir = cli_helpers.get_tempdir() 1n

5329 assert system_tempdir == os.fsdecode(our_tempdir) or ( 1n

5330 # TODO(the-13th-letter): `tests.isolated_config` 

5331 # guarantees that `Path.cwd() == config_filename(None)`. 

5332 # So this sub-branch ought to never trigger in our 

5333 # tests. 

5334 system_tempdir == os.getcwd() # noqa: PTH109 

5335 and our_tempdir == cli_helpers.config_filename(subsystem=None) 

5336 ) 

5337 assert not temp_path.exists(), f'temp path {temp_path} not cleaned up!' 1n

5338 

5339 def test_140b_get_tempdir_force_default(self) -> None: 

5340 """[`cli_helpers.get_tempdir`][] returns a temporary directory. 

5341 

5342 If all candidates are mocked to fail for the standard temporary 

5343 directory choices, then we return the `derivepassphrase` 

5344 configuration directory. 

5345 

5346 """ 

5347 runner = tests.CliRunner(mix_stderr=False) 1z

5348 # TODO(the-13th-letter): Rewrite using parenthesized 

5349 # with-statements. 

5350 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5351 with contextlib.ExitStack() as stack: 1z

5352 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1z

5353 stack.enter_context( 1z

5354 tests.isolated_vault_config( 

5355 monkeypatch=monkeypatch, 

5356 runner=runner, 

5357 vault_config={'services': {}}, 

5358 ) 

5359 ) 

5360 monkeypatch.delenv('TMPDIR', raising=False) 1z

5361 monkeypatch.delenv('TEMP', raising=False) 1z

5362 monkeypatch.delenv('TMP', raising=False) 1z

5363 config_dir = cli_helpers.config_filename(subsystem=None) 1z

5364 

5365 def is_dir_false( 1z

5366 self: pathlib.Path, 

5367 /, 

5368 *, 

5369 follow_symlinks: bool = False, 

5370 ) -> bool: 

5371 del self, follow_symlinks 1z

5372 return False 1z

5373 

5374 def is_dir_error( 1z

5375 self: pathlib.Path, 

5376 /, 

5377 *, 

5378 follow_symlinks: bool = False, 

5379 ) -> bool: 

5380 del follow_symlinks 1z

5381 raise OSError( 1z

5382 errno.EACCES, 

5383 os.strerror(errno.EACCES), 

5384 str(self), 

5385 ) 

5386 

5387 monkeypatch.setattr(pathlib.Path, 'is_dir', is_dir_false) 1z

5388 assert cli_helpers.get_tempdir() == config_dir 1z

5389 

5390 monkeypatch.setattr(pathlib.Path, 'is_dir', is_dir_error) 1z

5391 assert cli_helpers.get_tempdir() == config_dir 1z

5392 

5393 @Parametrize.DELETE_CONFIG_INPUT 

5394 def test_203_repeated_config_deletion( 

5395 self, 

5396 command_line: list[str], 

5397 config: _types.VaultConfig, 

5398 result_config: _types.VaultConfig, 

5399 ) -> None: 

5400 """Repeatedly removing the same parts of a configuration works.""" 

5401 for start_config in [config, result_config]: 13

5402 runner = tests.CliRunner(mix_stderr=False) 13

5403 # TODO(the-13th-letter): Rewrite using parenthesized 

5404 # with-statements. 

5405 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5406 with contextlib.ExitStack() as stack: 13

5407 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 13

5408 stack.enter_context( 13

5409 tests.isolated_vault_config( 

5410 monkeypatch=monkeypatch, 

5411 runner=runner, 

5412 vault_config=start_config, 

5413 ) 

5414 ) 

5415 result = runner.invoke( 13

5416 cli.derivepassphrase_vault, 

5417 command_line, 

5418 catch_exceptions=False, 

5419 ) 

5420 assert result.clean_exit(empty_stderr=True), ( 13

5421 'expected clean exit' 

5422 ) 

5423 with cli_helpers.config_filename(subsystem='vault').open( 13

5424 encoding='UTF-8' 

5425 ) as infile: 

5426 config_readback = json.load(infile) 13

5427 assert config_readback == result_config 13

5428 

5429 def test_204_phrase_from_key_manually(self) -> None: 

5430 """The dummy service, key and config settings are consistent.""" 

5431 assert ( 2mb

5432 vault.Vault( 

5433 phrase=DUMMY_PHRASE_FROM_KEY1, **DUMMY_CONFIG_SETTINGS 

5434 ).generate(DUMMY_SERVICE) 

5435 == DUMMY_RESULT_KEY1 

5436 ) 

5437 

5438 @Parametrize.VALIDATION_FUNCTION_INPUT 

5439 def test_210a_validate_constraints_manually( 

5440 self, 

5441 vfunc: Callable[[click.Context, click.Parameter, Any], int | None], 

5442 input: int, 

5443 ) -> None: 

5444 """Command-line argument constraint validation works.""" 

5445 ctx = cli.derivepassphrase_vault.make_context(cli.PROG_NAME, []) 2kb

5446 param = cli.derivepassphrase_vault.params[0] 2kb

5447 assert vfunc(ctx, param, input) == input 2kb

5448 

5449 @Parametrize.CONNECTION_HINTS 

5450 def test_227_get_suitable_ssh_keys( 

5451 self, 

5452 running_ssh_agent: tests.RunningSSHAgentInfo, 

5453 conn_hint: str, 

5454 ) -> None: 

5455 """[`cli_helpers.get_suitable_ssh_keys`][] works.""" 

5456 with pytest.MonkeyPatch.context() as monkeypatch: 1I

5457 monkeypatch.setattr( 1I

5458 ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys 

5459 ) 

5460 hint: ssh_agent.SSHAgentClient | _types.SSHAgentSocket | None 

5461 # TODO(the-13th-letter): Rewrite using structural pattern 

5462 # matching. 

5463 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5464 if conn_hint == 'client': 1I

5465 hint = ssh_agent.SSHAgentClient() 1I

5466 elif conn_hint == 'socket': 1I

5467 if isinstance( 1I

5468 running_ssh_agent.socket, str 

5469 ): # pragma: no cover 

5470 if not hasattr(socket, 'AF_UNIX'): 

5471 pytest.skip('socket module does not support AF_UNIX') 

5472 # socket.AF_UNIX is not defined everywhere. 

5473 hint = socket.socket(family=socket.AF_UNIX) # type: ignore[attr-defined] 

5474 hint.connect(running_ssh_agent.socket) 

5475 else: # pragma: no cover 

5476 hint = running_ssh_agent.socket() 1I

5477 else: 

5478 assert conn_hint == 'none' 1I

5479 hint = None 1I

5480 exception: Exception | None = None 1I

5481 try: 1I

5482 list(cli_helpers.get_suitable_ssh_keys(hint)) 1I

5483 except RuntimeError: # pragma: no cover 

5484 pass 

5485 except Exception as e: # noqa: BLE001 # pragma: no cover 

5486 exception = e 

5487 finally: 

5488 assert exception is None, ( 1I

5489 'exception querying suitable SSH keys' 

5490 ) 

5491 

5492 @Parametrize.KEY_TO_PHRASE_SETTINGS 

5493 def test_400_key_to_phrase( 

5494 self, 

5495 ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient, 

5496 list_keys_action: ListKeysAction | None, 

5497 system_support_action: SystemSupportAction | None, 

5498 address_action: SocketAddressAction | None, 

5499 sign_action: SignAction, 

5500 pattern: str, 

5501 ) -> None: 

5502 """All errors in [`cli_helpers.key_to_phrase`][] are handled.""" 

5503 

5504 class ErrCallback(BaseException): 1c

5505 def __init__(self, *args: Any, **kwargs: Any) -> None: 1c

5506 super().__init__(*args[:1]) 1c

5507 self.args = args 1c

5508 self.kwargs = kwargs 1c

5509 

5510 def err(*args: Any, **_kwargs: Any) -> NoReturn: 1c

5511 raise ErrCallback(*args, **_kwargs) 1c

5512 

5513 with pytest.MonkeyPatch.context() as monkeypatch: 1c

5514 loaded_keys = list( 1c

5515 ssh_agent_client_with_test_keys_loaded.list_keys() 

5516 ) 

5517 loaded_key = base64.standard_b64encode(loaded_keys[0][0]) 1c

5518 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', sign_action) 1c

5519 if list_keys_action: 1c

5520 monkeypatch.setattr( 1c

5521 ssh_agent.SSHAgentClient, 'list_keys', list_keys_action 

5522 ) 

5523 if address_action: 1c

5524 address_action(monkeypatch) 1c

5525 if system_support_action: 1c

5526 system_support_action(monkeypatch) 1c

5527 with pytest.raises(ErrCallback, match=pattern) as excinfo: 1c

5528 cli_helpers.key_to_phrase(loaded_key, error_callback=err) 1c

5529 if list_keys_action == ListKeysAction.FAIL_RUNTIME: 1c

5530 assert excinfo.value.kwargs 1c

5531 assert isinstance( 1c

5532 excinfo.value.kwargs['exc_info'], 

5533 ssh_agent.SSHAgentFailedError, 

5534 ) 

5535 assert excinfo.value.kwargs['exc_info'].__context__ is not None 1c

5536 assert isinstance( 1c

5537 excinfo.value.kwargs['exc_info'].__context__, 

5538 ssh_agent.TrailingDataError, 

5539 ) 

5540 

5541 

5542# TODO(the-13th-letter): Remove this class in v1.0. 

5543# https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#upgrading-to-v1.0 

5544class TestCLITransition: 

5545 """Transition tests for the command-line interface up to v1.0.""" 

5546 

5547 @Parametrize.BASE_CONFIG_VARIATIONS 

5548 def test_110_load_config_backup( 

5549 self, 

5550 config: Any, 

5551 ) -> None: 

5552 """Loading the old settings file works.""" 

5553 runner = tests.CliRunner(mix_stderr=False) 2hb

5554 # TODO(the-13th-letter): Rewrite using parenthesized 

5555 # with-statements. 

5556 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5557 with contextlib.ExitStack() as stack: 2hb

5558 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2hb

5559 stack.enter_context( 2hb

5560 tests.isolated_config( 

5561 monkeypatch=monkeypatch, 

5562 runner=runner, 

5563 ) 

5564 ) 

5565 cli_helpers.config_filename( 2hb

5566 subsystem='old settings.json' 

5567 ).write_text(json.dumps(config, indent=2) + '\n', encoding='UTF-8') 

5568 assert cli_helpers.migrate_and_load_old_config()[0] == config 2hb

5569 

5570 @Parametrize.BASE_CONFIG_VARIATIONS 

5571 def test_111_migrate_config( 

5572 self, 

5573 config: Any, 

5574 ) -> None: 

5575 """Migrating the old settings file works.""" 

5576 runner = tests.CliRunner(mix_stderr=False) 2ib

5577 # TODO(the-13th-letter): Rewrite using parenthesized 

5578 # with-statements. 

5579 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5580 with contextlib.ExitStack() as stack: 2ib

5581 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2ib

5582 stack.enter_context( 2ib

5583 tests.isolated_config( 

5584 monkeypatch=monkeypatch, 

5585 runner=runner, 

5586 ) 

5587 ) 

5588 cli_helpers.config_filename( 2ib

5589 subsystem='old settings.json' 

5590 ).write_text(json.dumps(config, indent=2) + '\n', encoding='UTF-8') 

5591 assert cli_helpers.migrate_and_load_old_config() == (config, None) 2ib

5592 

5593 @Parametrize.BASE_CONFIG_VARIATIONS 

5594 def test_112_migrate_config_error( 

5595 self, 

5596 config: Any, 

5597 ) -> None: 

5598 """Migrating the old settings file atop a directory fails.""" 

5599 runner = tests.CliRunner(mix_stderr=False) 14

5600 # TODO(the-13th-letter): Rewrite using parenthesized 

5601 # with-statements. 

5602 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5603 with contextlib.ExitStack() as stack: 14

5604 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 14

5605 stack.enter_context( 14

5606 tests.isolated_config( 

5607 monkeypatch=monkeypatch, 

5608 runner=runner, 

5609 ) 

5610 ) 

5611 cli_helpers.config_filename( 14

5612 subsystem='old settings.json' 

5613 ).write_text(json.dumps(config, indent=2) + '\n', encoding='UTF-8') 

5614 cli_helpers.config_filename(subsystem='vault').mkdir( 14

5615 parents=True, exist_ok=True 

5616 ) 

5617 config2, err = cli_helpers.migrate_and_load_old_config() 14

5618 assert config2 == config 14

5619 assert isinstance(err, OSError) 14

5620 # The Annoying OS uses EEXIST, other OSes use EISDIR. 

5621 assert err.errno in {errno.EISDIR, errno.EEXIST} 14

5622 

5623 @Parametrize.BAD_CONFIGS 

5624 def test_113_migrate_config_error_bad_config_value( 

5625 self, 

5626 config: Any, 

5627 ) -> None: 

5628 """Migrating an invalid old settings file fails.""" 

5629 runner = tests.CliRunner(mix_stderr=False) 1^

5630 # TODO(the-13th-letter): Rewrite using parenthesized 

5631 # with-statements. 

5632 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5633 with contextlib.ExitStack() as stack: 1^

5634 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1^

5635 stack.enter_context( 1^

5636 tests.isolated_config( 

5637 monkeypatch=monkeypatch, 

5638 runner=runner, 

5639 ) 

5640 ) 

5641 cli_helpers.config_filename( 1^

5642 subsystem='old settings.json' 

5643 ).write_text(json.dumps(config, indent=2) + '\n', encoding='UTF-8') 

5644 with pytest.raises( 1^

5645 ValueError, match=cli_helpers.INVALID_VAULT_CONFIG 

5646 ): 

5647 cli_helpers.migrate_and_load_old_config() 1^

5648 

5649 def test_200_forward_export_vault_path_parameter( 

5650 self, 

5651 caplog: pytest.LogCaptureFixture, 

5652 ) -> None: 

5653 """Forwarding arguments from "export" to "export vault" works.""" 

5654 pytest.importorskip('cryptography', minversion='38.0') 1V

5655 runner = tests.CliRunner(mix_stderr=False) 1V

5656 # TODO(the-13th-letter): Rewrite using parenthesized 

5657 # with-statements. 

5658 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5659 with contextlib.ExitStack() as stack: 1V

5660 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1V

5661 stack.enter_context( 1V

5662 tests.isolated_vault_exporter_config( 

5663 monkeypatch=monkeypatch, 

5664 runner=runner, 

5665 vault_config=tests.VAULT_V03_CONFIG, 

5666 vault_key=tests.VAULT_MASTER_KEY, 

5667 ) 

5668 ) 

5669 monkeypatch.setenv('VAULT_KEY', tests.VAULT_MASTER_KEY) 1V

5670 result = runner.invoke( 1V

5671 cli.derivepassphrase, 

5672 ['export', 'VAULT_PATH'], 

5673 ) 

5674 assert result.clean_exit(empty_stderr=False), 'expected clean exit' 1V

5675 assert tests.deprecation_warning_emitted( 1V

5676 'A subcommand will be required here in v1.0', caplog.record_tuples 

5677 ) 

5678 assert tests.deprecation_warning_emitted( 1V

5679 'Defaulting to subcommand "vault"', caplog.record_tuples 

5680 ) 

5681 assert json.loads(result.stdout) == tests.VAULT_V03_CONFIG_DATA 1V

5682 

5683 def test_201_forward_export_vault_empty_commandline( 

5684 self, 

5685 caplog: pytest.LogCaptureFixture, 

5686 ) -> None: 

5687 """Deferring from "export" to "export vault" works.""" 

5688 pytest.importorskip('cryptography', minversion='38.0') 19

5689 runner = tests.CliRunner(mix_stderr=False) 19

5690 # TODO(the-13th-letter): Rewrite using parenthesized 

5691 # with-statements. 

5692 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5693 with contextlib.ExitStack() as stack: 19

5694 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 19

5695 stack.enter_context( 19

5696 tests.isolated_config( 

5697 monkeypatch=monkeypatch, 

5698 runner=runner, 

5699 ) 

5700 ) 

5701 result = runner.invoke( 19

5702 cli.derivepassphrase, 

5703 ['export'], 

5704 ) 

5705 assert tests.deprecation_warning_emitted( 19

5706 'A subcommand will be required here in v1.0', caplog.record_tuples 

5707 ) 

5708 assert tests.deprecation_warning_emitted( 19

5709 'Defaulting to subcommand "vault"', caplog.record_tuples 

5710 ) 

5711 assert result.error_exit(error="Missing argument 'PATH'"), ( 19

5712 'expected error exit and known error type' 

5713 ) 

5714 

5715 @Parametrize.CHARSET_NAME 

5716 def test_210_forward_vault_disable_character_set( 

5717 self, 

5718 caplog: pytest.LogCaptureFixture, 

5719 charset_name: str, 

5720 ) -> None: 

5721 """Forwarding arguments from top-level to "vault" works.""" 

5722 option = f'--{charset_name}' 1J

5723 charset = vault.Vault.CHARSETS[charset_name].decode('ascii') 1J

5724 runner = tests.CliRunner(mix_stderr=False) 1J

5725 # TODO(the-13th-letter): Rewrite using parenthesized 

5726 # with-statements. 

5727 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5728 with contextlib.ExitStack() as stack: 1J

5729 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1J

5730 stack.enter_context( 1J

5731 tests.isolated_config( 

5732 monkeypatch=monkeypatch, 

5733 runner=runner, 

5734 ) 

5735 ) 

5736 monkeypatch.setattr( 1J

5737 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt 

5738 ) 

5739 result = runner.invoke( 1J

5740 cli.derivepassphrase, 

5741 [option, '0', '-p', '--', DUMMY_SERVICE], 

5742 input=DUMMY_PASSPHRASE, 

5743 catch_exceptions=False, 

5744 ) 

5745 assert result.clean_exit(empty_stderr=False), 'expected clean exit' 1J

5746 assert tests.deprecation_warning_emitted( 1J

5747 'A subcommand will be required here in v1.0', caplog.record_tuples 

5748 ) 

5749 assert tests.deprecation_warning_emitted( 1J

5750 'Defaulting to subcommand "vault"', caplog.record_tuples 

5751 ) 

5752 for c in charset: 1J

5753 assert c not in result.stdout, ( 1J

5754 f'derived password contains forbidden character {c!r}' 

5755 ) 

5756 

5757 def test_211_forward_vault_empty_command_line( 

5758 self, 

5759 caplog: pytest.LogCaptureFixture, 

5760 ) -> None: 

5761 """Deferring from top-level to "vault" works.""" 

5762 runner = tests.CliRunner(mix_stderr=False) 1(

5763 # TODO(the-13th-letter): Rewrite using parenthesized 

5764 # with-statements. 

5765 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5766 with contextlib.ExitStack() as stack: 1(

5767 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1(

5768 stack.enter_context( 1(

5769 tests.isolated_config( 

5770 monkeypatch=monkeypatch, 

5771 runner=runner, 

5772 ) 

5773 ) 

5774 result = runner.invoke( 1(

5775 cli.derivepassphrase, 

5776 [], 

5777 input=DUMMY_PASSPHRASE, 

5778 catch_exceptions=False, 

5779 ) 

5780 assert tests.deprecation_warning_emitted( 1(

5781 'A subcommand will be required here in v1.0', caplog.record_tuples 

5782 ) 

5783 assert tests.deprecation_warning_emitted( 1(

5784 'Defaulting to subcommand "vault"', caplog.record_tuples 

5785 ) 

5786 assert result.error_exit( 1(

5787 error='Deriving a passphrase requires a SERVICE.' 

5788 ), 'expected error exit and known error type' 

5789 

5790 def test_300_export_using_old_config_file( 

5791 self, 

5792 caplog: pytest.LogCaptureFixture, 

5793 ) -> None: 

5794 """Exporting from (and migrating) the old settings file works.""" 

5795 caplog.set_level(logging.INFO) 15

5796 runner = tests.CliRunner(mix_stderr=False) 15

5797 # TODO(the-13th-letter): Rewrite using parenthesized 

5798 # with-statements. 

5799 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5800 with contextlib.ExitStack() as stack: 15

5801 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 15

5802 stack.enter_context( 15

5803 tests.isolated_config( 

5804 monkeypatch=monkeypatch, 

5805 runner=runner, 

5806 ) 

5807 ) 

5808 cli_helpers.config_filename( 15

5809 subsystem='old settings.json' 

5810 ).write_text( 

5811 json.dumps( 

5812 {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}}, 

5813 indent=2, 

5814 ) 

5815 + '\n', 

5816 encoding='UTF-8', 

5817 ) 

5818 result = runner.invoke( 15

5819 cli.derivepassphrase_vault, 

5820 ['--export', '-'], 

5821 catch_exceptions=False, 

5822 ) 

5823 assert result.clean_exit(), 'expected clean exit' 15

5824 assert tests.deprecation_warning_emitted( 15

5825 'v0.1-style config file', caplog.record_tuples 

5826 ), 'expected known warning message in stderr' 

5827 assert tests.deprecation_info_emitted( 15

5828 'Successfully migrated to ', caplog.record_tuples 

5829 ), 'expected known warning message in stderr' 

5830 

5831 def test_300a_export_using_old_config_file_migration_error( 

5832 self, 

5833 caplog: pytest.LogCaptureFixture, 

5834 ) -> None: 

5835 """Exporting from (and not migrating) the old settings file fails.""" 

5836 runner = tests.CliRunner(mix_stderr=False) 1K

5837 # TODO(the-13th-letter): Rewrite using parenthesized 

5838 # with-statements. 

5839 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5840 with contextlib.ExitStack() as stack: 1K

5841 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1K

5842 stack.enter_context( 1K

5843 tests.isolated_config( 

5844 monkeypatch=monkeypatch, 

5845 runner=runner, 

5846 ) 

5847 ) 

5848 cli_helpers.config_filename( 1K

5849 subsystem='old settings.json' 

5850 ).write_text( 

5851 json.dumps( 

5852 {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}}, 

5853 indent=2, 

5854 ) 

5855 + '\n', 

5856 encoding='UTF-8', 

5857 ) 

5858 

5859 def raiser(*_args: Any, **_kwargs: Any) -> None: 1K

5860 raise OSError( 1K

5861 errno.EACCES, 

5862 os.strerror(errno.EACCES), 

5863 cli_helpers.config_filename(subsystem='vault'), 

5864 ) 

5865 

5866 monkeypatch.setattr(os, 'replace', raiser) 1K

5867 monkeypatch.setattr(pathlib.Path, 'rename', raiser) 1K

5868 result = runner.invoke( 1K

5869 cli.derivepassphrase_vault, 

5870 ['--export', '-'], 

5871 catch_exceptions=False, 

5872 ) 

5873 assert result.clean_exit(), 'expected clean exit' 1K

5874 assert tests.deprecation_warning_emitted( 1K

5875 'v0.1-style config file', caplog.record_tuples 

5876 ), 'expected known warning message in stderr' 

5877 assert tests.warning_emitted( 1K

5878 'Failed to migrate to ', caplog.record_tuples 

5879 ), 'expected known warning message in stderr' 

5880 

5881 def test_400_completion_service_name_old_config_file( 

5882 self, 

5883 ) -> None: 

5884 """Completing service names from the old settings file works.""" 

5885 config = {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}} 16

5886 runner = tests.CliRunner(mix_stderr=False) 16

5887 # TODO(the-13th-letter): Rewrite using parenthesized 

5888 # with-statements. 

5889 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

5890 with contextlib.ExitStack() as stack: 16

5891 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 16

5892 stack.enter_context( 16

5893 tests.isolated_vault_config( 

5894 monkeypatch=monkeypatch, 

5895 runner=runner, 

5896 vault_config=config, 

5897 ) 

5898 ) 

5899 old_name = cli_helpers.config_filename( 16

5900 subsystem='old settings.json' 

5901 ) 

5902 new_name = cli_helpers.config_filename(subsystem='vault') 16

5903 old_name.unlink(missing_ok=True) 16

5904 new_name.rename(old_name) 16

5905 assert cli_helpers.shell_complete_service( 16

5906 click.Context(cli.derivepassphrase), 

5907 click.Argument(['some_parameter']), 

5908 '', 

5909 ) == [DUMMY_SERVICE] 

5910 

5911 

5912KNOWN_SERVICES = (DUMMY_SERVICE, 'email', 'bank', 'work') 

5913"""Known service names. Used for the [`ConfigManagementStateMachine`][].""" 

5914VALID_PROPERTIES = ( 

5915 'length', 

5916 'repeat', 

5917 'upper', 

5918 'lower', 

5919 'number', 

5920 'space', 

5921 'dash', 

5922 'symbol', 

5923) 

5924"""Known vault properties. Used for the [`ConfigManagementStateMachine`][].""" 

5925 

5926 

5927def build_reduced_vault_config_settings( 

5928 config: _types.VaultConfigServicesSettings, 

5929 keys_to_prune: frozenset[str], 

5930) -> _types.VaultConfigServicesSettings: 

5931 """Return a service settings object with certain keys pruned. 

5932 

5933 Args: 

5934 config: 

5935 The original service settings object. 

5936 keys_to_prune: 

5937 The keys to prune from the settings object. 

5938 

5939 """ 

5940 config2 = copy.deepcopy(config) 1b

5941 for key in keys_to_prune: 1b

5942 config2.pop(key, None) # type: ignore[misc] 1b

5943 return config2 1b

5944 

5945 

5946SERVICES_STRATEGY = strategies.builds( 

5947 build_reduced_vault_config_settings, 

5948 tests.vault_full_service_config(), 

5949 strategies.sets( 

5950 strategies.sampled_from(VALID_PROPERTIES), 

5951 max_size=7, 

5952 ), 

5953) 

5954"""A hypothesis strategy to build incomplete service configurations.""" 

5955 

5956 

5957def services_strategy() -> strategies.SearchStrategy[ 

5958 _types.VaultConfigServicesSettings 

5959]: 

5960 """Return a strategy to build incomplete service configurations.""" 

5961 return SERVICES_STRATEGY 1ab

5962 

5963 

5964def assemble_config( 

5965 global_data: _types.VaultConfigGlobalSettings, 

5966 service_data: list[tuple[str, _types.VaultConfigServicesSettings]], 

5967) -> _types.VaultConfig: 

5968 """Return a vault config using the global and service data.""" 

5969 services_dict = dict(service_data) 1b

5970 return ( 1b

5971 {'global': global_data, 'services': services_dict} 

5972 if global_data 

5973 else {'services': services_dict} 

5974 ) 

5975 

5976 

5977@strategies.composite 

5978def draw_service_name_and_data( 

5979 draw: hypothesis.strategies.DrawFn, 

5980 num_entries: int, 

5981) -> tuple[tuple[str, _types.VaultConfigServicesSettings], ...]: 

5982 """Draw a service name and settings, as a hypothesis strategy. 

5983 

5984 Will draw service names from [`KNOWN_SERVICES`][] and service 

5985 settings via [`services_strategy`][]. 

5986 

5987 Args: 

5988 draw: 

5989 The `draw` function, as provided for by hypothesis. 

5990 num_entries: 

5991 The number of services to draw. 

5992 

5993 Returns: 

5994 A sequence of pairs of service names and service settings. 

5995 

5996 """ 

5997 possible_services = list(KNOWN_SERVICES) 1b

5998 selected_services: list[str] = [] 1b

5999 for _ in range(num_entries): 1b

6000 selected_services.append( 1b

6001 draw(strategies.sampled_from(possible_services)) 

6002 ) 

6003 possible_services.remove(selected_services[-1]) 1b

6004 return tuple( 1b

6005 (service, draw(services_strategy())) for service in selected_services 

6006 ) 

6007 

6008 

6009VAULT_FULL_CONFIG = strategies.builds( 

6010 assemble_config, 

6011 services_strategy(), 

6012 strategies.integers( 

6013 min_value=2, 

6014 max_value=4, 

6015 ).flatmap(draw_service_name_and_data), 

6016) 

6017"""A hypothesis strategy to build full vault configurations.""" 

6018 

6019 

6020def vault_full_config() -> strategies.SearchStrategy[_types.VaultConfig]: 

6021 """Return a strategy to build full vault configurations.""" 

6022 return VAULT_FULL_CONFIG 

6023 

6024 

6025class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): 

6026 """A state machine recording changes in the vault configuration. 

6027 

6028 Record possible configuration states in bundles, then in each rule, 

6029 take a configuration and manipulate it somehow. 

6030 

6031 Attributes: 

6032 setting: 

6033 A bundle for single-service settings. 

6034 configuration: 

6035 A bundle for full vault configurations. 

6036 

6037 """ 

6038 

6039 def __init__(self) -> None: 

6040 """Initialize self, set up context managers and enter them.""" 

6041 super().__init__() 1b

6042 self.runner = tests.CliRunner(mix_stderr=False) 1b

6043 self.exit_stack = contextlib.ExitStack().__enter__() 1b

6044 self.monkeypatch = self.exit_stack.enter_context( 1b

6045 pytest.MonkeyPatch().context() 

6046 ) 

6047 self.isolated_config = self.exit_stack.enter_context( 1b

6048 tests.isolated_vault_config( 

6049 monkeypatch=self.monkeypatch, 

6050 runner=self.runner, 

6051 vault_config={'services': {}}, 

6052 ) 

6053 ) 

6054 

6055 setting: stateful.Bundle[_types.VaultConfigServicesSettings] = ( 

6056 stateful.Bundle('setting') 

6057 ) 

6058 """""" 

6059 configuration: stateful.Bundle[_types.VaultConfig] = stateful.Bundle( 

6060 'configuration' 

6061 ) 

6062 """""" 

6063 

6064 @stateful.initialize( 

6065 target=configuration, 

6066 configs=strategies.lists( 

6067 vault_full_config(), 

6068 min_size=8, 

6069 max_size=8, 

6070 ), 

6071 ) 

6072 def declare_initial_configs( 

6073 self, 

6074 configs: Iterable[_types.VaultConfig], 

6075 ) -> stateful.MultipleResults[_types.VaultConfig]: 

6076 """Initialize the configuration bundle with eight configurations.""" 

6077 return stateful.multiple(*configs) 1b

6078 

6079 @stateful.initialize( 

6080 target=setting, 

6081 configs=strategies.lists( 

6082 vault_full_config(), 

6083 min_size=4, 

6084 max_size=4, 

6085 ), 

6086 ) 

6087 def extract_initial_settings( 

6088 self, 

6089 configs: list[_types.VaultConfig], 

6090 ) -> stateful.MultipleResults[_types.VaultConfigServicesSettings]: 

6091 """Initialize the settings bundle with four service settings.""" 

6092 settings: list[_types.VaultConfigServicesSettings] = [] 1b

6093 for c in configs: 1b

6094 settings.extend(c['services'].values()) 1b

6095 return stateful.multiple(*map(copy.deepcopy, settings)) 1b

6096 

6097 @staticmethod 

6098 def fold_configs( 

6099 c1: _types.VaultConfig, c2: _types.VaultConfig 

6100 ) -> _types.VaultConfig: 

6101 """Fold `c1` into `c2`, overriding the latter.""" 

6102 new_global_dict = c1.get('global', c2.get('global')) 1b

6103 if new_global_dict is not None: 1b

6104 return { 1b

6105 'global': new_global_dict, 

6106 'services': {**c2['services'], **c1['services']}, 

6107 } 

6108 return { 1b

6109 'services': {**c2['services'], **c1['services']}, 

6110 } 

6111 

6112 @stateful.rule( 

6113 target=configuration, 

6114 config=configuration, 

6115 setting=setting.filter(bool), 

6116 maybe_unset=strategies.sets( 

6117 strategies.sampled_from(VALID_PROPERTIES), 

6118 max_size=3, 

6119 ), 

6120 overwrite=strategies.booleans(), 

6121 ) 

6122 def set_globals( 

6123 self, 

6124 config: _types.VaultConfig, 

6125 setting: _types.VaultConfigGlobalSettings, 

6126 maybe_unset: set[str], 

6127 overwrite: bool, 

6128 ) -> _types.VaultConfig: 

6129 """Set the global settings of a configuration. 

6130 

6131 Args: 

6132 config: 

6133 The configuration to edit. 

6134 setting: 

6135 The new global settings. 

6136 maybe_unset: 

6137 Settings keys to additionally unset, if not already 

6138 present in the new settings. Corresponds to the 

6139 `--unset` command-line argument. 

6140 overwrite: 

6141 Overwrite the settings object if true, or merge if 

6142 false. Corresponds to the `--overwrite-existing` and 

6143 `--merge-existing` command-line arguments. 

6144 

6145 Returns: 

6146 The amended configuration. 

6147 

6148 """ 

6149 cli_helpers.save_config(config) 1b

6150 config_global = config.get('global', {}) 1b

6151 maybe_unset = set(maybe_unset) - setting.keys() 1b

6152 if overwrite: 1b

6153 config['global'] = config_global = {} 1b

6154 elif maybe_unset: 1b

6155 for key in maybe_unset: 1b

6156 config_global.pop(key, None) # type: ignore[misc] 1b

6157 config.setdefault('global', {}).update(setting) 1b

6158 assert _types.is_vault_config(config) 1b

6159 # NOTE: This relies on settings_obj containing only the keys 

6160 # "length", "repeat", "upper", "lower", "number", "space", 

6161 # "dash" and "symbol". 

6162 result = self.runner.invoke( 1b

6163 cli.derivepassphrase_vault, 

6164 [ 

6165 '--config', 

6166 '--overwrite-existing' if overwrite else '--merge-existing', 

6167 ] 

6168 + [f'--unset={key}' for key in maybe_unset] 

6169 + [ 

6170 f'--{key}={value}' 

6171 for key, value in setting.items() 

6172 if key in VALID_PROPERTIES 

6173 ], 

6174 catch_exceptions=False, 

6175 ) 

6176 assert result.clean_exit(empty_stderr=False) 1b

6177 assert cli_helpers.load_config() == config 1b

6178 return config 1b

6179 

6180 @stateful.rule( 

6181 target=configuration, 

6182 config=configuration, 

6183 service=strategies.sampled_from(KNOWN_SERVICES), 

6184 setting=setting.filter(bool), 

6185 maybe_unset=strategies.sets( 

6186 strategies.sampled_from(VALID_PROPERTIES), 

6187 max_size=3, 

6188 ), 

6189 overwrite=strategies.booleans(), 

6190 ) 

6191 def set_service( 

6192 self, 

6193 config: _types.VaultConfig, 

6194 service: str, 

6195 setting: _types.VaultConfigServicesSettings, 

6196 maybe_unset: set[str], 

6197 overwrite: bool, 

6198 ) -> _types.VaultConfig: 

6199 """Set the named service settings for a configuration. 

6200 

6201 Args: 

6202 config: 

6203 The configuration to edit. 

6204 service: 

6205 The name of the service to set. 

6206 setting: 

6207 The new service settings. 

6208 maybe_unset: 

6209 Settings keys to additionally unset, if not already 

6210 present in the new settings. Corresponds to the 

6211 `--unset` command-line argument. 

6212 overwrite: 

6213 Overwrite the settings object if true, or merge if 

6214 false. Corresponds to the `--overwrite-existing` and 

6215 `--merge-existing` command-line arguments. 

6216 

6217 Returns: 

6218 The amended configuration. 

6219 

6220 """ 

6221 cli_helpers.save_config(config) 1b

6222 config_service = config['services'].get(service, {}) 1b

6223 maybe_unset = set(maybe_unset) - setting.keys() 1b

6224 if overwrite: 1b

6225 config['services'][service] = config_service = {} 1b

6226 elif maybe_unset: 1b

6227 for key in maybe_unset: 1b

6228 config_service.pop(key, None) # type: ignore[misc] 1b

6229 config['services'].setdefault(service, {}).update(setting) 1b

6230 assert _types.is_vault_config(config) 1b

6231 # NOTE: This relies on settings_obj containing only the keys 

6232 # "length", "repeat", "upper", "lower", "number", "space", 

6233 # "dash" and "symbol". 

6234 result = self.runner.invoke( 1b

6235 cli.derivepassphrase_vault, 

6236 [ 

6237 '--config', 

6238 '--overwrite-existing' if overwrite else '--merge-existing', 

6239 ] 

6240 + [f'--unset={key}' for key in maybe_unset] 

6241 + [ 

6242 f'--{key}={value}' 

6243 for key, value in setting.items() 

6244 if key in VALID_PROPERTIES 

6245 ] 

6246 + ['--', service], 

6247 catch_exceptions=False, 

6248 ) 

6249 assert result.clean_exit(empty_stderr=False) 1b

6250 assert cli_helpers.load_config() == config 1b

6251 return config 1b

6252 

6253 @stateful.rule( 

6254 target=configuration, 

6255 config=configuration, 

6256 ) 

6257 def purge_global( 

6258 self, 

6259 config: _types.VaultConfig, 

6260 ) -> _types.VaultConfig: 

6261 """Purge the globals of a configuration. 

6262 

6263 Args: 

6264 config: 

6265 The configuration to edit. 

6266 

6267 Returns: 

6268 The pruned configuration. 

6269 

6270 """ 

6271 cli_helpers.save_config(config) 1b

6272 config.pop('global', None) 1b

6273 result = self.runner.invoke( 1b

6274 cli.derivepassphrase_vault, 

6275 ['--delete-globals'], 

6276 input='y', 

6277 catch_exceptions=False, 

6278 ) 

6279 assert result.clean_exit(empty_stderr=False) 1b

6280 assert cli_helpers.load_config() == config 1b

6281 return config 1b

6282 

6283 @stateful.rule( 

6284 target=configuration, 

6285 config_and_service=configuration.filter( 

6286 lambda c: bool(c['services']) 

6287 ).flatmap( 

6288 lambda c: strategies.tuples( 

6289 strategies.just(c), 

6290 strategies.sampled_from(tuple(c['services'].keys())), 

6291 ) 

6292 ), 

6293 ) 

6294 def purge_service( 

6295 self, 

6296 config_and_service: tuple[_types.VaultConfig, str], 

6297 ) -> _types.VaultConfig: 

6298 """Purge the settings of a named service in a configuration. 

6299 

6300 Args: 

6301 config_and_service: 

6302 A 2-tuple containing the configuration to edit, and the 

6303 service name to purge. 

6304 

6305 Returns: 

6306 The pruned configuration. 

6307 

6308 """ 

6309 config, service = config_and_service 1b

6310 cli_helpers.save_config(config) 1b

6311 config['services'].pop(service, None) 1b

6312 result = self.runner.invoke( 1b

6313 cli.derivepassphrase_vault, 

6314 ['--delete', '--', service], 

6315 input='y', 

6316 catch_exceptions=False, 

6317 ) 

6318 assert result.clean_exit(empty_stderr=False) 1b

6319 assert cli_helpers.load_config() == config 1b

6320 return config 1b

6321 

6322 @stateful.rule( 

6323 target=configuration, 

6324 config=configuration, 

6325 ) 

6326 def purge_all( 

6327 self, 

6328 config: _types.VaultConfig, 

6329 ) -> _types.VaultConfig: 

6330 """Purge the entire configuration. 

6331 

6332 Args: 

6333 config: 

6334 The configuration to edit. 

6335 

6336 Returns: 

6337 The empty configuration. 

6338 

6339 """ 

6340 cli_helpers.save_config(config) 1b

6341 config = {'services': {}} 1b

6342 result = self.runner.invoke( 1b

6343 cli.derivepassphrase_vault, 

6344 ['--clear'], 

6345 input='y', 

6346 catch_exceptions=False, 

6347 ) 

6348 assert result.clean_exit(empty_stderr=False) 1b

6349 assert cli_helpers.load_config() == config 1b

6350 return config 1b

6351 

6352 @stateful.rule( 

6353 target=configuration, 

6354 base_config=configuration, 

6355 config_to_import=configuration, 

6356 overwrite=strategies.booleans(), 

6357 ) 

6358 def import_configuration( 

6359 self, 

6360 base_config: _types.VaultConfig, 

6361 config_to_import: _types.VaultConfig, 

6362 overwrite: bool, 

6363 ) -> _types.VaultConfig: 

6364 """Import the given configuration into a base configuration. 

6365 

6366 Args: 

6367 base_config: 

6368 The configuration to import into. 

6369 config_to_import: 

6370 The configuration to import. 

6371 overwrite: 

6372 Overwrite the base configuration if true, or merge if 

6373 false. Corresponds to the `--overwrite-existing` and 

6374 `--merge-existing` command-line arguments. 

6375 

6376 Returns: 

6377 The imported or merged configuration. 

6378 

6379 """ 

6380 cli_helpers.save_config(base_config) 1b

6381 config = ( 1b

6382 self.fold_configs(config_to_import, base_config) 

6383 if not overwrite 

6384 else config_to_import 

6385 ) 

6386 assert _types.is_vault_config(config) 1b

6387 result = self.runner.invoke( 1b

6388 cli.derivepassphrase_vault, 

6389 ['--import', '-'] 

6390 + (['--overwrite-existing'] if overwrite else []), 

6391 input=json.dumps(config_to_import), 

6392 catch_exceptions=False, 

6393 ) 

6394 assert result.clean_exit(empty_stderr=False) 1b

6395 assert cli_helpers.load_config() == config 1b

6396 return config 1b

6397 

6398 def teardown(self) -> None: 

6399 """Upon teardown, exit all contexts entered in `__init__`.""" 

6400 self.exit_stack.close() 1b

6401 

6402 

6403TestConfigManagement = ConfigManagementStateMachine.TestCase 

6404"""The [`unittest.TestCase`][] class that will actually be run.""" 

6405 

6406 

6407class FakeConfigurationMutexAction(NamedTuple): 

6408 """An action/a step in the [`FakeConfigurationMutexStateMachine`][]. 

6409 

6410 Attributes: 

6411 command_line: 

6412 The command-line for `derivepassphrase vault` to execute. 

6413 input: 

6414 The input to this command. 

6415 

6416 """ 

6417 

6418 command_line: list[str] 

6419 """""" 

6420 input: str | bytes | None = None 

6421 """""" 

6422 

6423 

6424def run_actions_handler( 

6425 id_num: int, 

6426 action: FakeConfigurationMutexAction, 

6427 *, 

6428 input_queue: queue.Queue, 

6429 output_queue: queue.Queue, 

6430 timeout: int, 

6431) -> None: 

6432 """Prepare the faked mutex, then run `action`. 

6433 

6434 This is a top-level handler function -- to be used in a new 

6435 [`multiprocessing.Process`][] -- to run a single action from the 

6436 [`FakeConfigurationMutexStateMachine`][]. Output from this function 

6437 must be sent down the output queue instead of relying on the call 

6438 stack. Additionally, because this runs in a separate process, we 

6439 need to restart coverage tracking if it is currently running. 

6440 

6441 Args: 

6442 id_num: 

6443 The internal ID of this subprocess. 

6444 action: 

6445 The action to execute. 

6446 input_queue: 

6447 The queue for data passed from the manager/parent process to 

6448 this subprocess. 

6449 output_queue: 

6450 The queue for data passed from this subprocess to the 

6451 manager/parent process. 

6452 timeout: 

6453 The maximum amount of time to wait for a data transfer along 

6454 the input or the output queue. If exceeded, we exit 

6455 immediately. 

6456 

6457 """ 

6458 with pytest.MonkeyPatch.context() as monkeypatch: 

6459 monkeypatch.setattr( 

6460 cli_helpers, 

6461 'configuration_mutex', 

6462 lambda: FakeConfigurationMutexStateMachine.ConfigurationMutexStub( 

6463 my_id=id_num, 

6464 input_queue=input_queue, 

6465 output_queue=output_queue, 

6466 timeout=timeout, 

6467 ), 

6468 ) 

6469 runner = tests.CliRunner(mix_stderr=False) 

6470 try: 

6471 result = runner.invoke( 

6472 cli.derivepassphrase_vault, 

6473 args=action.command_line, 

6474 input=action.input, 

6475 catch_exceptions=True, 

6476 ) 

6477 output_queue.put( 

6478 FakeConfigurationMutexStateMachine.IPCMessage( 

6479 id_num, 

6480 'result', 

6481 ( 

6482 result.clean_exit(empty_stderr=False), 

6483 copy.copy(result.stdout), 

6484 copy.copy(result.stderr), 

6485 ), 

6486 ), 

6487 block=True, 

6488 timeout=timeout, 

6489 ) 

6490 except Exception as exc: # pragma: no cover # noqa: BLE001 

6491 output_queue.put( 

6492 FakeConfigurationMutexStateMachine.IPCMessage( 

6493 id_num, 'exception', exc 

6494 ), 

6495 block=False, 

6496 ) 

6497 

6498 

6499@hypothesis.settings( 

6500 stateful_step_count=tests.get_concurrency_step_count(), 

6501 deadline=None, 

6502) 

6503class FakeConfigurationMutexStateMachine(stateful.RuleBasedStateMachine): 

6504 """A state machine simulating the (faked) configuration mutex. 

6505 

6506 Generate an ordered set of concurrent writers to the 

6507 derivepassphrase configuration, then test that the writers' accesses 

6508 are serialized correctly, i.e., test that the writers correctly use 

6509 the mutex to avoid concurrent accesses, under the assumption that 

6510 the mutex itself is correctly implemented. 

6511 

6512 We use a custom mutex implementation to both ensure that all writers 

6513 attempt to lock the configuration at the same time and that the lock 

6514 is granted in our desired order. This test is therefore independent 

6515 of the actual (operating system-specific) mutex implementation in 

6516 `derivepassphrase`. 

6517 

6518 Attributes: 

6519 setting: 

6520 A bundle for single-service settings. 

6521 configuration: 

6522 A bundle for full vault configurations. 

6523 

6524 """ 

6525 

6526 class IPCMessage(NamedTuple): 

6527 """A message for inter-process communication. 

6528 

6529 Used by the configuration mutex stub class to affect/signal the 

6530 control flow amongst the linked mutex clients. 

6531 

6532 Attributes: 

6533 child_id: 

6534 The ID of the sending or receiving child process. 

6535 message: 

6536 One of "ready", "go", "config", "result" or "exception". 

6537 payload: 

6538 The (optional) message payload. 

6539 

6540 """ 

6541 

6542 child_id: int 

6543 """""" 

6544 message: Literal['ready', 'go', 'config', 'result', 'exception'] 

6545 """""" 

6546 payload: object | None 

6547 """""" 

6548 

6549 class ConfigurationMutexStub(cli_helpers.ConfigurationMutex): 

6550 """Configuration mutex subclass that enforces a locking order. 

6551 

6552 Each configuration mutex stub object ("mutex client") has an 

6553 associated ID, and one read-only and one write-only pipe 

6554 (actually: [`multiprocessing.Queue`][] objects) to the "manager" 

6555 instance coordinating these stub objects. First, the mutex 

6556 client signals readiness, then the manager signals when the 

6557 mutex shall be considered "acquired", then finally the mutex 

6558 client sends the result back (simultaneously releasing the mutex 

6559 again). The manager may optionally send an abort signal if the 

6560 operations take too long. 

6561 

6562 This subclass also copies the effective vault configuration 

6563 to `intermediate_configs` upon releasing the lock. 

6564 

6565 """ 

6566 

6567 def __init__( 

6568 self, 

6569 *, 

6570 my_id: int, 

6571 timeout: int, 

6572 input_queue: queue.Queue[ 

6573 FakeConfigurationMutexStateMachine.IPCMessage 

6574 ], 

6575 output_queue: queue.Queue[ 

6576 FakeConfigurationMutexStateMachine.IPCMessage 

6577 ], 

6578 ) -> None: 

6579 """Initialize this mutex client. 

6580 

6581 Args: 

6582 my_id: 

6583 The ID of this client. 

6584 timeout: 

6585 The timeout for each get and put operation on the 

6586 queues. 

6587 input_queue: 

6588 The message queue for IPC messages from the manager 

6589 instance to this mutex client. 

6590 output_queue: 

6591 The message queue for IPC messages from this mutex 

6592 client to the manager instance. 

6593 

6594 """ 

6595 super().__init__() 

6596 

6597 def lock() -> None: 

6598 """Simulate locking of the mutex. 

6599 

6600 Issue a "ready" message, wait for a "go", then return. 

6601 If an exception occurs, issue an "exception" message, 

6602 then raise the exception. 

6603 

6604 """ 

6605 IPCMessage: TypeAlias = ( 

6606 FakeConfigurationMutexStateMachine.IPCMessage 

6607 ) 

6608 try: 

6609 output_queue.put( 

6610 IPCMessage(my_id, 'ready', None), 

6611 block=True, 

6612 timeout=timeout, 

6613 ) 

6614 ok = input_queue.get(block=True, timeout=timeout) 

6615 if ok != IPCMessage(my_id, 'go', None): # pragma: no cover 

6616 output_queue.put( 

6617 IPCMessage(my_id, 'exception', ok), block=False 

6618 ) 

6619 raise ( 

6620 ok[2] 

6621 if isinstance(ok[2], BaseException) 

6622 else RuntimeError(ok[2]) 

6623 ) 

6624 except (queue.Empty, queue.Full) as exc: # pragma: no cover 

6625 output_queue.put( 

6626 IPCMessage(my_id, 'exception', exc), block=False 

6627 ) 

6628 return 

6629 

6630 def unlock() -> None: 

6631 """Simulate unlocking of the mutex. 

6632 

6633 Issue a "config" message, then return. If an exception 

6634 occurs, issue an "exception" message, then raise the 

6635 exception. 

6636 

6637 """ 

6638 IPCMessage: TypeAlias = ( 

6639 FakeConfigurationMutexStateMachine.IPCMessage 

6640 ) 

6641 try: 

6642 output_queue.put( 

6643 IPCMessage( 

6644 my_id, 

6645 'config', 

6646 copy.copy(cli_helpers.load_config()), 

6647 ), 

6648 block=True, 

6649 timeout=timeout, 

6650 ) 

6651 except (queue.Empty, queue.Full) as exc: # pragma: no cover 

6652 output_queue.put( 

6653 IPCMessage(my_id, 'exception', exc), block=False 

6654 ) 

6655 raise 

6656 

6657 self.lock = lock 

6658 self.unlock = unlock 

6659 

6660 setting: stateful.Bundle[_types.VaultConfigServicesSettings] = ( 

6661 stateful.Bundle('setting') 

6662 ) 

6663 """""" 

6664 configuration: stateful.Bundle[_types.VaultConfig] = stateful.Bundle( 

6665 'configuration' 

6666 ) 

6667 """""" 

6668 

6669 def __init__(self, *args: Any, **kwargs: Any) -> None: 

6670 """Initialize the state machine.""" 

6671 super().__init__(*args, **kwargs) 1b

6672 self.actions: list[FakeConfigurationMutexAction] = [] 1b

6673 # Determine the step count by poking around in the hypothesis 

6674 # internals. As this isn't guaranteed to be stable, turn off 

6675 # coverage. 

6676 try: # pragma: no cover 1b

6677 settings: hypothesis.settings | None 

6678 settings = FakeConfigurationMutexStateMachine.TestCase.settings 1b

6679 except AttributeError: # pragma: no cover 

6680 settings = None 

6681 self.step_count = tests.get_concurrency_step_count(settings) 1b

6682 

6683 @stateful.initialize( 

6684 target=configuration, 

6685 configs=strategies.lists( 

6686 vault_full_config(), 

6687 min_size=8, 

6688 max_size=8, 

6689 ), 

6690 ) 

6691 def declare_initial_configs( 

6692 self, 

6693 configs: list[_types.VaultConfig], 

6694 ) -> stateful.MultipleResults[_types.VaultConfig]: 

6695 """Initialize the configuration bundle with eight configurations.""" 

6696 return stateful.multiple(*configs) 1b

6697 

6698 @stateful.initialize( 

6699 target=setting, 

6700 configs=strategies.lists( 

6701 vault_full_config(), 

6702 min_size=4, 

6703 max_size=4, 

6704 ), 

6705 ) 

6706 def extract_initial_settings( 

6707 self, 

6708 configs: list[_types.VaultConfig], 

6709 ) -> stateful.MultipleResults[_types.VaultConfigServicesSettings]: 

6710 """Initialize the settings bundle with four service settings.""" 

6711 settings: list[_types.VaultConfigServicesSettings] = [] 1b

6712 for c in configs: 1b

6713 settings.extend(c['services'].values()) 1b

6714 return stateful.multiple(*map(copy.deepcopy, settings)) 1b

6715 

6716 @stateful.initialize( 

6717 config=vault_full_config(), 

6718 ) 

6719 def declare_initial_action( 

6720 self, 

6721 config: _types.VaultConfig, 

6722 ) -> None: 

6723 """Initialize the actions bundle from the configuration bundle. 

6724 

6725 This is roughly comparable to the 

6726 [`add_import_configuration_action`][] general rule, but adding 

6727 it as a separate initialize rule avoids having to guard every 

6728 other action-amending rule against empty action sequences, which 

6729 would discard huge portions of the rule selection search space 

6730 and thus trigger loads of hypothesis health check warnings. 

6731 

6732 """ 

6733 command_line = ['--import', '-', '--overwrite-existing'] 1b

6734 input = json.dumps(config) # noqa: A001 1b

6735 hypothesis.note(f'# {command_line = }, {input = }') 1b

6736 action = FakeConfigurationMutexAction( 1b

6737 command_line=command_line, input=input 

6738 ) 

6739 self.actions.append(action) 1b

6740 

6741 @stateful.rule( 

6742 setting=setting.filter(bool), 

6743 maybe_unset=strategies.sets( 

6744 strategies.sampled_from(VALID_PROPERTIES), 

6745 max_size=3, 

6746 ), 

6747 overwrite=strategies.booleans(), 

6748 ) 

6749 def add_set_globals_action( 

6750 self, 

6751 setting: _types.VaultConfigGlobalSettings, 

6752 maybe_unset: set[str], 

6753 overwrite: bool, 

6754 ) -> None: 

6755 """Set the global settings of a configuration. 

6756 

6757 Args: 

6758 setting: 

6759 The new global settings. 

6760 maybe_unset: 

6761 Settings keys to additionally unset, if not already 

6762 present in the new settings. Corresponds to the 

6763 `--unset` command-line argument. 

6764 overwrite: 

6765 Overwrite the settings object if true, or merge if 

6766 false. Corresponds to the `--overwrite-existing` and 

6767 `--merge-existing` command-line arguments. 

6768 

6769 """ 

6770 maybe_unset = set(maybe_unset) - setting.keys() 1b

6771 command_line = ( 1b

6772 [ 

6773 '--config', 

6774 '--overwrite-existing' if overwrite else '--merge-existing', 

6775 ] 

6776 + [f'--unset={key}' for key in maybe_unset] 

6777 + [ 

6778 f'--{key}={value}' 

6779 for key, value in setting.items() 

6780 if key in VALID_PROPERTIES 

6781 ] 

6782 ) 

6783 input = None # noqa: A001 1b

6784 hypothesis.note(f'# {command_line = }, {input = }') 1b

6785 action = FakeConfigurationMutexAction( 1b

6786 command_line=command_line, input=input 

6787 ) 

6788 self.actions.append(action) 1b

6789 

6790 @stateful.rule( 

6791 service=strategies.sampled_from(KNOWN_SERVICES), 

6792 setting=setting.filter(bool), 

6793 maybe_unset=strategies.sets( 

6794 strategies.sampled_from(VALID_PROPERTIES), 

6795 max_size=3, 

6796 ), 

6797 overwrite=strategies.booleans(), 

6798 ) 

6799 def add_set_service_action( 

6800 self, 

6801 service: str, 

6802 setting: _types.VaultConfigServicesSettings, 

6803 maybe_unset: set[str], 

6804 overwrite: bool, 

6805 ) -> None: 

6806 """Set the named service settings for a configuration. 

6807 

6808 Args: 

6809 service: 

6810 The name of the service to set. 

6811 setting: 

6812 The new service settings. 

6813 maybe_unset: 

6814 Settings keys to additionally unset, if not already 

6815 present in the new settings. Corresponds to the 

6816 `--unset` command-line argument. 

6817 overwrite: 

6818 Overwrite the settings object if true, or merge if 

6819 false. Corresponds to the `--overwrite-existing` and 

6820 `--merge-existing` command-line arguments. 

6821 

6822 """ 

6823 maybe_unset = set(maybe_unset) - setting.keys() 1b

6824 command_line = ( 1b

6825 [ 

6826 '--config', 

6827 '--overwrite-existing' if overwrite else '--merge-existing', 

6828 ] 

6829 + [f'--unset={key}' for key in maybe_unset] 

6830 + [ 

6831 f'--{key}={value}' 

6832 for key, value in setting.items() 

6833 if key in VALID_PROPERTIES 

6834 ] 

6835 + ['--', service] 

6836 ) 

6837 input = None # noqa: A001 1b

6838 hypothesis.note(f'# {command_line = }, {input = }') 1b

6839 action = FakeConfigurationMutexAction( 1b

6840 command_line=command_line, input=input 

6841 ) 

6842 self.actions.append(action) 1b

6843 

6844 @stateful.rule() 

6845 def add_purge_global_action( 

6846 self, 

6847 ) -> None: 

6848 """Purge the globals of a configuration.""" 

6849 command_line = ['--delete-globals'] 1b

6850 input = None # 'y' # noqa: A001 1b

6851 hypothesis.note(f'# {command_line = }, {input = }') 1b

6852 action = FakeConfigurationMutexAction( 1b

6853 command_line=command_line, input=input 

6854 ) 

6855 self.actions.append(action) 1b

6856 

6857 @stateful.rule( 

6858 service=strategies.sampled_from(KNOWN_SERVICES), 

6859 ) 

6860 def add_purge_service_action( 

6861 self, 

6862 service: str, 

6863 ) -> None: 

6864 """Purge the settings of a named service in a configuration. 

6865 

6866 Args: 

6867 service: 

6868 The service name to purge. 

6869 

6870 """ 

6871 command_line = ['--delete', '--', service] 1b

6872 input = None # 'y' # noqa: A001 1b

6873 hypothesis.note(f'# {command_line = }, {input = }') 1b

6874 action = FakeConfigurationMutexAction( 1b

6875 command_line=command_line, input=input 

6876 ) 

6877 self.actions.append(action) 1b

6878 

6879 @stateful.rule() 

6880 def add_purge_all_action( 

6881 self, 

6882 ) -> None: 

6883 """Purge the entire configuration.""" 

6884 command_line = ['--clear'] 1b

6885 input = None # 'y' # noqa: A001 1b

6886 hypothesis.note(f'# {command_line = }, {input = }') 1b

6887 action = FakeConfigurationMutexAction( 1b

6888 command_line=command_line, input=input 

6889 ) 

6890 self.actions.append(action) 1b

6891 

6892 @stateful.rule( 

6893 config_to_import=configuration, 

6894 overwrite=strategies.booleans(), 

6895 ) 

6896 def add_import_configuration_action( 

6897 self, 

6898 config_to_import: _types.VaultConfig, 

6899 overwrite: bool, 

6900 ) -> None: 

6901 """Import the given configuration. 

6902 

6903 Args: 

6904 config_to_import: 

6905 The configuration to import. 

6906 overwrite: 

6907 Overwrite the base configuration if true, or merge if 

6908 false. Corresponds to the `--overwrite-existing` and 

6909 `--merge-existing` command-line arguments. 

6910 

6911 """ 

6912 command_line = ['--import', '-'] + ( 1b

6913 ['--overwrite-existing'] if overwrite else [] 

6914 ) 

6915 input = json.dumps(config_to_import) # noqa: A001 1b

6916 hypothesis.note(f'# {command_line = }, {input = }') 1b

6917 action = FakeConfigurationMutexAction( 1b

6918 command_line=command_line, input=input 

6919 ) 

6920 self.actions.append(action) 1b

6921 

6922 @stateful.precondition(lambda self: len(self.actions) > 0) 1ab

6923 @stateful.invariant() 

6924 def run_actions( # noqa: C901 

6925 self, 

6926 ) -> None: 

6927 """Run the actions, serially and concurrently. 

6928 

6929 Run the actions once serially, then once more concurrently with 

6930 the faked configuration mutex, and assert that both runs yield 

6931 identical intermediate and final results. 

6932 

6933 We must run the concurrent version in processes, not threads or 

6934 Python async functions, because the `click` testing machinery 

6935 manipulates global properties (e.g. the standard I/O streams, 

6936 the current directory, and the environment), and we require this 

6937 manipulation to happen in a time-overlapped manner. 

6938 

6939 However, running multiple processes increases the risk of the 

6940 operating system imposing process count or memory limits on us. 

6941 We therefore skip the test as a whole if we fail to start a new 

6942 process due to lack of necessary resources (memory, processes, 

6943 or open file descriptors). 

6944 

6945 """ 

6946 if not TYPE_CHECKING: # pragma: no branch 1b

6947 multiprocessing = pytest.importorskip('multiprocessing') 1b

6948 IPCMessage: TypeAlias = FakeConfigurationMutexStateMachine.IPCMessage 1b

6949 intermediate_configs: dict[int, _types.VaultConfig] = {} 1b

6950 intermediate_results: dict[ 1b

6951 int, tuple[bool, str | None, str | None] 

6952 ] = {} 

6953 true_configs: dict[int, _types.VaultConfig] = {} 1b

6954 true_results: dict[int, tuple[bool, str | None, str | None]] = {} 1b

6955 timeout = 30 # Hopefully slow enough to accomodate The Annoying OS. 1b

6956 actions = self.actions 1b

6957 mp = multiprocessing.get_context() 1b

6958 # Coverage tracking writes coverage data to the current working 

6959 # directory, but because the subprocesses are spawned within the 

6960 # `tests.isolated_vault_config` context manager, their starting 

6961 # working directory is the isolated one, not the original one. 

6962 orig_cwd = pathlib.Path.cwd() 1b

6963 

6964 fatal_process_creation_errnos = { 1b

6965 # Specified by POSIX for fork(3). 

6966 errno.ENOMEM, 

6967 # Specified by POSIX for fork(3). 

6968 errno.EAGAIN, 

6969 # Specified by Linux/glibc for fork(3) 

6970 getattr(errno, 'ENOSYS', errno.ENOMEM), 

6971 # Specified by POSIX for posix_spawn(3). 

6972 errno.EINVAL, 

6973 } 

6974 

6975 hypothesis.note(f'# {actions = }') 1b

6976 

6977 stack = contextlib.ExitStack() 1b

6978 with stack: 1b

6979 runner = tests.CliRunner(mix_stderr=False) 1b

6980 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1b

6981 stack.enter_context( 1b

6982 tests.isolated_vault_config( 

6983 monkeypatch=monkeypatch, 

6984 runner=runner, 

6985 vault_config={'services': {}}, 

6986 ) 

6987 ) 

6988 for i, action in enumerate(actions): 1b

6989 result = runner.invoke( 1b

6990 cli.derivepassphrase_vault, 

6991 args=action.command_line, 

6992 input=action.input, 

6993 catch_exceptions=True, 

6994 ) 

6995 true_configs[i] = copy.copy(cli_helpers.load_config()) 1b

6996 true_results[i] = ( 1b

6997 result.clean_exit(empty_stderr=False), 

6998 result.stdout, 

6999 result.stderr, 

7000 ) 

7001 

7002 with stack: # noqa: PLR1702 1b

7003 runner = tests.CliRunner(mix_stderr=False) 1b

7004 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1b

7005 stack.enter_context( 1b

7006 tests.isolated_vault_config( 

7007 monkeypatch=monkeypatch, 

7008 runner=runner, 

7009 vault_config={'services': {}}, 

7010 ) 

7011 ) 

7012 

7013 child_output_queue: multiprocessing.Queue[IPCMessage] = mp.Queue() 1b

7014 child_input_queues: list[ 1b

7015 multiprocessing.Queue[IPCMessage] | None 

7016 ] = [] 

7017 processes: list[multiprocessing.process.BaseProcess] = [] 1b

7018 processes_pending: set[multiprocessing.process.BaseProcess] = set() 1b

7019 ready_wait: set[int] = set() 1b

7020 

7021 try: 1b

7022 for i, action in enumerate(actions): 1b

7023 q: multiprocessing.Queue[IPCMessage] | None = mp.Queue() 1b

7024 try: 1b

7025 p: multiprocessing.process.BaseProcess = mp.Process( 1b

7026 name=f'fake-mutex-action-{i:02d}', 

7027 target=run_actions_handler, 

7028 kwargs={ 

7029 'id_num': i, 

7030 'timeout': timeout, 

7031 'action': action, 

7032 'input_queue': q, 

7033 'output_queue': child_output_queue, 

7034 }, 

7035 daemon=False, 

7036 ) 

7037 p.start() 1b

7038 except OSError as exc: # pragma: no cover 

7039 if exc.errno in fatal_process_creation_errnos: 

7040 pytest.skip( 

7041 'cannot test mutex functionality due to ' 

7042 'lack of system resources for ' 

7043 'creating enough subprocesses' 

7044 ) 

7045 raise 

7046 else: 

7047 processes.append(p) 1b

7048 processes_pending.add(p) 1b

7049 child_input_queues.append(q) 1b

7050 ready_wait.add(i) 1b

7051 

7052 while processes_pending: 1b

7053 try: 1b

7054 self.mainloop( 1b

7055 timeout=timeout, 

7056 child_output_queue=child_output_queue, 

7057 child_input_queues=child_input_queues, 

7058 ready_wait=ready_wait, 

7059 intermediate_configs=intermediate_configs, 

7060 intermediate_results=intermediate_results, 

7061 processes=processes, 

7062 processes_pending=processes_pending, 

7063 block=True, 

7064 ) 

7065 except Exception as exc: # pragma: no cover 

7066 for i, q in enumerate(child_input_queues): 

7067 if q: 

7068 q.put(IPCMessage(i, 'exception', exc)) 

7069 for p in processes_pending: 

7070 p.join(timeout=timeout) 

7071 raise 

7072 finally: 

7073 try: 1b

7074 while True: 1b

7075 try: 1b

7076 self.mainloop( 1b

7077 timeout=timeout, 

7078 child_output_queue=child_output_queue, 

7079 child_input_queues=child_input_queues, 

7080 ready_wait=ready_wait, 

7081 intermediate_configs=intermediate_configs, 

7082 intermediate_results=intermediate_results, 

7083 processes=processes, 

7084 processes_pending=processes_pending, 

7085 block=False, 

7086 ) 

7087 except queue.Empty: 1b

7088 break 1b

7089 finally: 

7090 # The subprocesses have this 

7091 # `tests.isolated_vault_config` directory as their 

7092 # startup and working directory, so systems like 

7093 # coverage tracking write their data files to this 

7094 # directory. We need to manually move them back to 

7095 # the starting working directory if they are to 

7096 # survive this test. 

7097 for coverage_file in pathlib.Path.cwd().glob( 1b

7098 '.coverage.*' 

7099 ): 

7100 shutil.move(coverage_file, orig_cwd) 1b

7101 hypothesis.note( 1b

7102 f'# {true_results = }, {intermediate_results = }, ' 

7103 f'identical = {true_results == intermediate_results}' 

7104 ) 

7105 hypothesis.note( 1b

7106 f'# {true_configs = }, {intermediate_configs = }, ' 

7107 f'identical = {true_configs == intermediate_configs}' 

7108 ) 

7109 assert intermediate_results == true_results 1b

7110 assert intermediate_configs == true_configs 1b

7111 

7112 @staticmethod 

7113 def mainloop( 

7114 *, 

7115 timeout: int, 

7116 child_output_queue: multiprocessing.Queue[ 

7117 FakeConfigurationMutexStateMachine.IPCMessage 

7118 ], 

7119 child_input_queues: list[ 

7120 multiprocessing.Queue[ 

7121 FakeConfigurationMutexStateMachine.IPCMessage 

7122 ] 

7123 | None 

7124 ], 

7125 ready_wait: set[int], 

7126 intermediate_configs: dict[int, _types.VaultConfig], 

7127 intermediate_results: dict[int, tuple[bool, str | None, str | None]], 

7128 processes: list[multiprocessing.process.BaseProcess], 

7129 processes_pending: set[multiprocessing.process.BaseProcess], 

7130 block: bool = True, 

7131 ) -> None: 

7132 IPCMessage: TypeAlias = FakeConfigurationMutexStateMachine.IPCMessage 1b

7133 msg = child_output_queue.get(block=block, timeout=timeout) 1b

7134 # TODO(the-13th-letter): Rewrite using structural pattern 

7135 # matching. 

7136 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

7137 if ( # pragma: no cover 1b

7138 isinstance(msg, IPCMessage) 

7139 and msg[1] == 'exception' 

7140 and isinstance(msg[2], Exception) 

7141 ): 

7142 e = msg[2] 

7143 raise e 

7144 if isinstance(msg, IPCMessage) and msg[1] == 'ready': 1b

7145 n = msg[0] 1b

7146 ready_wait.remove(n) 1b

7147 if not ready_wait: 1b

7148 assert child_input_queues 1b

7149 assert child_input_queues[0] 1b

7150 child_input_queues[0].put( 1b

7151 IPCMessage(0, 'go', None), 

7152 block=True, 

7153 timeout=timeout, 

7154 ) 

7155 elif isinstance(msg, IPCMessage) and msg[1] == 'config': 1b

7156 n = msg[0] 1b

7157 config = msg[2] 1b

7158 intermediate_configs[n] = cast('_types.VaultConfig', config) 1b

7159 elif isinstance(msg, IPCMessage) and msg[1] == 'result': 1b

7160 n = msg[0] 1b

7161 result_ = msg[2] 1b

7162 result_tuple: tuple[bool, str | None, str | None] = cast( 1b

7163 'tuple[bool, str | None, str | None]', result_ 

7164 ) 

7165 intermediate_results[n] = result_tuple 1b

7166 child_input_queues[n] = None 1b

7167 p = processes[n] 1b

7168 p.join(timeout=timeout) 1b

7169 assert not p.is_alive() 1b

7170 processes_pending.remove(p) 1b

7171 assert result_tuple[0], ( 1b

7172 f'action #{n} exited with an error: {result_tuple!r}' 

7173 ) 

7174 if n + 1 < len(processes): 1b

7175 next_child_input_queue = child_input_queues[n + 1] 1b

7176 assert next_child_input_queue 1b

7177 next_child_input_queue.put( 1b

7178 IPCMessage(n + 1, 'go', None), 

7179 block=True, 

7180 timeout=timeout, 

7181 ) 

7182 else: 

7183 raise AssertionError() 

7184 

7185 

7186TestFakedConfigurationMutex = tests.skip_if_no_multiprocessing_support( 

7187 FakeConfigurationMutexStateMachine.TestCase 

7188) 

7189"""The [`unittest.TestCase`][] class that will actually be run.""" 

7190 

7191 

7192def completion_item( 

7193 item: str | click.shell_completion.CompletionItem, 

7194) -> click.shell_completion.CompletionItem: 

7195 """Convert a string to a completion item, if necessary.""" 

7196 return ( 1m

7197 click.shell_completion.CompletionItem(item, type='plain') 

7198 if isinstance(item, str) 

7199 else item 

7200 ) 

7201 

7202 

7203def assertable_item( 

7204 item: str | click.shell_completion.CompletionItem, 

7205) -> tuple[str, Any, str | None]: 

7206 """Convert a completion item into a pretty-printable item. 

7207 

7208 Intended to make completion items introspectable in pytest's 

7209 `assert` output. 

7210 

7211 """ 

7212 item = completion_item(item) 1m

7213 return (item.type, item.value, item.help) 1m

7214 

7215 

7216class TestShellCompletion: 

7217 """Tests for the shell completion machinery.""" 

7218 

7219 class Completions: 

7220 """A deferred completion call.""" 

7221 

7222 def __init__( 

7223 self, 

7224 args: Sequence[str], 

7225 incomplete: str, 

7226 ) -> None: 

7227 """Initialize the object. 

7228 

7229 Args: 

7230 args: 

7231 The sequence of complete command-line arguments. 

7232 incomplete: 

7233 The final, incomplete, partial argument. 

7234 

7235 """ 

7236 self.args = tuple(args) 1)*!Pu

7237 self.incomplete = incomplete 1)*!Pu

7238 

7239 def __call__(self) -> Sequence[click.shell_completion.CompletionItem]: 

7240 """Return the completion items.""" 

7241 args = list(self.args) 1)*!Pu

7242 completion = click.shell_completion.ShellComplete( 1)*!Pu

7243 cli=cli.derivepassphrase, 

7244 ctx_args={}, 

7245 prog_name='derivepassphrase', 

7246 complete_var='_DERIVEPASSPHRASE_COMPLETE', 

7247 ) 

7248 return completion.get_completions(args, self.incomplete) 1)*!Pu

7249 

7250 def get_words(self) -> Sequence[str]: 

7251 """Return the completion items' values, as a sequence.""" 

7252 return tuple(c.value for c in self()) 1)*Pu

7253 

7254 @Parametrize.COMPLETABLE_ITEMS 

7255 def test_100_is_completable_item( 

7256 self, 

7257 partial: str, 

7258 is_completable: bool, 

7259 ) -> None: 

7260 """Our `_is_completable_item` predicate for service names works.""" 

7261 assert cli_helpers.is_completable_item(partial) == is_completable 2nb

7262 

7263 @Parametrize.COMPLETABLE_OPTIONS 

7264 def test_200_options( 

7265 self, 

7266 command_prefix: Sequence[str], 

7267 incomplete: str, 

7268 completions: AbstractSet[str], 

7269 ) -> None: 

7270 """Our completion machinery works for all commands' options.""" 

7271 comp = self.Completions(command_prefix, incomplete) 1)

7272 assert frozenset(comp.get_words()) == completions 1)

7273 

7274 @Parametrize.COMPLETABLE_SUBCOMMANDS 

7275 def test_201_subcommands( 

7276 self, 

7277 command_prefix: Sequence[str], 

7278 incomplete: str, 

7279 completions: AbstractSet[str], 

7280 ) -> None: 

7281 """Our completion machinery works for all commands' subcommands.""" 

7282 comp = self.Completions(command_prefix, incomplete) 1*

7283 assert frozenset(comp.get_words()) == completions 1*

7284 

7285 @Parametrize.COMPLETABLE_PATH_ARGUMENT 

7286 @Parametrize.INCOMPLETE 

7287 def test_202_paths( 

7288 self, 

7289 command_prefix: Sequence[str], 

7290 incomplete: str, 

7291 ) -> None: 

7292 """Our completion machinery works for all commands' paths.""" 

7293 file = click.shell_completion.CompletionItem('', type='file') 1!

7294 completions = frozenset({(file.type, file.value, file.help)}) 1!

7295 comp = self.Completions(command_prefix, incomplete) 1!

7296 assert ( 1!

7297 frozenset((x.type, x.value, x.help) for x in comp()) == completions 

7298 ) 

7299 

7300 @Parametrize.COMPLETABLE_SERVICE_NAMES 

7301 def test_203_service_names( 

7302 self, 

7303 config: _types.VaultConfig, 

7304 incomplete: str, 

7305 completions: AbstractSet[str], 

7306 ) -> None: 

7307 """Our completion machinery works for vault service names.""" 

7308 runner = tests.CliRunner(mix_stderr=False) 1P

7309 # TODO(the-13th-letter): Rewrite using parenthesized 

7310 # with-statements. 

7311 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

7312 with contextlib.ExitStack() as stack: 1P

7313 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1P

7314 stack.enter_context( 1P

7315 tests.isolated_vault_config( 

7316 monkeypatch=monkeypatch, 

7317 runner=runner, 

7318 vault_config=config, 

7319 ) 

7320 ) 

7321 comp = self.Completions(['vault'], incomplete) 1P

7322 assert frozenset(comp.get_words()) == completions 1P

7323 

7324 @Parametrize.SHELL_FORMATTER 

7325 @Parametrize.COMPLETION_FUNCTION_INPUTS 

7326 def test_300_shell_completion_formatting( 

7327 self, 

7328 shell: str, 

7329 format_func: Callable[[click.shell_completion.CompletionItem], str], 

7330 config: _types.VaultConfig, 

7331 comp_func: Callable[ 

7332 [click.Context, click.Parameter, str], 

7333 list[str | click.shell_completion.CompletionItem], 

7334 ], 

7335 args: list[str], 

7336 incomplete: str, 

7337 results: list[str | click.shell_completion.CompletionItem], 

7338 ) -> None: 

7339 """Custom completion functions work for all shells.""" 

7340 runner = tests.CliRunner(mix_stderr=False) 1m

7341 # TODO(the-13th-letter): Rewrite using parenthesized 

7342 # with-statements. 

7343 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

7344 with contextlib.ExitStack() as stack: 1m

7345 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1m

7346 stack.enter_context( 1m

7347 tests.isolated_vault_config( 

7348 monkeypatch=monkeypatch, 

7349 runner=runner, 

7350 vault_config=config, 

7351 ) 

7352 ) 

7353 expected_items = [assertable_item(item) for item in results] 1m

7354 expected_string = '\n'.join( 1m

7355 format_func(completion_item(item)) for item in results 

7356 ) 

7357 manual_raw_items = comp_func( 1m

7358 click.Context(cli.derivepassphrase), 

7359 click.Argument(['sample_parameter']), 

7360 incomplete, 

7361 ) 

7362 manual_items = [assertable_item(item) for item in manual_raw_items] 1m

7363 manual_string = '\n'.join( 1m

7364 format_func(completion_item(item)) for item in manual_raw_items 

7365 ) 

7366 assert manual_items == expected_items 1m

7367 assert manual_string == expected_string 1m

7368 comp_class = click.shell_completion.get_completion_class(shell) 1m

7369 assert comp_class is not None 1m

7370 comp = comp_class( 1m

7371 cli.derivepassphrase, 

7372 {}, 

7373 'derivepassphrase', 

7374 '_DERIVEPASSPHRASE_COMPLETE', 

7375 ) 

7376 monkeypatch.setattr( 1m

7377 comp, 

7378 'get_completion_args', 

7379 lambda *_a, **_kw: (args, incomplete), 

7380 ) 

7381 actual_raw_items = comp.get_completions( 1m

7382 *comp.get_completion_args() 

7383 ) 

7384 actual_items = [assertable_item(item) for item in actual_raw_items] 1m

7385 actual_string = comp.complete() 1m

7386 assert actual_items == expected_items 1m

7387 assert actual_string == expected_string 1m

7388 

7389 @Parametrize.CONFIG_SETTING_MODE 

7390 @Parametrize.SERVICE_NAME_COMPLETION_INPUTS 

7391 def test_400_incompletable_service_names( 

7392 self, 

7393 caplog: pytest.LogCaptureFixture, 

7394 mode: Literal['config', 'import'], 

7395 config: _types.VaultConfig, 

7396 key: str, 

7397 incomplete: str, 

7398 completions: AbstractSet[str], 

7399 ) -> None: 

7400 """Completion skips incompletable items.""" 

7401 vault_config = config if mode == 'config' else {'services': {}} 1u

7402 runner = tests.CliRunner(mix_stderr=False) 1u

7403 # TODO(the-13th-letter): Rewrite using parenthesized 

7404 # with-statements. 

7405 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

7406 with contextlib.ExitStack() as stack: 1u

7407 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1u

7408 stack.enter_context( 1u

7409 tests.isolated_vault_config( 

7410 monkeypatch=monkeypatch, 

7411 runner=runner, 

7412 vault_config=vault_config, 

7413 ) 

7414 ) 

7415 if mode == 'config': 1u

7416 result = runner.invoke( 1u

7417 cli.derivepassphrase_vault, 

7418 ['--config', '--length=10', '--', key], 

7419 catch_exceptions=False, 

7420 ) 

7421 else: 

7422 result = runner.invoke( 1u

7423 cli.derivepassphrase_vault, 

7424 ['--import', '-'], 

7425 catch_exceptions=False, 

7426 input=json.dumps(config), 

7427 ) 

7428 assert result.clean_exit(), 'expected clean exit' 1u

7429 assert tests.warning_emitted( 1u

7430 'contains an ASCII control character', caplog.record_tuples 

7431 ), 'expected known warning message in stderr' 

7432 assert tests.warning_emitted( 1u

7433 'not be available for completion', caplog.record_tuples 

7434 ), 'expected known warning message in stderr' 

7435 assert cli_helpers.load_config() == config 1u

7436 comp = self.Completions(['vault'], incomplete) 1u

7437 assert frozenset(comp.get_words()) == completions 1u

7438 

7439 def test_410a_service_name_exceptions_not_found( 

7440 self, 

7441 ) -> None: 

7442 """Service name completion quietly fails on missing configuration.""" 

7443 runner = tests.CliRunner(mix_stderr=False) 2jb

7444 # TODO(the-13th-letter): Rewrite using parenthesized 

7445 # with-statements. 

7446 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

7447 with contextlib.ExitStack() as stack: 2jb

7448 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 2jb

7449 stack.enter_context( 2jb

7450 tests.isolated_vault_config( 

7451 monkeypatch=monkeypatch, 

7452 runner=runner, 

7453 vault_config={ 

7454 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS} 

7455 }, 

7456 ) 

7457 ) 

7458 cli_helpers.config_filename(subsystem='vault').unlink( 2jb

7459 missing_ok=True 

7460 ) 

7461 assert not cli_helpers.shell_complete_service( 2jb

7462 click.Context(cli.derivepassphrase), 

7463 click.Argument(['some_parameter']), 

7464 '', 

7465 ) 

7466 

7467 @Parametrize.SERVICE_NAME_EXCEPTIONS 

7468 def test_410b_service_name_exceptions_custom_error( 

7469 self, 

7470 exc_type: type[Exception], 

7471 ) -> None: 

7472 """Service name completion quietly fails on configuration errors.""" 

7473 runner = tests.CliRunner(mix_stderr=False) 1+

7474 # TODO(the-13th-letter): Rewrite using parenthesized 

7475 # with-statements. 

7476 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

7477 with contextlib.ExitStack() as stack: 1+

7478 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1+

7479 stack.enter_context( 1+

7480 tests.isolated_vault_config( 

7481 monkeypatch=monkeypatch, 

7482 runner=runner, 

7483 vault_config={ 

7484 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS} 

7485 }, 

7486 ) 

7487 ) 

7488 

7489 def raiser(*_a: Any, **_kw: Any) -> NoReturn: 1+

7490 raise exc_type('just being difficult') # noqa: EM101,TRY003 1+

7491 

7492 monkeypatch.setattr(cli_helpers, 'load_config', raiser) 1+

7493 assert not cli_helpers.shell_complete_service( 1+

7494 click.Context(cli.derivepassphrase), 

7495 click.Argument(['some_parameter']), 

7496 '', 

7497 )