Add missing tests for reworked error message handling
Marco Ricci

Marco Ricci commited on 2024-08-16 16:31:24
Zeige 2 geänderte Dateien mit 185 Einfügungen und 0 Löschungen.


Add missing tests for the revised error messages/classes and error
handling code due to a1763e8b5dedbf123856a79ddb0e8395cddd6f88 and
5c6045e10ca9c8b56432711dec5efb98b5892d55.
... ...
@@ -8,6 +8,7 @@ import base64
8 8
 import contextlib
9 9
 import json
10 10
 import os
11
+import stat
11 12
 from typing import TYPE_CHECKING
12 13
 
13 14
 import pytest
... ...
@@ -411,3 +412,55 @@ def isolated_config(
411 412
 def auto_prompt(*args: Any, **kwargs: Any) -> str:
412 413
     del args, kwargs  # Unused.
413 414
     return DUMMY_PASSPHRASE.decode('UTF-8')
415
+
416
+
417
+def make_file_readonly(
418
+    pathname: str | bytes | os.PathLike[str],
419
+    /,
420
+    *,
421
+    try_race_free_implementation: bool = True,
422
+) -> None:
423
+    """Mark a file as read-only.
424
+
425
+    On POSIX, this entails removing the write permission bits for user,
426
+    group and other, and ensuring the read permission bit for user is
427
+    set.
428
+
429
+    Unfortunately, Windows has its own rules: Set exactly(?) the read
430
+    permission bit for user to make the file read-only, and set
431
+    exactly(?) the write permission bit for user to make the file
432
+    read/write; all other permission bit settings are ignored.
433
+
434
+    The cross-platform procedure therefore is:
435
+
436
+    1. Call `os.stat` on the file, noting the permission bits.
437
+    2. Calculate the new permission bits POSIX-style.
438
+    3. Call `os.chmod` with permission bit `stat.S_IREAD`.
439
+    4. Call `os.chmod` with the correct POSIX-style permissions.
440
+
441
+    If the platform supports it, we use a file descriptor instead of
442
+    a path name.  Otherwise, we use the same path name multiple times,
443
+    and are susceptible to race conditions.
444
+
445
+    """
446
+    fname: int | str | bytes | os.PathLike[str]
447
+    if try_race_free_implementation and {os.stat, os.chmod} <= os.supports_fd:
448
+        fname = os.open(
449
+            pathname,
450
+            os.O_RDONLY
451
+            | getattr(os, 'O_CLOEXEC', 0)
452
+            | getattr(os, 'O_NOCTTY', 0),
453
+        )
454
+    else:
455
+        fname = pathname
456
+    try:
457
+        orig_mode = os.stat(fname).st_mode
458
+        new_mode = (
459
+            orig_mode & ~stat.S_IWUSR & ~stat.S_IWGRP & ~stat.S_IWOTH
460
+            | stat.S_IREAD
461
+        )
462
+        os.chmod(fname, stat.S_IREAD)
463
+        os.chmod(fname, new_mode)
464
+    finally:
465
+        if isinstance(fname, int):
466
+            os.close(fname)
... ...
@@ -1004,6 +1004,105 @@ contents go here
1004 1004
                 custom_error.encode() in result.stderr_bytes
1005 1005
             ), 'expected error message missing'
1006 1006
 
