diff --git a/WP_Auth0.php b/WP_Auth0.php index c8e48665..ce64c283 100644 --- a/WP_Auth0.php +++ b/WP_Auth0.php @@ -126,6 +126,19 @@ public function init() { $edit_profile = new WP_Auth0_EditProfile( $this->db_manager, $users_repo, $this->a0_options ); $edit_profile->init(); + $api_client_creds = new WP_Auth0_Api_Client_Credentials( $this->a0_options ); + + $api_change_password = new WP_Auth0_Api_Change_Password( $this->a0_options, $api_client_creds ); + $profile_change_pwd = new WP_Auth0_Profile_Change_Password( $api_change_password ); + $profile_change_pwd->init(); + + $profile_delete_data = new WP_Auth0_Profile_Delete_Data( $users_repo ); + $profile_delete_data->init(); + + $api_delete_mfa = new WP_Auth0_Api_Delete_User_Mfa( $this->a0_options, $api_client_creds ); + $profile_delete_mfa = new WP_Auth0_Profile_Delete_Mfa( $this->a0_options, $api_delete_mfa ); + $profile_delete_mfa->init(); + WP_Auth0_Email_Verification::init(); } @@ -420,7 +433,7 @@ public function render_form( $html ) { // Do not show Auth0 form when ... if ( // .. processing lost password - ( isset( $_GET['action'] ) && $_GET['action'] == 'lostpassword' ) + ( isset( $_GET['action'] ) && in_array( $_GET['action'], array( 'lostpassword', 'rp' ) ) ) // ... handling an Auth0 callback || ! empty( $_GET['auth0'] ) // ... plugin is not configured @@ -510,6 +523,7 @@ private function autoloader( $class ) { $source_dir . 'admin/', $source_dir . 'api/', $source_dir . 'exceptions/', + $source_dir . 'profile/', $source_dir . 'wizard/', $source_dir . 'initial-setup/', ); diff --git a/assets/js/edit-user-profile.js b/assets/js/edit-user-profile.js new file mode 100644 index 00000000..9a682d08 --- /dev/null +++ b/assets/js/edit-user-profile.js @@ -0,0 +1,81 @@ +/* global jQuery, wpa0UserProfile, alert */ + +jQuery(function($) { + 'use strict'; + + var passwordFieldRow = $('#password'); + var emailField = $('input[name=email]'); + var deleteUserDataButton = $('#auth0_delete_data'); + var deleteMfaDataButton = $('#auth0_delete_mfa'); + + /** + * Hide the password field if not an Auth0 strategy. + */ + if ( passwordFieldRow.length && wpa0UserProfile.userStrategy && 'auth0' !== wpa0UserProfile.userStrategy ) { + passwordFieldRow.hide(); + } + + /** + * Disable email changes if not an Auth0 connection. + */ + if ( emailField.length && wpa0UserProfile.userStrategy && 'auth0' !== wpa0UserProfile.userStrategy ) { + emailField.prop( 'disabled', true ); + $('

' + wpa0UserProfile.i18n.cannotChangeEmail + '

') + .addClass('description') + .insertAfter(emailField); + } + + /** + * Delete Auth0 data button click. + */ + deleteUserDataButton.click(function (e) { + if ( ! window.confirm(wpa0UserProfile.i18n.confirmDeleteId) ) { + return; + } + e.preventDefault(); + userProfileAjaxAction($(this), 'auth0_delete_data', wpa0UserProfile.deleteIdNonce ); + }); + + /** + * Delete MFA data button click. + */ + deleteMfaDataButton.click(function (e) { + if ( ! window.confirm(wpa0UserProfile.i18n.confirmDeleteMfa) ) { + return; + } + e.preventDefault(); + userProfileAjaxAction($(this), 'auth0_delete_mfa', wpa0UserProfile.deleteMfaNonce); + }); + + /** + * Perform a generic user profile AJAX call. + * + * @param uiControl + * @param action + * @param nonce + */ + function userProfileAjaxAction( uiControl, action, nonce ) { + var postData = { + 'action' : action, + '_ajax_nonce' : nonce, + 'user_id' : wpa0UserProfile.userId + }; + var errorMsg = wpa0UserProfile.i18n.actionFailed; + uiControl.prop( 'disabled', true ); + $.post( + wpa0UserProfile.ajaxUrl, + postData, + function(response) { + if ( response.success ) { + uiControl.val(wpa0UserProfile.i18n.actionComplete); + } else { + if (response.data && response.data.error) { + errorMsg = response.data.error; + } + alert(errorMsg); + uiControl.prop( 'disabled', false ); + } + } + ); + } +}); \ No newline at end of file diff --git a/composer.json b/composer.json index 06362b5a..5d0f8241 100644 --- a/composer.json +++ b/composer.json @@ -38,8 +38,7 @@ "phpcbf-tests": "./vendor/bin/phpcbf --standard=phpcs-test-ruleset.xml -s ./tests/", "phpcbf-path": "SHELL_INTERACTIVE=1 ./vendor/bin/phpcbf --standard=phpcs-ruleset.xml", "sniffs": "./vendor/bin/phpcs --standard=phpcs-ruleset.xml -e", - "test": "./vendor/bin/phpunit --coverage-text", - "test-ci": "./vendor/bin/phpunit --debug --verbose --coverage-clover=coverage.xml", - "test-path": "SHELL_INTERACTIVE=1 ./vendor/bin/phpunit" + "test": "SHELL_INTERACTIVE=1 ./vendor/bin/phpunit --coverage-text --verbose", + "test-ci": "./vendor/bin/phpunit --debug --verbose --coverage-clover=coverage.xml" } } diff --git a/lib/WP_Auth0_Api_Client.php b/lib/WP_Auth0_Api_Client.php index a61d4d3c..b17a8f70 100755 --- a/lib/WP_Auth0_Api_Client.php +++ b/lib/WP_Auth0_Api_Client.php @@ -901,6 +901,9 @@ public static function delete_connection( $domain, $app_token, $id ) { return json_decode( $response['body'] ); } + /** + * TODO: Deprecate + */ public static function delete_user_mfa( $domain, $app_token, $user_id, $provider ) { $endpoint = "https://$domain/api/v2/users/$user_id/multifactor/$provider"; diff --git a/lib/WP_Auth0_EditProfile.php b/lib/WP_Auth0_EditProfile.php index 527ca5eb..0fb0e944 100644 --- a/lib/WP_Auth0_EditProfile.php +++ b/lib/WP_Auth0_EditProfile.php @@ -1,38 +1,102 @@ a0_options = $a0_options; - $this->users_repo = $users_repo; + /** + * WP_Auth0_Options instance. + * + * @var WP_Auth0_Options + */ + protected $a0_options; + + /** + * WP_Auth0_EditProfile constructor. + * + * @param WP_Auth0_DBManager $db_manager - WP_Auth0_DBManager instance. + * @param WP_Auth0_UsersRepo $users_repo - WP_Auth0_UsersRepo instance. + * @param WP_Auth0_Options $a0_options - WP_Auth0_Options instance. + */ + public function __construct( + WP_Auth0_DBManager $db_manager, + WP_Auth0_UsersRepo $users_repo, + WP_Auth0_Options $a0_options + ) { $this->db_manager = $db_manager; + $this->users_repo = $users_repo; + $this->a0_options = $a0_options; } + /** + * Add actions and filters for the profile page. + */ public function init() { - global $pagenow; - + add_action( 'admin_enqueue_scripts', array( $this, 'admin_enqueue_scripts' ) ); add_action( 'personal_options_update', array( $this, 'override_email_update' ), 1 ); + } - add_action( 'edit_user_profile', array( $this, 'show_delete_identity' ) ); - add_action( 'edit_user_profile', array( $this, 'show_delete_mfa' ) ); - add_action( 'show_user_profile', array( $this, 'show_delete_mfa' ) ); - - add_action( 'wp_ajax_auth0_delete_mfa', array( $this, 'delete_mfa' ) ); - add_action( 'wp_ajax_auth0_delete_data', array( $this, 'delete_user_data' ) ); - - add_action( 'show_user_profile', array( $this, 'show_change_password' ) ); - add_action( 'personal_options_update', array( $this, 'update_change_password' ) ); - add_filter( 'user_profile_update_errors', array( $this, 'validate_new_password' ), 10, 3 ); + /** + * Enqueue styles and scripts for the user profile edit screen. + * Hooked to: admin_enqueue_scripts + * + * @codeCoverageIgnore + */ + public function admin_enqueue_scripts() { + global $user_id; + global $pagenow; - if ( $pagenow == 'profile.php' || $pagenow == 'user-edit.php' ) { - add_action( 'admin_footer', array( $this, 'disable_email_field' ) ); + if ( ! in_array( $pagenow, array( 'profile.php', 'user-edit.php' ) ) ) { + return; } + + wp_enqueue_script( + 'wpa0_user_profile', + WPA0_PLUGIN_JS_URL . 'edit-user-profile.js', + array( 'jquery' ), + WPA0_VERSION + ); + + $profile = get_auth0userinfo( $user_id ); + $strategy = isset( $profile->sub ) ? WP_Auth0_Users::get_strategy( $profile->sub ) : ''; + + wp_localize_script( + 'wpa0_user_profile', + 'wpa0UserProfile', + array( + 'userId' => intval( $user_id ), + 'userStrategy' => sanitize_text_field( $strategy ), + 'deleteIdNonce' => wp_create_nonce( 'delete_auth0_identity' ), + 'deleteMfaNonce' => wp_create_nonce( 'delete_auth0_mfa' ), + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'i18n' => array( + 'confirmDeleteId' => __( 'Are you sure you want to delete the Auth0 user data for this user?', 'wp-auth0' ), + 'confirmDeleteMfa' => __( 'Are you sure you want to delete the Auth0 MFA data for this user?', 'wp-auth0' ), + 'actionComplete' => __( 'Deleted', 'wp-auth0' ), + 'actionFailed' => __( 'Action failed, please see the Auth0 error log for details.', 'wp-auth0' ), + 'cannotChangeEmail' => __( 'Email cannot be changed for non-database connections.', 'wp-auth0' ), + ), + ) + ); } + // TODO: Deprecate public function validate_new_password( $errors, $update, $user ) { $auth0_password = isset( $_POST['auth0_password'] ) ? $_POST['auth0_password'] : null; $auth0_repeat_password = isset( $_POST['auth0_repeat_password'] ) ? $_POST['auth0_repeat_password'] : null; @@ -42,7 +106,7 @@ public function validate_new_password( $errors, $update, $user ) { } } - + // TODO: Deprecate public function update_change_password() { $current_user = get_currentauth0user(); $user_profile = $current_user->auth0_obj; @@ -92,6 +156,7 @@ public function update_change_password() { } } + // TODO: Deprecate public function delete_user_data() { if ( ! is_admin() ) { return; @@ -102,6 +167,7 @@ public function delete_user_data() { $this->users_repo->delete_auth0_object( $user_id ); } + // TODO: Deprecate public function delete_mfa() { if ( ! is_admin() ) { return; @@ -123,6 +189,7 @@ public function delete_mfa() { WP_Auth0_Api_Client::delete_user_mfa( $domain, $app_token, $user_id, $provider ); } + // TODO: Deprecate public function show_delete_identity() { if ( ! is_admin() ) { return; @@ -165,6 +232,8 @@ function DeleteAuth0Data(event) { auth0_obj; @@ -257,6 +327,7 @@ public function show_change_password() { auth0_obj; @@ -294,6 +365,10 @@ public function disable_email_field() { } } + /** + * Process email changes and pass the update to Auth0 if it passes validation. + * Hooked to: personal_options_update + */ public function override_email_update() { global $wpdb; global $errors; diff --git a/lib/WP_Auth0_Email_Verification.php b/lib/WP_Auth0_Email_Verification.php index 83bdd1d5..5f3fc1a0 100644 --- a/lib/WP_Auth0_Email_Verification.php +++ b/lib/WP_Auth0_Email_Verification.php @@ -103,7 +103,7 @@ public function resend_verification_email() { wp_send_json_error( array( 'error' => __( 'No Auth0 user ID provided.', 'wp-auth0' ) ) ); } - if ( ! $this->api_jobs_resend->call() ) { + if ( ! $this->api_jobs_resend->call( $_POST['sub'] ) ) { wp_send_json_error( array( 'error' => __( 'API call failed.', 'wp-auth0' ) ) ); } @@ -120,8 +120,7 @@ public function resend_verification_email() { function wp_auth0_ajax_resend_verification_email() { $options = WP_Auth0_Options::Instance(); $api_client_creds = new WP_Auth0_Api_Client_Credentials( $options ); - $auth0_user_id = isset( $_POST['sub'] ) ? $_POST['sub'] : null; - $api_jobs_verification = new WP_Auth0_Api_Jobs_Verification( $options, $api_client_creds, $auth0_user_id ); + $api_jobs_verification = new WP_Auth0_Api_Jobs_Verification( $options, $api_client_creds ); $email_verification = new WP_Auth0_Email_Verification( $api_jobs_verification ); $email_verification->resend_verification_email(); diff --git a/lib/WP_Auth0_ErrorLog.php b/lib/WP_Auth0_ErrorLog.php index 49e836f1..8efda001 100644 --- a/lib/WP_Auth0_ErrorLog.php +++ b/lib/WP_Auth0_ErrorLog.php @@ -125,6 +125,8 @@ private function update( array $log ) { * Enqueue scripts and styles. * * @deprecated 3.6.0 - Not needed, handled in WP_Auth0_Admin::admin_enqueue() + * + * @codeCoverageIgnore */ public function admin_enqueue() { // phpcs:ignore diff --git a/lib/WP_Auth0_Users.php b/lib/WP_Auth0_Users.php index f3de37d4..7db6fcb4 100644 --- a/lib/WP_Auth0_Users.php +++ b/lib/WP_Auth0_Users.php @@ -91,4 +91,19 @@ public static function create_user( $userinfo, $role = null ) { // Return the user ID return $user_id; } + + /** + * Get the strategy from an Auth0 user ID. + * + * @param string $auth0_id - Auth0 user ID. + * + * @return string + */ + public static function get_strategy( $auth0_id ) { + if ( false === strpos( $auth0_id, '|' ) ) { + return ''; + } + $auth0_id_parts = explode( '|', $auth0_id ); + return $auth0_id_parts[0]; + } } diff --git a/lib/api/WP_Auth0_Api_Change_Password.php b/lib/api/WP_Auth0_Api_Change_Password.php new file mode 100644 index 00000000..8242f388 --- /dev/null +++ b/lib/api/WP_Auth0_Api_Change_Password.php @@ -0,0 +1,91 @@ +api_client_creds = $api_client_creds; + } + + /** + * Set the user_id and password, make the API call, and handle the response. + * + * @param string|null $user_id - Auth0 user ID to change the password for. + * @param string|null $password - New password. + * + * @return bool|string + */ + public function call( $user_id = null, $password = null ) { + + if ( empty( $user_id ) || empty( $password ) ) { + return self::RETURN_ON_FAILURE; + } + + if ( ! $this->set_bearer( 'update:users' ) ) { + return self::RETURN_ON_FAILURE; + } + + return $this + ->set_path( 'api/v2/users/' . rawurlencode( $user_id ) ) + ->add_body( 'password', $password ) + ->patch() + ->handle_response( __METHOD__ ); + } + + /** + * Handle API response. + * + * @param string $method - Method that called the API. + * + * @return integer + */ + protected function handle_response( $method ) { + + if ( $this->handle_wp_error( $method ) ) { + return self::RETURN_ON_FAILURE; + } + + if ( $this->handle_failed_response( $method ) ) { + $response_body = json_decode( $this->response_body ); + if ( isset( $response_body->message ) && false !== strpos( $response_body->message, 'PasswordStrengthError' ) ) { + return __( 'Password is too weak, please choose a different one.', 'wp-auth0' ); + } + return self::RETURN_ON_FAILURE; + } + + return true; + } +} diff --git a/lib/api/WP_Auth0_Api_Client_Credentials.php b/lib/api/WP_Auth0_Api_Client_Credentials.php index 417ca868..f1d9b9c6 100644 --- a/lib/api/WP_Auth0_Api_Client_Credentials.php +++ b/lib/api/WP_Auth0_Api_Client_Credentials.php @@ -32,7 +32,6 @@ class WP_Auth0_Api_Client_Credentials extends WP_Auth0_Api_Abstract { * @param WP_Auth0_Options $options - WP_Auth0_Options instance. */ public function __construct( WP_Auth0_Options $options ) { - parent::__construct( $options ); $this->set_path( 'oauth/token' ) ->send_client_id() diff --git a/lib/api/WP_Auth0_Api_Delete_User_Mfa.php b/lib/api/WP_Auth0_Api_Delete_User_Mfa.php new file mode 100644 index 00000000..e110ad7c --- /dev/null +++ b/lib/api/WP_Auth0_Api_Delete_User_Mfa.php @@ -0,0 +1,86 @@ +api_client_creds = $api_client_creds; + } + + /** + * Set the user_id and provider, set the authorization header, call the API, and handle the response. + * + * @param string|null $user_id - Auth0 user ID to delete the MFA provider. + * @param string $provider - MFA provider. + * + * @return int|mixed + */ + public function call( $user_id = null, $provider = 'google-authenticator' ) { + + if ( empty( $user_id ) || empty( $provider ) ) { + return self::RETURN_ON_FAILURE; + } + + if ( ! $this->set_bearer( 'update:users' ) ) { + return self::RETURN_ON_FAILURE; + } + + return $this + ->set_path( sprintf( 'api/v2/users/%s/multifactor/%s', rawurlencode( $user_id ), rawurlencode( $provider ) ) ) + ->delete() + ->handle_response( __METHOD__ ); + } + + /** + * Handle API response. + * + * @param string $method - Method that called the API. + * + * @return integer + */ + protected function handle_response( $method ) { + + if ( $this->handle_wp_error( $method ) ) { + return self::RETURN_ON_FAILURE; + } + + if ( $this->handle_failed_response( $method, 204 ) ) { + return self::RETURN_ON_FAILURE; + } + + return 1; + } +} diff --git a/lib/api/WP_Auth0_Api_Jobs_Verification.php b/lib/api/WP_Auth0_Api_Jobs_Verification.php index a4d72aea..a6a41572 100644 --- a/lib/api/WP_Auth0_Api_Jobs_Verification.php +++ b/lib/api/WP_Auth0_Api_Jobs_Verification.php @@ -34,32 +34,37 @@ class WP_Auth0_Api_Jobs_Verification extends WP_Auth0_Api_Abstract { * * @param WP_Auth0_Options $options - WP_Auth0_Options instance. * @param WP_Auth0_Api_Client_Credentials $api_client_creds - WP_Auth0_Api_Client_Credentials instance. - * @param string $user_id - Auth0 User ID. */ public function __construct( WP_Auth0_Options $options, - WP_Auth0_Api_Client_Credentials $api_client_creds, - $user_id + WP_Auth0_Api_Client_Credentials $api_client_creds ) { parent::__construct( $options ); $this->api_client_creds = $api_client_creds; $this->set_path( 'api/v2/jobs/verification-email' ) - ->add_body( 'user_id', $user_id ) ->send_client_id(); } /** * Set body data, make the API call, and handle the response. * - * @return boolean + * @param string|null $user_id - Auth0 user ID to send the verify email to. + * + * @return bool|mixed|null */ - public function call() { + public function call( $user_id = null ) { + + if ( empty( $user_id ) ) { + return self::RETURN_ON_FAILURE; + } if ( ! $this->set_bearer( self::API_SCOPE ) ) { return self::RETURN_ON_FAILURE; } - return $this->post()->handle_response( __METHOD__ ); + return $this->add_body( 'user_id', $user_id ) + ->post() + ->handle_response( __METHOD__ ); } /** diff --git a/lib/profile/WP_Auth0_Profile_Change_Password.php b/lib/profile/WP_Auth0_Profile_Change_Password.php new file mode 100644 index 00000000..21afbf46 --- /dev/null +++ b/lib/profile/WP_Auth0_Profile_Change_Password.php @@ -0,0 +1,89 @@ +api_change_password = $api_change_password; + } + + /** + * Add actions and filters for the profile page. + * + * @codeCoverageIgnore - Tested in TestProfileChangePassword::testInitHooks() + */ + public function init() { + add_action( 'user_profile_update_errors', array( $this, 'validate_new_password' ), 10, 2 ); + add_action( 'validate_password_reset', array( $this, 'validate_new_password' ), 10, 2 ); + } + + /** + * Update the user's password at Auth0 + * Hooked to: user_profile_update_errors, validate_password_reset + * IMPORTANT: Internal callback use only, do not call this function directly! + * + * @param WP_Error $errors - WP_Error object to use if validation fails. + * @param boolean|WP_User $user - Boolean update or WP_User instance, depending on action. + * + * @return boolean + */ + public function validate_new_password( $errors, $user ) { + + // Exit if we're not changing the password. + if ( empty( $_POST['pass1'] ) ) { + return false; + } + $new_password = $_POST['pass1']; + + if ( isset( $_POST['user_id'] ) ) { + $wp_user_id = absint( $_POST['user_id'] ); + } elseif ( is_object( $user ) && $user instanceof WP_User ) { + $wp_user_id = absint( $user->ID ); + } else { + return false; + } + + // Exit if this is not an Auth0 user. + $auth0_id = WP_Auth0_UsersRepo::get_meta( $wp_user_id, 'auth0_id' ); + if ( empty( $auth0_id ) ) { + return false; + } + $strategy = WP_Auth0_Users::get_strategy( $auth0_id ); + + // Exit if this is not a database strategy user. + if ( 'auth0' !== $strategy ) { + return false; + } + + $result = $this->api_change_password->call( $auth0_id, $new_password ); + + // Password change was successful, nothing else to do. + if ( true === $result ) { + return true; + } + + // Password change was unsuccessful so don't change WP user account. + unset( $_POST['pass1'] ); + unset( $_POST['pass1-text'] ); + unset( $_POST['pass2'] ); + + // Add an error message to appear at the top of the page. + $error_msg = is_string( $result ) ? $result : __( 'Password could not be updated.', 'wp-auth0' ); + $errors->add( 'auth0_password', $error_msg, array( 'form-field' => 'pass1' ) ); + return false; + } +} diff --git a/lib/profile/WP_Auth0_Profile_Delete_Data.php b/lib/profile/WP_Auth0_Profile_Delete_Data.php new file mode 100644 index 00000000..094ab4e7 --- /dev/null +++ b/lib/profile/WP_Auth0_Profile_Delete_Data.php @@ -0,0 +1,88 @@ +users_repo = $users_repo; + } + + /** + * Add actions and filters for the profile page. + * + * @codeCoverageIgnore - Tested in TestProfileDeleteData::testInitHooks() + */ + public function init() { + add_action( 'edit_user_profile', array( $this, 'show_delete_identity' ) ); + add_action( 'show_user_profile', array( $this, 'show_delete_identity' ) ); + add_action( 'wp_ajax_auth0_delete_data', array( $this, 'delete_user_data' ) ); + } + + /** + * Show the delete Auth0 user data button. + * Hooked to: edit_user_profile, show_user_profile + * IMPORTANT: Internal callback use only, do not call this function directly! + */ + public function show_delete_identity() { + global $user_id; + + if ( ! current_user_can( 'edit_users', $user_id ) ) { + return; + } + + if ( ! get_auth0userinfo( $user_id ) ) { + return; + } + + ?> + + + + + +
+ + + +
+ __( 'Empty user_id', 'wp-auth0' ) ) ); + } + + $user_id = $_POST['user_id']; + + if ( ! current_user_can( 'edit_users' ) ) { + wp_send_json_error( array( 'error' => __( 'Forbidden', 'wp-auth0' ) ) ); + } + + $this->users_repo->delete_auth0_object( $user_id ); + wp_send_json_success(); + } +} diff --git a/lib/profile/WP_Auth0_Profile_Delete_Mfa.php b/lib/profile/WP_Auth0_Profile_Delete_Mfa.php new file mode 100644 index 00000000..a1299169 --- /dev/null +++ b/lib/profile/WP_Auth0_Profile_Delete_Mfa.php @@ -0,0 +1,111 @@ +a0_options = $a0_options; + $this->api_delete_mfa = $api_delete_mfa; + } + + /** + * Add actions and filters for the profile page. + * + * @codeCoverageIgnore - Tested in TestProfileDeleteMfa::testInitHooks() + */ + public function init() { + add_action( 'edit_user_profile', array( $this, 'show_delete_mfa' ) ); + add_action( 'show_user_profile', array( $this, 'show_delete_mfa' ) ); + add_action( 'wp_ajax_auth0_delete_mfa', array( $this, 'delete_mfa' ) ); + } + + /** + * Show the delete Auth0 MFA data button. + * Hooked to: edit_user_profile, show_user_profile + * IMPORTANT: Internal callback use only, do not call this function directly! + */ + public function show_delete_mfa() { + global $user_id; + if ( ! current_user_can( 'edit_users', $user_id ) ) { + return; + } + + if ( ! $this->a0_options->get( 'mfa' ) ) { + return; + } + + if ( ! get_auth0userinfo( $user_id ) ) { + return; + } + ?> + + + + + +
+ + + +
+ __( 'Empty user_id', 'wp-auth0' ) ) ); + } + + $user_id = $_POST['user_id']; + + if ( ! current_user_can( 'edit_users', $user_id ) ) { + wp_send_json_error( array( 'error' => __( 'Forbidden', 'wp-auth0' ) ) ); + } + + $profile = get_auth0userinfo( $user_id ); + + if ( ! $profile || empty( $profile->sub ) ) { + wp_send_json_error( array( 'error' => __( 'Auth0 profile data not found', 'wp-auth0' ) ) ); + } + + if ( ! $this->api_delete_mfa->call( $profile->sub ) ) { + wp_send_json_error( array( 'error' => __( 'API call failed', 'wp-auth0' ) ) ); + } + + wp_send_json_success(); + } +} diff --git a/phpcs-ruleset.xml b/phpcs-ruleset.xml index 5d971e2e..6e43eaaf 100644 --- a/phpcs-ruleset.xml +++ b/phpcs-ruleset.xml @@ -34,6 +34,7 @@ + diff --git a/phpcs-test-ruleset.xml b/phpcs-test-ruleset.xml index c74ca821..a547f89f 100644 --- a/phpcs-test-ruleset.xml +++ b/phpcs-test-ruleset.xml @@ -14,6 +14,7 @@ + diff --git a/tests/testApiChangePassword.php b/tests/testApiChangePassword.php new file mode 100644 index 00000000..86d8dc07 --- /dev/null +++ b/tests/testApiChangePassword.php @@ -0,0 +1,200 @@ +startHttpHalting(); + self::$options->set( 'domain', self::TEST_DOMAIN ); + + // Mock for a successful API call. + $change_password = $this->getStub( true ); + + // Should fail with a missing user_id and password. + $returned = $change_password->call(); + $this->assertFalse( $returned ); + + // Should fail with a missing password. + $returned = $change_password->call( uniqid() ); + $this->assertFalse( $returned ); + + // Should fail if not authorized to use the API. + $change_password = $this->getStub( false ); + $returned = $change_password->call( uniqid(), uniqid() ); + $this->assertFalse( $returned ); + + // Should succeed with a user_id + provider and set_bearer returning true. + $change_password = $this->getStub( true ); + $decoded_res = []; + try { + $change_password->call( 'test|1234567890', 'strong-password' ); + } catch ( Exception $e ) { + $decoded_res = unserialize( $e->getMessage() ); + } + + $this->assertNotEmpty( $decoded_res ); + $this->assertEquals( + 'https://' . self::TEST_DOMAIN . '/api/v2/users/test%7C1234567890', + $decoded_res['url'] + ); + $this->assertEquals( 'PATCH', $decoded_res['method'] ); + $this->assertArrayHasKey( 'password', $decoded_res['body'] ); + $this->assertEquals( 'strong-password', $decoded_res['body']['password'] ); + } + + /** + * Test a basic Delete MFA call against a mock API server. + */ + public function testCall() { + $this->startHttpMocking(); + self::$options->set( 'domain', self::TEST_DOMAIN ); + + // Mock for a successful API call. + $delete_mfa = $this->getStub( true ); + + // 1. Make sure that a transport returns the default failed response and logs an error. + $this->http_request_type = 'wp_error'; + $this->assertFalse( $delete_mfa->call( uniqid(), uniqid() ) ); + $log = self::$error_log->get(); + $this->assertCount( 1, $log ); + $this->assertEquals( 'Caught WP_Error.', $log[0]['message'] ); + + // 2. Make sure that an Auth0 API error returns the default failed response and logs an error. + $this->http_request_type = 'auth0_api_error'; + $this->assertFalse( $delete_mfa->call( uniqid(), uniqid() ) ); + $log = self::$error_log->get(); + $this->assertCount( 2, $log ); + $this->assertEquals( 'caught_api_error', $log[0]['code'] ); + + // 4. Make sure that a weak password error returns the correct message. + $this->http_request_type = 'failed_weak_password'; + $this->assertEquals( + 'Password is too weak, please choose a different one.', + $delete_mfa->call( uniqid(), uniqid() ) + ); + $log = self::$error_log->get(); + $this->assertCount( 3, $log ); + $this->assertEquals( '400', $log[0]['code'] ); + + // 4. Make sure it succeeds. + $this->http_request_type = 'success_empty_body'; + $this->assertTrue( $delete_mfa->call( uniqid(), uniqid() ) ); + $this->assertCount( 3, self::$error_log->get() ); + } + + /* + * PHPUnit overrides to run after tests. + */ + + /** + * Stop HTTP halting and mocking, reset JWKS transient. + */ + public function tearDown() { + parent::tearDown(); + self::$options->set( 'domain', null ); + $this->stopHttpHalting(); + $this->stopHttpMocking(); + self::$error_log->clear(); + $this->assertEmpty( self::$error_log->get() ); + } + + /* + * Test helper functions. + */ + + /** + * Specific mock API responses for this suite. + * + * @return array|null|WP_Error + */ + public function httpMock() { + switch ( $this->getResponseType() ) { + case 'failed_weak_password': + return [ + 'body' => json_encode( + [ + 'statusCode' => 400, + 'error' => 'Bad Request', + 'message' => 'PasswordStrengthError: Password is too weak', + ] + ), + 'response' => [ 'code' => 400 ], + ]; + } + return $this->httpMockDefault(); + } + + /** + * Get a mocked WP_Auth0_Api_Change_Password to return true or false for set_bearer. + * + * @param bool $set_bearer_returns - Should the set_bearer call succeed or fail. + * + * @return PHPUnit_Framework_MockObject_MockObject|WP_Auth0_Api_Change_Password + */ + public function getStub( $set_bearer_returns ) { + $mock = $this + ->getMockBuilder( WP_Auth0_Api_Change_Password::class ) + ->setMethods( [ 'set_bearer' ] ) + ->setConstructorArgs( [ self::$options, self::$api_client_creds ] ) + ->getMock(); + $mock->method( 'set_bearer' )->willReturn( $set_bearer_returns ); + return $mock; + } +} diff --git a/tests/testApiClientCredentials.php b/tests/testApiClientCredentials.php index 290b00a8..86a299fc 100644 --- a/tests/testApiClientCredentials.php +++ b/tests/testApiClientCredentials.php @@ -152,11 +152,6 @@ public function testCall() { * @return array|null|WP_Error */ public function httpMock() { - $parent_mock = $this->httpMockDefault(); - if ( ! is_null( $parent_mock ) ) { - return $parent_mock; - } - switch ( $this->getResponseType() ) { case 'access_token': return [ @@ -164,6 +159,7 @@ public function httpMock() { 'response' => [ 'code' => 200 ], ]; } + return $this->httpMockDefault(); } /** @@ -173,6 +169,8 @@ public function tearDown() { parent::tearDown(); $this->stopHttpHalting(); $this->stopHttpMocking(); + self::$error_log->clear(); + $this->assertEmpty( self::$error_log->get() ); delete_transient( WPA0_JWKS_CACHE_TRANSIENT_NAME ); } } diff --git a/tests/testApiDeleteMfa.php b/tests/testApiDeleteMfa.php new file mode 100644 index 00000000..12a52a72 --- /dev/null +++ b/tests/testApiDeleteMfa.php @@ -0,0 +1,176 @@ +startHttpHalting(); + self::$options->set( 'domain', self::TEST_DOMAIN ); + + // Should fail with a missing user_id. + $delete_mfa = $this->getStub( true ); + $returned = $delete_mfa->call(); + $this->assertEquals( 0, $returned ); + + // Should fail if not authorized to use the API. + $delete_mfa = $this->getStub( false ); + $returned = $delete_mfa->call( uniqid() ); + $this->assertEquals( 0, $returned ); + + // Should succeed with a user_id + provider and set_bearer returning true. + $delete_mfa = $this->getStub( true ); + $decoded_res = []; + try { + $delete_mfa->call( 'test|1234567890', 'a-provider' ); + } catch ( Exception $e ) { + $decoded_res = unserialize( $e->getMessage() ); + } + + $this->assertNotEmpty( $decoded_res ); + $this->assertEquals( + 'https://' . self::TEST_DOMAIN . '/api/v2/users/test%7C1234567890/multifactor/a-provider', + $decoded_res['url'] + ); + $this->assertEquals( 'DELETE', $decoded_res['method'] ); + $this->assertEmpty( $decoded_res['body'] ); + } + + /** + * Test a basic Delete MFA call against a mock API server. + */ + public function testCall() { + $this->startHttpMocking(); + self::$options->set( 'domain', self::TEST_DOMAIN ); + + $delete_mfa = $this->getStub( true ); + + // 1. Make sure that a transport returns the default failed response and logs an error. + $this->http_request_type = 'wp_error'; + $this->assertEquals( 0, $delete_mfa->call( uniqid() ) ); + $log = self::$error_log->get(); + $this->assertCount( 1, $log ); + $this->assertEquals( 'Caught WP_Error.', $log[0]['message'] ); + + // 2. Make sure that an Auth0 API error returns the default failed response and logs an error. + $this->http_request_type = 'auth0_api_error'; + $this->assertEquals( 0, $delete_mfa->call( uniqid() ) ); + $log = self::$error_log->get(); + $this->assertCount( 2, $log ); + $this->assertEquals( 'caught_api_error', $log[0]['code'] ); + + // 3. Make sure it succeeds. + $this->http_request_type = 'success_delete_empty_body'; + $this->assertEquals( 1, $delete_mfa->call( uniqid() ) ); + $this->assertCount( 2, self::$error_log->get() ); + } + + /* + * PHPUnit overrides to run after tests. + */ + + /** + * Stop HTTP halting and mocking, reset JWKS transient. + */ + public function tearDown() { + parent::tearDown(); + self::$options->set( 'domain', null ); + $this->stopHttpHalting(); + $this->stopHttpMocking(); + self::$error_log->clear(); + $this->assertEmpty( self::$error_log->get() ); + } + + /* + * Test helper functions. + */ + + /** + * Specific mock API responses for this suite. + * + * @return array|null|WP_Error + */ + public function httpMock() { + switch ( $this->getResponseType() ) { + case 'success_delete_empty_body': + return [ + 'body' => '', + 'response' => [ 'code' => 204 ], + ]; + } + return $this->httpMockDefault(); + } + + /** + * Get a mocked WP_Auth0_Api_Delete_User_Mfa to return true or false for set_bearer. + * + * @param bool $set_bearer_returns - Should the set_bearer call succeed or fail. + * + * @return PHPUnit_Framework_MockObject_MockObject|WP_Auth0_Api_Delete_User_Mfa + */ + public function getStub( $set_bearer_returns ) { + $mock = $this + ->getMockBuilder( WP_Auth0_Api_Delete_User_Mfa::class ) + ->setMethods( [ 'set_bearer' ] ) + ->setConstructorArgs( [ self::$options, self::$api_client_creds ] ) + ->getMock(); + $mock->method( 'set_bearer' )->willReturn( $set_bearer_returns ); + return $mock; + } +} diff --git a/tests/testApiJobsVerification.php b/tests/testApiJobsVerification.php new file mode 100644 index 00000000..67d384b1 --- /dev/null +++ b/tests/testApiJobsVerification.php @@ -0,0 +1,196 @@ +startHttpHalting(); + self::$options->set( 'domain', self::TEST_DOMAIN ); + self::$options->set( 'client_id', self::TEST_CLIENT_ID ); + + // Should fail without a user_id. + $jobs_verification = $this->getStub( true ); + $returned = $jobs_verification->call(); + $this->assertFalse( $returned ); + + // Should fail if not authorized to use the API. + $jobs_verification = $this->getStub( false ); + $returned = $jobs_verification->call( self::TEST_USER_ID ); + $this->assertFalse( $returned ); + + // Should succeed with a user_id + provider and set_bearer returning true. + $jobs_verification = $this->getStub( true ); + $decoded_res = []; + try { + $jobs_verification->call( self::TEST_USER_ID ); + } catch ( Exception $e ) { + $decoded_res = unserialize( $e->getMessage() ); + } + + $this->assertNotEmpty( $decoded_res ); + $this->assertEquals( 'https://' . self::TEST_DOMAIN . '/api/v2/jobs/verification-email', $decoded_res['url'] ); + $this->assertEquals( 'POST', $decoded_res['method'] ); + $this->assertArrayHasKey( 'user_id', $decoded_res['body'] ); + $this->assertEquals( self::TEST_USER_ID, $decoded_res['body']['user_id'] ); + $this->assertArrayHasKey( 'client_id', $decoded_res['body'] ); + $this->assertEquals( self::TEST_CLIENT_ID, $decoded_res['body']['client_id'] ); + } + + /** + * Test a basic Delete MFA call against a mock API server. + */ + public function testCall() { + $this->startHttpMocking(); + self::$options->set( 'domain', self::TEST_DOMAIN ); + self::$options->set( 'client_id', self::TEST_CLIENT_ID ); + + $jobs_verification = $this->getStub( true ); + + // 1. Make sure that a transport returns the default failed response and logs an error. + $this->http_request_type = 'wp_error'; + $this->assertFalse( $jobs_verification->call( self::TEST_USER_ID ) ); + $log = self::$error_log->get(); + $this->assertCount( 1, $log ); + $this->assertEquals( 'Caught WP_Error.', $log[0]['message'] ); + + // 2. Make sure that an Auth0 API error returns the default failed response and logs an error. + $this->http_request_type = 'auth0_api_error'; + $this->assertFalse( $jobs_verification->call( self::TEST_USER_ID ) ); + $log = self::$error_log->get(); + $this->assertCount( 2, $log ); + $this->assertEquals( 'caught_api_error', $log[0]['code'] ); + + // 3. Make sure it succeeds. + $this->http_request_type = 'success_job_email_verification'; + $this->assertTrue( $jobs_verification->call( self::TEST_USER_ID ) ); + $this->assertCount( 2, self::$error_log->get() ); + } + + /* + * PHPUnit overrides to run after tests. + */ + + /** + * Stop HTTP halting and mocking, reset JWKS transient. + */ + public function tearDown() { + parent::tearDown(); + self::$options->set( 'domain', null ); + self::$options->set( 'client_id', null ); + $this->stopHttpHalting(); + $this->stopHttpMocking(); + self::$error_log->clear(); + $this->assertEmpty( self::$error_log->get() ); + } + + /* + * Test helper functions. + */ + + /** + * Specific mock API responses for this suite. + * + * @return array|null|WP_Error + */ + public function httpMock() { + switch ( $this->getResponseType() ) { + case 'success_job_email_verification': + return [ + 'body' => json_encode( + [ + 'type' => 'verification_email', + 'status' => 'pending', + 'created_at' => date( 'c' ), + 'id' => 'job_' . uniqid(), + ] + ), + 'response' => [ 'code' => 201 ], + ]; + } + return $this->httpMockDefault(); + } + + /** + * Get a mocked WP_Auth0_Api_Jobs_Verification to return true or false for set_bearer. + * + * @param bool $set_bearer_returns - Should the set_bearer call succeed or fail. + * + * @return PHPUnit_Framework_MockObject_MockObject|WP_Auth0_Api_Jobs_Verification + */ + public function getStub( $set_bearer_returns ) { + $mock = $this + ->getMockBuilder( WP_Auth0_Api_Jobs_Verification::class ) + ->setMethods( [ 'set_bearer' ] ) + ->setConstructorArgs( [ self::$options, self::$api_client_creds, self::TEST_USER_ID ] ) + ->getMock(); + $mock->method( 'set_bearer' )->willReturn( $set_bearer_returns ); + return $mock; + } +} diff --git a/tests/testEditProfile.php b/tests/testEditProfile.php new file mode 100644 index 00000000..2bea7d01 --- /dev/null +++ b/tests/testEditProfile.php @@ -0,0 +1,112 @@ + [ + 'priority' => 1, + 'accepted_args' => 1, + ], + ]; + $this->assertHooked( 'personal_options_update', 'WP_Auth0_EditProfile', $expect_hooked ); + + // Test page-specific JS enqueuing. + $expect_hooked = [ + 'admin_enqueue_scripts' => [ + 'priority' => 10, + 'accepted_args' => 1, + ], + ]; + + $pagenow = 'profile.php'; + $this->clear_hooks( 'admin_enqueue_scripts' ); + self::$editProfile->init(); + $this->assertHooked( 'admin_enqueue_scripts', 'WP_Auth0_EditProfile', $expect_hooked ); + + $pagenow = 'user-edit.php'; + $this->clear_hooks( 'admin_enqueue_scripts' ); + self::$editProfile->init(); + $this->assertHooked( 'admin_enqueue_scripts', 'WP_Auth0_EditProfile', $expect_hooked ); + } +} diff --git a/tests/testEmailVerification.php b/tests/testEmailVerification.php index 780c9007..d90807cd 100644 --- a/tests/testEmailVerification.php +++ b/tests/testEmailVerification.php @@ -63,7 +63,7 @@ public static function setUpBeforeClass() { * Test the the AJAX handler function is hooked properly. */ public function testHooks() { - $hooked = $this->getHooked( 'wp_ajax_nopriv_resend_verification_email' ); + $hooked = $this->get_hook( 'wp_ajax_nopriv_resend_verification_email' ); $this->assertNotEmpty( $hooked[0] ); $this->assertEquals( 'wp_auth0_ajax_resend_verification_email', $hooked[0]['function'] ); @@ -121,7 +121,7 @@ public function testWpRenderDie() { * Test AJAX email verification send. */ public function testResendVerificationEmail() { - $this->start_ajax(); + $this->startAjaxHalting(); // 1. Should fail with a bad nonce. $caught_exception = false; diff --git a/tests/testProfileChangePassword.php b/tests/testProfileChangePassword.php new file mode 100644 index 00000000..55917ab9 --- /dev/null +++ b/tests/testProfileChangePassword.php @@ -0,0 +1,138 @@ + [ + 'priority' => 10, + 'accepted_args' => 2, + ], + ]; + // Same method hooked to both actions. + $this->assertHooked( 'user_profile_update_errors', 'WP_Auth0_Profile_Change_Password', $expect_hooked ); + $this->assertHooked( 'validate_password_reset', 'WP_Auth0_Profile_Change_Password', $expect_hooked ); + } + + /** + * Test that the validate_new_password method works as expected. + */ + public function testValidateNewPassword() { + $errors = new WP_Error(); + + $user_data = $this->createUser(); + $user_id = $user_data->ID; + $user_obj = get_user_by( 'id', $user_id ); + + // Create a stub for the WP_Auth0_Api_Change_Password class. + $mock_api_test_password = $this + ->getMockBuilder( WP_Auth0_Api_Change_Password::class ) + ->setMethods( [ 'call' ] ) + ->setConstructorArgs( [ self::$options, self::$api_client_creds ] ) + ->getMock(); + $mock_api_test_password->method( 'call' )->willReturn( true, false ); + + $change_password = new WP_Auth0_Profile_Change_Password( $mock_api_test_password ); + + // Call should fail because of a missing password. + $this->assertFalse( $change_password->validate_new_password( $errors, false ) ); + + $_POST['pass1'] = uniqid(); + + // Call should fail with a password because of a missing user ID. + $this->assertFalse( $change_password->validate_new_password( $errors, false ) ); + + // Call should fail with a user object or user_id in $_POST because of no Auth0 data stored. + $this->assertFalse( $change_password->validate_new_password( $errors, $user_obj ) ); + $_POST['user_id'] = $user_id; + $this->assertFalse( $change_password->validate_new_password( $errors, false ) ); + + $this->storeAuth0Data( $user_id, 'not-auth0' ); + + // Call should fail with Auth0 data stored because of a wrong strategy. + $this->assertFalse( $change_password->validate_new_password( $errors, false ) ); + + $this->storeAuth0Data( $user_id ); + + // Call should succeed with a mocked API. + $this->assertTrue( $change_password->validate_new_password( $errors, false ) ); + + // Call should fail on the second call with a mocked API. + $this->assertFalse( $change_password->validate_new_password( $errors, false ) ); + $this->assertEquals( 'Password could not be updated.', $errors->errors['auth0_password'][0] ); + $this->assertEquals( 'pass1', $errors->error_data['auth0_password']['form-field'] ); + $this->assertFalse( isset( $_POST['pass1'] ) ); + } +} diff --git a/tests/testProfileDeleteData.php b/tests/testProfileDeleteData.php new file mode 100644 index 00000000..510d4b9b --- /dev/null +++ b/tests/testProfileDeleteData.php @@ -0,0 +1,215 @@ + [ + 'priority' => 10, + 'accepted_args' => 1, + ], + ]; + // Same method hooked to both actions. + $this->assertHooked( 'edit_user_profile', 'WP_Auth0_Profile_Delete_Data', $expect_hooked ); + $this->assertHooked( 'show_user_profile', 'WP_Auth0_Profile_Delete_Data', $expect_hooked ); + + $expect_hooked = [ + 'delete_user_data' => [ + 'priority' => 10, + 'accepted_args' => 1, + ], + ]; + $this->assertHooked( 'wp_ajax_auth0_delete_data', 'WP_Auth0_Profile_Delete_Data', $expect_hooked ); + } + + /** + * Test that a delete_user_data AJAX call with no nonce fails. + */ + public function testThatAjaxFailsWithNoNonce() { + $this->startAjaxHalting(); + $caught_exception = false; + try { + self::$delete_data->delete_user_data(); + } catch ( Exception $e ) { + $caught_exception = ( 'bad_nonce' === $e->getMessage() ); + } + $this->assertTrue( $caught_exception ); + } + + /** + * Test that a delete_user_data AJAX call with no user_id fails. + */ + public function testThatAjaxFailsWithNoUserId() { + $this->startAjaxHalting(); + + // Set the nonce. + $_REQUEST['_ajax_nonce'] = wp_create_nonce( 'delete_auth0_identity' ); + + $caught_exception = false; + ob_start(); + try { + self::$delete_data->delete_user_data(); + } catch ( Exception $e ) { + $caught_exception = ( 'die_ajax' === $e->getMessage() ); + } + $return_json = ob_get_clean(); + + $this->assertTrue( $caught_exception ); + $this->assertEquals( '{"success":false,"data":{"error":"Empty user_id"}}', $return_json ); + } + + /** + * Test that a delete_user_data AJAX call with a non-admin user fails. + */ + public function testThatAjaxFailsWithNoAdmin() { + $this->startAjaxHalting(); + + // Set the nonce. + $_REQUEST['_ajax_nonce'] = wp_create_nonce( 'delete_auth0_identity' ); + + // Set the user ID. + $_POST['user_id'] = 1; + + $caught_exception = false; + ob_start(); + try { + self::$delete_data->delete_user_data(); + } catch ( Exception $e ) { + $caught_exception = ( 'die_ajax' === $e->getMessage() ); + } + $return_json = ob_get_clean(); + + $this->assertTrue( $caught_exception ); + $this->assertEquals( '{"success":false,"data":{"error":"Forbidden"}}', $return_json ); + } + + /** + * Test that a delete_user_data AJAX call can succeed. + */ + public function testThatAjaxCallSucceeds() { + $this->startAjaxReturn(); + + // Set the user ID. + $_POST['user_id'] = 1; + + // Set the admin user, store Auth0 profile data to delete, and reset the nonce. + $this->setGlobalUser(); + $this->storeAuth0Data( 1 ); + $_REQUEST['_ajax_nonce'] = wp_create_nonce( 'delete_auth0_identity' ); + + // Make sure we have data to delete. + $this->assertNotEmpty( WP_Auth0_UsersRepo::get_meta( 1, 'auth0_id' ) ); + $this->assertNotEmpty( WP_Auth0_UsersRepo::get_meta( 1, 'auth0_obj' ) ); + $this->assertNotEmpty( WP_Auth0_UsersRepo::get_meta( 1, 'last_update' ) ); + + ob_start(); + self::$delete_data->delete_user_data(); + $this->assertEquals( '{"success":true}', ob_get_clean() ); + $this->assertEmpty( WP_Auth0_UsersRepo::get_meta( 1, 'auth0_id' ) ); + $this->assertEmpty( WP_Auth0_UsersRepo::get_meta( 1, 'auth0_obj' ) ); + $this->assertEmpty( WP_Auth0_UsersRepo::get_meta( 1, 'last_update' ) ); + } + + /** + * Test that the ID delete control appears under certain conditions. + */ + public function testShowDeleteIdentity() { + // Should not show this control if not an admin. + ob_start(); + self::$delete_data->show_delete_identity(); + $this->assertEmpty( ob_get_clean() ); + + $user_id = $this->setGlobalUser(); + + // Should not show this control if user is not an Auth0-connected user. + ob_start(); + self::$delete_data->show_delete_identity(); + $this->assertEmpty( ob_get_clean() ); + + $this->storeAuth0Data( $user_id ); + + ob_start(); + self::$delete_data->show_delete_identity(); + $delete_id_html = ob_get_clean(); + + // Make sure we have the id attribute that connects to the AJAX action. + $input = $this->getDomListFromTagName( $delete_id_html, 'input' ); + $this->assertEquals( 1, $input->length ); + $this->assertEquals( 'auth0_delete_data', $input->item( 0 )->getAttribute( 'id' ) ); + + // Make sure we have a table with the right class. + $table = $this->getDomListFromTagName( $delete_id_html, 'table' ); + $this->assertEquals( 1, $table->length ); + $this->assertEquals( 'form-table', $table->item( 0 )->getAttribute( 'class' ) ); + } + + /* + * PHPUnit overrides to run after tests. + */ + + /** + * Runs after each test completes. + */ + public function tearDown() { + parent::tearDown(); + $this->stopAjaxHalting(); + $this->stopAjaxReturn(); + } +} diff --git a/tests/testProfileDeleteMfa.php b/tests/testProfileDeleteMfa.php new file mode 100644 index 00000000..b3cd2f19 --- /dev/null +++ b/tests/testProfileDeleteMfa.php @@ -0,0 +1,314 @@ + [ + 'priority' => 10, + 'accepted_args' => 1, + ], + ]; + // Same method hooked to both actions. + $this->assertHooked( 'edit_user_profile', 'WP_Auth0_Profile_Delete_Mfa', $expect_hooked ); + $this->assertHooked( 'show_user_profile', 'WP_Auth0_Profile_Delete_Mfa', $expect_hooked ); + + $expect_hooked = [ + 'delete_mfa' => [ + 'priority' => 10, + 'accepted_args' => 1, + ], + ]; + $this->assertHooked( 'wp_ajax_auth0_delete_mfa', 'WP_Auth0_Profile_Delete_Mfa', $expect_hooked ); + } + + /** + * Test that an AJAX call with no nonce fails. + */ + public function testThatAjaxFailsWithNoNonce() { + $this->startAjaxHalting(); + + // No nonce set should fail. + $caught_exception = false; + try { + self::$delete_mfa->delete_mfa(); + } catch ( Exception $e ) { + $caught_exception = ( 'bad_nonce' === $e->getMessage() ); + } + $this->assertTrue( $caught_exception ); + } + + /** + * Test that an AJAX call with no user_id fails. + */ + public function testThatAjaxFailsWithNoUserId() { + $this->startAjaxHalting(); + + // Set the nonce. + $_REQUEST['_ajax_nonce'] = wp_create_nonce( 'delete_auth0_mfa' ); + + ob_start(); + $caught_exception = false; + try { + self::$delete_mfa->delete_mfa(); + } catch ( Exception $e ) { + $caught_exception = ( 'die_ajax' === $e->getMessage() ); + } + + $this->assertTrue( $caught_exception ); + $this->assertEquals( '{"success":false,"data":{"error":"Empty user_id"}}', ob_get_clean() ); + } + + /** + * Test that an AJAX call with no admin user fails. + */ + public function testThatAjaxFailsWithNoAdmin() { + $this->startAjaxHalting(); + + // Set the nonce. + $_REQUEST['_ajax_nonce'] = wp_create_nonce( 'delete_auth0_mfa' ); + + // Set the user ID. + $_POST['user_id'] = 1; + + ob_start(); + $caught_exception = false; + try { + self::$delete_mfa->delete_mfa(); + } catch ( Exception $e ) { + $caught_exception = ( 'die_ajax' === $e->getMessage() ); + } + + $this->assertTrue( $caught_exception ); + $this->assertEquals( '{"success":false,"data":{"error":"Forbidden"}}', ob_get_clean() ); + } + + /** + * Test that an AJAX call with no Auth0 data fails. + */ + public function testThatAjaxFailsWithNoAuth0Data() { + $this->startAjaxHalting(); + + // Set the nonce. + $_REQUEST['_ajax_nonce'] = wp_create_nonce( 'delete_auth0_mfa' ); + + // Set the user ID. + $_POST['user_id'] = 1; + + // Set the admin user and nonce. + $this->setGlobalUser(); + $_REQUEST['_ajax_nonce'] = wp_create_nonce( 'delete_auth0_mfa' ); + + ob_start(); + $caught_exception = false; + try { + self::$delete_mfa->delete_mfa(); + } catch ( Exception $e ) { + $caught_exception = ( 'die_ajax' === $e->getMessage() ); + } + + $this->assertTrue( $caught_exception ); + $this->assertEquals( '{"success":false,"data":{"error":"Auth0 profile data not found"}}', ob_get_clean() ); + } + + /** + * Test that the delete MFA action works as expected. + */ + public function testDeleteMfaAjax() { + $this->startAjaxHalting(); + + // Set the user ID. + $_POST['user_id'] = 1; + + // Set the admin user and nonce. + $this->setGlobalUser(); + $_REQUEST['_ajax_nonce'] = wp_create_nonce( 'delete_auth0_mfa' ); + + // Set Auth0 profile. + $this->storeAuth0Data( 1 ); + + // Mocked to simulate a failed API call. + $delete_mfa = $this->getStub( false ); + $caught_exception = false; + + ob_start(); + try { + $delete_mfa->delete_mfa(); + } catch ( Exception $e ) { + $caught_exception = ( 'die_ajax' === $e->getMessage() ); + } + + $this->assertTrue( $caught_exception ); + $this->assertEquals( '{"success":false,"data":{"error":"API call failed"}}', ob_get_clean() ); + } + + /** + * Test that an AJAX call will succeed. + */ + public function testThatAjaxCallSucceeds() { + $this->startAjaxReturn(); + + $this->setGlobalUser(); + $this->storeAuth0Data( 1 ); + $_REQUEST['_ajax_nonce'] = wp_create_nonce( 'delete_auth0_mfa' ); + $_POST['user_id'] = 1; + + // Mocked to simulate a successful API call. + $delete_mfa = $this->getStub( true ); + + ob_start(); + $delete_mfa->delete_mfa(); + $this->assertEquals( '{"success":true}', ob_get_clean() ); + } + + /** + * Test that the ID delete control appears under certain conditions. + */ + public function testShowDeleteMfa() { + // Should not show this control if not an admin. + ob_start(); + self::$delete_mfa->show_delete_mfa(); + $this->assertEmpty( ob_get_clean() ); + + $user_id = $this->setGlobalUser(); + + // Should not show this control if MFA is not turned on. + ob_start(); + self::$delete_mfa->show_delete_mfa(); + $this->assertEmpty( ob_get_clean() ); + + self::$options->set( 'mfa', 1 ); + + // Should not show this control if user is not an Auth0-connected user. + ob_start(); + self::$delete_mfa->show_delete_mfa(); + $this->assertEmpty( ob_get_clean() ); + + $this->storeAuth0Data( $user_id ); + + ob_start(); + self::$delete_mfa->show_delete_mfa(); + $delete_mfa_html = ob_get_clean(); + + $this->assertNotEmpty( $delete_mfa_html ); + + // Make sure we have the id attribute that connects to the AJAX action. + $input = $this->getDomListFromTagName( $delete_mfa_html, 'input' ); + $this->assertEquals( 1, $input->length ); + $this->assertEquals( 'auth0_delete_mfa', $input->item( 0 )->getAttribute( 'id' ) ); + + // Make sure we have a table with the right class. + $table = $this->getDomListFromTagName( $delete_mfa_html, 'table' ); + $this->assertEquals( 1, $table->length ); + $this->assertEquals( 'form-table', $table->item( 0 )->getAttribute( 'class' ) ); + } + + /* + * PHPUnit overrides to run after tests. + */ + + /** + * Runs after each test completes. + */ + public function tearDown() { + parent::tearDown(); + $this->stopAjaxHalting(); + $this->stopAjaxReturn(); + } + + /* + * Test helper functions. + */ + + /** + * Creates a new WP_Auth0_Profile_Delete_Mfa object with a mocked API. + * + * @param boolean $return - Whether the call method should return true or false. + * + * @return WP_Auth0_Profile_Delete_Mfa + */ + public function getStub( $return ) { + // Create a stub for the WP_Auth0_Api_Delete_User_Mfa class. + $mock_api_delete_mfa = $this + ->getMockBuilder( WP_Auth0_Api_Delete_User_Mfa::class ) + ->setMethods( [ 'call', 'set_bearer' ] ) + ->setConstructorArgs( [ self::$options, self::$api_client_creds ] ) + ->getMock(); + $mock_api_delete_mfa->method( 'set_bearer' )->willReturn( true ); + $mock_api_delete_mfa->method( 'call' )->willReturn( $return ); + + return new WP_Auth0_Profile_Delete_Mfa( self::$options, $mock_api_delete_mfa ); + } +} diff --git a/tests/traits/ajaxHelpers.php b/tests/traits/ajaxHelpers.php index 03106094..7aa51d82 100644 --- a/tests/traits/ajaxHelpers.php +++ b/tests/traits/ajaxHelpers.php @@ -13,21 +13,25 @@ trait AjaxHelpers { /** - * Set filters for processing AJAX tests. + * Set a filter to halt AJAX requests with an exception. * Call at the top of tests that use AJAX handler functions. */ - public function start_ajax() { + public function startAjaxHalting() { add_filter( 'wp_doing_ajax', '__return_true' ); - add_filter( - 'wp_die_ajax_handler', - function() { - return [ $this, 'stop_ajax' ]; - } - ); + add_filter( 'wp_die_ajax_handler', [ $this, 'startAjaxHaltingHook' ] ); } /** - * Stop AJAX requests from dying. + * Returns the function used to halt an AJAX request. + * + * @return array + */ + public function startAjaxHaltingHook() { + return [ $this, 'haltAjax' ]; + } + + /** + * Stop AJAX requests by throwing an exception. * Hooked to: wp_die_ajax_handler * * @param string}int $message - Message for die page. @@ -36,7 +40,7 @@ function() { * * @throws Exception - Always, to stop AJAX process. */ - public function stop_ajax( $message, $title, $args ) { + public function haltAjax( $message, $title, $args ) { if ( -1 === $message && ! empty( $args['response'] ) && 403 === $args['response'] && empty( $title ) ) { $error_msg = 'bad_nonce'; } else { @@ -44,4 +48,50 @@ public function stop_ajax( $message, $title, $args ) { } throw new Exception( $error_msg ); } + + /** + * Remove the filter that halts AJAX requests. + * Call this in a test suites tearDown method. + */ + public function stopAjaxHalting() { + remove_filter( 'wp_doing_ajax', '__return_true' ); + remove_filter( 'wp_die_ajax_handler', [ $this, 'startAjaxHaltingHook' ] ); + } + + /** + * Return AJAX request messages. + * Call at the top of tests that use AJAX handler functions. + */ + public function startAjaxReturn() { + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( 'wp_die_ajax_handler', [ $this, 'startAjaxReturnHook' ] ); + } + + /** + * Returns the function used to return an AJAX request message. + * + * @return array + */ + public function startAjaxReturnHook() { + return [ $this, 'ajaxReturn' ]; + } + + /** + * Prevent the wp_die page from dying and echo the message passed. + * Hooked to: wp_die_handler + * + * @param string $message - HTML to show on the wp_die page. + */ + public function ajaxReturn( $message ) { + echo $message; + } + + /** + * Remove the filter that returns AJAX messages. + * Call this in a test suites tearDown method. + */ + public function stopAjaxReturn() { + remove_filter( 'wp_doing_ajax', '__return_true', 10 ); + remove_filter( 'wp_die_ajax_handler', [ $this, 'startAjaxReturnHook' ], 10 ); + } } diff --git a/tests/traits/hookHelpers.php b/tests/traits/hookHelpers.php index 84c44f29..b301a8b4 100644 --- a/tests/traits/hookHelpers.php +++ b/tests/traits/hookHelpers.php @@ -19,7 +19,7 @@ trait HookHelpers { * * @return array */ - public function getHooked( $hook = '' ) { + public function get_hook( $hook = '' ) { global $wp_filter; if ( isset( $wp_filter[ $hook ]->callbacks ) ) { @@ -58,6 +58,16 @@ public function getHooked( $hook = '' ) { return $hooks; } + /** + * Remove all hooked functions from a hook. + * + * @param string $hook - Hook to clear. + */ + public function clear_hooks( $hook = '' ) { + global $wp_filter; + unset( $wp_filter[ $hook ] ); + } + /** * Assert that hooked functions exists with the correct priority and arg numbers. * @@ -68,7 +78,7 @@ public function getHooked( $hook = '' ) { * @return void */ public function assertHooked( $hook_name, $function, array $hooked ) { - $hooks = $this->getHooked( $hook_name ); + $hooks = $this->get_hook( $hook_name ); $found = 0; foreach ( $hooks as $hook ) { diff --git a/tests/traits/usersHelper.php b/tests/traits/usersHelper.php index 4138daac..235dcc59 100644 --- a/tests/traits/usersHelper.php +++ b/tests/traits/usersHelper.php @@ -11,6 +11,13 @@ */ trait UsersHelper { + /** + * WP_Auth0_UsersRepo instance. + * + * @var WP_Auth0_UsersRepo + */ + protected static $users_repo; + /** * Create a new User. * @@ -46,4 +53,28 @@ public function getUserinfo( $strategy = 'test-strategy' ) { $userinfo->email = $name . '@example.com'; return $userinfo; } + + /** + * Set the global WP user. + * + * @param int $set_uid - WP user ID to set. + * + * @return int + */ + public function setGlobalUser( $set_uid = 1 ) { + global $user_id; + $user_id = $set_uid; + wp_set_current_user( $user_id ); + return $user_id; + } + + /** + * Store dummy Auth0 data. + * + * @param int $user_id - WP user ID to set. + * @param string $strategy - Auth0 user strategy to use. + */ + public function storeAuth0Data( $user_id, $strategy = 'auth0' ) { + self::$users_repo->update_auth0_object( $user_id, $this->getUserinfo( $strategy ) ); + } }