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

274 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: basic functionality.""" 

6 

7from __future__ import annotations 

8 

9import contextlib 

10import json 

11import textwrap 

12import types 

13from typing import TYPE_CHECKING 

14 

15import pytest 

16from typing_extensions import Any, NamedTuple 

17 

18from derivepassphrase import _types, cli, ssh_agent, vault 

19from derivepassphrase._internals import ( 

20 cli_helpers, 

21) 

22from derivepassphrase.ssh_agent import socketprovider 

23from tests import data, machinery 

24from tests.data import callables 

25from tests.machinery import pytest as pytest_machinery 

26 

27if TYPE_CHECKING: 

28 from collections.abc import Generator 

29 from typing import NoReturn 

30 

31DUMMY_SERVICE = data.DUMMY_SERVICE 

32DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE 

33DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS 

34DUMMY_RESULT_PASSPHRASE = data.DUMMY_RESULT_PASSPHRASE 

35DUMMY_RESULT_KEY1 = data.DUMMY_RESULT_KEY1 

36 

37DUMMY_KEY1_B64 = data.DUMMY_KEY1_B64 

38DUMMY_KEY2_B64 = data.DUMMY_KEY2_B64 

39 

40 

41PROG_NAME = cli.PROG_NAME 

42 

43 

44class IncompatibleConfiguration(NamedTuple): 

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

46 needs_service: bool | None 

47 input: str | None 

48 

49 

50class SingleConfiguration(NamedTuple): 

51 needs_service: bool | None 

52 input: str | None 

53 check_success: bool 

54 

55 

56class OptionCombination(NamedTuple): 

57 options: list[str] 

58 incompatible: bool 

59 needs_service: bool | None 

60 input: str | None 

61 check_success: bool 

62 

63 

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

65 ("--phrase",), 

66 ("--key",), 

67 ("--length", "20"), 

68 ("--repeat", "20"), 

69 ("--lower", "1"), 

70 ("--upper", "1"), 

71 ("--number", "1"), 

72 ("--space", "1"), 

73 ("--dash", "1"), 

74 ("--symbol", "1"), 

75] 

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

77 ("--notes",), 

78 ("--config",), 

79 ("--delete",), 

80 ("--delete-globals",), 

81 ("--clear",), 

82] 

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

84 ("--delete",), 

85 ("--delete-globals",), 

86 ("--clear",), 

87] 

88STORAGE_OPTIONS: list[tuple[str, ...]] = [("--export", "-"), ("--import", "-")] 

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

90 ("--phrase",): IncompatibleConfiguration( 

91 [("--key",), *CONFIGURATION_COMMANDS, *STORAGE_OPTIONS], 

92 True, 

93 DUMMY_PASSPHRASE, 

94 ), 

95 ("--key",): IncompatibleConfiguration( 

96 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

97 ), 

98 ("--length", "20"): IncompatibleConfiguration( 

99 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

100 ), 

101 ("--repeat", "20"): IncompatibleConfiguration( 

102 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

103 ), 

104 ("--lower", "1"): IncompatibleConfiguration( 

105 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

106 ), 

107 ("--upper", "1"): IncompatibleConfiguration( 

108 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

109 ), 

110 ("--number", "1"): IncompatibleConfiguration( 

111 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

112 ), 

113 ("--space", "1"): IncompatibleConfiguration( 

114 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

115 ), 

116 ("--dash", "1"): IncompatibleConfiguration( 

117 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

118 ), 

119 ("--symbol", "1"): IncompatibleConfiguration( 

120 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE 

121 ), 

122 ("--notes",): IncompatibleConfiguration( 

123 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, None 

124 ), 

125 ("--config", "-p"): IncompatibleConfiguration( 

126 [("--delete",), ("--delete-globals",), ("--clear",), *STORAGE_OPTIONS], 

127 None, 

128 DUMMY_PASSPHRASE, 

129 ), 

130 ("--delete",): IncompatibleConfiguration( 

131 [("--delete-globals",), ("--clear",), *STORAGE_OPTIONS], True, None 

132 ), 

133 ("--delete-globals",): IncompatibleConfiguration( 

134 [("--clear",), *STORAGE_OPTIONS], False, None 

135 ), 

136 ("--clear",): IncompatibleConfiguration(STORAGE_OPTIONS, False, None), 

137 ("--export", "-"): IncompatibleConfiguration( 

138 [("--import", "-")], False, None 

139 ), 

140 ("--import", "-"): IncompatibleConfiguration([], False, None), 

141} 

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

143 ("--phrase",): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

144 ("--key",): SingleConfiguration(True, None, False), 

145 ("--length", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

146 ("--repeat", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

147 ("--lower", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

148 ("--upper", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

149 ("--number", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

150 ("--space", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

151 ("--dash", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

152 ("--symbol", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True), 

153 ("--notes",): SingleConfiguration(True, None, False), 

154 ("--config", "-p"): SingleConfiguration(None, DUMMY_PASSPHRASE, False), 

155 ("--delete",): SingleConfiguration(True, None, False), 

156 ("--delete-globals",): SingleConfiguration(False, None, True), 

157 ("--clear",): SingleConfiguration(False, None, True), 

158 ("--export", "-"): SingleConfiguration(False, None, True), 

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

160} 

161INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = [] 

162config: IncompatibleConfiguration | SingleConfiguration 

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

164 for opt2 in config.other_options: 

165 INTERESTING_OPTION_COMBINATIONS.extend([ 

166 OptionCombination( 

167 options=list(opt + opt2), 

168 incompatible=True, 

169 needs_service=config.needs_service, 

170 input=config.input, 

171 check_success=False, 

172 ), 

173 OptionCombination( 

174 options=list(opt2 + opt), 

175 incompatible=True, 

176 needs_service=config.needs_service, 

177 input=config.input, 

178 check_success=False, 

179 ), 

180 ]) 

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

182 INTERESTING_OPTION_COMBINATIONS.append( 

183 OptionCombination( 

184 options=list(opt), 

185 incompatible=False, 

186 needs_service=config.needs_service, 

187 input=config.input, 

188 check_success=config.check_success, 

189 ) 

190 ) 

191 

192 

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

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

195 return line.startswith(( 1c

196 f"{PROG_NAME}: Warning: ", 

197 f"{PROG_NAME}: Deprecation warning: ", 

198 )) 

199 

200 

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

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

203 possible_warnings = [ 1bc

204 "Replacing invalid value ", 

205 "Removing ineffective setting ", 

206 ( 

207 "Setting a global passphrase is ineffective " 

208 "because a key is also set." 

209 ), 

210 ( 

211 "Setting a service passphrase is ineffective " 

212 "because a key is also set:" 

213 ), 

214 ] 

215 return any( 1bc

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

217 ) 

218 

219 

220class Parametrize(types.SimpleNamespace): 

221 """Common test parametrizations.""" 

222 

223 AUTO_PROMPT = pytest.mark.parametrize( 

224 "auto_prompt", [False, True], ids=["normal_prompt", "auto_prompt"] 

225 ) 

226 CHARSET_NAME = pytest.mark.parametrize( 

227 "charset_name", ["lower", "upper", "number", "space", "dash", "symbol"] 

228 ) 

229 BASE_CONFIG_WITH_KEY_VARIATIONS = pytest.mark.parametrize( 

230 "config", 

231 [ 

232 pytest.param( 

233 { 

234 "global": {"key": DUMMY_KEY1_B64}, 

235 "services": {DUMMY_SERVICE: {}}, 

236 }, 

237 id="global_config", 

238 ), 

239 pytest.param( 

240 {"services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}}}, 

241 id="service_config", 

242 ), 

243 pytest.param( 

244 { 

245 "global": {"key": DUMMY_KEY1_B64}, 

246 "services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}}, 

247 }, 

248 id="full_config", 

249 ), 

250 ], 

251 ) 

252 CONFIG_WITH_KEY = pytest.mark.parametrize( 

253 "config", 

254 [ 

255 pytest.param( 

256 { 

257 "global": {"key": DUMMY_KEY1_B64}, 

258 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}, 

259 }, 

260 id="global", 

261 ), 

262 pytest.param( 

263 { 

264 "global": {"phrase": DUMMY_PASSPHRASE.rstrip("\n")}, 

265 "services": { 

266 DUMMY_SERVICE: { 

267 "key": DUMMY_KEY1_B64, 

268 **DUMMY_CONFIG_SETTINGS, 

269 } 

270 }, 

271 }, 

272 id="service", 

273 ), 

274 ], 

275 ) 

276 CONFIG_WITH_PHRASE = pytest.mark.parametrize( 

277 "config", 

278 [ 

279 pytest.param( 

280 { 

281 "global": {"phrase": DUMMY_PASSPHRASE.rstrip("\n")}, 

282 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}, 

283 }, 

284 id="global", 

285 ), 

286 pytest.param( 

287 { 

288 "global": { 

289 "phrase": DUMMY_PASSPHRASE.rstrip("\n") 

290 + "XXX" 

291 + DUMMY_PASSPHRASE.rstrip("\n") 

292 }, 

293 "services": { 

294 DUMMY_SERVICE: { 

295 "phrase": DUMMY_PASSPHRASE.rstrip("\n"), 

296 **DUMMY_CONFIG_SETTINGS, 

297 } 

298 }, 

299 }, 

300 id="service", 

301 ), 

302 ], 

303 ) 

304 KEY_OVERRIDING_IN_CONFIG = pytest.mark.parametrize( 

305 ["config", "command_line"], 

306 [ 

307 pytest.param( 

308 { 

309 "global": {"key": DUMMY_KEY1_B64}, 

310 "services": {}, 

311 }, 

312 ["--config", "-p"], 

313 id="global", 

314 ), 

315 pytest.param( 

316 { 

317 "services": { 

318 DUMMY_SERVICE: { 

319 "key": DUMMY_KEY1_B64, 

320 **DUMMY_CONFIG_SETTINGS, 

321 }, 

322 }, 

323 }, 

324 ["--config", "-p", "--", DUMMY_SERVICE], 

325 id="service", 

326 ), 

327 pytest.param( 

328 { 

329 "global": {"key": DUMMY_KEY1_B64}, 

330 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}, 

331 }, 

332 ["--config", "-p", "--", DUMMY_SERVICE], 

333 id="service-over-global", 

334 ), 

335 ], 

336 ) 

337 KEY_INDEX = pytest.mark.parametrize( 

338 "key_index", 

339 [ 

340 i 

341 for i, x in enumerate(data.ALL_KEYS.values(), start=1) 

342 if x.is_suitable() 

343 ], 

344 ids=lambda i: f"index{i}", 

345 ) 

346 EXPLICIT_SSH_AGENT_SOCKET_PROVIDER = pytest.mark.parametrize( 

347 ["on_command_line", "in_config"], 

348 [ 

349 pytest.param(False, True, id="configuration_only"), 

350 pytest.param(True, False, id="command_line_only"), 

351 pytest.param( 

352 True, True, id="command_line_overrides_configuration" 

353 ), 

354 ], 

355 ) 

356 VAULT_CHARSET_OPTION = pytest.mark.parametrize( 

357 "option", 

358 [ 

359 "--lower", 

360 "--upper", 

361 "--number", 

362 "--space", 

363 "--dash", 

364 "--symbol", 

365 "--repeat", 

366 "--length", 

367 ], 

368 ) 

369 OPTION_COMBINATIONS_INCOMPATIBLE = pytest.mark.parametrize( 

370 ["options", "service"], 

371 [ 

372 pytest.param(o.options, o.needs_service, id=" ".join(o.options)) 

373 for o in INTERESTING_OPTION_COMBINATIONS 

374 if o.incompatible 

375 ], 

376 ) 

377 OPTION_COMBINATIONS_SERVICE_NEEDED = pytest.mark.parametrize( 

378 ["options", "needs_service", "input", "check_success"], 

379 [ 

380 pytest.param( 

381 o.options, 

382 o.needs_service, 

383 o.input, 

384 o.check_success, 

385 id=" ".join(o.options), 

386 ) 

387 for o in INTERESTING_OPTION_COMBINATIONS 

388 if not o.incompatible 

389 ], 

390 ) 

391 

392 

393class TestHelp: 

394 """Tests related to help output.""" 

395 

396 def test_help_output( 

397 self, 

398 ) -> None: 

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

400 runner = machinery.CliRunner(mix_stderr=False) 1s

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

402 # with-statements. 

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

404 with contextlib.ExitStack() as stack: 1s

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

406 stack.enter_context( 1s

407 pytest_machinery.isolated_config( 

408 monkeypatch=monkeypatch, 

409 runner=runner, 

410 ) 

411 ) 

412 result = runner.invoke( 1s

413 cli.derivepassphrase_vault, 

414 ["--help"], 

415 catch_exceptions=False, 

416 ) 

417 assert result.clean_exit( 1s

418 empty_stderr=True, output="Passphrase generation:\n" 

419 ), "expected clean exit, and option groups in help text" 

420 assert result.clean_exit( 1s

421 empty_stderr=True, output="Use $VISUAL or $EDITOR to configure" 

422 ), "expected clean exit, and option group epilog in help text" 

423 

424 

425class TestDerivedPassphraseConstraints: 

426 """Tests for (derived) passphrase constraints.""" 

427 

428 def _test(self, command_line: list[str]) -> machinery.ReadableResult: 

429 runner = machinery.CliRunner(mix_stderr=False) 1mq

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

431 # with-statements. 

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

433 with contextlib.ExitStack() as stack: 1mq

434 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1mq

435 stack.enter_context( 1mq

436 pytest_machinery.isolated_config( 

437 monkeypatch=monkeypatch, 

438 runner=runner, 

439 ) 

440 ) 

441 monkeypatch.setattr( 1mq

442 cli_helpers, 

443 "prompt_for_passphrase", 

444 callables.auto_prompt, 

445 ) 

446 return runner.invoke( 1mq

447 cli.derivepassphrase_vault, 

448 command_line, 

449 input=DUMMY_PASSPHRASE, 

450 catch_exceptions=False, 

451 ) 

452 

453 @Parametrize.CHARSET_NAME 

454 def test_disable_character_set( 

455 self, 

456 charset_name: str, 

457 ) -> None: 

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

459 option = f"--{charset_name}" 1m

460 charset = vault.Vault.CHARSETS[charset_name].decode("ascii") 1m

461 result = self._test([option, "0", "-p", "--", DUMMY_SERVICE]) 1m

462 assert result.clean_exit(empty_stderr=True), "expected clean exit:" 1m

463 for c in charset: 1m

464 assert c not in result.stdout, ( 1m

465 f"derived password contains forbidden character {c!r}" 

466 ) 

467 

468 def test_disable_repetition( 

469 self, 

470 ) -> None: 

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

472 result = self._test(["--repeat", "0", "-p", "--", DUMMY_SERVICE]) 1q

473 assert result.clean_exit(empty_stderr=True), ( 1q

474 "expected clean exit and empty stderr" 

475 ) 

476 passphrase = result.stdout.rstrip("\r\n") 1q

477 for i in range(len(passphrase) - 1): 1q

478 assert passphrase[i : i + 1] != passphrase[i + 1 : i + 2], ( 1q

479 f"derived password contains repeated character " 

480 f"at position {i}: {result.stdout!r}" 

481 ) 

482 

483 

484class TestPhraseBasic: 

485 """Tests for master passphrase configuration: basic.""" 

486 

487 def _test( 

488 self, 

489 command_line: list[str], 

490 /, 

491 *, 

492 config: _types.VaultConfig = { # noqa: B006 

493 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS} 

494 }, 

495 auto_prompt: bool = False, 

496 multiline: bool = False, 

497 ) -> None: 

498 runner = machinery.CliRunner(mix_stderr=False) 1ik

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

500 # with-statements. 

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

502 with contextlib.ExitStack() as stack: 1ik

503 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1ik

504 stack.enter_context( 1ik

505 pytest_machinery.isolated_vault_config( 

506 monkeypatch=monkeypatch, 

507 runner=runner, 

508 vault_config=config, 

509 ) 

510 ) 

511 

512 def phrase_from_key(*_args: Any, **_kwargs: Any) -> NoReturn: 1ik

513 pytest.fail("Attempted to use a key in a phrase-based test!") 

514 

515 monkeypatch.setattr( 1ik

516 vault.Vault, "phrase_from_key", phrase_from_key 

517 ) 

518 if auto_prompt: 1ik

519 monkeypatch.setattr( 1i

520 cli_helpers, 

521 "prompt_for_passphrase", 

522 callables.auto_prompt, 

523 ) 

524 result = runner.invoke( 1ik

525 cli.derivepassphrase_vault, 

526 command_line, 

527 input=None if auto_prompt else DUMMY_PASSPHRASE, 

528 catch_exceptions=False, 

529 ) 

530 if multiline: 1ik

531 assert result.clean_exit(), "expected clean exit" 1i

532 else: 

533 assert result.clean_exit(empty_stderr=True), ( 1k

534 "expected clean exit and empty stderr" 

535 ) 

536 assert result.stdout, "expected program output" 1ik

537 last_line = ( 1ik

538 result.stdout.splitlines(keepends=True)[-1] 

539 if multiline 

540 else result.stdout 

541 ) 

542 assert ( 1ik

543 last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_PASSPHRASE 

544 ), "expected known output" 

545 

546 @Parametrize.CONFIG_WITH_PHRASE 

547 def test_phrase_from_config( 

548 self, 

549 config: _types.VaultConfig, 

550 ) -> None: 

551 """A stored configured master passphrase will be used.""" 

552 self._test(["--", DUMMY_SERVICE], config=config) 1k

553 

554 @Parametrize.AUTO_PROMPT 

555 def test_phrase_from_command_line( 

556 self, 

557 auto_prompt: bool, 

558 ) -> None: 

559 """A master passphrase requested on the command-line will be used.""" 

560 self._test( 1i

561 ["-p", "--", DUMMY_SERVICE], 

562 auto_prompt=auto_prompt, 

563 multiline=True, 

564 ) 

565 

566 

567class TestKeyBasic: 

568 """Tests for SSH key configuration: basic.""" 

569 

570 @contextlib.contextmanager 

571 def _setup_environment( 

572 self, 

573 /, 

574 *, 

575 config: _types.VaultConfig = { # noqa: B006 

576 "services": { 

577 DUMMY_SERVICE: {**DUMMY_CONFIG_SETTINGS}, 

578 }, 

579 }, 

580 main_config_str: str | None = None, 

581 registry: dict[str, _types.SSHAgentSocketProvider | str | None] 

582 | None = None, 

583 ) -> Generator[machinery.CliRunner, None, None]: 

584 runner = machinery.CliRunner(mix_stderr=False) 1efdh

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

586 # with-statements. 

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

588 with contextlib.ExitStack() as stack: 1efdh

589 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1efdh

590 stack.enter_context( 1efdh

591 pytest_machinery.isolated_vault_config( 

592 monkeypatch=monkeypatch, 

593 runner=runner, 

594 vault_config=config, 

595 main_config_str=main_config_str, 

596 ) 

597 ) 

598 monkeypatch.setattr( 1efdh

599 cli_helpers, 

600 "get_suitable_ssh_keys", 

601 callables.suitable_ssh_keys, 

602 ) 

603 monkeypatch.setattr( 1efdh

604 vault.Vault, 

605 "phrase_from_key", 

606 callables.phrase_from_key, 

607 ) 

608 if registry is not None: 1efdh

609 monkeypatch.setattr( 1dh

610 socketprovider.SocketProvider, "registry", registry 

611 ) 

612 monkeypatch.setattr( 1dh

613 ssh_agent.SSHAgentClient, 

614 "SOCKET_PROVIDERS", 

615 ("incorrect",), 

616 ) 

617 yield runner 1efdh

618 

619 def _check_result( 

620 self, result: machinery.ReadableResult, /, *, multiline: bool = False 

621 ) -> None: 

622 if multiline: 1efd

623 assert result.clean_exit(), "expected clean exit" 1e

624 else: 

625 assert result.clean_exit(empty_stderr=True), ( 1fd

626 "expected clean exit and empty stderr" 

627 ) 

628 assert result.stdout, "expected program output" 1efd

629 last_line = ( 1efd

630 result.stdout.splitlines(keepends=True)[-1] 

631 if multiline 

632 else result.stdout 

633 ) 

634 assert ( 1efd

635 last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE 

636 ), "known false output: phrase-based instead of key-based" 

637 assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, ( 1efd

638 "expected known output" 

639 ) 

640 

641 def _test( 

642 self, 

643 command_line: list[str], 

644 /, 

645 *, 

646 config: _types.VaultConfig = { # noqa: B006 

647 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS} 

648 }, 

649 main_config_str: str | None = None, 

650 registry: dict[str, _types.SSHAgentSocketProvider | str | None] 

651 | None = None, 

652 multiline: bool = False, 

653 input: str | bytes | None = None, 

654 ) -> None: 

655 with self._setup_environment( 1efd

656 config=config, main_config_str=main_config_str, registry=registry 

657 ) as runner: 

658 result = runner.invoke( 1efd

659 cli.derivepassphrase_vault, 

660 command_line, 

661 input=input, 

662 catch_exceptions=False, 

663 ) 

664 self._check_result(result, multiline=multiline) 1efd

665 

666 @Parametrize.CONFIG_WITH_KEY 

667 def test_key_from_config( 

668 self, 

669 running_ssh_agent: data.RunningSSHAgentInfo, 

670 config: _types.VaultConfig, 

671 ) -> None: 

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

673 del running_ssh_agent 1f

674 self._test(["--", DUMMY_SERVICE], config=config) 1f

675 

676 def test_key_from_command_line( 

677 self, 

678 running_ssh_agent: data.RunningSSHAgentInfo, 

679 ) -> None: 

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

681 del running_ssh_agent 1e

682 self._test(["-k", "--", DUMMY_SERVICE], input="1\n", multiline=True) 1e

683 

684 

685@pytest.fixture 

686def provider_registry() -> dict[ 

687 str, _types.SSHAgentSocketProvider | str | None 

688]: 

689 """Set up a controlled SSH agent socket provider registry.""" 

690 

691 def err() -> _types.SSHAgentSocket: 

692 pytest.fail("Attempting to use the wrong SSH agent socket provider!") 

693 

694 return { 

695 "correct": machinery.StubbedSSHAgentSocket, 

696 "incorrect": err, 

697 } 

698 

699 

700# TODO(the-13th-letter): Rewrite the parametrization to be somewhat more 

701# transparent, but keep it this high-level if possible. 

702class TestKeyExplicitSSHAgentSocketProvider(TestKeyBasic): 

703 """Tests for SSH key configuration: explicit SSH agent socket providers.""" 

704 

705 ARG = "--ssh-agent-socket-provider=correct" 

706 ARG_NONEXISTANT = "--ssh-agent-socket-provider=nonexistant" 

707 MAIN_CONFIG_STR = textwrap.dedent(r""" 

