Add preliminary tests for the exporter
Marco Ricci

Marco Ricci commited on 2024-08-25 23:50:03
Zeige 6 geänderte Dateien mit 515 Einfügungen und 5 Löschungen.


Includes basic testing of command-line functionality, explicit examples
of each supported format, and proper hatch environment and dependency
selection (with exporter support, or without) to run tests in.  The
major missing pieces are weird-to-reach error conditions in the
internals of the exporter functions (which require invasive mocking) and
explicit API testing of the exporter functions (i.e. beyond each
exporter's "main" function).  Because the internal API is not yet
stable, particularly the v0.2 and v0.3 exporters, this is currently left
as a TODO.

Since we are attempting to support installations without the
cryptography module, we must ensure the exporter modules all stay
importable without triggering import errors.  (This is also relevant for
pytest's test collection phase, where those modules too are scanned for
doctests.)  Currently we stub out each (sub-)module of the cryptography
package implicitly, such that calling any method on the stub raises an
import error, as well as explicitly through a `STUBBED` attribute on the
exporter module.
... ...
@@ -74,11 +74,19 @@ packages = ['src/derivepassphrase']
74 74
 
75 75
 [tool.hatch.envs.hatch-test]
76 76
 default-args = ['src', 'tests']
77
-features = ["export"]
78 77
 
79 78
 [[tool.hatch.envs.hatch-test.matrix]]
80 79
 python = ["3.10", "3.11", "3.12", "pypy3.10"]
81 80
 
81
+[[tool.hatch.envs.hatch-test.matrix]]
82
+python = ["3.10", "3.11", "3.12", "pypy3.10"]
83
+feature = ["export"]
84
+
85
+[tool.hatch.envs.hatch-test.overrides]
86
+matrix.feature.features = [
87
+    { value = "export", if = ["export"] },
88
+]
89
+
82 90
 [tool.hatch.env]
83 91
 requires = [
84 92
   "hatch-mkdocs",
... ...
@@ -147,6 +147,8 @@ def derivepassphrase_export(
147 147
                 module = importlib.import_module(
148 148
                     'derivepassphrase.exporter.vault_v03_and_below'
149 149
                 )
150
+                if module.STUBBED:
151
+                    raise ModuleNotFoundError
150 152
                 with open(path, 'rb') as infile:
151 153
                     contents = base64.standard_b64decode(infile.read())
152 154
                 return module.V02Reader(contents, key).run()
... ...
@@ -154,6 +156,8 @@ def derivepassphrase_export(
154 156
                 module = importlib.import_module(
155 157
                     'derivepassphrase.exporter.vault_v03_and_below'
156 158
                 )
159
+                if module.STUBBED:
160
+                    raise ModuleNotFoundError
157 161
                 with open(path, 'rb') as infile:
158 162
                     contents = base64.standard_b64decode(infile.read())
159 163
                 return module.V03Reader(contents, key).run()
... ...
@@ -161,6 +165,8 @@ def derivepassphrase_export(
161 165
                 module = importlib.import_module(
162 166
                     'derivepassphrase.exporter.storeroom'
163 167
                 )
168
+                if module.STUBBED:
169
+                    raise ModuleNotFoundError
164 170
                 return module.export_storeroom_data(path, key)
165 171
             case _:  # pragma: no cover
166 172
                 assert_never(fmt)
... ...
@@ -11,14 +11,41 @@ import os.path
11 11
 import struct
12 12
 from typing import TYPE_CHECKING, Any, TypedDict
13 13
 
14
+from derivepassphrase import exporter
15
+
16
+if TYPE_CHECKING:
17
+    from collections.abc import Iterator
18
+
14 19
     from cryptography.hazmat.primitives import ciphers, hashes, hmac, padding
15 20
     from cryptography.hazmat.primitives.ciphers import algorithms, modes
16 21
     from cryptography.hazmat.primitives.kdf import pbkdf2
22
+else:
23
+    try:
24
+        from cryptography.hazmat.primitives import (
25
+            ciphers,
26
+            hashes,
27
+            hmac,
28
+            padding,
29
+        )
30
+        from cryptography.hazmat.primitives.ciphers import algorithms, modes
31
+        from cryptography.hazmat.primitives.kdf import pbkdf2
32
+    except ModuleNotFoundError as exc:
17 33
 
18
-from derivepassphrase import exporter
34
+        class DummyModule:
35
+            def __init__(self, exc: type[Exception]) -> None:
36
+                self.exc = exc
19 37
 
20
-if TYPE_CHECKING:
21
-    from collections.abc import Iterator
38
+            def __getattr__(self, name: str) -> Any:
39
+                def func(*args: Any, **kwargs: Any) -> Any:  # noqa: ARG001
40
+                    raise self.exc
41
+
42
+                return func
43
+
44
+        ciphers = hashes = hmac = padding = DummyModule(exc)
45
+        algorithms = modes = pbkdf2 = DummyModule(exc)
46
+        STUBBED = True
47
+    else:
48
+        STUBBED = False
22 49
 
23 50
 STOREROOM_MASTER_KEYS_UUID = b'35b7c7ed-f71e-4adf-9051-02fb0f1e0e17'
24 51
 VAULT_CIPHER_UUID = b'73e69e8a-cb05-4b50-9f42-59d76a511299'
... ...
@@ -7,13 +7,44 @@ import base64
7 7
 import json
8 8
 import logging
9 9
 import warnings
10
-from typing import Any
10
+from typing import TYPE_CHECKING, Any
11 11
 
12
+if TYPE_CHECKING:
12 13
     from cryptography import exceptions as crypt_exceptions
13 14
     from cryptography import utils as crypt_utils
14 15
     from cryptography.hazmat.primitives import ciphers, hashes, hmac, padding
15 16
     from cryptography.hazmat.primitives.ciphers import algorithms, modes
16 17
     from cryptography.hazmat.primitives.kdf import pbkdf2
18
+else:
19
+    try:
20
+        from cryptography import exceptions as crypt_exceptions
21
+        from cryptography import utils as crypt_utils
22
+        from cryptography.hazmat.primitives import (
23
+            ciphers,
24
+            hashes,
25
+            hmac,
26
+            padding,
27
+        )
28
+        from cryptography.hazmat.primitives.ciphers import algorithms, modes
29
+        from cryptography.hazmat.primitives.kdf import pbkdf2
30
+    except ModuleNotFoundError as exc:
31
+
32
+        class DummyModule:
33
+            def __init__(self, exc: type[Exception]) -> None:
34
+                self.exc = exc
35
+
36
+            def __getattr__(self, name: str) -> Any:
37
+                def func(*args: Any, **kwargs: Any) -> Any:  # noqa: ARG001
38
+                    raise self.exc
39
+
40
+                return func
41
+
42
+        crypt_exceptions = crypt_utils = DummyModule(exc)
43
+        ciphers = hashes = hmac = padding = DummyModule(exc)
44
+        algorithms = modes = pbkdf2 = DummyModule(exc)
45
+        STUBBED = True
46
+    else:
47
+        STUBBED = False
17 48
 
18 49
 from derivepassphrase import exporter, vault
19 50
 
... ...
@@ -9,9 +9,12 @@ import contextlib
9 9
 import json
10 10
 import os
11 11
 import stat
12
+import tempfile
13
+import zipfile
12 14
 from typing import TYPE_CHECKING
13 15
 
14 16
 import pytest
17
+from typing_extensions import assert_never
15 18
 
16 19
 from derivepassphrase import _types, cli
17 20
 
... ...
@@ -409,6 +412,60 @@ def isolated_config(
409 412
         yield
410 413
 
411 414
 
415
+@contextlib.contextmanager
416
+def isolated_vault_exporter_config(
417
+    monkeypatch: pytest.MonkeyPatch,
418
+    runner: click.testing.CliRunner,
419
+    vault_config: str | bytes | None = None,
420
+    vault_key: str | None = None,
421
+) -> Iterator[None]:
422
+    if TYPE_CHECKING:
423
+        chdir = contextlib.chdir
424
+    else:
425
+        try:
426
+            chdir = contextlib.chdir
427
+        except AttributeError:
428
+
429
+            @contextlib.contextmanager
430
+            def chdir(newpath: str) -> Iterator[None]:
431
+                oldpath = os.getcwd()
432
+                os.chdir(newpath)
433
+                yield
434
+                os.chdir(oldpath)
435
+
436
+    with runner.isolated_filesystem():
437
+        monkeypatch.setenv('HOME', os.getcwd())
438
+        monkeypatch.setenv('USERPROFILE', os.getcwd())
439
+        monkeypatch.delenv('VAULT_PATH', raising=False)
440
+        monkeypatch.delenv('VAULT_KEY', raising=False)
441
+        monkeypatch.delenv('LOGNAME', raising=False)
442
+        monkeypatch.delenv('USER', raising=False)
443
+        monkeypatch.delenv('USERNAME', raising=False)
444
+        if vault_key is not None:
445
+            monkeypatch.setenv('VAULT_KEY', vault_key)
446
+        match vault_config:
447
+            case str():
448
+                with open('.vault', 'w', encoding='UTF-8') as outfile:
449
+                    print(vault_config, file=outfile)
450
+            case bytes():
451
+                os.makedirs('.vault', mode=0o700, exist_ok=True)
452
+                with (
453
+                    chdir('.vault'),
454
+                    tempfile.NamedTemporaryFile(suffix='.zip') as tmpzipfile,
455
+                ):
456
+                    for line in vault_config.splitlines():
457
+                        tmpzipfile.write(base64.standard_b64decode(line))
458
+                    tmpzipfile.flush()
459
+                    tmpzipfile.seek(0, 0)
460
+                    with zipfile.ZipFile(tmpzipfile.file) as zipfileobj:
461
+                        zipfileobj.extractall()
462
+            case None:
463
+                pass
464
+            case _:  # pragma: no cover
465
+                assert_never(vault_config)
466
+        yield
467
+
468
+
412 469
 def auto_prompt(*args: Any, **kwargs: Any) -> str:
413 470
     del args, kwargs  # Unused.
414 471
     return DUMMY_PASSPHRASE.decode('UTF-8')
... ...
@@ -0,0 +1,381 @@
1
+# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: MIT
4
+
5
+from __future__ import annotations
6
+
7
+import json
8
+import os
9
+from typing import Any
10
+
11
+import click.testing
12
+import pytest
13
+
14
+import tests
15
+from derivepassphrase import exporter
16
+
17
+VAULT_MASTER_KEY = 'vault key'
18
+VAULT_V02_CONFIG = 'P7xeh5y4jmjpJ2pFq4KUcTVoaE9ZOEkwWmpVTURSSWQxbGt6emN4aFE4eFM3anVPbDRNTGpOLzY3eDF5aE1YTm5LNWh5Q1BwWTMwM3M5S083MWRWRFlmOXNqSFJNcStGMWFOS3c2emhiOUNNenZYTmNNMnZxaUErdlRoOGF2ZHdGT1ZLNTNLOVJQcU9jWmJrR3g5N09VcVBRZ0ZnSFNUQy9HdFVWWnFteVhRVkY3MHNBdnF2ZWFEbFBseWRGelE1c3BFTnVUckRQdWJSL29wNjFxd2Y2ZVpob3VyVzRod3FKTElTenJ1WTZacTJFOFBtK3BnVzh0QWVxcWtyWFdXOXYyenNQeFNZbWt1MDU2Vm1kVGtISWIxWTBpcWRFbyswUVJudVVhZkVlNVpGWDA4WUQ2Q2JTWW81SnlhQ2Zxa3cxNmZoQjJES0Uyd29rNXpSck5iWVBrVmEwOXFya1NpMi9saU5LL3F0M3N3MjZKekNCem9ER2svWkZ0SUJLdmlHRno0VlQzQ3pqZTBWcTM3YmRiNmJjTkhqUHZoQ0NxMW1ldW1XOFVVK3pQMEtUMkRMVGNvNHFlOG40ck5KcGhsYXg1b1VzZ1NYU1B2T3RXdEkwYzg4NWE3YWUzOWI1MDI0MThhMWZjODQ3MDA2OTJmNDQ0MDkxNGFiNmRlMGQ2YjZiNjI5NGMwN2IwMmI4MGZi'  # noqa: E501
19
+VAULT_V02_CONFIG_DATA = {
20
+    'global': {
21
+        'phrase': tests.DUMMY_PASSPHRASE.decode('utf-8').rstrip('\n'),
22
+    },
23
+    'services': {
24
+        '(meta)': {
25
+            'notes': 'This config was originally in v0.2 format.',
26
+        },
27
+        tests.DUMMY_SERVICE: tests.DUMMY_CONFIG_SETTINGS.copy(),
28
+    },
29
+}
30
+VAULT_V03_CONFIG = 'sBPBrr8BFHPxSJkV/A53zk9zwDQHFxLe6UIusCVvzFQre103pcj5xxmE11lMTA0U2QTYjkhRXKkH5WegSmYpAnzReuRsYZlWWp6N4kkubf+twZ9C3EeggPm7as2Af4TICHVbX4uXpIHeQJf9y1OtqrO+SRBrgPBzgItoxsIxebxVKgyvh1CZQOSkn7BIzt9xKhDng3ubS4hQ91fB0QCumlldTbUl8tj4Xs5JbvsSlUMxRlVzZ0OgAOrSsoWELXmsp6zXFa9K6wIuZa4wQuMLQFHiA64JO1CR3I+rviWCeMlbTOuJNx6vMB5zotKJqA2hIUpN467TQ9vI4g/QTo40m5LT2EQKbIdTvBQAzcV4lOcpr5Lqt4LHED5mKvm/4YfpuuT3I3XCdWfdG5SB7ciiB4Go+xQdddy3zZMiwm1fEwIB8XjFf2cxoJdccLQ2yxf+9diedBP04EsMHrvxKDhQ7/vHl7xF2MMFTDKl3WFd23vvcjpR1JgNAKYprG/e1p/7'  # noqa: E501
31
+VAULT_V03_CONFIG_DATA = {
32
+    'global': {
33
+        'phrase': tests.DUMMY_PASSPHRASE.decode('utf-8').rstrip('\n'),
34
+    },
35
+    'services': {
36
+        '(meta)': {
37
+            'notes': 'This config was originally in v0.3 format.',
38
+        },
39
+        tests.DUMMY_SERVICE: tests.DUMMY_CONFIG_SETTINGS.copy(),
40
+    },
41
+}
42
+VAULT_STOREROOM_CONFIG_ZIPPED = b"""
43
+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
93
+"""
94
+VAULT_STOREROOM_CONFIG_DATA = {
95
+    'global': {
96
+        'phrase': tests.DUMMY_PASSPHRASE.decode('utf-8').rstrip('\n'),
97
+    },
98
+    'services': {
99
+        '(meta)': {
100
+            'notes': 'This config was originally in storeroom format.',
101
+        },
102
+        tests.DUMMY_SERVICE: tests.DUMMY_CONFIG_SETTINGS.copy(),
103
+    },
104
+}
105
+
106
+try:
107
+    from cryptography.hazmat.primitives import ciphers
108
+except ModuleNotFoundError:
109
+    CRYPTOGRAPHY_SUPPORT = False
110
+else:
111
+    CRYPTOGRAPHY_SUPPORT = True
112
+    del ciphers
113
+
114
+CANNOT_LOAD_CRYPTOGRAPHY = (
115
+    b'Cannot load the required Python module "cryptography".'
116
+)
117
+
118
+
119
+class Test001ExporterUtils:
120
+    @pytest.mark.parametrize(
121
+        ['expected', 'vault_key', 'logname', 'user', 'username'],
122
+        [
123
+            ('4username', None, None, None, '4username'),
124
+            ('3user', None, None, '3user', None),
125
+            ('3user', None, None, '3user', '4username'),
126
+            ('2logname', None, '2logname', None, None),
127
+            ('2logname', None, '2logname', None, '4username'),
128
+            ('2logname', None, '2logname', '3user', None),
129
+            ('2logname', None, '2logname', '3user', '4username'),
130
+            ('1vault_key', '1vault_key', None, None, None),
131
+            ('1vault_key', '1vault_key', None, None, '4username'),
132
+            ('1vault_key', '1vault_key', None, '3user', None),
133
+            ('1vault_key', '1vault_key', None, '3user', '4username'),
134
+            ('1vault_key', '1vault_key', '2logname', None, None),
135
+            ('1vault_key', '1vault_key', '2logname', None, '4username'),
136
+            ('1vault_key', '1vault_key', '2logname', '3user', None),
137
+            ('1vault_key', '1vault_key', '2logname', '3user', '4username'),
138
+        ],
139
+    )
140
+    def test200_get_vault_key(
141
+        self,
142
+        monkeypatch: pytest.MonkeyPatch,
143
+        expected: str,
144
+        vault_key: str | None,
145
+        logname: str | None,
146
+        user: str | None,
147
+        username: str | None,
148
+    ) -> None:
149
+        priority_list = [
150
+            ('VAULT_KEY', vault_key),
151
+            ('LOGNAME', logname),
152
+            ('USER', user),
153
+            ('USERNAME', username),
154
+        ]
155
+        runner = click.testing.CliRunner(mix_stderr=False)
156
+        with tests.isolated_vault_exporter_config(
157
+            monkeypatch=monkeypatch, runner=runner
158
+        ):
159
+            for key, value in priority_list:
160
+                if value is not None:
161
+                    monkeypatch.setenv(key, value)
162
+            assert os.fsdecode(exporter.get_vault_key()) == expected
163
+
164
+    @pytest.mark.parametrize(
165
+        ['expected', 'path'],
166
+        [
167
+            ('/tmp', '/tmp'),
168
+            ('~', os.path.curdir),
169
+            ('~/.vault', None),
170
+        ],
171
+    )
172
+    def test_210_get_vault_path(
173
+        self,
174
+        monkeypatch: pytest.MonkeyPatch,
175
+        expected: str,
176
+        path: str | None,
177
+    ) -> None:
178
+        runner = click.testing.CliRunner(mix_stderr=False)
179
+        with tests.isolated_vault_exporter_config(
180
+            monkeypatch=monkeypatch, runner=runner
181
+        ):
182
+            if path:
183
+                monkeypatch.setenv('VAULT_PATH', path)
184
+            assert os.fsdecode(
185
+                os.path.realpath(exporter.get_vault_path())
186
+            ) == os.path.realpath(os.path.expanduser(expected))
187
+
188
+    def test_300_get_vault_key_without_envs(
189
+        self, monkeypatch: pytest.MonkeyPatch
190
+    ) -> None:
191
+        monkeypatch.delenv('VAULT_KEY', raising=False)
192
+        monkeypatch.delenv('LOGNAME', raising=False)
193
+        monkeypatch.delenv('USER', raising=False)
194
+        monkeypatch.delenv('USERNAME', raising=False)
195
+        with pytest.raises(KeyError, match='VAULT_KEY'):
196
+            exporter.get_vault_key()
197
+
198
+    def test_310_get_vault_path_without_home(
199
+        self, monkeypatch: pytest.MonkeyPatch
200
+    ) -> None:
201
+        monkeypatch.setattr(os.path, 'expanduser', lambda x: x)
202
+        with pytest.raises(
203
+            RuntimeError, match='[Cc]annot determine home directory'
204
+        ):
205
+            exporter.get_vault_path()
206
+
207
+
208
+class Test002CLI:
209
+    @pytest.mark.xfail(
210
+        not CRYPTOGRAPHY_SUPPORT, reason='cryptography module not found'
211
+    )
212
+    def test_200_path_parameter(self, monkeypatch: pytest.MonkeyPatch) -> None:
213
+        runner = click.testing.CliRunner(mix_stderr=False)
214
+        with tests.isolated_vault_exporter_config(
215
+            monkeypatch=monkeypatch,
216
+            runner=runner,
217
+            vault_config=VAULT_V03_CONFIG,
218
+            vault_key=VAULT_MASTER_KEY,
219
+        ):
220
+            monkeypatch.setenv('VAULT_KEY', VAULT_MASTER_KEY)
221
+            result = runner.invoke(
222
+                exporter.derivepassphrase_export,
223
+                ['VAULT_PATH'],
224
+            )
225
+        assert not result.exception
226
+        assert (result.exit_code, result.stderr_bytes) == (0, b'')
227
+        assert json.loads(result.stdout) == VAULT_V03_CONFIG_DATA
228
+
229
+    @pytest.mark.xfail(
230
+        not CRYPTOGRAPHY_SUPPORT, reason='cryptography module not found'
231
+    )
232
+    def test_201_key_parameter(self, monkeypatch: pytest.MonkeyPatch) -> None:
233
+        runner = click.testing.CliRunner(mix_stderr=False)
234
+        with tests.isolated_vault_exporter_config(
235
+            monkeypatch=monkeypatch,
236
+            runner=runner,
237
+            vault_config=VAULT_V03_CONFIG,
238
+        ):
239
+            result = runner.invoke(
240
+                exporter.derivepassphrase_export,
241
+                ['-k', VAULT_MASTER_KEY, '.vault'],
242
+            )
243
+        assert not result.exception
244
+        assert (result.exit_code, result.stderr_bytes) == (0, b'')
245
+        assert json.loads(result.stdout) == VAULT_V03_CONFIG_DATA
246
+
247
+    @pytest.mark.xfail(
248
+        not CRYPTOGRAPHY_SUPPORT, reason='cryptography module not found'
249
+    )
250
+    @pytest.mark.parametrize(
251
+        ['version', 'config', 'config_data'],
252
+        [
253
+            pytest.param(
254
+                '0.2', VAULT_V02_CONFIG, VAULT_V02_CONFIG_DATA, id='0.2'
255
+            ),
256
+            pytest.param(
257
+                '0.3', VAULT_V03_CONFIG, VAULT_V03_CONFIG_DATA, id='0.3'
258
+            ),
259
+            pytest.param(
260
+                'storeroom',
261
+                VAULT_STOREROOM_CONFIG_ZIPPED,
262
+                VAULT_STOREROOM_CONFIG_DATA,
263
+                id='storeroom',
264
+            ),
265
+        ],
266
+    )
267
+    def test_210_load_vault_v02_v03_storeroom(
268
+        self,
269
+        monkeypatch: pytest.MonkeyPatch,
270
+        version: str,
271
+        config: str | bytes,
272
+        config_data: dict[str, Any],
273
+    ) -> None:
274
+        runner = click.testing.CliRunner(mix_stderr=False)
275
+        with tests.isolated_vault_exporter_config(
276
+            monkeypatch=monkeypatch,
277
+            runner=runner,
278
+            vault_config=config,
279
+        ):
280
+            result = runner.invoke(
281
+                exporter.derivepassphrase_export,
282
+                [
283
+                    '-f',
284
+                    f'v{version}' if version.startswith('0') else version,
285
+                    '-k',
286
+                    VAULT_MASTER_KEY,
287
+                    'VAULT_PATH',
288
+                ],
289
+            )
290
+        assert not result.exception
291
+        assert (result.exit_code, result.stderr_bytes) == (0, b'')
292
+        assert json.loads(result.stdout) == config_data
293
+
294
+    def test_300_invalid_format(
295
+        self,
296
+        monkeypatch: pytest.MonkeyPatch,
297
+    ) -> None:
298
+        runner = click.testing.CliRunner(mix_stderr=False)
299
+        with tests.isolated_vault_exporter_config(
300
+            monkeypatch=monkeypatch,
301
+            runner=runner,
302
+            vault_config=VAULT_V03_CONFIG,
303
+            vault_key=VAULT_MASTER_KEY,
304
+        ):
305
+            result = runner.invoke(
306
+                exporter.derivepassphrase_export,
307
+                ['-f', 'INVALID', 'VAULT_PATH'],
308
+                catch_exceptions=False,
309
+            )
310
+        assert isinstance(result.exception, SystemExit)
311
+        assert result.exit_code
312
+        assert result.stderr_bytes
313
+        assert b'Invalid value for' in result.stderr_bytes
314
+        assert b'-f' in result.stderr_bytes
315
+        assert b'--format' in result.stderr_bytes
316
+        assert b'INVALID' in result.stderr_bytes
317
+
318
+    @pytest.mark.xfail(
319
+        not CRYPTOGRAPHY_SUPPORT, reason='cryptography module not found'
320
+    )
321
+    def test_301_vault_config_not_found(
322
+        self,
323
+        monkeypatch: pytest.MonkeyPatch,
324
+    ) -> None:
325
+        runner = click.testing.CliRunner(mix_stderr=False)
326
+        with tests.isolated_vault_exporter_config(
327
+            monkeypatch=monkeypatch,
328
+            runner=runner,
329
+            vault_config=VAULT_V03_CONFIG,
330
+            vault_key=VAULT_MASTER_KEY,
331
+        ):
332
+            result = runner.invoke(
333
+                exporter.derivepassphrase_export,
334
+                ['does-not-exist.txt'],
335
+            )
336
+        assert isinstance(result.exception, SystemExit)
337
+        assert result.exit_code
338
+        assert result.stderr_bytes
339
+        assert (
340
+            b"Cannot parse 'does-not-exist.txt' as a valid config"
341
+            in result.stderr_bytes
342
+        )
343
+        assert CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr_bytes
344
+
345
+    @pytest.mark.xfail(
346
+        not CRYPTOGRAPHY_SUPPORT, reason='cryptography module not found'
347
+    )
348
+    def test_302_vault_config_invalid(
349
+        self,
350
+        monkeypatch: pytest.MonkeyPatch,
351
+    ) -> None:
352
+        runner = click.testing.CliRunner(mix_stderr=False)
353
+        with tests.isolated_vault_exporter_config(
354
+            monkeypatch=monkeypatch,
355
+            runner=runner,
356
+            vault_config='',
357
+            vault_key=VAULT_MASTER_KEY,
358
+        ):
359
+            result = runner.invoke(
360
+                exporter.derivepassphrase_export,
361
+                ['.vault'],
362
+            )
363
+        assert isinstance(result.exception, SystemExit)
364
+        assert result.exit_code
365
+        assert result.stderr_bytes
366
+        assert (
367
+            b"Cannot parse '.vault' as a valid config." in result.stderr_bytes
368
+        )
369
+        assert CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr_bytes
370
+
371
+
372
+class TestStoreroomExporter:
373
+    pass  # TODO(the-13th-letter): Fill in once design is stable.
374
+
375
+
376
+class TestV02Exporter:
377
+    pass  # TODO(the-13th-letter): Fill in once design is stable.
378
+
379
+
380
+class TestV03Exporter:
381
+    pass  # TODO(the-13th-letter): Fill in once design is stable.
0 382