
"""Tests for certbot._internal.cert_manager."""
# pylint: disable=protected-access
import re
import shutil
import sys
import tempfile
import unittest
from unittest import mock

import configobj
import pytest

from certbot import configuration
from certbot import errors
from certbot._internal import san
from certbot._internal.storage import ALL_FOUR
from certbot._internal.tests import storage_test
from certbot.compat import filesystem
from certbot.compat import os
from certbot.display import util as display_util
from certbot.tests import util as test_util


class BaseCertManagerTest(test_util.ConfigTestCase):
    """Base class for setting up Cert Manager tests.
    """
    def setUp(self):
        super().setUp()

        self.config.quiet = False
        filesystem.makedirs(self.config.renewal_configs_dir)

        self.domains = {
            "example.org": None,
            "other.com": os.path.join(self.config.config_dir, "specialarchive")
        }
        self.config_files = {domain: self._set_up_config(domain, self.domains[domain])
            for domain in self.domains}

        # We also create a file that isn't a renewal config in the same
        # location to test that logic that reads in all-and-only renewal
        # configs will ignore it and NOT attempt to parse it.
        with open(os.path.join(self.config.renewal_configs_dir, "IGNORE.THIS"), "w") as junk:
            junk.write("This file should be ignored!")

    def _set_up_config(self, domain, custom_archive):
        # TODO: maybe provide NamespaceConfig.make_dirs?
        # TODO: main() should create those dirs, c.f. #902
        filesystem.makedirs(os.path.join(self.config.live_dir, domain))
        config_file = configobj.ConfigObj()

        if custom_archive is not None:
            filesystem.makedirs(custom_archive)
            config_file["archive_dir"] = custom_archive
        else:
            filesystem.makedirs(os.path.join(self.config.default_archive_dir, domain))

        for kind in ALL_FOUR:
            config_file[kind] = os.path.join(self.config.live_dir, domain,
                                        kind + ".pem")

        config_file.filename = os.path.join(self.config.renewal_configs_dir,
                                       domain + ".conf")
        config_file.write()
        return config_file


class DeleteTest(storage_test.BaseRenewableCertTest):
    """Tests for certbot._internal.cert_manager.delete
    """

    def _call(self):
        from certbot._internal import cert_manager
        cert_manager.delete(self.config)

    @test_util.patch_display_util()
    @mock.patch('certbot.display.util.notify')
    @mock.patch('certbot._internal.cert_manager.lineage_for_certname')
    @mock.patch('certbot._internal.storage.delete_files')
    def test_delete_from_config_yes(self, mock_delete_files, mock_lineage_for_certname,
        mock_notify, mock_util):
        """Test delete"""
        mock_lineage_for_certname.return_value = self.test_rc
        mock_util().yesno.return_value = True
        self.config.certname = "example.org"
        self._call()
        mock_delete_files.assert_called_once_with(self.config, "example.org")
        mock_notify.assert_called_once_with(
            "Deleted all files relating to certificate example.org."
        )

    @test_util.patch_display_util()
    @mock.patch('certbot._internal.cert_manager.lineage_for_certname')
    @mock.patch('certbot._internal.storage.delete_files')
    def test_delete_from_config_no(self, mock_delete_files, mock_lineage_for_certname,
        mock_util):
        """Test delete"""
        mock_lineage_for_certname.return_value = self.test_rc
        mock_util().yesno.return_value = False
        self.config.certname = "example.org"
        self._call()
        assert mock_delete_files.call_count == 0

    @test_util.patch_display_util()
    @mock.patch('certbot._internal.cert_manager.lineage_for_certname')
    @mock.patch('certbot._internal.storage.delete_files')
    def test_delete_interactive_single_yes(self, mock_delete_files, mock_lineage_for_certname,
        mock_util):
        """Test delete"""
        mock_lineage_for_certname.return_value = self.test_rc
        mock_util().checklist.return_value = (display_util.OK, ["example.org"])
        mock_util().yesno.return_value = True
        self._call()
        mock_delete_files.assert_called_once_with(self.config, "example.org")

    @test_util.patch_display_util()
    @mock.patch('certbot._internal.cert_manager.lineage_for_certname')
    @mock.patch('certbot._internal.storage.delete_files')
    def test_delete_interactive_single_no(self, mock_delete_files, mock_lineage_for_certname,
        mock_util):
        """Test delete"""
        mock_lineage_for_certname.return_value = self.test_rc
        mock_util().checklist.return_value = (display_util.OK, ["example.org"])
        mock_util().yesno.return_value = False
        self._call()
        assert mock_delete_files.call_count == 0

    @test_util.patch_display_util()
    @mock.patch('certbot._internal.cert_manager.lineage_for_certname')
    @mock.patch('certbot._internal.storage.delete_files')
    def test_delete_interactive_multiple_yes(self, mock_delete_files, mock_lineage_for_certname,
        mock_util):
        """Test delete"""
        mock_lineage_for_certname.return_value = self.test_rc
        mock_util().checklist.return_value = (display_util.OK, ["example.org", "other.org"])
        mock_util().yesno.return_value = True
        self._call()
        mock_delete_files.assert_any_call(self.config, "example.org")
        mock_delete_files.assert_any_call(self.config, "other.org")
        assert mock_delete_files.call_count == 2

    @test_util.patch_display_util()
    @mock.patch('certbot._internal.cert_manager.lineage_for_certname')
    @mock.patch('certbot._internal.storage.delete_files')
    def test_delete_interactive_multiple_no(self, mock_delete_files, mock_lineage_for_certname,
        mock_util):
        """Test delete"""
        mock_lineage_for_certname.return_value = self.test_rc
        mock_util().checklist.return_value = (display_util.OK, ["example.org", "other.org"])
        mock_util().yesno.return_value = False
        self._call()
        assert mock_delete_files.call_count == 0


