Coverage for tests / test_derivepassphrase_cli / test_vault_cli_config_management.py: 100.000%

245 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-07-06 21:34 +0200

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

2# 

3# SPDX-License-Identifier: Zlib 

4 

5"""Tests for the `derivepassphrase vault` command-line interface: config management. 

6 

7This includes tests for importing, exporting and setting the 

8configuration, whether validly or invalidly. It does not contain any 

9configuration that is cross-subsystem or that doesn't pertain to 

10a `vault`-specific CLI call; those are [basic and common subsystem 

11tests][tests.test_derivepassphrase_cli.test_000_basic]. 

12 

13""" 

14 

15from __future__ import annotations 

16 

17import contextlib 

18import copy 

19import errno 

20import json 

21import os 

22import shutil 

23import types 

24from typing import TYPE_CHECKING 

25 

26import hypothesis 

27import pytest 

28from hypothesis import strategies 

29 

30from derivepassphrase import _types, cli, ssh_agent 

31from derivepassphrase._internals import ( 

32 cli_helpers, 

33) 

34from tests import data, machinery 

35from tests.data import callables 

36from tests.machinery import hypothesis as hypothesis_machinery 

37from tests.machinery import pytest as pytest_machinery 

38 

39if TYPE_CHECKING: 

40 from collections.abc import Generator 

41 from typing import NoReturn 

42 

43 from typing_extensions import Any 

44 

45DUMMY_SERVICE = data.DUMMY_SERVICE 

46 

47DUMMY_KEY1_B64 = data.DUMMY_KEY1_B64 

48 

49 

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

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

52 possible_warnings = [ 1gbc

53 "Replacing invalid value ", 

54 "Removing ineffective setting ", 

55 ( 

56 "Setting a global passphrase is ineffective " 

57 "because a key is also set." 

58 ), 

59 ( 

60 "Setting a service passphrase is ineffective " 

61 "because a key is also set:" 

62 ), 

63 ] 

64 return any( 1gbc

65 machinery.warning_emitted(w, [record]) for w in possible_warnings 

66 ) 

67 

68 

69def assert_vault_config_is_indented_and_line_broken( 

70 config_txt: str, 

71 /, 

72) -> None: 

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

74 

75 Indented and rewrapped vault configurations as produced by 

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

77 on a separate, indented line: 

78 

79 ~~~~ 

80 { 

81 "services": { 

82 ... 

83 } <-- this brace here 

84 } 

85 ~~~~ 

86 

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

88 

89 ~~~~ 

90 "services": {} 

91 ~~~~ 

92 

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

94 keys. 

95 

