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 );
+ }
}