diff --git a/src/wp-admin/user-edit.php b/src/wp-admin/user-edit.php index 8055de6b36385..eda921f221cf0 100644 --- a/src/wp-admin/user-edit.php +++ b/src/wp-admin/user-edit.php @@ -106,21 +106,12 @@ // Execute confirmed email change. See send_confirmation_on_profile_email(). if ( IS_PROFILE_PAGE && isset( $_GET['newuseremail'] ) && $current_user->ID ) { - $new_email = get_user_meta( $current_user->ID, '_new_email', true ); - if ( $new_email && hash_equals( $new_email['hash'], $_GET['newuseremail'] ) ) { - $user = new stdClass(); - $user->ID = $current_user->ID; - $user->user_email = esc_html( trim( $new_email['newemail'] ) ); - if ( is_multisite() && $wpdb->get_var( $wpdb->prepare( "SELECT user_login FROM {$wpdb->signups} WHERE user_login = %s", $current_user->user_login ) ) ) { - $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->signups} SET user_email = %s WHERE user_login = %s", $user->user_email, $current_user->user_login ) ); - } - wp_update_user( $user ); - delete_user_meta( $current_user->ID, '_new_email' ); + if ( confirm_user_email_change( $current_user->ID, $_GET['newuseremail'] ) ) { wp_redirect( add_query_arg( array( 'updated' => 'true' ), self_admin_url( 'profile.php' ) ) ); - die(); } else { wp_redirect( add_query_arg( array( 'error' => 'new-email' ), self_admin_url( 'profile.php' ) ) ); } + die(); } elseif ( IS_PROFILE_PAGE && ! empty( $_GET['dismiss'] ) && $current_user->ID . '_new_email' === $_GET['dismiss'] ) { check_admin_referer( 'dismiss-' . $current_user->ID . '_new_email' ); delete_user_meta( $current_user->ID, '_new_email' ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php index 9b25cf7974cbc..6f188550fab0d 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php @@ -806,6 +806,27 @@ public function update_item( $request ) { } } + /* + * Ask a user to confirm a change to their own email address, rather than + * applying it immediately, as the profile screen does. The change is held + * in the `_new_email` user meta until it is confirmed. + */ + if ( is_string( $request['email'] ) && '' !== $request['email'] ) { + $email_sent = send_user_email_change_confirmation_email( $user, $request['email'] ); + + if ( is_wp_error( $email_sent ) ) { + return new WP_Error( + 'rest_user_invalid_email', + __( 'Invalid email address.' ), + array( 'status' => 400 ) + ); + } + + if ( true === $email_sent ) { + unset( $request['email'] ); + } + } + $user = $this->prepare_item_for_database( $request ); // Ensure we're operating on the same user we already checked. diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 6ce8dbf05175d..3bd3b24db4bfd 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -3886,46 +3886,117 @@ function send_confirmation_on_profile_email( $user_id = 0 ) { return false; } - if ( $current_user->user_email !== $_POST['email'] ) { - if ( ! is_email( $_POST['email'] ) ) { + $email_sent = send_user_email_change_confirmation_email( $current_user, $_POST['email'] ); + + if ( is_wp_error( $email_sent ) ) { + // WP_Error::copy_errors() with the addition of adding data. + foreach ( $email_sent->get_error_codes() as $code ) { $errors->add( - 'user_email', - __( 'Error: The email address is not correct.' ), + $code, + $email_sent->get_error_message( $code ), array( 'form-field' => 'email', ) ); - - $_POST['email'] = addslashes( $current_user->user_email ); - return; } - if ( email_exists( $_POST['email'] ) ) { - $errors->add( - 'user_email', - __( 'Error: The email address is already used.' ), - array( - 'form-field' => 'email', - ) - ); - delete_user_meta( $current_user->ID, '_new_email' ); + $_POST['email'] = addslashes( $current_user->user_email ); + return; + } - $_POST['email'] = addslashes( $current_user->user_email ); - return; - } + if ( true === $email_sent ) { + $_POST['email'] = $current_user->user_email; + } +} - $hash = md5( $_POST['email'] . time() . wp_rand() ); - $new_user_email = array( - 'hash' => $hash, - 'newemail' => $_POST['email'], +/** + * Sends a confirmation request email when a change of user email address is attempted. + * + * The new address is held in the `_new_email` user meta until the user confirms it + * by following the link in the email. The address on the account is unchanged + * until then. + * + * A confirmation is only required when a user changes their own email address. An + * administrator changing somebody else's address is not asked to confirm it. + * + * @since 7.2.0 + * + * @param WP_User $user The user whose email address is being changed. + * @param string $email The new email address. + * @return true|WP_Error|null True if a confirmation email was sent and the change should not be + * applied yet, WP_Error if the address was rejected, null if no + * confirmation is needed and the change may be applied. + */ +function send_user_email_change_confirmation_email( $user, $email ) { + if ( ! $user instanceof WP_User || ! $user->exists() ) { + return null; + } + + /* + * Correcting the case of an address is the same mailbox, so it is not a change + * that needs confirming, and `email_exists()` below would read it as an address + * that is already in use. See #44672. + */ + if ( 0 === strcasecmp( $user->user_email, $email ) ) { + return null; + } + + if ( ! is_email( $email ) ) { + return new WP_Error( + 'user_email', + __( 'Error: The email address is not correct.' ) ); - update_user_meta( $current_user->ID, '_new_email', $new_user_email ); + } + + if ( email_exists( $email ) ) { + delete_user_meta( $user->ID, '_new_email' ); + + return new WP_Error( + 'user_email', + __( 'Error: The email address is already used.' ) + ); + } + + // The email is only sent if a user changes their own email. + $should_send_email_for_change = ( get_current_user_id() === $user->ID ); + + /** + * Filters whether a 'confirm your email address' email should be sent. + * If false is returned, the change is made immediately. + * + * @since 7.2.0 + * + * @param bool $should_send_email_for_change Whether to use an email confirmation. + * @param WP_User $user The user having their email changed. + * @param string $email The new email address. + */ + $should_send_email_for_change = apply_filters( 'should_send_email_for_email_change', $should_send_email_for_change, $user, $email ); - $sitename = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + if ( ! $should_send_email_for_change ) { + return null; + } - /* translators: Do not translate USERNAME, ADMIN_URL, EMAIL, SITENAME, SITEURL: those are placeholders. */ - $email_text = __( - 'Howdy ###USERNAME###, + $hash = md5( $email . time() . wp_rand() ); + $new_user_email = array( + 'hash' => $hash, + 'newemail' => $email, + ); + update_user_meta( $user->ID, '_new_email', $new_user_email ); + + $confirm_url = add_query_arg( + array( + 'action' => 'confirmemail', + 'id' => $user->ID, + 'hash' => $hash, + ), + wp_login_url() + ); + + $sitename = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + + /* translators: Do not translate USERNAME, ADMIN_URL, EMAIL, SITENAME, SITEURL: those are placeholders. */ + $email_text = __( + 'Howdy ###USERNAME###, You recently requested to have the email address on your account changed. @@ -3940,43 +4011,99 @@ function send_confirmation_on_profile_email( $user_id = 0 ) { Regards, All at ###SITENAME### ###SITEURL###' - ); + ); - /** - * Filters the text of the email sent when a change of user email address is attempted. - * - * The following strings have a special meaning and will get replaced dynamically: - * - * - `###USERNAME###` The current user's username. - * - `###ADMIN_URL###` The link to click on to confirm the email change. - * - `###EMAIL###` The new email. - * - `###SITENAME###` The name of the site. - * - `###SITEURL###` The URL to the site. - * - * @since MU (3.0.0) - * @since 4.9.0 This filter is no longer Multisite specific. - * - * @param string $email_text Text in the email. - * @param array $new_user_email { - * Data relating to the new user email address. - * - * @type string $hash The secure hash used in the confirmation link URL. - * @type string $newemail The proposed new email address. - * } - */ - $content = apply_filters( 'new_user_email_content', $email_text, $new_user_email ); + /** + * Filters the text of the email sent when a change of user email address is attempted. + * + * The following strings have a special meaning and will get replaced dynamically: + * + * - `###USERNAME###` The current user's username. + * - `###ADMIN_URL###` The link to click on to confirm the email change. + * - `###EMAIL###` The new email. + * - `###SITENAME###` The name of the site. + * - `###SITEURL###` The URL to the site. + * + * @since MU (3.0.0) + * @since 4.9.0 This filter is no longer Multisite specific. + * + * @param string $email_text Text in the email. + * @param array $new_user_email { + * Data relating to the new user email address. + * + * @type string $hash The secure hash used in the confirmation link URL. + * @type string $newemail The proposed new email address. + * } + */ + $content = apply_filters( 'new_user_email_content', $email_text, $new_user_email ); - $content = str_replace( '###USERNAME###', $current_user->user_login, $content ); - $content = str_replace( '###ADMIN_URL###', esc_url( self_admin_url( 'profile.php?newuseremail=' . $hash ) ), $content ); - $content = str_replace( '###EMAIL###', $_POST['email'], $content ); - $content = str_replace( '###SITENAME###', $sitename, $content ); - $content = str_replace( '###SITEURL###', home_url(), $content ); + $content = str_replace( '###USERNAME###', $user->user_login, $content ); + $content = str_replace( '###ADMIN_URL###', esc_url( $confirm_url ), $content ); + $content = str_replace( '###EMAIL###', $email, $content ); + $content = str_replace( '###SITENAME###', $sitename, $content ); + $content = str_replace( '###SITEURL###', home_url(), $content ); - /* translators: New email address notification email subject. %s: Site title. */ - wp_mail( $_POST['email'], sprintf( __( '[%s] Email Change Request' ), $sitename ), $content ); + /* translators: New email address notification email subject. %s: Site title. */ + wp_mail( $email, sprintf( __( '[%s] Email Change Request' ), $sitename ), $content ); - $_POST['email'] = $current_user->user_email; + return true; +} + +/** + * Applies a pending email address change once the user has confirmed it. + * + * @since 7.2.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param int $user_id The ID of the user whose email address is being changed. + * @param string $email_hash The confirmation hash from the link in the confirmation email. + * @return bool Whether the email address was changed. + */ +function confirm_user_email_change( $user_id, $email_hash ) { + global $wpdb; + + $the_user = get_userdata( $user_id ); + + if ( ! $the_user ) { + return false; + } + + $new_email = get_user_meta( $the_user->ID, '_new_email', true ); + + if ( ! is_array( $new_email ) || empty( $new_email['hash'] ) || empty( $new_email['newemail'] ) ) { + return false; + } + + if ( ! hash_equals( $new_email['hash'], (string) $email_hash ) ) { + return false; + } + + $user = new stdClass(); + $user->ID = $the_user->ID; + $user->user_email = esc_html( trim( $new_email['newemail'] ) ); + if ( is_multisite() && $wpdb->get_var( $wpdb->prepare( "SELECT user_login FROM {$wpdb->signups} WHERE user_login = %s", $the_user->user_login ) ) ) { + $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->signups} SET user_email = %s WHERE user_login = %s", $user->user_email, $the_user->user_login ) ); } + + $updated = wp_update_user( $user ); + + if ( is_wp_error( $updated ) ) { + return false; + } + + delete_user_meta( $user->ID, '_new_email' ); + + /** + * Fires after a user has confirmed a change to their email address. + * + * @since 7.2.0 + * + * @param int $user_id The ID of the user whose email address was changed. + */ + do_action( 'user_email_confirmed', $user->ID ); + + return true; } /** diff --git a/src/wp-login.php b/src/wp-login.php index 43689323647de..a5e19c6f9a45a 100644 --- a/src/wp-login.php +++ b/src/wp-login.php @@ -495,6 +495,7 @@ function wp_login_viewport_meta() { 'register', 'checkemail', 'confirmaction', + 'confirmemail', 'login', WP_Recovery_Mode_Link_Service::LOGIN_ACTION_ENTERED, ); @@ -1237,6 +1238,44 @@ function wp_login_viewport_meta() { login_footer(); break; + case 'confirmemail': + if ( ! isset( $_GET['id'], $_GET['hash'] ) ) { + wp_die( __( 'Missing or invalid key.' ) ); + } + + $user_id = (int) $_GET['id']; + $email_key = sanitize_text_field( wp_unslash( $_GET['hash'] ) ); + + if ( ! is_user_logged_in() ) { + $confirm_url = add_query_arg( + array( + 'action' => 'confirmemail', + 'id' => $user_id, + 'hash' => rawurlencode( $email_key ), + ), + wp_login_url() + ); + + wp_safe_redirect( wp_login_url( $confirm_url ) ); + exit; + } + + if ( ! current_user_can( 'edit_user', $user_id ) ) { + wp_die( __( 'Missing or invalid key.' ) ); + } + + if ( ! confirm_user_email_change( $user_id, $email_key ) ) { + wp_die( __( 'Missing or invalid key.' ) ); + } + + login_header( + __( 'Email address confirmed.' ), + '

