Make `reconfigure` use staging server
diff --git a/certbot/CHANGELOG.md b/certbot/CHANGELOG.md
index ffed0c77841..58910277022 100644
--- a/certbot/CHANGELOG.md
+++ b/certbot/CHANGELOG.md
@@ -16,6 +16,8 @@ Certbot adheres to [Semantic Versioning](https://semver.org/).
 
 * Updates `joinpath` syntax to only use one addition per call, because the multiple inputs
   version was causing mypy errors on Python 3.10.
+* Makes the `reconfigure` verb actually use the staging server for the dry run to check the new
+  configuration.
 
 More details about these changes can be found on our GitHub repo.
 
diff --git a/certbot/certbot/_internal/cli/__init__.py b/certbot/certbot/_internal/cli/__init__.py
index 4943dbbfb4b..0eccef8d8ba 100644
--- a/certbot/certbot/_internal/cli/__init__.py
+++ b/certbot/certbot/_internal/cli/__init__.py
@@ -36,6 +36,7 @@
 from certbot._internal.cli.cli_utils import nonnegative_int
 from certbot._internal.cli.cli_utils import parse_preferred_challenges
 from certbot._internal.cli.cli_utils import read_file
+from certbot._internal.cli.cli_utils import set_test_server_options
 from certbot._internal.cli.group_adder import _add_all_groups
 from certbot._internal.cli.helpful import HelpfulArgumentParser
 from certbot._internal.cli.paths_parser import _paths_parser
diff --git a/certbot/certbot/_internal/cli/cli_utils.py b/certbot/certbot/_internal/cli/cli_utils.py
index 9d9c309da10..4f7f4bba6bf 100644
--- a/certbot/certbot/_internal/cli/cli_utils.py
+++ b/certbot/certbot/_internal/cli/cli_utils.py
@@ -1,6 +1,7 @@
 """Certbot command line util function"""
 import argparse
 import copy
+import glob
 import inspect
 from typing import Any
 from typing import Iterable
@@ -250,3 +251,48 @@ def nonnegative_int(value: str) -> int:
     if int_value < 0:
         raise argparse.ArgumentTypeError("value must be non-negative")
     return int_value
+
+def set_test_server_options(verb: str, config: configuration.NamespaceConfig) -> None:
+    """Updates server, break_my_certs, staging, tos, and
+    register_unsafely_without_email in config as necessary to prepare
+    to use the test server.
+
+    We have --staging/--dry-run; perform sanity check and set config.server
+
+    :param str verb: subcommand called
+
+    :param config: parsed command line arguments
+    :type config: configuration.NamespaceConfig
+
+    :raises errors.Error: if non-default server is used and --staging is set
+    :raises errors.Error: if inapplicable verb is used and --dry-run is set
+    """
+
+    # Flag combinations should produce these results:
+    #                             | --staging      | --dry-run   |
+    # ------------------------------------------------------------
+    # | --server acme-v02         | Use staging    | Use staging |
+    # | --server acme-staging-v02 | Use staging    | Use staging |
+    # | --server <other>          | Conflict error | Use <other> |
+
+    default_servers = (flag_default("server"), constants.STAGING_URI)
+
+    if config.staging and config.server not in default_servers:
+        raise errors.Error("--server value conflicts with --staging")
+
+    if config.server == flag_default("server"):
+        config.server = constants.STAGING_URI
+        # If the account has already been loaded (such as by calling reconstitute before this),
+        # clear it so that we don't try to use the prod account on the staging server.
+        config.account = None
+
+    if config.dry_run:
+        if verb not in ["certonly", "renew", "reconfigure"]:
+            raise errors.Error("--dry-run currently only works with the "
+                               "'certonly' or 'renew' subcommands (%r)" % verb)
+        config.break_my_certs = config.staging = True
+        if glob.glob(os.path.join(config.config_dir, constants.ACCOUNTS_DIR, "*")):
+            # The user has a prod account, but might not have a staging
+            # one; we don't want to start trying to perform interactive registration
+            config.tos = True
+            config.register_unsafely_without_email = True
diff --git a/certbot/certbot/_internal/cli/helpful.py b/certbot/certbot/_internal/cli/helpful.py
index 73b4b316da0..10febf2ce48 100644
--- a/certbot/certbot/_internal/cli/helpful.py
+++ b/certbot/certbot/_internal/cli/helpful.py
@@ -2,7 +2,6 @@
 
 import argparse
 import functools
-import glob
 import sys
 from typing import Any
 from typing import Dict
@@ -26,11 +25,11 @@
 from certbot._internal.cli.cli_utils import CustomHelpFormatter
 from certbot._internal.cli.cli_utils import flag_default
 from certbot._internal.cli.cli_utils import HelpfulArgumentGroup
+from certbot._internal.cli.cli_utils import set_test_server_options
 from certbot._internal.cli.verb_help import VERB_HELP
 from certbot._internal.cli.verb_help import VERB_HELP_MAP
 from certbot._internal.display import obj as display_obj
 from certbot._internal.plugins import disco
-from certbot.compat import os
 from certbot.configuration import ArgumentSource
 from certbot.configuration import NamespaceConfig
 
@@ -318,33 +317,10 @@ def parse_args(self) -> NamespaceConfig:
         return config
 
     def set_test_server(self, config: NamespaceConfig) -> None:
-        """We have --staging/--dry-run; perform sanity check and set config.server"""
-
-        # Flag combinations should produce these results:
-        #                             | --staging      | --dry-run   |
-        # ------------------------------------------------------------
-        # | --server acme-v02         | Use staging    | Use staging |
-        # | --server acme-staging-v02 | Use staging    | Use staging |
-        # | --server <other>          | Conflict error | Use <other> |
-
-        default_servers = (flag_default("server"), constants.STAGING_URI)
-
-        if config.staging and config.server not in default_servers:
-            raise errors.Error("--server value conflicts with --staging")
-
-        if config.server == flag_default("server"):
-            config.server = constants.STAGING_URI
-
-        if config.dry_run:
-            if self.verb not in ["certonly", "renew"]:
-                raise errors.Error("--dry-run currently only works with the "
-                                   "'certonly' or 'renew' subcommands (%r)" % self.verb)
-            config.break_my_certs = config.staging = True
-            if glob.glob(os.path.join(config.config_dir, constants.ACCOUNTS_DIR, "*")):
-                # The user has a prod account, but might not have a staging
-                # one; we don't want to start trying to perform interactive registration
-                config.tos = True
-                config.register_unsafely_without_email = True
+        """Updates server, break_my_certs, staging, tos, and
+        register_unsafely_without_email in config as necessary to prepare
+        to use the test server."""
+        return set_test_server_options(self.verb, config)
 
     def handle_csr(self, config: NamespaceConfig) -> None:
         """Process a --csr flag."""
diff --git a/certbot/certbot/_internal/main.py b/certbot/certbot/_internal/main.py
index 70332e32c49..43eeaff1642 100644
--- a/certbot/certbot/_internal/main.py
+++ b/certbot/certbot/_internal/main.py
@@ -1727,10 +1727,8 @@ def reconfigure(config: configuration.NamespaceConfig,
     # to say nothing of the difficulty in explaining what exactly this subcommand can modify
 
 
-    # To make sure that the requested changes work, do a dry run. While setting up the dry run,
-    # we will set all the needed fields in config, which will then be saved upon success.
-    config.dry_run = True
-
+    # To make sure that the requested changes work, we're going to do a dry run, and only save
+    # upon success. First, modify the config as the user requested.
     if not config.certname:
         certname_question = "Which certificate would you like to reconfigure?"
         config.certname = cert_manager.get_certnames(
@@ -1772,17 +1770,44 @@ def reconfigure(config: configuration.NamespaceConfig,
     if not renewal_candidate:
         raise errors.ConfigurationError("Could not load certificate. See logs for errors.")
 
+    renewalparams = orig_renewal_conf['renewalparams']
+    # If server was set but hasn't changed and no account is loaded,
+    # load the old account because reconstitute won't have
+    if lineage_config.set_by_user('server') and lineage_config.server == renewalparams['server']\
+        and lineage_config.account is None:
+        lineage_config.account = renewalparams['account']
+    for param in ('account', 'server',):
+        if getattr(lineage_config, param) != renewalparams.get(param):
+            msg = ("Using reconfigure to change the ACME account or server is not supported. "
+                   "If you would like to do so, use renew with the --force-renewal flag instead "
+                   "of reconfigure. Note that doing so will count against any rate limits. For "
+                   "more information on this method, see "
+                   "https://certbot.org/renew-reconfiguration")
+            raise errors.ConfigurationError(msg)
+
     # this is where lineage_config gets fully filled out (e.g. --apache will set auth and installer)
     installer, auth = plug_sel.choose_configurator_plugins(lineage_config, plugins, "certonly")
-    le_client = _init_le_client(lineage_config, auth, installer)
+
+    # make a deep copy of lineage_config because we're about to modify it for a test dry run
+    dry_run_lineage_config = copy.deepcopy(lineage_config)
+
+    # we also set noninteractive_mode to more accurately simulate renewal (since `certbot renew`
+    # implies noninteractive mode) and to avoid prompting the user as changes made to
+    # dry_run_lineage_config beyond this point will not be applied to the original lineage_config
+    dry_run_lineage_config.noninteractive_mode = True
+    dry_run_lineage_config.dry_run = True
+    cli.set_test_server_options("reconfigure", dry_run_lineage_config)
+
+    le_client = _init_le_client(dry_run_lineage_config, auth, installer)
 
     # renews cert as dry run to test that the new values are ok
     # at this point, renewal_candidate.configuration has the old values, but will use
     # the values from lineage_config when doing the dry run
-    _get_and_save_cert(le_client, lineage_config, certname=certname,
+    _get_and_save_cert(le_client, dry_run_lineage_config, certname=certname,
         lineage=renewal_candidate)
 
     # this function will update lineage.configuration with the new values, and save it to disk
+    # use the pre-dry-run version
     renewal_candidate.save_new_config_values(lineage_config)
 
     _report_reconfigure_results(renewal_file, orig_renewal_conf)
diff --git a/certbot/certbot/_internal/tests/main_test.py b/certbot/certbot/_internal/tests/main_test.py
index c7e8d21676c..e71eddc75b7 100644
--- a/certbot/certbot/_internal/tests/main_test.py
+++ b/certbot/certbot/_internal/tests/main_test.py
@@ -563,7 +563,7 @@ def setUp(self):
             # Options used in the renewal process
             [renewalparams]
             account = ee43634db0aa4e6804f152be39990e6a
-            server = https://acme-staging-v02.api.letsencrypt.org/directory
+            server = https://acme-v02.api.letsencrypt.org/directory
             authenticator = nginx
             installer = nginx
             key_type = rsa
@@ -621,6 +621,72 @@ def test_update_configurator(self):
         new_config = self._call('--cert-name example.com --apache'.split())
         assert new_config['renewalparams']['authenticator'] == 'apache'
 
+    def test_only_intended_changes(self):
+        """ Check that we don't accidentally modify anything that we didn't mean to """
+        named_mock = mock.Mock()
+        named_mock.name = 'apache'
+
+        self.mocks['pick_installer'].return_value = named_mock
+        self.mocks['pick_auth'].return_value = named_mock
+        self.mocks['find_init'].return_value = named_mock
+
+        new_config = self._call('--cert-name example.com --apache'.split())
+        # Undo the changes we made in calling and in testing
+        new_config['renewalparams']['authenticator'] = 'nginx'
+        new_config['renewalparams']['installer'] = 'nginx'
+        del new_config['renewalparams']['config_dir']
+        new_config['version'] = self.original_config['version']
+
+        assert new_config == self.original_config
+
+    @mock.patch('certbot._internal.hooks.validate_hooks')
+    def test_staging_used(self, unused_validate_hooks):
+        """ Check that we use the staging server for the dry run """
+        assert self.original_config['renewalparams']['server'] == \
+            'https://acme-v02.api.letsencrypt.org/directory'
+
+        self._call('--cert-name example.com --pre-hook'.split() + ['echo pre'])
+
+        assert 'staging' in self.mocks['_init_le_client'].call_args.args[0].server
+        assert 'staging' in self.mocks['_get_and_save_cert'].call_args.args[1].server
+
+    def test_new_account_or_server_errors(self):
+        """ Check that we error when attempting to change the account id or server,
+            but not when it's the same
+        """
+        orig_account_id = self.original_config['renewalparams']['account']
+        orig_server = self.original_config['renewalparams']['server']
+
+        # new account
+        try:
+            self._call(f'--cert-name example.com --account newaccountid'.split())
+        except errors.ConfigurationError as err:
+            assert "Using reconfigure to change the ACME account" in str(err)
+
+        # check that config isn't modified
+        with open(self.renewal_file, 'r') as f:
+            new_config = configobj.ConfigObj(f, encoding='utf-8', default_encoding='utf-8')
+        assert new_config['renewalparams']['account'] == orig_account_id
+
+        # same account
+        new_config = self._call(f'--cert-name example.com --account {orig_account_id}'.split())
+        assert new_config['renewalparams']['account'] == orig_account_id
+
+        # new server
+        try:
+            self._call(f'--cert-name example.com --server x.com'.split())
+        except errors.ConfigurationError as err:
+            assert "Using reconfigure to change the ACME account" in str(err)
+
+        # check that config isn't modified
+        with open(self.renewal_file, 'r') as f:
+            new_config = configobj.ConfigObj(f, encoding='utf-8', default_encoding='utf-8')
+        assert new_config['renewalparams']['server'] == orig_server
+
+        # same server
+        new_config = self._call(f'--cert-name example.com --server {orig_server}'.split())
+        assert new_config['renewalparams']['server'] == orig_server
+
     @mock.patch('certbot._internal.hooks.validate_hooks')
     def test_update_hooks(self, unused_validate_hooks):
         assert 'pre_hook' not in self.original_config

Fixes #9847.

Creates `set_test_server_options` in `cli_utils` so that when `dry_run` is set after parse time, applicable options are additionally changed as well, and calls it from `main.reconfigure`.

Clears `config.account` in `set_test_server_options` when default `server` is switched to `staging`, so that the account id loaded during `reconstitute` isn't used with the staging server.

Note that this implies that if `account` was set on the cli and the default server is being used, the new account will not be tested during the dry run. Given that a dry run specifically implies using the staging server, there's not really a way to do that anyway. The new account will still be saved to the renewal conf file as the user requested.


Regression test failing on master:

```python
__________________________________________________________ ReconfigureTest.test_staging_used ___________________________________________________________

self = <certbot._internal.tests.main_test.ReconfigureTest testMethod=test_staging_used>

    def test_staging_used(self):
        """ Check that we use the staging server for the dry run"""
        assert self.original_config['renewalparams']['server'] == \
            'https://acme-v02.api.letsencrypt.org/directory'
    
        new_config = self._call('--cert-name example.com --pre-hook'.split() + ['echo pre'])
    
>       assert 'staging' in self.mocks['_init_le_client'].call_args.args[0].server
E       AssertionError: assert 'staging' in 'https://acme-v02.api.letsencrypt.org/directory'
E        +  where 'https://acme-v02.api.letsencrypt.org/directory' = <certbot.configuration.NamespaceConfig object at 0x7f49e303d780>.server

certbot/certbot/_internal/tests/main_test.py:649: AssertionError
```