class CertificatesTest(BaseCertManagerTest):
    """Tests for certbot._internal.cert_manager.certificates
    """
    def _certificates(self, *args, **kwargs):
        from certbot._internal.cert_manager import certificates
        return certificates(*args, **kwargs)

    @mock.patch('certbot._internal.cert_manager.logger')
    @test_util.patch_display_util()
    def test_certificates_parse_fail(self, mock_utility, mock_logger):
        self._certificates(self.config)
        assert mock_logger.warning.called #pylint: disable=no-member
        assert mock_utility.called

    @mock.patch('certbot._internal.cert_manager.logger')
    @test_util.patch_display_util()
    def test_certificates_quiet(self, mock_utility, mock_logger):
        self.config.quiet = True
        self._certificates(self.config)
        assert mock_utility.notification.called is False
        assert mock_logger.warning.called #pylint: disable=no-member

    @mock.patch('certbot.crypto_util.verify_renewable_cert')
    @mock.patch('certbot._internal.cert_manager.logger')
    @test_util.patch_display_util()
    @mock.patch("certbot._internal.storage.RenewableCert")
    @mock.patch('certbot._internal.cert_manager._report_human_readable')
    def test_certificates_parse_success(self, mock_report, mock_renewable_cert,
        mock_utility, mock_logger, mock_verifier):
        mock_verifier.return_value = None
        mock_report.return_value = ""
        self._certificates(self.config)
        assert mock_logger.warning.called is False
        assert mock_report.called
        assert mock_utility.called
        assert mock_renewable_cert.called

    @mock.patch('certbot._internal.cert_manager.logger')
    @test_util.patch_display_util()
    def test_certificates_no_files(self, mock_utility, mock_logger):
        empty_tempdir = tempfile.mkdtemp()
        empty_config = configuration.NamespaceConfig(mock.MagicMock(
            config_dir=os.path.join(empty_tempdir, "config"),
            work_dir=os.path.join(empty_tempdir, "work"),
            logs_dir=os.path.join(empty_tempdir, "logs"),
            quiet=False
        ))

        filesystem.makedirs(empty_config.renewal_configs_dir)
        self._certificates(empty_config)
        assert mock_logger.warning.called is False
        assert mock_utility.called
        shutil.rmtree(empty_tempdir)

    @mock.patch('certbot.crypto_util.get_serial_from_cert')
    @mock.patch('certbot._internal.cert_manager.ocsp.RevocationChecker.ocsp_revoked')
    def test_report_human_readable(self, mock_revoked, mock_serial):
        mock_revoked.return_value = None
        mock_serial.return_value = 1234567890
        import datetime

        from certbot._internal import cert_manager
        expiry = datetime.datetime.now(datetime.timezone.utc)

        cert = mock.MagicMock(lineagename="nameone")
        cert.target_expiry = expiry
        cert.sans.return_value = [san.DNSName("nameone"), san.DNSName("nametwo")]
        cert.is_test_cert = False
        parsed_certs = [cert]

        mock_config = mock.MagicMock(certname=None, lineagename=None)
        mock_config.domains = []
        # pylint: disable=protected-access

        # pylint: disable=protected-access
        def get_report():
            return cert_manager._report_human_readable(mock_config, parsed_certs)

        out = get_report()
        assert "INVALID: EXPIRED" in out

        cert.target_expiry += datetime.timedelta(hours=2)
        # pylint: disable=protected-access
        out = get_report()
        assert ('1 hour' in out or '2 hour(s)' in out) is True
        assert 'VALID' in out
        assert 'INVALID' not in out

        cert.target_expiry += datetime.timedelta(days=1)
        # pylint: disable=protected-access
        out = get_report()
        assert '1 day' in out
        assert 'under' not in out
        assert 'VALID' in out
        assert 'INVALID' not in out

        cert.target_expiry += datetime.timedelta(days=2)
        # pylint: disable=protected-access
        out = get_report()
        assert '3 days' in out
        assert 'VALID' in out
        assert 'INVALID' not in out

        cert.is_test_cert = True
        mock_revoked.return_value = True
        out = get_report()
        assert 'INVALID: TEST_CERT, REVOKED' in out

        cert = mock.MagicMock(lineagename="indescribable")
        cert.target_expiry = expiry
        cert.sans.return_value = [san.DNSName("nameone"), san.DNSName("thrice.named")]
        cert.is_test_cert = True
        parsed_certs.append(cert)

        out = get_report()
        assert len(re.findall("INVALID:", out)) == 2
        mock_config.domains = [san.DNSName("thrice.named")]
        out = get_report()
        assert len(re.findall("INVALID:", out)) == 1
        mock_config.domains = [san.DNSName("nameone")]
        out = get_report()
        assert len(re.findall("INVALID:", out)) == 2
        mock_config.certname = "indescribable"
        out = get_report()
        assert len(re.findall("INVALID:", out)) == 1
        mock_config.certname = "horror"
        out = get_report()
        assert len(re.findall("INVALID:", out)) == 0


