Coverage for tests\test_derivepassphrase_cli.py: 98.941%

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

2#

3# SPDX-License-Identifier: Zlib

4

5 from __future__ import annotations (empty)

6

7 import base64 (empty)

8 import contextlib (empty)

9 import copy (empty)

10 import ctypes (empty)

11 import enum (empty)

12 import errno (empty)

13 import io (empty)

14 import json (empty)

15 import logging (empty)

16 import operator (empty)

17 import os (empty)

18 import pathlib (empty)

19 import queue (empty)

20 import re (empty)

21 import shlex (empty)

22 import shutil (empty)

23 import socket (empty)

24 import tempfile (empty)

25 import textwrap (empty)

26 import types (empty)

27 import warnings (empty)

28 from typing import TYPE_CHECKING, cast (empty)

29

30 import click.testing (empty)

31 import hypothesis (empty)

32 import pytest (empty)

33 from hypothesis import stateful, strategies (empty)

34 from typing_extensions import Any, NamedTuple, TypeAlias (empty)

35

36 import tests (empty)

37 from derivepassphrase import _types, cli, ssh_agent, vault (empty)

38 from derivepassphrase._internals import ( (empty)

39 cli_helpers,

40 cli_machinery,

41 cli_messages,

42)

43 from derivepassphrase.ssh_agent import socketprovider (empty)

44

45 if TYPE_CHECKING: (empty)

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

53 DUMMY_SERVICE = tests.DUMMY_SERVICE (empty)

54 DUMMY_PASSPHRASE = tests.DUMMY_PASSPHRASE (empty)

55 DUMMY_CONFIG_SETTINGS = tests.DUMMY_CONFIG_SETTINGS (empty)

56 DUMMY_RESULT_PASSPHRASE = tests.DUMMY_RESULT_PASSPHRASE (empty)

57 DUMMY_RESULT_KEY1 = tests.DUMMY_RESULT_KEY1 (empty)

58 DUMMY_PHRASE_FROM_KEY1_RAW = tests.DUMMY_PHRASE_FROM_KEY1_RAW (empty)

59 DUMMY_PHRASE_FROM_KEY1 = tests.DUMMY_PHRASE_FROM_KEY1 (empty)

60

61 DUMMY_KEY1 = tests.DUMMY_KEY1 (empty)

62 DUMMY_KEY1_B64 = tests.DUMMY_KEY1_B64 (empty)

63 DUMMY_KEY2 = tests.DUMMY_KEY2 (empty)

64 DUMMY_KEY2_B64 = tests.DUMMY_KEY2_B64 (empty)

65 DUMMY_KEY3 = tests.DUMMY_KEY3 (empty)

66 DUMMY_KEY3_B64 = tests.DUMMY_KEY3_B64 (empty)

67

68 TEST_CONFIGS = tests.TEST_CONFIGS (empty)

69

70

71 class IncompatibleConfiguration(NamedTuple): (empty)

72 other_options: list[tuple[str, ...]] (empty)

73 needs_service: bool | None (empty)

74 input: str | None (empty)

75

76

77 class SingleConfiguration(NamedTuple): (empty)

78 needs_service: bool | None (empty)

79 input: str | None (empty)

80 check_success: bool (empty)

81

82

83 class OptionCombination(NamedTuple): (empty)

84 options: list[str] (empty)

85 incompatible: bool (empty)

86 needs_service: bool | None (empty)

87 input: str | None (empty)

88 check_success: bool (empty)

89

90

91 class VersionOutputData(NamedTuple): (empty)

92 derivation_schemes: dict[str, bool] (empty)

93 foreign_configuration_formats: dict[str, bool] (empty)

94 extras: frozenset[str] (empty)

95 subcommands: frozenset[str] (empty)

96 features: dict[str, bool] (empty)

97

98

99 class KnownLineType(str, enum.Enum): (empty)

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

101 ':'

102 )

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

104 ':'

105 )

106 SUPPORTED_SCHEMES = ( (empty)

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( (empty)

112 ':'

113 )

114 SUPPORTED_SUBCOMMANDS = ( (empty)

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

116 )

117 SUPPORTED_FEATURES = ( (empty)

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

119 )

120 UNAVAILABLE_FEATURES = ( (empty)

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

122 )

123 ENABLED_EXTRAS = ( (empty)

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

125 )

126

127

128 PASSPHRASE_GENERATION_OPTIONS: list[tuple[str, ...]] = [ (empty)

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]

140 CONFIGURATION_OPTIONS: list[tuple[str, ...]] = [ (empty)

141 ('--notes',),

142 ('--config',),

143 ('--delete',),

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

145 ('--clear',),

146]

147 CONFIGURATION_COMMANDS: list[tuple[str, ...]] = [ (empty)

148 ('--delete',),

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

150 ('--clear',),

151]

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

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

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}

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

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}

225 INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = [] (empty)

226 config: IncompatibleConfiguration | SingleConfiguration (empty)

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

228 for opt2 in config.other_options: (empty)

229 INTERESTING_OPTION_COMBINATIONS.extend([ (empty)

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 ])

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

246 INTERESTING_OPTION_COMBINATIONS.append( (empty)

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

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

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

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

260

261

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

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

264 possible_warnings = [ 4 ctx 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) 4 ctx 1vtCw

277

278

279 def assert_vault_config_is_indented_and_line_broken( (empty)

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 = { 4 ctx 1CDFw

308 '}',

309 '},',

310 '"services": {}',

311 '"services": {},',

312 }

313 assert any([ 4 ctx 1CDFw

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

315 for line in config_txt.splitlines()

316 ])

317

318

319 def vault_config_exporter_shell_interpreter( # noqa: C901 (empty)

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 4 ctx 1jikl

337 script = script.splitlines(False) 4 ctx 1jikl

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

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

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

341 command = cli.derivepassphrase_vault 4 ctx 1jikl

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

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

344 n = len(prog_name_list) 4 ctx 1jikl

345 it = iter(script) 4 ctx 1jikl

346 while True: 4 ctx 1jikl

347 try: 4 ctx 1jikl

348 raw_line = next(it) 4 ctx 1jikl

349 except StopIteration: 4 ctx 1jikl

350 break 4 ctx 1jikl

351 else:

352 line = shlex.split(raw_line) 4 ctx 1jikl

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

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

355 continue 4 ctx 1jikl

356 line[:n] = [] 4 ctx 1jikl

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

358 # naive HERE document support

359 while True: 4 ctx 1jikl

360 try: 4 ctx 1jikl

361 raw_line = next(it) 4 ctx 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': 4 ctx 1jikl

367 break 4 ctx 1jikl

368 input_buffer.append(raw_line) 4 ctx 1jikl

369 line.pop() 4 ctx 1jikl

370 yield runner.invoke( 4 ctx 1jikl

371 command,

372 line,

373 catch_exceptions=False,

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

375 )

376

377

378 def parse_version_output( # noqa: C901 (empty)

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]] = [] 5 ctx 1hgedf

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

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

419 if not line.strip(): 5 ctx 1hgedf

420 if paragraph: 5 ctx 1hgedf

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

422 paragraph.clear() 5 ctx 1hgedf

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

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

425 else:

426 paragraph.append(line) 5 ctx 1hgedf

427 if paragraph: # pragma: no branch 5 ctx 1hgedf

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

429 paragraph.clear() 5 ctx 1hgedf

430 assert paragraphs, ( 5 ctx 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], ( 5 ctx 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], ( 5 ctx 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] = {} 5 ctx 1hgedf

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

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

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

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

446 if len(paragraphs) < 2: # pragma: no cover 5 ctx 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]: 5 ctx 1hgedf

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

456 if line_type == line: 5 ctx 1hgedf

457 continue 1 ctx 1h

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

459 item = item_.strip() 5 ctx 1hgedf

460 if not item: 5 ctx 1hgedf

461 continue 5 ctx 1hgedf

462 if line_type == KnownLineType.SUPPORTED_FOREIGN_CONFS: 5 ctx 1hgedf

463 formats[item] = True 2 ctx 1hd

464 elif line_type == KnownLineType.UNAVAILABLE_FOREIGN_CONFS: 5 ctx 1hgedf

465 formats[item] = False 3 ctx 1hed

466 elif line_type == KnownLineType.SUPPORTED_SCHEMES: 5 ctx 1hgedf

467 schemes[item] = True 2 ctx 1hg

468 elif line_type == KnownLineType.UNAVAILABLE_SCHEMES: 5 ctx 1hgedf

469 schemes[item] = False 1 ctx 1h

470 elif line_type == KnownLineType.SUPPORTED_SUBCOMMANDS: 5 ctx 1hgedf

471 subcommands.add(item) 3 ctx 1hge

472 elif line_type == KnownLineType.ENABLED_EXTRAS: 3 ctx 1hdf

473 extras.add(item) 2 ctx 1hd

474 elif line_type == KnownLineType.SUPPORTED_FEATURES: 2 ctx 1hf

475 features[item] = True 1 ctx 1h

476 elif line_type == KnownLineType.UNAVAILABLE_FEATURES: 2 ctx 1hf

477 features[item] = False 2 ctx 1hf

478 else:

479 raise AssertionError( # noqa: TRY003

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

481 )

482 return VersionOutputData( 5 ctx 1hgedf

483 derivation_schemes=schemes,

484 foreign_configuration_formats=formats,

485 subcommands=frozenset(subcommands),

486 extras=frozenset(extras),

487 features=features,

488 )

489

490

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

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 1 ctx 1m

498 item.type,

499 item.value,

500 )

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

502

503

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

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 1 ctx 1m

511 item.type,

512 item.value,

513 item.help,

514 )

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

516

517

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

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 = '_' 1 ctx 1m

534 help_, value = ( 1 ctx 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_}' 1 ctx 1m

540

541

542 class ListKeysAction(str, enum.Enum): (empty)

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() (empty)

553 """""" (empty)

554 FAIL = enum.auto() (empty)

555 """""" (empty)

556 FAIL_RUNTIME = enum.auto() (empty)

557 """""" (empty)

558

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

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: 1 ctx 1c

565 return [] 1 ctx 1c

566 if self == self.FAIL: 1 ctx 1c

567 raise ssh_agent.SSHAgentFailedError( 1 ctx 1c

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

569 )

570 if self == self.FAIL_RUNTIME: 1 ctx 1c

571 raise ssh_agent.TrailingDataError() 1 ctx 1c

572 raise AssertionError()

573

574

575 class SignAction(str, enum.Enum): (empty)

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() (empty)

585 """""" (empty)

586 FAIL_RUNTIME = enum.auto() (empty)

587 """""" (empty)

588

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

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: 1 ctx 1c

595 raise ssh_agent.SSHAgentFailedError( 1 ctx 1c

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

597 )

598 if self == self.FAIL_RUNTIME: 1 ctx 1c

599 raise ssh_agent.TrailingDataError() 1 ctx 1c

600 raise AssertionError()

601

602

603 class SocketAddressAction(str, enum.Enum): (empty)

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() (empty)

621 """""" (empty)

622 MANGLE_SSH_AUTH_SOCK = enum.auto() (empty)

623 """""" (empty)

624 UNSET_ANNOYING_OS_NAMED_PIPE = enum.auto() (empty)

625 """""" (empty)

626 UNSET_SSH_AUTH_SOCK = enum.auto() (empty)

627 """""" (empty)

628

