Coverage for tests/test_derivepassphrase_cli.py: 99.778%

1# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>

2#

3# SPDX-License-Identifier: MIT

4

5 from __future__ import annotations

6

7 from collections.abc import Callable

8 import json

9 import os

10 import socket

11 from typing_extensions import Any, cast, NamedTuple

12

13 import click.testing

14 import derivepassphrase as dpp

15 import derivepassphrase.cli as cli

16 import ssh_agent_client.types

17 import pytest

18 import tests

19

20 DUMMY_SERVICE = tests.DUMMY_SERVICE

21 DUMMY_PASSPHRASE = tests.DUMMY_PASSPHRASE

22 DUMMY_CONFIG_SETTINGS = tests.DUMMY_CONFIG_SETTINGS

23 DUMMY_RESULT_PASSPHRASE = tests.DUMMY_RESULT_PASSPHRASE

24 DUMMY_RESULT_KEY1 = tests.DUMMY_RESULT_KEY1

25 DUMMY_PHRASE_FROM_KEY1_RAW = tests.DUMMY_PHRASE_FROM_KEY1_RAW

26 DUMMY_PHRASE_FROM_KEY1 = tests.DUMMY_PHRASE_FROM_KEY1

27

28 DUMMY_KEY1 = tests.DUMMY_KEY1

29 DUMMY_KEY1_B64 = tests.DUMMY_KEY1_B64

30 DUMMY_KEY2 = tests.DUMMY_KEY2

31

32

33 class IncompatibleConfiguration(NamedTuple):

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

35 needs_service: bool | None

36 input: bytes | None

37

38 class SingleConfiguration(NamedTuple):

39 needs_service: bool | None

40 input: bytes | None

41 check_success: bool

42

43 class OptionCombination(NamedTuple):

44 options: list[str]

45 incompatible: bool

46 needs_service: bool | None

47 input: bytes | None

48 check_success: bool

49

50 PASSWORD_GENERATION_OPTIONS: list[tuple[str, ...]] = [

51 ('--phrase',), ('--key',), ('--length', '20'), ('--repeat', '20'),

52 ('--lower', '1'), ('--upper', '1'), ('--number', '1'),

53 ('--space', '1'), ('--dash', '1'), ('--symbol', '1')

54]

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

56 ('--notes',), ('--config',), ('--delete',), ('--delete-globals',),

57 ('--clear',)

58]

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

60 ('--notes',), ('--delete',), ('--delete-globals',), ('--clear',)

61]

62 STORAGE_OPTIONS: list[tuple[str, ...]] = [

63 ('--export', '-'), ('--import', '-')

64]

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

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

67 [('--key',)] + CONFIGURATION_COMMANDS + STORAGE_OPTIONS,

68 True, DUMMY_PASSPHRASE),

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

70 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,

71 True, DUMMY_PASSPHRASE),

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

73 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,

74 True, DUMMY_PASSPHRASE),

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

76 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,

77 True, DUMMY_PASSPHRASE),

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

79 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,

80 True, DUMMY_PASSPHRASE),

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

82 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,

83 True, DUMMY_PASSPHRASE),

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

85 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,

86 True, DUMMY_PASSPHRASE),

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

88 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,

89 True, DUMMY_PASSPHRASE),

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

91 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,

92 True, DUMMY_PASSPHRASE),

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

94 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,

95 True, DUMMY_PASSPHRASE),

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

97 [('--config',), ('--delete',), ('--delete-globals',),

98 ('--clear',)] + STORAGE_OPTIONS,

99 True, None),

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

101 [('--delete',), ('--delete-globals',),

102 ('--clear',)] + STORAGE_OPTIONS,

103 None, DUMMY_PASSPHRASE),

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

105 [('--delete-globals',), ('--clear',)] + STORAGE_OPTIONS, True, None),

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

107 [('--clear',)] + STORAGE_OPTIONS, False, None),

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

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

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

111 ('--import', '-'): IncompatibleConfiguration(

112 [], False, None),

113}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

132}

133 INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = []

134 config: IncompatibleConfiguration | SingleConfiguration

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

136 for opt2 in config.other_options:

137 INTERESTING_OPTION_COMBINATIONS.extend([

138 OptionCombination(options=list(opt + opt2), incompatible=True,

139 needs_service=config.needs_service,

140 input=config.input, check_success=False),

141 OptionCombination(options=list(opt2 + opt), incompatible=True,

142 needs_service=config.needs_service,

143 input=config.input, check_success=False)

144 ])

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