96 """ 

97 known_indented_lines = { 1dgebch

98 "}", 

99 "},", 

100 '"services": {}', 

101 '"services": {},', 

102 } 

103 assert any([ 1dgebch

104 line.strip() in known_indented_lines and line.startswith((" ", "\t")) 

105 for line in config_txt.splitlines() 

106 ]) 

107 

108 

109class Parametrize(types.SimpleNamespace): 

110 """Common test parametrizations.""" 

111 

112 CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES = pytest.mark.parametrize( 

113 ["command_line", "input", "err_text"], 

114 [ 

115 pytest.param( 

116 [], 

117 "", 

118 "Cannot update the global settings without any given settings", 

119 id="None", 

120 ), 

121 pytest.param( 

122 ["--", "sv"], 

123 "", 

124 "Cannot update the service-specific settings without any given settings", 

125 id="None-sv", 

126 ), 

127 pytest.param( 

128 ["--phrase", "--", "sv"], 

129 "\n", 

130 "No passphrase was given", 

131 id="phrase-sv", 

132 ), 

133 pytest.param( 

134 ["--phrase", "--", "sv"], 

135 "", 

136 "No passphrase was given", 

137 id="phrase-sv-eof", 

138 ), 

139 pytest.param( 

140 ["--key"], 

141 "\n", 

142 "No SSH key was selected", 

143 id="key-sv", 

144 ), 

145 pytest.param( 

146 ["--key"], 

147 "", 

148 "No SSH key was selected", 

149 id="key-sv-eof", 

150 ), 

151 ], 

152 ) 

153 CONFIG_EDITING_VIA_CONFIG_FLAG = hypothesis_machinery.explicit_examples( 

154 ["command_line", "input", "starting_config", "result_config"], 

155 [ 

156 pytest.param( 

157 ["--phrase"], 

158 "my passphrase\n", 

159 {"global": {"phrase": "abc"}, "services": {}}, 

160 {"global": {"phrase": "my passphrase"}, "services": {}}, 

161 id="phrase", 

162 ), 

163 pytest.param( 

164 ["--key"], 

165 "1\n", 

166 {"global": {"phrase": "abc"}, "services": {}}, 

167 { 

168 "global": {"key": DUMMY_KEY1_B64, "phrase": "abc"}, 

169 "services": {}, 

170 }, 

171 id="key", 

172 ), 

173 pytest.param( 

174 ["--phrase", "--", "sv"], 

175 "my passphrase\n", 

176 {"global": {"phrase": "abc"}, "services": {}}, 

177 { 

178 "global": {"phrase": "abc"}, 

179 "services": {"sv": {"phrase": "my passphrase"}}, 

180 }, 

181 id="phrase-sv", 

182 ), 

183 pytest.param( 

184 ["--key", "--", "sv"], 

185 "1\n", 

186 {"global": {"phrase": "abc"}, "services": {}}, 

187 { 

188 "global": {"phrase": "abc"}, 

189 "services": {"sv": {"key": DUMMY_KEY1_B64}}, 

190 }, 

191 id="key-sv", 

192 ), 

193 pytest.param( 

194 ["--key", "--length", "15", "--", "sv"], 

195 "1\n", 

196 {"global": {"phrase": "abc"}, "services": {}}, 

197 { 

198 "global": {"phrase": "abc"}, 

199 "services": {"sv": {"key": DUMMY_KEY1_B64, "length": 15}}, 

200 }, 

201 id="key-length-sv", 

202 ), 

203 ], 

204 ) 

205 VALID_TEST_CONFIGS = pytest.mark.parametrize( 

206 "config", 

207 [conf.config for conf in data.TEST_CONFIGS if conf.is_valid()], 

208 ) 

209 EXPORT_FORMAT_OPTIONS = pytest.mark.parametrize( 

210 "export_options", 

211 [ 

212 [], 

213 ["--export-as=sh"], 

214 ], 

215 ids=["json-format", "sh-format"], 

216 ) 

217 TRY_RACE_FREE_IMPLEMENTATION = pytest.mark.parametrize( 

218 "try_race_free_implementation", 

219 [False, True], 

220 ids=["racy", "maybe-race-free"], 

221 ) 

222 

223 

224class TestImportConfigValid: 

225 """Tests concerning `vault` configuration imports: valid imports.""" 

226 

227 def _test( 

228 self, 

229 /, 

230 *, 

231 caplog: pytest.LogCaptureFixture, 

232 config: _types.VaultConfig, 

233 ) -> None: 

234 config2 = copy.deepcopy(config) 1gc

235 _types.clean_up_falsy_vault_config_values(config2) 1gc

236 runner = machinery.CliRunner(mix_stderr=False) 1gc

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

238 # with-statements. 

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

240 with contextlib.ExitStack() as stack: 1gc

241 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1gc

242 stack.enter_context( 1gc

243 pytest_machinery.isolated_vault_config( 

244 monkeypatch=monkeypatch, 

245 runner=runner, 

246 vault_config={"services": {}}, 

247 ) 

248 ) 

249 result = runner.invoke( 1gc

250 cli.derivepassphrase_vault, 

251 ["--import", "-"], 

252 input=json.dumps(config), 

253 catch_exceptions=False, 

254 ) 

255 config_txt = cli_helpers.config_filename( 1gc

256 subsystem="vault" 

257 ).read_text(encoding="UTF-8") 

258 config3 = json.loads(config_txt) 1gc

259 assert result.clean_exit(empty_stderr=False), "expected clean exit" 1gc

260 assert config3 == config2, "config not imported correctly" 1gc

261 assert not result.stderr or all( 1gc

262 map(is_harmless_config_import_warning, caplog.record_tuples) 

263 ), "unexpected error output" 

264 assert_vault_config_is_indented_and_line_broken(config_txt) 1gc

265 

266 @Parametrize.VALID_TEST_CONFIGS 

267 def test_normal_config( 

268 self, 

269 caplog: pytest.LogCaptureFixture, 

270 config: Any, 

271 ) -> None: 

272 """Importing a configuration works.""" 

273 self._test(caplog=caplog, config=config) 1g

274 

275 @hypothesis.settings( 

276 suppress_health_check=[ 

277 *hypothesis.settings().suppress_health_check, 

278 hypothesis.HealthCheck.function_scoped_fixture, 

279 ], 

280 ) 

281 @hypothesis.given( 

282 conf=hypothesis_machinery.smudged_vault_test_config( 

283 strategies.sampled_from([ 

284 conf for conf in data.TEST_CONFIGS if conf.is_valid() 

285 ]) 

286 ) 

287 ) 

288 def test_smudged_config( 

289 self, 

290 caplog: pytest.LogCaptureFixture, 

291 conf: data.VaultTestConfig, 

292 ) -> None: 

293 """Importing a smudged configuration works. 