629 def __call__( (empty)

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 { 1 ctx 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: 1 ctx 1c

643 monkeypatch.setenv( 1 ctx 1c

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

645 )

646 elif self == self.UNSET_SSH_AUTH_SOCK: 1 ctx 1c

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

648 else:

649 raise AssertionError()

650

651

652 class SystemSupportAction(str, enum.Enum): (empty)

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() (empty)

677 """""" (empty)

678 UNSET_AF_UNIX_AND_ENSURE_USE = enum.auto() (empty)

679 """""" (empty)

680 UNSET_NATIVE = enum.auto() (empty)

681 """""" (empty)

682 UNSET_NATIVE_AND_ENSURE_USE = enum.auto() (empty)

683 """""" (empty)

684 UNSET_PROVIDER_LIST = enum.auto() (empty)

685 """""" (empty)

686 UNSET_WINDLL = enum.auto() (empty)

687 """""" (empty)

688 UNSET_WINDLL_AND_ENSURE_USE = enum.auto() (empty)

689 """""" (empty)

690

691 def __call__( (empty)

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: 1 ctx 1c

704 monkeypatch.setattr( 1 ctx 1c

705 ssh_agent.SSHAgentClient, 'SOCKET_PROVIDERS', []

706 )

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

708 self.check_or_ensure_use( 1 ctx 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}: 1 ctx 1c

717 self.check_or_ensure_use( 1 ctx 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) 1 ctx 1c

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

724 self.check_or_ensure_use( 1 ctx 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) 1 ctx 1c

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

731 else:

732 raise AssertionError()

733

734 @staticmethod (empty)

735 def check_or_ensure_use( (empty)

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: 1 ctx 1c

756 monkeypatch.setattr( 1 ctx 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: 1 ctx 1c

769 intended = socketprovider.SocketProvider.resolve(provider) 1 ctx 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: 1 ctx 1c

780 try: 1 ctx 1c

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

782 except socketprovider.NoSuchProviderError as exc:

783 actual = exc

784 except NotImplementedError:

785 continue

786 break 1 ctx 1c

787 else:

788 actual = None

789 if intended != actual: 1 ctx 1c

790 pytest.skip( 1 ctx 1c

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

792 f'is not currently in use'

793 )

794

795

796 class Parametrize(types.SimpleNamespace): (empty)

797 """Common test parametrizations."""

798

799 EAGER_ARGUMENTS = pytest.mark.parametrize( (empty)

800 'arguments',

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

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

803 )

804 CHARSET_NAME = pytest.mark.parametrize( (empty)

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

806 )

807 COMMAND_NON_EAGER_ARGUMENTS = pytest.mark.parametrize( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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

1539 )

1540 NOOP_EDIT_FUNCS = pytest.mark.parametrize( (empty)

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( (empty)

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

1550 )

1551 EXPORT_FORMAT_OPTIONS = pytest.mark.parametrize( (empty)

1552 'export_options',

1553 [

1554 [],

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

1556 ],

1557 )

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

1559 ISATTY = pytest.mark.parametrize( (empty)

1560 'isatty',

1561 [False, True],

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

1563 )

1564 KEY_INDEX = pytest.mark.parametrize( (empty)

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

1566 )

1567 KEY_TO_PHRASE_SETTINGS = pytest.mark.parametrize( (empty)

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( (empty)

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( (empty)

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]) (empty)

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

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

1801 MODERN_EDITOR_INTERFACE = pytest.mark.parametrize( (empty)

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

1803 )

1804 NOTES_PLACEMENT = pytest.mark.parametrize( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

1869 'try_race_free_implementation', [True, False]

1870 )

1871 VERSION_OUTPUT_DATA = pytest.mark.parametrize( (empty)

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

1873 [

1874 pytest.param(

1875 """\

1876 derivepassphrase 0.4.0

1877 Using cryptography 44.0.0

1878

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

1880 vault v0.3.

1881 PEP 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 """\

1900 derivepassphrase 0.5

1901

1902 Supported derivation schemes: vault.

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

1904 Supported subcommands: export, vault.

1905 No 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

1927 inventpassphrase -1.3

1928 Using not-a-library 7.12

1929 Copyright 2025 Nobody. All rights reserved.

1930

1931 Supported derivation schemes: nonsense.

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

1933 geiger counter,

1934 crossword solver.

1935 Supported foreign configuration formats: derivepassphrase, nonsense.

1936 Known foreign configuration formats: divination v3.141592,

1937 /dev/random.

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

1939 Supported features: delete-while-open.

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

1941 PEP 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`][].""" (empty)

1979 VALIDATION_FUNCTION_INPUT = pytest.mark.parametrize( (empty)

1980 ['vfunc', 'input'],

1981 [

1982 (cli_machinery.validate_occurrence_constraint, 20),

1983 (cli_machinery.validate_length, 20),

1984 ],

1985 )

1986

1987

1988 class TestAllCLI: (empty)

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

1990

1991 @Parametrize.MASK_PROG_NAME (empty)

1992 @Parametrize.MASK_VERSION (empty)

1993 @Parametrize.VERSION_OUTPUT_DATA (empty)

1994 def test_001_parse_version_output( (empty)

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 1 ctx 1h

2005 version = None if mask_version else version 1 ctx 1h

2006 assert ( 1 ctx 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: (empty)

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 ctx 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 ctx 1_

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

2027 stack.enter_context( 1 ctx 1_

2028 tests.isolated_config(

2029 monkeypatch=monkeypatch,

2030 runner=runner,

2031 )

2032 )

2033 result = runner.invoke( 1 ctx 1_

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

2035 )

2036 assert result.clean_exit( 1 ctx 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( (empty)

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 ctx 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 ctx 1`

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

2056 stack.enter_context( 1 ctx 1`

2057 tests.isolated_config(

2058 monkeypatch=monkeypatch,

2059 runner=runner,

2060 )

2061 )

2062 result = runner.invoke( 1 ctx 1`

2063 cli.derivepassphrase,

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

2065 catch_exceptions=False,

2066 )

2067 assert result.clean_exit( 1 ctx 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( (empty)

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 ctx 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 ctx 1{

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

2087 stack.enter_context( 1 ctx 1{

2088 tests.isolated_config(

2089 monkeypatch=monkeypatch,

2090 runner=runner,

2091 )

2092 )

2093 result = runner.invoke( 1 ctx 1{

2094 cli.derivepassphrase,

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

2096 catch_exceptions=False,

2097 )

2098 assert result.clean_exit( 1 ctx 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( (empty)

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 ctx 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 ctx 1,

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

2118 stack.enter_context( 1 ctx 1,

2119 tests.isolated_config(

2120 monkeypatch=monkeypatch,

2121 runner=runner,

2122 )

2123 )

2124 result = runner.invoke( 1 ctx 1,

2125 cli.derivepassphrase,

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

2127 catch_exceptions=False,

2128 )

2129 assert result.clean_exit( 1 ctx 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 ctx 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 (empty)

2137 @Parametrize.EAGER_ARGUMENTS (empty)

2138 def test_200_eager_options( (empty)

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 ctx 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 ctx 1|

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

2151 stack.enter_context( 1 ctx 1|

2152 tests.isolated_config(

2153 monkeypatch=monkeypatch,

2154 runner=runner,

2155 )

2156 )

2157 result = runner.invoke( 1 ctx 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 ctx 1|

2163

2164 @Parametrize.ISATTY (empty)

2165 @Parametrize.COLORFUL_COMMAND_INPUT (empty)

2166 def test_201_automatic_color_mode( (empty)

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 ctx 1#

2180 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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 ctx 1#

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

2186 stack.enter_context( 1 ctx 1#

2187 tests.isolated_config(

2188 monkeypatch=monkeypatch,

2189 runner=runner,

2190 )

2191 )

2192 result = runner.invoke( 1 ctx 1#

2193 cli.derivepassphrase,

2194 command_line,

2195 input=input,

2196 catch_exceptions=False,

2197 color=isatty,

2198 )

2199 assert ( 1 ctx 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 ctx 1#

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

2206 )

2207

2208 def test_202a_derivepassphrase_version_option_output( (empty)

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) 1 ctx 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: 1 ctx 1g

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

2227 stack.enter_context( 1 ctx 1g

2228 tests.isolated_config(

2229 monkeypatch=monkeypatch,

2230 runner=runner,

2231 )

2232 )

2233 result = runner.invoke( 1 ctx 1g

2234 cli.derivepassphrase,

2235 ['--version'],

2236 catch_exceptions=False,

2237 )

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

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

2240 version_data = parse_version_output(result.stdout) 1 ctx 1g

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

2242 subcommands = set(_types.Subcommand) 1 ctx 1g

2243 assert version_data.derivation_schemes == actually_known_schemes 1 ctx 1g

2244 assert not version_data.foreign_configuration_formats 1 ctx 1g

2245 assert version_data.subcommands == subcommands 1 ctx 1g

2246 assert not version_data.features 1 ctx 1g

2247 assert not version_data.extras 1 ctx 1g

2248

2249 def test_202b_export_version_option_output( (empty)

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) 1 ctx 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: 1 ctx 1e

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

2268 stack.enter_context( 1 ctx 1e

2269 tests.isolated_config(

2270 monkeypatch=monkeypatch,

2271 runner=runner,

2272 )

2273 )

2274 result = runner.invoke( 1 ctx 1e

2275 cli.derivepassphrase,

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

2277 catch_exceptions=False,

2278 )

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

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

2281 version_data = parse_version_output(result.stdout) 1 ctx 1e

2282 actually_known_formats: dict[str, bool] = { 1 ctx 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) 1 ctx 1e

2288 assert not version_data.derivation_schemes 1 ctx 1e

2289 assert ( 1 ctx 1e

2290 version_data.foreign_configuration_formats

2291 == actually_known_formats

2292 )

2293 assert version_data.subcommands == subcommands 1 ctx 1e

2294 assert not version_data.features 1 ctx 1e

2295 assert not version_data.extras 1 ctx 1e

2296

2297 def test_202c_export_vault_version_option_output( (empty)

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) 1 ctx 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: 1 ctx 1d

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

2316 stack.enter_context( 1 ctx 1d

2317 tests.isolated_config(

2318 monkeypatch=monkeypatch,

2319 runner=runner,

2320 )

2321 )

2322 result = runner.invoke( 1 ctx 1d

2323 cli.derivepassphrase,

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

2325 catch_exceptions=False,

2326 )

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

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

2329 version_data = parse_version_output(result.stdout) 1 ctx 1d

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

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

2332 with contextlib.suppress(ModuleNotFoundError): 1 ctx 1d

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

2334

2335 actually_known_formats.update({ 1 ctx 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: 1 ctx 1d

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

2342 assert not version_data.derivation_schemes 1 ctx 1d

2343 assert ( 1 ctx 1d

2344 version_data.foreign_configuration_formats

2345 == actually_known_formats

2346 )

2347 assert not version_data.subcommands 1 ctx 1d

2348 assert not version_data.features 1 ctx 1d

2349 assert version_data.extras == actually_enabled_extras 1 ctx 1d

2350

2351 def test_202d_vault_version_option_output( (empty)

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) 1 ctx 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: 1 ctx 1f

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

2369 stack.enter_context( 1 ctx 1f

2370 tests.isolated_config(

2371 monkeypatch=monkeypatch,

2372 runner=runner,

2373 )

2374 )

2375 result = runner.invoke( 1 ctx 1f

2376 cli.derivepassphrase,

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

2378 catch_exceptions=False,

2379 )

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

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

2382 version_data = parse_version_output(result.stdout) 1 ctx 1f

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

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

2385 }

2386 assert not version_data.derivation_schemes 1 ctx 1f

2387 assert not version_data.foreign_configuration_formats 1 ctx 1f

2388 assert not version_data.subcommands 1 ctx 1f

2389 assert version_data.features == features 1 ctx 1f

2390 assert not version_data.extras 1 ctx 1f

2391

2392

2393 class TestCLI: (empty)

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

2395

2396 def test_200_help_output( (empty)

2397 self,

2398 ) -> None:

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

2400 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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 ctx 1-

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

2406 stack.enter_context( 1 ctx 1-

2407 tests.isolated_config(

2408 monkeypatch=monkeypatch,

2409 runner=runner,

2410 )

2411 )

2412 result = runner.invoke( 1 ctx 1-

2413 cli.derivepassphrase_vault,

2414 ['--help'],

2415 catch_exceptions=False,

2416 )

2417 assert result.clean_exit( 1 ctx 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 ctx 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( (empty)

2427 self,

2428 ) -> None:

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

2430 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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 ctx 1.

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

2436 stack.enter_context( 1 ctx 1.

2437 tests.isolated_config(

2438 monkeypatch=monkeypatch,

2439 runner=runner,

2440 )

2441 )

2442 result = runner.invoke( 1 ctx 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 ctx 1.

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

2449 )

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

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

2452 )

2453

2454 @Parametrize.CHARSET_NAME (empty)

2455 def test_201_disable_character_set( (empty)

2456 self,

2457 charset_name: str,

2458 ) -> None:

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

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

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

2462 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1Q

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

2468 stack.enter_context( 1 ctx 1Q

2469 tests.isolated_config(

2470 monkeypatch=monkeypatch,

2471 runner=runner,

2472 )

2473 )

2474 monkeypatch.setattr( 1 ctx 1Q

2475 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt

2476 )

2477 result = runner.invoke( 1 ctx 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:' 1 ctx 1Q

2484 for c in charset: 1 ctx 1Q

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

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

2487 )

2488

2489 def test_202_disable_repetition( (empty)

2490 self,

2491 ) -> None:

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

2493 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1W

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

2499 stack.enter_context( 1 ctx 1W

2500 tests.isolated_config(

2501 monkeypatch=monkeypatch,

2502 runner=runner,

2503 )

2504 )

2505 monkeypatch.setattr( 1 ctx 1W

2506 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt

2507 )

2508 result = runner.invoke( 1 ctx 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), ( 1 ctx 1W

2515 'expected clean exit and empty stderr'

2516 )

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

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

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

2520 f'derived password contains repeated character '

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

2522 )

2523

2524 @Parametrize.CONFIG_WITH_KEY (empty)

2525 def test_204a_key_from_config( (empty)

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 1 ctx 1R

2532 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1R

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

2538 stack.enter_context( 1 ctx 1R

2539 tests.isolated_vault_config(

2540 monkeypatch=monkeypatch,

2541 runner=runner,

2542 vault_config=config,

2543 )

2544 )

2545 monkeypatch.setattr( 1 ctx 1R

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

2547 )

2548 result = runner.invoke( 1 ctx 1R

2549 cli.derivepassphrase_vault,

2550 ['--', DUMMY_SERVICE],

2551 catch_exceptions=False,

2552 )

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

2554 'expected clean exit and empty stderr'

2555 )

2556 assert result.stdout 1 ctx 1R

2557 assert ( 1 ctx 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 ( 1 ctx 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( (empty)

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 1 ctx 1G

2571 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1G

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

2577 stack.enter_context( 1 ctx 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( 1 ctx 1G

2587 cli_helpers, 'get_suitable_ssh_keys', tests.suitable_ssh_keys

2588 )

2589 monkeypatch.setattr( 1 ctx 1G

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

2591 )

2592 result = runner.invoke( 1 ctx 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' 1 ctx 1G

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

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

2601 assert ( 1 ctx 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, ( 1 ctx 1G

2605 'expected known output'

2606 )

2607

2608 @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS (empty)

2609 @Parametrize.KEY_INDEX (empty)

2610 def test_204c_key_override_on_command_line( (empty)

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 1 ctx 1L

2618 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1L

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

2624 stack.enter_context( 1 ctx 1L

2625 tests.isolated_vault_config(

2626 monkeypatch=monkeypatch,

2627 runner=runner,

2628 vault_config=config,

2629 )

2630 )

2631 monkeypatch.setattr( 1 ctx 1L

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

2633 )

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

2635 result = runner.invoke( 1 ctx 1L

2636 cli.derivepassphrase_vault,

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

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

2639 )

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

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

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

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

2644 'expected no error messages on stderr'

2645 )

2646

2647 def test_205_service_phrase_if_key_in_global_config( (empty)

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 1 ctx 1H

2653 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1H

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

2659 stack.enter_context( 1 ctx 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( 1 ctx 1H

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

2676 )

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

2678 result = runner.invoke( 1 ctx 1H

2679 cli.derivepassphrase_vault,

2680 ['--', DUMMY_SERVICE],

2681 catch_exceptions=False,

2682 )

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

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

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

2686 assert ( 1 ctx 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, ( 1 ctx 1H

2690 'expected known output'

2691 )

2692

2693 @Parametrize.KEY_OVERRIDING_IN_CONFIG (empty)

2694 def test_206_setting_phrase_thus_overriding_key_in_config( (empty)

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 1 ctx 1v

2703 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1v

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

2709 stack.enter_context( 1 ctx 1v

2710 tests.isolated_vault_config(

2711 monkeypatch=monkeypatch,

2712 runner=runner,

2713 vault_config=config,

2714 )

2715 )

2716 monkeypatch.setattr( 1 ctx 1v

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

2718 )

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

2720 result = runner.invoke( 1 ctx 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' 1 ctx 1v

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

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

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

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

2731 assert tests.warning_emitted( 1 ctx 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))) 1 ctx 1v

2739 assert all( 1 ctx 1v

2740 map(is_harmless_config_import_warning, caplog.record_tuples)

2741 ), 'unexpected error output'

2742

2743 @hypothesis.given( (empty)

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( (empty)

2754 self,

2755 notes: str,

2756 ) -> None:

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

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

2759 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1M

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

2765 stack.enter_context( 1 ctx 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( 1 ctx 1M

2783 cli.derivepassphrase_vault,

2784 ['--', DUMMY_SERVICE],

2785 )

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

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

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

2789 'ascii'

2790 ), 'expected known program output'

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

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

2793 'expected no error messages on stderr'

2794 )

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

2796 'expected known stderr contents'

2797 )

2798

2799 @Parametrize.VAULT_CHARSET_OPTION (empty)

2800 def test_210_invalid_argument_range( (empty)

2801 self,

2802 option: str,

2803 ) -> None:

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

2805 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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 ctx 1/

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

2811 stack.enter_context( 1 ctx 1/

2812 tests.isolated_config(

2813 monkeypatch=monkeypatch,

2814 runner=runner,

2815 )

2816 )

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

2818 result = runner.invoke( 1 ctx 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 ctx 1/

2825 'expected error exit and known error message'

2826 )

2827

2828 @Parametrize.OPTION_COMBINATIONS_SERVICE_NEEDED (empty)

2829 def test_211_service_needed( (empty)

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) 1 ctx 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: 1 ctx 1A

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

2843 stack.enter_context( 1 ctx 1A

2844 tests.isolated_vault_config(

2845 monkeypatch=monkeypatch,

2846 runner=runner,

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

2848 )

2849 )

2850 monkeypatch.setattr( 1 ctx 1A

2851 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt

2852 )

2853 result = runner.invoke( 1 ctx 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: 1 ctx 1A

2860 err_msg = ( 1 ctx 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), ( 1 ctx 1A

2866 'expected error exit and known error message'

2867 )

2868 else:

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

2870 'expected clean exit'

2871 )

2872 if check_success: 1 ctx 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: 1 ctx 1A

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

2878 stack.enter_context( 1 ctx 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( 1 ctx 1A

2889 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt

2890 )

2891 result = runner.invoke( 1 ctx 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' 1 ctx 1A

2898

2899 def test_211a_empty_service_name_causes_warning( (empty)

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: 1 ctx 1t

2910 return is_harmless_config_import_warning( 1 ctx 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) 1 ctx 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: 1 ctx 1t

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

2922 stack.enter_context( 1 ctx 1t

2923 tests.isolated_vault_config(

2924 monkeypatch=monkeypatch,

2925 runner=runner,

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

2927 )

2928 )

2929 monkeypatch.setattr( 1 ctx 1t

2930 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt

2931 )

2932 result = runner.invoke( 1 ctx 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' 1 ctx 1t

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

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

2940 'expected known error output'

2941 )

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

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

2944 'services': {},

2945 }, 'requested configuration change was not applied'

2946 caplog.clear() 1 ctx 1t

2947 result = runner.invoke( 1 ctx 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' 1 ctx 1t

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

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

2956 'expected known error output'

2957 )

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

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

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

2961 }, 'requested configuration change was not applied'

2962

2963 @Parametrize.OPTION_COMBINATIONS_INCOMPATIBLE (empty)

2964 def test_212_incompatible_options( (empty)

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 ctx 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 ctx 1}

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

2976 stack.enter_context( 1 ctx 1}

2977 tests.isolated_config(

2978 monkeypatch=monkeypatch,

2979 runner=runner,

2980 )

2981 )

2982 result = runner.invoke( 1 ctx 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 ctx 1}

2989 'expected error exit and known error message'

2990 )

2991

2992 @Parametrize.VALID_TEST_CONFIGS (empty)

2993 def test_213_import_config_success( (empty)

2994 self,

2995 caplog: pytest.LogCaptureFixture,

2996 config: Any,

2997 ) -> None:

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

2999 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1C

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

3005 stack.enter_context( 1 ctx 1C

3006 tests.isolated_vault_config(

3007 monkeypatch=monkeypatch,

3008 runner=runner,

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

3010 )

3011 )

3012 result = runner.invoke( 1 ctx 1C

3013 cli.derivepassphrase_vault,

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

3015 input=json.dumps(config),

3016 catch_exceptions=False,

3017 )

3018 config_txt = cli_helpers.config_filename( 1 ctx 1C

3019 subsystem='vault'

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

3021 config2 = json.loads(config_txt) 1 ctx 1C

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

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

3024 assert not result.stderr or all( # pragma: no branch 1 ctx 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) 1 ctx 1C

3028

3029 @hypothesis.settings( (empty)

3030 suppress_health_check=[

3031 *hypothesis.settings().suppress_health_check,

3032 hypothesis.HealthCheck.function_scoped_fixture,

3033 ],

3034 )

3035 @hypothesis.given( (empty)

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( (empty)

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 1 ctx 1w

3055 config2 = copy.deepcopy(config) 1 ctx 1w

3056 _types.clean_up_falsy_vault_config_values(config2) 1 ctx 1w

3057 # Reset caplog between hypothesis runs.

3058 caplog.clear() 1 ctx 1w

3059 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1w

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

3065 stack.enter_context( 1 ctx 1w

3066 tests.isolated_vault_config(

3067 monkeypatch=monkeypatch,

3068 runner=runner,

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

3070 )

3071 )

3072 result = runner.invoke( 1 ctx 1w

3073 cli.derivepassphrase_vault,

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

3075 input=json.dumps(config),

3076 catch_exceptions=False,

3077 )

3078 config_txt = cli_helpers.config_filename( 1 ctx 1w

3079 subsystem='vault'

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

3081 config3 = json.loads(config_txt) 1 ctx 1w

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

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

3084 assert not result.stderr or all( 1 ctx 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) 1 ctx 1w

3088

3089 def test_213b_import_bad_config_not_vault_config( (empty)

3090 self,

3091 ) -> None:

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

3093 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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 ctx 1~

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

3099 stack.enter_context( 1 ctx 1~

3100 tests.isolated_config(

3101 monkeypatch=monkeypatch,

3102 runner=runner,

3103 )

3104 )

3105 result = runner.invoke( 1 ctx 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 ctx 1~

3112 'expected error exit and known error message'

3113 )

3114

3115 def test_213c_import_bad_config_not_json_data( (empty)

3116 self,

3117 ) -> None:

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

3119 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 2ab

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

3125 stack.enter_context( 1 ctx 2ab

3126 tests.isolated_config(

3127 monkeypatch=monkeypatch,

3128 runner=runner,

3129 )

3130 )

3131 result = runner.invoke( 1 ctx 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'), ( 1 ctx 2ab

3138 'expected error exit and known error message'

3139 )

3140

3141 def test_213d_import_bad_config_not_a_file( (empty)

3142 self,

3143 ) -> None:

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

3145 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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 ctx 1$

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

3156 stack.enter_context( 1 ctx 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 ctx 1$

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

3165 )

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

3167 result = runner.invoke( 1 ctx 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 ctx 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 (empty)

3180 def test_214_export_config_success( (empty)

3181 self,

3182 caplog: pytest.LogCaptureFixture,

3183 config: Any,

3184 ) -> None:

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

3186 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1D

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

3192 stack.enter_context( 1 ctx 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( 1 ctx 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) 1 ctx 1D

3204 result = runner.invoke( 1 ctx 1D

3205 cli.derivepassphrase_vault,

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

3207 catch_exceptions=False,

3208 )

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

3210 encoding='UTF-8'

3211 ) as infile:

3212 config2 = json.load(infile) 1 ctx 1D

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

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

3215 assert not result.stderr or all( # pragma: no branch 1 ctx 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) 1 ctx 1D

3219

3220 @Parametrize.EXPORT_FORMAT_OPTIONS (empty)

3221 def test_214a_export_settings_no_stored_settings( (empty)

3222 self,

3223 export_options: list[str],

3224 ) -> None:

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

3226 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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 ctx 1:

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

3232 stack.enter_context( 1 ctx 1:

3233 tests.isolated_config(

3234 monkeypatch=monkeypatch,

3235 runner=runner,

3236 )

3237 )

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

3239 missing_ok=True

3240 )

3241 result = runner.invoke( 1 ctx 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 ctx 1:

3251

3252 @Parametrize.EXPORT_FORMAT_OPTIONS (empty)

3253 def test_214b_export_settings_bad_stored_config( (empty)

3254 self,

3255 export_options: list[str],

3256 ) -> None:

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

3258 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 2bb

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

3264 stack.enter_context( 1 ctx 2bb

3265 tests.isolated_vault_config(

3266 monkeypatch=monkeypatch,

3267 runner=runner,

3268 vault_config={},

3269 )

3270 )

3271 result = runner.invoke( 1 ctx 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:'), ( 1 ctx 2bb

3278 'expected error exit and known error message'

3279 )

3280

3281 @Parametrize.EXPORT_FORMAT_OPTIONS (empty)

3282 def test_214c_export_settings_not_a_file( (empty)

3283 self,

3284 export_options: list[str],

3285 ) -> None:

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

3287 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 17

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

3293 stack.enter_context( 1 ctx 17

3294 tests.isolated_config(

3295 monkeypatch=monkeypatch,

3296 runner=runner,

3297 )

3298 )

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

3300 config_file.unlink(missing_ok=True) 1 ctx 17

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

3302 result = runner.invoke( 1 ctx 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:'), ( 1 ctx 17

3309 'expected error exit and known error message'

3310 )

3311

3312 @Parametrize.EXPORT_FORMAT_OPTIONS (empty)

3313 def test_214d_export_settings_target_not_a_file( (empty)

3314 self,

3315 export_options: list[str],

3316 ) -> None:

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

3318 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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 ctx 1;

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

3324 stack.enter_context( 1 ctx 1;

3325 tests.isolated_config(

3326 monkeypatch=monkeypatch,

3327 runner=runner,

3328 )

3329 )

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

3331 result = runner.invoke( 1 ctx 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 ctx 1;

3338 'expected error exit and known error message'

3339 )

3340

3341 @tests.skip_if_on_the_annoying_os (empty)

3342 @Parametrize.EXPORT_FORMAT_OPTIONS (empty)

3343 def test_214e_export_settings_settings_directory_not_a_directory( (empty)

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 (empty)

3377 @hypothesis.given( 2 ctx 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( (empty)

3387 self,

3388 notes_placement: Literal['before', 'after'],

3389 placement_args: list[str],

3390 notes: str,

3391 ) -> None:

3392 notes = notes.strip() 1 ctx 1N

3393 maybe_notes = {'notes': notes} if notes else {} 1 ctx 1N

3394 vault_config = { 1 ctx 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') 1 ctx 1N

3401 expected = ( 1 ctx 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) 1 ctx 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: 1 ctx 1N

3411 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1N

3412 stack.enter_context( 1 ctx 1N

3413 tests.isolated_vault_config(

3414 monkeypatch=monkeypatch,

3415 runner=runner,

3416 vault_config=vault_config,

3417 )

3418 )

3419 result = runner.invoke( 1 ctx 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' 1 ctx 1N

3425

3426 @Parametrize.MODERN_EDITOR_INTERFACE (empty)

3427 @hypothesis.settings( 2 ctx 1ar

3428 suppress_health_check=[

3429 *hypothesis.settings().suppress_health_check,

3430 hypothesis.HealthCheck.function_scoped_fixture,

3431 ],

3432 )

3433 @hypothesis.given( (empty)

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( (empty)

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( 1 ctx 1r

3450 cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER

3451 )

3452 edit_result = f""" 1 ctx 1r

3453

3454{marker}

3455{notes}

3456"""

3457 # Reset caplog between hypothesis runs.

3458 caplog.clear() 1 ctx 1r

3459 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1r

3464 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1r

3465 stack.enter_context( 1 ctx 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( 1 ctx 1r

3476 subsystem='notes backup'

3477 )

3478 notes_backup_file.write_text( 1 ctx 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) 1 ctx 1r

3483 result = runner.invoke( 1 ctx 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' 1 ctx 1r

3497 assert all(map(is_warning_line, result.stderr.splitlines(True))) 1 ctx 1r

3498 assert modern_editor_interface or tests.warning_emitted( 1 ctx 1r

3499 'A backup copy of the old notes was saved',

3500 caplog.record_tuples,

3501 ), 'expected known warning message in stderr'

3502 assert ( 1 ctx 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( 1 ctx 1r

3508 encoding='UTF-8'

3509 ) as infile:

3510 config = json.load(infile) 1 ctx 1r

3511 assert config == { 1 ctx 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 (empty)

3523 @hypothesis.given( 2 ctx 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( (empty)

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: 1 ctx 1q

3541 del text 1 ctx 1q

3542 return '' 1 ctx 1q

3543

3544 def space(text: str, *_args: Any, **_kwargs: Any) -> str: 1 ctx 1q

3545 del text 1 ctx 1q

3546 return ' ' + notes.strip() + '\n\n\n\n\n\n' 1 ctx 1q

3547

3548 edit_funcs = {'empty': empty, 'space': space} 1 ctx 1q

3549 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1q

3554 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1q

3555 stack.enter_context( 1 ctx 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( 1 ctx 1q

3566 subsystem='notes backup'

3567 )

3568 notes_backup_file.write_text( 1 ctx 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]) 1 ctx 1q

3573 result = runner.invoke( 1 ctx 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( 1 ctx 1q

3587 error='the user aborted the request'

3588 ), 'expected clean exit'

3589 assert ( 1 ctx 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( 1 ctx 1q

3595 encoding='UTF-8'

3596 ) as infile:

3597 config = json.load(infile) 1 ctx 1q

3598 assert config == { 1 ctx 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 (empty)

3606 @hypothesis.settings( 2 ctx 1as

3607 suppress_health_check=[

3608 *hypothesis.settings().suppress_health_check,

3609 hypothesis.HealthCheck.function_scoped_fixture,

3610 ],

3611 )

3612 @hypothesis.given( (empty)

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( (empty)

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( 1 ctx 1s

3633 cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER

3634 )

3635 hypothesis.assume(str(notes_marker) not in notes.strip()) 1 ctx 1s

3636 # Reset caplog between hypothesis runs.

3637 caplog.clear() 1 ctx 1s

3638 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1s

3643 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1s

3644 stack.enter_context( 1 ctx 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( 1 ctx 1s

3655 subsystem='notes backup'

3656 )

3657 notes_backup_file.write_text( 1 ctx 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) 1 ctx 1s

3662 result = runner.invoke( 1 ctx 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' 1 ctx 1s

3676 assert not result.stderr or all( 1 ctx 1s

3677 map(is_warning_line, result.stderr.splitlines(True))

3678 )

3679 assert not caplog.record_tuples or tests.warning_emitted( 1 ctx 1s

3680 'A backup copy of the old notes was saved',

3681 caplog.record_tuples,

3682 ), 'expected known warning message in stderr'

3683 assert ( 1 ctx 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( 1 ctx 1s

3689 encoding='UTF-8'

3690 ) as infile:

3691 config = json.load(infile) 1 ctx 1s

3692 assert config == { 1 ctx 1s

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

3694 'services': {'sv': {'notes': notes.strip()}},

3695 }

3696

3697 @hypothesis.given( (empty)

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( (empty)

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) 1 ctx 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: 1 ctx 1X

3720 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1X

3721 stack.enter_context( 1 ctx 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: '') 1 ctx 1X

3732 result = runner.invoke( 1 ctx 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'), ( 1 ctx 1X

3744 'expected known error message'

3745 )

3746 with cli_helpers.config_filename(subsystem='vault').open( 1 ctx 1X

3747 encoding='UTF-8'

3748 ) as infile:

3749 config = json.load(infile) 1 ctx 1X

3750 assert config == { 1 ctx 1X

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

3752 'services': {'sv': {'notes': notes.strip()}},

3753 }

3754

3755 def test_223a_edit_empty_notes_abort( (empty)

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) 1 ctx 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: 1 ctx 1Y

3768 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1Y

3769 stack.enter_context( 1 ctx 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: '') 1 ctx 1Y

3780 result = runner.invoke( 1 ctx 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'), ( 1 ctx 1Y

3792 'expected known error message'

3793 )

3794 with cli_helpers.config_filename(subsystem='vault').open( 1 ctx 1Y

3795 encoding='UTF-8'

3796 ) as infile:

3797 config = json.load(infile) 1 ctx 1Y

3798 assert config == { 1 ctx 1Y

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

3800 'services': {},

3801 }

3802

3803 @Parametrize.MODERN_EDITOR_INTERFACE (empty)

3804 @hypothesis.settings( 2 ctx 1ao

3805 suppress_health_check=[

3806 *hypothesis.settings().suppress_health_check,

3807 hypothesis.HealthCheck.function_scoped_fixture,

3808 ],

3809 )

3810 @hypothesis.given( (empty)

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( (empty)

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 {} 1 ctx 1o

3826 vault_config = { 1 ctx 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() 1 ctx 1o

3834 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1o

3839 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1o

3840 stack.enter_context( 1 ctx 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 1 ctx 1o

3848

3849 def raiser(*_args: Any, **_kwargs: Any) -> NoReturn: 1 ctx 1o

3850 pytest.fail(EDIT_ATTEMPTED)

3851

3852 notes_backup_file = cli_helpers.config_filename( 1 ctx 1o

3853 subsystem='notes backup'

3854 )

3855 notes_backup_file.write_text( 1 ctx 1o

3856 'These backup notes are left over from the previous session.',

3857 encoding='UTF-8',

3858 )

3859 monkeypatch.setattr(click, 'edit', raiser) 1 ctx 1o

3860 result = runner.invoke( 1 ctx 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( 1 ctx 1o

3873 output=DUMMY_RESULT_PASSPHRASE.decode('ascii')

3874 ), 'expected clean exit'

3875 assert result.stderr 1 ctx 1o

3876 assert notes.strip() in result.stderr 1 ctx 1o

3877 assert all( 1 ctx 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( 1 ctx 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 ( 1 ctx 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( 1 ctx 1o

3893 encoding='UTF-8'

3894 ) as infile:

3895 config = json.load(infile) 1 ctx 1o

3896 assert config == vault_config 1 ctx 1o

3897

3898 @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG (empty)

3899 def test_224_store_config_good( (empty)

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) 1 ctx 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: 1 ctx 1F

3916 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1F

3917 stack.enter_context( 1 ctx 1F

3918 tests.isolated_vault_config(

3919 monkeypatch=monkeypatch,

3920 runner=runner,

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

3922 )

3923 )

3924 monkeypatch.setattr( 1 ctx 1F

3925 cli_helpers, 'get_suitable_ssh_keys', tests.suitable_ssh_keys

3926 )

3927 result = runner.invoke( 1 ctx 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' 1 ctx 1F

3934 config_txt = cli_helpers.config_filename( 1 ctx 1F

3935 subsystem='vault'

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

3937 config = json.loads(config_txt) 1 ctx 1F

3938 assert config == result_config, ( 1 ctx 1F

3939 'stored config does not match expectation'

3940 )

3941 assert_vault_config_is_indented_and_line_broken(config_txt) 1 ctx 1F

3942

3943 @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES (empty)

3944 def test_225_store_config_fail( (empty)

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 ctx 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 ctx 1=

3956 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1=

3957 stack.enter_context( 1 ctx 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 ctx 1=

3965 cli_helpers, 'get_suitable_ssh_keys', tests.suitable_ssh_keys

3966 )

3967 result = runner.invoke( 1 ctx 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 ctx 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( (empty)

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 ctx 1%

3992 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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 ctx 1%

3997 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1%

3998 stack.enter_context( 1 ctx 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 ctx 1%

4006 result = runner.invoke( 1 ctx 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 ctx 1%

4012 'expected error exit and known error message'

4013 )

4014

4015 def test_225c_store_config_fail_manual_bad_ssh_agent_connection( (empty)

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() 1 ctx 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 (empty)

4046 def test_225d_store_config_fail_manual_read_only_file( (empty)

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 ctx 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 ctx 1?

4056 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1?

4057 stack.enter_context( 1 ctx 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 ctx 1?

4065 cli_helpers.config_filename(subsystem='vault'),

4066 try_race_free_implementation=try_race_free_implementation,

4067 )

4068 result = runner.invoke( 1 ctx 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 ctx 1?

4074 'expected error exit and known error message'

4075 )

4076

4077 def test_225e_store_config_fail_manual_custom_error( (empty)

4078 self,

4079 ) -> None:

4080 """OS-erroring with `--config` fails."""

4081 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1S

4086 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1S

4087 stack.enter_context( 1 ctx 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' 1 ctx 1S

4095

4096 def raiser(config: Any) -> None: 1 ctx 1S

4097 del config 1 ctx 1S

4098 raise RuntimeError(custom_error) 1 ctx 1S

4099

4100 monkeypatch.setattr(cli_helpers, 'save_config', raiser) 1 ctx 1S

4101 result = runner.invoke( 1 ctx 1S

4102 cli.derivepassphrase_vault,

4103 ['--config', '--length=15', '--', DUMMY_SERVICE],

4104 catch_exceptions=False,

4105 )

4106 assert result.error_exit(error=custom_error), ( 1 ctx 1S

4107 'expected error exit and known error message'

4108 )

4109

4110 def test_225f_store_config_fail_unset_and_set_same_settings( (empty)

4111 self,

4112 ) -> None:

4113 """Issuing conflicting settings to `--config` fails."""

4114 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 2cb

4119 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 2cb

4120 stack.enter_context( 1 ctx 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( 1 ctx 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( 1 ctx 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( (empty)

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 1 ctx 1Z

4148 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1Z

4153 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1Z

4154 stack.enter_context( 1 ctx 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( 1 ctx 1Z

4163 *_args: Any,

4164 **_kwargs: Any,

4165 ) -> list[_types.SSHKeyCommentPair]:

4166 return [] 1 ctx 1Z

4167

4168 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'list_keys', func) 1 ctx 1Z

4169 result = runner.invoke( 1 ctx 1Z

4170 cli.derivepassphrase_vault,

4171 ['--key', '--config'],

4172 catch_exceptions=False,

4173 )

4174 assert result.error_exit(error='no keys suitable'), ( 1 ctx 1Z

4175 'expected error exit and known error message'

4176 )

4177

4178 def test_225h_store_config_fail_manual_ssh_agent_runtime_error( (empty)

4179 self,

4180 running_ssh_agent: tests.RunningSSHAgentInfo,

4181 ) -> None:

4182 """The SSH agent erroring during `--config --key` fails."""

4183 del running_ssh_agent 1 ctx 10

4184 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 10

4189 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 10

4190 stack.enter_context( 1 ctx 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: 1 ctx 10

4199 raise ssh_agent.TrailingDataError() 1 ctx 10

4200

4201 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'list_keys', raiser) 1 ctx 10

4202 result = runner.invoke( 1 ctx 10

4203 cli.derivepassphrase_vault,

4204 ['--key', '--config'],

4205 catch_exceptions=False,

4206 )

4207 assert result.error_exit( 1 ctx 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( (empty)

4212 self,

4213 running_ssh_agent: tests.RunningSSHAgentInfo,

4214 ) -> None:

4215 """The SSH agent refusing during `--config --key` fails."""

4216 del running_ssh_agent 1 ctx 11

4217 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 11

4222 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 11

4223 stack.enter_context( 1 ctx 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: 1 ctx 11

4232 raise ssh_agent.SSHAgentFailedError( 1 ctx 11

4233 _types.SSH_AGENT.FAILURE, b''

4234 )

4235

4236 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'list_keys', func) 1 ctx 11

4237 result = runner.invoke( 1 ctx 11

4238 cli.derivepassphrase_vault,

4239 ['--key', '--config'],

4240 catch_exceptions=False,

4241 )

4242 assert result.error_exit(error='refused to'), ( 1 ctx 11

4243 'expected error exit and known error message'

4244 )

4245

4246 def test_226_no_arguments(self) -> None: (empty)

4247 """Calling `derivepassphrase vault` without any arguments fails."""

4248 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 2db

4253 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 2db

4254 stack.enter_context( 1 ctx 2db

4255 tests.isolated_config(

4256 monkeypatch=monkeypatch,

4257 runner=runner,

4258 )

4259 )

4260 result = runner.invoke( 1 ctx 2db

4261 cli.derivepassphrase_vault, [], catch_exceptions=False

4262 )

4263 assert result.error_exit( 1 ctx 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( (empty)

4268 self,

4269 ) -> None:

4270 """Deriving a passphrase without a passphrase or key fails."""

4271 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 2eb

4276 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 2eb

4277 stack.enter_context( 1 ctx 2eb

4278 tests.isolated_config(

4279 monkeypatch=monkeypatch,

4280 runner=runner,

4281 )

4282 )

4283 result = runner.invoke( 1 ctx 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'), ( 1 ctx 2eb

4289 'expected error exit and known error message'

4290 )

4291

4292 def test_230_config_directory_nonexistant( (empty)

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) 1 ctx 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: 1 ctx 1O

4307 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1O

4308 stack.enter_context( 1 ctx 1O

4309 tests.isolated_config(

4310 monkeypatch=monkeypatch,

4311 runner=runner,

4312 )

4313 )

4314 with contextlib.suppress(FileNotFoundError): 1 ctx 1O

4315 shutil.rmtree(cli_helpers.config_filename(subsystem=None)) 1 ctx 1O

4316 result = runner.invoke( 1 ctx 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' 1 ctx 1O

4323 assert result.stderr == 'Passphrase:', ( 1 ctx 1O

4324 'program unexpectedly failed?!'

4325 )

4326 with cli_helpers.config_filename(subsystem='vault').open( 1 ctx 1O

4327 encoding='UTF-8'

4328 ) as infile:

4329 config_readback = json.load(infile) 1 ctx 1O

4330 assert config_readback == { 1 ctx 1O

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

4332 'services': {},

4333 }, 'config mismatch'

4334

4335 def test_230a_config_directory_not_a_file( (empty)

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) 1 ctx 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: 1 ctx 1E

4353 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1E

4354 stack.enter_context( 1 ctx 1E

4355 tests.isolated_config(

4356 monkeypatch=monkeypatch,

4357 runner=runner,

4358 )

4359 )

4360 save_config_ = cli_helpers.save_config 1 ctx 1E

4361

4362 def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any: 1 ctx 1E

4363 config_dir = cli_helpers.config_filename(subsystem=None) 1 ctx 1E

4364 with contextlib.suppress(FileNotFoundError): 1 ctx 1E

4365 shutil.rmtree(config_dir) 1 ctx 1E

4366 config_dir.write_text('Obstruction!!\n') 1 ctx 1E

4367 monkeypatch.setattr(cli_helpers, 'save_config', save_config_) 1 ctx 1E

4368 return save_config_(*args, **kwargs) 1 ctx 1E

4369

4370 monkeypatch.setattr( 1 ctx 1E

4371 cli_helpers, 'save_config', obstruct_config_saving

4372 )

4373 result = runner.invoke( 1 ctx 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:'), ( 1 ctx 1E

4380 'expected error exit and known error message'

4381 )

4382

4383 def test_230b_store_config_custom_error( (empty)

4384 self,

4385 ) -> None:

4386 """Storing the configuration reacts even to weird errors."""

4387 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1T

4392 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1T

4393 stack.enter_context( 1 ctx 1T

4394 tests.isolated_config(

4395 monkeypatch=monkeypatch,

4396 runner=runner,

4397 )

4398 )

4399 custom_error = 'custom error message' 1 ctx 1T

4400

4401 def raiser(config: Any) -> None: 1 ctx 1T

4402 del config 1 ctx 1T

4403 raise RuntimeError(custom_error) 1 ctx 1T

4404

4405 monkeypatch.setattr(cli_helpers, 'save_config', raiser) 1 ctx 1T

4406 result = runner.invoke( 1 ctx 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), ( 1 ctx 1T

4413 'expected error exit and known error message'

4414 )

4415

4416 @Parametrize.UNICODE_NORMALIZATION_WARNING_INPUTS (empty)

4417 def test_300_unicode_normalization_form_warning( (empty)

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 ctx 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 ctx 1@

4431 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1@

4432 stack.enter_context( 1 ctx 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 ctx 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 ctx 1@

4451 assert tests.warning_emitted(warning_message, caplog.record_tuples), ( 1 ctx 1@

4452 'expected known warning message in stderr'

4453 )

4454

4455 @Parametrize.UNICODE_NORMALIZATION_ERROR_INPUTS (empty)

4456 def test_301_unicode_normalization_form_error( (empty)

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 ctx 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 ctx 1[

4469 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1[

4470 stack.enter_context( 1 ctx 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 ctx 1[

4483 cli.derivepassphrase_vault,

4484 command_line,

4485 catch_exceptions=False,

4486 input=input,

4487 )

4488 assert result.error_exit( 1 ctx 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 ctx 1[

4492 'expected error exit and known error message'

4493 )

4494

4495 @Parametrize.UNICODE_NORMALIZATION_COMMAND_LINES (empty)

4496 def test_301a_unicode_normalization_form_error_from_stored_config( (empty)

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 ctx 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 ctx 1]

4506 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1]

4507 stack.enter_context( 1 ctx 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 ctx 1]

4522 cli.derivepassphrase_vault,

4523 command_line,

4524 input=DUMMY_PASSPHRASE,

4525 catch_exceptions=False,

4526 )

4527 assert result.error_exit( 1 ctx 1]

4528 error='The user configuration file is invalid.'

4529 ), 'expected error exit and known error message'

4530 assert result.error_exit( 1 ctx 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( (empty)

4538 self,

4539 ) -> None:

4540 """Loading a user configuration file in an invalid format fails."""

4541 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 2fb

4546 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 2fb

4547 stack.enter_context( 1 ctx 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( 1 ctx 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:'), ( 1 ctx 2fb

4562 'expected error exit and known error message'

4563 )

4564

4565 def test_311_bad_user_config_is_a_directory( (empty)

4566 self,

4567 ) -> None:

4568 """Loading a user configuration file in an invalid format fails."""

4569 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 18

4574 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 18

4575 stack.enter_context( 1 ctx 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( 1 ctx 18

4584 subsystem='user configuration'

4585 )

4586 user_config.unlink() 1 ctx 18

4587 user_config.mkdir(parents=True, exist_ok=True) 1 ctx 18

4588 result = runner.invoke( 1 ctx 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:'), ( 1 ctx 18

4595 'expected error exit and known error message'

4596 )

4597

4598 def test_400_missing_af_unix_support( (empty)

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) 1 ctx 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: 1 ctx 12

4608 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 12

4609 stack.enter_context( 1 ctx 12

4610 tests.isolated_vault_config(

4611 monkeypatch=monkeypatch,

4612 runner=runner,

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

4614 )

4615 )

4616 monkeypatch.setenv( 1 ctx 12

4617 'SSH_AUTH_SOCK', "the value doesn't even matter"

4618 )

4619 monkeypatch.setattr( 1 ctx 12

4620 ssh_agent.SSHAgentClient, 'SOCKET_PROVIDERS', ['posix']

4621 )

4622 monkeypatch.delattr(socket, 'AF_UNIX', raising=False) 1 ctx 12

4623 result = runner.invoke( 1 ctx 12

4624 cli.derivepassphrase_vault,

4625 ['--key', '--config'],

4626 catch_exceptions=False,

4627 )

4628 assert result.error_exit( 1 ctx 12

4629 error='does not support communicating with it'

4630 ), 'expected error exit and known error message'

4631 assert tests.warning_emitted( 1 ctx 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

4637 class TestCLIUtils: (empty)

4638 """Tests for command-line utility functions."""

4639

4640 @Parametrize.BASE_CONFIG_VARIATIONS (empty)

4641 def test_100_load_config( (empty)

4642 self,

4643 config: Any,

4644 ) -> None:

4645 """[`cli_helpers.load_config`][] works for valid configurations."""

4646 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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 ctx 1'

4651 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1'

4652 stack.enter_context( 1 ctx 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 ctx 1'

4660 with config_filename.open(encoding='UTF-8') as fileobj: 1 ctx 1'

4661 assert json.load(fileobj) == config 1 ctx 1'

4662 assert cli_helpers.load_config() == config 1 ctx 1'

4663

4664 def test_110_save_bad_config( (empty)

4665 self,

4666 ) -> None:

4667 """[`cli_helpers.save_config`][] fails for bad configurations."""

4668 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 2gb

4673 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 2gb

4674 stack.enter_context( 1 ctx 2gb

4675 tests.isolated_vault_config(

4676 monkeypatch=monkeypatch,

4677 runner=runner,

4678 vault_config={},

4679 )

4680 )

4681 stack.enter_context( 1 ctx 2gb

4682 pytest.raises(ValueError, match='Invalid vault config')

4683 )

4684 cli_helpers.save_config(None) # type: ignore[arg-type] 1 ctx 2gb

4685

4686 def test_111_prompt_for_selection_multiple(self) -> None: (empty)

4687 """[`cli_helpers.prompt_for_selection`][] works in the "multiple" case."""

4688

4689 @click.command() 1 ctx 1y

4690 @click.option('--heading', default='Our menu:') 1 ctx 1y

4691 @click.argument('items', nargs=-1) 1 ctx 1y

4692 def driver(heading: str, items: list[str]) -> None: 1 ctx 1y

4693 # from https://montypython.fandom.com/wiki/Spam#The_menu

4694 items = items or [ 1 ctx 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) 1 ctx 1y

4714 click.echo('A fine choice: ', nl=False) 1 ctx 1y

4715 click.echo(items[index]) 1 ctx 1y

4716 click.echo('(Note: Vikings strictly optional.)') 1 ctx 1y

4717

4718 runner = tests.CliRunner(mix_stderr=True) 1 ctx 1y

4719 result = runner.invoke(driver, [], input='9') 1 ctx 1y

4720 assert result.clean_exit( 1 ctx 1y

4721 output="""\

4722 Our 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

4733 Your selection? (1-10, leave empty to abort): 9

4734 A 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( 1 ctx 1y

4739 driver, ['--heading='], input='\n', catch_exceptions=True

4740 )

4741 assert result.error_exit(error=IndexError), ( 1 ctx 1y

4742 'expected error exit and known error type'

4743 )

4744 assert ( 1 ctx 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

4757 Your 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( 1 ctx 1y

4765 driver, ['--heading='], input='', catch_exceptions=True

4766 )

4767 assert result.error_exit(error=IndexError), ( 1 ctx 1y

4768 'expected error exit and known error type'

4769 )

4770 assert result.stdout in { 1 ctx 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

4782 Your 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

4795 Your selection? (1-10, leave empty to abort): """,

4796 }, 'expected known output'

4797

4798 def test_112_prompt_for_selection_single(self) -> None: (empty)

4799 """[`cli_helpers.prompt_for_selection`][] works in the "single" case."""

4800

4801 @click.command() 1 ctx 1x

4802 @click.option('--item', default='baked beans') 1 ctx 1x

4803 @click.argument('prompt') 1 ctx 1x

4804 def driver(item: str, prompt: str) -> None: 1 ctx 1x

4805 try: 1 ctx 1x

4806 cli_helpers.prompt_for_selection( 1 ctx 1x

4807 [item], heading='', single_choice_prompt=prompt

4808 )

4809 except IndexError: 1 ctx 1x

4810 click.echo('Boo.') 1 ctx 1x

4811 raise 1 ctx 1x

4812 else:

4813 click.echo('Great!') 1 ctx 1x

4814

4815 runner = tests.CliRunner(mix_stderr=True) 1 ctx 1x

4816 result = runner.invoke( 1 ctx 1x

4817 driver, ['Will replace with spam. Confirm, y/n?'], input='y'

4818 )

4819 assert result.clean_exit( 1 ctx 1x

4820 output="""\

4821[1] baked beans

4822 Will replace with spam. Confirm, y/n? y

4823 Great!

4824"""

4825 ), 'expected clean exit'

4826 result = runner.invoke( 1 ctx 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), ( 1 ctx 1x

4832 'expected error exit and known error type'

4833 )

4834 assert ( 1 ctx 1x

4835 result.stdout

4836 == """\

4837[1] baked beans

4838 Will replace with spam, okay? (Please say "y" or "n".):\x20

4839 Boo.

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( 1 ctx 1x

4847 driver,

4848 ['Will replace with spam, okay? (Please say "y" or "n".)'],

4849 input='',

4850 )

4851 assert result.error_exit(error=IndexError), ( 1 ctx 1x

4852 'expected error exit and known error type'

4853 )

4854 assert result.stdout in { 1 ctx 1x

4855 """\

4856[1] baked beans

4857 Will replace with spam, okay? (Please say "y" or "n".):\x20

4858 Boo.

4859""",

4860 """\

4861[1] baked beans

4862 Will replace with spam, okay? (Please say "y" or "n".): Boo.

4863""",

4864 }, 'expected known output'

4865

4866 def test_113_prompt_for_passphrase( (empty)

4867 self,

4868 ) -> None:

4869 """[`cli_helpers.prompt_for_passphrase`][] works."""

4870 with pytest.MonkeyPatch.context() as monkeypatch: 1 ctx 1U

4871 monkeypatch.setattr( 1 ctx 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()) 1 ctx 1U

4877 err_msg = 'missing arguments to passphrase prompt' 1 ctx 1U

4878 assert 'args' in res, err_msg 1 ctx 1U

4879 assert 'kwargs' in res, err_msg 1 ctx 1U

4880 assert res['args'][:1] == ['Passphrase'], err_msg 1 ctx 1U

4881 assert res['kwargs'].get('default') == '', err_msg 1 ctx 1U

4882 assert not res['kwargs'].get('show_default', True), err_msg 1 ctx 1U

4883 assert res['kwargs'].get('err'), err_msg 1 ctx 1U

4884 assert res['kwargs'].get('hide_input'), err_msg 1 ctx 1U

4885

4886 def test_120_standard_logging_context_manager( (empty)

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 1 ctx 1B

4898 package_name = cli_machinery.StandardCLILogging.package_name 1 ctx 1B

4899 logger = logging.getLogger(package_name) 1 ctx 1B

4900 deprecation_logger = logging.getLogger(f'{package_name}.deprecation') 1 ctx 1B

4901 logging_cm = cli_machinery.StandardCLILogging.ensure_standard_logging() 1 ctx 1B

4902 with logging_cm: 1 ctx 1B

4903 assert ( 1 ctx 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') 1 ctx 1B

4912 with logging_cm: 1 ctx 1B

4913 deprecation_logger.warning('message 2') 1 ctx 1B

4914 assert ( 1 ctx 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() == ( 1 ctx 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') 1 ctx 1B

4930 assert ( 1 ctx 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() == ( 1 ctx 1B

4939 '',

4940 f'{prog_name}: Warning: message 3\n',

4941 )

4942 assert caplog.record_tuples == [ 1 ctx 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( (empty)

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 = ( 1 ctx 1p

4962 cli_machinery.StandardCLILogging.ensure_standard_warnings_logging()

4963 )

4964 THE_FUTURE = 'the future will be here sooner than you think' # noqa: N806 1 ctx 1p

4965 JUST_TESTING = 'just testing whether warnings work' # noqa: N806 1 ctx 1p

4966 with warnings_cm: 1 ctx 1p

4967 assert ( 1 ctx 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) 1 ctx 1p

4976 with warnings_cm: 1 ctx 1p

4977 warnings.warn(FutureWarning(THE_FUTURE), stacklevel=1) 1 ctx 1p

4978 _out, err = capsys.readouterr() 1 ctx 1p

4979 err_lines = err.splitlines(True) 1 ctx 1p

4980 assert any( 1 ctx 1p

4981 f'UserWarning: {JUST_TESTING}' in line

4982 for line in err_lines

4983 )

4984 assert any( 1 ctx 1p

4985 f'FutureWarning: {THE_FUTURE}' in line

4986 for line in err_lines

4987 )

4988 warnings.warn(UserWarning(JUST_TESTING), stacklevel=1) 1 ctx 1p

4989 _out, err = capsys.readouterr() 1 ctx 1p

4990 err_lines = err.splitlines(True) 1 ctx 1p

4991 assert any( 1 ctx 1p

4992 f'UserWarning: {JUST_TESTING}' in line for line in err_lines

4993 )

4994 assert not any( 1 ctx 1p

4995 f'FutureWarning: {THE_FUTURE}' in line for line in err_lines

4996 )

4997 record_tuples = caplog.record_tuples 1 ctx 1p

4998 assert [tup[:2] for tup in record_tuples] == [ 1 ctx 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] 1 ctx 1p

5004 assert f'FutureWarning: {THE_FUTURE}' in record_tuples[1][2] 1 ctx 1p

5005 assert f'UserWarning: {JUST_TESTING}' in record_tuples[2][2] 1 ctx 1p

5006

5007 def export_as_sh_helper( (empty)

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') 4 ctx 1jikl

5025 with io.StringIO() as outfile: 4 ctx 1jikl

5026 cli_helpers.print_config_as_sh_script( 4 ctx 1jikl

5027 config, outfile=outfile, prog_name_list=prog_name_list

5028 )

5029 script = outfile.getvalue() 4 ctx 1jikl

5030 runner = tests.CliRunner(mix_stderr=False) 4 ctx 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: 4 ctx 1jikl

5035 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 4 ctx 1jikl

5036 stack.enter_context( 4 ctx 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): 4 ctx 1jikl

5044 assert result.clean_exit() 4 ctx 1jikl

5045 assert cli_helpers.load_config() == config 4 ctx 1jikl

5046

5047 @hypothesis.given( (empty)

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( (empty)

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 = { 1 ctx 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 1 ctx 1j

5088 assert _types.is_vault_config(config) 1 ctx 1j

5089 return self.export_as_sh_helper(config) 1 ctx 1j

5090

5091 @hypothesis.given( (empty)

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( (empty)

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 = { 1 ctx 1i

5125 'global': global_config_importable,

5126 'services': {},

5127 }

5128 assert _types.clean_up_falsy_vault_config_values(config) is not None 1 ctx 1i

5129 assert _types.is_vault_config(config) 1 ctx 1i

5130 if not config['global']: 1 ctx 1i

5131 config.pop('global') 1 ctx 1i

5132 return self.export_as_sh_helper(config) 1 ctx 1i

5133

5134 @hypothesis.given( (empty)

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( (empty)

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 = { 1 ctx 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 1 ctx 1k

5195 assert _types.is_vault_config(config) 1 ctx 1k

5196 return self.export_as_sh_helper(config) 1 ctx 1k

5197

5198 @hypothesis.given( (empty)

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( (empty)

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 = { 1 ctx 1l

5249 'services': {

5250 service_name: service_config_importable,

5251 },

5252 }

5253 assert _types.clean_up_falsy_vault_config_values(config) is not None 1 ctx 1l

5254 assert _types.is_vault_config(config) 1 ctx 1l

5255 return self.export_as_sh_helper(config) 1 ctx 1l

5256

5257 # The Annoying OS appears to silently truncate spaces at the end of

5258 # filenames.

5259 @hypothesis.given( (empty)

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='.') (empty)

5276 def test_140a_get_tempdir( (empty)

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 1 ctx 1n

5291 def make_temporary_directory( 1 ctx 1n

5292 path: pathlib.Path,

5293 ) -> Iterator[pathlib.Path]:

5294 try: 1 ctx 1n

5295 path.mkdir() 1 ctx 1n

5296 yield path 1 ctx 1n

5297 finally:

5298 shutil.rmtree(path) 1 ctx 1n

5299

5300 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1n

5305 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1n

5306 stack.enter_context( 1 ctx 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()) 1 ctx 1n

5314 monkeypatch.delenv('TMPDIR', raising=False) 1 ctx 1n

5315 monkeypatch.delenv('TEMP', raising=False) 1 ctx 1n

5316 monkeypatch.delenv('TMP', raising=False) 1 ctx 1n

5317 monkeypatch.setattr(tempfile, 'tempdir', None) 1 ctx 1n

5318 temp_path = pathlib.Path.cwd() / suffix 1 ctx 1n

5319 if env_var: 1 ctx 1n

5320 monkeypatch.setenv(env_var, os.fsdecode(temp_path)) 1 ctx 1n

5321 stack.enter_context(make_temporary_directory(temp_path)) 1 ctx 1n

5322 new_tempdir = os.fsdecode(tempfile.gettempdir()) 1 ctx 1n

5323 hypothesis.assume( 1 ctx 1n

5324 temp_path.resolve() == pathlib.Path.cwd().resolve()

5325 or old_tempdir != new_tempdir

5326 )

5327 system_tempdir = os.fsdecode(tempfile.gettempdir()) 1 ctx 1n

5328 our_tempdir = cli_helpers.get_tempdir() 1 ctx 1n

5329 assert system_tempdir == os.fsdecode(our_tempdir) or ( 1 ctx 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!' 1 ctx 1n

5338

5339 def test_140b_get_tempdir_force_default(self) -> None: (empty)

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) 1 ctx 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: 1 ctx 1z

5352 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1z

5353 stack.enter_context( 1 ctx 1z

5354 tests.isolated_vault_config(

5355 monkeypatch=monkeypatch,

5356 runner=runner,

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

5358 )

5359 )

5360 monkeypatch.delenv('TMPDIR', raising=False) 1 ctx 1z

5361 monkeypatch.delenv('TEMP', raising=False) 1 ctx 1z

5362 monkeypatch.delenv('TMP', raising=False) 1 ctx 1z

5363 config_dir = cli_helpers.config_filename(subsystem=None) 1 ctx 1z

5364

5365 def is_dir_false( 1 ctx 1z

5366 self: pathlib.Path,

5367 /,

5368 *,

5369 follow_symlinks: bool = False,

5370 ) -> bool:

5371 del self, follow_symlinks 1 ctx 1z

5372 return False 1 ctx 1z

5373

5374 def is_dir_error( 1 ctx 1z

5375 self: pathlib.Path,

5376 /,

5377 *,

5378 follow_symlinks: bool = False,

5379 ) -> bool:

5380 del follow_symlinks 1 ctx 1z

5381 raise OSError( 1 ctx 1z

5382 errno.EACCES,

5383 os.strerror(errno.EACCES),

5384 str(self),

5385 )

5386

5387 monkeypatch.setattr(pathlib.Path, 'is_dir', is_dir_false) 1 ctx 1z

5388 assert cli_helpers.get_tempdir() == config_dir 1 ctx 1z

5389

5390 monkeypatch.setattr(pathlib.Path, 'is_dir', is_dir_error) 1 ctx 1z

5391 assert cli_helpers.get_tempdir() == config_dir 1 ctx 1z

5392

5393 @Parametrize.DELETE_CONFIG_INPUT (empty)

5394 def test_203_repeated_config_deletion( (empty)

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]: 1 ctx 13

5402 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 13

5407 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 13

5408 stack.enter_context( 1 ctx 13

5409 tests.isolated_vault_config(

5410 monkeypatch=monkeypatch,

5411 runner=runner,

5412 vault_config=start_config,

5413 )

5414 )

5415 result = runner.invoke( 1 ctx 13

5416 cli.derivepassphrase_vault,

5417 command_line,

5418 catch_exceptions=False,

5419 )

5420 assert result.clean_exit(empty_stderr=True), ( 1 ctx 13

5421 'expected clean exit'

5422 )

5423 with cli_helpers.config_filename(subsystem='vault').open( 1 ctx 13

5424 encoding='UTF-8'

5425 ) as infile:

5426 config_readback = json.load(infile) 1 ctx 13

5427 assert config_readback == result_config 1 ctx 13

5428

5429 def test_204_phrase_from_key_manually(self) -> None: (empty)

5430 """The dummy service, key and config settings are consistent."""

5431 assert ( 1 ctx 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 (empty)

5439 def test_210a_validate_constraints_manually( (empty)

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, []) 1 ctx 2kb

5446 param = cli.derivepassphrase_vault.params[0] 1 ctx 2kb

5447 assert vfunc(ctx, param, input) == input 1 ctx 2kb

5448

5449 @Parametrize.CONNECTION_HINTS (empty)

5450 def test_227_get_suitable_ssh_keys( (empty)

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: 1 ctx 1I

5457 monkeypatch.setattr( 1 ctx 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': 1 ctx 1I

5465 hint = ssh_agent.SSHAgentClient() 1 ctx 1I

5466 elif conn_hint == 'socket': 1 ctx 1I

5467 if isinstance( 1 ctx 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() 1 ctx 1I

5477 else:

5478 assert conn_hint == 'none' 1 ctx 1I

5479 hint = None 1 ctx 1I

5480 exception: Exception | None = None 1 ctx 1I

5481 try: 1 ctx 1I

5482 list(cli_helpers.get_suitable_ssh_keys(hint)) 1 ctx 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, ( 1 ctx 1I

5489 'exception querying suitable SSH keys'

5490 )

5491

5492 @Parametrize.KEY_TO_PHRASE_SETTINGS (empty)

5493 def test_400_key_to_phrase( (empty)

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): 1 ctx 1c

5505 def __init__(self, *args: Any, **kwargs: Any) -> None: 1 ctx 1c

5506 super().__init__(*args[:1]) 1 ctx 1c

5507 self.args = args 1 ctx 1c

5508 self.kwargs = kwargs 1 ctx 1c

5509

5510 def err(*args: Any, **_kwargs: Any) -> NoReturn: 1 ctx 1c

5511 raise ErrCallback(*args, **_kwargs) 1 ctx 1c

5512

5513 with pytest.MonkeyPatch.context() as monkeypatch: 1 ctx 1c

5514 loaded_keys = list( 1 ctx 1c

5515 ssh_agent_client_with_test_keys_loaded.list_keys()

5516 )

5517 loaded_key = base64.standard_b64encode(loaded_keys[0][0]) 1 ctx 1c

5518 monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', sign_action) 1 ctx 1c

5519 if list_keys_action: 1 ctx 1c

5520 monkeypatch.setattr( 1 ctx 1c

5521 ssh_agent.SSHAgentClient, 'list_keys', list_keys_action

5522 )

5523 if address_action: 1 ctx 1c

5524 address_action(monkeypatch) 1 ctx 1c

5525 if system_support_action: 1 ctx 1c

5526 system_support_action(monkeypatch) 1 ctx 1c

5527 with pytest.raises(ErrCallback, match=pattern) as excinfo: 1 ctx 1c

5528 cli_helpers.key_to_phrase(loaded_key, error_callback=err) 1 ctx 1c

5529 if list_keys_action == ListKeysAction.FAIL_RUNTIME: 1 ctx 1c

5530 assert excinfo.value.kwargs 1 ctx 1c

5531 assert isinstance( 1 ctx 1c

5532 excinfo.value.kwargs['exc_info'],

5533 ssh_agent.SSHAgentFailedError,

5534 )

5535 assert excinfo.value.kwargs['exc_info'].__context__ is not None 1 ctx 1c

5536 assert isinstance( 1 ctx 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

5544 class TestCLITransition: (empty)

5545 """Transition tests for the command-line interface up to v1.0."""

5546

5547 @Parametrize.BASE_CONFIG_VARIATIONS (empty)

5548 def test_110_load_config_backup( (empty)

5549 self,

5550 config: Any,

5551 ) -> None:

5552 """Loading the old settings file works."""

5553 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 2hb

5558 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 2hb

5559 stack.enter_context( 1 ctx 2hb

5560 tests.isolated_config(

5561 monkeypatch=monkeypatch,

5562 runner=runner,

5563 )

5564 )

5565 cli_helpers.config_filename( 1 ctx 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 1 ctx 2hb

5569

5570 @Parametrize.BASE_CONFIG_VARIATIONS (empty)

5571 def test_111_migrate_config( (empty)

5572 self,

5573 config: Any,

5574 ) -> None:

5575 """Migrating the old settings file works."""

5576 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 2ib

5581 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 2ib

5582 stack.enter_context( 1 ctx 2ib

5583 tests.isolated_config(

5584 monkeypatch=monkeypatch,

5585 runner=runner,

5586 )

5587 )

5588 cli_helpers.config_filename( 1 ctx 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) 1 ctx 2ib

5592

5593 @Parametrize.BASE_CONFIG_VARIATIONS (empty)

5594 def test_112_migrate_config_error( (empty)

5595 self,

5596 config: Any,

5597 ) -> None:

5598 """Migrating the old settings file atop a directory fails."""

5599 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 14

5604 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 14

5605 stack.enter_context( 1 ctx 14

5606 tests.isolated_config(

5607 monkeypatch=monkeypatch,

5608 runner=runner,

5609 )

5610 )

5611 cli_helpers.config_filename( 1 ctx 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( 1 ctx 14

5615 parents=True, exist_ok=True

5616 )

5617 config2, err = cli_helpers.migrate_and_load_old_config() 1 ctx 14

5618 assert config2 == config 1 ctx 14

5619 assert isinstance(err, OSError) 1 ctx 14

5620 # The Annoying OS uses EEXIST, other OSes use EISDIR.

5621 assert err.errno in {errno.EISDIR, errno.EEXIST} 1 ctx 14

5622

5623 @Parametrize.BAD_CONFIGS (empty)

5624 def test_113_migrate_config_error_bad_config_value( (empty)

5625 self,

5626 config: Any,

5627 ) -> None:

5628 """Migrating an invalid old settings file fails."""

5629 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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 ctx 1^

5634 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1^

5635 stack.enter_context( 1 ctx 1^

5636 tests.isolated_config(

5637 monkeypatch=monkeypatch,

5638 runner=runner,

5639 )

5640 )

5641 cli_helpers.config_filename( 1 ctx 1^

5642 subsystem='old settings.json'

5643 ).write_text(json.dumps(config, indent=2) + '\n', encoding='UTF-8')

5644 with pytest.raises( 1 ctx 1^

5645 ValueError, match=cli_helpers.INVALID_VAULT_CONFIG

5646 ):

5647 cli_helpers.migrate_and_load_old_config() 1 ctx 1^

5648

5649 def test_200_forward_export_vault_path_parameter( (empty)

5650 self,

5651 caplog: pytest.LogCaptureFixture,

5652 ) -> None:

5653 """Forwarding arguments from "export" to "export vault" works."""

5654 pytest.importorskip('cryptography', minversion='38.0') 1 ctx 1V

5655 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1V

5660 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1V

5661 stack.enter_context( 1 ctx 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) 1 ctx 1V

5670 result = runner.invoke( 1 ctx 1V

5671 cli.derivepassphrase,

5672 ['export', 'VAULT_PATH'],

5673 )

5674 assert result.clean_exit(empty_stderr=False), 'expected clean exit' 1 ctx 1V

5675 assert tests.deprecation_warning_emitted( 1 ctx 1V

5676 'A subcommand will be required here in v1.0', caplog.record_tuples

5677 )

5678 assert tests.deprecation_warning_emitted( 1 ctx 1V

5679 'Defaulting to subcommand "vault"', caplog.record_tuples

5680 )

5681 assert json.loads(result.stdout) == tests.VAULT_V03_CONFIG_DATA 1 ctx 1V

5682

5683 def test_201_forward_export_vault_empty_commandline( (empty)

5684 self,

5685 caplog: pytest.LogCaptureFixture,

5686 ) -> None:

5687 """Deferring from "export" to "export vault" works."""

5688 pytest.importorskip('cryptography', minversion='38.0') 1 ctx 19

5689 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 19

5694 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 19

5695 stack.enter_context( 1 ctx 19

5696 tests.isolated_config(

5697 monkeypatch=monkeypatch,

5698 runner=runner,

5699 )

5700 )

5701 result = runner.invoke( 1 ctx 19

5702 cli.derivepassphrase,

5703 ['export'],

5704 )

5705 assert tests.deprecation_warning_emitted( 1 ctx 19

5706 'A subcommand will be required here in v1.0', caplog.record_tuples

5707 )

5708 assert tests.deprecation_warning_emitted( 1 ctx 19

5709 'Defaulting to subcommand "vault"', caplog.record_tuples

5710 )

5711 assert result.error_exit(error="Missing argument 'PATH'"), ( 1 ctx 19

5712 'expected error exit and known error type'

5713 )

5714

5715 @Parametrize.CHARSET_NAME (empty)

5716 def test_210_forward_vault_disable_character_set( (empty)

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}' 1 ctx 1J

5723 charset = vault.Vault.CHARSETS[charset_name].decode('ascii') 1 ctx 1J

5724 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1J

5729 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1J

5730 stack.enter_context( 1 ctx 1J

5731 tests.isolated_config(

5732 monkeypatch=monkeypatch,

5733 runner=runner,

5734 )

5735 )

5736 monkeypatch.setattr( 1 ctx 1J

5737 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt

5738 )

5739 result = runner.invoke( 1 ctx 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' 1 ctx 1J

5746 assert tests.deprecation_warning_emitted( 1 ctx 1J

5747 'A subcommand will be required here in v1.0', caplog.record_tuples

5748 )

5749 assert tests.deprecation_warning_emitted( 1 ctx 1J

5750 'Defaulting to subcommand "vault"', caplog.record_tuples

5751 )

5752 for c in charset: 1 ctx 1J

5753 assert c not in result.stdout, ( 1 ctx 1J

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

5755 )

5756

5757 def test_211_forward_vault_empty_command_line( (empty)

5758 self,

5759 caplog: pytest.LogCaptureFixture,

5760 ) -> None:

5761 """Deferring from top-level to "vault" works."""

5762 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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 ctx 1(

5767 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1(

5768 stack.enter_context( 1 ctx 1(

5769 tests.isolated_config(

5770 monkeypatch=monkeypatch,

5771 runner=runner,

5772 )

5773 )

5774 result = runner.invoke( 1 ctx 1(

5775 cli.derivepassphrase,

5776 [],

5777 input=DUMMY_PASSPHRASE,

5778 catch_exceptions=False,

5779 )

5780 assert tests.deprecation_warning_emitted( 1 ctx 1(

5781 'A subcommand will be required here in v1.0', caplog.record_tuples

5782 )

5783 assert tests.deprecation_warning_emitted( 1 ctx 1(

5784 'Defaulting to subcommand "vault"', caplog.record_tuples

5785 )

5786 assert result.error_exit( 1 ctx 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( (empty)

5791 self,

5792 caplog: pytest.LogCaptureFixture,

5793 ) -> None:

5794 """Exporting from (and migrating) the old settings file works."""

5795 caplog.set_level(logging.INFO) 1 ctx 15

5796 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 15

5801 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 15

5802 stack.enter_context( 1 ctx 15

5803 tests.isolated_config(

5804 monkeypatch=monkeypatch,

5805 runner=runner,

5806 )

5807 )

5808 cli_helpers.config_filename( 1 ctx 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( 1 ctx 15

5819 cli.derivepassphrase_vault,

5820 ['--export', '-'],

5821 catch_exceptions=False,

5822 )

5823 assert result.clean_exit(), 'expected clean exit' 1 ctx 15

5824 assert tests.deprecation_warning_emitted( 1 ctx 15

5825 'v0.1-style config file', caplog.record_tuples

5826 ), 'expected known warning message in stderr'

5827 assert tests.deprecation_info_emitted( 1 ctx 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( (empty)

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) 1 ctx 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: 1 ctx 1K

5841 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1K

5842 stack.enter_context( 1 ctx 1K

5843 tests.isolated_config(

5844 monkeypatch=monkeypatch,

5845 runner=runner,

5846 )

5847 )

5848 cli_helpers.config_filename( 1 ctx 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: 1 ctx 1K

5860 raise OSError( 1 ctx 1K

5861 errno.EACCES,

5862 os.strerror(errno.EACCES),

5863 cli_helpers.config_filename(subsystem='vault'),

5864 )

5865

5866 monkeypatch.setattr(os, 'replace', raiser) 1 ctx 1K

5867 monkeypatch.setattr(pathlib.Path, 'rename', raiser) 1 ctx 1K

5868 result = runner.invoke( 1 ctx 1K

5869 cli.derivepassphrase_vault,

5870 ['--export', '-'],

5871 catch_exceptions=False,

5872 )

5873 assert result.clean_exit(), 'expected clean exit' 1 ctx 1K

5874 assert tests.deprecation_warning_emitted( 1 ctx 1K

5875 'v0.1-style config file', caplog.record_tuples

5876 ), 'expected known warning message in stderr'

5877 assert tests.warning_emitted( 1 ctx 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( (empty)

5882 self,

5883 ) -> None:

5884 """Completing service names from the old settings file works."""

5885 config = {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}} 1 ctx 16

5886 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 16

5891 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 16

5892 stack.enter_context( 1 ctx 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( 1 ctx 16

5900 subsystem='old settings.json'

5901 )

5902 new_name = cli_helpers.config_filename(subsystem='vault') 1 ctx 16

5903 old_name.unlink(missing_ok=True) 1 ctx 16

5904 new_name.rename(old_name) 1 ctx 16

5905 assert cli_helpers.shell_complete_service( 1 ctx 16

5906 click.Context(cli.derivepassphrase),

5907 click.Argument(['some_parameter']),

5908 '',

5909 ) == [DUMMY_SERVICE]

5910

5911

5912 KNOWN_SERVICES = (DUMMY_SERVICE, 'email', 'bank', 'work') (empty)

5913"""Known service names. Used for the [`ConfigManagementStateMachine`][].""" (empty)

5914 VALID_PROPERTIES = ( (empty)

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`][].""" (empty)

5925

5926

5927 def build_reduced_vault_config_settings( (empty)

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) 1 ctx 1b

5941 for key in keys_to_prune: 1 ctx 1b

5942 config2.pop(key, None) # type: ignore[misc] 1 ctx 1b

5943 return config2 1 ctx 1b

5944

5945

5946 SERVICES_STRATEGY = strategies.builds( (empty)

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.""" (empty)

5955

5956

5957 def services_strategy() -> strategies.SearchStrategy[ (empty)

5958 _types.VaultConfigServicesSettings

5959]:

5960 """Return a strategy to build incomplete service configurations."""

5961 return SERVICES_STRATEGY 2 ctx 1ab

5962

5963

5964 def assemble_config( (empty)

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) 1 ctx 1b

5970 return ( 1 ctx 1b

5971 {'global': global_data, 'services': services_dict}

5972 if global_data

5973 else {'services': services_dict}

5974 )

5975

5976

5977@strategies.composite (empty)

5978 def draw_service_name_and_data( (empty)

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) 1 ctx 1b

5998 selected_services: list[str] = [] 1 ctx 1b

5999 for _ in range(num_entries): 1 ctx 1b

6000 selected_services.append( 1 ctx 1b

6001 draw(strategies.sampled_from(possible_services))

6002 )

6003 possible_services.remove(selected_services[-1]) 1 ctx 1b

6004 return tuple( 1 ctx 1b

6005 (service, draw(services_strategy())) for service in selected_services

6006 )

6007

6008

6009 VAULT_FULL_CONFIG = strategies.builds( (empty)

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.""" (empty)

6018

6019

6020 def vault_full_config() -> strategies.SearchStrategy[_types.VaultConfig]: (empty)

6021 """Return a strategy to build full vault configurations."""

6022 return VAULT_FULL_CONFIG (empty)

6023

6024

6025 class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): (empty)

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: (empty)

6040 """Initialize self, set up context managers and enter them."""

6041 super().__init__() 1 ctx 1b

6042 self.runner = tests.CliRunner(mix_stderr=False) 1 ctx 1b

6043 self.exit_stack = contextlib.ExitStack().__enter__() 1 ctx 1b

6044 self.monkeypatch = self.exit_stack.enter_context( 1 ctx 1b

6045 pytest.MonkeyPatch().context()

6046 )

6047 self.isolated_config = self.exit_stack.enter_context( 1 ctx 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] = ( (empty)

6056 stateful.Bundle('setting')

6057 )

6058 """""" (empty)

6059 configuration: stateful.Bundle[_types.VaultConfig] = stateful.Bundle( (empty)

6060 'configuration'

6061 )

6062 """""" (empty)

6063

6064 @stateful.initialize( (empty)

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( (empty)

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) 1 ctx 1b

6078

6079 @stateful.initialize( (empty)

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( (empty)

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] = [] 1 ctx 1b

6093 for c in configs: 1 ctx 1b

6094 settings.extend(c['services'].values()) 1 ctx 1b

6095 return stateful.multiple(*map(copy.deepcopy, settings)) 1 ctx 1b

6096

6097 @staticmethod (empty)

6098 def fold_configs( (empty)

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')) 1 ctx 1b

6103 if new_global_dict is not None: 1 ctx 1b

6104 return { 1 ctx 1b

6105 'global': new_global_dict,

6106 'services': {**c2['services'], **c1['services']},

6107 }

6108 return { 1 ctx 1b

6109 'services': {**c2['services'], **c1['services']},

6110 }

6111

6112 @stateful.rule( (empty)

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( (empty)

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) 1 ctx 1b

6150 config_global = config.get('global', {}) 1 ctx 1b

6151 maybe_unset = set(maybe_unset) - setting.keys() 1 ctx 1b

6152 if overwrite: 1 ctx 1b

6153 config['global'] = config_global = {} 1 ctx 1b

6154 elif maybe_unset: 1 ctx 1b

6155 for key in maybe_unset: 1 ctx 1b

6156 config_global.pop(key, None) # type: ignore[misc] 1 ctx 1b

6157 config.setdefault('global', {}).update(setting) 1 ctx 1b

6158 assert _types.is_vault_config(config) 1 ctx 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( 1 ctx 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) 1 ctx 1b

6177 assert cli_helpers.load_config() == config 1 ctx 1b

6178 return config 1 ctx 1b

6179

6180 @stateful.rule( (empty)

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( (empty)

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) 1 ctx 1b

6222 config_service = config['services'].get(service, {}) 1 ctx 1b

6223 maybe_unset = set(maybe_unset) - setting.keys() 1 ctx 1b

6224 if overwrite: 1 ctx 1b

6225 config['services'][service] = config_service = {} 1 ctx 1b

6226 elif maybe_unset: 1 ctx 1b

6227 for key in maybe_unset: 1 ctx 1b

6228 config_service.pop(key, None) # type: ignore[misc] 1 ctx 1b

6229 config['services'].setdefault(service, {}).update(setting) 1 ctx 1b

6230 assert _types.is_vault_config(config) 1 ctx 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( 1 ctx 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) 1 ctx 1b

6250 assert cli_helpers.load_config() == config 1 ctx 1b

6251 return config 1 ctx 1b

6252

6253 @stateful.rule( (empty)

6254 target=configuration,

6255 config=configuration,

6256 )

6257 def purge_global( (empty)

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) 1 ctx 1b

6272 config.pop('global', None) 1 ctx 1b

6273 result = self.runner.invoke( 1 ctx 1b

6274 cli.derivepassphrase_vault,

6275 ['--delete-globals'],

6276 input='y',

6277 catch_exceptions=False,

6278 )

6279 assert result.clean_exit(empty_stderr=False) 1 ctx 1b

6280 assert cli_helpers.load_config() == config 1 ctx 1b

6281 return config 1 ctx 1b

6282

6283 @stateful.rule( (empty)

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( (empty)

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 1 ctx 1b

6310 cli_helpers.save_config(config) 1 ctx 1b

6311 config['services'].pop(service, None) 1 ctx 1b

6312 result = self.runner.invoke( 1 ctx 1b

6313 cli.derivepassphrase_vault,

6314 ['--delete', '--', service],

6315 input='y',

6316 catch_exceptions=False,

6317 )

6318 assert result.clean_exit(empty_stderr=False) 1 ctx 1b

6319 assert cli_helpers.load_config() == config 1 ctx 1b

6320 return config 1 ctx 1b

6321

6322 @stateful.rule( (empty)

6323 target=configuration,

6324 config=configuration,

6325 )

6326 def purge_all( (empty)

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) 1 ctx 1b

6341 config = {'services': {}} 1 ctx 1b

6342 result = self.runner.invoke( 1 ctx 1b

6343 cli.derivepassphrase_vault,

6344 ['--clear'],

6345 input='y',

6346 catch_exceptions=False,

6347 )

6348 assert result.clean_exit(empty_stderr=False) 1 ctx 1b

6349 assert cli_helpers.load_config() == config 1 ctx 1b

6350 return config 1 ctx 1b

6351

6352 @stateful.rule( (empty)

6353 target=configuration,

6354 base_config=configuration,

6355 config_to_import=configuration,

6356 overwrite=strategies.booleans(),

6357 )

6358 def import_configuration( (empty)

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) 1 ctx 1b

6381 config = ( 1 ctx 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) 1 ctx 1b

6387 result = self.runner.invoke( 1 ctx 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) 1 ctx 1b

6395 assert cli_helpers.load_config() == config 1 ctx 1b

6396 return config 1 ctx 1b

6397

6398 def teardown(self) -> None: (empty)

6399 """Upon teardown, exit all contexts entered in `__init__`."""

6400 self.exit_stack.close() 1 ctx 1b

6401

6402

6403 TestConfigManagement = ConfigManagementStateMachine.TestCase (empty)

6404"""The [`unittest.TestCase`][] class that will actually be run.""" (empty)

6405

6406

6407 class FakeConfigurationMutexAction(NamedTuple): (empty)

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] (empty)

6419 """""" (empty)

6420 input: str | bytes | None = None (empty)

6421 """""" (empty)

6422

6423

6424 def run_actions_handler( (empty)

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: (empty)

6459 monkeypatch.setattr( (empty)

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) (empty)

6470 try: (empty)

6471 result = runner.invoke( (empty)

6472 cli.derivepassphrase_vault,

6473 args=action.command_line,

6474 input=action.input,

6475 catch_exceptions=True,

6476 )

6477 output_queue.put( (empty)

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( (empty)

6500 stateful_step_count=tests.get_concurrency_step_count(),

6501 deadline=None,

6502)

6503 class FakeConfigurationMutexStateMachine(stateful.RuleBasedStateMachine): (empty)

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): (empty)

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 (empty)

6543 """""" (empty)

6544 message: Literal['ready', 'go', 'config', 'result', 'exception'] (empty)

6545 """""" (empty)

6546 payload: object | None (empty)

6547 """""" (empty)

6548

6549 class ConfigurationMutexStub(cli_helpers.ConfigurationMutex): (empty)

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__( (empty)

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__() (empty)

6596

6597 def lock() -> None: (empty)

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 = ( (empty)

6606 FakeConfigurationMutexStateMachine.IPCMessage

6607 )

6608 try: (empty)

6609 output_queue.put( (empty)

6610 IPCMessage(my_id, 'ready', None),

6611 block=True,

6612 timeout=timeout,

6613 )

6614 ok = input_queue.get(block=True, timeout=timeout) (empty)

6615 if ok != IPCMessage(my_id, 'go', None): # pragma: no cover (empty)

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: (empty)

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 = ( (empty)

6639 FakeConfigurationMutexStateMachine.IPCMessage

6640 )

6641 try: (empty)

6642 output_queue.put( (empty)

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 (empty)

6658 self.unlock = unlock (empty)

6659

6660 setting: stateful.Bundle[_types.VaultConfigServicesSettings] = ( (empty)

6661 stateful.Bundle('setting')

6662 )

6663 """""" (empty)

6664 configuration: stateful.Bundle[_types.VaultConfig] = stateful.Bundle( (empty)

6665 'configuration'

6666 )

6667 """""" (empty)

6668

6669 def __init__(self, *args: Any, **kwargs: Any) -> None: (empty)

6670 """Initialize the state machine."""

6671 super().__init__(*args, **kwargs) 1 ctx 1b

6672 self.actions: list[FakeConfigurationMutexAction] = [] 1 ctx 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 1 ctx 1b

6677 settings: hypothesis.settings | None

6678 settings = FakeConfigurationMutexStateMachine.TestCase.settings 1 ctx 1b

6679 except AttributeError: # pragma: no cover

6680 settings = None

6681 self.step_count = tests.get_concurrency_step_count(settings) 1 ctx 1b

6682

6683 @stateful.initialize( (empty)

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( (empty)

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) 1 ctx 1b

6697

6698 @stateful.initialize( (empty)

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( (empty)

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] = [] 1 ctx 1b

6712 for c in configs: 1 ctx 1b

6713 settings.extend(c['services'].values()) 1 ctx 1b

6714 return stateful.multiple(*map(copy.deepcopy, settings)) 1 ctx 1b

6715

6716 @stateful.initialize( (empty)

6717 config=vault_full_config(),

6718 )

6719 def declare_initial_action( (empty)

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'] 1 ctx 1b

6734 input = json.dumps(config) # noqa: A001 1 ctx 1b

6735 hypothesis.note(f'# {command_line = }, {input = }') 1 ctx 1b

6736 action = FakeConfigurationMutexAction( 1 ctx 1b

6737 command_line=command_line, input=input

6738 )

6739 self.actions.append(action) 1 ctx 1b

6740

6741 @stateful.rule( (empty)

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( (empty)

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() 1 ctx 1b

6771 command_line = ( 1 ctx 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 1 ctx 1b

6784 hypothesis.note(f'# {command_line = }, {input = }') 1 ctx 1b

6785 action = FakeConfigurationMutexAction( 1 ctx 1b

6786 command_line=command_line, input=input

6787 )

6788 self.actions.append(action) 1 ctx 1b

6789

6790 @stateful.rule( (empty)

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( (empty)

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() 1 ctx 1b

6824 command_line = ( 1 ctx 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 1 ctx 1b

6838 hypothesis.note(f'# {command_line = }, {input = }') 1 ctx 1b

6839 action = FakeConfigurationMutexAction( 1 ctx 1b

6840 command_line=command_line, input=input

6841 )

6842 self.actions.append(action) 1 ctx 1b

6843

6844 @stateful.rule() (empty)

6845 def add_purge_global_action( (empty)

6846 self,

6847 ) -> None:

6848 """Purge the globals of a configuration."""

6849 command_line = ['--delete-globals'] 1 ctx 1b

6850 input = None # 'y' # noqa: A001 1 ctx 1b

6851 hypothesis.note(f'# {command_line = }, {input = }') 1 ctx 1b

6852 action = FakeConfigurationMutexAction( 1 ctx 1b

6853 command_line=command_line, input=input

6854 )

6855 self.actions.append(action) 1 ctx 1b

6856

6857 @stateful.rule( (empty)

6858 service=strategies.sampled_from(KNOWN_SERVICES),

6859 )

6860 def add_purge_service_action( (empty)

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] 1 ctx 1b

6872 input = None # 'y' # noqa: A001 1 ctx 1b

6873 hypothesis.note(f'# {command_line = }, {input = }') 1 ctx 1b

6874 action = FakeConfigurationMutexAction( 1 ctx 1b

6875 command_line=command_line, input=input

6876 )

6877 self.actions.append(action) 1 ctx 1b

6878

6879 @stateful.rule() (empty)

6880 def add_purge_all_action( (empty)

6881 self,

6882 ) -> None:

6883 """Purge the entire configuration."""

6884 command_line = ['--clear'] 1 ctx 1b

6885 input = None # 'y' # noqa: A001 1 ctx 1b

6886 hypothesis.note(f'# {command_line = }, {input = }') 1 ctx 1b

6887 action = FakeConfigurationMutexAction( 1 ctx 1b

6888 command_line=command_line, input=input

6889 )

6890 self.actions.append(action) 1 ctx 1b

6891

6892 @stateful.rule( (empty)

6893 config_to_import=configuration,

6894 overwrite=strategies.booleans(),

6895 )

6896 def add_import_configuration_action( (empty)

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', '-'] + ( 1 ctx 1b

6913 ['--overwrite-existing'] if overwrite else []

6914 )

6915 input = json.dumps(config_to_import) # noqa: A001 1 ctx 1b

6916 hypothesis.note(f'# {command_line = }, {input = }') 1 ctx 1b

6917 action = FakeConfigurationMutexAction( 1 ctx 1b

6918 command_line=command_line, input=input

6919 )

6920 self.actions.append(action) 1 ctx 1b

6921

6922 @stateful.precondition(lambda self: len(self.actions) > 0) 2 ctx 1ab

6923 @stateful.invariant() (empty)

6924 def run_actions( # noqa: C901 (empty)

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 1 ctx 1b

6947 multiprocessing = pytest.importorskip('multiprocessing') 1 ctx 1b

6948 IPCMessage: TypeAlias = FakeConfigurationMutexStateMachine.IPCMessage 1 ctx 1b

6949 intermediate_configs: dict[int, _types.VaultConfig] = {} 1 ctx 1b

6950 intermediate_results: dict[ 1 ctx 1b

6951 int, tuple[bool, str | None, str | None]

6952 ] = {}

6953 true_configs: dict[int, _types.VaultConfig] = {} 1 ctx 1b

6954 true_results: dict[int, tuple[bool, str | None, str | None]] = {} 1 ctx 1b

6955 timeout = 30 # Hopefully slow enough to accomodate The Annoying OS. 1 ctx 1b

6956 actions = self.actions 1 ctx 1b

6957 mp = multiprocessing.get_context() 1 ctx 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() 1 ctx 1b

6963

6964 fatal_process_creation_errnos = { 1 ctx 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 = }') 1 ctx 1b

6976

6977 stack = contextlib.ExitStack() 1 ctx 1b

6978 with stack: 1 ctx 1b

6979 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1b

6980 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1b

6981 stack.enter_context( 1 ctx 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): 1 ctx 1b

6989 result = runner.invoke( 1 ctx 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()) 1 ctx 1b

6996 true_results[i] = ( 1 ctx 1b

6997 result.clean_exit(empty_stderr=False),

6998 result.stdout,

6999 result.stderr,

7000 )

7001

7002 with stack: # noqa: PLR1702 1 ctx 1b

7003 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1b

7004 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1b

7005 stack.enter_context( 1 ctx 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() 1 ctx 1b

7014 child_input_queues: list[ 1 ctx 1b

7015 multiprocessing.Queue[IPCMessage] | None

7016 ] = []

7017 processes: list[multiprocessing.process.BaseProcess] = [] 1 ctx 1b

7018 processes_pending: set[multiprocessing.process.BaseProcess] = set() 1 ctx 1b

7019 ready_wait: set[int] = set() 1 ctx 1b

7020

7021 try: 1 ctx 1b

7022 for i, action in enumerate(actions): 1 ctx 1b

7023 q: multiprocessing.Queue[IPCMessage] | None = mp.Queue() 1 ctx 1b

7024 try: 1 ctx 1b

7025 p: multiprocessing.process.BaseProcess = mp.Process( 1 ctx 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() 1 ctx 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) 1 ctx 1b

7048 processes_pending.add(p) 1 ctx 1b

7049 child_input_queues.append(q) 1 ctx 1b

7050 ready_wait.add(i) 1 ctx 1b

7051

7052 while processes_pending: 1 ctx 1b

7053 try: 1 ctx 1b

7054 self.mainloop( 1 ctx 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: 1 ctx 1b

7074 while True: 1 ctx 1b

7075 try: 1 ctx 1b

7076 self.mainloop( 1 ctx 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: 1 ctx 1b

7088 break 1 ctx 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( 1 ctx 1b

7098 '.coverage.*'

7099 ):

7100 shutil.move(coverage_file, orig_cwd) 1 ctx 1b

7101 hypothesis.note( 1 ctx 1b

7102 f'# {true_results = }, {intermediate_results = }, '

7103 f'identical = {true_results == intermediate_results}'

7104 )

7105 hypothesis.note( 1 ctx 1b

7106 f'# {true_configs = }, {intermediate_configs = }, '

7107 f'identical = {true_configs == intermediate_configs}'

7108 )

7109 assert intermediate_results == true_results 1 ctx 1b

7110 assert intermediate_configs == true_configs 1 ctx 1b

7111

7112 @staticmethod (empty)

7113 def mainloop( (empty)

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 1 ctx 1b

7133 msg = child_output_queue.get(block=block, timeout=timeout) 1 ctx 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 1 ctx 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': 1 ctx 1b

7145 n = msg[0] 1 ctx 1b

7146 ready_wait.remove(n) 1 ctx 1b

7147 if not ready_wait: 1 ctx 1b

7148 assert child_input_queues 1 ctx 1b

7149 assert child_input_queues[0] 1 ctx 1b

7150 child_input_queues[0].put( 1 ctx 1b

7151 IPCMessage(0, 'go', None),

7152 block=True,

7153 timeout=timeout,

7154 )

7155 elif isinstance(msg, IPCMessage) and msg[1] == 'config': 1 ctx 1b

7156 n = msg[0] 1 ctx 1b

7157 config = msg[2] 1 ctx 1b

7158 intermediate_configs[n] = cast('_types.VaultConfig', config) 1 ctx 1b

7159 elif isinstance(msg, IPCMessage) and msg[1] == 'result': 1 ctx 1b

7160 n = msg[0] 1 ctx 1b

7161 result_ = msg[2] 1 ctx 1b

7162 result_tuple: tuple[bool, str | None, str | None] = cast( 1 ctx 1b

7163 'tuple[bool, str | None, str | None]', result_

7164 )

7165 intermediate_results[n] = result_tuple 1 ctx 1b

7166 child_input_queues[n] = None 1 ctx 1b

7167 p = processes[n] 1 ctx 1b

7168 p.join(timeout=timeout) 1 ctx 1b

7169 assert not p.is_alive() 1 ctx 1b

7170 processes_pending.remove(p) 1 ctx 1b

7171 assert result_tuple[0], ( 1 ctx 1b

7172 f'action #{n} exited with an error: {result_tuple! r}'

7173 )

7174 if n + 1 < len(processes): 1 ctx 1b

7175 next_child_input_queue = child_input_queues[n + 1] 1 ctx 1b

7176 assert next_child_input_queue 1 ctx 1b

7177 next_child_input_queue.put( 1 ctx 1b

7178 IPCMessage(n + 1, 'go', None),

7179 block=True,

7180 timeout=timeout,

7181 )

7182 else:

7183 raise AssertionError()

7184

7185

7186 TestFakedConfigurationMutex = tests.skip_if_no_multiprocessing_support( (empty)

7187 FakeConfigurationMutexStateMachine.TestCase

7188)

7189"""The [`unittest.TestCase`][] class that will actually be run.""" (empty)

7190

7191

7192 def completion_item( (empty)

7193 item: str | click.shell_completion.CompletionItem,

7194) -> click.shell_completion.CompletionItem:

7195 """Convert a string to a completion item, if necessary."""

7196 return ( 1 ctx 1m

7197 click.shell_completion.CompletionItem(item, type='plain')

7198 if isinstance(item, str)

7199 else item

7200 )

7201

7202

7203 def assertable_item( (empty)

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) 1 ctx 1m

7213 return (item.type, item.value, item.help) 1 ctx 1m

7214

7215

7216 class TestShellCompletion: (empty)

7217 """Tests for the shell completion machinery."""

7218

7219 class Completions: (empty)

7220 """A deferred completion call."""

7221

7222 def __init__( (empty)

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) 5 ctx 1)*!Pu

7237 self.incomplete = incomplete 5 ctx 1)*!Pu

7238

7239 def __call__(self) -> Sequence[click.shell_completion.CompletionItem]: (empty)

7240 """Return the completion items."""

7241 args = list(self.args) 5 ctx 1)*!Pu

7242 completion = click.shell_completion.ShellComplete( 5 ctx 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) 5 ctx 1)*!Pu

7249

7250 def get_words(self) -> Sequence[str]: (empty)

7251 """Return the completion items' values, as a sequence."""

7252 return tuple(c.value for c in self()) 4 ctx 1)*Pu

7253

7254 @Parametrize.COMPLETABLE_ITEMS (empty)

7255 def test_100_is_completable_item( (empty)

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 1 ctx 2nb

7262

7263 @Parametrize.COMPLETABLE_OPTIONS (empty)

7264 def test_200_options( (empty)

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 ctx 1)

7272 assert frozenset(comp.get_words()) == completions 1 ctx 1)

7273

7274 @Parametrize.COMPLETABLE_SUBCOMMANDS (empty)

7275 def test_201_subcommands( (empty)

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 ctx 1*

7283 assert frozenset(comp.get_words()) == completions 1 ctx 1*

7284

7285 @Parametrize.COMPLETABLE_PATH_ARGUMENT (empty)

7286 @Parametrize.INCOMPLETE (empty)

7287 def test_202_paths( (empty)

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 ctx 1!

7294 completions = frozenset({(file.type, file.value, file.help)}) 1 ctx 1!

7295 comp = self.Completions(command_prefix, incomplete) 1 ctx 1!

7296 assert ( 1 ctx 1!

7297 frozenset((x.type, x.value, x.help) for x in comp()) == completions

7298 )

7299

7300 @Parametrize.COMPLETABLE_SERVICE_NAMES (empty)

7301 def test_203_service_names( (empty)

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) 1 ctx 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: 1 ctx 1P

7313 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1P

7314 stack.enter_context( 1 ctx 1P

7315 tests.isolated_vault_config(

7316 monkeypatch=monkeypatch,

7317 runner=runner,

7318 vault_config=config,

7319 )

7320 )

7321 comp = self.Completions(['vault'], incomplete) 1 ctx 1P

7322 assert frozenset(comp.get_words()) == completions 1 ctx 1P

7323

7324 @Parametrize.SHELL_FORMATTER (empty)

7325 @Parametrize.COMPLETION_FUNCTION_INPUTS (empty)

7326 def test_300_shell_completion_formatting( (empty)

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) 1 ctx 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: 1 ctx 1m

7345 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1m

7346 stack.enter_context( 1 ctx 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] 1 ctx 1m

7354 expected_string = '\n'.join( 1 ctx 1m

7355 format_func(completion_item(item)) for item in results

7356 )

7357 manual_raw_items = comp_func( 1 ctx 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] 1 ctx 1m

7363 manual_string = '\n'.join( 1 ctx 1m

7364 format_func(completion_item(item)) for item in manual_raw_items

7365 )

7366 assert manual_items == expected_items 1 ctx 1m

7367 assert manual_string == expected_string 1 ctx 1m

7368 comp_class = click.shell_completion.get_completion_class(shell) 1 ctx 1m

7369 assert comp_class is not None 1 ctx 1m

7370 comp = comp_class( 1 ctx 1m

7371 cli.derivepassphrase,

7372 {},

7373 'derivepassphrase',

7374 '_DERIVEPASSPHRASE_COMPLETE',

7375 )

7376 monkeypatch.setattr( 1 ctx 1m

7377 comp,

7378 'get_completion_args',

7379 lambda *_a, **_kw: (args, incomplete),

7380 )

7381 actual_raw_items = comp.get_completions( 1 ctx 1m

7382 *comp.get_completion_args()

7383 )

7384 actual_items = [assertable_item(item) for item in actual_raw_items] 1 ctx 1m

7385 actual_string = comp.complete() 1 ctx 1m

7386 assert actual_items == expected_items 1 ctx 1m

7387 assert actual_string == expected_string 1 ctx 1m

7388

7389 @Parametrize.CONFIG_SETTING_MODE (empty)

7390 @Parametrize.SERVICE_NAME_COMPLETION_INPUTS (empty)

7391 def test_400_incompletable_service_names( (empty)

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': {}} 1 ctx 1u

7402 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1u

7407 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1u

7408 stack.enter_context( 1 ctx 1u

7409 tests.isolated_vault_config(

7410 monkeypatch=monkeypatch,

7411 runner=runner,

7412 vault_config=vault_config,

7413 )

7414 )

7415 if mode == 'config': 1 ctx 1u

7416 result = runner.invoke( 1 ctx 1u

7417 cli.derivepassphrase_vault,

7418 ['--config', '--length=10', '--', key],

7419 catch_exceptions=False,

7420 )

7421 else:

7422 result = runner.invoke( 1 ctx 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' 1 ctx 1u

7429 assert tests.warning_emitted( 1 ctx 1u

7430 'contains an ASCII control character', caplog.record_tuples

7431 ), 'expected known warning message in stderr'

7432 assert tests.warning_emitted( 1 ctx 1u

7433 'not be available for completion', caplog.record_tuples

7434 ), 'expected known warning message in stderr'

7435 assert cli_helpers.load_config() == config 1 ctx 1u

7436 comp = self.Completions(['vault'], incomplete) 1 ctx 1u

7437 assert frozenset(comp.get_words()) == completions 1 ctx 1u

7438

7439 def test_410a_service_name_exceptions_not_found( (empty)

7440 self,

7441 ) -> None:

7442 """Service name completion quietly fails on missing configuration."""

7443 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 2jb

7448 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 2jb

7449 stack.enter_context( 1 ctx 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( 1 ctx 2jb

7459 missing_ok=True

7460 )

7461 assert not cli_helpers.shell_complete_service( 1 ctx 2jb

7462 click.Context(cli.derivepassphrase),

7463 click.Argument(['some_parameter']),

7464 '',

7465 )

7466

7467 @Parametrize.SERVICE_NAME_EXCEPTIONS (empty)

7468 def test_410b_service_name_exceptions_custom_error( (empty)

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 ctx 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 ctx 1+

7478 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1+

7479 stack.enter_context( 1 ctx 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 ctx 1+

7490 raise exc_type('just being difficult') # noqa: EM101,TRY003 1 ctx 1+

7491

7492 monkeypatch.setattr(cli_helpers, 'load_config', raiser) 1 ctx 1+

7493 assert not cli_helpers.shell_complete_service( 1 ctx 1+

7494 click.Context(cli.derivepassphrase),

7495 click.Argument(['some_parameter']),

7496 '',

7497 )