146 INTERESTING_OPTION_COMBINATIONS.append(

147 OptionCombination(options=list(opt), incompatible=False,

148 needs_service=config.needs_service,

149 input=config.input,

150 check_success=config.check_success))

151

152

153 class TestCLI:

154 def test_200_help_output(self):

155 runner = click.testing.CliRunner(mix_stderr=False)

156 result = runner.invoke(cli.derivepassphrase, ['--help'],

157 catch_exceptions=False)

158 assert result.exit_code == 0

159 assert 'Password generation:\n' in result.output, (

160 'Option groups not respected in help text.'

161 )

162 assert 'Use NUMBER=0, e.g. "--symbol 0"' in result.output, (

163 'Option group epilog not printed.'

164 )

165

166 @pytest.mark.parametrize(['charset_name'],

167 [('lower',), ('upper',), ('number',), ('space',),

168 ('dash',), ('symbol',)])

169 def test_201_disable_character_set(

170 self, monkeypatch: Any, charset_name: str

171 ) -> None:

172 monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)

173 option = f'--{charset_name}'

174 charset = dpp.Vault._CHARSETS[charset_name].decode('ascii')

175 runner = click.testing.CliRunner(mix_stderr=False)

176 result = runner.invoke(cli.derivepassphrase,

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

178 input=DUMMY_PASSPHRASE, catch_exceptions=False)

179 assert result.exit_code == 0, (

180 f'program died unexpectedly with exit code {result.exit_code}'

181 )

182 assert not result.stderr_bytes, (

183 f'program barfed on stderr: {result.stderr_bytes!r}'

184 )

185 for c in charset:

186 assert c not in result.stdout, (

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

188 f'{result.stdout!r}'

189 )

190

191 def test_202_disable_repetition(self, monkeypatch: Any) -> None:

192 monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)

193 runner = click.testing.CliRunner(mix_stderr=False)

194 result = runner.invoke(cli.derivepassphrase,

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

196 input=DUMMY_PASSPHRASE, catch_exceptions=False)

197 assert result.exit_code == 0, (

198 f'program died unexpectedly with exit code {result.exit_code}'

199 )

200 assert not result.stderr_bytes, (

201 f'program barfed on stderr: {result.stderr_bytes!r}'

202 )

203 passphrase = result.stdout.rstrip('\r\n')

204 for i in range(len(passphrase) - 1):

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

206 f'derived password contains repeated character '

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

208 )

209

210 @pytest.mark.parametrize(['config'], [

211 pytest.param({'global': {'key': DUMMY_KEY1_B64},

212 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}},

213 id='global'),

214 pytest.param({'global': {'phrase': DUMMY_PASSPHRASE.rstrip(b'\n')

215 .decode('ASCII')},

216 'services': {DUMMY_SERVICE: {'key': DUMMY_KEY1_B64,

217 **DUMMY_CONFIG_SETTINGS}}},

218 id='service'),

219 ])

220 def test_204a_key_from_config(

221 self, monkeypatch: Any, config: dpp.types.VaultConfig,

222 ) -> None:

223 runner = click.testing.CliRunner(mix_stderr=False)

224 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

225 config=config):

226 monkeypatch.setattr(dpp.Vault, 'phrase_from_key',

227 tests.phrase_from_key)

228 result = runner.invoke(cli.derivepassphrase, [DUMMY_SERVICE],

229 catch_exceptions=False)

230 assert (result.exit_code, result.stderr_bytes) == (0, b''), (

231 'program exited with failure'

232 )

233 assert (

234 result.stdout_bytes.rstrip(b'\n') != DUMMY_RESULT_PASSPHRASE

235 ), (

236 'program generated unexpected result (phrase instead of key)'

237 )

238 assert result.stdout_bytes.rstrip(b'\n') == DUMMY_RESULT_KEY1, (

239 'program generated unexpected result (wrong settings?)'

240 )

241

242 def test_204b_key_from_command_line(self, monkeypatch: Any) -> None:

243 runner = click.testing.CliRunner(mix_stderr=False)

244 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

245 config={'services': {DUMMY_SERVICE:

246 DUMMY_CONFIG_SETTINGS}}):

247 monkeypatch.setattr(cli, '_get_suitable_ssh_keys',

248 tests.suitable_ssh_keys)