' . __( 'Your new email address has been confirmed.' ) . '

' + ); + + login_footer(); + exit; + case 'confirmaction': if ( ! isset( $_GET['request_id'] ) ) { wp_die( __( 'Missing request ID.' ) ); diff --git a/tests/phpstan/baselines/variable.undefined.neon b/tests/phpstan/baselines/variable.undefined.neon index 91c668011373e..9dfbbcd30a34f 100644 --- a/tests/phpstan/baselines/variable.undefined.neon +++ b/tests/phpstan/baselines/variable.undefined.neon @@ -536,7 +536,7 @@ parameters: - message: '#^Variable \$wpdb might not be defined\.$#' identifier: variable.undefined - count: 12 + count: 6 path: ../../../src/wp-admin/user-edit.php - message: '#^Variable \$blog_id might not be defined\.$#' diff --git a/tests/phpunit/tests/rest-api/rest-users-controller.php b/tests/phpunit/tests/rest-api/rest-users-controller.php index 86ec4b8048551..e9766ff3b634a 100644 --- a/tests/phpunit/tests/rest-api/rest-users-controller.php +++ b/tests/phpunit/tests/rest-api/rest-users-controller.php @@ -3128,6 +3128,248 @@ public function test_get_default_value( $args, $expected ) { $this->assertSame( $expected, $meta[ $meta_key ] ); } + /** + * @ticket 57413 + */ + public function test_send_confirmation_on_profile_email() { + reset_phpmailer_instance(); + $was_confirmation_email_sent = false; + + $user_id = self::factory()->user->create( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'after@example.com' ); + $response = rest_get_server()->dispatch( $request ); + + $new_data = $response->get_data(); + $this->assertSame( 'before@example.com', $new_data['email'] ); + + if ( ! empty( $GLOBALS['phpmailer']->mock_sent ) ) { + $was_confirmation_email_sent = ( isset( $GLOBALS['phpmailer']->mock_sent[0] ) && 'after@example.com' === $GLOBALS['phpmailer']->mock_sent[0]['to'][0][0] ); + } + + // A confirmation email is sent. + $this->assertTrue( $was_confirmation_email_sent ); + + // The new email address gets put into user_meta. + $new_email_meta = get_user_meta( $user_id, '_new_email', true ); + $this->assertSame( 'after@example.com', $new_email_meta['newemail'] ); + } + + /** + * @ticket 57413 + */ + public function test_no_confirmation_on_profile_email_by_admin() { + reset_phpmailer_instance(); + $was_confirmation_email_sent = false; + + $user_id = self::factory()->user->create( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( self::$superadmin ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'after@example.com' ); + $response = rest_get_server()->dispatch( $request ); + + $new_data = $response->get_data(); + $this->assertSame( 'after@example.com', $new_data['email'] ); + + if ( ! empty( $GLOBALS['phpmailer']->mock_sent ) ) { + $was_confirmation_email_sent = ( isset( $GLOBALS['phpmailer']->mock_sent[0] ) && 'after@example.com' === $GLOBALS['phpmailer']->mock_sent[0]['to'][0][0] ); + } + + // No confirmation email is sent. + $this->assertFalse( $was_confirmation_email_sent ); + + // No usermeta is created. + $new_email_meta = get_user_meta( $user_id, '_new_email', true ); + $this->assertEmpty( $new_email_meta ); + } + + /** + * A user changing their own email address over REST is asked to confirm it, + * the same as on the profile screen. + * + * @ticket 57413 + */ + public function test_update_item_own_email_requires_confirmation() { + reset_phpmailer_instance(); + + $user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'after@example.com' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + // The address on the account is unchanged until the user confirms it. + $data = $response->get_data(); + $this->assertSame( 'before@example.com', $data['email'] ); + $this->assertSame( 'before@example.com', get_userdata( $user_id )->user_email ); + + // The change is held in user meta. + $new_email_meta = get_user_meta( $user_id, '_new_email', true ); + $this->assertSame( 'after@example.com', $new_email_meta['newemail'] ); + + // A confirmation email is sent to the new address. + $mailer = tests_retrieve_phpmailer_instance(); + $this->assertSame( 'after@example.com', $mailer->get_recipient( 'to' )->address ); + } + + /** + * Other fields in the same request are still applied while the email change is pending. + * + * @ticket 57413 + */ + public function test_update_item_applies_other_fields_while_email_is_pending() { + reset_phpmailer_instance(); + + $user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'after@example.com' ); + $request->set_param( 'first_name', 'Updated' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'Updated', get_user_meta( $user_id, 'first_name', true ) ); + $this->assertSame( 'before@example.com', get_userdata( $user_id )->user_email ); + } + + /** + * An administrator changing somebody else's address is not asked to confirm it. + * + * @ticket 57413 + */ + public function test_update_item_other_user_email_is_applied_immediately() { + reset_phpmailer_instance(); + + $user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( self::$superadmin ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'after@example.com' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 'after@example.com', $data['email'] ); + $this->assertSame( 'after@example.com', get_userdata( $user_id )->user_email ); + $this->assertEmpty( get_user_meta( $user_id, '_new_email', true ) ); + + /* + * wp_update_user() mails a "Notice of Email Change" to the old address on every + * change, so the assertion is that nothing was sent to the new address asking + * for confirmation, not that no mail was sent at all. + */ + $this->assertFalse( $this->was_mail_sent_to( 'after@example.com' ) ); + } + + /** + * Sending the address the account already has is not a change, so it is not held. + * + * @ticket 57413 + */ + public function test_update_item_unchanged_email_does_not_require_confirmation() { + reset_phpmailer_instance(); + + $user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'before@example.com' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertEmpty( get_user_meta( $user_id, '_new_email', true ) ); + $this->assertEmpty( tests_retrieve_phpmailer_instance()->mock_sent ); + } + + /** + * The confirmation can be turned off, for sites that manage addresses elsewhere. + * + * @ticket 57413 + */ + public function test_update_item_own_email_confirmation_can_be_filtered_off() { + reset_phpmailer_instance(); + + $user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user_id ); + + add_filter( 'should_send_email_for_email_change', '__return_false' ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'after@example.com' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'after@example.com', get_userdata( $user_id )->user_email ); + $this->assertEmpty( get_user_meta( $user_id, '_new_email', true ) ); + $this->assertFalse( $this->was_mail_sent_to( 'after@example.com' ) ); + } + + /** + * Whether any mail was sent to the given address. + * + * @since 7.2.0 + * + * @param string $address The address to look for. + * @return bool Whether a message was sent to the address. + */ + protected function was_mail_sent_to( $address ) { + foreach ( tests_retrieve_phpmailer_instance()->mock_sent as $mail ) { + if ( isset( $mail['to'][0][0] ) && $address === $mail['to'][0][0] ) { + return true; + } + } + + return false; + } + public function data_get_default_data() { return array( array( diff --git a/tests/phpunit/tests/user.php b/tests/phpunit/tests/user.php index f600adbcb1164..b04f8d48f3fc9 100644 --- a/tests/phpunit/tests/user.php +++ b/tests/phpunit/tests/user.php @@ -2318,6 +2318,276 @@ public function test_remove_send_confirmation_on_profile_email() { $this->assertSame( $_POST['email'], 'after@example.com' ); } + /** + * @ticket 57413 + */ + public function test_no_confirmation_on_profile_email_by_admin() { + reset_phpmailer_instance(); + $was_confirmation_email_sent = false; + + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + $_POST['email'] = 'after@example.com'; + $_POST['user_id'] = $user->ID; + + wp_set_current_user( self::$admin_id ); + + do_action( 'personal_options_update' ); + + if ( ! empty( $GLOBALS['phpmailer']->mock_sent ) ) { + $was_confirmation_email_sent = ( isset( $GLOBALS['phpmailer']->mock_sent[0] ) && 'after@example.com' === $GLOBALS['phpmailer']->mock_sent[0]['to'][0][0] ); + } + + // No confirmation email is sent. + $this->assertFalse( $was_confirmation_email_sent ); + + // No usermeta is created. + $new_email_meta = get_user_meta( $user->ID, '_new_email', true ); + $this->assertEmpty( $new_email_meta ); + + // $_POST['email'] should be the email address posted from the form. + $this->assertSame( 'after@example.com', $_POST['email'] ); + } + + /** + * @ticket 57413 + * + * @covers ::send_user_email_change_confirmation_email + */ + public function test_send_user_email_change_confirmation_email_returns_null_when_email_is_unchanged() { + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user->ID ); + + $this->assertNull( send_user_email_change_confirmation_email( $user, 'before@example.com' ) ); + $this->assertEmpty( get_user_meta( $user->ID, '_new_email', true ) ); + } + + /** + * @ticket 57413 + * + * @covers ::send_user_email_change_confirmation_email + */ + public function test_send_user_email_change_confirmation_email_rejects_an_invalid_email() { + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user->ID ); + + $result = send_user_email_change_confirmation_email( $user, 'not-an-email' ); + + $this->assertWPError( $result, 'An invalid address should return a WP_Error.' ); + $this->assertSame( 'user_email', $result->get_error_code() ); + $this->assertEmpty( get_user_meta( $user->ID, '_new_email', true ), 'No change should be left pending.' ); + } + + /** + * @ticket 57413 + * + * @covers ::send_user_email_change_confirmation_email + */ + public function test_send_user_email_change_confirmation_email_rejects_an_address_in_use() { + self::factory()->user->create( array( 'user_email' => 'taken@example.com' ) ); + + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user->ID ); + + // A pending change from an earlier request should not survive a rejection. + update_user_meta( + $user->ID, + '_new_email', + array( + 'hash' => 'stalehash', + 'newemail' => 'pending@example.com', + ) + ); + + $result = send_user_email_change_confirmation_email( $user, 'taken@example.com' ); + + $this->assertWPError( $result, 'An address already in use should return a WP_Error.' ); + $this->assertSame( 'user_email', $result->get_error_code() ); + $this->assertEmpty( get_user_meta( $user->ID, '_new_email', true ), 'The pending change should be discarded.' ); + } + + /** + * @ticket 57413 + * + * @covers ::send_user_email_change_confirmation_email + */ + public function test_send_user_email_change_confirmation_email_stores_the_pending_change() { + reset_phpmailer_instance(); + + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user->ID ); + + $this->assertTrue( send_user_email_change_confirmation_email( $user, 'after@example.com' ) ); + + $new_email_meta = get_user_meta( $user->ID, '_new_email', true ); + $this->assertSame( 'after@example.com', $new_email_meta['newemail'] ); + $this->assertNotEmpty( $new_email_meta['hash'] ); + + // The address on the account is untouched until the change is confirmed. + $this->assertSame( 'before@example.com', get_userdata( $user->ID )->user_email ); + + $mailer = tests_retrieve_phpmailer_instance(); + $this->assertSame( 'after@example.com', $mailer->get_recipient( 'to' )->address ); + $this->assertStringContainsString( 'action=confirmemail', $mailer->get_sent()->body ); + } + + /** + * An administrator changing somebody else's address is not asked to confirm it. + * + * @ticket 57413 + * + * @covers ::send_user_email_change_confirmation_email + */ + public function test_send_user_email_change_confirmation_email_skips_confirmation_for_another_user() { + reset_phpmailer_instance(); + + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( self::$admin_id ); + + $this->assertNull( send_user_email_change_confirmation_email( $user, 'after@example.com' ) ); + $this->assertEmpty( get_user_meta( $user->ID, '_new_email', true ) ); + $this->assertEmpty( tests_retrieve_phpmailer_instance()->mock_sent ); + } + + /** + * @ticket 57413 + * + * @covers ::send_user_email_change_confirmation_email + */ + public function test_should_send_email_for_email_change_filter_can_skip_the_confirmation() { + reset_phpmailer_instance(); + + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user->ID ); + + $filter_args = array(); + + add_filter( + 'should_send_email_for_email_change', + static function ( $should_send, $filtered_user, $email ) use ( &$filter_args ) { + $filter_args = array( $should_send, $filtered_user, $email ); + return false; + }, + 10, + 3 + ); + + $this->assertNull( send_user_email_change_confirmation_email( $user, 'after@example.com' ) ); + $this->assertEmpty( get_user_meta( $user->ID, '_new_email', true ) ); + $this->assertEmpty( tests_retrieve_phpmailer_instance()->mock_sent ); + + $this->assertTrue( $filter_args[0], 'A user changing their own address should default to requiring confirmation.' ); + $this->assertSame( $user->ID, $filter_args[1]->ID ); + $this->assertSame( 'after@example.com', $filter_args[2] ); + } + + /** + * @ticket 57413 + * + * @covers ::confirm_user_email_change + */ + public function test_confirm_user_email_change_applies_the_pending_change() { + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user->ID ); + + send_user_email_change_confirmation_email( $user, 'after@example.com' ); + + $new_email_meta = get_user_meta( $user->ID, '_new_email', true ); + + $action = new MockAction(); + add_action( 'user_email_confirmed', array( $action, 'action' ) ); + + $this->assertTrue( confirm_user_email_change( $user->ID, $new_email_meta['hash'] ) ); + $this->assertSame( 'after@example.com', get_userdata( $user->ID )->user_email ); + $this->assertEmpty( get_user_meta( $user->ID, '_new_email', true ), 'The pending change should be cleared.' ); + $this->assertSame( 1, $action->get_call_count() ); + } + + /** + * @ticket 57413 + * + * @covers ::confirm_user_email_change + */ + public function test_confirm_user_email_change_rejects_an_invalid_hash() { + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user->ID ); + + send_user_email_change_confirmation_email( $user, 'after@example.com' ); + + $this->assertFalse( confirm_user_email_change( $user->ID, 'not-the-hash' ) ); + $this->assertSame( 'before@example.com', get_userdata( $user->ID )->user_email ); + $this->assertNotEmpty( get_user_meta( $user->ID, '_new_email', true ), 'The pending change should survive a failed attempt.' ); + } + + /** + * @ticket 57413 + * + * @covers ::confirm_user_email_change + */ + public function test_confirm_user_email_change_returns_false_without_a_pending_change() { + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + $this->assertFalse( confirm_user_email_change( $user->ID, 'any-hash' ) ); + $this->assertSame( 'before@example.com', get_userdata( $user->ID )->user_email ); + } + + /** + * @ticket 57413 + * + * @covers ::confirm_user_email_change + */ + public function test_confirm_user_email_change_returns_false_for_an_unknown_user() { + $this->assertFalse( confirm_user_email_change( 0, 'any-hash' ) ); + } + /** * Ensure user email address change confirmation emails do not contain encoded HTML entities *