6e05ad2a2a6d8de341a84dc8257911e21538c64e
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

1) # SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2) #
3) # SPDX-License-Identifier: MIT
4) 
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

5) from __future__ import annotations
6) 
7) import base64
8) import contextlib
9) import errno
10) import json
11) import os
12) from typing import Any, cast, TYPE_CHECKING, NamedTuple
13) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

14) import click.testing
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

15) import derivepassphrase as dpp
16) import derivepassphrase.cli as cli
17) import ssh_agent_client.types
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

18) import pytest
19) 
20) DUMMY_SERVICE = 'service1'
21) DUMMY_PASSPHRASE = b'my secret passphrase\n'
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

22) DUMMY_CONFIG_SETTINGS = {"length": 10, "upper": 1, "lower": 1, "repeat": 5,
23)                          "number": 1, "space": 1, "dash": 1, "symbol": 1}
24) DUMMY_RESULT_PASSPHRASE = b'.2V_QJkd o'
25) DUMMY_RESULT_KEY1 = b'E<b<{ -7iG'
26) DUMMY_PHRASE_FROM_KEY1_RAW = (
27)     b'\x00\x00\x00\x0bssh-ed25519'
28)     b'\x00\x00\x00@\xf0\x98\x19\x80l\x1a\x97\xd5&\x03n'
29)     b'\xcc\xe3e\x8f\x86f\x07\x13\x19\x13\t!33\xf9\xe46S'
30)     b'\x1d\xaf\xfd\r\x08\x1f\xec\xf8s\x9b\x8c_U9\x16|ST,'
31)     b'\x1eR\xbb0\xed\x7f\x89\xe2/iQU\xd8\x9e\xa6\x02'
32) )
33) DUMMY_PHRASE_FROM_KEY1 = b'8JgZgGwal9UmA27M42WPhmYHExkTCSEzM/nkNlMdr/0NCB/s+HObjF9VORZ8U1QsHlK7MO1/ieIvaVFV2J6mAg=='
34) 
35) # See Ed25519 and RSA test keys in test_key_signing.py
36) DUMMY_KEY1 = 'AAAAC3NzaC1lZDI1NTE5AAAAIIF4gWgm1gJIXw//Mkhv5MEwidwcakUGCekJD/vCEml2'
37) DUMMY_KEY2 = 'AAAAB3NzaC1yc2EAAAADAQABAAABgQCxoe7pezhxWy4NI0mUwKqg9WCYOAS+IjxN9eYcqpfcmQiojcuy9XsiN/xYJ1O94SrsKS5mEia2xHnYA4RUChTyYNcM2v6cnnBQ/N/VQhpGMN7SVxdbhKUXTWFCwbjBgO6rGyHB6WtoH8vd7TOEPt+NgcXwhsWyoaUUdYTA62V+GF9vEmxMaC4ubgDz+B0QkPnauSoNxmkhcIe0lsLNb1pClZyz88PDnKXCX/d0HuN/HJ+sbPg7dCvOyqFYSyKn3uY6bCXqoIdurxXzH3O7z0P8f5sbmKOrGGKNuNxVRbeVl/D/3uDL0nqsbfUc1qvkfwbJwtMXC4IV6kOZMSk2BAsqh7x48gQ+rhYeEVSi8F3CWs4HJQoqrGt7K9a3mCSlMBHP70u3w6ME7eumoryxlUofewTd17ZEkzdX08l2ZlKzZvwQUrc+xQZ2Uw8z2mfW6Ti4gi0pYGaig7Ke4PwuXpo/C5YAWfeXycsvJZ2uaYRjMdZeJGNAnHLUGLkBscw5aI8='
38) 
39) 
40) class IncompatibleConfiguration(NamedTuple):
41)     other_options: list[tuple[str, ...]]
42)     needs_service: bool | None
43)     input: bytes | None
44) 
45) class SingleConfiguration(NamedTuple):
46)     needs_service: bool | None
47)     input: bytes | None
48)     check_success: bool
49) 
50) class OptionCombination(NamedTuple):
51)     options: list[str]
52)     incompatible: bool
53)     needs_service: bool | None
54)     input: bytes | None
55)     check_success: bool
56) 
57) PASSWORD_GENERATION_OPTIONS: list[tuple[str, ...]] = [
58)     ('--phrase',), ('--key',), ('--length', '20'), ('--repeat', '20'),
59)     ('--lower', '1'), ('--upper', '1'), ('--number', '1'),
60)     ('--space', '1'), ('--dash', '1'), ('--symbol', '1')
61) ]
62) CONFIGURATION_OPTIONS: list[tuple[str, ...]] = [
63)     ('--notes',), ('--config',), ('--delete',), ('--delete-globals',),
64)     ('--clear',)
65) ]
66) CONFIGURATION_COMMANDS: list[tuple[str, ...]] = [
67)     ('--notes',), ('--delete',), ('--delete-globals',), ('--clear',)
68) ]
69) STORAGE_OPTIONS: list[tuple[str, ...]] = [
70)     ('--export', '-'), ('--import', '-')
71) ]
72) INCOMPATIBLE: dict[tuple[str, ...], IncompatibleConfiguration] = {
73)     ('--phrase',): IncompatibleConfiguration(
74)         [('--key',)] + CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
75)         True, DUMMY_PASSPHRASE),
76)     ('--key',): IncompatibleConfiguration(
77)         CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
78)         True, DUMMY_PASSPHRASE),
79)     ('--length', '20'): IncompatibleConfiguration(
80)         CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
81)         True, DUMMY_PASSPHRASE),
82)     ('--repeat', '20'): IncompatibleConfiguration(
83)         CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
84)         True, DUMMY_PASSPHRASE),
85)     ('--lower', '1'): IncompatibleConfiguration(
86)         CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
87)         True, DUMMY_PASSPHRASE),
88)     ('--upper', '1'): IncompatibleConfiguration(
89)         CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
90)         True, DUMMY_PASSPHRASE),
91)     ('--number', '1'): IncompatibleConfiguration(
92)         CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
93)         True, DUMMY_PASSPHRASE),
94)     ('--space', '1'): IncompatibleConfiguration(
95)         CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
96)         True, DUMMY_PASSPHRASE),
97)     ('--dash', '1'): IncompatibleConfiguration(
98)         CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
99)         True, DUMMY_PASSPHRASE),
100)     ('--symbol', '1'): IncompatibleConfiguration(
101)         CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
102)         True, DUMMY_PASSPHRASE),
103)     ('--notes',): IncompatibleConfiguration(
104)         [('--config',), ('--delete',), ('--delete-globals',),
105)          ('--clear',)] + STORAGE_OPTIONS,
106)         True, None),
107)     ('--config', '-p'): IncompatibleConfiguration(
108)         [('--delete',), ('--delete-globals',),
109)          ('--clear',)] + STORAGE_OPTIONS,
110)         None, DUMMY_PASSPHRASE),
111)     ('--delete',): IncompatibleConfiguration(
112)         [('--delete-globals',), ('--clear',)] + STORAGE_OPTIONS, True, None),
113)     ('--delete-globals',): IncompatibleConfiguration(
114)         [('--clear',)] + STORAGE_OPTIONS, False, None),
115)     ('--clear',): IncompatibleConfiguration(STORAGE_OPTIONS, False, None),
116)     ('--export', '-'): IncompatibleConfiguration(
117)         [('--import', '-')], False, None),
118)     ('--import', '-'): IncompatibleConfiguration(
119)         [], False, None),
120) }
121) SINGLES: dict[tuple[str, ...], SingleConfiguration] = {
122)     ('--phrase',): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
123)     ('--key',): SingleConfiguration(True, None, False),
124)     ('--length', '20'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
125)     ('--repeat', '20'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
126)     ('--lower', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
127)     ('--upper', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
128)     ('--number', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
129)     ('--space', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
130)     ('--dash', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
131)     ('--symbol', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
132)     ('--notes',): SingleConfiguration(True, None, False),
133)     ('--config', '-p'): SingleConfiguration(None, DUMMY_PASSPHRASE, False),
134)     ('--delete',): SingleConfiguration(True, None, False),
135)     ('--delete-globals',): SingleConfiguration(False, None, True),
136)     ('--clear',): SingleConfiguration(False, None, True),
137)     ('--export', '-'): SingleConfiguration(False, None, True),
138)     ('--import', '-'): SingleConfiguration(False, b'{"services": {}}', True),
139) }
140) INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = []
141) config: OptionCombination | SingleConfiguration
142) for opt, config in INCOMPATIBLE.items():
143)     for opt2 in config.other_options:
144)         INTERESTING_OPTION_COMBINATIONS.extend([
145)             OptionCombination(options=list(opt + opt2), incompatible=True,
146)                               needs_service=config.needs_service,
147)                               input=config.input, check_success=False),
148)             OptionCombination(options=list(opt2 + opt), incompatible=True,
149)                               needs_service=config.needs_service,
150)                               input=config.input, check_success=False)
151)         ])
152) for opt, config in SINGLES.items():
153)     INTERESTING_OPTION_COMBINATIONS.append(
154)         OptionCombination(options=list(opt), incompatible=False,
155)                           needs_service=config.needs_service,
156)                           input=config.input,
157)                           check_success=config.check_success))
158) 
159) @contextlib.contextmanager
160) def isolated_config(
161)     monkeypatch: Any, runner: click.testing.CliRunner, config: Any,
162) ):
163)     with runner.isolated_filesystem():
164)         monkeypatch.setenv('HOME', os.getcwd())
165)         monkeypatch.setenv('USERPROFILE', os.getcwd())
166)         monkeypatch.delenv(cli.prog_name.replace(' ', '_').upper() + '_PATH',
167)                            raising=False)
168)         os.makedirs(os.path.dirname(cli._config_filename()), exist_ok=True)
169)         with open(cli._config_filename(), 'wt') as outfile:
170)             json.dump(config, outfile)
171)         yield
172) 
173) 
174) def test_100_save_bad_config(monkeypatch: Any) -> None:
175)     runner = click.testing.CliRunner()
176)     with isolated_config(monkeypatch=monkeypatch, runner=runner, config={}):
177)         with pytest.raises(ValueError, match='Invalid vault config'):
178)             cli._save_config(None)  # type: ignore
179) 
180) 
181) def test_101_prompt_for_selection_multiple(monkeypatch: Any) -> None:
182)     @click.command()
183)     @click.option('--heading', default='Our menu:')
184)     @click.argument('items', nargs=-1)
185)     def driver(heading, items):
186)         # from https://montypython.fandom.com/wiki/Spam#The_menu
187)         items = items or [
188)             'Egg and bacon',
189)             'Egg, sausage and bacon',
190)             'Egg and spam',
191)             'Egg, bacon and spam',
192)             'Egg, bacon, sausage and spam',
193)             'Spam, bacon, sausage and spam',
194)             'Spam, egg, spam, spam, bacon and spam',
195)             'Spam, spam, spam, egg and spam',
196)             ('Spam, spam, spam, spam, spam, spam, baked beans, '
197)              'spam, spam, spam and spam'),
198)             ('Lobster thermidor aux crevettes with a mornay sauce '
199)              'garnished with truffle paté, brandy '
200)              'and a fried egg on top and spam'),
201)         ]
202)         index = cli._prompt_for_selection(items, heading=heading)
203)         click.echo('A fine choice: ', nl=False)
204)         click.echo(items[index])
205)         click.echo('(Note: Vikings strictly optional.)')
206)     runner = click.testing.CliRunner(mix_stderr=True)
207)     result = runner.invoke(driver, [], input='9')
208)     assert result.exit_code == 0, 'driver program failed'
209)     assert result.stdout == '''\
210) Our menu:
211) [1] Egg and bacon
212) [2] Egg, sausage and bacon
213) [3] Egg and spam
214) [4] Egg, bacon and spam
215) [5] Egg, bacon, sausage and spam
216) [6] Spam, bacon, sausage and spam
217) [7] Spam, egg, spam, spam, bacon and spam
218) [8] Spam, spam, spam, egg and spam
219) [9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
220) [10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
221) Your selection? (1-10, leave empty to abort): 9
222) A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
223) (Note: Vikings strictly optional.)
224) ''', 'driver program produced unexpected output'
225)     result = runner.invoke(driver, ['--heading='], input='',
226)                            catch_exceptions=True)
227)     assert result.exit_code > 0, 'driver program succeeded?!'
228)     assert result.stdout == '''\
229) [1] Egg and bacon
230) [2] Egg, sausage and bacon
231) [3] Egg and spam
232) [4] Egg, bacon and spam
233) [5] Egg, bacon, sausage and spam
234) [6] Spam, bacon, sausage and spam
235) [7] Spam, egg, spam, spam, bacon and spam
236) [8] Spam, spam, spam, egg and spam
237) [9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
238) [10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
239) Your selection? (1-10, leave empty to abort): \n''', (
240)         'driver program produced unexpected output'
241)     )
242)     assert isinstance(result.exception, IndexError), (
243)         'driver program did not raise IndexError?!'
244)     )
245) 
246) 
247) def test_102_prompt_for_selection_single(monkeypatch: Any) -> None:
248)     @click.command()
249)     @click.option('--item', default='baked beans')
250)     @click.argument('prompt')
251)     def driver(item, prompt):
252)         try:
253)             cli._prompt_for_selection([item], heading='',
254)                                       single_choice_prompt=prompt)
255)         except IndexError as e:
256)             click.echo('Boo.')
257)             raise e
258)         else:
259)             click.echo('Great!')
260)     runner = click.testing.CliRunner(mix_stderr=True)
261)     result = runner.invoke(driver, ['Will replace with spam. Confirm, y/n?'],
262)                            input='y')
263)     assert result.exit_code == 0, 'driver program failed'
264)     assert result.stdout == '''\
265) [1] baked beans
266) Will replace with spam. Confirm, y/n? y
267) Great!
268) ''', 'driver program produced unexpected output'
269)     result = runner.invoke(driver,
270)                            ['Will replace with spam, okay? ' +
271)                             '(Please say "y" or "n".)'],
272)                            input='')
273)     assert result.exit_code > 0, 'driver program succeeded?!'
274)     assert result.stdout == '''\
275) [1] baked beans
276) Will replace with spam, okay? (Please say "y" or "n".): 
277) Boo.
278) ''', 'driver program produced unexpected output'
279)     assert isinstance(result.exception, IndexError), (
280)         'driver program did not raise IndexError?!'
281)     )
282) 
283) 
284) def test_103_prompt_for_passphrase(monkeypatch: Any) -> None:
285)     monkeypatch.setattr(click, 'prompt',
286)                         lambda *a, **kw: json.dumps({'args': a, 'kwargs': kw}))
287)     res = json.loads(cli._prompt_for_passphrase())
288)     assert 'args' in res and 'kwargs' in res, (
289)         'missing arguments to passphrase prompt'
290)     )
291)     assert res['args'][:1] == ['Passphrase'], (
292)         'missing arguments to passphrase prompt'
293)     )
294)     assert (res['kwargs'].get('default') == ''
295)             and not res['kwargs'].get('show_default', True)), (
296)         'missing arguments to passphrase prompt'
297)     )
298)     assert res['kwargs'].get('err') and res['kwargs'].get('hide_input'), (
299)         'missing arguments to passphrase prompt'
300)     )
301) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

