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 |