249 monkeypatch.setattr(dpp.Vault, 'phrase_from_key',

250 tests.phrase_from_key)

251 result = runner.invoke(cli.derivepassphrase,

252 ['-k', DUMMY_SERVICE],

253 input=b'1\n', catch_exceptions=False)

254 assert result.exit_code == 0, 'program exited with failure'

255 assert result.stdout_bytes, 'program output expected'

256 last_line = result.stdout_bytes.splitlines(True)[-1]

257 assert last_line.rstrip(b'\n') != DUMMY_RESULT_PASSPHRASE, (

258 'program generated unexpected result (phrase instead of key)'

259 )

260 assert last_line.rstrip(b'\n') == DUMMY_RESULT_KEY1, (

261 'program generated unexpected result (wrong settings?)'

262 )

263

264 def test_205_service_phrase_if_key_in_global_config(

265 self, monkeypatch: Any,

266 ) -> None:

267 runner = click.testing.CliRunner(mix_stderr=False)

268 with tests.isolated_config(

269 monkeypatch=monkeypatch, runner=runner,

270 config={

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

272 'services': {

273 DUMMY_SERVICE: {

274 'phrase': DUMMY_PASSPHRASE.rstrip(b'\n')

275 .decode('ASCII'),

276 **DUMMY_CONFIG_SETTINGS}}}

277 ):

278 result = runner.invoke(cli.derivepassphrase, [DUMMY_SERVICE],

279 catch_exceptions=False)

280 assert result.exit_code == 0, 'program exited with failure'

281 assert result.stdout_bytes, 'program output expected'

282 last_line = result.stdout_bytes.splitlines(True)[-1]

283 assert last_line.rstrip(b'\n') != DUMMY_RESULT_KEY1, (

284 'program generated unexpected result (key instead of phrase)'

285 )

286 assert last_line.rstrip(b'\n') == DUMMY_RESULT_PASSPHRASE, (

287 'program generated unexpected result (wrong settings?)'

288 )

289

290 @pytest.mark.parametrize(['option'],

291 [('--lower',), ('--upper',), ('--number',),

292 ('--space',), ('--dash',), ('--symbol',),

293 ('--repeat',), ('--length',)])

294 def test_210_invalid_argument_range(self, option: str) -> None:

295 runner = click.testing.CliRunner(mix_stderr=False)

296 value: str | int

297 for value in '-42', 'invalid':

298 result = runner.invoke(cli.derivepassphrase,

299 [option, cast(str, value), '-p',

300 DUMMY_SERVICE],

301 input=DUMMY_PASSPHRASE,

302 catch_exceptions=False)

303 assert result.exit_code > 0, (

304 'program unexpectedly succeeded'

305 )

306 assert result.stderr_bytes, (

307 'program did not print any error message'

308 )

309 assert b'Error: Invalid value' in result.stderr_bytes, (

310 'program did not print the expected error message'

311 )

312

313 @pytest.mark.parametrize(

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

315 [(o.options, o.needs_service, o.input, o.check_success)

316 for o in INTERESTING_OPTION_COMBINATIONS if not o.incompatible],

317 )

318 def test_211_service_needed(

319 self, monkeypatch: Any, options: list[str],

320 service: bool | None, input: bytes | None, check_success: bool,

321 ) -> None:

322 monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)

323 runner = click.testing.CliRunner(mix_stderr=False)

324 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

325 config={'global': {'phrase': 'abc'},

326 'services': {}}):

327 result = runner.invoke(cli.derivepassphrase,

328 options if service

329 else options + [DUMMY_SERVICE],

330 input=input, catch_exceptions=False)

331 if service is not None:

332 assert result.exit_code > 0, (

333 'program unexpectedly succeeded'

334 )

335 assert result.stderr_bytes, (

336 'program did not print any error message'

337 )

338 err_msg = (b' requires a SERVICE' if service

339 else b' does not take a SERVICE argument')

340 assert err_msg in result.stderr_bytes, (

341 'program did not print the expected error message'

342 )

343 else:

344 assert (result.exit_code, result.stderr_bytes) == (0, b''), (

345 'program unexpectedly failed'

346 )

347 if check_success:

348 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

349 config={'global': {'phrase': 'abc'},

350 'services': {}}):

351 monkeypatch.setattr(cli, '_prompt_for_passphrase',

352 tests.auto_prompt)

