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

143 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# TODO(the-13th-letter): Remove this module in v1.0. 

6# https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#upgrading-to-v1.0 

7 

8"""Tests for the `derivepassphrase` command-line interface: transitional functionality. 

9 

10This includes deprecation warnings for deprecated commands or options, 

11and functionality provided for ease of transition. 

12 

13""" 

14 

15from __future__ import annotations 

16 

17import contextlib 

18import errno 

19import json 

20import logging 

21import os 

22import pathlib 

23 

24import click.testing 

25import pytest 

26from typing_extensions import Any 

27 

28from derivepassphrase import cli, vault 

29from derivepassphrase._internals import ( 

30 cli_helpers, 

31) 

32from tests import data, machinery 

33from tests.data import callables 

34from tests.machinery import pytest as pytest_machinery 

35from tests.test_derivepassphrase_cli import ( 

36 test_utils, 

37 test_vault_cli_basic_functionality, 

38) 

39 

40DUMMY_SERVICE = data.DUMMY_SERVICE 

41DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE 

42DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS 

43 

44 

45class Parametrize( 

46 test_vault_cli_basic_functionality.Parametrize, test_utils.Parametrize 

47): 

48 """Common test parametrizations.""" 

49 

50 BAD_CONFIGS = pytest.mark.parametrize( 

51 "config", 

52 [ 

53 {"global": "", "services": {}}, 

54 {"global": 0, "services": {}}, 

55 { 

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

57 "services": False, 

58 }, 

59 { 

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

61 "services": True, 

62 }, 

63 { 

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

65 "services": None, 

66 }, 

67 ], 

68 ) 

69 

70 

71class TestConfigMigrationMachinery: 

72 """Tests for the configuration file migration machinery.""" 

73 

74 @Parametrize.BASE_CONFIG_VARIATIONS 

75 def test_load_config_backup( 

76 self, 

77 config: Any, 

78 ) -> None: 

79 """Loading the old settings file works.""" 

80 runner = machinery.CliRunner(mix_stderr=False) 1k

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

82 # with-statements. 

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

84 with contextlib.ExitStack() as stack: 1k

85 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1k

86 stack.enter_context( 1k

87 pytest_machinery.isolated_config( 

88 monkeypatch=monkeypatch, 

89 runner=runner, 

90 ) 

91 ) 

