Coverage for tests / test_derivepassphrase_cli / test_vault_cli_notes_handling.py: 100.000%
191 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"""Tests for the `derivepassphrase vault` command-line interface: notes handling."""
7from __future__ import annotations
9import contextlib
10import json
11import types
12from typing import TYPE_CHECKING
14import click.testing
15import hypothesis
16import pytest
17from hypothesis import strategies
18from typing_extensions import Any, TypedDict
20from derivepassphrase import _types, cli
21from derivepassphrase._internals import (
22 cli_helpers,
23 cli_messages,
24)
25from tests import data, machinery
26from tests.machinery import pytest as pytest_machinery
28if TYPE_CHECKING:
29 from typing import NoReturn
31 from typing_extensions import Literal, NotRequired
33DUMMY_SERVICE = data.DUMMY_SERVICE
34DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
35DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS
36DUMMY_RESULT_PASSPHRASE = data.DUMMY_RESULT_PASSPHRASE
39def is_warning_line(line: str) -> bool:
40 """Return true if the line is a warning line."""
41 return " Warning: " in line or " Deprecation warning: " in line 1fbc
44class Strategies:
45 @staticmethod
46 def notes(*, max_size: int = 512) -> strategies.SearchStrategy[str]:
47 return strategies.text(
48 strategies.characters(
49 min_codepoint=32, max_codepoint=126, include_characters="\n"
50 ),
51 min_size=1,
52 max_size=max_size,
53 )
56class Parametrize(types.SimpleNamespace):
57 """Common test parametrizations."""
59 MODERN_EDITOR_INTERFACE = pytest.mark.parametrize(
60 "modern_editor_interface", [False, True], ids=["legacy", "modern"]
61 )
62 NOTES_PLACEMENT = pytest.mark.parametrize(
63 ["notes_placement", "placement_args"],
64 [
65 pytest.param("after", ["--print-notes-after"], id="after"),
66 pytest.param("before", ["--print-notes-before"], id="before"),
67 ],
68 )
71class TestNotesPrinting:
72 """Tests concerning printing the service notes."""
74 def _test(
75 self,
76 notes: str,
77 /,
78 notes_placement: Literal["before", "after"] | None = None,
79 placement_args: list[str] | tuple[str, ...] = (),
80 ) -> None:
81 notes_stripped = notes.strip() 1hg
82 maybe_notes = {"notes": notes_stripped} if notes_stripped else {} 1hg
83 vault_config = { 1hg
84 "global": {"phrase": DUMMY_PASSPHRASE},
85 "services": {
86 DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
87 },
88 }
89 result_phrase = DUMMY_RESULT_PASSPHRASE.decode("ascii") 1hg
90 expected = ( 1hg
91 f"{notes_stripped}\n\n{result_phrase}\n"
92 if notes_placement == "before"
93 else f"{result_phrase}\n\n{notes_stripped}\n\n"
94 if notes_placement == "after"
95 else None
96 )
97 runner = machinery.CliRunner(mix_stderr=notes_placement is not None) 1hg
98 # TODO(the-13th-letter): Rewrite using parenthesized
99 # with-statements.
100 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
101 with contextlib.ExitStack() as stack: 1hg
102 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1hg
103 stack.enter_context( 1hg
104 pytest_machinery.isolated_vault_config(
105 monkeypatch=monkeypatch,
106 runner=runner,
107 vault_config=vault_config,
108 )
109 )
110 result = runner.invoke( 1hg
111 cli.derivepassphrase_vault,
112 [*placement_args, "--", DUMMY_SERVICE],
113 catch_exceptions=False,
114 )
115 if expected is not None: 1hg
116 assert result.clean_exit(output=expected), ( 1h
117 "expected clean exit"
118 )
119 else:
120 assert result.clean_exit(), "expected clean exit" 1g
121 assert result.stdout, "expected program output" 1g
122 assert result.stdout.strip() == result_phrase, ( 1g
123 "expected known program output"
124 )
125 assert result.stderr or not notes_stripped, "expected stderr" 1g
126 assert "Error:" not in result.stderr, ( 1g
127 "expected no error messages on stderr"
128 )
129 assert result.stderr.strip() == notes_stripped, ( 1hg
130 "expected known stderr contents"
131 )
133 @hypothesis.given(notes=Strategies.notes())
134 def test_service_with_notes_actually_prints_notes( 1ag
135 self,
136 notes: str,
137 ) -> None:
138 """Service notes are printed, if they contain content.
140 (Service notes are trimmed before printing, and thus result in
141 output if and only if they are non-empty after trimming leading
142 and trailing whitespace.)
144 """
145 hypothesis.assume("Error:" not in notes) 1g
146 self._test(notes, notes_placement=None, placement_args=()) 1g
148 @Parametrize.NOTES_PLACEMENT
149 @hypothesis.given(notes=Strategies.notes().filter(str.strip)) 1ah
150 @hypothesis.example(notes=" ").xfail(
151 reason="empty notes", raises=AssertionError
152 )
153 def test_notes_placement(
154 self,
155 notes_placement: Literal["before", "after"],
156 placement_args: list[str],
157 notes: str,
158 ) -> None:
159 self._test( 1h
160 notes,
161 notes_placement=notes_placement,
162 placement_args=placement_args,
163 )
166class TestNotesEditing:
167 """Superclass for tests concerning editing service notes."""
169 CURRENT_NOTES = "Contents go here"
170 OLD_NOTES_TEXT = (
171 "These backup notes are left over from the previous session."
172 )
174 def _calculate_expected_contents(
175 self,
176 final_notes: str,
177 /,
178 *,
179 modern_editor_interface: bool,
180 current_notes: str | None = CURRENT_NOTES,
181 old_notes_text: str | None = OLD_NOTES_TEXT,
182 ) -> tuple[str, str]:
183 current_notes = current_notes or "" 1ebdc
184 old_notes_text = old_notes_text or "" 1ebdc
185 # For the modern editor interface, the notes change if and only
186 # if the notes change to a different, non-empty string. There
187 # are no backup notes, so we return the old ones (which may be
188 # synthetic) unchanged.
189 if modern_editor_interface: 1ebdc
190 return old_notes_text.strip(), ( 1ebdc
191 final_notes.strip()
192 if final_notes.strip()
193 and final_notes.strip() != current_notes.strip()
194 else current_notes.strip()
195 )
196 # For the legacy editor interface, the notes and the backup
197 # notes change if and only if the new notes differ from the
198 # previous notes.
199 return ( 1bdc
200 (current_notes.strip(), final_notes.strip())
201 if final_notes.strip() != current_notes.strip()
202 else (old_notes_text.strip(), current_notes.strip())
203 )
205 def _test(
206 self,
207 edit_result: str,
208 /,
209 *,
210 modern_editor_interface: bool,
211 current_notes: str | None = CURRENT_NOTES,
212 old_notes_text: str | None = OLD_NOTES_TEXT,
213 ) -> tuple[machinery.ReadableResult, str, _types.VaultConfig]:
214 if hypothesis.currently_in_test_context(): # pragma: no branch 1ebdc
215 hypothesis.note(f"{edit_result = }") 1ebdc
216 hypothesis.note(f"{modern_editor_interface = }") 1ebdc
217 hypothesis.note( 1ebdc
218 f"vault_config = {self._vault_config(current_notes or '')}"
219 )
220 runner = machinery.CliRunner(mix_stderr=False) 1ebdc
221 # TODO(the-13th-letter): Rewrite using parenthesized
222 # with-statements.
223 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
224 with contextlib.ExitStack() as stack: 1ebdc
225 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1ebdc
226 stack.enter_context( 1ebdc
227 pytest_machinery.isolated_vault_config(
228 monkeypatch=monkeypatch,
229 runner=runner,
230 vault_config=self._vault_config(current_notes or ""),
231 )
232 )
233 notes_backup_file = cli_helpers.config_filename( 1ebdc
234 subsystem="notes backup"
235 )
236 if old_notes_text and old_notes_text.strip(): # pragma: no branch 1ebdc
237 notes_backup_file.write_text( 1ebdc
238 old_notes_text.strip(), encoding="UTF-8"
239 )
240 monkeypatch.setattr(click, "edit", lambda *_a, **_kw: edit_result) 1ebdc
241 result = runner.invoke( 1ebdc
242 cli.derivepassphrase_vault,
243 [
244 "--config",
245 "--notes",
246 "--modern-editor-interface"
247 if modern_editor_interface
248 else "--vault-legacy-editor-interface",
249 "--",
250 "sv",
251 ],
252 catch_exceptions=False,
253 )
254 backup_contents = notes_backup_file.read_text(encoding="UTF-8") 1ebdc
255 with cli_helpers.config_filename(subsystem="vault").open( 1ebdc
256 encoding="UTF-8"
257 ) as infile:
258 config = json.load(infile) 1ebdc
259 if hypothesis.currently_in_test_context(): # pragma: no branch 1ebdc
260 hypothesis.note(f"{result = }") 1ebdc
261 hypothesis.note(f"{backup_contents = }") 1ebdc
262 hypothesis.note(f"{config = }") 1ebdc
263 return result, backup_contents, config 1ebdc
265 def _assert_noop_exit(
266 self,
267 result: machinery.ReadableResult,
268 /,
269 *,
270 modern_editor_interface: bool = False,
271 ) -> None:
272 # We do not distinguish between aborts and no-op edits. Aborts
273 # are treated as failures (error exit), and thus tested
274 # specifically in a different class.
275 if modern_editor_interface: 1d
276 assert result.error_exit( 1d
277 error="the user aborted the request"
278 ) or result.clean_exit(empty_stderr=True), "expected clean exit"
279 else:
280 assert result.clean_exit(empty_stderr=False), "expected clean exit" 1d
282 def _assert_normal_exit(self, result: machinery.ReadableResult) -> None:
283 assert result.clean_exit(), "expected clean exit" 1bc
284 assert all(map(is_warning_line, result.stderr.splitlines(True))) 1bc
286 def _assert_notes_backup_warning(
287 self,
288 caplog: pytest.LogCaptureFixture,
289 /,
290 *,
291 modern_editor_interface: bool,
292 notes_unchanged: bool = False,
293 ) -> None:
294 assert ( 1bdc
295 modern_editor_interface
296 or notes_unchanged
297 or machinery.warning_emitted(
298 "A backup copy of the old notes was saved",
299 caplog.record_tuples,
300 )
301 ), "expected known warning message on stderr"
303 def _assert_notes_and_backup_notes(
304 self,
305 /,
306 *,
307 final_notes: str,
308 new_backup_notes: str,
309 new_config: _types.VaultConfig,
310 modern_editor_interface: bool,
311 current_notes: str | None = CURRENT_NOTES,
312 old_notes_text: str | None = OLD_NOTES_TEXT,
313 ) -> None:
314 if hypothesis.currently_in_test_context(): # pragma: no branch 1ebdc
315 hypothesis.note(f"{final_notes = }") 1ebdc
316 hypothesis.note(f"{current_notes = }") 1ebdc
317 expected_backup_notes, expected_notes = ( 1ebdc
318 self._calculate_expected_contents(
319 final_notes,
320 modern_editor_interface=modern_editor_interface,
321 current_notes=current_notes,
322 old_notes_text=old_notes_text,
323 )
324 )
325 expected_config = self._vault_config(expected_notes) 1ebdc
326 assert new_config == expected_config 1ebdc
327 assert new_backup_notes == expected_backup_notes 1ebdc
329 @staticmethod
330 def _vault_config(
331 starting_notes: str = CURRENT_NOTES, /
332 ) -> _types.VaultConfig:
333 return { 1ebdc
334 "global": {"phrase": "abc"},
335 "services": {
336 "sv": {"notes": starting_notes.strip()}
337 if starting_notes.strip()
338 else {}
339 },
340 }
342 class ExtraArgs(TypedDict):
343 modern_editor_interface: bool
344 current_notes: NotRequired[str]
345 old_notes_text: NotRequired[str]
348class TestNotesEditingValid(TestNotesEditing):
349 """Tests concerning editing service notes: valid calls."""
351 @Parametrize.MODERN_EDITOR_INTERFACE
352 @hypothesis.settings( 1ac
353 suppress_health_check=[
354 *hypothesis.settings().suppress_health_check,
355 hypothesis.HealthCheck.function_scoped_fixture,
356 ],
357 )
358 @hypothesis.given(
359 notes=Strategies
360 .notes()
361 .filter(str.strip)
362 .filter(lambda notes: notes != TestNotesEditingValid.CURRENT_NOTES)
363 )
364 @hypothesis.example(TestNotesEditing.CURRENT_NOTES)
365 @hypothesis.example(notes=" ").xfail(
366 reason="empty notes", raises=AssertionError
367 )
368 def test_successful_edit(
369 self,
370 caplog: pytest.LogCaptureFixture,
371 modern_editor_interface: bool,
372 notes: str,
373 ) -> None:
374 """Editing notes works."""
375 # Reset caplog between hypothesis runs.
376 caplog.clear() 1c
377 marker = cli_messages.TranslatedString( 1c
378 cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
379 )
380 edit_result = ( 1c
381 f"""
383{marker}
384{notes}
385"""
386 if modern_editor_interface
387 else notes.strip()
388 )
390 extra_args: TestNotesEditing.ExtraArgs = { 1c
391 "modern_editor_interface": modern_editor_interface,
392 "current_notes": self.CURRENT_NOTES,
393 "old_notes_text": self.OLD_NOTES_TEXT,
394 }
395 notes_unchanged = notes.strip() == extra_args["current_notes"].strip() 1c
397 result, new_backup_notes, new_config = self._test( 1c
398 edit_result, **extra_args
399 )
400 self._assert_normal_exit(result) 1c
401 self._assert_notes_and_backup_notes( 1c
402 final_notes=notes,
403 new_backup_notes=new_backup_notes,
404 new_config=new_config,
405 **extra_args,
406 )
407 self._assert_notes_backup_warning( 1c
408 caplog,
409 modern_editor_interface=modern_editor_interface,
410 notes_unchanged=notes_unchanged,
411 )
413 # TODO(the-13th-letter): Investigate behavior with respect to
414 # whitespace-only notes and the legacy editor interface.
415 @Parametrize.MODERN_EDITOR_INTERFACE
416 @hypothesis.settings( 1ad
417 suppress_health_check=[
418 *hypothesis.settings().suppress_health_check,
419 hypothesis.HealthCheck.function_scoped_fixture,
420 ],
421 )
422 @hypothesis.given(notes=Strategies.notes().filter(str.strip))
423 @hypothesis.example(TestNotesEditing.CURRENT_NOTES)
424 def test_noop_edit(
425 self,
426 caplog: pytest.LogCaptureFixture,
427 modern_editor_interface: bool,
428 notes: str,
429 ) -> None:
430 """No-op editing existing notes works.
432 The notes are unchanged, and the command-line interface does not
433 report an abort. For the legacy editor interface, the backup
434 notes are unchanged as well.
436 """
437 # Reset caplog between hypothesis runs.
438 caplog.clear() 1d
439 marker = cli_messages.TranslatedString( 1d
440 cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
441 )
442 edit_result = (f"{marker}\n" if modern_editor_interface else "") + ( 1d
443 " " * 6 + notes + "\n" * 6
444 )
446 extra_args: TestNotesEditing.ExtraArgs = { 1d
447 "modern_editor_interface": modern_editor_interface,
448 "current_notes": notes.strip(),
449 "old_notes_text": self.OLD_NOTES_TEXT,
450 }
452 result, new_backup_notes, new_config = self._test( 1d
453 edit_result, **extra_args
454 )
455 self._assert_noop_exit( 1d
456 result,
457 modern_editor_interface=modern_editor_interface,
458 )
459 self._assert_notes_and_backup_notes( 1d
460 final_notes=notes,
461 new_backup_notes=new_backup_notes,
462 new_config=new_config,
463 **extra_args,
464 )
465 self._assert_notes_backup_warning( 1d
466 caplog,
467 modern_editor_interface=modern_editor_interface,
468 notes_unchanged=True,
469 )
471 # TODO(the-13th-letter): Keep this behavior or not, with or without
472 # warning?
473 @Parametrize.MODERN_EDITOR_INTERFACE
474 @hypothesis.settings( 1ab
475 suppress_health_check=[
476 *hypothesis.settings().suppress_health_check,
477 hypothesis.HealthCheck.function_scoped_fixture,
478 ],
479 )
480 @hypothesis.given(notes=Strategies.notes().filter(str.strip))
481 @hypothesis.example(notes=" ").xfail(
482 reason="empty notes", raises=AssertionError
483 )
484 def test_marker_removed(
485 self,
486 caplog: pytest.LogCaptureFixture,
487 modern_editor_interface: bool,
488 notes: str,
489 ) -> None:
490 """Removing the notes marker still saves the notes.
492 TODO: Keep this behavior or not, with or without warning?
494 """
495 notes_marker = cli_messages.TranslatedString( 1b
496 cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
497 )
498 hypothesis.assume(str(notes_marker) not in notes.strip()) 1b
499 # Reset caplog between hypothesis runs.
500 caplog.clear() 1b
502 extra_args: TestNotesEditing.ExtraArgs = { 1b
503 "modern_editor_interface": modern_editor_interface,
504 "current_notes": self.CURRENT_NOTES,
505 "old_notes_text": self.OLD_NOTES_TEXT,
506 }
507 notes_unchanged = notes.strip() == extra_args["current_notes"].strip() 1b
509 result, new_backup_notes, new_config = self._test( 1b
510 notes.strip(), **extra_args
511 )
512 self._assert_normal_exit(result) 1b
513 self._assert_notes_and_backup_notes( 1b
514 final_notes=notes,
515 new_backup_notes=new_backup_notes,
516 new_config=new_config,
517 **extra_args,
518 )
519 self._assert_notes_backup_warning( 1b
520 caplog,
521 modern_editor_interface=modern_editor_interface,
522 notes_unchanged=notes_unchanged,
523 )
526class TestNotesEditingInvalid(TestNotesEditing):
527 """Tests concerning editing service notes: invalid/error calls."""
529 @hypothesis.given(notes=Strategies.notes())
530 @hypothesis.example(notes="") 1ae
531 def test_abort(
532 self,
533 notes: str,
534 ) -> None:
535 """Aborting editing notes works, even if no notes are stored yet.
537 Aborting is only supported with the modern editor interface.
539 """
540 edit_result = "" 1e
542 extra_args: TestNotesEditing.ExtraArgs = { 1e
543 "modern_editor_interface": True,
544 "current_notes": notes.strip(),
545 "old_notes_text": self.OLD_NOTES_TEXT,
546 }
548 result, new_backup_notes, new_config = self._test( 1e
549 edit_result, **extra_args
550 )
551 assert result.error_exit(error="the user aborted the request"), ( 1e
552 "expected error exit"
553 )
554 self._assert_notes_and_backup_notes( 1e
555 final_notes=notes.strip(),
556 new_backup_notes=new_backup_notes,
557 new_config=new_config,
558 **extra_args,
559 )
561 @Parametrize.MODERN_EDITOR_INTERFACE
562 @hypothesis.settings( 1af
563 suppress_health_check=[
564 *hypothesis.settings().suppress_health_check,
565 hypothesis.HealthCheck.function_scoped_fixture,
566 ],
567 )
568 @hypothesis.given(notes=Strategies.notes())
569 @hypothesis.example(notes="")
570 def test_fail_on_config_option_missing(
571 self,
572 caplog: pytest.LogCaptureFixture,
573 modern_editor_interface: bool,
574 notes: str,
575 ) -> None:
576 """Editing notes fails (and warns) if `--config` is missing."""
577 maybe_notes = {"notes": notes.strip()} if notes.strip() else {} 1f
578 vault_config = { 1f
579 "global": {"phrase": DUMMY_PASSPHRASE},
580 "services": {
581 DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
582 },
583 }
584 old_notes_text = ( 1f
585 "These backup notes are left over from the previous session."
586 )
587 # Reset caplog between hypothesis runs.
588 caplog.clear() 1f
589 runner = machinery.CliRunner(mix_stderr=False) 1f
590 # TODO(the-13th-letter): Rewrite using parenthesized
591 # with-statements.
592 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
593 with contextlib.ExitStack() as stack: 1f
594 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1f
595 stack.enter_context( 1f
596 pytest_machinery.isolated_vault_config(
597 monkeypatch=monkeypatch,
598 runner=runner,
599 vault_config=vault_config,
600 )
601 )
602 EDIT_ATTEMPTED = "edit attempted!" # noqa: N806 1f
604 def raiser(*_args: Any, **_kwargs: Any) -> NoReturn: 1f
605 pytest.fail(EDIT_ATTEMPTED)
607 notes_backup_file = cli_helpers.config_filename( 1f
608 subsystem="notes backup"
609 )
610 notes_backup_file.write_text(old_notes_text, encoding="UTF-8") 1f
611 monkeypatch.setattr(click, "edit", raiser) 1f
612 result = runner.invoke( 1f
613 cli.derivepassphrase_vault,
614 [
615 "--notes",
616 "--modern-editor-interface"
617 if modern_editor_interface
618 else "--vault-legacy-editor-interface",
619 "--",
620 DUMMY_SERVICE,
621 ],
622 catch_exceptions=False,
623 )
624 assert result.clean_exit( 1f
625 output=DUMMY_RESULT_PASSPHRASE.decode("ascii")
626 ), "expected clean exit"
627 assert result.stderr 1f
628 assert notes.strip() in result.stderr 1f
629 assert all( 1f
630 is_warning_line(line)
631 for line in result.stderr.splitlines(True)
632 if line.startswith(f"{cli.PROG_NAME}: ")
633 )
634 assert machinery.warning_emitted( 1f
635 "Specifying --notes without --config is ineffective. "
636 "No notes will be edited.",
637 caplog.record_tuples,
638 ), "expected known warning message in stderr"
639 assert ( 1f
640 modern_editor_interface
641 or notes_backup_file.read_text(encoding="UTF-8")
642 == old_notes_text
643 )
644 with cli_helpers.config_filename(subsystem="vault").open( 1f
645 encoding="UTF-8"
646 ) as infile:
647 config = json.load(infile) 1f
648 assert config == vault_config 1f