class SearchLineagesTest(BaseCertManagerTest):
    """Tests for certbot._internal.cert_manager._search_lineages."""

    @mock.patch('certbot.util.make_or_verify_dir')
    @mock.patch('certbot._internal.storage.renewal_conf_files')
    @mock.patch('certbot._internal.storage.RenewableCert')
    def test_cert_storage_error(self, mock_renewable_cert, mock_renewal_conf_files,
                                mock_make_or_verify_dir):
        mock_renewal_conf_files.return_value = ["badfile"]
        mock_renewable_cert.side_effect = errors.CertStorageError
        from certbot._internal import cert_manager

        # pylint: disable=protected-access
        assert cert_manager._search_lineages(self.config, lambda x: x, "check") == "check"
        assert mock_make_or_verify_dir.called


class LineageForCertnameTest(BaseCertManagerTest):
    """Tests for certbot._internal.cert_manager.lineage_for_certname"""

    @mock.patch('certbot.util.make_or_verify_dir')
    @mock.patch('certbot._internal.storage.renewal_file_for_certname')
    @mock.patch('certbot._internal.storage.RenewableCert')
    def test_found_match(self, mock_renewable_cert, mock_renewal_conf_file,
                         mock_make_or_verify_dir):
        mock_renewal_conf_file.return_value = "somefile.conf"
        mock_match = mock.Mock(lineagename="example.com")
        mock_renewable_cert.return_value = mock_match
        from certbot._internal import cert_manager
        assert cert_manager.lineage_for_certname(self.config, "example.com") == mock_match
        assert mock_make_or_verify_dir.called

    @mock.patch('certbot.util.make_or_verify_dir')
    @mock.patch('certbot._internal.storage.renewal_file_for_certname')
    def test_no_match(self, mock_renewal_conf_file, mock_make_or_verify_dir):
        mock_renewal_conf_file.return_value = "other.com.conf"
        from certbot._internal import cert_manager
        assert cert_manager.lineage_for_certname(self.config, "example.com") is None
        assert mock_make_or_verify_dir.called

    @mock.patch('certbot.util.make_or_verify_dir')
    @mock.patch('certbot._internal.storage.renewal_file_for_certname')
    def test_no_renewal_file(self, mock_renewal_conf_file, mock_make_or_verify_dir):
        mock_renewal_conf_file.side_effect = errors.CertStorageError()
        from certbot._internal import cert_manager
        assert cert_manager.lineage_for_certname(self.config, "example.com") is None
        assert mock_make_or_verify_dir.called