353 result = runner.invoke(cli.derivepassphrase,

354 options + [DUMMY_SERVICE]

355 if service else options,

356 input=input, catch_exceptions=False)

357 assert (result.exit_code, result.stderr_bytes) == (0, b''), (

358 'program unexpectedly failed'

359 )

360

361 @pytest.mark.parametrize(

362 ['options', 'service', 'input'],

363 [(o.options, o.needs_service, o.input)

364 for o in INTERESTING_OPTION_COMBINATIONS if o.incompatible],

365 )

366 def test_212_incompatible_options(

367 self, options: list[str], service: bool | None, input: bytes | None,

368 ) -> None:

369 runner = click.testing.CliRunner(mix_stderr=False)

370 result = runner.invoke(cli.derivepassphrase,

371 options + [DUMMY_SERVICE] if service

372 else options,

373 input=DUMMY_PASSPHRASE, catch_exceptions=False)

374 assert result.exit_code > 0, (

375 'program unexpectedly succeeded'

376 )

377 assert result.stderr_bytes, (

378 'program did not print any error message'

379 )

380 assert b'mutually exclusive with ' in result.stderr_bytes, (

381 'program did not print the expected error message'

382 )

383

384 def test_213_import_bad_config_not_vault_config(

385 self, monkeypatch: Any,

386 ) -> None:

387 runner = click.testing.CliRunner(mix_stderr=False)

388 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

389 config={'services': {}}):

390 result = runner.invoke(cli.derivepassphrase, ['--import', '-'],

391 input=b'null', catch_exceptions=False)

392 assert result.exit_code > 0, (

393 'program unexpectedly succeeded'

394 )

395 assert result.stderr_bytes, (

396 'program did not print any error message'

397 )

398 assert b'not a valid config' in result.stderr_bytes, (

399 'program did not print the expected error message'

400 )

401

402 def test_213a_import_bad_config_not_json_data(

403 self, monkeypatch: Any,

404 ) -> None:

405 runner = click.testing.CliRunner(mix_stderr=False)

406 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

407 config={'services': {}}):

408 result = runner.invoke(cli.derivepassphrase, ['--import', '-'],

409 input=b'This string is not valid JSON.',

410 catch_exceptions=False)

411 assert result.exit_code > 0, (

412 'program unexpectedly succeeded'

413 )

414 assert result.stderr_bytes, (

415 'program did not print any error message'

416 )

417 assert b'cannot decode JSON' in result.stderr_bytes, (

418 'program did not print the expected error message'

419 )

420

421 def test_213b_import_bad_config_not_a_file(

422 self, monkeypatch: Any,

423 ) -> None:

424 runner = click.testing.CliRunner(mix_stderr=False)

425 # `isolated_config` validates the configuration. So, to pass an

426 # actual broken configuration, we must open the configuration file

427 # ourselves afterwards, inside the context.

428 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

429 config={'services': {}}):

430 with open(cli._config_filename(), 'wt') as outfile:

431 print('This string is not valid JSON.', file=outfile)

432 dname = os.path.dirname(cli._config_filename())

433 result = runner.invoke(

434 cli.derivepassphrase,

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

436 catch_exceptions=False)

437 assert result.exit_code > 0, (

438 'program unexpectedly succeeded'

439 )

440 assert result.stderr_bytes, (

441 'program did not print any error message'

442 )

443 # Don't test the actual error message, because it is subject to

444 # locale settings. TODO: find a way anyway.

445

446 def test_214_export_settings_no_stored_settings(

447 self, monkeypatch: Any,

448 ) -> None:

449 runner = click.testing.CliRunner(mix_stderr=False)

450 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

451 config={'services': {}}):

452 try:

453 os.remove(cli._config_filename())

454 except FileNotFoundError: # pragma: no cover

455 pass

456 result = runner.invoke(cli.derivepassphrase, ['--export', '-'],

457 catch_exceptions=False)

458 assert (result.exit_code, result.stderr_bytes) == (0, b''), (

459 'program exited with failure'

460 )

461

462 def test_214a_export_settings_bad_stored_config(

463 self, monkeypatch: Any,

464 ) -> None:

465 runner = click.testing.CliRunner(mix_stderr=False)

466 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

467 config={}):

468 result = runner.invoke(cli.derivepassphrase, ['--export', '-'],

469 input=b'null', catch_exceptions=False)

470 assert result.exit_code > 0, (

471 'program unexpectedly succeeded'

472 )