1007
+    def test_225b_store_config_fail_manual_no_ssh_agent(
1008
+        self,
1009
+        monkeypatch: Any,
1010
+    ) -> None:
1011
+        runner = click.testing.CliRunner(mix_stderr=False)
1012
+        with tests.isolated_config(
1013
+            monkeypatch=monkeypatch,
1014
+            runner=runner,
1015
+            config={'global': {'phrase': 'abc'}, 'services': {}},
1016
+        ):
1017
+            monkeypatch.delenv('SSH_AUTH_SOCK', raising=False)
1018
+            result = runner.invoke(
1019
+                cli.derivepassphrase,
1020
+                ['--key', '--config'],
1021
+                catch_exceptions=False,
1022
+            )
1023
+            assert result.exit_code != 0, 'program unexpectedly succeeded'
1024
+            assert result.stderr_bytes is not None
1025
+            assert (
1026
+                b'Cannot find running SSH agent' in result.stderr_bytes
1027
+            ), 'expected error message missing'
1028
+
1029
+    def test_225c_store_config_fail_manual_bad_ssh_agent_connection(
1030
+        self,
1031
+        monkeypatch: Any,
1032
+    ) -> None:
1033
+        runner = click.testing.CliRunner(mix_stderr=False)
1034
+        with tests.isolated_config(
1035
+            monkeypatch=monkeypatch,
1036
+            runner=runner,
1037
+            config={'global': {'phrase': 'abc'}, 'services': {}},
1038
+        ):
1039
+            monkeypatch.setenv('SSH_AUTH_SOCK', os.getcwd())
1040
+            result = runner.invoke(
1041
+                cli.derivepassphrase,
1042
+                ['--key', '--config'],
1043
+                catch_exceptions=False,
1044
+            )
1045
+            assert result.exit_code != 0, 'program unexpectedly succeeded'
1046
+            assert result.stderr_bytes is not None
1047
+            assert (
1048
+                b'Cannot connect to SSH agent' in result.stderr_bytes
1049
+            ), 'expected error message missing'
1050
+
1051
+    @pytest.mark.parametrize('try_race_free_implementation', [True, False])
1052
+    def test_225d_store_config_fail_manual_read_only_file(
1053
+        self,
1054
+        monkeypatch: Any,
1055
+        try_race_free_implementation: bool,
1056
+    ) -> None:
1057
+        runner = click.testing.CliRunner(mix_stderr=False)
1058
+        with tests.isolated_config(
1059
+            monkeypatch=monkeypatch,
1060
+            runner=runner,
1061
+            config={'global': {'phrase': 'abc'}, 'services': {}},
1062
+        ):
1063
+            tests.make_file_readonly(
1064
+                cli._config_filename(),
1065
+                try_race_free_implementation=try_race_free_implementation,
1066
+            )
1067
+            result = runner.invoke(
1068
+                cli.derivepassphrase,
1069
+                ['--config', '--length=15', DUMMY_SERVICE],
1070
+                catch_exceptions=False,
1071
+            )
1072
+            assert result.exit_code != 0, 'program unexpectedly succeeded'
1073
+            assert result.stderr_bytes is not None
1074
+            assert (
1075
+                b'Cannot store config' in result.stderr_bytes
1076
+            ), 'expected error message missing'
1077
+
1078
+    def test_225e_store_config_fail_manual_custom_error(
1079
+        self,
1080
+        monkeypatch: Any,
1081
+    ) -> None:
1082
+        runner = click.testing.CliRunner(mix_stderr=False)
1083
+        with tests.isolated_config(
1084
+            monkeypatch=monkeypatch,
1085
+            runner=runner,
1086
+            config={'global': {'phrase': 'abc'}, 'services': {}},
1087
+        ):
1088
+            custom_error = 'custom error message'
1089
+
1090
+            def raiser(config: Any) -> None:
1091
+                del config
1092
+                raise RuntimeError(custom_error)
1093
+
1094
+            monkeypatch.setattr(cli, '_save_config', raiser)
1095
+            result = runner.invoke(
1096
+                cli.derivepassphrase,
1097
+                ['--config', '--length=15', DUMMY_SERVICE],
1098
+                catch_exceptions=False,
1099
+            )
1100
+            assert result.exit_code != 0, 'program unexpectedly succeeded'
1101
+            assert result.stderr_bytes is not None
1102
+            assert (
1103
+                custom_error.encode() in result.stderr_bytes
1104
+            ), 'expected error message missing'
1105
+
1007 1106
     def test_226_no_arguments(self, monkeypatch: Any) -> None:
1008 1107
         runner = click.testing.CliRunner(mix_stderr=False)
1009 1108
         with tests.isolated_config(
... ...
@@ -1101,6 +1200,39 @@ contents go here
1101 1200
                 input='abc\n',
1102 1201
             )
1103 1202
             assert result.exit_code != 0, 'program unexpectedly succeeded?!'
1203
+            assert result.stderr_bytes is not None
1204
+            assert (
1205
+                b'Cannot store config' in result.stderr_bytes
1206
+            ), 'program unexpectedly failed?!'
1207
+
1208
+    def test_230b_store_config_custom_error(self, monkeypatch: Any) -> None:
1209
+        runner = click.testing.CliRunner(mix_stderr=False)
1210
+        with tests.isolated_config(
1211
+            monkeypatch=monkeypatch,
1212
+            runner=runner,
1213
+            config={'services': {}},
1214
+        ):
1215
+            _save_config = cli._save_config
1216
+
1217
+            def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any:
1218
+                with contextlib.suppress(FileNotFoundError):
1219
+                    shutil.rmtree('.derivepassphrase')
1220
+                with open(
1221
+                    '.derivepassphrase', 'w', encoding='UTF-8'
1222
+                ) as outfile:
1223
+                    print('Obstruction!!', file=outfile)
1224
+                monkeypatch.setattr(cli, '_save_config', _save_config)
1225
+                return _save_config(*args, **kwargs)
1226
+
1227
+            monkeypatch.setattr(cli, '_save_config', obstruct_config_saving)
1228
+            result = runner.invoke(
1229
+                cli.derivepassphrase,
1230
+                ['--config', '-p'],
1231
+                catch_exceptions=False,
1232
+                input='abc\n',
1233
+            )
1234
+            assert result.exit_code != 0, 'program unexpectedly succeeded?!'
1235
+            assert result.stderr_bytes is not None
1104 1236
             assert (
1105 1237
                 b'Cannot store config' in result.stderr_bytes
1106 1238
             ), 'program unexpectedly failed?!'
1107 1239