class DomainsForCertnameTest(BaseCertManagerTest):
    """Tests for certbot._internal.cert_manager.sans_for_certname"""

    @mock.patch('certbot.util.make_or_verify_dir')
    @mock.patch('certbot._internal.storage.renewal_file_for_certname')
    @mock.patch('certbot._internal.storage.RenewableCert')
    def test_found_match(self, mock_renewable_cert, mock_renewal_conf_file,
                         mock_make_or_verify_dir):
        mock_renewal_conf_file.return_value = "somefile.conf"
        mock_match = mock.Mock(lineagename="example.com")
        domains = [san.DNSName("example.com"), san.DNSName("example.org")]
        mock_match.sans.return_value = domains
        mock_renewable_cert.return_value = mock_match
        from certbot._internal import cert_manager
        assert cert_manager.sans_for_certname(self.config, "example.com") == \
               domains
        assert mock_make_or_verify_dir.called

    @mock.patch('certbot.util.make_or_verify_dir')
    @mock.patch('certbot._internal.storage.renewal_file_for_certname')
    def test_no_match(self, mock_renewal_conf_file, mock_make_or_verify_dir):
        mock_renewal_conf_file.return_value = "somefile.conf"
        from certbot._internal import cert_manager
        assert cert_manager.sans_for_certname(self.config, "other.com") is None
        assert mock_make_or_verify_dir.called


class DuplicativeCertsTest(storage_test.BaseRenewableCertTest):
    """Test to avoid duplicate lineages."""

    def setUp(self):
        super().setUp()
        self.config_file.write()
        self._write_out_ex_kinds()

    @mock.patch('certbot.util.make_or_verify_dir')
    def test_find_duplicative_names(self, unused_makedir):
        from certbot._internal.cert_manager import find_duplicative_certs
        test_cert = test_util.load_vector('cert-san_512.pem')
        with open(self.test_rc.cert, 'wb') as f:
            f.write(test_cert)

        # No overlap at all
        result = find_duplicative_certs(
            self.config, [san.DNSName('wow.net'), san.DNSName('hooray.org')])
        assert result == (None, None)

        # Totally identical
        result = find_duplicative_certs(
            self.config, [san.DNSName('example.com'), san.DNSName('www.example.com')])
        assert result[0].configfile.filename.endswith('example.org.conf')
        assert result[1] is None

        # Superset
        result = find_duplicative_certs(
            self.config, [san.DNSName('example.com'), san.DNSName('www.example.com'),
                          san.DNSName('something.new')])
        assert result[0] is None
        assert result[1].configfile.filename.endswith('example.org.conf')

        # Partial overlap doesn't count
        result = find_duplicative_certs(
            self.config, [san.DNSName('example.com'), san.DNSName('something.new')])
        assert result == (None, None)