294 

295 Tested via hypothesis. 

296 

297 """ 

298 # Reset caplog between hypothesis runs. 

299 caplog.clear() 1c

300 self._test(caplog=caplog, config=conf.config) 1c

301 

302 

303class TestImportConfigInvalid: 

304 """Tests concerning `vault` configuration imports: invalid imports.""" 

305 

306 @contextlib.contextmanager 

307 def _setup_environment(self) -> Generator[machinery.CliRunner, None, None]: 

308 runner = machinery.CliRunner(mix_stderr=False) 1uwx

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

310 # with-statements. 

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

312 with contextlib.ExitStack() as stack: 1uwx

313 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1uwx

314 stack.enter_context( 1uwx

315 pytest_machinery.isolated_config( 

316 monkeypatch=monkeypatch, 

317 runner=runner, 

318 ) 

319 ) 

320 yield runner 1uwx

321 

322 def _test( 

323 self, 

324 command_line: list[str], 

325 /, 

326 *, 

327 input: str | bytes | None = None, 

328 ) -> machinery.ReadableResult: 

329 with self._setup_environment() as runner: 1wx

330 return runner.invoke( 1wx

331 cli.derivepassphrase_vault, 

332 command_line, 

333 input=input, 

334 catch_exceptions=False, 

335 ) 

336 

337 def test_not_a_vault_config( 

338 self, 

339 ) -> None: 

340 """Importing an invalid config fails.""" 

341 result = self._test(["--import", "-"], input="null") 1w

342 assert result.error_exit(error="Invalid vault config"), ( 1w

343 "expected error exit and known error message" 

344 ) 

345 

346 def test_not_json_data( 

347 self, 

348 ) -> None: 

349 """Importing an invalid config fails.""" 

350 result = self._test( 1x

351 ["--import", "-"], input="This string is not valid JSON." 

352 ) 

353 assert result.error_exit(error="cannot decode JSON"), ( 1x

354 "expected error exit and known error message" 

355 ) 

356 

357 def test_not_a_file( 

358 self, 

359 ) -> None: 

360 """Importing an invalid config fails.""" 

361 with self._setup_environment() as runner: 1u

362 # `_setup_environment` (via `isolated_vault_config`) ensures 

363 # the configuration is valid JSON. So, to pass an actual 

364 # broken configuration, we must open the configuration file 

365 # ourselves afterwards, inside the context. 

366 cli_helpers.config_filename(subsystem="vault").write_text( 1u

367 "This string is not valid JSON.\n", encoding="UTF-8" 

368 ) 

369 dname = cli_helpers.config_filename(subsystem=None) 1u

370 result = runner.invoke( 1u

371 cli.derivepassphrase_vault, 

372 ["--import", os.fsdecode(dname)], 

373 catch_exceptions=False, 

374 ) 

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

376 assert result.error_exit( 1u

377 error=os.strerror(errno.EISDIR) 

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

379 "expected error exit and known error message" 

380 ) 

381 

382 

383class TestExportConfigValid: 

384 """Tests concerning `vault` configuration exports: valid exports.""" 

385 

386 def _assert_result( 

387 self, 

388 result: machinery.ReadableResult, 

389 /, 

390 *, 

391 caplog: pytest.LogCaptureFixture, 

392 ) -> None: 

393 assert result.clean_exit(empty_stderr=False), "expected clean exit" 1db

394 assert not result.stderr or all( 1db

395 map(is_harmless_config_import_warning, caplog.record_tuples) 

396 ), "unexpected error output" 

397 

398 def _test( 

399 self, 

400 /, 

401 *, 

402 caplog: pytest.LogCaptureFixture, 

403 config: _types.VaultConfig, 

404 use_import: bool = False, 

405 ) -> None: 

406 config2 = copy.deepcopy(config) 1db

407 _types.clean_up_falsy_vault_config_values(config2) 1db

408 runner = machinery.CliRunner(mix_stderr=False) 1db

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

410 # with-statements. 

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

412 with contextlib.ExitStack() as stack: 1db

413 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1db

414 stack.enter_context( 1db

415 pytest_machinery.isolated_vault_config( 

416 monkeypatch=monkeypatch, 

417 runner=runner, 

418 vault_config={"services": {}}, 

419 ) 

420 ) 

421 if use_import: 1db

422 result1 = runner.invoke( 1b

423 cli.derivepassphrase_vault, 

424 ["--import", "-"], 

425 input=json.dumps(config), 

426 catch_exceptions=False, 

427 ) 

428 self._assert_result(result1, caplog=caplog) 1b

429 else: 

430 with cli_helpers.config_filename(subsystem="vault").open( 1d

431 "w", encoding="UTF-8" 

432 ) as outfile: 

433 # Ensure the config is written on one line. 

434 json.dump(config, outfile, indent=None) 1d

435 result = runner.invoke( 1db

436 cli.derivepassphrase_vault, 

437 ["--export", "-"], 

438 catch_exceptions=False, 

439 ) 

440 self._assert_result(result, caplog=caplog) 1db

441 config3 = json.loads(result.stdout) 1db

442 assert config3 == config2, "config not exported correctly" 1db

443 assert_vault_config_is_indented_and_line_broken(result.stdout) 1db

444 

445 @Parametrize.VALID_TEST_CONFIGS 

446 def test_normal_config( 

447 self, 

448 caplog: pytest.LogCaptureFixture, 

449 config: Any, 

450 ) -> None: 

451 """Exporting a configuration works.""" 

452 self._test(caplog=caplog, config=config, use_import=False) 1d

453 

454 @hypothesis.settings( 

455 suppress_health_check=[ 

456 *hypothesis.settings().suppress_health_check, 

457 hypothesis.HealthCheck.function_scoped_fixture, 

458 ], 

459 ) 

460 @hypothesis.given( 

461 conf=hypothesis_machinery.smudged_vault_test_config( 

462 strategies.sampled_from([ 

463 conf for conf in data.TEST_CONFIGS if conf.is_valid() 

464 ]) 

465 ) 

466 ) 

467 def test_reexport_smudged_config( 

468 self, 

469 caplog: pytest.LogCaptureFixture, 

470 conf: data.VaultTestConfig, 

471 ) -> None: 

472 """Re-exporting a smudged configuration works. 

