diff --git a/src/wp-admin/includes/class-wp-plugins-list-table.php b/src/wp-admin/includes/class-wp-plugins-list-table.php index a07945a9733db..ff471650ea7c4 100644 --- a/src/wp-admin/includes/class-wp-plugins-list-table.php +++ b/src/wp-admin/includes/class-wp-plugins-list-table.php @@ -791,9 +791,9 @@ public function single_row( $item ) { $class .= ' update'; } - $paused = is_plugin_paused( $plugin_file ); - $paused_on_network_sites_count = $screen->in_admin( 'network' ) ? count_paused_plugin_sites_for_network( $plugin_file ) : 0; - if ( $paused || $paused_on_network_sites_count ) { + $paused = is_plugin_paused( $plugin_file ); + + if ( $paused ) { $class .= ' paused'; } @@ -885,15 +885,8 @@ public function single_row( $item ) { echo ''; - if ( $paused || $paused_on_network_sites_count ) { - $notice_text = __( 'This plugin failed to load properly and was paused within the admin backend.' ); - if ( $screen->in_admin( 'network' ) && $paused_on_network_sites_count ) { - $notice_text = sprintf( - /* translators: %s: number of sites */ - _n( 'This plugin failed to load properly and was paused within the admin backend for %s site.', 'This plugin failed to load properly and was paused within the admin backend for %s sites.', $paused_on_network_sites_count ), - number_format_i18n( $paused_on_network_sites_count ) - ); - } + if ( $paused ) { + $notice_text = __( 'This plugin failed to load properly and is paused during recovery mode.' ); printf( '

%s

', $notice_text ); @@ -911,11 +904,18 @@ public function single_row( $item ) { $error['type'] = $core_errors[ $error['type'] ]; + if ( empty( $error['wp_is_protected'] ) ) { + /* translators: 1: error type, 2: error line number, 3: error file name, 4: error message */ + $error_message = __( 'The plugin caused an error of type %1$s in line %2$s of the file %3$s. Error message: %4$s' ); + } else { + /* translators: 1: error type, 2: error line number, 3: error file name, 4: error message */ + $error_message = __( 'The plugin caused an error in the admin backend of type %1$s in line %2$s of the file %3$s. Error message: %4$s' ); + } + printf( '

%s

', sprintf( - /* translators: 1: error type, 2: error line number, 3: error file name, 4: error message */ - __( 'The plugin caused an error of type %1$s in line %2$s of the file %3$s. Error message: %4$s' ), + $error_message, "{$error['type']}", "{$error['line']}", "{$error['file']}", diff --git a/src/wp-includes/class-wp-fatal-error-handler.php b/src/wp-includes/class-wp-fatal-error-handler.php index 0299b67a48528..45abde34a3744 100644 --- a/src/wp-includes/class-wp-fatal-error-handler.php +++ b/src/wp-includes/class-wp-fatal-error-handler.php @@ -40,10 +40,12 @@ public function handle() { // If the error was stored and thus the extension paused, // redirect the request to catch multiple errors in one go. - if ( $this->store_error( $error ) ) { + if ( $this->store_error( $error ) && wp_is_recovery_mode() ) { $this->redirect_protected(); } + maybe_send_recovery_mode_email(); + // Display the PHP error template. $this->display_error_template(); } catch ( Exception $e ) { @@ -83,11 +85,6 @@ protected function detect_error() { * @return bool True if the error was stored successfully, false otherwise. */ protected function store_error( $error ) { - // Do not pause extensions if they only crash on a non-protected endpoint. - if ( ! is_protected_endpoint() ) { - return false; - } - return wp_record_extension_error( $error ); } @@ -102,11 +99,6 @@ protected function store_error( $error ) { * @since 5.1.0 */ protected function redirect_protected() { - // Do not redirect requests on non-protected endpoints. - if ( ! is_protected_endpoint() ) { - return; - } - // Pluggable is usually loaded after plugins, so we manually include it here for redirection functionality. if ( ! function_exists( 'wp_redirect' ) ) { include ABSPATH . WPINC . '/pluggable.php'; @@ -171,9 +163,9 @@ protected function display_default_error_template() { 'response' => 500, 'exit' => false, ); - if ( function_exists( 'admin_url' ) ) { - $args['link_url'] = admin_url(); - $args['link_text'] = __( 'Log into the admin backend to fix this.' ); + if ( function_exists( 'wp_login_url' ) ) { + $args['link_url'] = get_recovery_mode_request_url(); + $args['link_text'] = __( 'Request a Recovery Mode email to fix this.' ); } /** diff --git a/src/wp-includes/default-constants.php b/src/wp-includes/default-constants.php index 1d3fd5df9873a..0a71a91f2660d 100644 --- a/src/wp-includes/default-constants.php +++ b/src/wp-includes/default-constants.php @@ -302,6 +302,11 @@ function wp_cookie_constants() { if ( ! defined( 'COOKIE_DOMAIN' ) ) { define( 'COOKIE_DOMAIN', false ); } + + /** + * @since 5.1.0 + */ + define( 'RECOVERY_MODE_COOKIE', 'wordpress_rec_' . COOKIEHASH ); } /** diff --git a/src/wp-includes/error-protection.php b/src/wp-includes/error-protection.php index c9b57409606a3..e045efde574e1 100644 --- a/src/wp-includes/error-protection.php +++ b/src/wp-includes/error-protection.php @@ -88,6 +88,8 @@ function wp_record_extension_error( $error ) { $parts = explode( '/', $path ); $extension = array_shift( $parts ); + $error['wp_is_protected'] = is_protected_endpoint(); + return call_user_func( $callback )->record( $extension, $error ); } diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php index 7fe5566e7e092..6951845f2aae1 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -701,7 +701,7 @@ function wp_get_active_and_valid_plugins() { * Remove plugins from the list of active plugins when we're on an endpoint * that should be protected against WSODs and the plugin is paused. */ - if ( is_protected_endpoint() ) { + if ( wp_is_recovery_mode() ) { $plugins = wp_skip_paused_plugins( $plugins ); } @@ -766,7 +766,7 @@ function wp_get_active_and_valid_themes() { * Remove themes from the list of active themes when we're on an endpoint * that should be protected against WSODs and the theme is paused. */ - if ( is_protected_endpoint() ) { + if ( wp_is_recovery_mode() ) { $themes = wp_skip_paused_themes( $themes ); // If no active and valid themes exist, skip loading themes. @@ -1289,6 +1289,483 @@ function wp_using_themes() { return apply_filters( 'wp_using_themes', defined( 'WP_USE_THEMES' ) && WP_USE_THEMES ); } +/** + * Is WordPress in Recovery Mode. + * + * In this mode, plugins or themes that cause WSODs will be paused. + * + * @since 5.1.0 + * + * @return bool + */ +function wp_is_recovery_mode() { + /** + * Filters whether WordPress is in Recovery Mode. + * + * @since 5.1.0 + * + * @param bool $wp_is_recovery_mode Whether WordPress is in recovery mode. + */ + return apply_filters( 'wp_is_recovery_mode', defined( 'WP_RECOVERY_MODE' ) && WP_RECOVERY_MODE ); +} + +/** + * Create a recovery mode key for a user. + * + * @since 5.1.0 + * + * @global PasswordHash $wp_hasher + * + * @return string Recovery mode key. + */ +function generate_and_store_recovery_mode_key() { + + global $wp_hasher; + + if ( ! function_exists( 'wp_generate_password' ) ) { + require_once ABSPATH . WPINC . '/pluggable.php'; + } + + $key = wp_generate_password( 20, false ); + + /** + * Fires when a recovery mode key is generated for a user. + * + * @since 5.1.0 + * + * @param string $key The recovery mode key. + */ + do_action( 'generate_recovery_mode_key', $key ); + + if ( empty( $wp_hasher ) ) { + require_once ABSPATH . WPINC . '/class-phpass.php'; + $wp_hasher = new PasswordHash( 8, true ); + } + + $hashed = $wp_hasher->HashPassword( $key ); + + update_site_option( 'recovery_key', array( + 'hashed_key' => $hashed, + 'created_at' => time(), + ) ); + + return $key; +} + +/** + * Verify if the recovery mode key is correct. + * + * @since 5.1.0 + * + * @param string $key The unhashed key. + * + * @return true|WP_Error + */ +function validate_recovery_mode_key( $key ) { + + $record = get_site_option( 'recovery_key' ); + + if ( ! $record ) { + return new WP_Error( 'no_recovery_key_set', __( 'Recovery Mode not initialized.' ) ); + } + + if ( ! is_array( $record ) || ! isset( $record['hashed_key'], $record['created_at'] ) ) { + return new WP_Error( 'invalid_recovery_key_format', __( 'Invalid recovery key format.' ) ); + } + + if ( ! function_exists( 'wp_check_password' ) ) { + require_once ABSPATH . WPINC . '/pluggable.php'; + } + + if ( ! wp_check_password( $key, $record['hashed_key'] ) ) { + return new WP_Error( 'hash_mismatch', __( 'Invalid recovery key.' ) ); + } + + $valid_for = HOUR_IN_SECONDS; + + if ( time() > $record['created_at'] + $valid_for ) { + return new WP_Error( 'key_expired', __( 'Recovery key expired.' ) ); + } + + return true; +} + +/** + * A form of `wp_hash()` specific to Recovery Mode. + * + * We cannot use `wp_hash()` because it is defined in `pluggable.php` which is not loaded until after plugins are loaded, + * which is too late to verify the recovery mode cookie. + * + * This tries to use the `AUTH` salts first, but if they aren't valid specific salts will be generated and stored. + * + * @param string $data + * + * @return string|false + */ +function recovery_mode_hash( $data ) { + + if ( ! defined( 'AUTH_KEY' ) || AUTH_KEY === 'put your unique phrase here' ) { + $auth_key = get_site_option( 'recovery_mode_auth_key' ); + + if ( ! $auth_key ) { + if ( ! function_exists( 'wp_generate_password' ) ) { + require_once ABSPATH . WPINC . '/pluggable.php'; + } + + $auth_key = wp_generate_password( 64, true, true ); + update_site_option( 'recovery_mode_auth_key', $auth_key ); + } + } else { + $auth_key = AUTH_KEY; + } + + if ( ! defined( 'AUTH_SALT' ) || 'put your unique phrase here' === AUTH_SALT || $auth_key === AUTH_SALT ) { + $auth_salt = get_site_option( 'recovery_mode_auth_salt' ); + + if ( ! $auth_salt ) { + if ( ! function_exists( 'wp_generate_password' ) ) { + require_once ABSPATH . WPINC . '/pluggable.php'; + } + + $auth_salt = wp_generate_password( 64, true, true ); + update_site_option( 'recovery_mode_auth_salt', $auth_salt ); + } + } else { + $auth_salt = AUTH_SALT; + } + + $secret = $auth_key . $auth_salt; + + return hash_hmac( 'sha1', $data, $secret ); +} + +/** + * Generate the recovery mode cookie value. + * + * @since 5.1.0 + * + * @return string + */ +function generate_recovery_mode_cookie() { + + if ( ! function_exists( 'wp_generate_password' ) ) { + require_once ABSPATH . WPINC . '/pluggable.php'; + } + + $to_sign = sprintf( 'recovery_mode|%s|%s', time(), wp_generate_password( 20, false ) ); + $signed = recovery_mode_hash( $to_sign ); + + return base64_encode( sprintf( '%s|%s', $to_sign, $signed ) ); +} + +/** + * Set the recovery mode cookie. + * + * @since 5.1.0 + */ +function set_recovery_mode_cookie() { + + $value = generate_recovery_mode_cookie(); + + setcookie( RECOVERY_MODE_COOKIE, $value, 0, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true ); + + if ( COOKIEPATH !== SITECOOKIEPATH ) { + setcookie( RECOVERY_MODE_COOKIE, $value, 0, SITECOOKIEPATH, COOKIE_DOMAIN, is_ssl(), true ); + } +} + +/** + * Clear the recovery mode cookie. + * + * @sicne 5.1.0 + */ +function clear_recovery_mode_cookie() { + setcookie( RECOVERY_MODE_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN ); + setcookie( RECOVERY_MODE_COOKIE, ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN ); +} + +/** + * Validate the recovery mode cookie. + * + * @since 5.1.0 + * + * @param string $cookie Optionally, specify the cookie value instead of fetching from the super global. + * + * @return true|WP_Error + */ +function validate_recovery_mode_cookie( $cookie = '' ) { + + if ( ! $cookie ) { + if ( empty( $_COOKIE[ RECOVERY_MODE_COOKIE ] ) ) { + return new WP_Error( 'no_cookie' ); + } + + $cookie = $_COOKIE[ RECOVERY_MODE_COOKIE ]; + } + + $cookie = base64_decode( $cookie ); + $parts = explode( '|', $cookie ); + + if ( 4 !== count( $parts ) ) { + return new WP_Error( 'invalid_format', __( 'Invalid cookie format.' ) ); + } + + list( , $created_at, $random, $signature ) = $parts; + + if ( ! ctype_digit( $created_at ) ) { + return new WP_Error( 'invalid_created_at', __( 'Invalid cookie format.' ) ); + } + + /** + * Filter the length of time a Recovery Mode cookie is valid for. + * + * @since 5.1.0 + * + * @param int $length Length in seconds. + */ + $length = apply_filters( 'recovery_mode_cookie_length', WEEK_IN_SECONDS ); + + if ( time() > $created_at + $length ) { + return new WP_Error( 'expired', __( 'Cookie expired.' ) ); + } + + $to_sign = sprintf( 'recovery_mode|%s|%s', $created_at, $random ); + $hashed = recovery_mode_hash( $to_sign ); + + if ( ! hash_equals( $signature, $hashed ) ) { + return new WP_Error( 'signature_mismatch', __( 'Invalid cookie.' ) ); + } + + return true; +} + +/** + * Handle initializing Recovery Mode and sending a Recovery Mode link. + * + * @since 5.1.0 + */ +function handle_recovery_mode_actions() { + + if ( isset( $_COOKIE[ RECOVERY_MODE_COOKIE ] ) ) { + $validated = validate_recovery_mode_cookie(); + + if ( is_wp_error( $validated ) ) { + clear_recovery_mode_cookie(); + + wp_die( $validated, '', array( + 'link_url' => get_recovery_mode_request_url(), + 'link_text' => __( 'Send a new email.' ), + ) ); + } + + if ( ! defined( 'WP_RECOVERY_MODE' ) ) { + define( 'WP_RECOVERY_MODE', true ); + } + + return; + } + + if ( ! isset( $GLOBALS['pagenow'] ) || 'wp-login.php' !== $GLOBALS['pagenow'] ) { + return; + } + + if ( isset( $_GET['action'], $_GET['rm_key'] ) && 'begin_recovery_mode' === $_GET['action'] ) { + $validated = validate_recovery_mode_key( $_GET['rm_key'] ); + + if ( is_wp_error( $validated ) ) { + wp_die( $validated, '', array( + 'link_url' => get_recovery_mode_request_url(), + 'link_text' => __( 'Send a new email.' ), + ) ); + } + + set_recovery_mode_cookie(); + + // This should be loaded by set_recovery_mode_cookie() but load it again to be safe. + if ( ! function_exists( 'wp_redirect' ) ) { + require_once ABSPATH . WPINC . '/pluggable.php'; + } + + $url = add_query_arg( 'action', 'begun_recovery_mode', wp_login_url() ); + $message = ''; + + wp_die( $message, '', array( + 'response' => 200, + 'link_url' => $url, + 'link_text' => __( 'Continue to Login' ), + ) ); + } + + if ( isset( $_GET['action'] ) && 'request_recovery_mode' === $_GET['action'] ) { + $sent = maybe_send_recovery_mode_email(); + + if ( ! function_exists( 'wp_redirect' ) ) { + require_once ABSPATH . WPINC . '/pluggable.php'; + } + + if ( is_wp_error( $sent ) ) { + $message = $sent; + $args = array(); + } else { + $message = __( 'Recovery Link sent to the Site Admin email address.' ); + $args = array( 'response' => 200 ); + } + + wp_die( $message, '', $args ); + exit; + } +} + +/** + * Get a URL to request a recovery mode link be emailed to the user. + * + * @since 5.1.0 + * + * @return string + */ +function get_recovery_mode_request_url() { + $url = add_query_arg( 'action', 'request_recovery_mode', wp_login_url() ); + + /** + * Filter the URL to request a recovery mode link be emailed to the user. + * + * @since 5.1.0 + * + * @param string $url + */ + return apply_filters( 'recovery_mode_request_url', $url ); +} + +/** + * Get a URL to begin recovery mode. + * + * @since 5.1.0 + * + * @param string $key Recovery Mode key created by {@see generate_and_store_recovery_mode_key()} + * + * @return string + */ +function get_recovery_mode_begin_url( $key ) { + + $url = add_query_arg( array( + 'action' => 'begin_recovery_mode', + 'rm_key' => $key, + ), wp_login_url() ); + + /** + * Filter the URL to begin recovery mode. + * + * @since 5.1.0 + * + * @param string $url + * @param string $key + */ + return apply_filters( 'recovery_mode_begin_url', $url, $key ); +} + +/** + * Send the recovery mode email if the rate limit has not been sent. + * + * @since 5.1.0 + * + * @return true|WP_Error True if email sent, WP_Error otherwise. + */ +function maybe_send_recovery_mode_email() { + + /** + * Filter the rate limit between sending new recovery mode email links. + * + * @since 5.1.0 + * + * @param int $rate_limit Time to wait in seconds. + */ + $rate_limit = apply_filters( 'recovery_mode_email_rate_limit', HOUR_IN_SECONDS ); + + $last_sent = get_site_option( 'recovery_mode_email_last_sent' ); + + if ( ! $last_sent || time() > $last_sent + $rate_limit ) { + $sent = send_recovery_mode_email(); + update_site_option( 'recovery_mode_email_last_sent', time() ); + + if ( $sent ) { + return true; + } + + return new WP_Error( 'email_failed', __( 'The email could not be sent. Possible reason: your host may have disabled the mail() function.' ) ); + } + + $error = sprintf( + /* translators: 1. Last sent as a human time diff 2. Wait time as a human time diff. */ + __( 'A recovery link was already sent %1$s ago. Please wait another %2$s before requesting a new email.' ), + human_time_diff( $last_sent ), + human_time_diff( $last_sent + $rate_limit ) + ); + + return new WP_Error( 'email_sent_already', $error ); +} + +/** + * Send the Recovery Mode email to the site admin email address. + * + * @since 5.1.0 + * + * @return bool Whether the email was sent successfully. + */ +function send_recovery_mode_email() { + + $key = generate_and_store_recovery_mode_key(); + $url = get_recovery_mode_begin_url( $key ); + $blogname = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + + $switched_locale = false; + + // The switch_to_locale() function is loaded before it can actually be used. + if ( function_exists( 'switch_to_locale' ) && isset( $GLOBALS['wp_locale_switcher'] ) ) { + $switched_locale = switch_to_locale( get_locale() ); + } + + $message = __( + 'Howdy, + +Your site recently experienced a fatal error. Click the link below to initiate recovery mode to fix the problem. + +This link expires in one hour. + +###LINK###' + ); + $message = str_replace( '###LINK###', $url, $message ); + + $email = array( + 'to' => get_option( 'admin_email' ), + 'subject' => __( '[%s] Recovery Mode' ), + 'message' => $message, + 'headers' => '', + ); + + /** + * Filter the contents of the Recovery Mode email. + * + * @since 5.1.0 + * + * @param array $email Used to build wp_mail(). + * @param string $key Recovery mode key. + */ + $email = apply_filters( 'recovery_mode_email', $email, $key ); + + $sent = wp_mail( + $email['to'], + wp_specialchars_decode( sprintf( $email['subject'], $blogname ) ), + $email['message'], + $email['headers'] + ); + + if ( $switched_locale ) { + restore_previous_locale(); + } + + return $sent; +} + /** * Determines whether we are currently on an endpoint that should be protected against WSODs. * diff --git a/src/wp-includes/ms-load.php b/src/wp-includes/ms-load.php index 4f630cce27a1f..91c9c8c301525 100644 --- a/src/wp-includes/ms-load.php +++ b/src/wp-includes/ms-load.php @@ -53,14 +53,6 @@ function wp_get_active_network_plugins() { } } - /* - * Remove plugins from the list of active plugins when we're on an endpoint - * that should be protected against WSODs and the plugin is paused. - */ - if ( is_protected_endpoint() ) { - $plugins = wp_skip_paused_plugins( $plugins ); - } - return $plugins; } diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index 0e9d4ad2f0258..90b9dba6df8de 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -979,6 +979,8 @@ function wp_clear_auth_cookie() { // Post password cookie setcookie( 'wp-postpass_' . COOKIEHASH, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN ); + + clear_recovery_mode_cookie(); } endif; diff --git a/src/wp-login.php b/src/wp-login.php index 1a302a35f132b..4171e9065b6a1 100644 --- a/src/wp-login.php +++ b/src/wp-login.php @@ -438,7 +438,7 @@ function retrieve_password() { } // Validate action so as to default to the login screen. -if ( ! in_array( $action, array( 'postpass', 'logout', 'lostpassword', 'retrievepassword', 'resetpass', 'rp', 'register', 'login', 'confirmaction' ), true ) && false === has_filter( 'login_form_' . $action ) ) { +if ( ! in_array( $action, array( 'postpass', 'logout', 'lostpassword', 'retrievepassword', 'resetpass', 'rp', 'register', 'login', 'confirmaction', 'begun_recovery_mode' ), true ) && false === has_filter( 'login_form_' . $action ) ) { $action = 'login'; } @@ -1024,6 +1024,8 @@ function retrieve_password() { $errors->add( 'registered', __( 'Registration complete. Please check your email.' ), 'message' ); } elseif ( strpos( $redirect_to, 'about.php?updated' ) ) { $errors->add( 'updated', __( 'You have successfully updated WordPress! Please log back in to see what’s new.' ), 'message' ); + } elseif ( 'begun_recovery_mode' === $action ) { + $errors->add( 'begun_recovery_mode', __( 'Recovery Mode Initialized. Please login to continue.' ), 'message' ); } } diff --git a/src/wp-settings.php b/src/wp-settings.php index e48208beb6922..60f1e50485c81 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -342,6 +342,9 @@ // Register the default theme directory root register_theme_directory( get_theme_root() ); +// Handle users requesting a recovery mode link and initiating recovery mode. +handle_recovery_mode_actions(); + // Load active plugins. foreach ( wp_get_active_and_valid_plugins() as $plugin ) { wp_register_plugin_realpath( $plugin ); diff --git a/tests/phpunit/tests/recovery-mode.php b/tests/phpunit/tests/recovery-mode.php new file mode 100644 index 0000000000000..a7d2a69781ccf --- /dev/null +++ b/tests/phpunit/tests/recovery-mode.php @@ -0,0 +1,130 @@ +user->create( array( 'role' => 'subscriber' ) ); + self::$administrator = self::factory()->user->create( array( 'role' => 'administrator' ) ); + + return parent::setUpBeforeClass(); + } + + public static function tearDownAfterClass() { + wp_delete_user( self::$subscriber ); + wp_delete_user( self::$administrator ); + + return parent::tearDownAfterClass(); + } + + public function test_generate_and_store_returns_recovery_key() { + $key = generate_and_store_recovery_mode_key(); + + $this->assertNotWPError( $key ); + } + + public function test_verify_recovery_mode_key_returns_wp_error_if_no_key_set() { + $error = validate_recovery_mode_key( 'abcd' ); + + $this->assertWPError( $error ); + $this->assertEquals( 'no_recovery_key_set', $error->get_error_code() ); + } + + public function test_verify_recovery_mode_key_returns_wp_error_if_stored_format_is_invalid() { + update_site_option( 'recovery_key', 'gibberish' ); + $error = validate_recovery_mode_key( 'abcd' ); + + $this->assertWPError( $error ); + $this->assertEquals( 'invalid_recovery_key_format', $error->get_error_code() ); + } + + public function test_verify_recovery_mode_key_returns_wp_error_if_empty_key() { + generate_and_store_recovery_mode_key(); + $error = validate_recovery_mode_key( '' ); + + $this->assertWPError( $error ); + $this->assertEquals( 'hash_mismatch', $error->get_error_code() ); + } + + public function test_verify_recovery_mode_key_returns_wp_error_if_hash_mismatch() { + generate_and_store_recovery_mode_key(); + $error = validate_recovery_mode_key( 'abcd' ); + + $this->assertWPError( $error ); + $this->assertEquals( 'hash_mismatch', $error->get_error_code() ); + } + + public function test_verify_recovery_mode_key_returns_wp_error_if_expired() { + $key = generate_and_store_recovery_mode_key(); + + $record = get_site_option( 'recovery_key' ); + $record['created_at'] = time() - HOUR_IN_SECONDS - 30; + update_site_option( 'recovery_key', $record ); + + $error = validate_recovery_mode_key( $key ); + + $this->assertWPError( $error ); + $this->assertEquals( 'key_expired', $error->get_error_code() ); + } + + public function test_verify_recovery_mode_key_returns_true_for_valid_key() { + + $key = generate_and_store_recovery_mode_key(); + $this->assertTrue( validate_recovery_mode_key( $key ) ); + } + + public function test_validate_recovery_mode_cookie_returns_wp_error_if_invalid_format() { + + $error = validate_recovery_mode_cookie( 'gibbersih' ); + $this->assertWPError( $error ); + $this->assertEquals( 'invalid_format', $error->get_error_code() ); + + $error = validate_recovery_mode_cookie( base64_encode( 'test|data|format' ) ); + $this->assertWPError( $error ); + $this->assertEquals( 'invalid_format', $error->get_error_code() ); + + $error = validate_recovery_mode_cookie( base64_encode( 'test|data|format|to|long' ) ); + $this->assertWPError( $error ); + $this->assertEquals( 'invalid_format', $error->get_error_code() ); + } + + public function test_validate_recovery_mode_cookie_returns_wp_error_if_expired() { + + $to_sign = sprintf( 'recovery_mode|%s|%s', time() - WEEK_IN_SECONDS - 30, wp_generate_password( 20, false ) ); + $signed = recovery_mode_hash( $to_sign ); + $cookie = base64_encode( sprintf( '%s|%s', $to_sign, $signed ) ); + + $error = validate_recovery_mode_cookie( $cookie ); + $this->assertWPError( $error ); + $this->assertEquals( 'expired', $error->get_error_code() ); + } + + public function test_validate_recovery_mode_cookie_returns_wp_error_if_signature_mismatch() { + + $cookie = generate_recovery_mode_cookie(); + $cookie .= 'gibbersih'; + + $error = validate_recovery_mode_cookie( $cookie ); + $this->assertWPError( $error ); + $this->assertEquals( 'signature_mismatch', $error->get_error_code() ); + } + + public function test_validate_recovery_mode_cookie_returns_wp_error_if_created_at_is_invalid_format() { + + $to_sign = sprintf( 'recovery_mode|%s|%s', 'month', wp_generate_password( 20, false ) ); + $signed = recovery_mode_hash( $to_sign ); + $cookie = base64_encode( sprintf( '%s|%s', $to_sign, $signed ) ); + + $error = validate_recovery_mode_cookie( $cookie ); + $this->assertWPError( $error ); + $this->assertEquals( 'invalid_created_at', $error->get_error_code() ); + } + + public function test_generate_and_validate_recovery_mode_cookie_returns_true_for_valid_cookie() { + + $cookie = generate_recovery_mode_cookie(); + $this->assertTrue( validate_recovery_mode_cookie( $cookie ) ); + } +}