473 assert result.stderr_bytes, (

474 'program did not print any error message'

475 )

476 assert b'cannot load config' in result.stderr_bytes, (

477 'program did not print the expected error message'

478 )

479

480 def test_214b_export_settings_not_a_file(

481 self, monkeypatch: Any,

482 ) -> None:

483 runner = click.testing.CliRunner(mix_stderr=False)

484 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

485 config={'services': {}}):

486 try:

487 os.remove(cli._config_filename())

488 except FileNotFoundError: # pragma: no cover

489 pass

490 os.makedirs(cli._config_filename())

491 result = runner.invoke(cli.derivepassphrase, ['--export', '-'],

492 input=b'null', catch_exceptions=False)

493 assert result.exit_code > 0, (

494 'program unexpectedly succeeded'

495 )

496 assert result.stderr_bytes, (

497 'program did not print any error message'

498 )

499 assert b'cannot load config' in result.stderr_bytes, (

500 'program did not print the expected error message'

501 )

502

503 def test_214c_export_settings_target_not_a_file(

504 self, monkeypatch: Any,

505 ) -> None:

506 runner = click.testing.CliRunner(mix_stderr=False)

507 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

508 config={'services': {}}):

509 dname = os.path.dirname(cli._config_filename())

510 result = runner.invoke(cli.derivepassphrase,

511 ['--export', os.fsdecode(dname)],

512 input=b'null', catch_exceptions=False)

513 assert result.exit_code > 0, (

514 'program unexpectedly succeeded'

515 )

516 assert result.stderr_bytes, (

517 'program did not print any error message'

518 )

519 assert b'cannot write config' in result.stderr_bytes, (

520 'program did not print the expected error message'

521 )

522

523 def test_220_edit_notes_successfully(self, monkeypatch: Any) -> None:

524 edit_result = '''

525

526# - - - - - >8 - - - - - >8 - - - - - >8 - - - - - >8 - - - - -

527 contents go here

528'''

529 runner = click.testing.CliRunner(mix_stderr=False)

530 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

531 config={'global': {'phrase': 'abc'},

532 'services': {}}):

533 monkeypatch.setattr(click, 'edit',

534 lambda *a, **kw: edit_result)

535 result = runner.invoke(cli.derivepassphrase, ['--notes', 'sv'],

536 catch_exceptions=False)

537 assert (result.exit_code, result.stderr_bytes) == (0, b''), (

538 'program exited with failure'

539 )

540 with open(cli._config_filename(), 'rt') as infile:

541 config = json.load(infile)

542 assert config == {'global': {'phrase': 'abc'},

543 'services': {'sv': {'notes':

544 'contents go here'}}}

545

546 def test_221_edit_notes_noop(self, monkeypatch: Any) -> None:

547 runner = click.testing.CliRunner(mix_stderr=False)

548 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

549 config={'global': {'phrase': 'abc'},

550 'services': {}}):

551 monkeypatch.setattr(click, 'edit', lambda *a, **kw: None)

552 result = runner.invoke(cli.derivepassphrase, ['--notes', 'sv'],

553 catch_exceptions=False)

554 assert (result.exit_code, result.stderr_bytes) == (0, b''), (

555 'program exited with failure'

556 )

557 with open(cli._config_filename(), 'rt') as infile:

558 config = json.load(infile)

559 assert config == {'global': {'phrase': 'abc'}, 'services': {}}

560

561 def test_222_edit_notes_marker_removed(self, monkeypatch: Any) -> None:

562 runner = click.testing.CliRunner(mix_stderr=False)

563 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

564 config={'global': {'phrase': 'abc'},

565 'services': {}}):

566 monkeypatch.setattr(click, 'edit', lambda *a, **kw: 'long\ntext')

567 result = runner.invoke(cli.derivepassphrase, ['--notes', 'sv'],

568 catch_exceptions=False)

569 assert (result.exit_code, result.stderr_bytes) == (0, b''), (

570 'program exited with failure'

571 )

572 with open(cli._config_filename(), 'rt') as infile:

573 config = json.load(infile)

574 assert config == {'global': {'phrase': 'abc'},

575 'services': {'sv': {'notes': 'long\ntext'}}}

576

577 def test_223_edit_notes_abort(self, monkeypatch: Any) -> None:

578 runner = click.testing.CliRunner(mix_stderr=False)

579 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