class CertPathToLineageTest(storage_test.BaseRenewableCertTest):
    """Tests for certbot._internal.cert_manager.cert_path_to_lineage"""

    def setUp(self):
        super().setUp()
        self.config_file.write()
        self._write_out_ex_kinds()
        self.fullchain = os.path.join(self.config.config_dir, 'live', 'example.org',
                'fullchain.pem')
        self.config.cert_path = self.fullchain

    def _call(self, cli_config):
        from certbot._internal.cert_manager import cert_path_to_lineage
        return cert_path_to_lineage(cli_config)

    def _archive_files(self, cli_config, filetype):
        from certbot._internal.cert_manager import _archive_files
        return _archive_files(cli_config, filetype)

    def test_basic_match(self):
        assert 'example.org' == self._call(self.config)

    def test_no_match_exists(self):
        bad_test_config = self.config
        bad_test_config.cert_path = os.path.join(self.config.config_dir, 'live',
                'SailorMoon', 'fullchain.pem')
        with pytest.raises(errors.Error):
            self._call(bad_test_config)

    @mock.patch('certbot._internal.cert_manager._acceptable_matches')
    def test_options_fullchain(self, mock_acceptable_matches):
        mock_acceptable_matches.return_value = [lambda x: x.fullchain_path]
        self.config.fullchain_path = self.fullchain
        assert 'example.org' == self._call(self.config)

    @mock.patch('certbot._internal.cert_manager._acceptable_matches')
    def test_options_cert_path(self, mock_acceptable_matches):
        mock_acceptable_matches.return_value = [lambda x: x.cert_path]
        test_cert_path = os.path.join(self.config.config_dir, 'live', 'example.org',
                'cert.pem')
        self.config.cert_path = test_cert_path
        assert 'example.org' == self._call(self.config)

    @mock.patch('certbot._internal.cert_manager._acceptable_matches')
    def test_options_archive_cert(self, mock_acceptable_matches):
        # Also this and the next test check that the regex of _archive_files is working.
        self.config.cert_path = os.path.join(self.config.config_dir, 'archive', 'example.org',
            'cert11.pem')
        mock_acceptable_matches.return_value = [lambda x: self._archive_files(x, 'cert')]
        assert 'example.org' == self._call(self.config)

    @mock.patch('certbot._internal.cert_manager._acceptable_matches')
    def test_options_archive_fullchain(self, mock_acceptable_matches):
        self.config.cert_path = os.path.join(self.config.config_dir, 'archive',
            'example.org', 'fullchain11.pem')
        mock_acceptable_matches.return_value = [lambda x:
                self._archive_files(x, 'fullchain')]
        assert 'example.org' == self._call(self.config)

    def test_only_path(self):
        self.config.cert_path = self.fullchain
        assert 'example.org' == self._call(self.config)


class MatchAndCheckOverlaps(storage_test.BaseRenewableCertTest):
    """Tests for certbot._internal.cert_manager.match_and_check_overlaps w/o overlapping
       archive dirs."""
    # A test with real overlapping archive dirs can be found in tests/boulder_integration.sh
    def setUp(self):
        super().setUp()
        self.config_file.write()
        self._write_out_ex_kinds()
        self.fullchain = os.path.join(self.config.config_dir, 'live', 'example.org',
                'fullchain.pem')
        self.config.cert_path = self.fullchain

    def _call(self, cli_config, acceptable_matches, match_func, rv_func):
        from certbot._internal.cert_manager import match_and_check_overlaps
        return match_and_check_overlaps(cli_config, acceptable_matches, match_func, rv_func)

    def test_basic_match(self):
        from certbot._internal.cert_manager import _acceptable_matches
        assert ['example.org'] == self._call(self.config, _acceptable_matches(),
            lambda x: self.config.cert_path, lambda x: x.lineagename)

    @mock.patch('certbot._internal.cert_manager._search_lineages')
    def test_no_matches(self, mock_search_lineages):
        mock_search_lineages.return_value = []
        with pytest.raises(errors.Error):
            self._call(self.config, None, None, None)

    @mock.patch('certbot._internal.cert_manager._search_lineages')
    def test_too_many_matches(self, mock_search_lineages):
        mock_search_lineages.return_value = ['spider', 'dance']
        with pytest.raises(errors.OverlappingMatchFound):
            self._call(self.config, None, None, None)


