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