580 config={'global': {'phrase': 'abc'},

581 'services': {}}):

582 monkeypatch.setattr(click, 'edit', lambda *a, **kw: '\n\n')

583 result = runner.invoke(cli.derivepassphrase, ['--notes', 'sv'],

584 catch_exceptions=False)

585 assert result.exit_code != 0, 'program unexpectedly succeeded'

586 assert result.stderr_bytes is not None

587 assert b'user aborted request' in result.stderr_bytes, (

588 'expected error message missing'

589 )

590 with open(cli._config_filename(), 'rt') as infile:

591 config = json.load(infile)

592 assert config == {'global': {'phrase': 'abc'}, 'services': {}}

593

594 @pytest.mark.parametrize(['command_line', 'input', 'result_config'], [

595 (

596 ['--phrase'],

597 b'my passphrase\n',

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

599 ),

600 (

601 ['--key'],

602 b'1\n',

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

604 ),

605 (

606 ['--phrase', 'sv'],

607 b'my passphrase\n',

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

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

610 ),

611 (

612 ['--key', 'sv'],

613 b'1\n',

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

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

616 ),

617 (

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

619 b'1\n',

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

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

622 ),

623 ])

624 def test_224_store_config_good(

625 self, monkeypatch: Any, command_line: list[str], input: bytes,

626 result_config: Any,

627 ) -> None:

628 runner = click.testing.CliRunner(mix_stderr=False)

629 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

630 config={'global': {'phrase': 'abc'},

631 'services': {}}):

632 monkeypatch.setattr(cli, '_get_suitable_ssh_keys',

633 tests.suitable_ssh_keys)

634 result = runner.invoke(cli.derivepassphrase,

635 ['--config'] + command_line,

636 catch_exceptions=False, input=input)

637 assert result.exit_code == 0, 'program exited with failure'

638 with open(cli._config_filename(), 'rt') as infile:

639 config = json.load(infile)

640 assert config == result_config, (

641 'stored config does not match expectation'

642 )

643

644 @pytest.mark.parametrize(['command_line', 'input', 'err_text'], [

645 ([], b'', b'cannot update global settings without actual settings'),

646 (

647 ['sv'],

648 b'',

649 b'cannot update service settings without actual settings',

650 ),

651 (['--phrase', 'sv'], b'', b'no passphrase given'),

652 (['--key'], b'', b'no valid SSH key selected'),

653 ])

654 def test_225_store_config_fail(

655 self, monkeypatch: Any, command_line: list[str],

656 input: bytes, err_text: bytes,

657 ) -> None:

658 runner = click.testing.CliRunner(mix_stderr=False)

659 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

660 config={'global': {'phrase': 'abc'},

661 'services': {}}):

662 monkeypatch.setattr(cli, '_get_suitable_ssh_keys',

663 tests.suitable_ssh_keys)

664 result = runner.invoke(cli.derivepassphrase,

665 ['--config'] + command_line,

666 catch_exceptions=False, input=input)

667 assert result.exit_code != 0, 'program unexpectedly succeeded?!'

668 assert result.stderr_bytes is not None

669 assert err_text in result.stderr_bytes, (

670 'expected error message missing'

671 )

672

673 def test_225a_store_config_fail_manual_no_ssh_key_selection(

674 self, monkeypatch: Any,

675 ) -> None:

676 runner = click.testing.CliRunner(mix_stderr=False)

677 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

678 config={'global': {'phrase': 'abc'},

679 'services': {}}):

680 def raiser():

681 raise RuntimeError('custom error message')

682 monkeypatch.setattr(cli, '_select_ssh_key', raiser)

683 result = runner.invoke(cli.derivepassphrase,

684 ['--key', '--config'],

685 catch_exceptions=False)

686 assert result.exit_code != 0, 'program unexpectedly succeeded'

687 assert result.stderr_bytes is not None

688 assert b'custom error message' in result.stderr_bytes, (

689 'expected error message missing'

690 )

691

692 def test_226_no_arguments(self) -> None:

693 runner = click.testing.CliRunner(mix_stderr=False)

694 result = runner.invoke(cli.derivepassphrase, [],

695 catch_exceptions=False)

696 assert result.exit_code != 0, 'program unexpectedly succeeded'

697 assert result.stderr_bytes is not None

698 assert b'SERVICE is required' in result.stderr_bytes, (

699 'expected error message missing'

700 )

701

