1"""Tests for certbot._internal.cli."""
2import argparse
3import copy
4import sys
5from importlib import reload as reload_module
6import io
7import tempfile
8import unittest
9
10from acme import challenges
11from certbot import errors
12from certbot._internal import cli
13from certbot._internal import constants
14from certbot._internal.plugins import disco
15from certbot.compat import filesystem
16from certbot.compat import os
17import certbot.tests.util as test_util
18from certbot.tests.util import TempDirTestCase
19
20try:
21    import mock
22except ImportError: # pragma: no cover
23    from unittest import mock
24
25
26PLUGINS = disco.PluginsRegistry.find_all()
27
28
29class TestReadFile(TempDirTestCase):
30    """Test cli.read_file"""
31    def test_read_file(self):
32        curr_dir = os.getcwd()
33        try:
34            # On Windows current directory may be on a different drive than self.tempdir.
35            # However a relative path between two different drives is invalid. So we move to
36            # self.tempdir to ensure that we stay on the same drive.
37            os.chdir(self.tempdir)
38            # The read-only filesystem introduced with macOS Catalina can break
39            # code using relative paths below. See
40            # https://bugs.python.org/issue38295 for another example of this.
41            # Eliminating any possible symlinks in self.tempdir before passing
42            # it to os.path.relpath solves the problem. This is done by calling
43            # filesystem.realpath which removes any symlinks in the path on
44            # POSIX systems.
45            real_path = filesystem.realpath(os.path.join(self.tempdir, 'foo'))
46            relative_path = os.path.relpath(real_path)
47            self.assertRaises(
48                argparse.ArgumentTypeError, cli.read_file, relative_path)
49
50            test_contents = b'bar\n'
51            with open(relative_path, 'wb') as f:
52                f.write(test_contents)
53
54            path, contents = cli.read_file(relative_path)
55            self.assertEqual(path, os.path.abspath(path))
56            self.assertEqual(contents, test_contents)
57        finally:
58            os.chdir(curr_dir)
59
60
61class FlagDefaultTest(unittest.TestCase):
62    """Tests cli.flag_default"""
63
64    def test_default_directories(self):
65        if os.name != 'nt':
66            if sys.platform.startswith('freebsd') or sys.platform.startswith('dragonfly'):
67                self.assertEqual(cli.flag_default('config_dir'), '/usr/local/etc/letsencrypt')
68                self.assertEqual(cli.flag_default('work_dir'), '/var/db/letsencrypt')
69                self.assertEqual(cli.flag_default('logs_dir'), '/var/log/letsencrypt')
70            else:
71                self.assertEqual(cli.flag_default('config_dir'), '/etc/letsencrypt')
72                self.assertEqual(cli.flag_default('work_dir'), '/var/lib/letsencrypt')
73                self.assertEqual(cli.flag_default('logs_dir'), '/var/log/letsencrypt')
74        else:
75            self.assertEqual(cli.flag_default('config_dir'), 'C:\\Certbot')
76            self.assertEqual(cli.flag_default('work_dir'), 'C:\\Certbot\\lib')
77            self.assertEqual(cli.flag_default('logs_dir'), 'C:\\Certbot\\log')
78
79
80class ParseTest(unittest.TestCase):
81    '''Test the cli args entrypoint'''
82
83
84    def setUp(self):
85        reload_module(cli)
86
87    @staticmethod
88    def _unmocked_parse(*args, **kwargs):
89        """Get result of cli.prepare_and_parse_args."""
90        return cli.prepare_and_parse_args(PLUGINS, *args, **kwargs)
91
92    @staticmethod
93    def parse(*args, **kwargs):
94        """Mocks zope.component.getUtility and calls _unmocked_parse."""
95        with test_util.patch_display_util():
96            return ParseTest._unmocked_parse(*args, **kwargs)
97
98    def _help_output(self, args):
99        "Run a command, and return the output string for scrutiny"
100
101        output = io.StringIO()
102
103        def write_msg(message, *args, **kwargs): # pylint: disable=missing-docstring,unused-argument
104            output.write(message)
105
106        with mock.patch('certbot._internal.main.sys.stdout', new=output):
107            with test_util.patch_display_util() as mock_get_utility:
108                mock_get_utility().notification.side_effect = write_msg
109                with mock.patch('certbot._internal.main.sys.stderr'):
110                    self.assertRaises(SystemExit, self._unmocked_parse, args, output)
111
112        return output.getvalue()
113
114    @mock.patch("certbot._internal.cli.helpful.flag_default")
115    def test_cli_ini_domains(self, mock_flag_default):
116        with tempfile.NamedTemporaryFile() as tmp_config:
117            tmp_config.close()  # close now because of compatibility issues on Windows
118            # use a shim to get ConfigArgParse to pick up tmp_config
119            shim = (
120                    lambda v: copy.deepcopy(constants.CLI_DEFAULTS[v])
121                    if v != "config_files"
122                    else [tmp_config.name]
123                    )
124            mock_flag_default.side_effect = shim
125
126            namespace = self.parse(["certonly"])
127            self.assertEqual(namespace.domains, [])
128            with open(tmp_config.name, 'w') as file_h:
129                file_h.write("domains = example.com")
130            namespace = self.parse(["certonly"])
131            self.assertEqual(namespace.domains, ["example.com"])
132            namespace = self.parse(["renew"])
133            self.assertEqual(namespace.domains, [])
134
135    def test_no_args(self):
136        namespace = self.parse([])
137        for d in ('config_dir', 'logs_dir', 'work_dir'):
138            self.assertEqual(getattr(namespace, d), cli.flag_default(d))
139
140    def test_install_abspath(self):
141        cert = 'cert'
142        key = 'key'
143        chain = 'chain'
144        fullchain = 'fullchain'
145
146        with mock.patch('certbot._internal.main.install'):
147            namespace = self.parse(['install', '--cert-path', cert,
148                                    '--key-path', 'key', '--chain-path',
149                                    'chain', '--fullchain-path', 'fullchain'])
150
151        self.assertEqual(namespace.cert_path, os.path.abspath(cert))
152        self.assertEqual(namespace.key_path, os.path.abspath(key))
153        self.assertEqual(namespace.chain_path, os.path.abspath(chain))
154        self.assertEqual(namespace.fullchain_path, os.path.abspath(fullchain))
155
156    def test_help(self):
157        self._help_output(['--help'])  # assert SystemExit is raised here
158        out = self._help_output(['--help', 'all'])
159        self.assertIn("--configurator", out)
160        self.assertIn("how a certificate is deployed", out)
161        self.assertIn("--webroot-path", out)
162        self.assertNotIn("--text", out)
163        self.assertNotIn("%s", out)
164        self.assertNotIn("{0}", out)
165        self.assertNotIn("--renew-hook", out)
166
167        out = self._help_output(['-h', 'nginx'])
168        if "nginx" in PLUGINS:
169            # may be false while building distributions without plugins
170            self.assertIn("--nginx-ctl", out)
171        self.assertNotIn("--webroot-path", out)
172        self.assertNotIn("--checkpoints", out)
173
174        out = self._help_output(['-h'])
175        if "nginx" in PLUGINS:
176            self.assertIn("Use the Nginx plugin", out)
177        else:
178            self.assertIn("(the certbot nginx plugin is not", out)
179
180        out = self._help_output(['--help', 'plugins'])
181        self.assertNotIn("--webroot-path", out)
182        self.assertIn("--prepare", out)
183        self.assertIn('"plugins" subcommand', out)
184
185        # test multiple topics
186        out = self._help_output(['-h', 'renew'])
187        self.assertIn("--keep", out)
188        out = self._help_output(['-h', 'automation'])
189        self.assertIn("--keep", out)
190        out = self._help_output(['-h', 'revoke'])
191        self.assertNotIn("--keep", out)
192
193        out = self._help_output(['--help', 'install'])
194        self.assertIn("--cert-path", out)
195        self.assertIn("--key-path", out)
196
197        out = self._help_output(['--help', 'revoke'])
198        self.assertIn("--cert-path", out)
199        self.assertIn("--key-path", out)
200        self.assertIn("--reason", out)
201        self.assertIn("--delete-after-revoke", out)
202        self.assertIn("--no-delete-after-revoke", out)
203
204        out = self._help_output(['-h', 'register'])
205        self.assertNotIn("--cert-path", out)
206        self.assertNotIn("--key-path", out)
207
208        out = self._help_output(['-h'])
209        self.assertIn(cli.SHORT_USAGE, out)
210        self.assertIn(cli.COMMAND_OVERVIEW[:100], out)
211        self.assertNotIn("%s", out)
212        self.assertNotIn("{0}", out)
213
214    def test_help_no_dashes(self):
215        self._help_output(['help'])  # assert SystemExit is raised here
216
217        out = self._help_output(['help', 'all'])
218        self.assertIn("--configurator", out)
219        self.assertIn("how a certificate is deployed", out)
220        self.assertIn("--webroot-path", out)
221        self.assertNotIn("--text", out)
222        self.assertNotIn("%s", out)
223        self.assertNotIn("{0}", out)
224
225        out = self._help_output(['help', 'install'])
226        self.assertIn("--cert-path", out)
227        self.assertIn("--key-path", out)
228
229        out = self._help_output(['help', 'revoke'])
230        self.assertIn("--cert-path", out)
231        self.assertIn("--key-path", out)
232
233    def test_parse_domains(self):
234        short_args = ['-d', 'example.com']
235        namespace = self.parse(short_args)
236        self.assertEqual(namespace.domains, ['example.com'])
237
238        short_args = ['-d', 'trailing.period.com.']
239        namespace = self.parse(short_args)
240        self.assertEqual(namespace.domains, ['trailing.period.com'])
241
242        short_args = ['-d', 'example.com,another.net,third.org,example.com']
243        namespace = self.parse(short_args)
244        self.assertEqual(namespace.domains, ['example.com', 'another.net',
245                                             'third.org'])
246
247        long_args = ['--domains', 'example.com']
248        namespace = self.parse(long_args)
249        self.assertEqual(namespace.domains, ['example.com'])
250
251        long_args = ['--domains', 'trailing.period.com.']
252        namespace = self.parse(long_args)
253        self.assertEqual(namespace.domains, ['trailing.period.com'])
254
255        long_args = ['--domains', 'example.com,another.net,example.com']
256        namespace = self.parse(long_args)
257        self.assertEqual(namespace.domains, ['example.com', 'another.net'])
258
259    def test_preferred_challenges(self):
260        short_args = ['--preferred-challenges', 'http, dns']
261        namespace = self.parse(short_args)
262
263        expected = [challenges.HTTP01.typ, challenges.DNS01.typ]
264        self.assertEqual(namespace.pref_challs, expected)
265
266        short_args = ['--preferred-challenges', 'jumping-over-the-moon']
267        # argparse.ArgumentError makes argparse print more information
268        # to stderr and call sys.exit()
269        with mock.patch('sys.stderr'):
270            self.assertRaises(SystemExit, self.parse, short_args)
271
272    def test_server_flag(self):
273        namespace = self.parse('--server example.com'.split())
274        self.assertEqual(namespace.server, 'example.com')
275
276    def test_must_staple_flag(self):
277        short_args = ['--must-staple']
278        namespace = self.parse(short_args)
279        self.assertIs(namespace.must_staple, True)
280        self.assertIs(namespace.staple, True)
281
282    def _check_server_conflict_message(self, parser_args, conflicting_args):
283        try:
284            self.parse(parser_args)
285            self.fail(  # pragma: no cover
286                "The following flags didn't conflict with "
287                '--server: {0}'.format(', '.join(conflicting_args)))
288        except errors.Error as error:
289            self.assertIn('--server', str(error))
290            for arg in conflicting_args:
291                self.assertIn(arg, str(error))
292
293    def test_staging_flag(self):
294        short_args = ['--staging']
295        namespace = self.parse(short_args)
296        self.assertIs(namespace.staging, True)
297        self.assertEqual(namespace.server, constants.STAGING_URI)
298
299        short_args += '--server example.com'.split()
300        self._check_server_conflict_message(short_args, '--staging')
301
302    def _assert_dry_run_flag_worked(self, namespace, existing_account):
303        self.assertIs(namespace.dry_run, True)
304        self.assertIs(namespace.break_my_certs, True)
305        self.assertIs(namespace.staging, True)
306        self.assertEqual(namespace.server, constants.STAGING_URI)
307
308        if existing_account:
309            self.assertIs(namespace.tos, True)
310            self.assertIs(namespace.register_unsafely_without_email, True)
311        else:
312            self.assertIs(namespace.tos, False)
313            self.assertIs(namespace.register_unsafely_without_email, False)
314
315    def test_dry_run_flag(self):
316        config_dir = tempfile.mkdtemp()
317        short_args = '--dry-run --config-dir {0}'.format(config_dir).split()
318        self.assertRaises(errors.Error, self.parse, short_args)
319
320        self._assert_dry_run_flag_worked(
321            self.parse(short_args + ['auth']), False)
322        self._assert_dry_run_flag_worked(
323            self.parse(short_args + ['certonly']), False)
324        self._assert_dry_run_flag_worked(
325            self.parse(short_args + ['renew']), False)
326
327        account_dir = os.path.join(config_dir, constants.ACCOUNTS_DIR)
328        filesystem.mkdir(account_dir)
329        filesystem.mkdir(os.path.join(account_dir, 'fake_account_dir'))
330
331        self._assert_dry_run_flag_worked(self.parse(short_args + ['auth']), True)
332        self._assert_dry_run_flag_worked(self.parse(short_args + ['renew']), True)
333        self._assert_dry_run_flag_worked(self.parse(short_args + ['certonly']), True)
334
335        short_args += ['certonly']
336
337        # `--dry-run --server example.com` should emit example.com
338        self.assertEqual(self.parse(short_args + ['--server', 'example.com']).server,
339                         'example.com')
340
341        # `--dry-run --server STAGING_URI` should emit STAGING_URI
342        self.assertEqual(self.parse(short_args + ['--server', constants.STAGING_URI]).server,
343                         constants.STAGING_URI)
344
345        # `--dry-run --server LIVE` should emit STAGING_URI
346        self.assertEqual(self.parse(short_args + ['--server', cli.flag_default("server")]).server,
347                         constants.STAGING_URI)
348
349        # `--dry-run --server example.com --staging` should emit an error
350        conflicts = ['--staging']
351        self._check_server_conflict_message(short_args + ['--server', 'example.com', '--staging'],
352                                            conflicts)
353
354    def test_option_was_set(self):
355        key_size_option = 'rsa_key_size'
356        key_size_value = cli.flag_default(key_size_option)
357        self.parse('--rsa-key-size {0}'.format(key_size_value).split())
358
359        self.assertIs(cli.option_was_set(key_size_option, key_size_value), True)
360        self.assertIs(cli.option_was_set('no_verify_ssl', True), True)
361
362        config_dir_option = 'config_dir'
363        self.assertFalse(cli.option_was_set(
364            config_dir_option, cli.flag_default(config_dir_option)))
365        self.assertFalse(cli.option_was_set(
366            'authenticator', cli.flag_default('authenticator')))
367
368    def test_ecdsa_key_option(self):
369        elliptic_curve_option = 'elliptic_curve'
370        elliptic_curve_option_value = cli.flag_default(elliptic_curve_option)
371        self.parse('--elliptic-curve {0}'.format(elliptic_curve_option_value).split())
372        self.assertIs(cli.option_was_set(elliptic_curve_option, elliptic_curve_option_value), True)
373
374    def test_invalid_key_type(self):
375        key_type_option = 'key_type'
376        key_type_value = cli.flag_default(key_type_option)
377        self.parse('--key-type {0}'.format(key_type_value).split())
378        self.assertIs(cli.option_was_set(key_type_option, key_type_value), True)
379
380        with self.assertRaises(SystemExit):
381            self.parse("--key-type foo")
382
383    def test_encode_revocation_reason(self):
384        for reason, code in constants.REVOCATION_REASONS.items():
385            namespace = self.parse(['--reason', reason])
386            self.assertEqual(namespace.reason, code)
387        for reason, code in constants.REVOCATION_REASONS.items():
388            namespace = self.parse(['--reason', reason.upper()])
389            self.assertEqual(namespace.reason, code)
390
391    def test_force_interactive(self):
392        self.assertRaises(
393            errors.Error, self.parse, "renew --force-interactive".split())
394        self.assertRaises(
395            errors.Error, self.parse, "-n --force-interactive".split())
396
397    def test_deploy_hook_conflict(self):
398        with mock.patch("certbot._internal.cli.sys.stderr"):
399            self.assertRaises(SystemExit, self.parse,
400                              "--renew-hook foo --deploy-hook bar".split())
401
402    def test_deploy_hook_matches_renew_hook(self):
403        value = "foo"
404        namespace = self.parse(["--renew-hook", value,
405                                "--deploy-hook", value,
406                                "--disable-hook-validation"])
407        self.assertEqual(namespace.deploy_hook, value)
408        self.assertEqual(namespace.renew_hook, value)
409
410    def test_deploy_hook_sets_renew_hook(self):
411        value = "foo"
412        namespace = self.parse(
413            ["--deploy-hook", value, "--disable-hook-validation"])
414        self.assertEqual(namespace.deploy_hook, value)
415        self.assertEqual(namespace.renew_hook, value)
416
417    def test_renew_hook_conflict(self):
418        with mock.patch("certbot._internal.cli.sys.stderr"):
419            self.assertRaises(SystemExit, self.parse,
420                              "--deploy-hook foo --renew-hook bar".split())
421
422    def test_renew_hook_matches_deploy_hook(self):
423        value = "foo"
424        namespace = self.parse(["--deploy-hook", value,
425                                "--renew-hook", value,
426                                "--disable-hook-validation"])
427        self.assertEqual(namespace.deploy_hook, value)
428        self.assertEqual(namespace.renew_hook, value)
429
430    def test_renew_hook_does_not_set_renew_hook(self):
431        value = "foo"
432        namespace = self.parse(
433            ["--renew-hook", value, "--disable-hook-validation"])
434        self.assertIsNone(namespace.deploy_hook)
435        self.assertEqual(namespace.renew_hook, value)
436
437    def test_max_log_backups_error(self):
438        with mock.patch('certbot._internal.cli.sys.stderr'):
439            self.assertRaises(
440                SystemExit, self.parse, "--max-log-backups foo".split())
441            self.assertRaises(
442                SystemExit, self.parse, "--max-log-backups -42".split())
443
444    def test_max_log_backups_success(self):
445        value = "42"
446        namespace = self.parse(["--max-log-backups", value])
447        self.assertEqual(namespace.max_log_backups, int(value))
448
449    def test_unchanging_defaults(self):
450        namespace = self.parse([])
451        self.assertEqual(namespace.domains, [])
452        self.assertEqual(namespace.pref_challs, [])
453
454        namespace.pref_challs = [challenges.HTTP01.typ]
455        namespace.domains = ['example.com']
456
457        namespace = self.parse([])
458        self.assertEqual(namespace.domains, [])
459        self.assertEqual(namespace.pref_challs, [])
460
461    def test_no_directory_hooks_set(self):
462        self.assertFalse(self.parse(["--no-directory-hooks"]).directory_hooks)
463
464    def test_no_directory_hooks_unset(self):
465        self.assertIs(self.parse([]).directory_hooks, True)
466
467    def test_delete_after_revoke(self):
468        namespace = self.parse(["--delete-after-revoke"])
469        self.assertIs(namespace.delete_after_revoke, True)
470
471    def test_delete_after_revoke_default(self):
472        namespace = self.parse([])
473        self.assertIsNone(namespace.delete_after_revoke)
474
475    def test_no_delete_after_revoke(self):
476        namespace = self.parse(["--no-delete-after-revoke"])
477        self.assertIs(namespace.delete_after_revoke, False)
478
479    def test_allow_subset_with_wildcard(self):
480        self.assertRaises(errors.Error, self.parse,
481                          "--allow-subset-of-names -d *.example.org".split())
482
483    def test_route53_no_revert(self):
484        for help_flag in ['-h', '--help']:
485            for topic in ['all', 'plugins', 'dns-route53']:
486                self.assertNotIn('certbot-route53:auth', self._help_output([help_flag, topic]))
487
488
489class DefaultTest(unittest.TestCase):
490    """Tests for certbot._internal.cli._Default."""
491
492
493    def setUp(self):
494        # pylint: disable=protected-access
495        self.default1 = cli._Default()
496        self.default2 = cli._Default()
497
498    def test_boolean(self):
499        self.assertIs(bool(self.default1), False)
500        self.assertIs(bool(self.default2), False)
501
502    def test_equality(self):
503        self.assertEqual(self.default1, self.default2)
504
505    def test_hash(self):
506        self.assertEqual(hash(self.default1), hash(self.default2))
507
508
509class SetByCliTest(unittest.TestCase):
510    """Tests for certbot.set_by_cli and related functions."""
511
512
513    def setUp(self):
514        reload_module(cli)
515
516    def test_deploy_hook(self):
517        self.assertTrue(_call_set_by_cli(
518            'renew_hook', '--deploy-hook foo'.split(), 'renew'))
519
520    def test_webroot_map(self):
521        args = '-w /var/www/html -d example.com'.split()
522        verb = 'renew'
523        self.assertIs(_call_set_by_cli('webroot_map', args, verb), True)
524
525
526def _call_set_by_cli(var, args, verb):
527    with mock.patch('certbot._internal.cli.helpful_parser') as mock_parser:
528        with test_util.patch_display_util():
529            mock_parser.args = args
530            mock_parser.verb = verb
531            return cli.set_by_cli(var)
532
533
534if __name__ == '__main__':
535    unittest.main()  # pragma: no cover
536