Coverage for tests/test_derivepassphrase_cli.py: 99.778%

356 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2024-07-14 11:39 +0200

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

2# 

3# SPDX-License-Identifier: MIT 

4 

5from __future__ import annotations 

6 

7from collections.abc import Callable 

8import json 

9import os 

10import socket 

11from typing_extensions import Any, cast, NamedTuple 

12 

13import click.testing 

14import derivepassphrase as dpp 

15import derivepassphrase.cli as cli 

16import ssh_agent_client.types 

17import pytest 

18import tests 

19 

20DUMMY_SERVICE = tests.DUMMY_SERVICE 

21DUMMY_PASSPHRASE = tests.DUMMY_PASSPHRASE 

22DUMMY_CONFIG_SETTINGS = tests.DUMMY_CONFIG_SETTINGS 

23DUMMY_RESULT_PASSPHRASE = tests.DUMMY_RESULT_PASSPHRASE 

24DUMMY_RESULT_KEY1 = tests.DUMMY_RESULT_KEY1 

25DUMMY_PHRASE_FROM_KEY1_RAW = tests.DUMMY_PHRASE_FROM_KEY1_RAW 

26DUMMY_PHRASE_FROM_KEY1 = tests.DUMMY_PHRASE_FROM_KEY1 

27 

28DUMMY_KEY1 = tests.DUMMY_KEY1 

29DUMMY_KEY1_B64 = tests.DUMMY_KEY1_B64 

30DUMMY_KEY2 = tests.DUMMY_KEY2 

31 

32 

33class IncompatibleConfiguration(NamedTuple): 

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

35 needs_service: bool | None 

36 input: bytes | None 

37 

38class SingleConfiguration(NamedTuple): 

39 needs_service: bool | None 

40 input: bytes | None 

41 check_success: bool 

42 

43class OptionCombination(NamedTuple): 

44 options: list[str] 

45 incompatible: bool 

46 needs_service: bool | None 

47 input: bytes | None 

48 check_success: bool 

49 

50PASSWORD_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] 

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

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

57 ('--clear',) 

58] 

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

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

61] 

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

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

64] 

65INCOMPATIBLE: 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} 

114SINGLES: 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} 

133INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = [] 

134config: IncompatibleConfiguration | SingleConfiguration 

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

145for 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 

153class 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 - - - - - 

527contents 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 

713class 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 == '''\ 

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

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

764A 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 

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

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

809Great! 

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 

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

819Boo. 

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'