diff --git a/providers/class-two-factor-email.php b/providers/class-two-factor-email.php index 7722e33e..28b3d096 100644 --- a/providers/class-two-factor-email.php +++ b/providers/class-two-factor-email.php @@ -39,6 +39,8 @@ class Two_Factor_Email extends Two_Factor_Provider { * Class constructor. * * @since 0.1-dev + * + * @codeCoverageIgnore */ protected function __construct() { add_action( 'two_factor_user_options_' . __CLASS__, array( $this, 'user_options' ) ); diff --git a/providers/class-two-factor-provider.php b/providers/class-two-factor-provider.php index 97fda12a..275cbae7 100644 --- a/providers/class-two-factor-provider.php +++ b/providers/class-two-factor-provider.php @@ -67,6 +67,8 @@ public function get_alternative_provider_label() { * Prints the name of the provider. * * @since 0.1-dev + * + * @codeCoverageIgnore */ public function print_label() { echo esc_html( $this->get_label() ); diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 700a7ac9..858e8778 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -5,6 +5,11 @@ * @package Two_Factor */ +/** + * Exception thrown when wp_redirect fires, to prevent exit() from terminating the test process. + */ +class Two_Factor_Redirect_Exception extends RuntimeException {} + /** * Class Test_ClassTwoFactorCore * @@ -49,6 +54,10 @@ public function tearDown(): void { parent::tearDown(); unset( $_COOKIE[ AUTH_COOKIE ], $_COOKIE[ LOGGED_IN_COOKIE ] ); + + // Remove the plugin's send_auth_cookies block that filter_authenticate installs, + // so it does not leak into subsequent tests that expect cookies to be settable. + remove_filter( 'send_auth_cookies', '__return_false', PHP_INT_MAX ); } /** @@ -122,6 +131,34 @@ public function clean_dummy_user() { unset( $_POST[ $key ] ); } + /** + * Run a callable that may call wp_safe_redirect() + exit. + * + * Intercepts the redirect via the wp_redirect filter to throw a + * Two_Factor_Redirect_Exception, preventing exit() from terminating the + * test process. Also captures and discards any output produced. + * + * @param callable $callback Code that may trigger a redirect. + * @return string|null The intercepted redirect URL, or null if no redirect occurred. + */ + private function do_redirect_callable( $callback ) { + $intercepted_url = null; + $redirect_filter = function ( $location ) { + throw new Two_Factor_Redirect_Exception( $location ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + }; + add_filter( 'wp_redirect', $redirect_filter, PHP_INT_MAX ); + ob_start(); + try { + $callback(); + } catch ( Two_Factor_Redirect_Exception $e ) { + $intercepted_url = $e->getMessage(); + } finally { + ob_end_clean(); + remove_filter( 'wp_redirect', $redirect_filter, PHP_INT_MAX ); + } + return $intercepted_url; + } + /** * Verify adding hooks. * @@ -446,22 +483,44 @@ public function test_filter_authenticate() { $this->assertFalse( Two_Factor_Core::is_api_request(), 'Is not an API request by default' ); + // The WP test framework registers __return_false on send_auth_cookies at priority 10 to + // prevent real cookies from being set during tests. Check for the plugin's specific + // __return_false callback at PHP_INT_MAX (see Two_Factor_Core::filter_authenticate). + $has_plugin_cookie_block = function () { + global $wp_filter; + + if ( + ! isset( $wp_filter['send_auth_cookies'] ) || + ! $wp_filter['send_auth_cookies'] instanceof WP_Hook || + empty( $wp_filter['send_auth_cookies']->callbacks[ PHP_INT_MAX ] ) + ) { + return false; + } + + foreach ( $wp_filter['send_auth_cookies']->callbacks[ PHP_INT_MAX ] as $cb ) { + if ( '__return_false' === $cb['function'] ) { + return true; + } + } + return false; + }; + $this->assertFalse( - has_filter( 'send_auth_cookies', '__return_false' ), - 'Auth cookie block not registerd before the `authenticate` filter has run.' + $has_plugin_cookie_block(), + 'Auth cookie block not registered before the `authenticate` filter has run.' ); Two_Factor_Core::filter_authenticate( $user_default ); $this->assertFalse( - has_filter( 'send_auth_cookies', '__return_false' ), + $has_plugin_cookie_block(), 'User login without 2fa should not block auth cookies.' ); Two_Factor_Core::filter_authenticate( $user_2fa_enabled ); $this->assertTrue( - has_filter( 'send_auth_cookies', '__return_false' ), + $has_plugin_cookie_block(), 'User login with 2fa should block auth cookies.' ); } @@ -471,13 +530,18 @@ public function test_filter_authenticate() { * * @covers Two_Factor_Core::filter_authenticate * @covers Two_Factor_Core::is_api_request + * @runInSeparateProcess + * @preserveGlobalState disabled */ public function test_filter_authenticate_api() { $user_default = new WP_User( self::factory()->user->create() ); $user_2fa_enabled = $this->get_dummy_user(); // User with a dummy two-factor method enabled. // TODO: Get Two_Factor_Core away from static methods to allow mocking this. - define( 'XMLRPC_REQUEST', true ); + // Guard against re-definition if the constant is already set in this process. + if ( ! defined( 'XMLRPC_REQUEST' ) ) { + define( 'XMLRPC_REQUEST', true ); + } $this->assertTrue( Two_Factor_Core::is_api_request(), 'Can detect an API request' ); @@ -492,12 +556,6 @@ public function test_filter_authenticate_api() { Two_Factor_Core::filter_authenticate( $user_2fa_enabled ), '2FA user should not be able to authenticate during API requests' ); - - $this->assertInstanceOf( - WP_User::class, - Two_Factor_Core::filter_authenticate( $user_2fa_enabled ), - 'Existing user session without a username should not trigger 2FA' - ); } /** @@ -848,8 +906,8 @@ public function test_reset_compromised_password() { $this->assertNotSame( $old_hash, $user->user_pass ); $this->assertSame( '1', get_user_meta( $user->ID, Two_Factor_Core::USER_PASSWORD_WAS_RESET_KEY, true ) ); $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_META_NONCE_KEY, true ) ); - $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY ) ); - $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY ) ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, false ) ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, false ) ); } /** @@ -1137,9 +1195,12 @@ public function test_is_current_user_session_two_factor_with_two_factor() { $this->assertNotFalse( $login_nonce ); // Process it. - ob_start(); - Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', true ); - ob_end_clean(); + $redirect_url = $this->do_redirect_callable( + function () use ( $user, $login_nonce ) { + Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', true ); + } + ); + $this->assertNotNull( $redirect_url, 'Expected a redirect after successful 2FA validation.' ); $this->assertNotEmpty( $_COOKIE[ AUTH_COOKIE ] ); $this->assertNotEmpty( $_COOKIE[ LOGGED_IN_COOKIE ] ); @@ -1187,9 +1248,12 @@ public function test_revalidation_sets_time() { $this->assertNotFalse( $login_nonce ); // Process it. - ob_start(); - Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', true ); - ob_end_clean(); + $redirect_url = $this->do_redirect_callable( + function () use ( $user, $login_nonce ) { + Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', true ); + } + ); + $this->assertNotNull( $redirect_url, 'Expected a redirect after successful 2FA validation.' ); $this->assertNotEmpty( $_COOKIE[ AUTH_COOKIE ] ); $this->assertNotEmpty( $_COOKIE[ LOGGED_IN_COOKIE ] ); @@ -1230,10 +1294,14 @@ public function test_revalidation_sets_time() { $this->assertLessThan( time(), Two_Factor_Core::is_current_user_session_two_factor() ); // Simulate clicking it with an incorrect nonce. - $bad_nonce = '__BAD_NONCE__'; - ob_start(); - Two_Factor_Core::_login_form_revalidate_2fa( $bad_nonce, 'Two_Factor_Dummy', '', true ); - ob_end_clean(); + $bad_nonce = '__BAD_NONCE__'; + $bad_redirect_url = $this->do_redirect_callable( + function () use ( $bad_nonce ) { + Two_Factor_Core::_login_form_revalidate_2fa( $bad_nonce, 'Two_Factor_Dummy', '', true ); + } + ); + $this->assertNotNull( $bad_redirect_url, 'Expected a redirect after bad-nonce revalidation attempt.' ); + $this->assertEquals( home_url(), $bad_redirect_url, 'Bad-nonce revalidation should redirect to home.' ); // Check it's still expired. $this->assertLessThan( time(), Two_Factor_Core::is_current_user_session_two_factor() ); @@ -1241,9 +1309,12 @@ public function test_revalidation_sets_time() { // Simulate clicking it. $login_nonce = wp_create_nonce( 'two_factor_revalidate_' . $user->ID ); - ob_start(); - Two_Factor_Core::_login_form_revalidate_2fa( $login_nonce, 'Two_Factor_Dummy', '', true ); - ob_end_clean(); + $good_redirect_url = $this->do_redirect_callable( + function () use ( $login_nonce ) { + Two_Factor_Core::_login_form_revalidate_2fa( $login_nonce, 'Two_Factor_Dummy', '', true ); + } + ); + $this->assertNotNull( $good_redirect_url, 'Expected a redirect after successful revalidation.' ); // Validate that the session is flagged as 2FA, and set to now-ish. $current_session_two_factor = Two_Factor_Core::is_current_user_session_two_factor(); @@ -1474,9 +1545,12 @@ public function test_filter_session_information() { $this->assertNotFalse( $login_nonce ); // Process it. - ob_start(); - Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', true ); - ob_end_clean(); + $redirect_url = $this->do_redirect_callable( + function () use ( $user, $login_nonce ) { + Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', true ); + } + ); + $this->assertNotNull( $redirect_url, 'Expected a redirect after successful 2FA validation.' ); $this->assertNotEmpty( $_COOKIE[ AUTH_COOKIE ] ); $this->assertNotEmpty( $_COOKIE[ LOGGED_IN_COOKIE ] ); @@ -1815,4 +1889,669 @@ function ( $providers ) { remove_all_filters( 'two_factor_providers' ); } + + /** + * Test delete_login_nonce removes the nonce. + * + * @covers Two_Factor_Core::delete_login_nonce + */ + public function test_delete_login_nonce() { + $user_id = self::factory()->user->create(); + $nonce = Two_Factor_Core::create_login_nonce( $user_id ); + + $this->assertNotEmpty( $nonce, 'Login nonce was created' ); + $this->assertNotEmpty( get_user_meta( $user_id, Two_Factor_Core::USER_META_NONCE_KEY, true ), 'Nonce meta exists' ); + + $result = Two_Factor_Core::delete_login_nonce( $user_id ); + + $this->assertTrue( $result, 'Nonce was deleted successfully' ); + $this->assertEmpty( get_user_meta( $user_id, Two_Factor_Core::USER_META_NONCE_KEY, true ), 'Nonce meta was removed' ); + } + + /** + * Test get_user_update_action_url generates correct URL. + * + * @covers Two_Factor_Core::get_user_update_action_url + */ + public function test_get_user_update_action_url() { + $user_id = self::factory()->user->create(); + $action = 'test_action'; + + $url = Two_Factor_Core::get_user_update_action_url( $user_id, $action ); + + $this->assertStringContainsString( 'two_factor_action=test_action', $url, 'URL contains action parameter' ); + $this->assertStringContainsString( '_two_factor_action_nonce=', $url, 'URL contains nonce parameter' ); + } + + /** + * Test get_user_two_factor_revalidate_url generates correct URL. + * + * @covers Two_Factor_Core::get_user_two_factor_revalidate_url + */ + public function test_get_user_two_factor_revalidate_url() { + $url = Two_Factor_Core::get_user_two_factor_revalidate_url(); + + $this->assertStringContainsString( 'action=revalidate_2fa', $url, 'URL contains revalidate action' ); + $this->assertStringNotContainsString( 'interim-login', $url, 'URL does not contain interim login by default' ); + + $url_with_interim = Two_Factor_Core::get_user_two_factor_revalidate_url( true ); + + $this->assertStringContainsString( 'interim-login=1', $url_with_interim, 'URL contains interim login when requested' ); + } + + /** + * Test is_valid_user_action validates actions correctly. + * + * @covers Two_Factor_Core::is_valid_user_action + */ + public function test_is_valid_user_action() { + $user_id = self::factory()->user->create(); + $action = 'test_action'; + + // Test without nonce. + $this->assertFalse( Two_Factor_Core::is_valid_user_action( $user_id, $action ), 'Action is invalid without nonce' ); + + // Test with invalid nonce. + $_REQUEST[ Two_Factor_Core::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] = 'invalid_nonce'; + $this->assertFalse( Two_Factor_Core::is_valid_user_action( $user_id, $action ), 'Action is invalid with wrong nonce' ); + + // Test with valid nonce. + $nonce = wp_create_nonce( sprintf( '%d-%s', $user_id, $action ) ); + $_REQUEST[ Two_Factor_Core::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] = $nonce; + $this->assertNotFalse( Two_Factor_Core::is_valid_user_action( $user_id, $action ), 'Action is valid with correct nonce' ); + + // Test with missing user_id. + $this->assertFalse( Two_Factor_Core::is_valid_user_action( 0, $action ), 'Action is invalid without user ID' ); + + // Test with missing action. + $this->assertFalse( Two_Factor_Core::is_valid_user_action( $user_id, '' ), 'Action is invalid without action name' ); + + // Cleanup. + unset( $_REQUEST[ Two_Factor_Core::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] ); + } + + /** + * Test current_user_being_edited returns correct user ID. + * + * @covers Two_Factor_Core::current_user_being_edited + */ + public function test_current_user_being_edited() { + $user_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); + + // Test without user_id in request. + $this->assertEquals( $user_id, Two_Factor_Core::current_user_being_edited(), 'Returns current user ID when no user_id in request' ); + + // Test with user_id in request for current user. + $_REQUEST['user_id'] = $user_id; + $this->assertEquals( $user_id, Two_Factor_Core::current_user_being_edited(), 'Returns user ID from request when editing self' ); + + // Test with admin editing another user. + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + $_REQUEST['user_id'] = $user_id; + + $this->assertEquals( $user_id, Two_Factor_Core::current_user_being_edited(), 'Returns user ID from request when admin edits user' ); + + // Test with non-admin trying to edit another user. + $other_user = self::factory()->user->create(); + wp_set_current_user( $other_user ); + $_REQUEST['user_id'] = $user_id; + + $this->assertEquals( $other_user, Two_Factor_Core::current_user_being_edited(), 'Returns current user ID when user lacks edit permission' ); + + // Cleanup. + unset( $_REQUEST['user_id'] ); + } + + /** + * Test trigger_user_settings_action triggers action hook. + * + * @covers Two_Factor_Core::trigger_user_settings_action + */ + public function test_trigger_user_settings_action() { + $user_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); + + $action = 'test_action'; + $action_fired = false; + $received_args = array(); + + // Add a test hook. + $test_callback = function ( $uid, $act ) use ( &$action_fired, &$received_args ) { + $action_fired = true; + $received_args = array( $uid, $act ); + }; + add_action( 'two_factor_user_settings_action', $test_callback, 10, 2 ); + + // Test without valid nonce. + Two_Factor_Core::trigger_user_settings_action(); + $this->assertFalse( $action_fired, 'Action does not fire without valid nonce' ); + + // Set up valid action and nonce. + $_REQUEST[ Two_Factor_Core::USER_SETTINGS_ACTION_QUERY_VAR ] = $action; + $nonce = wp_create_nonce( sprintf( '%d-%s', $user_id, $action ) ); + $_REQUEST[ Two_Factor_Core::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] = $nonce; + + // Trigger the action. + Two_Factor_Core::trigger_user_settings_action(); + + $this->assertTrue( $action_fired, 'Action fires with valid nonce' ); + $this->assertEquals( $user_id, $received_args[0], 'Action receives correct user ID' ); + $this->assertEquals( $action, $received_args[1], 'Action receives correct action name' ); + + // Cleanup. + remove_action( 'two_factor_user_settings_action', $test_callback, 10 ); + unset( $_REQUEST[ Two_Factor_Core::USER_SETTINGS_ACTION_QUERY_VAR ] ); + unset( $_REQUEST[ Two_Factor_Core::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] ); + } + + /** + * Test get_primary_provider_for_user with multiple providers. + * + * @covers Two_Factor_Core::get_primary_provider_for_user + */ + public function test_get_primary_provider_for_user_with_multiple_providers() { + $user = $this->get_dummy_user( array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy' ) ); + + // Enable TOTP as well. + $totp = Two_Factor_Totp::get_instance(); + $totp->set_user_totp_key( $user->ID, 'test_key' ); + Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Totp' ); + + // Get the initial primary provider. + $primary = Two_Factor_Core::get_primary_provider_for_user( $user->ID ); + $this->assertNotNull( $primary, 'Primary provider exists when multiple are enabled' ); + $initial_primary_key = $primary->get_key(); + $this->assertContains( $initial_primary_key, array( 'Two_Factor_Dummy', 'Two_Factor_Totp' ), 'Primary is one of enabled providers' ); + + // Set Dummy as primary explicitly. + update_user_meta( $user->ID, Two_Factor_Core::PROVIDER_USER_META_KEY, 'Two_Factor_Dummy' ); + $primary = Two_Factor_Core::get_primary_provider_for_user( $user->ID ); + $this->assertEquals( 'Two_Factor_Dummy', $primary->get_key(), 'Primary provider can be set to Dummy' ); + + // Set TOTP as primary. + update_user_meta( $user->ID, Two_Factor_Core::PROVIDER_USER_META_KEY, 'Two_Factor_Totp' ); + $primary = Two_Factor_Core::get_primary_provider_for_user( $user->ID ); + $this->assertEquals( 'Two_Factor_Totp', $primary->get_key(), 'Primary provider can be changed to TOTP' ); + + // Disable TOTP, should fall back to Dummy. + Two_Factor_Core::disable_provider_for_user( $user->ID, 'Two_Factor_Totp' ); + $primary = Two_Factor_Core::get_primary_provider_for_user( $user->ID ); + $this->assertEquals( 'Two_Factor_Dummy', $primary->get_key(), 'Primary falls back when selected provider is disabled' ); + + $this->clean_dummy_user(); + } + + /** + * Test get_primary_provider_for_user with filter. + * + * @covers Two_Factor_Core::get_primary_provider_for_user + */ + public function test_get_primary_provider_for_user_with_filter() { + $user = $this->get_dummy_user( array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy' ) ); + + // Enable Email provider as well. + Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Email' ); + + // Add filter to force Email as primary. + add_filter( + 'two_factor_primary_provider_for_user', + function ( $provider, $user_id ) use ( $user ) { + if ( $user_id === $user->ID ) { + return 'Two_Factor_Email'; + } + return $provider; + }, + 10, + 2 + ); + + $primary = Two_Factor_Core::get_primary_provider_for_user( $user->ID ); + $this->assertEquals( 'Two_Factor_Email', $primary->get_key(), 'Filter can override primary provider' ); + + remove_all_filters( 'two_factor_primary_provider_for_user' ); + $this->clean_dummy_user(); + } + + /** + * Test show_two_factor_login displays login form. + * + * @covers Two_Factor_Core::show_two_factor_login + */ + public function test_show_two_factor_login() { + $user = $this->get_dummy_user( array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy' ) ); + + // Test with user parameter. + ob_start(); + Two_Factor_Core::show_two_factor_login( $user ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'wp-login.php', $output, 'Output contains login form action' ); + $this->assertStringContainsString( 'validate_2fa_form', $output, 'Output contains two-factor form' ); + $this->assertStringContainsString( 'wp-auth-nonce', $output, 'Output contains auth nonce field' ); + + // Test without user parameter (uses current user). + wp_set_current_user( $user->ID ); + ob_start(); + Two_Factor_Core::show_two_factor_login( null ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'wp-login.php', $output, 'Output contains login form with current user' ); + $this->assertStringContainsString( 'validate_2fa_form', $output, 'Output contains two-factor form for current user' ); + + $this->clean_dummy_user(); + } + + /** + * Test get_supported_providers_for_user. + * + * @covers Two_Factor_Core::get_supported_providers_for_user + */ + public function test_get_supported_providers_for_user() { + $user = self::factory()->user->create_and_get(); + $providers = Two_Factor_Core::get_supported_providers_for_user( $user ); + + $this->assertIsArray( $providers, 'Returns an array of providers' ); + $this->assertArrayHasKey( 'Two_Factor_Email', $providers, 'Email provider is supported by default' ); + $this->assertArrayHasKey( 'Two_Factor_Totp', $providers, 'TOTP provider is supported by default' ); + } + + /** + * Test is_user_using_two_factor with enabled provider. + * + * @covers Two_Factor_Core::is_user_using_two_factor + */ + public function test_is_user_using_two_factor_with_enabled_provider() { + $user = $this->get_dummy_user( array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy' ) ); + + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user->ID ), 'User with enabled provider is using two factor' ); + + wp_set_current_user( $user->ID ); + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor(), 'Current user with enabled provider is using two factor' ); + + $this->clean_dummy_user(); + } + + /** + * Test user_two_factor_options_update with invalid nonce. + * + * @covers Two_Factor_Core::user_two_factor_options_update + */ + public function test_user_two_factor_options_update_with_invalid_nonce() { + $user = self::factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + + // Do NOT set nonce — the function skips the update block entirely when nonce is absent. + $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] = array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy' ); + + // Should not enable provider without a valid nonce. + Two_Factor_Core::user_two_factor_options_update( $user->ID ); + + $providers = Two_Factor_Core::get_enabled_providers_for_user( $user->ID ); + $this->assertEmpty( $providers, 'Providers not enabled without a valid nonce' ); + + // Cleanup. + unset( $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + } + + /** + * Test collect_auth_cookie_tokens stores tokens correctly. + * + * @covers Two_Factor_Core::collect_auth_cookie_tokens + */ + public function test_collect_auth_cookie_tokens() { + $user_id = self::factory()->user->create( + array( + 'user_login' => 'testuser', + 'user_pass' => 'password123', + ) + ); + + // Reset the private static before the test to ensure a clean baseline, + // but capture the original value so it can be restored afterward. + $reflection = new ReflectionClass( Two_Factor_Core::class ); + $prop = $reflection->getProperty( 'password_auth_tokens' ); + $prop->setAccessible( true ); + $original_tokens = $prop->getValue( null ); + + try { + $prop->setValue( null, array() ); + + // Authenticate user — this fires set_auth_cookie / set_logged_in_cookie, + // which call collect_auth_cookie_tokens() via hook. + $authenticated = wp_signon( + array( + 'user_login' => 'testuser', + 'user_password' => 'password123', + ) + ); + + $this->assertSame( $user_id, $authenticated->ID, 'Correct user authenticated' ); + + // Verify collect_auth_cookie_tokens() actually stored at least one token. + $tokens = $prop->getValue( null ); + $this->assertNotEmpty( $tokens, 'collect_auth_cookie_tokens stored at least one token' ); + + // Cleanup. + WP_Session_Tokens::get_instance( $user_id )->destroy_all(); + } finally { + // Restore original static state to avoid leaking into other tests. + $prop->setValue( null, $original_tokens ); + } + } + + /** + * Test get_available_providers_for_user with configured providers. + * + * @covers Two_Factor_Core::get_available_providers_for_user + */ + public function test_get_available_providers_for_user_with_configured_providers() { + $user = self::factory()->user->create_and_get(); + + // Initially no providers. + $this->assertEmpty( Two_Factor_Core::get_available_providers_for_user( $user->ID ), 'No providers available initially' ); + + // Enable TOTP and configure it. + $totp = Two_Factor_Totp::get_instance(); + $totp->set_user_totp_key( $user->ID, 'test_key' ); + Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Totp' ); + + $available = Two_Factor_Core::get_available_providers_for_user( $user->ID ); + $this->assertCount( 1, $available, 'One provider is available when configured' ); + $this->assertArrayHasKey( 'Two_Factor_Totp', $available, 'TOTP provider is available' ); + + // Enable Dummy (always available). + Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Dummy' ); + $available = Two_Factor_Core::get_available_providers_for_user( $user->ID ); + $this->assertCount( 2, $available, 'Two providers are available' ); + } + + /** + * Verify process_provider() returns WP_Error when no provider is given. + * + * @covers Two_Factor_Core::process_provider + */ + public function test_process_provider_with_null_provider() { + $user = self::factory()->user->create_and_get(); + $result = Two_Factor_Core::process_provider( null, $user, true ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'two_factor_provider_missing', $result->get_error_code() ); + } + + /** + * Verify process_provider() returns false when pre-processing signals a re-send. + * + * @covers Two_Factor_Core::process_provider + */ + public function test_process_provider_returns_false_when_pre_process_returns_true() { + $user = self::factory()->user->create_and_get(); + $provider = Two_Factor_Email::get_instance(); + + // Simulate a "resend code" request – triggers pre_process_authentication() to return true. + $_REQUEST[ Two_Factor_Email::INPUT_NAME_RESEND_CODE ] = '1'; + $result = Two_Factor_Core::process_provider( $provider, $user, true ); + unset( $_REQUEST[ Two_Factor_Email::INPUT_NAME_RESEND_CODE ] ); + + $this->assertFalse( $result ); + } + + /** + * Verify process_provider() returns false on a GET (non-POST) request. + * + * @covers Two_Factor_Core::process_provider + */ + public function test_process_provider_not_post_request() { + $user = self::factory()->user->create_and_get(); + $provider = Two_Factor_Dummy::get_instance(); + + $result = Two_Factor_Core::process_provider( $provider, $user, false ); + + $this->assertFalse( $result ); + } + + /** + * Verify process_provider() returns WP_Error when the user is rate-limited. + * + * @covers Two_Factor_Core::process_provider + */ + public function test_process_provider_rate_limited() { + $user = self::factory()->user->create_and_get(); + $provider = Two_Factor_Dummy::get_instance(); + + update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 1 ); + update_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, time() ); + + $result = Two_Factor_Core::process_provider( $provider, $user, true ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'two_factor_too_fast', $result->get_error_code() ); + } + + /** + * Verify process_provider() returns WP_Error and increments the failed-attempts + * counter when authentication fails. + * + * @covers Two_Factor_Core::process_provider + */ + public function test_process_provider_invalid_authentication() { + $user = self::factory()->user->create_and_get(); + $provider = Two_Factor_Email::get_instance(); + + // No code submitted in POST → validate_authentication() returns false. + $result = Two_Factor_Core::process_provider( $provider, $user, true ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'two_factor_invalid', $result->get_error_code() ); + $this->assertEquals( 1, (int) get_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, true ) ); + } + + /** + * Verify process_provider() returns true when authentication succeeds. + * + * @covers Two_Factor_Core::process_provider + */ + public function test_process_provider_successful_authentication() { + $user = self::factory()->user->create_and_get(); + $provider = Two_Factor_Dummy::get_instance(); + + $result = Two_Factor_Core::process_provider( $provider, $user, true ); + + $this->assertTrue( $result ); + } + + /** + * Verify _login_form_validate_2fa() renders the login form and creates a new + * nonce when the provider fails authentication. + * + * @covers Two_Factor_Core::_login_form_validate_2fa + */ + public function test_login_form_validate_2fa_provider_failure() { + $user = self::factory()->user->create_and_get(); + Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Email' ); + + $login_nonce = Two_Factor_Core::create_login_nonce( $user->ID ); + $this->assertNotFalse( $login_nonce ); + + // POST request but no email code supplied → provider fails. + ob_start(); + Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Email', '', true ); + ob_end_clean(); + + // Authentication did not succeed – no auth cookie should be set. + $this->assertArrayNotHasKey( AUTH_COOKIE, $_COOKIE ); + + // A new login nonce should have been created for the retry form. + $stored_nonce = get_user_meta( $user->ID, Two_Factor_Core::USER_META_NONCE_KEY, true ); + $this->assertNotEmpty( $stored_nonce ); + } + + /** + * Verify that rest_api_can_edit_user_and_update_two_factor_options() returns + * false when the current user cannot edit the target user. + * + * @covers Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options + */ + public function test_rest_api_can_edit_user_no_capability() { + $subscriber = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $other_user = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + + wp_set_current_user( $subscriber->ID ); + + $result = Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options( $other_user->ID ); + + $this->assertFalse( $result ); + } + + /** + * Verify that rest_api_can_edit_user_and_update_two_factor_options() returns + * WP_Error when revalidation is required. + * + * @covers Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options + */ + public function test_rest_api_can_edit_user_revalidation_required() { + $user = self::factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + wp_set_auth_cookie( $user->ID ); + + // Enable 2FA, but the session carries no two-factor metadata → + // current_user_can_update_two_factor_options( 'save' ) returns false. + Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Dummy' ); + + $result = Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options( $user->ID ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'revalidation_required', $result->get_error_code() ); + } + + /** + * Verify that rest_api_can_edit_user_and_update_two_factor_options() applies + * the two_factor_rest_api_can_edit_user filter when permissions are satisfied. + * + * @covers Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options + */ + public function test_rest_api_can_edit_user_filter_overrides() { + $user = self::factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + wp_set_auth_cookie( $user->ID ); + + // Set up a valid 2FA session so save-context passes. + $manager = WP_Session_Tokens::get_instance( $user->ID ); + $token = wp_get_session_token(); + $session = $manager->get( $token ); + + $session['two-factor-provider'] = 'Two_Factor_Dummy'; + $session['two-factor-login'] = time(); + $manager->update( $token, $session ); + + // Filter should receive (true, $user_id) and its return value used. + $filter_received_user_id = null; + $filter = function ( $can, $user_id ) use ( &$filter_received_user_id ) { + $filter_received_user_id = $user_id; + return false; + }; + add_filter( 'two_factor_rest_api_can_edit_user', $filter, 10, 2 ); + + $result = Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options( $user->ID ); + + remove_filter( 'two_factor_rest_api_can_edit_user', $filter, 10 ); + + $this->assertFalse( $result, 'Filter return value overrides default' ); + $this->assertSame( $user->ID, $filter_received_user_id, 'Filter receives correct user ID' ); + } + + /** + * Verify filter_manage_users_columns() adds the two-factor column. + * + * @covers Two_Factor_Core::filter_manage_users_columns + */ + public function test_filter_manage_users_columns() { + $columns = array( + 'username' => 'Username', + 'email' => 'Email', + ); + + $result = Two_Factor_Core::filter_manage_users_columns( $columns ); + + $this->assertArrayHasKey( 'two-factor', $result ); + // Existing columns must be preserved. + $this->assertSame( 'Username', $result['username'] ); + } + + /** + * Verify manage_users_custom_column() returns the original output for + * columns other than 'two-factor'. + * + * @covers Two_Factor_Core::manage_users_custom_column + */ + public function test_manage_users_custom_column_wrong_column() { + $result = Two_Factor_Core::manage_users_custom_column( 'original_output', 'username', 1 ); + + $this->assertSame( 'original_output', $result ); + } + + /** + * Verify manage_users_custom_column() returns a "Disabled" indicator for + * users who have not enabled two-factor. + * + * @covers Two_Factor_Core::manage_users_custom_column + */ + public function test_manage_users_custom_column_disabled() { + $user = self::factory()->user->create_and_get(); + + $result = Two_Factor_Core::manage_users_custom_column( '', 'two-factor', $user->ID ); + + $this->assertStringContainsString( 'Disabled', $result ); + } + + /** + * Verify manage_users_custom_column() returns the provider label for users + * who have two-factor enabled. + * + * @covers Two_Factor_Core::manage_users_custom_column + */ + public function test_manage_users_custom_column_enabled() { + $user = $this->get_dummy_user( array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy' ) ); + + $result = Two_Factor_Core::manage_users_custom_column( '', 'two-factor', $user->ID ); + + $this->assertSame( Two_Factor_Dummy::get_instance()->get_label(), $result ); + + $this->clean_dummy_user(); + } + + /** + * Verify wp_login() returns early without side-effects for users who have + * not enabled two-factor authentication. + * + * @covers Two_Factor_Core::wp_login + */ + public function test_wp_login_non_two_factor_user() { + $user = self::factory()->user->create_and_get(); + // No 2FA providers enabled – is_user_using_two_factor() returns false. + + // Should return without touching auth cookies or destroying sessions. + Two_Factor_Core::wp_login( $user->user_login, $user ); + + // No auth cookie set by wp_login for a non-2FA user. + $this->assertArrayNotHasKey( AUTH_COOKIE, $_COOKIE ); + } + + /** + * Verify add_settings_action_link() prepends a settings link. + * + * @covers Two_Factor_Core::add_settings_action_link + */ + public function test_add_settings_action_link() { + $links = array( 'deactivate' => 'Deactivate' ); + $result = Two_Factor_Core::add_settings_action_link( $links ); + + // Settings link should be first. + $this->assertCount( 2, $result ); + $first = reset( $result ); + $this->assertStringContainsString( 'assertStringContainsString( 'Settings', $first ); + $this->assertStringContainsString( 'profile.php', $first ); + } } diff --git a/tests/providers/class-two-factor-email.php b/tests/providers/class-two-factor-email.php index f3d990fc..8b37a983 100644 --- a/tests/providers/class-two-factor-email.php +++ b/tests/providers/class-two-factor-email.php @@ -149,14 +149,25 @@ public function test_generate_token_and_validate_token_false_deleted() { * Verify emailed tokens can be validated. * * @covers Two_Factor_Email::generate_and_email_token + * @covers Two_Factor_Email::get_client_ip * @covers Two_Factor_Email::validate_token */ public function test_generate_and_email_token() { $user = new WP_User( self::factory()->user->create() ); - $this->provider->generate_and_email_token( $user ); + $prev_remote_addr = $_SERVER['REMOTE_ADDR'] ?? null; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + try { + $this->provider->generate_and_email_token( $user ); + } finally { + if ( null === $prev_remote_addr ) { + unset( $_SERVER['REMOTE_ADDR'] ); + } else { + $_SERVER['REMOTE_ADDR'] = $prev_remote_addr; + } + } - $pattern = '/Enter (\d*) to log in./'; + $pattern = '/verification code below:\R\R(\d+)/'; $content = $GLOBALS['phpmailer']->Body; $this->assertGreaterThan( 0, preg_match( $pattern, $content, $match ) ); @@ -284,6 +295,7 @@ public function test_user_token_has_ttl() { /** * Ensure the token generation time is stored. * + * @covers Two_Factor_Email::user_has_token * @covers Two_Factor_Email::user_token_lifetime */ public function test_tokens_have_generation_time() { @@ -337,7 +349,10 @@ public function test_tokens_can_expire() { 'Fresh tokens are also valid' ); - // Update the generation time to one second before the TTL. + // Regenerate a fresh token (previous validate_token call deleted the original). + $token = $this->provider->generate_token( $user_id ); + + // Update the generation time to one second after the TTL. $expired_token_timestamp = time() - $this->provider->user_token_ttl( $user_id ) - 1; update_user_meta( $user_id, Two_Factor_Email::TOKEN_META_KEY_TIMESTAMP, $expired_token_timestamp ); @@ -417,4 +432,97 @@ function () { remove_all_filters( 'two_factor_token_ttl' ); } + + /** + * Verify the alternative provider label contains expected text. + * + * @covers Two_Factor_Email::get_alternative_provider_label + */ + public function test_get_alternative_provider_label() { + $label = $this->provider->get_alternative_provider_label(); + $this->assertStringContainsString( 'email', strtolower( $label ) ); + } + + /** + * Verify pre_process_authentication returns false when no resend code is set. + * + * @covers Two_Factor_Email::pre_process_authentication + */ + public function test_pre_process_authentication_without_resend() { + $user = self::factory()->user->create_and_get(); + + $this->assertFalse( + $this->provider->pre_process_authentication( $user ), + 'Returns false when no resend is requested' + ); + } + + /** + * Verify authentication_page outputs the login form for a valid user with no token. + * + * @covers Two_Factor_Email::authentication_page + * @covers Two_Factor_Email::get_client_ip + */ + public function test_authentication_page_with_user_no_token() { + $user = self::factory()->user->create_and_get(); + + ob_start(); + $this->provider->authentication_page( $user ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'two-factor-email-code', $output ); + $this->assertStringContainsString( 'authcode', $output ); + } + + /** + * Verify authentication_page skips token generation when a valid token already exists. + * + * @covers Two_Factor_Email::authentication_page + */ + public function test_authentication_page_with_existing_token() { + $user = self::factory()->user->create_and_get(); + + // Pre-generate a token so authentication_page should NOT email a new one. + $this->provider->generate_token( $user->ID ); + + $emails_before = count( self::$mockmailer->mock_sent ); + + ob_start(); + $this->provider->authentication_page( $user ); + $output = ob_get_clean(); + + $this->assertCount( $emails_before, self::$mockmailer->mock_sent, 'No new email sent when token already exists' ); + $this->assertStringContainsString( 'two-factor-email-code', $output ); + } + + /** + * Verify user_options outputs the user's email address. + * + * @covers Two_Factor_Email::user_options + */ + public function test_user_options() { + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'test-coverage@example.com', + ) + ); + + ob_start(); + $this->provider->user_options( $user ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'test-coverage@example.com', $output ); + } + + /** + * Verify uninstall_user_meta_keys returns the expected meta keys. + * + * @covers Two_Factor_Email::uninstall_user_meta_keys + */ + public function test_uninstall_user_meta_keys() { + $keys = Two_Factor_Email::uninstall_user_meta_keys(); + + $this->assertContains( Two_Factor_Email::TOKEN_META_KEY, $keys ); + $this->assertContains( Two_Factor_Email::TOKEN_META_KEY_TIMESTAMP, $keys ); + } } diff --git a/tests/providers/class-two-factor-provider.php b/tests/providers/class-two-factor-provider.php index dcce6c6f..149fd022 100644 --- a/tests/providers/class-two-factor-provider.php +++ b/tests/providers/class-two-factor-provider.php @@ -87,4 +87,92 @@ public function test_get_instance() { $this->assertSame( $instance_one, $instance_two ); } + + /** + * Verify get_key() returns the provider's class name. + * + * @covers Two_Factor_Provider::get_key + */ + public function test_get_key_returns_class_name() { + $provider = Two_Factor_Dummy::get_instance(); + $this->assertSame( 'Two_Factor_Dummy', $provider->get_key() ); + } + + /** + * Verify is_supported_for_user() returns true when the provider is globally registered. + * + * is_supported_for_user() checks Two_Factor_Core::get_supported_providers_for_user(), + * which reflects global registration (the two_factor_providers filter), not per-user + * enabled state. Two_Factor_Dummy is registered globally when WP_DEBUG is true. + * + * @covers Two_Factor_Provider::is_supported_for_user + */ + public function test_is_supported_for_user_when_globally_registered() { + $user = self::factory()->user->create_and_get(); + + $this->assertTrue( Two_Factor_Dummy::is_supported_for_user( $user ) ); + } + + /** + * Verify is_supported_for_user() returns false when the provider is not enabled for the user. + * + * @covers Two_Factor_Provider::is_supported_for_user + */ + public function test_is_supported_for_user_without_active_provider() { + $user = self::factory()->user->create_and_get(); + + // Remove Two_Factor_Dummy from supported providers for this user via filter. + $filter = function ( $providers ) { + unset( $providers['Two_Factor_Dummy'] ); + return $providers; + }; + add_filter( 'two_factor_providers_for_user', $filter ); + try { + $this->assertFalse( Two_Factor_Dummy::is_supported_for_user( $user ) ); + } finally { + remove_filter( 'two_factor_providers_for_user', $filter ); + } + } + + /** + * Verify get_alternative_provider_label() returns the default "Use {label}" string. + * + * @covers Two_Factor_Provider::get_alternative_provider_label + */ + public function test_get_alternative_provider_label_default() { + $provider = Two_Factor_Dummy::get_instance(); + $label = $provider->get_alternative_provider_label(); + + $this->assertStringContainsString( $provider->get_label(), $label ); + } + + /** + * Verify the base pre_process_authentication() returns false. + * + * @covers Two_Factor_Provider::pre_process_authentication + */ + public function test_pre_process_authentication_base_returns_false() { + $provider = Two_Factor_Dummy::get_instance(); + $user = self::factory()->user->create_and_get(); + + $this->assertFalse( $provider->pre_process_authentication( $user ) ); + } + + /** + * Verify the base uninstall_user_meta_keys() returns an empty array. + * + * @covers Two_Factor_Provider::uninstall_user_meta_keys + */ + public function test_uninstall_user_meta_keys_base_returns_empty() { + $this->assertSame( array(), Two_Factor_Dummy::uninstall_user_meta_keys() ); + } + + /** + * Verify the base uninstall_options() returns an empty array. + * + * @covers Two_Factor_Provider::uninstall_options + */ + public function test_uninstall_options_base_returns_empty() { + $this->assertSame( array(), Two_Factor_Dummy::uninstall_options() ); + } } diff --git a/tests/providers/class-two-factor-totp.php b/tests/providers/class-two-factor-totp.php index 7af07117..50ea34f8 100644 --- a/tests/providers/class-two-factor-totp.php +++ b/tests/providers/class-two-factor-totp.php @@ -444,4 +444,55 @@ public function test_sha512_authenticate() { $this->assertTrue( $provider::is_valid_authcode( $token, substr( $vector[2], 2 ), $hash ) ); } } + + /** + * Helper to call the protected static pad_secret() method via reflection. + * + * @param string $secret The secret to pad. + * @param int $length The desired padded length. + * @return string + */ + private function call_pad_secret( $secret, $length ) { + $method = new ReflectionMethod( 'Two_Factor_Totp', 'pad_secret' ); + $method->setAccessible( true ); + return $method->invoke( null, $secret, $length ); + } + + /** + * Verify pad_secret() pads the secret to the specified length by repeating itself. + * + * @covers Two_Factor_Totp::pad_secret + */ + public function test_pad_secret_pads_to_length() { + $this->assertSame( 'ABABAB', $this->call_pad_secret( 'AB', 6 ) ); + } + + /** + * Verify pad_secret() returns the secret unchanged when it already equals the target length. + * + * @covers Two_Factor_Totp::pad_secret + */ + public function test_pad_secret_exact_length() { + $this->assertSame( 'ABCDEF', $this->call_pad_secret( 'ABCDEF', 6 ) ); + } + + /** + * Verify pad_secret() throws InvalidArgumentException for an empty secret. + * + * @covers Two_Factor_Totp::pad_secret + */ + public function test_pad_secret_empty_throws_exception() { + $this->expectException( InvalidArgumentException::class ); + $this->call_pad_secret( '', 6 ); + } + + /** + * Verify pad_secret() throws InvalidArgumentException when length is zero. + * + * @covers Two_Factor_Totp::pad_secret + */ + public function test_pad_secret_zero_length_throws_exception() { + $this->expectException( InvalidArgumentException::class ); + $this->call_pad_secret( 'ABC', 0 ); + } }