Test exporter data loading functionality more robustly
Marco Ricci

Marco Ricci commited on 2024-08-31 21:04:32
Zeige 4 geänderte Dateien mit 152 Einfügungen und 42 Löschungen.


Move the `_load_data` function, which deals with determining the correct
vault storage format (with automatic rollover) into the top level, where
the function can be tested in isolation, and stubbed out if necessary.

Also, use the environments with disabled cryptography support to test
for correct failure behavior of the exporter.  Use
`importlib.util.find_spec` instead of an `import` statement to check for
importability of a module, which does not require handling of
`ModuleNotFoundError` if restricted to top-level modules/packages.
... ...
@@ -31,6 +31,43 @@ __all__ = ('derivepassphrase_export',)
31 31
 PROG_NAME = 'derivepassphrase_export'
32 32
 
33 33
 
34
+def _load_data(
35
+    fmt: Literal['v0.2', 'v0.3', 'storeroom'],
36
+    path: str | bytes | os.PathLike[str],
37
+    key: bytes,
38
+) -> Any:
39
+    contents: bytes
40
+    module: types.ModuleType
41
+    match fmt:
42
+        case 'v0.2':
43
+            module = importlib.import_module(
44
+                'derivepassphrase.exporter.vault_v03_and_below'
45
+            )
46
+            if module.STUBBED:
47
+                raise ModuleNotFoundError
48
+            with open(path, 'rb') as infile:
49
+                contents = base64.standard_b64decode(infile.read())
50
+            return module.V02Reader(contents, key).run()
51
+        case 'v0.3':
52
+            module = importlib.import_module(
53
+                'derivepassphrase.exporter.vault_v03_and_below'
54
+            )
55
+            if module.STUBBED:
56
+                raise ModuleNotFoundError
57
+            with open(path, 'rb') as infile:
58
+                contents = base64.standard_b64decode(infile.read())
59
+            return module.V03Reader(contents, key).run()
60
+        case 'storeroom':
61
+            module = importlib.import_module(
62
+                'derivepassphrase.exporter.storeroom'
63
+            )
64
+            if module.STUBBED:
65
+                raise ModuleNotFoundError
66
+            return module.export_storeroom_data(path, key)
67
+        case _:  # pragma: no cover
68
+            assert_never(fmt)
69
+
70
+
34 71
 @click.command(
35 72
     context_settings={'help_option_names': ['-h', '--help']},
36 73
 )