702 def test_226a_no_passphrase_or_key(self) -> None:

703 runner = click.testing.CliRunner(mix_stderr=False)

704 result = runner.invoke(cli.derivepassphrase, [DUMMY_SERVICE],

705 catch_exceptions=False)

706 assert result.exit_code != 0, 'program unexpectedly succeeded'

707 assert result.stderr_bytes is not None

708 assert b'no passphrase or key given' in result.stderr_bytes, (

709 'expected error message missing'

710 )

711

712

713 class TestCLIUtils:

714

715 def test_100_save_bad_config(self, monkeypatch: Any) -> None:

716 runner = click.testing.CliRunner()

717 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,

718 config={}):

719 with pytest.raises(ValueError, match='Invalid vault config'):

720 cli._save_config(None) # type: ignore

721

722

723 def test_101_prompt_for_selection_multiple(self, monkeypatch: Any) -> None:

724 @click.command()

725 @click.option('--heading', default='Our menu:')

726 @click.argument('items', nargs=-1)

727 def driver(heading, items):

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

729 items = items or [

730 'Egg and bacon',

731 'Egg, sausage and bacon',

732 'Egg and spam',

733 'Egg, bacon and spam',

734 'Egg, bacon, sausage and spam',

735 'Spam, bacon, sausage and spam',

736 'Spam, egg, spam, spam, bacon and spam',

737 'Spam, spam, spam, egg and spam',

738 ('Spam, spam, spam, spam, spam, spam, baked beans, '

739 'spam, spam, spam and spam'),

740 ('Lobster thermidor aux crevettes with a mornay sauce '

741 'garnished with truffle paté, brandy '

742 'and a fried egg on top and spam'),

743 ]

744 index = cli._prompt_for_selection(items, heading=heading)

745 click.echo('A fine choice: ', nl=False)

746 click.echo(items[index])

747 click.echo('(Note: Vikings strictly optional.)')

748 runner = click.testing.CliRunner(mix_stderr=True)

749 result = runner.invoke(driver, [], input='9')

750 assert result.exit_code == 0, 'driver program failed'

751 assert result.stdout == '''\

752 Our menu:

753[1] Egg and bacon

754[2] Egg, sausage and bacon

755[3] Egg and spam

756[4] Egg, bacon and spam

757[5] Egg, bacon, sausage and spam

758[6] Spam, bacon, sausage and spam

759[7] Spam, egg, spam, spam, bacon and spam

760[8] Spam, spam, spam, egg and spam

761[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam

762[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam

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

764 A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam

765(Note: Vikings strictly optional.)

766''', 'driver program produced unexpected output'

767 result = runner.invoke(driver, ['--heading='], input='',

768 catch_exceptions=True)

769 assert result.exit_code > 0, 'driver program succeeded?!'

770 assert result.stdout == '''\

771[1] Egg and bacon

772[2] Egg, sausage and bacon

773[3] Egg and spam

774[4] Egg, bacon and spam

775[5] Egg, bacon, sausage and spam

776[6] Spam, bacon, sausage and spam

777[7] Spam, egg, spam, spam, bacon and spam

778[8] Spam, spam, spam, egg and spam

779[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam

780[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam

781 Your selection? (1-10, leave empty to abort): \n''', (

782 'driver program produced unexpected output'

783 )

784 assert isinstance(result.exception, IndexError), (

785 'driver program did not raise IndexError?!'

786 )

787

788

789 def test_102_prompt_for_selection_single(self, monkeypatch: Any) -> None:

790 @click.command()

791 @click.option('--item', default='baked beans')

792 @click.argument('prompt')

793 def driver(item, prompt):

794 try:

795 cli._prompt_for_selection([item], heading='',

796 single_choice_prompt=prompt)

797 except IndexError as e:

798 click.echo('Boo.')

799 raise e

800 else:

801 click.echo('Great!')

802 runner = click.testing.CliRunner(mix_stderr=True)

803 result = runner.invoke(driver, ['Will replace with spam. Confirm, y/n?'],

804 input='y')

805 assert result.exit_code == 0, 'driver program failed'

806 assert result.stdout == '''\

807[1] baked beans

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

809 Great!

810''', 'driver program produced unexpected output'

811 result = runner.invoke(driver,

812 ['Will replace with spam, okay? ' +

813 '(Please say "y" or "n".)'],

814 input='')

815 assert result.exit_code > 0, 'driver program succeeded?!'

