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
« 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
5# TODO(the-13th-letter): Remove this module in v1.0.
6# https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#upgrading-to-v1.0
8"""Tests for the `derivepassphrase` command-line interface: transitional functionality.
10This includes deprecation warnings for deprecated commands or options,
11and functionality provided for ease of transition.
13"""
15from __future__ import annotations
17import contextlib
18import errno
19import json
20import logging
21import os
22import pathlib
24import click.testing
25import pytest
26from typing_extensions import Any
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)
40DUMMY_SERVICE = data.DUMMY_SERVICE
41DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
42DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS
45class Parametrize(
46 test_vault_cli_basic_functionality.Parametrize, test_utils.Parametrize
47):
48 """Common test parametrizations."""
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 )
71class TestConfigMigrationMachinery:
72 """Tests for the configuration file migration machinery."""
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
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
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
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
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]
207class TestArgumentForwarding:
208 """Tests for the argument forwarding up to v1.0."""
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
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 )
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 )
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"
354class TestConfigMigration:
355 """Tests for the configuration file migration up to v1.0."""
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"
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 )
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 )
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"