708 [vault] 

709 ssh-agent-socket-provider = "correct" 

710 """) 

711 WRONG_CONFIG_STR = textwrap.dedent(r""" 

712 [vault] 

713 ssh-agent-socket-provider = "incorrect" 

714 """) 

715 NONEXISTANT_CONFIG_STR = textwrap.dedent(r""" 

716 [vault] 

717 ssh-agent-socket-provider = "nonexistant" 

718 """) 

719 

720 @Parametrize.EXPLICIT_SSH_AGENT_SOCKET_PROVIDER 

721 @Parametrize.CONFIG_WITH_KEY 

722 def test_explicit_ssh_agent_socket_provider( 

723 self, 

724 provider_registry: dict[ 

725 str, _types.SSHAgentSocketProvider | str | None 

726 ], 

727 config: _types.VaultConfig, 

728 on_command_line: bool, 

729 in_config: bool, 

730 ) -> None: 

731 args = [self.ARG] if on_command_line else [] 1d

732 main_config_str = ( 1d

733 self.WRONG_CONFIG_STR 

734 if in_config and on_command_line 

735 else self.MAIN_CONFIG_STR 

736 if in_config 

737 else None 

738 ) 

739 self._test( 1d

740 [*args, "--", DUMMY_SERVICE], 

741 config=config, 

742 registry=provider_registry, 

743 main_config_str=main_config_str, 

744 ) 

745 

746 @Parametrize.EXPLICIT_SSH_AGENT_SOCKET_PROVIDER 

747 @Parametrize.CONFIG_WITH_KEY 

748 def test_explicit_ssh_agent_socket_provider_not_found( 

749 self, 

750 provider_registry: dict[ 

751 str, _types.SSHAgentSocketProvider | str | None 

752 ], 

753 config: _types.VaultConfig, 

754 on_command_line: bool, 

755 in_config: bool, 

756 ) -> None: 

757 assert "nonexistant" not in provider_registry, ( 1h

758 '"nonexistant" name actually found in registry?!' 

759 ) 

760 args = [self.ARG_NONEXISTANT] if on_command_line else [] 1h

761 main_config_str = ( 1h

762 self.MAIN_CONFIG_STR 

763 if in_config and on_command_line 

764 else self.NONEXISTANT_CONFIG_STR 

765 if in_config 

766 else None 

767 ) 

768 with self._setup_environment( 1h

769 config=config, 

770 registry=provider_registry, 

771 main_config_str=main_config_str, 

772 ) as runner: 

773 result = runner.invoke( 1h

774 cli.derivepassphrase_vault, 

775 [*args, "--", DUMMY_SERVICE], 

776 catch_exceptions=False, 

777 ) 

778 assert result.error_exit( 1h

779 error=" is not in derivepassphrase's provider registry." 

780 ) 

781 

782 

783class TestPhraseAndKeyOverriding: 

784 """Tests for master passphrase and SSH key configuration: overriding.""" 

785 

786 def _test( 

787 self, 

788 command_line: list[str], 

789 /, 

790 *, 

791 config: _types.VaultConfig = { # noqa: B006 

792 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS} 

793 }, 

794 input: str | bytes | None = None, 

795 ) -> machinery.ReadableResult: 

796 runner = machinery.CliRunner(mix_stderr=False) 1ljc

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

798 # with-statements. 

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

800 with contextlib.ExitStack() as stack: 1ljc

801 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1ljc

802 stack.enter_context( 1ljc

803 pytest_machinery.isolated_vault_config( 

804 monkeypatch=monkeypatch, 

805 runner=runner, 

806 vault_config=config, 

807 ) 

808 ) 

809 monkeypatch.setattr( 1ljc

810 ssh_agent.SSHAgentClient, 

811 "list_keys", 

812 callables.list_keys, 

813 ) 

814 monkeypatch.setattr( 1ljc

815 ssh_agent.SSHAgentClient, "sign", callables.sign 

816 ) 

817 result = runner.invoke( 1ljc

818 cli.derivepassphrase_vault, 

819 command_line, 

820 input=input, 

821 catch_exceptions=False, 

822 ) 

823 assert result.clean_exit(), "expected clean exit" 1ljc

824 return result 1ljc

825 

826 @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS 

827 @Parametrize.KEY_INDEX 

828 def test_key_override_on_command_line( 

829 self, 

830 use_stub_agent_with_address: None, 

831 config: _types.VaultConfig, 

832 key_index: int, 

833 ) -> None: 

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

835 del use_stub_agent_with_address 1l

836 result = self._test( 1l

837 ["-k", "--", DUMMY_SERVICE], 

838 config=config, 

839 input=f"{key_index}\n", 

840 ) 

841 assert result.stdout, "expected program output" 1l

842 assert result.stderr, "expected stderr" 1l

843 assert "Error:" not in result.stderr, ( 1l

844 "expected no error messages on stderr" 

845 ) 

846 

847 def test_service_phrase_if_key_in_global_config( 

848 self, 

849 use_stub_agent_with_address: None, 

850 ) -> None: 

851 """A configured passphrase does not override a configured key.""" 

852 del use_stub_agent_with_address 1j

853 result = self._test( 1j

854 ["--", DUMMY_SERVICE], 

855 config={ 

856 "global": {"key": DUMMY_KEY1_B64}, 

857 "services": { 

858 DUMMY_SERVICE: { 

859 "phrase": DUMMY_PASSPHRASE.rstrip("\n"), 

860 **DUMMY_CONFIG_SETTINGS, 

861 } 

862 }, 

863 }, 

864 ) 

865 assert result.stdout, "expected program output" 1j

866 last_line = result.stdout.splitlines(True)[-1] 1j

867 assert ( 1j

868 last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE 

869 ), "known false output: phrase-based instead of key-based" 

870 assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, ( 1j

871 "expected known output" 

872 ) 

873 

874 @Parametrize.KEY_OVERRIDING_IN_CONFIG 

875 def test_setting_phrase_thus_overriding_key_in_config( 

876 self, 

877 use_stub_agent_with_address: None, 

878 caplog: pytest.LogCaptureFixture, 

879 config: _types.VaultConfig, 

880 command_line: list[str], 

881 ) -> None: 

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

883 del use_stub_agent_with_address 1c

884 result = self._test( 1c

885 command_line, config=config, input=DUMMY_PASSPHRASE 

886 ) 

887 assert not result.stdout.strip(), "expected no program output" 1c

888 assert result.stderr, "expected known error output" 1c

889 # First line contains passphrase prompt. Depending on the click 

890 # version, either this is a standalone line, or the first warning 

891 # line is attached to the end. So it needs special treatment. 

892 err_lines = result.stderr.splitlines(True) 1c

893 prompt_line = err_lines[0] 1c

894 assert prompt_line.startswith("Passphrase:") 1c

895 assert machinery.warning_emitted( 1c

896 "Setting a service passphrase is ineffective ", 

897 caplog.record_tuples, 

898 ) or machinery.warning_emitted( 

899 "Setting a global passphrase is ineffective ", 

900 caplog.record_tuples, 

901 ), "expected known warning message" 

902 remainder = prompt_line.removeprefix("Passphrase:").lstrip() 1c

903 other_lines = ( 1c

904 [remainder, *err_lines[1:]] if remainder else err_lines[1:] 

905 ) 

906 assert all(map(is_warning_line, other_lines)) 1c

907 assert all( 1c

908 map(is_harmless_config_import_warning, caplog.record_tuples) 

909 ), "unexpected error output" 

910 

911 

912class TestInvalidCommandLines: 

913 """Tests concerning invalid command-lines.""" 

914 

915 @contextlib.contextmanager 

916 def _setup_environment( 

917 self, 

918 /, 

919 *, 

920 auto_prompt: bool = False, 

921 config: _types.VaultConfig = { # noqa: B006 

922 "services": { 

923 DUMMY_SERVICE: {**DUMMY_CONFIG_SETTINGS}, 

924 }, 

925 }, 

926 ) -> Generator[machinery.CliRunner, None, None]: 

927 runner = machinery.CliRunner(mix_stderr=False) 1bnropg

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

929 # with-statements. 

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

931 with contextlib.ExitStack() as stack: 1bnropg

932 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1bnropg

933 stack.enter_context( 1bnropg

934 pytest_machinery.isolated_vault_config( 

935 monkeypatch=monkeypatch, 

936 runner=runner, 

937 vault_config=config, 

938 ) 

939 ) 

940 if auto_prompt: 1bnropg

941 monkeypatch.setattr( 1bnopg

942 cli_helpers, 

943 "prompt_for_passphrase", 

944 callables.auto_prompt, 

945 ) 

946 yield runner 1bnropg

947 

948 def _call( 

949 self, 

950 command_line: list[str], 

951 /, 

952 *, 

953 config: _types.VaultConfig = { # noqa: B006 

954 "services": { 

955 DUMMY_SERVICE: {**DUMMY_CONFIG_SETTINGS}, 

956 }, 

957 }, 

958 input: str | bytes | None = None, 

959 runner: machinery.CliRunner | None = None, 

960 ) -> machinery.ReadableResult: 

961 if runner: 1bnopg

962 return runner.invoke( 1b

963 cli.derivepassphrase_vault, 

964 command_line, 

965 input=input, 

966 catch_exceptions=False, 

967 ) 

968 with self._setup_environment( 1nopg

969 config=config, auto_prompt=input is not None 

970 ) as runner2: 

971 return runner2.invoke( 1nopg

972 cli.derivepassphrase_vault, 

973 command_line, 

974 input=input, 

975 catch_exceptions=False, 

976 ) 

977 

978 @Parametrize.VAULT_CHARSET_OPTION 

979 def test_invalid_argument_range( 

980 self, 

981 option: str, 

982 ) -> None: 

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

984 with self._setup_environment() as runner: 1r

985 for value in "-42", "invalid": 1r

986 result = runner.invoke( 1r

987 cli.derivepassphrase_vault, 

988 [option, value, "-p", "--", DUMMY_SERVICE], 

989 input=DUMMY_PASSPHRASE, 

990 catch_exceptions=False, 

991 ) 

992 assert result.error_exit(error="Invalid value"), ( 1r

993 "expected error exit and known error message" 

994 ) 

995 

996 @Parametrize.OPTION_COMBINATIONS_SERVICE_NEEDED 

997 def test_service_needed( 

998 self, 

999 options: list[str], 

1000 needs_service: bool | None, 

1001 input: str | None, 

1002 check_success: bool, 

1003 ) -> None: 

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

1005 config: _types.VaultConfig = { 1g

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

1007 "services": {}, 

1008 } 

1009 result = self._call( 1g

1010 options if needs_service else [*options, "--", DUMMY_SERVICE], 

1011 config=config, 

1012 input=input, 

1013 ) 

1014 if needs_service is not None: 1g

1015 err_msg = ( 1g

1016 " requires a SERVICE" 

1017 if needs_service 

1018 else " does not take a SERVICE argument" 

1019 ) 

1020 assert result.error_exit(error=err_msg), ( 1g

1021 "expected error exit and known error message" 

1022 ) 

1023 if check_success: 1g

1024 result = self._call( 1g

1025 [*options, "--", DUMMY_SERVICE] 

1026 if needs_service 

1027 else options, 

1028 config=config, 

1029 input=input, 

1030 ) 

1031 assert result.clean_exit(empty_stderr=True), ( 1g

1032 "expected clean exit" 

1033 ) 

1034 else: 

1035 assert result.clean_exit(empty_stderr=True), "expected clean exit" 1g

1036 

1037 def test_empty_service_name_causes_warning( 

1038 self, 

1039 caplog: pytest.LogCaptureFixture, 

1040 ) -> None: 

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

1042 

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

1044 

1045 """ 