473 

474 Tested via hypothesis. 

475 

476 """ 

477 # Reset caplog between hypothesis runs. 

478 caplog.clear() 1b

479 self._test(caplog=caplog, config=conf.config, use_import=True) 1b

480 

481 @Parametrize.EXPORT_FORMAT_OPTIONS 

482 def test_no_stored_settings( 

483 self, 

484 export_options: list[str], 

485 ) -> None: 

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

487 runner = machinery.CliRunner(mix_stderr=False) 1z

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

489 # with-statements. 

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

491 with contextlib.ExitStack() as stack: 1z

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

493 stack.enter_context( 1z

494 pytest_machinery.isolated_config( 

495 monkeypatch=monkeypatch, 

496 runner=runner, 

497 ) 

498 ) 

499 cli_helpers.config_filename(subsystem="vault").unlink( 1z

500 missing_ok=True 

501 ) 

502 result = runner.invoke( 1z

503 # Test parent context navigation by not calling 

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

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

506 # program name correctly. 

507 cli.derivepassphrase, 

508 ["vault", "--export", "-", *export_options], 

509 catch_exceptions=False, 

510 ) 

511 assert result.clean_exit(empty_stderr=True), "expected clean exit" 1z

512 assert result.stdout.startswith("#!") or json.loads(result.stdout) == { 1z

513 "services": {} 

514 } 

515 

516 

517class TestExportConfigInvalid: 

518 """Tests concerning `vault` configuration exports: invalid exports.""" 

519 

520 @contextlib.contextmanager 

521 def _test( 

522 self, 

523 command_line: list[str], 

524 /, 

525 *, 

526 config: _types.VaultConfig = {"services": {}}, # noqa: B006 

527 error_messages: tuple[str, ...] = (), 

528 ) -> Generator[list[str], None, None]: 

529 runner = machinery.CliRunner(mix_stderr=False) 1yonv

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

531 # with-statements. 

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

533 with contextlib.ExitStack() as stack: 1yonv

534 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1yonv

535 stack.enter_context( 1yonv

536 pytest_machinery.isolated_vault_config( 

537 monkeypatch=monkeypatch, 

538 runner=runner, 

539 vault_config=config, 

540 ) 

541 ) 

542 # If the setup blows up, bubble the exception. 

543 yield command_line # noqa: RUF075 1yonv

544 result = runner.invoke( 1yonv

545 cli.derivepassphrase_vault, 

546 command_line, 

547 input="null", 

548 catch_exceptions=False, 

549 ) 

550 assert any([result.error_exit(error=msg) for msg in error_messages]), ( 1yonv

551 "expected error exit and known error message" 

552 ) 

553 

554 @Parametrize.EXPORT_FORMAT_OPTIONS 

555 def test_bad_stored_config( 

556 self, 

557 export_options: list[str], 

558 ) -> None: 

559 """Exporting an invalid config fails.""" 

560 with self._test( 1y

561 ["--export", "-", *export_options], 

562 config=None, # type: ignore[arg-type,typeddict-item] 

563 error_messages=("Cannot load vault settings:",), 

564 ): 

565 pass 1y

566 

567 @Parametrize.EXPORT_FORMAT_OPTIONS 

568 def test_not_a_file( 

569 self, 

570 export_options: list[str], 

571 ) -> None: 

572 """Exporting an invalid config fails.""" 

573 with self._test( 1o

574 ["--export", "-", *export_options], 

575 error_messages=("Cannot load vault settings:",), 

576 ): 

577 config_file = cli_helpers.config_filename(subsystem="vault") 1o

578 config_file.unlink(missing_ok=True) 1o

579 config_file.mkdir(parents=True, exist_ok=True) 1o

580 

581 @Parametrize.EXPORT_FORMAT_OPTIONS 

582 def test_target_not_a_file( 

583 self, 

584 export_options: list[str], 

585 ) -> None: 

586 """Exporting an invalid config fails.""" 

587 with self._test( 1v

588 [], error_messages=("Cannot export vault settings:",) 

589 ) as command_line: 

590 dname = cli_helpers.config_filename(subsystem=None) 1v

591 command_line[:] = ["--export", os.fsdecode(dname), *export_options] 1v

592 

593 @pytest_machinery.skip_if_on_the_annoying_os 

594 @Parametrize.EXPORT_FORMAT_OPTIONS 

595 def test_settings_directory_not_a_directory( 

596 self, 

597 export_options: list[str], 

598 ) -> None: 

599 """Exporting an invalid config fails.""" 

600 with self._test( 1n

601 ["--export", "-", *export_options], 

602 error_messages=( 

603 "Cannot load vault settings:", 

604 "Cannot load user config:", 

605 ), 

606 ): 

607 config_dir = cli_helpers.config_filename(subsystem=None) 1n

608 with contextlib.suppress(FileNotFoundError): 1n

609 shutil.rmtree(config_dir) 1n

610 config_dir.write_text("Obstruction!!\n") 1n

611 

612 

613class TestStoringConfigurationSuccesses: 

614 """Tests concerning storing the configuration: successes.""" 

615 

616 def _test( 

617 self, 

618 command_line: list[str], 

619 /, 

620 *, 

621 starting_config: _types.VaultConfig | None, 

622 result_config: _types.VaultConfig, 

623 input: str | bytes | None = None, 

624 ) -> machinery.ReadableResult: 

625 runner = machinery.CliRunner(mix_stderr=False) 1eh

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

627 # with-statements. 

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

629 with contextlib.ExitStack() as stack: 1eh

630 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1eh

631 stack.enter_context( 1eh

632 pytest_machinery.isolated_vault_config( 

633 monkeypatch=monkeypatch, 

634 runner=runner, 

635 vault_config=starting_config, 

636 ) 

637 ) 

638 if starting_config is None: 1eh

639 with contextlib.suppress(FileNotFoundError): 1e

640 shutil.rmtree(cli_helpers.config_filename(subsystem=None)) 1e

641 monkeypatch.setattr( 1eh

642 cli_helpers, 

643 "get_suitable_ssh_keys", 

644 callables.suitable_ssh_keys, 

645 ) 

646 result = runner.invoke( 1eh

647 cli.derivepassphrase_vault, 

648 ["--config", *command_line], 

649 catch_exceptions=False, 

650 input=input, 

651 ) 

652 assert result.clean_exit(), "expected clean exit" 1eh

653 config_txt = cli_helpers.config_filename( 1eh

654 subsystem="vault" 

655 ).read_text(encoding="UTF-8") 

656 config = json.loads(config_txt) 1eh

657 assert config == result_config, ( 1eh

658 "stored config does not match expectation" 

659 ) 

660 assert_vault_config_is_indented_and_line_broken(config_txt) 1eh

661 return result 1eh

662 

663 @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG 

664 def test_store_good_config( 1ah

665 self, 

666 command_line: list[str], 

667 input: str, 

668 starting_config: Any, 

669 result_config: Any, 

670 ) -> None: 

671 """Storing valid settings via `--config` works. 