... ...
@@ -78,42 +115,6 @@ def derivepassphrase_export(
78 115
 
79 116
     """
80 117
 
81
-    def load_data(
82
-        fmt: Literal['v0.2', 'v0.3', 'storeroom'],
83
-        path: str | bytes | os.PathLike[str],
84
-        key: bytes,
85
-    ) -> Any:
86
-        contents: bytes
87
-        module: types.ModuleType
88
-        match fmt:
89
-            case 'v0.2':
90
-                module = importlib.import_module(
91
-                    'derivepassphrase.exporter.vault_v03_and_below'
92
-                )
93
-                if module.STUBBED:
94
-                    raise ModuleNotFoundError
95
-                with open(path, 'rb') as infile:
96
-                    contents = base64.standard_b64decode(infile.read())
97
-                return module.V02Reader(contents, key).run()
98
-            case 'v0.3':
99
-                module = importlib.import_module(
100
-                    'derivepassphrase.exporter.vault_v03_and_below'
101
-                )
102
-                if module.STUBBED:
103
-                    raise ModuleNotFoundError
104
-                with open(path, 'rb') as infile:
105
-                    contents = base64.standard_b64decode(infile.read())
106
-                return module.V03Reader(contents, key).run()
107
-            case 'storeroom':
108
-                module = importlib.import_module(
109
-                    'derivepassphrase.exporter.storeroom'
110
-                )
111
-                if module.STUBBED:
112
-                    raise ModuleNotFoundError
113
-                return module.export_storeroom_data(path, key)
114
-            case _:  # pragma: no cover
115
-                assert_never(fmt)
116
-
117 118
     logging.basicConfig()
118 119
     if path in {'VAULT_PATH', b'VAULT_PATH'}:
119 120
         path = exporter.get_vault_path()
... ...
@@ -123,7 +124,7 @@ def derivepassphrase_export(
123 124
         key = key.encode('utf-8')
124 125
     for fmt in formats:
125 126
         try:
126
-            config = load_data(fmt, path, key)
127
+            config = _load_data(fmt, path, key)
127 128
         except (
128 129
             IsADirectoryError,
129 130
             NotADirectoryError,
... ...
@@ -165,3 +166,7 @@ def derivepassphrase_export(
165 166
             err=True,
166 167
         )
167 168
         ctx.exit(1)
169
+
170
+
171
+if __name__ == '__main__':
172
+    derivepassphrase_export()
... ...
@@ -6,6 +6,7 @@ from __future__ import annotations
6 6
 
7 7
 import base64
8 8
 import contextlib
9
+import importlib.util
9 10
 import json
10 11
 import os
11 12
 import stat
... ...
@@ -446,6 +447,14 @@ CANNOT_LOAD_CRYPTOGRAPHY = (
446 447
 skip_if_no_agent = pytest.mark.skipif(
447 448
     not os.environ.get('SSH_AUTH_SOCK'), reason='running SSH agent required'
448 449
 )
450
+skip_if_cryptography_support = pytest.mark.skipif(
451
+    importlib.util.find_spec('cryptography') is not None,
452
+    reason='cryptography support available; cannot test "no support" scenario',
453
+)
454
+skip_if_no_cryptography_support = pytest.mark.skipif(
455
+    importlib.util.find_spec('cryptography') is None,
456
+    reason='no "cryptography" support',
457
+)
449 458
 
450 459
 
451 460
 def list_keys(self: Any = None) -> list[_types.KeyCommentPair]:
... ...
@@ -4,9 +4,7 @@
4 4
 
5 5
 from __future__ import annotations
6 6
 
7
-import json
8 7
 import os
9
-from typing import Any
10 8
 
11 9
 import click.testing
12 10
 import pytest
... ...
@@ -129,3 +127,51 @@ class Test002CLI:
129 127
         assert b'-f' in result.stderr_bytes
130 128
         assert b'--format' in result.stderr_bytes
131 129
         assert b'INVALID' in result.stderr_bytes
130
+
131
+    @tests.skip_if_cryptography_support
132
+    @pytest.mark.parametrize(
133
+        ['format', 'config', 'key'],
134
+        [
135
+            pytest.param(
136
+                'v0.2',
137
+                tests.VAULT_V02_CONFIG,
138
+                tests.VAULT_MASTER_KEY,
139
+                id='v0.2',
140
+            ),
141
+            pytest.param(
142
+                'v0.3',
143
+                tests.VAULT_V03_CONFIG,
144
+                tests.VAULT_MASTER_KEY,
145
+                id='v0.3',
146
+            ),
147
+            pytest.param(
148
+                'storeroom',
149
+                tests.VAULT_STOREROOM_CONFIG_ZIPPED,
150
+                tests.VAULT_MASTER_KEY,
151
+                id='storeroom',
152
+            ),
153
+        ],
154
+    )
155
+    def test_999_no_cryptography_error_message(
156
+        self,
157
+        monkeypatch: pytest.MonkeyPatch,
158
+        format: str,
159
+        config: str | bytes,
160
+        key: str,
161
+    ) -> None:
162
+        runner = click.testing.CliRunner(mix_stderr=False)
163
+        with tests.isolated_vault_exporter_config(
164
+            monkeypatch=monkeypatch,
165
+            runner=runner,
166
+            vault_config=config,
167
+            vault_key=key,
168
+        ):
169
+            result = runner.invoke(
170
+                cli.derivepassphrase_export,
171
+                ['-f', format, 'VAULT_PATH'],
172
+                catch_exceptions=False,
173
+            )
174
+        assert isinstance(result.exception, SystemExit)
175
+        assert result.exit_code
176
+        assert result.stderr_bytes
177
+        assert tests.CANNOT_LOAD_CRYPTOGRAPHY in result.stderr_bytes
... ...
@@ -5,18 +5,19 @@
5 5
 from __future__ import annotations
6 6
 
7 7
 import json
8
-import os
9
-from typing import Any
8
+from typing import TYPE_CHECKING
10 9
 
11 10
 import click.testing
12 11
 import pytest
13 12
 
14 13
 import tests
15
-from derivepassphrase import exporter
16 14
 from derivepassphrase.exporter import cli
17 15
 
18 16
 cryptography = pytest.importorskip('cryptography', minversion='38.0')
19 17
 
18
+if TYPE_CHECKING:
19
+    from typing import Any
20
+
20 21
 
21 22
 class TestCLI:
22 23
     def test_200_path_parameter(self, monkeypatch: pytest.MonkeyPatch) -> None:
... ...
@@ -144,3 +145,52 @@ class TestCLI:
144 145
             b"Cannot parse '.vault' as a valid config." in result.stderr_bytes
145 146
         )
146 147
         assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr_bytes
148
+
149
+    def test_403_invalid_vault_config_bad_signature(
150
+        self,
151
+        monkeypatch: pytest.MonkeyPatch,
152
+    ) -> None:
153
+        runner = click.testing.CliRunner(mix_stderr=False)
154
+        with tests.isolated_vault_exporter_config(
155
+            monkeypatch=monkeypatch,
156
+            runner=runner,
157
+            vault_config=tests.VAULT_V02_CONFIG,
158
+            vault_key=tests.VAULT_MASTER_KEY,
159
+        ):
160
+            result = runner.invoke(
161
+                cli.derivepassphrase_export,
162
+                ['-f', 'v0.3', '.vault'],
163
+            )
164
+        assert isinstance(result.exception, SystemExit)
165
+        assert result.exit_code
166
+        assert result.stderr_bytes
167
+        assert (
168
+            b"Cannot parse '.vault' as a valid config." in result.stderr_bytes
169
+        )
170
+        assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr_bytes
171
+
172
+    def test_500_vault_config_invalid_internal(
173
+        self,
174
+        monkeypatch: pytest.MonkeyPatch,
175
+    ) -> None:
176
+        runner = click.testing.CliRunner(mix_stderr=False)
177
+        with tests.isolated_vault_exporter_config(
178
+            monkeypatch=monkeypatch,
179
+            runner=runner,
180
+            vault_config=tests.VAULT_V03_CONFIG,
181
+            vault_key=tests.VAULT_MASTER_KEY,
182
+        ):
183
+
184
+            def _load_data(*_args: Any, **_kwargs: Any) -> None:
185
+                return None
186
+
187
+            monkeypatch.setattr(cli, '_load_data', _load_data)
188
+            result = runner.invoke(
189
+                cli.derivepassphrase_export,
190
+                ['.vault'],
191
+            )
192
+        assert isinstance(result.exception, SystemExit)
193
+        assert result.exit_code
194
+        assert result.stderr_bytes
195
+        assert b'Invalid vault config: ' in result.stderr_bytes
196
+        assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr_bytes
147 197