1046 

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

1048 return is_harmless_config_import_warning( 1b

1049 record 

1050 ) or machinery.warning_emitted( 

1051 "An empty SERVICE is not supported by vault(1)", [record] 

1052 ) 

1053 

1054 def check_result(result: machinery.ReadableResult) -> None: 1b

1055 assert result.clean_exit(empty_stderr=False), "expected clean exit" 1b

1056 assert result.stderr is not None, "expected known error output" 1b

1057 assert all(map(is_expected_warning, caplog.record_tuples)), ( 1b

1058 "expected known error output" 

1059 ) 

1060 

1061 with self._setup_environment( 1b

1062 config={"services": {}}, auto_prompt=True 

1063 ) as runner: 

1064 result = self._call( 1b

1065 ["--config", "--length=30", "--", ""], runner=runner 

1066 ) 

1067 check_result(result) 1b

1068 assert cli_helpers.load_config() == { 1b

1069 "global": {"length": 30}, 

1070 "services": {}, 

1071 }, "requested configuration change was not applied" 

1072 caplog.clear() 1b

1073 result = self._call( 1b

1074 ["--import", "-"], 

1075 input=json.dumps({"services": {"": {"length": 40}}}), 

1076 runner=runner, 

1077 ) 

1078 check_result(result) 1b