672 

673 The format also contains embedded newlines and indentation to make 

674 the config more readable. 

675 

676 """ 

677 self._test( 1h

678 command_line, 

679 input=input, 

680 starting_config=starting_config, 

681 result_config=result_config, 

682 ) 

683 

684 def test_config_directory_nonexistant( 

685 self, 

686 ) -> None: 

687 """Running without an existing config directory works. 

688 

689 This is a regression test; see [the "pretty-print-json" 

690 issue][PRETTY_PRINT_JSON] for context. See also 

691 [TestStoringConfigurationFailures.test_config_directory_not_a_file][] 

692 for a related aspect of this. 

693 

694 [PRETTY_PRINT_JSON]: https://the13thletter.info/derivepassphrase/0.x/wishlist/pretty-print-json/ 

695 

696 """ 

697 result = self._test( 1e

698 ["-p"], 

699 starting_config=None, 

700 result_config={"global": {"phrase": "abc"}, "services": {}}, 

701 input="abc\n", 

702 ) 

703 # Some versions of click leave the space and/or the line 

704 # terminator in stderr when running in the test runner. 

705 assert result.stderr.strip() == "Passphrase:", ( 1e

706 "program unexpectedly failed?!" 

707 ) 

708 

709 

710class TestStoringConfigurationFailures: 

711 """Tests concerning storing the configuration: failures.""" 

712 

713 @contextlib.contextmanager 

714 def _test( 

715 self, 

716 command_line: list[str], 

717 error_text: str, 

718 input: str | bytes | None = None, 

719 starting_config: _types.VaultConfig = { # noqa: B006 

720 "global": {"phrase": "abc"}, 

721 "services": {}, 

722 }, 

723 patch_suitable_ssh_keys: bool = True, 

724 ) -> Generator[pytest.MonkeyPatch, None, None]: 

725 runner = machinery.CliRunner(mix_stderr=False) 1fpqjirkstlm

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

727 # with-statements. 

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

729 with contextlib.ExitStack() as stack: 1fpqjirkstlm

730 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1fpqjirkstlm

731 stack.enter_context( 1fpqjirkstlm

732 pytest_machinery.isolated_vault_config( 

733 monkeypatch=monkeypatch, 

734 runner=runner, 

735 vault_config=starting_config, 

736 ) 

737 ) 

738 # Patch the list of suitable SSH keys by default, lest we be 

739 # at the mercy of whatever SSH agent may be running. (But 

740 # allow a test to turn this off, if it would interfere with 

741 # the testing target, e.g. because we are testing 

742 # non-reachability of the agent.) 

743 if patch_suitable_ssh_keys: 1fpqjirkstlm

744 monkeypatch.setattr( 1fjirst

745 cli_helpers, 

746 "get_suitable_ssh_keys", 

747 callables.suitable_ssh_keys, 

748 ) 

749 # If the setup blows up, bubble the exception. 

750 yield monkeypatch # noqa: RUF075 1fpqjirkstlm

751 result = runner.invoke( 1fpqjirkstlm

752 cli.derivepassphrase_vault, 

753 ["--config", *command_line], 

754 catch_exceptions=False, 

755 input=input, 

756 ) 

757 assert result.error_exit(error=error_text), ( 1fpqjirkstlm

758 "expected error exit and known error message" 

759 ) 

760 

761 @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES 

762 def test_store_bad_config( 

763 self, 

764 command_line: list[str], 

765 input: str, 

766 err_text: str, 

767 ) -> None: 

768 """Storing invalid settings via `--config` fails.""" 

769 with self._test(command_line, error_text=err_text, input=input): 1t

770 pass 1t

771 

772 def test_fail_because_no_ssh_key_selection(self) -> None: 

773 """Not selecting an SSH key during `--config --key` fails. 