class GetCertnameTest(unittest.TestCase):
    """Tests for certbot._internal.cert_manager."""

    def setUp(self):
        get_utility_patch = test_util.patch_display_util()
        self.mock_get_utility = get_utility_patch.start()
        self.addCleanup(get_utility_patch.stop)
        self.config = mock.MagicMock()
        self.config.certname = None

    @mock.patch('certbot._internal.storage.renewal_conf_files')
    @mock.patch('certbot._internal.storage.lineagename_for_filename')
    def test_get_certnames(self, mock_name, mock_files):
        mock_files.return_value = ['example.com.conf']
        mock_name.return_value = 'example.com'
        from certbot._internal import cert_manager
        prompt = "Which certificate would you"
        self.mock_get_utility().menu.return_value = (display_util.OK, 0)
        assert cert_manager.get_certnames(
                self.config, "verb", allow_multiple=False) == ['example.com']
        assert prompt in self.mock_get_utility().menu.call_args[0][0]

    @mock.patch('certbot._internal.storage.renewal_conf_files')
    @mock.patch('certbot._internal.storage.lineagename_for_filename')
    def test_get_certnames_custom_prompt(self, mock_name, mock_files):
        mock_files.return_value = ['example.com.conf']
        mock_name.return_value = 'example.com'
        from certbot._internal import cert_manager
        prompt = "custom prompt"
        self.mock_get_utility().menu.return_value = (display_util.OK, 0)
        assert cert_manager.get_certnames(
                self.config, "verb", allow_multiple=False, custom_prompt=prompt) == \
            ['example.com']
        assert self.mock_get_utility().menu.call_args[0][0] == \
                          prompt

    @mock.patch('certbot._internal.storage.renewal_conf_files')
    @mock.patch('certbot._internal.storage.lineagename_for_filename')
    def test_get_certnames_user_abort(self, mock_name, mock_files):
        mock_files.return_value = ['example.com.conf']
        mock_name.return_value = 'example.com'
        from certbot._internal import cert_manager
        self.mock_get_utility().menu.return_value = (display_util.CANCEL, 0)
        with pytest.raises(errors.Error):
            cert_manager.get_certnames(self.config, "erroring_anyway", allow_multiple=False)

    @mock.patch('certbot._internal.storage.renewal_conf_files')
    @mock.patch('certbot._internal.storage.lineagename_for_filename')
    def test_get_certnames_allow_multiple(self, mock_name, mock_files):
        mock_files.return_value = ['example.com.conf']
        mock_name.return_value = 'example.com'
        from certbot._internal import cert_manager
        prompt = "Which certificate(s) would you"
        self.mock_get_utility().checklist.return_value = (display_util.OK,
                                                          ['example.com'])
        assert cert_manager.get_certnames(
                self.config, "verb", allow_multiple=True) == ['example.com']
        assert prompt in self.mock_get_utility().checklist.call_args[0][0]

    @mock.patch('certbot._internal.storage.renewal_conf_files')
    @mock.patch('certbot._internal.storage.lineagename_for_filename')
    def test_get_certnames_allow_multiple_custom_prompt(self, mock_name, mock_files):
        mock_files.return_value = ['example.com.conf']
        mock_name.return_value = 'example.com'
        from certbot._internal import cert_manager
        prompt = "custom prompt"
        self.mock_get_utility().checklist.return_value = (display_util.OK,
                                                          ['example.com'])
        assert cert_manager.get_certnames(
                self.config, "verb", allow_multiple=True, custom_prompt=prompt) == \
            ['example.com']
        assert self.mock_get_utility().checklist.call_args[0][0] == \
            prompt

    @mock.patch('certbot._internal.storage.renewal_conf_files')
    @mock.patch('certbot._internal.storage.lineagename_for_filename')
    def test_get_certnames_allow_multiple_user_abort(self, mock_name, mock_files):
        mock_files.return_value = ['example.com.conf']
        mock_name.return_value = 'example.com'
        from certbot._internal import cert_manager
        self.mock_get_utility().checklist.return_value = (display_util.CANCEL, [])
        with pytest.raises(errors.Error):
            cert_manager.get_certnames(self.config, "erroring_anyway", allow_multiple=True)


if __name__ == "__main__":
    sys.exit(pytest.main(sys.argv[1:] + [__file__]))  # pragma: no cover