1079 assert cli_helpers.load_config() == { 1b

1080 "global": {"length": 30}, 

1081 "services": {"": {"length": 40}}, 

1082 }, "requested configuration change was not applied" 

1083 

1084 @Parametrize.OPTION_COMBINATIONS_INCOMPATIBLE 

1085 def test_incompatible_options( 

1086 self, 

1087 options: list[str], 

1088 service: bool | None, 

1089 ) -> None: 

1090 """Incompatible options are detected.""" 

1091 result = self._call( 1n

1092 [*options, "--", DUMMY_SERVICE] if service else options, 

1093 input=DUMMY_PASSPHRASE, 

1094 ) 

1095 assert result.error_exit(error="mutually exclusive with "), ( 1n

1096 "expected error exit and known error message" 

1097 ) 

1098 

1099 def test_no_arguments(self) -> None: 

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

1101 result = self._call([], input=DUMMY_PASSPHRASE) 1o

1102 assert result.error_exit( 1o

1103 error="Deriving a passphrase requires a SERVICE" 

1104 ), "expected error exit and known error message" 

1105 

1106 def test_no_passphrase_or_key( 

1107 self, 

1108 ) -> None: 

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

1110 result = self._call(["--", DUMMY_SERVICE], input=DUMMY_PASSPHRASE) 1p

1111 assert result.error_exit(error="No passphrase or key was given"), ( 1p

1112 "expected error exit and known error message" 

1113 )