774 

775 (This test does not actually need a running agent; the agent's 

776 response is mocked by the test harness.) 

777 

778 """ 

779 with self._test( 1j

780 ["--key"], error_text="the user aborted the request" 

781 ) as monkeypatch: 

782 

783 def prompt_for_selection(*_args: Any, **_kwargs: Any) -> NoReturn: 1j

784 raise IndexError(cli_helpers.EMPTY_SELECTION) 1j

785 

786 monkeypatch.setattr( 1j

787 cli_helpers, "prompt_for_selection", prompt_for_selection 

788 ) 

789 

790 def test_fail_because_no_ssh_agent( 

791 self, 

792 use_stub_agent_with_address: None, 

793 ) -> None: 

794 """Not running an SSH agent during `--config --key` fails. 

795 

796 (This test runs against the stub agent; it does not actually 

797 need an agent installed on the user's system.) 

798 

799 """ 

800 del use_stub_agent_with_address 1q

801 with self._test( 1q

802 ["--key"], 

803 error_text="Cannot find any running SSH agent", 

804 patch_suitable_ssh_keys=False, 

805 ) as monkeypatch: 

806 monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) 1q

807 

808 def test_fail_because_bad_ssh_agent_connection( 

809 self, 

810 use_stub_agent_with_address: None, 

811 ) -> None: 

812 """Not running a reachable SSH agent during `--config --key` fails. 

813 

814 (This test runs against the stub agent; it does not actually 

815 need an agent installed on the user's system.) 

816 

817 """ 

818 del use_stub_agent_with_address 1p

819 with self._test( 1p

820 ["--key"], 

821 error_text="Cannot connect to the SSH agent", 

822 patch_suitable_ssh_keys=False, 

823 ) as monkeypatch: 

824 monkeypatch.setenv("SSH_AUTH_SOCK", "invalid address format") 1p

825 

826 @Parametrize.TRY_RACE_FREE_IMPLEMENTATION 

827 def test_fail_because_read_only_file( 

828 self, try_race_free_implementation: bool 

829 ) -> None: 

830 """Using a read-only configuration file with `--config` fails.""" 

831 with self._test( 1r

832 ["--length=15", "--", DUMMY_SERVICE], 

833 error_text="Cannot store vault settings:", 

834 ): 

835 callables.make_file_readonly( 1r

836 cli_helpers.config_filename(subsystem="vault"), 

837 try_race_free_implementation=try_race_free_implementation, 

838 ) 

839 

840 def test_fail_because_of_custom_error(self) -> None: 

841 """Triggering internal errors during `--config` leads to failure.""" 

842 custom_error = "custom error message" 1i

843 with self._test( 1i

844 ["--length=15", "--", DUMMY_SERVICE], error_text=custom_error 

845 ) as monkeypatch: 

846 

847 def raiser(config: Any) -> None: 1i

848 del config 1i

849 raise RuntimeError(custom_error) 1i

850 

851 monkeypatch.setattr(cli_helpers, "save_config", raiser) 1i

852 

853 def test_fail_because_unsetting_and_setting_same_settings(self) -> None: 

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

855 with self._test( 1s

856 ["--unset=length", "--length=15", "--", DUMMY_SERVICE], 

857 error_text="Attempted to unset and set --length at the same time.", 

858 ): 

859 pass 1s

860 

861 def test_fail_because_ssh_agent_has_no_keys_loaded( 

862 self, 

863 use_stub_agent: None, 

864 ) -> None: 

865 """Not holding any SSH keys during `--config --key` fails. 

866 

867 (This test does not actually need a running agent; the agent's 

868 response is mocked by the test harness.) 

869 

870 """ 

871 del use_stub_agent 1k

872 with self._test( 1k

873 ["--key"], 

874 error_text="no keys suitable", 

875 patch_suitable_ssh_keys=False, 

876 ) as monkeypatch: 

877 

878 def func( 1k

879 *_args: Any, 

880 **_kwargs: Any, 

881 ) -> list[_types.SSHKeyCommentPair]: 

882 return [] 1k

883 

884 monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func) 1k

885 

886 def test_store_config_fail_manual_ssh_agent_runtime_error( 

887 self, 

888 use_stub_agent: None, 

889 ) -> None: 

890 """Triggering an error in the SSH agent during `--config --key` leads to failure. 

891 

892 (This test does not actually need a running agent; the agent's 

893 response is mocked by the test harness.) 

894 

895 """ 

896 del use_stub_agent 1m

897 with self._test( 1m

898 ["--key"], 

899 error_text="violates the communication protocol", 

900 patch_suitable_ssh_keys=False, 

901 ) as monkeypatch: 

902 

903 def raiser(*_args: Any, **_kwargs: Any) -> None: 1m

904 raise ssh_agent.TrailingDataError() 1m

905 

906 monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", raiser) 1m

907 

908 def test_store_config_fail_manual_ssh_agent_refuses( 

909 self, 

910 use_stub_agent: None, 

911 ) -> None: 

912 """The SSH agent refusing during `--config --key` leads to failure. 

913 

914 (This test does not actually need a running agent; the agent's 

915 response is mocked by the test harness.) 

916 

917 """ 

918 del use_stub_agent 1l

919 with self._test( 1l

920 ["--key"], error_text="refused to", patch_suitable_ssh_keys=False 

921 ) as monkeypatch: 

922 

923 def func(*_args: Any, **_kwargs: Any) -> NoReturn: 1l

924 raise ssh_agent.SSHAgentFailedError( 1l

925 _types.SSH_AGENT.FAILURE, b"" 

926 ) 

927 

928 monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func) 1l

929 

930 def test_config_directory_not_a_file(self) -> None: 

931 """Erroring without an existing config directory errors normally. 

932 

933 That is, the missing configuration directory does not cause any 

934 errors by itself. 

935 

936 This is a regression test; see [the "pretty-print-json" 

937 issue][PRETTY_PRINT_JSON] for context. See also 

938 [TestStoringConfigurationSuccesses.test_config_directory_nonexistant][] 

939 for a related aspect of this. 

940 

941 [PRETTY_PRINT_JSON]: https://the13thletter.info/derivepassphrase/0.x/wishlist/pretty-print-json/ 

942 

943 """ 

944 with self._test( 1f

945 ["--phrase"], 

946 error_text="Cannot store vault settings:", 

947 input="abc\n", 

948 ) as monkeypatch: 

949 save_config_ = cli_helpers.save_config 1f

950 

951 def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any: 1f

952 config_dir = cli_helpers.config_filename(subsystem=None) 1f

953 with contextlib.suppress(FileNotFoundError): 1f

954 shutil.rmtree(config_dir) 1f

955 config_dir.write_text("Obstruction!!\n") 1f

956 monkeypatch.setattr(cli_helpers, "save_config", save_config_) 1f

957 return save_config_(*args, **kwargs) 1f

958 

959 monkeypatch.setattr( 1f

960 cli_helpers, "save_config", obstruct_config_saving 

961 )