92 cli_helpers.config_filename( 1k

93 subsystem="old settings.json" 

94 ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8") 

95 assert cli_helpers.migrate_and_load_old_config()[0] == config 1k

96 

97 @Parametrize.BASE_CONFIG_VARIATIONS 

98 def test_migration( 

99 self, 

100 config: Any, 

101 ) -> None: 

102 """Migrating the old settings file works.""" 

103 runner = machinery.CliRunner(mix_stderr=False) 1l

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

105 # with-statements. 

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

107 with contextlib.ExitStack() as stack: 1l

108 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1l

109 stack.enter_context( 1l

110 pytest_machinery.isolated_config( 

111 monkeypatch=monkeypatch, 

112 runner=runner, 

113 ) 

114 ) 

115 cli_helpers.config_filename( 1l

116 subsystem="old settings.json" 

117 ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8") 

118 assert cli_helpers.migrate_and_load_old_config() == (config, None) 1l

119 

120 @Parametrize.BASE_CONFIG_VARIATIONS 

121 def test_migration_error( 

122 self, 

123 config: Any, 

124 ) -> None: 

125 """Migrating the old settings file atop a directory fails.""" 

126 runner = machinery.CliRunner(mix_stderr=False) 1e

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

128 # with-statements. 

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

130 with contextlib.ExitStack() as stack: 1e

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

132 stack.enter_context( 1e

133 pytest_machinery.isolated_config( 

134 monkeypatch=monkeypatch, 

135 runner=runner, 

136 ) 

137 ) 

138 cli_helpers.config_filename( 1e

139 subsystem="old settings.json" 

140 ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8") 

141 cli_helpers.config_filename(subsystem="vault").mkdir( 1e

142 parents=True, exist_ok=True 

143 ) 

144 config2, err = cli_helpers.migrate_and_load_old_config() 1e

145 assert config2 == config 1e

146 assert isinstance(err, OSError) 1e

147 # The Annoying OS uses EEXIST, other OSes use EISDIR. 

148 assert err.errno in {errno.EISDIR, errno.EEXIST} 1e

149 

150 @Parametrize.BAD_CONFIGS 

151 def test_bad_config_migration( 

152 self, 

153 config: Any, 

154 ) -> None: 

155 """Migrating an invalid old settings file fails.""" 

156 runner = machinery.CliRunner(mix_stderr=False) 1j

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

158 # with-statements. 

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

160 with contextlib.ExitStack() as stack: 1j

161 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1j

162 stack.enter_context( 1j

163 pytest_machinery.isolated_config( 

164 monkeypatch=monkeypatch, 

165 runner=runner, 

166 ) 

167 ) 

168 cli_helpers.config_filename( 1j

169 subsystem="old settings.json" 

170 ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8") 

171 with pytest.raises( 1j

172 ValueError, match=cli_helpers.INVALID_VAULT_CONFIG 

173 ): 

174 cli_helpers.migrate_and_load_old_config() 1j

175 

176 def test_completion_supports_old_config_file( 

177 self, 

178 ) -> None: 

179 """Completing service names from the old settings file works.""" 

180 config = {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}} 1f

181 runner = machinery.CliRunner(mix_stderr=False) 1f

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

183 # with-statements. 

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

185 with contextlib.ExitStack() as stack: 1f

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

187 stack.enter_context( 1f

188 pytest_machinery.isolated_vault_config( 

189 monkeypatch=monkeypatch, 

190 runner=runner, 

191 vault_config=config, 

192 ) 

193 ) 

194 old_name = cli_helpers.config_filename( 1f

195 subsystem="old settings.json" 

196 ) 

197 new_name = cli_helpers.config_filename(subsystem="vault") 1f

198 old_name.unlink(missing_ok=True) 1f

199 new_name.rename(old_name) 1f

200 assert cli_helpers.shell_complete_service( 1f

201 click.Context(cli.derivepassphrase), 

202 click.Argument(["some_parameter"]), 

203 "", 

204 ) == [DUMMY_SERVICE] 

205 

206 

207class TestArgumentForwarding: 

208 """Tests for the argument forwarding up to v1.0.""" 

209 

210 def test_forward_export_vault_path_parameter( 

211 self, 

212 caplog: pytest.LogCaptureFixture, 

213 ) -> None: 

214 """Forwarding arguments from "export" to "export vault" works.""" 

215 pytest.importorskip("cryptography", minversion="38.0") 1d

216 runner = machinery.CliRunner(mix_stderr=False) 1d

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

218 # with-statements. 

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

220 with contextlib.ExitStack() as stack: 1d

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

222 stack.enter_context( 1d

223 pytest_machinery.isolated_vault_exporter_config( 

224 monkeypatch=monkeypatch, 

225 runner=runner, 

226 vault_config=data.VAULT_V03_CONFIG, 

227 vault_key=data.VAULT_MASTER_KEY, 

228 ) 

229 ) 

230 monkeypatch.setenv("VAULT_KEY", data.VAULT_MASTER_KEY) 1d

231 result = runner.invoke( 1d

232 cli.derivepassphrase, 

233 ["export", "VAULT_PATH"], 

234 ) 

235 assert result.clean_exit(empty_stderr=False), "expected clean exit" 1d

236 assert machinery.deprecation_warning_emitted( 1d

237 "A subcommand will be required here in v1.0", caplog.record_tuples 

238 ) 

239 assert machinery.deprecation_warning_emitted( 1d

240 'Defaulting to subcommand "vault"', caplog.record_tuples 

241 ) 

242 assert json.loads(result.stdout) == data.VAULT_V03_CONFIG_DATA 1d

243 

244 def test_forward_export_vault_empty_commandline( 

245 self, 

246 caplog: pytest.LogCaptureFixture, 

247 ) -> None: 

248 """Deferring from "export" to "export vault" works.""" 

249 pytest.importorskip("cryptography", minversion="38.0") 1h

250 runner = machinery.CliRunner(mix_stderr=False) 1h

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

252 # with-statements. 

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

254 with contextlib.ExitStack() as stack: 1h

255 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1h

256 stack.enter_context( 1h

257 pytest_machinery.isolated_config( 

258 monkeypatch=monkeypatch, 

259 runner=runner, 

260 ) 

261 ) 

262 result = runner.invoke( 1h

263 cli.derivepassphrase, 

264 ["export"], 

265 ) 

266 assert machinery.deprecation_warning_emitted( 1h

267 "A subcommand will be required here in v1.0", caplog.record_tuples 

268 ) 

269 assert machinery.deprecation_warning_emitted( 1h

270 'Defaulting to subcommand "vault"', caplog.record_tuples 

271 ) 

272 assert result.error_exit(error="Missing argument 'PATH'"), ( 1h

273 "expected error exit and known error type" 

274 ) 

275 

276 @Parametrize.CHARSET_NAME 

277 def test_forward_vault_disable_character_set( 

278 self, 

279 caplog: pytest.LogCaptureFixture, 

280 charset_name: str, 

281 ) -> None: 

282 """Forwarding arguments from top-level to "vault" works.""" 

283 option = f"--{charset_name}" 1b

284 charset = vault.Vault.CHARSETS[charset_name].decode("ascii") 1b

285 runner = machinery.CliRunner(mix_stderr=False) 1b

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

287 # with-statements. 

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

289 with contextlib.ExitStack() as stack: 1b

290 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1b

291 stack.enter_context( 1b

292 pytest_machinery.isolated_config( 

293 monkeypatch=monkeypatch, 

294 runner=runner, 

295 ) 

296 ) 

297 monkeypatch.setattr( 1b

298 cli_helpers, 

299 "prompt_for_passphrase", 

300 callables.auto_prompt, 

301 ) 

302 result = runner.invoke( 1b

303 cli.derivepassphrase, 

304 [option, "0", "-p", "--", DUMMY_SERVICE], 

305 input=DUMMY_PASSPHRASE, 

306 catch_exceptions=False, 

307 ) 

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

309 assert machinery.deprecation_warning_emitted( 1b

310 "A subcommand will be required here in v1.0", caplog.record_tuples 

311 ) 

312 assert machinery.deprecation_warning_emitted( 1b

313 'Defaulting to subcommand "vault"', caplog.record_tuples 

314 ) 

315 for c in charset: 1b

316 assert c not in result.stdout, ( 1b

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

318 ) 

319 

320 def test_forward_vault_empty_command_line( 

321 self, 

322 caplog: pytest.LogCaptureFixture, 

323 ) -> None: 

324 """Deferring from top-level to "vault" works.""" 

325 runner = machinery.CliRunner(mix_stderr=False) 1i

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

327 # with-statements. 

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

329 with contextlib.ExitStack() as stack: 1i

330 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1i

331 stack.enter_context( 1i

332 pytest_machinery.isolated_config( 

333 monkeypatch=monkeypatch, 

334 runner=runner, 

335 ) 

336 ) 

337 result = runner.invoke( 1i

338 cli.derivepassphrase, 

339 [], 

340 input=DUMMY_PASSPHRASE, 

341 catch_exceptions=False, 

342 ) 

343 assert machinery.deprecation_warning_emitted( 1i

344 "A subcommand will be required here in v1.0", caplog.record_tuples 

345 ) 

346 assert machinery.deprecation_warning_emitted( 1i

347 'Defaulting to subcommand "vault"', caplog.record_tuples 

348 ) 

349 assert result.error_exit( 1i

350 error="Deriving a passphrase requires a SERVICE." 

351 ), "expected error exit and known error type" 

352 

353 

354class TestConfigMigration: 

355 """Tests for the configuration file migration up to v1.0.""" 

356 

357 def test_export_and_automatic_migration( 

358 self, 

359 caplog: pytest.LogCaptureFixture, 

360 ) -> None: 

361 """Exporting from (and migrating) the old settings file works.""" 

362 caplog.set_level(logging.INFO) 1g

363 runner = machinery.CliRunner(mix_stderr=False) 1g

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

365 # with-statements. 

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

367 with contextlib.ExitStack() as stack: 1g

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

369 stack.enter_context( 1g

370 pytest_machinery.isolated_config( 

371 monkeypatch=monkeypatch, 

372 runner=runner, 

373 ) 

374 ) 

375 cli_helpers.config_filename( 1g

376 subsystem="old settings.json" 

377 ).write_text( 

378 json.dumps( 

379 {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}}, 

380 indent=2, 

381 ) 

382 + "\n", 

383 encoding="UTF-8", 

384 ) 

385 result = runner.invoke( 1g

386 cli.derivepassphrase_vault, 

387 ["--export", "-"], 

388 catch_exceptions=False, 

389 ) 

390 assert result.clean_exit(), "expected clean exit" 1g

391 assert machinery.deprecation_warning_emitted( 1g

392 "v0.1-style config file", caplog.record_tuples 

393 ), "expected known warning message in stderr" 

394 assert machinery.deprecation_info_emitted( 1g

395 "Successfully migrated to ", caplog.record_tuples 

396 ), "expected known warning message in stderr" 

397 

398 def test_export_and_automatic_migration_with_errors( 

399 self, 

400 caplog: pytest.LogCaptureFixture, 

401 ) -> None: 

402 """Exporting from (and not migrating) the old settings file fails.""" 

403 runner = machinery.CliRunner(mix_stderr=False) 1c

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

405 # with-statements. 

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

407 with contextlib.ExitStack() as stack: 1c

408 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1c

409 stack.enter_context( 1c

410 pytest_machinery.isolated_config( 

411 monkeypatch=monkeypatch, 

412 runner=runner, 

413 ) 

414 ) 

415 cli_helpers.config_filename( 1c

416 subsystem="old settings.json" 

417 ).write_text( 

418 json.dumps( 

419 {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}}, 

420 indent=2, 

421 ) 

422 + "\n", 

423 encoding="UTF-8", 

424 ) 

425 

426 def raiser(*_args: Any, **_kwargs: Any) -> None: 1c

427 raise OSError( 1c

428 errno.EACCES, 

429 os.strerror(errno.EACCES), 

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

431 ) 

432 

433 monkeypatch.setattr(os, "replace", raiser) 1c

434 monkeypatch.setattr(pathlib.Path, "rename", raiser) 1c

435 result = runner.invoke( 1c

436 cli.derivepassphrase_vault, 

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

438 catch_exceptions=False, 

439 ) 

440 assert result.clean_exit(), "expected clean exit" 1c

441 assert machinery.deprecation_warning_emitted( 1c

442 "v0.1-style config file", caplog.record_tuples 

443 ), "expected known warning message in stderr" 

444 assert machinery.warning_emitted( 1c

445 "Failed to migrate to ", caplog.record_tuples 

446 ), "expected known warning message in stderr"