Repository: certbot/certbot

Issue:
No confirmation on certificate deletion
## I ran this command and it produced this output:
`certbot delete --cert-name example.com`

Certificate was immediately deleted without any question asked.

Same goes for `certbot delete` with just `<enter>` on the certificate selection menu.

## Certbot's behavior differed from what I expected because:
Perhaps a confirmation would be nice. See https://community.letsencrypt.org/t/delete-all-certificates/135281 for an example of someone deleting all his certificates by accident... Twice..


Discussion:

Resolving patch:
diff --git a/certbot/certbot/_internal/cert_manager.py b/certbot/certbot/_internal/cert_manager.py
--- a/certbot/certbot/_internal/cert_manager.py
+++ b/certbot/certbot/_internal/cert_manager.py
@@ -90,9 +90,16 @@ def certificates(config):
 def delete(config):
     """Delete Certbot files associated with a certificate lineage."""
     certnames = get_certnames(config, "delete", allow_multiple=True)
+    disp = zope.component.getUtility(interfaces.IDisplay)
+    msg = ["The following certificate(s) are selected for deletion:\n"]
+    for certname in certnames:
+        msg.append("  * " + certname)
+    msg.append("\nAre you sure you want to delete the above certificate(s)?")
+    if not disp.yesno("\n".join(msg), default=True):
+        logger.info("Deletion of certificate(s) canceled.")
+        return
     for certname in certnames:
         storage.delete_files(config, certname)
-        disp = zope.component.getUtility(interfaces.IDisplay)
         disp.notification("Deleted all files relating to certificate {0}."
             .format(certname), pause=False)
 


Test patch:
diff --git a/certbot/tests/cert_manager_test.py b/certbot/tests/cert_manager_test.py
--- a/certbot/tests/cert_manager_test.py
+++ b/certbot/tests/cert_manager_test.py
@@ -116,10 +116,11 @@ def _call(self):
     @test_util.patch_get_utility()
     @mock.patch('certbot._internal.cert_manager.lineage_for_certname')
     @mock.patch('certbot._internal.storage.delete_files')
-    def test_delete_from_config(self, mock_delete_files, mock_lineage_for_certname,
-        unused_get_utility):
+    def test_delete_from_config_yes(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 = True
         self.config.certname = "example.org"
         self._call()
         mock_delete_files.assert_called_once_with(self.config, "example.org")
@@ -127,27 +128,65 @@ def test_delete_from_config(self, mock_delete_files, mock_lineage_for_certname,
     @test_util.patch_get_utility()
     @mock.patch('certbot._internal.cert_manager.lineage_for_certname')
     @mock.patch('certbot._internal.storage.delete_files')
-    def test_delete_interactive_single(self, mock_delete_files, mock_lineage_for_certname,
+    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()
+        self.assertEqual(mock_delete_files.call_count, 0)
+
+    @test_util.patch_get_utility()
+    @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_get_utility()
     @mock.patch('certbot._internal.cert_manager.lineage_for_certname')
     @mock.patch('certbot._internal.storage.delete_files')
-    def test_delete_interactive_multiple(self, mock_delete_files, mock_lineage_for_certname,
+    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()
+        self.assertEqual(mock_delete_files.call_count, 0)
+
+    @test_util.patch_get_utility()
+    @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")
         self.assertEqual(mock_delete_files.call_count, 2)
 
+    @test_util.patch_get_utility()
+    @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()
+        self.assertEqual(mock_delete_files.call_count, 0)
+
 
 class CertificatesTest(BaseCertManagerTest):
     """Tests for certbot._internal.cert_manager.certificates