816 assert result.stdout == '''\

817[1] baked beans

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

819 Boo.

820''', 'driver program produced unexpected output'

821 assert isinstance(result.exception, IndexError), (

822 'driver program did not raise IndexError?!'

823 )

824

825

826 def test_103_prompt_for_passphrase(self, monkeypatch: Any) -> None:

827 monkeypatch.setattr(click, 'prompt',

828 lambda *a, **kw: json.dumps({'args': a, 'kwargs': kw}))

829 res = json.loads(cli._prompt_for_passphrase())

830 assert 'args' in res and 'kwargs' in res, (

831 'missing arguments to passphrase prompt'

832 )

833 assert res['args'][:1] == ['Passphrase'], (

834 'missing arguments to passphrase prompt'

835 )

836 assert (res['kwargs'].get('default') == ''

837 and not res['kwargs'].get('show_default', True)), (

838 'missing arguments to passphrase prompt'

839 )

840 assert res['kwargs'].get('err') and res['kwargs'].get('hide_input'), (

841 'missing arguments to passphrase prompt'

842 )

843

844

845 @pytest.mark.parametrize(['command_line', 'config', 'result_config'], [

846 (['--delete-globals'],

847 {'global': {'phrase': 'abc'}, 'services': {}}, {'services': {}}),

848 (['--delete', DUMMY_SERVICE],

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

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

851 {'global': {'phrase': 'abc'}, 'services': {}}),

852 (['--clear'],

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

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

855 {'services': {}}),

856 ])

857 def test_203_repeated_config_deletion(

858 self, monkeypatch: Any, command_line: list[str],

859 config: dpp.types.VaultConfig, result_config: dpp.types.VaultConfig,

860 ) -> None:

861 runner = click.testing.CliRunner(mix_stderr=False)

862 for start_config in [config, result_config]:

863 with tests.isolated_config(monkeypatch=monkeypatch,

864 runner=runner, config=start_config):

865 result = runner.invoke(cli.derivepassphrase, command_line,

866 catch_exceptions=False)

867 assert (result.exit_code, result.stderr_bytes) == (0, b''), (

868 'program exited with failure'

869 )

870 with open(cli._config_filename(), 'rt') as infile:

871 config_readback = json.load(infile)

872 assert config_readback == result_config

873

874

875 def test_204_phrase_from_key_manually(self) -> None:

876 assert (

877 dpp.Vault(phrase=DUMMY_PHRASE_FROM_KEY1, **DUMMY_CONFIG_SETTINGS)

878 .generate(DUMMY_SERVICE) == DUMMY_RESULT_KEY1

879 )

880

881

882 @pytest.mark.parametrize(['vfunc', 'input'], [

883 (cli._validate_occurrence_constraint, 20),

884 (cli._validate_length, 20),

885 ])

886 def test_210a_validate_constraints_manually(

887 self,

888 vfunc: Callable[[click.Context, click.Parameter, Any], int | None],

889 input: int,

890 ) -> None:

891 ctx = cli.derivepassphrase.make_context(cli.prog_name, [])

892 param = cli.derivepassphrase.params[0]

893 assert vfunc(ctx, param, input) == input

894

895

896 @tests.skip_if_no_agent

897 @pytest.mark.parametrize(['conn_hint'],

898 [('none',), ('socket',), ('client',)])

899 def test_227_get_suitable_ssh_keys(

900 self, monkeypatch: Any, conn_hint: str,

901 ) -> None:

902 monkeypatch.setattr(ssh_agent_client.SSHAgentClient,

903 'list_keys', tests.list_keys)

904 hint: ssh_agent_client.SSHAgentClient | socket.socket | None

905 match conn_hint:

906 case 'client': 906 ↛ 908line 906 didn't jump to line 908 because the pattern on line 906 always matched

907 hint = ssh_agent_client.SSHAgentClient()

908 case 'socket':

909 hint = socket.socket(family=socket.AF_UNIX)

910 hint.connect(os.environ['SSH_AUTH_SOCK'])

911 case _:

912 assert conn_hint == 'none'

913 hint = None

914 exception: Exception | None = None

915 try:

916 list(cli._get_suitable_ssh_keys(hint))

917 except RuntimeError: # pragma: no cover

918 pass

919 except Exception as e: # pragma: no cover

920 exception = e

921 finally:

922 assert exception == None, 'exception querying suitable SSH keys'