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 |