302) 
303) def test_200_help_output():
304)     runner = click.testing.CliRunner(mix_stderr=False)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

305)     result = runner.invoke(cli.derivepassphrase, ['--help'],
306)                            catch_exceptions=False)
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

307)     assert result.exit_code == 0
308)     assert 'Password generation:\n' in result.output, (
309)         'Option groups not respected in help text.'
310)     )
311)     assert 'Use NUMBER=0, e.g. "--symbol 0"' in result.output, (
312)         'Option group epilog not printed.'
313)     )
314) 
315) @pytest.mark.parametrize(['charset_name'],
316)                          [('lower',), ('upper',), ('number',), ('space',),
317)                           ('dash',), ('symbol',)])
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

318) def test_201_disable_character_set(
319)     monkeypatch: Any, charset_name: str
320) ) -> None:
321)     monkeypatch.setattr(cli, '_prompt_for_passphrase',
322)                         lambda *a, **kw: DUMMY_PASSPHRASE.decode('UTF-8'))
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

323)     option = f'--{charset_name}'
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

324)     charset = dpp.Vault._CHARSETS[charset_name].decode('ascii')
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

325)     runner = click.testing.CliRunner(mix_stderr=False)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

326)     result = runner.invoke(cli.derivepassphrase,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

327)                            [option, '0', '-p', DUMMY_SERVICE],
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

328)                            input=DUMMY_PASSPHRASE, catch_exceptions=False)
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

329)     assert result.exit_code == 0, (
330)         f'program died unexpectedly with exit code {result.exit_code}'
331)     )
332)     assert not result.stderr_bytes, (
333)         f'program barfed on stderr: {result.stderr_bytes}'
334)     )
335)     for c in charset:
336)         assert c not in result.stdout, (
337)             f'derived password contains forbidden character {c!r}: '
338)             f'{result.stdout!r}'
339)         )
340) 
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

341) def test_202_disable_repetition(monkeypatch: Any) -> None:
342)     monkeypatch.setattr(cli, '_prompt_for_passphrase',
343)                         lambda *a, **kw: DUMMY_PASSPHRASE.decode('UTF-8'))
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

344)     runner = click.testing.CliRunner(mix_stderr=False)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

345)     result = runner.invoke(cli.derivepassphrase,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

346)                            ['--repeat', '0', '-p', DUMMY_SERVICE],
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

347)                            input=DUMMY_PASSPHRASE, catch_exceptions=False)
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

348)     assert result.exit_code == 0, (
349)         f'program died unexpectedly with exit code {result.exit_code}'
350)     )
351)     assert not result.stderr_bytes, (
352)         f'program barfed on stderr: {result.stderr_bytes}'
353)     )
354)     passphrase = result.stdout.rstrip('\r\n')
355)     for i in range(len(passphrase) - 1):
356)         assert passphrase[i:i+1] != passphrase[i+1:i+2], (
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

357)             f'derived password contains repeated character '
358)             f'at position {i}: {result.stdout!r}'
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

359)         )