diff --git a/tests/php/integration/Quform/QuformTest.php b/tests/php/integration/Quform/QuformTest.php new file mode 100644 index 000000000..de0cde504 --- /dev/null +++ b/tests/php/integration/Quform/QuformTest.php @@ -0,0 +1,81 @@ +settings()->set( 'honeypot', 'on' ); + hcaptcha()->settings()->set( 'set_min_submit_time', 'on' ); + hcaptcha()->settings()->set( 'quform_status', 'form' ); + $this->set_protected_property( hcaptcha(), 'supported_forms', null ); + } + + /** + * Test that the rendered form contains honeypot fields. + * + * @return void + */ + public function test_add_hcaptcha_contains_honeypot(): void { + $output = '
'; + $output = ( new Quform() )->add_hcaptcha( $output, 'quform', [ 'id' => 1 ], [] ); + + self::assertStringContainsString( 'name="hcap_hp_test"', $output ); + self::assertStringContainsString( 'name="hcap_hp_sig"', $output ); + self::assertStringContainsString( 'name="hcaptcha-widget-id"', $output ); + } + + /** + * Test that a filled honeypot blocks Quform processing. + * + * @return void + */ + public function test_filled_honeypot_is_rejected(): void { + $this->prepare_verify_post( 'hcaptcha_quform_nonce', 'hcaptcha_quform' ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'quform/quform.php' ], + 'form_id' => 1, + ] + ); + $_POST['hcap_hp_test'] = 'bot'; + + $form = Mockery::mock( Quform_Form::class ); + $form->shouldReceive( 'getId' )->once()->andReturn( 1 ); + $form->shouldReceive( 'getValues' )->once()->andReturn( [] ); + $form->shouldReceive( 'getCurrentPage' )->once()->andReturn( null ); + + $result = ( new Quform() )->verify( [], $form ); + + self::assertSame( 'error', $result['type'] ); + self::assertSame( 'Anti-spam check failed.', $result['errors']['9999_9999'] ); + } +} diff --git a/tests/php/integration/SimpleMembership/RegisterTest.php b/tests/php/integration/SimpleMembership/RegisterTest.php new file mode 100644 index 000000000..b21423528 --- /dev/null +++ b/tests/php/integration/SimpleMembership/RegisterTest.php @@ -0,0 +1,80 @@ +settings()->set( 'honeypot', 'on' ); + hcaptcha()->settings()->set( 'set_min_submit_time', 'on' ); + hcaptcha()->settings()->set( 'simple_membership_status', 'register' ); + $this->set_protected_property( hcaptcha(), 'supported_forms', null ); + } + + /** + * Test the registration form contains honeypot fields. + * + * @return void + */ + public function test_add_hcaptcha(): void { + $output = ( new Register() )->add_hcaptcha( + '
', + 'swpm_registration_form', + [], + [] + ); + $id = [ + 'source' => [ 'simple-membership/simple-wp-membership.php' ], + 'form_id' => 'register', + ]; + + self::assertStringContainsString( 'h-captcha', $output ); + self::assertStringContainsString( HCaptcha::widget_id_value( $id ), $output ); + self::assertStringContainsString( 'name="hcap_hp_test"', $output ); + self::assertStringContainsString( 'name="hcap_hp_sig"', $output ); + } + + /** + * Test a filled honeypot blocks registration. + * + * @return void + */ + public function test_filled_honeypot_is_rejected(): void { + $this->prepare_verify_post( + 'hcaptcha_simple_membership_register_nonce', + 'hcaptcha_simple_membership_register' + ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'simple-membership/simple-wp-membership.php' ], + 'form_id' => 'register', + ] + ); + $_POST['hcap_hp_test'] = 'bot'; + + self::assertSame( 'Anti-spam check failed.', ( new Register() )->verify() ); + } +} diff --git a/tests/php/integration/Subscriber/FormTest.php b/tests/php/integration/Subscriber/FormTest.php index 9c1bd2f4b..94765a74f 100644 --- a/tests/php/integration/Subscriber/FormTest.php +++ b/tests/php/integration/Subscriber/FormTest.php @@ -7,6 +7,7 @@ namespace HCaptcha\Tests\Integration\Subscriber; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Subscriber\Form; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; @@ -17,6 +18,20 @@ */ class FormTest extends HCaptchaWPTestCase { + /** + * Set up the test. + * + * @return void + */ + public function setUp(): void { + parent::setUp(); + + hcaptcha()->settings()->set( 'honeypot', 'on' ); + hcaptcha()->settings()->set( 'set_min_submit_time', 'on' ); + hcaptcha()->settings()->set( 'subscriber_status', 'form' ); + $this->set_protected_property( hcaptcha(), 'supported_forms', null ); + } + /** * Tests add_captcha(). */ @@ -35,7 +50,11 @@ public function test_add_captcha(): void { $expected = $content . $this->get_hcap_form( $args ); $subject = new Form(); - self::assertSame( $expected, $subject->add_captcha( $content ) ); + $output = $subject->add_captcha( $content ); + + self::assertSame( $expected, $output ); + self::assertStringContainsString( 'name="hcap_hp_test"', $output ); + self::assertStringContainsString( 'name="hcap_hp_sig"', $output ); } /** @@ -60,4 +79,23 @@ public function test_verify_not_verified(): void { self::assertSame( 'The hCaptcha is invalid.', $subject->verify( true ) ); } + + /** + * Test verify() with a filled honeypot. + * + * @return void + */ + public function test_verify_filled_honeypot(): void { + $this->prepare_verify_post( 'hcaptcha_subscriber_form_nonce', 'hcaptcha_subscriber_form' ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'subscriber/subscriber.php' ], + 'form_id' => 'form', + ] + ); + $_POST['hcap_hp_test'] = 'bot'; + + self::assertSame( 'Anti-spam check failed.', ( new Form() )->verify( true ) ); + } } diff --git a/tests/php/integration/SupportCandy/FormTest.php b/tests/php/integration/SupportCandy/FormTest.php new file mode 100644 index 000000000..2ca5de1d1 --- /dev/null +++ b/tests/php/integration/SupportCandy/FormTest.php @@ -0,0 +1,101 @@ +settings()->set( 'honeypot', 'on' ); + hcaptcha()->settings()->set( 'set_min_submit_time', 'on' ); + hcaptcha()->settings()->set( 'supportcandy_status', 'form' ); + $this->set_protected_property( hcaptcha(), 'supported_forms', null ); + } + + /** + * Test that the rendered form contains honeypot fields. + * + * @return void + */ + public function test_form_contains_honeypot(): void { + ob_start(); + ( new Form() )->add_captcha(); + $output = (string) ob_get_clean(); + + self::assertStringContainsString( 'name="hcap_hp_test"', $output ); + self::assertStringContainsString( 'name="hcap_hp_sig"', $output ); + self::assertStringContainsString( 'name="hcaptcha-widget-id"', $output ); + } + + /** + * Test that a filled honeypot blocks ticket creation. + * + * @return void + */ + public function test_filled_honeypot_is_rejected(): void { + $this->prepare_verify_post( + 'hcaptcha_support_candy_new_topic_nonce', + 'hcaptcha_support_candy_new_topic' + ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'supportcandy/supportcandy.php' ], + 'form_id' => 'form', + ] + ); + $_POST['hcap_hp_test'] = 'bot'; + + self::assertSame( + 'Anti-spam check failed.', + API::verify_post( + 'hcaptcha_support_candy_new_topic_nonce', + 'hcaptcha_support_candy_new_topic' + ) + ); + } + + /** + * Test that scripts are printed for Support Candy ticket shortcodes. + * + * @return void + */ + public function test_print_hcaptcha_scripts_for_ticket_shortcodes(): void { + $subject = new Form(); + + self::assertFalse( $subject->print_hcaptcha_scripts( false ) ); + self::assertSame( 'output', $subject->support_candy_shortcode_tag( 'output', 'other', [], [] ) ); + self::assertFalse( $subject->print_hcaptcha_scripts( false ) ); + + $subject->support_candy_shortcode_tag( 'output', 'supportcandy', [], [] ); + + self::assertTrue( $subject->print_hcaptcha_scripts( false ) ); + + $subject = new Form(); + + $subject->support_candy_shortcode_tag( 'output', 'wpsc_create_ticket', [], [] ); + + self::assertTrue( $subject->print_hcaptcha_scripts( false ) ); + } +} diff --git a/tests/php/integration/ThemeMyLogin/AssetsTest.php b/tests/php/integration/ThemeMyLogin/AssetsTest.php new file mode 100644 index 000000000..4d20ab96b --- /dev/null +++ b/tests/php/integration/ThemeMyLogin/AssetsTest.php @@ -0,0 +1,70 @@ +form_shown = true; + Assets::enqueue_scripts(); + + self::assertTrue( wp_script_is( 'hcaptcha-theme-my-login' ) ); + } + + /** + * Test add_type_module(). + * + * @return void + * @noinspection JSUnresolvedLibraryURL + */ + public function test_add_type_module(): void { + // phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript + $tag = ''; + $expected = ''; + // phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript + + self::assertSame( $tag, Assets::add_type_module( $tag, 'some-handle', '' ) ); + self::assertSame( $expected, Assets::add_type_module( $tag, 'hcaptcha-theme-my-login', '' ) ); + } +} diff --git a/tests/php/integration/Tutor/FormsTest.php b/tests/php/integration/Tutor/FormsTest.php new file mode 100644 index 000000000..ba39e7b8f --- /dev/null +++ b/tests/php/integration/Tutor/FormsTest.php @@ -0,0 +1,312 @@ +set_protected_property( hcaptcha(), 'supported_forms', null ); + } + + /** + * Test the Tutor LMS Lite login template. + * + * @return void + * @noinspection PhpUndefinedFunctionInspection + */ + public function test_lite_login_form(): void { + hcaptcha()->settings()->set( 'tutor_status', 'login' ); + new Login(); + + add_filter( 'hcap_delay_api_event', '__return_true' ); + ob_start(); + tutor_load_template( 'login-form' ); + $output = (string) ob_get_clean(); + remove_filter( 'hcap_delay_api_event', '__return_true' ); + + self::assertTrue( is_plugin_active( static::$plugin ) ); + self::assertStringContainsString( 'id="tutor-login-form"', $output ); + self::assertStringContainsString( 'name="hcaptcha_login_nonce"', $output ); + self::assertStringContainsString( 'class="h-captcha hcaptcha-api-delayed"', $output ); + } + + /** + * Test the Tutor LMS Lite registration templates. + * + * @return void + * @noinspection PhpUndefinedFunctionInspection + */ + public function test_lite_registration_forms(): void { + update_option( 'users_can_register', 1 ); + hcaptcha()->settings()->set( 'tutor_status', 'register' ); + new Register(); + add_filter( 'hcap_delay_api_event', '__return_true' ); + + $templates = [ + 'dashboard.registration' => 'tutor_student_reg_form_end', + 'dashboard.instructor.registration' => 'tutor_instructor_reg_form_end', + ]; + + foreach ( $templates as $template => $hook ) { + $stop = static function () { + throw new RuntimeException( 'Tutor registration form rendered.' ); + }; + + add_action( $hook, $stop, PHP_INT_MAX ); + ob_start(); + + try { + tutor_load_template( $template ); + self::fail( 'Tutor registration form hook was not called.' ); + } catch ( RuntimeException $e ) { + self::assertSame( 'Tutor registration form rendered.', $e->getMessage() ); + } finally { + $output = (string) ob_get_clean(); + remove_action( $hook, $stop, PHP_INT_MAX ); + } + + self::assertStringContainsString( 'id="tutor-registration-form"', $output ); + self::assertStringContainsString( 'name="hcaptcha_tutor_register_nonce"', $output ); + self::assertStringContainsString( 'class="h-captcha hcaptcha-api-delayed"', $output ); + } + + remove_filter( 'hcap_delay_api_event', '__return_true' ); + } + + /** + * Test the checkout template hooks used by Tutor LMS Lite. + * + * @return void + */ + public function test_lite_checkout_hooks(): void { + hcaptcha()->settings()->set( 'tutor_status', 'checkout' ); + new Checkout(); + + add_filter( 'hcap_delay_api_event', '__return_true' ); + ob_start(); + do_action( 'tutor_load_template_before', 'ecommerce.checkout', [] ); + echo '
'; + do_action( 'tutor_load_template_after', 'ecommerce.checkout', [] ); + $output = (string) ob_get_clean(); + remove_filter( 'hcap_delay_api_event', '__return_true' ); + + self::assertStringContainsString( 'name="hcaptcha_tutor_checkout_nonce"', $output ); + self::assertStringContainsString( 'class="h-captcha hcaptcha-api-delayed"', $output ); + self::assertStringContainsString( '', $output ); + } + + /** + * Test login entry includes the username but excludes the password. + * + * @return void + */ + public function test_login_entry_data(): void { + $_POST['log'] = 'student'; + $_POST['pwd'] = 'secret'; + + $subject = new Login(); + $entry = $this->set_method_accessibility( $subject, 'get_login_entry' )->invoke( $subject ); + + self::assertSame( [ 'log' => 'student' ], $entry['data'] ); + self::assertSame( 'login', $entry['expected_id']['form_id'] ); + } + + /** + * Test registration entry includes useful anti-spam fields only. + * + * @return void + */ + public function test_registration_entry_data(): void { + $_POST['first_name'] = 'Jane'; + $_POST['last_name'] = 'Doe'; + $_POST['user_login'] = 'jane'; + $_POST['email'] = 'jane@example.com'; + $_POST['password'] = 'secret'; + $_POST['password_confirmation'] = 'secret'; + + $subject = new Register(); + $entry = $this->set_method_accessibility( $subject, 'get_entry' )->invoke( $subject ); + + self::assertSame( + [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'user_login' => 'jane', + 'email' => 'jane@example.com', + 'name' => 'Jane Doe', + ], + $entry['data'] + ); + self::assertSame( 'register', $entry['expected_id']['form_id'] ); + } + + /** + * Test checkout entry includes ordinary billing fields without payment details. + * + * @return void + */ + public function test_checkout_entry_data(): void { + $_POST['billing_first_name'] = 'Jane'; + $_POST['billing_last_name'] = 'Doe'; + $_POST['billing_email'] = 'jane@example.com'; + $_POST['billing_phone'] = '123456789'; + $_POST['billing_address'] = 'Some street'; + $_POST['payment_method'] = 'card'; + + $subject = new Checkout(); + $entry = $this->set_method_accessibility( $subject, 'get_entry' )->invoke( $subject ); + + self::assertSame( + [ + 'billing_first_name' => 'Jane', + 'billing_last_name' => 'Doe', + 'billing_email' => 'jane@example.com', + 'billing_phone' => '123456789', + 'billing_address' => 'Some street', + 'email' => 'jane@example.com', + 'name' => 'Jane Doe', + ], + $entry['data'] + ); + self::assertSame( 'checkout', $entry['expected_id']['form_id'] ); + } + + /** + * Test Tutor registration sends entry data to anti-spam verification. + * + * @return void + */ + public function test_registration_disposable_email_is_rejected(): void { + $settings = (array) get_option( 'hcaptcha_settings', [] ); + $settings['disposable_email'] = [ 'on' ]; + update_option( 'hcaptcha_settings', $settings ); + $this->prepare_verify_post( 'hcaptcha_tutor_register_nonce', 'hcaptcha_tutor_register' ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'tutor-pro/tutor-pro.php', 'tutor/tutor.php' ], + 'form_id' => 'register', + ] + ); + $_POST['tutor_action'] = 'tutor_register_student'; + $_POST['email'] = 'student@example.com'; + add_filter( 'hcap_is_disposable_email', '__return_true' ); + + $result = ( new Register() )->verify( new WP_Error(), 'student', 'student@example.com' ); + + self::assertInstanceOf( WP_Error::class, $result ); + self::assertSame( 'Please use a permanent email address.', $result->get_error_message() ); + } + + /** + * Test Tutor login rejects a widget ID from another Tutor form. + * + * @return void + */ + public function test_login_rejects_wrong_widget_id(): void { + $this->prepare_verify_post( 'hcaptcha_login_nonce', 'hcaptcha_login' ); + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'tutor-pro/tutor-pro.php', 'tutor/tutor.php' ], + 'form_id' => 'register', + ] + ); + add_filter( 'hcap_login_limit_exceeded', '__return_true' ); + + $result = ( new Login() )->login_base_verify( new WP_User( 1 ), 'secret' ); + + self::assertInstanceOf( WP_Error::class, $result ); + self::assertSame( 'Bad hCaptcha signature!', $result->get_error_message( 'bad-signature' ) ); + } + + /** + * Test Tutor registration rejects a signed widget ID from another source. + * + * @return void + */ + public function test_registration_rejects_wrong_widget_id(): void { + $this->prepare_verify_post( 'hcaptcha_tutor_register_nonce', 'hcaptcha_tutor_register' ); + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'WordPress' ], + 'form_id' => 'register', + ] + ); + + $_POST['tutor_action'] = 'tutor_register_student'; + + $result = ( new Register() )->verify( new WP_Error(), 'student', 'student@example.com' ); + + self::assertSame( 'Bad hCaptcha signature!', $result->get_error_message( 'bad-signature' ) ); + } + + /** + * Test Tutor checkout stops when another form's widget ID is submitted. + * + * @return void + */ + public function test_checkout_rejects_wrong_widget_id(): void { + $this->prepare_verify_post( 'hcaptcha_tutor_checkout_nonce', 'hcaptcha_tutor_checkout' ); + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'tutor-pro/tutor-pro.php', 'tutor/tutor.php' ], + 'form_id' => 'register', + ] + ); + + $subject = new Checkout(); + $subject->verify(); + + self::assertSame( + [ 'Bad hCaptcha signature!' ], + get_transient( CheckoutController::PAY_NOW_ERROR_TRANSIENT_KEY . get_current_user_id() ) + ); + self::assertFalse( has_action( 'tutor_action_tutor_pay_now' ) ); + } +} diff --git a/tests/php/integration/Tutor/LostPasswordTest.php b/tests/php/integration/Tutor/LostPasswordTest.php new file mode 100644 index 000000000..07b0680b6 --- /dev/null +++ b/tests/php/integration/Tutor/LostPasswordTest.php @@ -0,0 +1,147 @@ +settings()->set( 'honeypot', 'on' ); + hcaptcha()->settings()->set( 'set_min_submit_time', 'on' ); + hcaptcha()->settings()->set( 'tutor_status', 'lost_pass' ); + $this->set_protected_property( hcaptcha(), 'supported_forms', null ); + } + + /** + * Test hCaptcha output uses the nonce verified by the integration. + * + * @return void + */ + public function test_add_hcaptcha(): void { + $subject = new LostPassword(); + + ob_start(); + $subject->add_hcaptcha(); + $output = (string) ob_get_clean(); + $id = [ + 'source' => [ 'tutor-pro/tutor-pro.php', 'tutor/tutor.php' ], + 'form_id' => 'lost_password', + ]; + + self::assertStringContainsString( 'h-captcha', $output ); + self::assertStringContainsString( HCaptcha::widget_id_value( $id ), $output ); + self::assertStringContainsString( 'name="hcaptcha_tutor_lost_password_nonce"', $output ); + self::assertStringContainsString( 'name="hcap_hp_test"', $output ); + self::assertStringContainsString( 'name="hcap_hp_sig"', $output ); + } + + /** + * Test the hCaptcha widget in the Tutor LMS Lite lost-password form. + * + * @return void + */ + public function test_lite_lost_password_form(): void { + new LostPassword(); + + add_filter( 'hcap_delay_api_event', '__return_true' ); + ob_start(); + include WP_PLUGIN_DIR . '/tutor/templates/template-part/retrieve-password.php'; + $output = (string) ob_get_clean(); + remove_filter( 'hcap_delay_api_event', '__return_true' ); + + self::assertTrue( is_plugin_active( static::$plugin ) ); + self::assertStringContainsString( 'class="tutor-forgot-password-form', $output ); + self::assertStringContainsString( 'name="hcaptcha_tutor_lost_password_nonce"', $output ); + self::assertStringContainsString( 'class="h-captcha hcaptcha-api-delayed"', $output ); + } + + /** + * Test lost-password entry includes only the submitted username. + * + * @return void + */ + public function test_entry_data(): void { + $_POST['user_login'] = 'student'; + $_POST['password'] = 'secret'; + + $subject = new LostPassword(); + $entry = $this->set_method_accessibility( $subject, 'get_entry' )->invoke( $subject ); + + self::assertSame( [ 'user_login' => 'student' ], $entry['data'] ); + self::assertSame( 'lost_password', $entry['expected_id']['form_id'] ); + } + + /** + * Test a filled honeypot blocks lost-password processing. + * + * @return void + */ + public function test_filled_honeypot_is_rejected(): void { + $this->prepare_verify_post( + 'hcaptcha_tutor_lost_password_nonce', + 'hcaptcha_tutor_lost_password' + ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'tutor-pro/tutor-pro.php', 'tutor/tutor.php' ], + 'form_id' => 'lost_password', + ] + ); + $_POST['hcap_hp_test'] = 'bot'; + + $result = ( new LostPassword() )->verify( new WP_Error() ); + + self::assertInstanceOf( WP_Error::class, $result ); + self::assertSame( 'Anti-spam check failed.', $result->get_error_message( 'spam' ) ); + } + + /** + * Test Tutor password recovery rejects a missing widget ID. + * + * @return void + */ + public function test_missing_widget_id_is_rejected(): void { + $this->prepare_verify_post( 'hcaptcha_tutor_lost_password_nonce', 'hcaptcha_tutor_lost_password' ); + + $result = ( new LostPassword() )->verify( new WP_Error() ); + + self::assertSame( 'Bad hCaptcha signature!', $result->get_error_message( 'bad-signature' ) ); + } +} diff --git a/tests/php/integration/UM/LoginTest.php b/tests/php/integration/UM/LoginTest.php index 3a17b2260..bea72d6c5 100644 --- a/tests/php/integration/UM/LoginTest.php +++ b/tests/php/integration/UM/LoginTest.php @@ -5,6 +5,11 @@ * @package HCaptcha\Tests */ +// phpcs:disable Generic.Commenting.DocComment.MissingShort +/** @noinspection PhpUndefinedNamespaceInspection */ +/** @noinspection PhpUndefinedClassInspection */ +// phpcs:enable Generic.Commenting.DocComment.MissingShort + namespace HCaptcha\Tests\Integration\UM; use HCaptcha\Helpers\HCaptcha; @@ -12,6 +17,7 @@ use HCaptcha\UM\Login; use Mockery; use ReflectionClass; +use um\core\Shortcodes; /** * Class LoginTest. @@ -47,6 +53,8 @@ class LoginTest extends HCaptchaPluginWPTestCase { /** * Test a live Ultimate Member login form. + * + * @noinspection PhpUndefinedFunctionInspection */ public function test_live_login_form(): void { wp_set_current_user( 0 ); @@ -68,7 +76,7 @@ public function test_live_login_form(): void { update_post_meta( $form_id, '_um_template', 'login' ); $integration = new Login(); - $class_file = wp_normalize_path( ( new ReflectionClass( \um\core\Shortcodes::class ) )->getFileName() ); + $class_file = wp_normalize_path( ( new ReflectionClass( Shortcodes::class ) )->getFileName() ); $form_data = UM()->query()->post_data( $form_id ); $form_args = array_merge( $form_data, UM()->shortcodes()->get_css_args( $form_data ) ); @@ -79,7 +87,7 @@ public function test_live_login_form(): void { self::assertTrue( is_plugin_active( static::$plugin ) ); self::assertStringStartsWith( wp_normalize_path( WP_PLUGIN_DIR . '/ultimate-member/' ), $class_file ); self::assertTrue( shortcode_exists( 'ultimatemember' ) ); - self::assertSame( \um\core\Shortcodes::class, get_class( $GLOBALS['shortcode_tags']['ultimatemember'][0] ) ); + self::assertSame( Shortcodes::class, get_class( $GLOBALS['shortcode_tags']['ultimatemember'][0] ) ); self::assertSame( 'ultimatemember', $GLOBALS['shortcode_tags']['ultimatemember'][1] ); self::assertSame( 'um_form', get_post_type( $form_id ) ); self::assertSame( 'publish', get_post_status( $form_id ) ); @@ -130,6 +138,25 @@ public function test_constructor_and_init_hooks(): void { ); } + /** + * Test Ultimate Member entry data without the login password. + * + * @return void + */ + public function test_entry_data(): void { + $subject = $this->get_subject(); + $entry = $this->set_method_accessibility( $subject, 'get_entry' )->invoke( + $subject, + [ + 'username' => 'member@example.com', + 'user_password' => 'do-not-copy', + ] + ); + + self::assertSame( 'member@example.com', $entry['data']['username'] ); + self::assertArrayNotHasKey( 'user_password', $entry['data'] ); + } + /** * Test add_um_captcha(). * diff --git a/tests/php/integration/UM/RegisterTest.php b/tests/php/integration/UM/RegisterTest.php index 07bbf535a..80519e0b7 100644 --- a/tests/php/integration/UM/RegisterTest.php +++ b/tests/php/integration/UM/RegisterTest.php @@ -74,6 +74,28 @@ public function test_constructor_and_init_hooks(): void { ); } + /** + * Test registration email reaches anti-spam entry without the password. + * + * @return void + */ + public function test_entry_data(): void { + $subject = $this->get_subject(); + $entry = $this->set_method_accessibility( $subject, 'get_entry' )->invoke( + $subject, + [ + 'user_email' => 'new@example.com', + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'user_password' => 'do-not-copy', + ] + ); + + self::assertSame( 'new@example.com', $entry['data']['email'] ); + self::assertSame( 'Jane Doe', $entry['data']['name'] ); + self::assertArrayNotHasKey( 'user_password', $entry['data'] ); + } + /** * Get subject. * diff --git a/tests/php/integration/UninstallFileTest.php b/tests/php/integration/UninstallFileTest.php index 19de797e8..baa8131be 100644 --- a/tests/php/integration/UninstallFileTest.php +++ b/tests/php/integration/UninstallFileTest.php @@ -8,7 +8,10 @@ namespace HCaptcha\Tests\Integration; use HCaptcha\Abstracts\LoginBase; +use HCaptcha\AutoVerify\AutoVerify; use HCaptcha\Admin\Events\Events; +use HCaptcha\Helpers\FormSubmitTimeStore; +use HCaptcha\Helpers\LoginAttempts; use HCaptcha\Migrations\Migrations; use HCaptcha\Settings\PluginSettingsBase; use KAGG\Settings\Abstracts\SettingsBase; @@ -59,7 +62,10 @@ public function test_uninstall_file( bool $is_multisite ): void { ]; $network_settings = [ 'some network settings' ]; $login_data = [ 'some login data' ]; + $login_address = '192.0.2.50'; $migrated_versions = [ 'some migration data' ]; + $login_attempts = new LoginAttempts(); + $auto_verify_name = 'hcaptcha_auto_verify_form_' . hash( 'sha256', '/autoverify' ); if ( $is_multisite ) { update_site_option( PluginSettingsBase::OPTION_NAME, $settings ); @@ -76,7 +82,12 @@ static function ( $constant_name ) { update_option( PluginSettingsBase::OPTION_NAME, $settings ); update_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE, $network_settings ); update_option( LoginBase::LOGIN_DATA, $login_data ); + update_option( FormSubmitTimeStore::REGISTRY_OPTION, [ 'some form timing data' ], false ); + update_option( LoginAttempts::RETIREMENT_OPTION, '1', false ); + update_option( $auto_verify_name, [ 'form registration' ], false ); + set_transient( AutoVerify::TRANSIENT, [ 'form registration' ] ); update_option( Migrations::MIGRATED_VERSIONS_OPTION_NAME, $migrated_versions ); + $login_attempts->increment( $login_address, time(), MINUTE_IN_SECONDS ); $table_name = 'hcaptcha_events'; $full_table_name = $wpdb->prefix . $table_name; @@ -132,6 +143,11 @@ static function ( $constant_name ) { self::assertSame( $settings, get_option( PluginSettingsBase::OPTION_NAME ) ); self::assertSame( $network_settings, get_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE ) ); self::assertSame( $login_data, get_option( LoginBase::LOGIN_DATA ) ); + self::assertSame( [ 'some form timing data' ], get_option( FormSubmitTimeStore::REGISTRY_OPTION ) ); + self::assertSame( '1', get_option( LoginAttempts::RETIREMENT_OPTION ) ); + self::assertSame( [ 'form registration' ], get_option( $auto_verify_name ) ); + self::assertSame( [ 'form registration' ], get_transient( AutoVerify::TRANSIENT ) ); + self::assertSame( 1, $login_attempts->read( $login_address, time() ) ); self::assertSame( $migrated_versions, get_option( Migrations::MIGRATED_VERSIONS_OPTION_NAME ) ); $settings = [ 'cleanup_on_uninstall' => [ 'on' ] ]; @@ -149,6 +165,11 @@ static function ( $constant_name ) { self::assertFalse( get_option( PluginSettingsBase::OPTION_NAME ) ); self::assertFalse( get_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE ) ); self::assertFalse( get_option( LoginBase::LOGIN_DATA ) ); + self::assertFalse( get_option( FormSubmitTimeStore::REGISTRY_OPTION ) ); + self::assertFalse( get_option( LoginAttempts::RETIREMENT_OPTION ) ); + self::assertFalse( get_option( $auto_verify_name ) ); + self::assertFalse( get_transient( AutoVerify::TRANSIENT ) ); + self::assertSame( 0, $login_attempts->read( $login_address, time() ) ); self::assertFalse( get_option( Migrations::MIGRATED_VERSIONS_OPTION_NAME ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching diff --git a/tests/php/integration/UsersWP/FormsTest.php b/tests/php/integration/UsersWP/FormsTest.php new file mode 100644 index 000000000..9d21ad960 --- /dev/null +++ b/tests/php/integration/UsersWP/FormsTest.php @@ -0,0 +1,127 @@ +settings()->set( 'honeypot', 'on' ); + hcaptcha()->settings()->set( 'set_min_submit_time', 'on' ); + hcaptcha()->settings()->set( 'users_wp_status', [ 'forgot', 'login', 'register' ] ); + $this->set_protected_property( hcaptcha(), 'supported_forms', null ); + } + + /** + * Test that rendered forms contain honeypot fields. + * + * @param string $integration_class Integration class. + * @param string $action UsersWP action. + * + * @dataProvider dp_test_forms + * @return void + */ + public function test_form_contains_honeypot( string $integration_class, string $action ): void { + $subject = new $integration_class(); + + ob_start(); + $subject->uwp_template_before( $action ); + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
'; + + $subject->uwp_template_after( $action ); + $output = (string) ob_get_clean(); + + self::assertStringContainsString( 'name="hcap_hp_test"', $output ); + self::assertStringContainsString( 'name="hcap_hp_sig"', $output ); + self::assertStringContainsString( 'name="hcaptcha-widget-id"', $output ); + } + + /** + * Test that a filled honeypot blocks UsersWP requests. + * + * @param string $integration_class Integration class. + * @param string $action UsersWP action. + * @param string $nonce hCaptcha nonce name. + * @param string $nonce_action hCaptcha nonce action. + * + * @dataProvider dp_test_forms + * @return void + */ + public function test_filled_honeypot_is_rejected( + string $integration_class, + string $action, + string $nonce, + string $nonce_action + ): void { + $this->prepare_verify_post( $nonce, $nonce_action ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'userswp/userswp.php' ], + 'form_id' => $action, + ] + ); + $_POST['hcap_hp_test'] = 'bot'; + + $result = ( new $integration_class() )->verify( [], $action, [] ); + + self::assertInstanceOf( WP_Error::class, $result ); + self::assertSame( + 'hCaptcha error: Anti-spam check failed.', + $result->get_error_message() + ); + } + + /** + * Provide UsersWP forms. + * + * @return array + */ + public function dp_test_forms(): array { + return [ + 'forgot password' => [ + ForgotPassword::class, + 'forgot', + 'hcaptcha_users_wp_forgot_password_nonce', + 'hcaptcha_users_wp_forgot_password', + ], + 'login' => [ + Login::class, + 'login', + 'hcaptcha_users_wp_login_nonce', + 'hcaptcha_users_wp_login', + ], + 'register' => [ + Register::class, + 'register', + 'hcaptcha_users_wp_register_nonce', + 'hcaptcha_users_wp_register', + ], + ]; + } +} diff --git a/tests/php/integration/WC/CheckoutTest.php b/tests/php/integration/WC/CheckoutTest.php index 9bd8581c3..b4797edd5 100644 --- a/tests/php/integration/WC/CheckoutTest.php +++ b/tests/php/integration/WC/CheckoutTest.php @@ -86,6 +86,55 @@ public function test_constructor_and_init_hooks(): void { ); } + /** + * Test checkout entry data for classic and block requests. + * + * @return void + */ + public function test_checkout_entry_data(): void { + $subject = new Checkout(); + $_POST = [ + 'billing_email' => 'buyer@example.com', + 'billing_first_name' => 'Jane', + 'billing_last_name' => 'Doe', + 'billing_address_1' => 'Main Street', + 'customer_opinion' => 'Great shop', + 'payment_method' => 'card', + ]; + + $classic = $this->set_method_accessibility( $subject, 'get_entry' )->invoke( $subject ); + $block = $this->set_method_accessibility( $subject, 'get_block_entry' )->invoke( + $subject, + [ + 'billing_address' => [ + 'email' => 'block@example.com', + 'first_name' => 'John', + 'last_name' => 'Smith', + 'address_1' => 'Oak Street', + ], + 'shipping_address' => [ 'address_1' => 'Pine Street' ], + 'additional_fields' => [ 'customer_opinion' => 'Very good' ], + 'customer_note' => 'Please call first', + 'payment_data' => [ 'card_number' => 'do-not-copy' ], + ] + ); + + self::assertSame( 'buyer@example.com', $classic['data']['email'] ); + self::assertSame( 'Jane Doe', $classic['data']['name'] ); + self::assertSame( 'Main Street', $classic['data']['billing_address_1'] ); + self::assertSame( 'Great shop', $classic['data']['customer_opinion'] ); + self::assertArrayNotHasKey( 'payment_method', $classic['data'] ); + self::assertSame( 'block@example.com', $block['data']['email'] ); + self::assertSame( 'John Smith', $block['data']['name'] ); + self::assertSame( 'Oak Street', $block['data']['address_1'] ); + self::assertSame( 'Pine Street', $block['data']['shipping_address']['address_1'] ); + self::assertSame( 'Very good', $block['data']['additional_fields']['customer_opinion'] ); + self::assertSame( 'Please call first', $block['data']['customer_note'] ); + self::assertArrayNotHasKey( 'payment_data', $block['data'] ); + + unset( $_POST['billing_email'], $_POST['billing_first_name'], $_POST['billing_last_name'], $_POST['billing_address_1'], $_POST['customer_opinion'], $_POST['payment_method'] ); + } + /** * Tests add_captcha(). * @@ -308,13 +357,21 @@ public function test_verify_block(): void { $this->prepare_verify_request( $hcaptcha_response ); // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + $hp_sig = $_POST[ $hp_sig_name ]; + $token = $_POST[ $token_name ]; + $request->set_param( $widget_id_name, $this->get_test_widget_id() ); $request->set_param( $hcaptcha_response_name, $hcaptcha_response ); - $request->set_param( $hp_sig_name, $_POST[ $hp_sig_name ] ); - $request->set_param( $token_name, $_POST[ $token_name ] ); + $request->set_param( $hp_sig_name, $hp_sig ); + $request->set_param( $token_name, $token ); $request->set_param( $hp_name, '' ); self::assertSame( $response, $subject->verify_block( $response, $handler, $request ) ); + self::assertArrayNotHasKey( $widget_id_name, $_POST ); + self::assertArrayNotHasKey( $hcaptcha_response_name, $_POST ); + self::assertArrayNotHasKey( $hp_sig_name, $_POST ); + self::assertArrayNotHasKey( $token_name, $_POST ); + self::assertArrayNotHasKey( $hp_name, $_POST ); // Checkout route, express payment type. $request = new WP_REST_Request( '', '/wc/store/v1/checkout' ); @@ -333,8 +390,8 @@ public function test_verify_block(): void { ); $request->set_param( $widget_id_name, $this->get_test_widget_id() ); $request->set_param( $hcaptcha_response_name, $hcaptcha_response ); - $request->set_param( $hp_sig_name, $_POST[ $hp_sig_name ] ); - $request->set_param( $token_name, $_POST[ $token_name ] ); + $request->set_param( $hp_sig_name, $hp_sig ); + $request->set_param( $token_name, $token ); $request->set_param( $hp_name, '' ); self::assertSame( $response, $subject->verify_block( $response, $handler, $request ) ); @@ -348,8 +405,8 @@ public function test_verify_block(): void { $this->prepare_verify_request( $hcaptcha_response, false ); $request->set_param( $widget_id_name, $this->get_test_widget_id() ); $request->set_param( $hcaptcha_response_name, $hcaptcha_response ); - $request->set_param( $hp_sig_name, $_POST[ $hp_sig_name ] ); - $request->set_param( $token_name, $_POST[ $token_name ] ); + $request->set_param( $hp_sig_name, $hp_sig ); + $request->set_param( $token_name, $token ); $request->set_param( $hp_name, '' ); // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash diff --git a/tests/php/integration/WC/LoginTest.php b/tests/php/integration/WC/LoginTest.php index 7260e87b7..bf7155553 100644 --- a/tests/php/integration/WC/LoginTest.php +++ b/tests/php/integration/WC/LoginTest.php @@ -8,9 +8,11 @@ namespace HCaptcha\Tests\Integration\WC; use HCaptcha\Helpers\HCaptcha; +use HCaptcha\Helpers\LoginAttempts; use HCaptcha\WC\Login; use tad\FunctionMocker\FunctionMocker; use WP_Error; +use WP_User; /** * Test Login class. @@ -20,6 +22,15 @@ */ class LoginTest extends WooCommerceTestCase { + /** + * Tear down the test. + */ + public function tearDown(): void { + LoginAttempts::delete_all(); + + parent::tearDown(); + } + /** * Test constructor and init_hooks(). */ @@ -112,6 +123,49 @@ public function test_verify_NOT_login_limit_exceeded(): void { ); } + /** + * Test shared bounded login failures and a successful-login reset. + */ + public function test_login_limit_uses_shared_bounded_store(): void { + $ip = '203.0.113.15'; + + update_option( + 'hcaptcha_settings', + [ + 'login_limit' => 2, + 'login_interval' => 15, + ] + ); + hcaptcha()->init_hooks(); + + $subject = new Login(); + $this->set_protected_property( $subject, 'ip', $ip ); + + $method = $this->set_method_accessibility( $subject, 'is_login_limit_exceeded' ); + + self::assertFalse( $method->invoke( $subject ) ); + + $subject->login_failed( 'test-user' ); + $subject->login_failed( 'test-user' ); + + self::assertTrue( $method->invoke( $subject ) ); + + $successful_request = new Login(); + $attempts = $this->get_protected_property( $successful_request, 'login_attempts' ); + + $this->set_protected_property( $successful_request, 'ip', $ip ); + $this->set_protected_property( + $successful_request, + 'login_attempts_reset_token', + $attempts->get_reset_token( $ip, time() ) + ); + $successful_request->login( 'test-user', new WP_User() ); + + self::assertFalse( $method->invoke( $subject ) ); + + $method->setAccessible( false ); + } + /** * Test verify() not verified. */ diff --git a/tests/php/integration/WC/OrderWithdrawalTest.php b/tests/php/integration/WC/OrderWithdrawalTest.php new file mode 100644 index 000000000..dc5a066c4 --- /dev/null +++ b/tests/php/integration/WC/OrderWithdrawalTest.php @@ -0,0 +1,228 @@ + 'buyer@example.com', + 'order_withdrawal_first_name' => 'Jane', + 'order_withdrawal_last_name' => 'Doe', + 'order_withdrawal_additional_details' => 'Please return the item.', + 'card_number' => 'do-not-copy', + ]; + + $subject = new OrderWithdrawal(); + $entry = $this->set_method_accessibility( $subject, 'get_entry' )->invoke( $subject ); + + self::assertSame( 'buyer@example.com', $entry['data']['email'] ); + self::assertSame( 'Jane Doe', $entry['data']['name'] ); + self::assertSame( 'Please return the item.', $entry['data']['message'] ); + self::assertArrayNotHasKey( 'card_number', $entry['data'] ); + } + + /** + * Test hCaptcha insertion into the live WooCommerce review template. + */ + public function test_template_part_injection(): void { + $hcaptcha = $this->get_hcap_form( + [ + 'action' => 'hcaptcha_wc_order_withdrawal', + 'name' => 'hcaptcha_wc_order_withdrawal_nonce', + 'id' => [ + 'source' => [ 'woocommerce/woocommerce.php' ], + 'form_id' => 'order_withdrawal', + ], + ] + ); + + new OrderWithdrawal(); + + ob_start(); + + wc_get_template( + 'myaccount/form-order-withdrawal.php', + [ + 'screen' => 'review', + 'data' => [ 'email' => 'customer@example.com' ], + 'hidden_fields' => [], + 'review_rows' => [], + 'nonce_action' => 'woocommerce_order_withdrawal', + 'nonce_field' => 'woocommerce-order-withdrawal-nonce', + 'action_field' => 'order_withdrawal_action', + 'action_confirm' => 'confirm', + 'action_edit' => 'edit', + 'form_action_url' => home_url( '/my-account/withdraw-order/' ), + ] + ); + + $output = ob_get_clean(); + + self::assertStringContainsString( 'class="woocommerce-OrderWithdrawalForm"', $output ); + self::assertSame( 1, substr_count( $output, $hcaptcha ) ); + self::assertLessThan( + strpos( $output, '

' ), + strpos( $output, $hcaptcha ) + ); + } + + /** + * Test that hCaptcha is not added to the details screen. + */ + public function test_no_injection_outside_review_screen(): void { + $template_name = 'myaccount/form-order-withdrawal.php'; + $row = ''; + $args = [ 'screen' => 'form' ]; + $subject = new OrderWithdrawal(); + + ob_start(); + $subject->before_template_part( $template_name, '', '', $args ); + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $row; + + $subject->after_template_part( $template_name, '', '', $args ); + + self::assertSame( $row, ob_get_clean() ); + } + + /** + * Test successful hCaptcha verification. + */ + public function test_verify(): void { + $this->prepare_confirmation_request(); + $this->prepare_verify_post( 'hcaptcha_wc_order_withdrawal_nonce', 'hcaptcha_wc_order_withdrawal' ); + $this->prepare_widget_id(); + + $subject = new OrderWithdrawal(); + + wc_clear_notices(); + $subject->verify(); + + self::assertSame( 'confirm', Request::filter_input( INPUT_POST, 'order_withdrawal_action' ) ); + self::assertSame( [], wc_get_notices() ); + } + + /** + * Test failed hCaptcha verification. + */ + public function test_verify_not_verified(): void { + $expected = [ + 'error' => [ + [ + 'notice' => 'The hCaptcha is invalid.', + 'data' => [], + ], + ], + ]; + + $this->prepare_confirmation_request(); + $this->prepare_verify_post( 'hcaptcha_wc_order_withdrawal_nonce', 'hcaptcha_wc_order_withdrawal', false ); + $this->prepare_widget_id(); + + $subject = new OrderWithdrawal(); + + wc_clear_notices(); + $subject->verify(); + + self::assertSame( 'review', Request::filter_input( INPUT_POST, 'order_withdrawal_action' ) ); + self::assertSame( $expected, wc_get_notices() ); + } + + /** + * Test that a request with an invalid WooCommerce nonce is ignored. + */ + public function test_verify_with_invalid_woocommerce_nonce(): void { + $this->prepare_confirmation_request(); + $_POST['woocommerce-order-withdrawal-nonce'] = 'invalid'; + + $subject = new OrderWithdrawal(); + + wc_clear_notices(); + $subject->verify(); + + self::assertSame( 'confirm', Request::filter_input( INPUT_POST, 'order_withdrawal_action' ) ); + self::assertSame( [], wc_get_notices() ); + } + + /** + * Test that a non-confirmation request is ignored. + */ + public function test_verify_with_review_action(): void { + $this->prepare_confirmation_request(); + $_POST['order_withdrawal_action'] = 'review'; + + $subject = new OrderWithdrawal(); + + wc_clear_notices(); + $subject->verify(); + + self::assertSame( 'review', Request::filter_input( INPUT_POST, 'order_withdrawal_action' ) ); + self::assertSame( [], wc_get_notices() ); + } + + /** + * Prepare a WooCommerce order withdrawal confirmation request. + */ + private function prepare_confirmation_request(): void { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['order_withdrawal_action'] = 'confirm'; + $_POST['woocommerce-order-withdrawal-nonce'] = wp_create_nonce( 'woocommerce_order_withdrawal' ); + } + + /** + * Prepare the hCaptcha widget id. + */ + private function prepare_widget_id(): void { + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'woocommerce/woocommerce.php' ], + 'form_id' => 'order_withdrawal', + ] + ); + } +} diff --git a/tests/php/integration/WCGermanized/ReturnRequestTest.php b/tests/php/integration/WCGermanized/ReturnRequestTest.php new file mode 100644 index 000000000..8c0daf632 --- /dev/null +++ b/tests/php/integration/WCGermanized/ReturnRequestTest.php @@ -0,0 +1,96 @@ +settings()->set( 'honeypot', 'on' ); + hcaptcha()->settings()->set( 'set_min_submit_time', 'on' ); + hcaptcha()->settings()->set( 'woocommerce_germanized_status', 'return_request' ); + $this->set_protected_property( hcaptcha(), 'supported_forms', null ); + } + + /** + * Test the return-request form contains honeypot fields. + * + * @return void + */ + public function test_add_hcaptcha(): void { + $subject = new ReturnRequest(); + + ob_start(); + $subject->before_submit_button(); + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Test fixture. + echo ''; + + $subject->after_submit_button(); + $output = (string) ob_get_clean(); + $id = [ + 'source' => [ 'woocommerce-germanized/woocommerce-germanized.php' ], + 'form_id' => 'return_request', + ]; + + self::assertStringContainsString( 'h-captcha', $output ); + self::assertStringContainsString( HCaptcha::widget_id_value( $id ), $output ); + self::assertStringContainsString( 'name="hcap_hp_test"', $output ); + self::assertStringContainsString( 'name="hcap_hp_sig"', $output ); + } + + /** + * Test a filled honeypot blocks return-request processing. + * + * @return void + */ + public function test_filled_honeypot_is_rejected(): void { + $this->prepare_verify_request( 'some response' ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'woocommerce-germanized/woocommerce-germanized.php' ], + 'form_id' => 'return_request', + ] + ); + $_POST['hcap_hp_test'] = 'bot'; + $_POST['return_request'] = '1'; + + $notice = []; + + FunctionMocker::replace( + 'wc_add_notice', + static function ( $message, $type ) use ( &$notice ) { + $notice = [ $message, $type ]; + } + ); + + ( new ReturnRequest() )->verify(); + + self::assertSame( [ 'Anti-spam check failed.', 'error' ], $notice ); + // phpcs:ignore WordPress.Security.NonceVerification.Missing + self::assertArrayNotHasKey( 'return_request', $_POST ); + } +} diff --git a/tests/php/integration/WCWishlists/CreateListTest.php b/tests/php/integration/WCWishlists/CreateListTest.php index 40b2b98b8..72a7f8a4c 100644 --- a/tests/php/integration/WCWishlists/CreateListTest.php +++ b/tests/php/integration/WCWishlists/CreateListTest.php @@ -7,6 +7,7 @@ namespace HCaptcha\Tests\Integration\WCWishlists; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use HCaptcha\WCWishlists\CreateList; @@ -24,6 +25,25 @@ class CreateListTest extends HCaptchaPluginWPTestCase { */ protected static $plugin = 'woocommerce/woocommerce.php'; + /** + * Set up the test. + * + * @return void + */ + public function setUp(): void { + parent::setUp(); + + hcaptcha()->settings()->set( 'honeypot', 'on' ); + hcaptcha()->settings()->set( 'set_min_submit_time', 'on' ); + hcaptcha()->settings()->set( 'woocommerce_wishlists_status', 'create_list' ); + hcaptcha()->modules['WooCommerce Wishlists'] = [ + [ 'woocommerce_wishlists_status', 'create_list' ], + 'woocommerce-wishlists/woocommerce-wishlists.php', + CreateList::class, + ]; + $this->set_protected_property( hcaptcha(), 'supported_forms', null ); + } + /** * Test before_wrapper() and after_wrapper(). */ @@ -53,7 +73,11 @@ public function test_wrapper(): void { echo $row; $subject->after_wrapper(); - self::assertSame( $expected, ob_get_clean() ); + $output = (string) ob_get_clean(); + + self::assertSame( $expected, $output ); + self::assertStringContainsString( 'name="hcap_hp_test"', $output ); + self::assertStringContainsString( 'name="hcap_hp_sig"', $output ); } /** @@ -101,4 +125,27 @@ public function test_verify_not_verified(): void { self::assertSame( $expected, wc_get_notices() ); } + + /** + * Test verify() with a filled honeypot. + * + * @noinspection PhpUndefinedFunctionInspection + */ + public function test_verify_filled_honeypot(): void { + $this->prepare_verify_post( 'hcaptcha_wc_create_wishlists_nonce', 'hcaptcha_wc_create_wishlists_action' ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'woocommerce-wishlists/woocommerce-wishlists.php' ], + 'form_id' => 'form', + ] + ); + $_POST['hcap_hp_test'] = 'bot'; + + WC()->init(); + wc_clear_notices(); + + self::assertFalse( ( new CreateList() )->verify( true ) ); + self::assertSame( 'Anti-spam check failed.', wc_get_notices( 'error' )[0]['notice'] ); + } } diff --git a/tests/php/integration/WP/CommentTest.php b/tests/php/integration/WP/CommentTest.php index f7b0d63c2..4ea4be194 100644 --- a/tests/php/integration/WP/CommentTest.php +++ b/tests/php/integration/WP/CommentTest.php @@ -169,6 +169,28 @@ public function test_add_captcha(): void { // Test when hCaptcha plugin is active. self::assertSame( $expected, $subject->add_captcha( $submit_field, [] ) ); } + + /** + * AJAX submission uses the comment integration's own verification. + */ + public function test_add_captcha_with_ajax_submission(): void { + update_option( + 'hcaptcha_settings', + [ + 'wp_status' => 'comment', + 'ajax_forms' => [ 'on' ], + ] + ); + + hcaptcha()->init_hooks(); + + $submit_field = '' . + ""; + $output = ( new Comment() )->add_captcha( $submit_field, [] ); + + self::assertStringContainsString( 'data-ajax="true"', $output ); + self::assertStringContainsString( 'data-auto="false"', $output ); + } /** * Test add_captcha() with built-in form interaction. * diff --git a/tests/php/integration/WP/LoginAttemptsConcurrencyTest.php b/tests/php/integration/WP/LoginAttemptsConcurrencyTest.php new file mode 100644 index 000000000..7fac790ae --- /dev/null +++ b/tests/php/integration/WP/LoginAttemptsConcurrencyTest.php @@ -0,0 +1,688 @@ +option_name( $address ), $this->option_name( $other_address ) ); + + for ( $index = 0; $index < 4; ++$index ) { + $jobs[] = [ + 'operation' => 'read-increment', + 'address' => $address, + 'now' => $now, + 'ttl' => 15 * MINUTE_IN_SECONDS, + ]; + } + + for ( $index = 0; $index < 3; ++$index ) { + $jobs[] = [ + 'operation' => 'read-increment', + 'address' => $other_address, + 'now' => $now, + 'ttl' => 15 * MINUTE_IN_SECONDS, + ]; + } + + $run = $this->run_workers( $jobs, $persistent_cache ); + $attempts = new LoginAttempts(); + + foreach ( $run['ready'] as $ready ) { + self::assertSame( 0, $ready['observed'] ); + } + + foreach ( $run['results'] as $id => $result ) { + self::assertNotSame( + LoginAttempts::MAX_FAILURES, + $result['result'], + 'Worker ' . $id . ' could not store the increment: ' . $result['last_error'] + ); + } + + self::assertSame( 4, $attempts->read( $address, $now ), 'Worker results: ' . wp_json_encode( $run['results'] ) ); + self::assertSame( 3, $attempts->read( $other_address, $now ), 'Worker results: ' . wp_json_encode( $run['results'] ) ); + $this->assert_cache_invalidated( $run, [ $address, $other_address ], $persistent_cache ); + + update_option( + 'hcaptcha_settings', + [ + 'login_limit' => 4, + 'login_interval' => 15, + ] + ); + hcaptcha()->init_hooks(); + + $subject = new Login(); + $this->set_protected_property( $subject, 'ip', $address ); + + // Four already in-flight requests observed zero. Once they finish, the + // next unsolved request is rejected using current shared state. + self::assertInstanceOf( WP_Error::class, $subject->login_base_verify( new WP_User( 1 ), 'password' ) ); + } + + /** + * Test atomic expiry rollover and saturation alongside another address. + * + * @param bool $persistent_cache Whether to exercise shared cache invalidation. + * + * @dataProvider dp_storage_modes + */ + public function test_concurrent_expiry_and_saturation_are_bounded( bool $persistent_cache ): void { + $expired_address = '198.51.100.91'; + $saturated_address = '203.0.113.91'; + $now = time(); + $jobs = []; + + self::assertNotSame( $this->option_name( $expired_address ), $this->option_name( $saturated_address ) ); + $this->seed_record( $expired_address, 37, $now - 1 ); + $this->seed_record( $saturated_address, LoginAttempts::MAX_FAILURES - 2, $now + HOUR_IN_SECONDS ); + + foreach ( [ $expired_address, $expired_address, $expired_address, $saturated_address, $saturated_address, $saturated_address ] as $address ) { + $jobs[] = [ + 'operation' => 'increment', + 'address' => $address, + 'now' => $now, + 'ttl' => MINUTE_IN_SECONDS, + ]; + } + + $run = $this->run_workers( $jobs, $persistent_cache ); + $attempts = new LoginAttempts(); + + self::assertSame( 3, $attempts->read( $expired_address, $now ) ); + self::assertSame( LoginAttempts::MAX_FAILURES, $attempts->read( $saturated_address, $now ) ); + $this->assert_cache_invalidated( $run, [ $expired_address, $saturated_address ], $persistent_cache ); + } + + /** + * Test a successful-login reset cannot erase later concurrent failures. + * + * The worker database adapter holds the reset DELETE until every increment + * has committed. This deterministic ordering would end at zero with an + * unconditional address DELETE; the reset token leaves all increments intact. + * + * @param bool $persistent_cache Whether to exercise shared cache invalidation. + * + * @dataProvider dp_storage_modes + */ + public function test_concurrent_success_reset_uses_compare_and_swap( bool $persistent_cache ): void { + $address = '198.51.100.92'; + $other_address = '203.0.113.92'; + $now = time(); + + self::assertNotSame( $this->option_name( $address ), $this->option_name( $other_address ) ); + $this->seed_record( $address, 2, $now + HOUR_IN_SECONDS ); + $this->seed_record( $other_address, 1, $now + HOUR_IN_SECONDS ); + + $jobs = [ + [ + 'id' => 'failure-1', + 'operation' => 'read-increment', + 'address' => $address, + 'now' => $now, + 'ttl' => MINUTE_IN_SECONDS, + 'interleave' => [ + 'signal_after_pattern' => '/^INSERT /i', + 'signal' => 'failure-1-written', + ], + ], + [ + 'id' => 'failure-2', + 'operation' => 'read-increment', + 'address' => $address, + 'now' => $now, + 'ttl' => MINUTE_IN_SECONDS, + 'interleave' => [ + 'signal_after_pattern' => '/^INSERT /i', + 'signal' => 'failure-2-written', + ], + ], + [ + 'id' => 'other-failure', + 'operation' => 'read-increment', + 'address' => $other_address, + 'now' => $now, + 'ttl' => MINUTE_IN_SECONDS, + 'interleave' => [ + 'signal_after_pattern' => '/^INSERT /i', + 'signal' => 'other-failure-written', + ], + ], + [ + 'id' => 'success-reset', + 'operation' => 'reset', + 'address' => $address, + 'now' => $now, + 'ttl' => MINUTE_IN_SECONDS, + 'interleave' => [ + 'wait_before_pattern' => '/^DELETE /i', + 'wait_for' => [ 'failure-1-written', 'failure-2-written', 'other-failure-written' ], + ], + ], + ]; + + $run = $this->run_workers( $jobs, $persistent_cache ); + $attempts = new LoginAttempts(); + + self::assertSame( 2, $run['ready']['failure-1']['observed'] ); + self::assertSame( 2, $run['ready']['failure-2']['observed'] ); + self::assertTrue( $run['ready']['success-reset']['token'] ); + self::assertSame( 4, $attempts->read( $address, $now ) ); + self::assertSame( 2, $attempts->read( $other_address, $now ) ); + $this->assert_cache_invalidated( $run, [ $address, $other_address ], $persistent_cache ); + } + + /** + * Test that a reset ordered before concurrent failures clears only old state. + * + * @param bool $persistent_cache Whether to exercise shared cache invalidation. + * + * @dataProvider dp_storage_modes + */ + public function test_concurrent_failures_after_success_reset_are_retained( bool $persistent_cache ): void { + $address = '198.51.100.93'; + $now = time(); + + $this->seed_record( $address, 5, $now + HOUR_IN_SECONDS ); + + $jobs = [ + [ + 'id' => 'success-reset', + 'operation' => 'reset', + 'address' => $address, + 'now' => $now, + 'ttl' => MINUTE_IN_SECONDS, + 'interleave' => [ + 'signal_after_pattern' => '/^DELETE /i', + 'signal' => 'reset-written', + ], + ], + ]; + + for ( $index = 1; $index <= 2; ++$index ) { + $jobs[] = [ + 'id' => 'failure-' . $index, + 'operation' => 'read-increment', + 'address' => $address, + 'now' => $now, + 'ttl' => MINUTE_IN_SECONDS, + 'interleave' => [ + 'wait_before_pattern' => '/^INSERT /i', + 'wait_for' => [ 'reset-written' ], + ], + ]; + } + + $run = $this->run_workers( $jobs, $persistent_cache ); + $attempts = new LoginAttempts(); + + self::assertTrue( $run['ready']['success-reset']['token'] ); + self::assertSame( 5, $run['ready']['failure-1']['observed'] ); + self::assertSame( 5, $run['ready']['failure-2']['observed'] ); + self::assertSame( 2, $attempts->read( $address, $now ) ); + $this->assert_cache_invalidated( $run, [ $address ], $persistent_cache ); + } + + /** + * Test database read errors require CAPTCHA instead of looking like zero failures. + */ + public function test_storage_read_failure_fails_closed(): void { + global $wpdb; + + $address = '198.51.100.94'; + + update_option( + 'hcaptcha_settings', + [ + 'login_limit' => 2, + 'login_interval' => 15, + ] + ); + hcaptcha()->init_hooks(); + + $subject = new Login(); + $this->set_protected_property( $subject, 'ip', $address ); + $method = $this->set_method_accessibility( $subject, 'is_login_limit_exceeded' ); + $previous = $wpdb->suppress_errors(); + $filter = static function ( string $query ): string { + if ( false !== stripos( $query, 'SELECT option_value' ) && false !== strpos( $query, LoginAttempts::OPTION_PREFIX ) ) { + return 'SELECT missing_login_attempt_value FROM missing_login_attempt_table'; + } + + return $query; + }; + + add_filter( 'query', $filter ); + + try { + self::assertTrue( $method->invoke( $subject ) ); + } finally { + remove_filter( 'query', $filter ); + $wpdb->suppress_errors( $previous ); + // Clear last_error for the surrounding WordPress test lifecycle. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( 'SELECT 1' ); + $method->setAccessible( false ); + } + } + + /** + * Test that a transient InnoDB deadlock does not lose a failed-login count. + * + * @return void + */ + public function test_increment_retries_deadlock(): void { + global $wpdb; + + $address = '198.51.100.95'; + $now = time(); + $record = hash_hmac( 'sha256', $address, wp_salt( 'nonce' ) ) . '|1|' . ( $now + MINUTE_IN_SECONDS ); + $original_wpdb = $wpdb; + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporary database double for retry behavior. + $wpdb = new class( $record ) { + /** + * Options table name. + * + * @var string + */ + public string $options = 'wp_options'; + + /** + * Latest database error. + * + * @var string + */ + public string $last_error = ''; + + /** + * Query count. + * + * @var int + */ + public int $query_count = 0; + + /** + * Stored record. + * + * @var string + */ + private string $record; + + /** + * Constructor. + * + * @param string $record Stored record. + */ + public function __construct( string $record ) { + $this->record = $record; + } + + /** + * Keep the prepared query for the database double. + * + * @param string $query Query. + * @param mixed ...$args Parameters. + * + * @return string + */ + public function prepare( string $query, ...$args ): string { + return $query; + } + + /** + * Fail the first statement with a retryable deadlock. + * + * @param string $query Query. + * + * @return int|false + */ + public function query( string $query ) { + ++$this->query_count; + $this->last_error = 1 === $this->query_count ? 'Deadlock found when trying to get lock' : ''; + + return 1 === $this->query_count ? false : 1; + } + + /** + * Read the record after a successful retry. + * + * @param string $query Query. + * + * @return string + */ + public function get_var( string $query ): string { + return $this->record; + } + }; + + try { + self::assertSame( 1, ( new LoginAttempts() )->increment( $address, $now, MINUTE_IN_SECONDS ) ); + self::assertSame( 2, $wpdb->query_count ); + } finally { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore WordPress's database connection. + $wpdb = $original_wpdb; + } + } + + /** + * Storage mode data provider. + * + * The production store always uses the database as authority. The second + * mode adds a cross-process persistent cache double and verifies that every + * option mutation invalidates it without changing database accounting. + * + * @return array + */ + public function dp_storage_modes(): array { + return [ + 'database-fallback' => [ false ], + 'persistent-cache' => [ true ], + ]; + } + + // phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh -- Bounded process orchestration includes explicit failure cleanup. + /** + * Run independent PHP workers behind one deterministic release barrier. + * + * @param array[] $jobs Worker jobs. + * @param bool $persistent_cache Whether to enable shared cache markers. + * + * @return array{ready: array, results: array, cache_deletions: string[]} + * @throws RuntimeException When the worker infrastructure cannot be created. + */ + private function run_workers( array $jobs, bool $persistent_cache ): array { + global $wpdb; + + // WordPress tests wrap each case in a transaction. Publish fixtures and + // release row locks before independent database connections are started. + self::commit_transaction(); + + $temp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/hcaptcha-login-attempts-' . bin2hex( random_bytes( 8 ) ); + $cache_dir = $temp_dir . '/cache'; + $processes = []; + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Isolated concurrency-test data. + if ( ! mkdir( $temp_dir, 0777, true ) && ! is_dir( $temp_dir ) ) { + throw new RuntimeException( 'Cannot create concurrency-test directory.' ); + } + + if ( $persistent_cache ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Isolated concurrency-test data. + mkdir( $cache_dir ); + + foreach ( array_unique( array_column( $jobs, 'address' ) ) as $address ) { + $marker = $cache_dir . '/' . hash( 'sha256', 'options|' . $this->option_name( $address ) ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Isolated cache marker. + file_put_contents( $marker, 'stale' ); + } + } + + $release_file = $temp_dir . '/release'; + + try { + foreach ( $jobs as $index => $job ) { + $id = (string) ( $job['id'] ?? 'worker-' . $index ); + $interleave = (array) ( $job['interleave'] ?? [] ); + $worker_data = array_merge( + $job, + [ + 'db_host' => DB_HOST, + 'db_name' => DB_NAME, + 'db_user' => DB_USER, + 'db_password' => DB_PASSWORD, + 'options_table' => $wpdb->options, + 'plugin_root' => dirname( __DIR__, 4 ), + 'salt' => wp_salt( 'nonce' ), + 'cache_dir' => $persistent_cache ? $cache_dir : '', + 'ready_file' => $temp_dir . '/ready-' . $id, + 'result_file' => $temp_dir . '/result-' . $id, + 'release_file' => $release_file, + ], + ); + + if ( isset( $interleave['signal'] ) ) { + $interleave['signal_file'] = $temp_dir . '/query-' . $interleave['signal']; + } + + if ( isset( $interleave['wait_for'] ) ) { + $interleave['wait_for'] = array_map( + static fn( string $marker ): string => $temp_dir . '/query-' . $marker, + $interleave['wait_for'] + ); + } + + $worker_data['interleave'] = $interleave; + $config = (string) wp_json_encode( $worker_data ); + $process = new Process( + [ PHP_BINARY, __DIR__ . '/../Helpers/LoginAttemptsWorker.php' ], + dirname( __DIR__, 4 ), + [ 'HCAPTCHA_LOGIN_ATTEMPTS_WORKER' => $config ] + ); + + $process->setTimeout( self::TIMEOUT ); + $process->start(); + $processes[ $id ] = $process; + } + + $this->wait_for_worker_markers( $processes, $temp_dir, 'ready-' ); + + $ready = []; + + foreach ( array_keys( $processes ) as $id ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Isolated worker marker. + $ready[ $id ] = json_decode( (string) file_get_contents( $temp_dir . '/ready-' . $id ), true ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Deterministic release barrier. + file_put_contents( $release_file, 'release' ); + + $results = []; + + foreach ( $processes as $id => $process ) { + $exit_code = $process->wait(); + + self::assertSame( + 0, + $exit_code, + 'Worker ' . $id . " failed:\n" . $process->getErrorOutput() . $process->getOutput() + ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Isolated worker result. + $results[ $id ] = json_decode( (string) file_get_contents( $temp_dir . '/result-' . $id ), true ); + } + + $deletions_file = $cache_dir . '/deletions.log'; + $cache_deletions = is_file( $deletions_file ) + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file -- Isolated cache log. + ? array_filter( array_map( 'trim', file( $deletions_file ) ) ) + : []; + + return [ + 'ready' => $ready, + 'results' => $results, + 'cache_deletions' => $cache_deletions, + ]; + } finally { + foreach ( $processes as $process ) { + if ( $process->isRunning() ) { + $process->stop( 0 ); + } + } + + $this->remove_temp_dir( $temp_dir ); + } + } + // phpcs:enable Generic.Metrics.CyclomaticComplexity.TooHigh + + /** + * Wait for every worker to reach the same release barrier. + * + * @param Process[] $processes Worker processes. + * @param string $temp_dir Temporary directory. + * @param string $prefix Marker prefix. + * + * @return void + */ + private function wait_for_worker_markers( array $processes, string $temp_dir, string $prefix ): void { + $deadline = microtime( true ) + self::TIMEOUT; + + while ( true ) { + $waiting = []; + + foreach ( $processes as $id => $process ) { + if ( is_file( $temp_dir . '/' . $prefix . $id ) ) { + continue; + } + + if ( ! $process->isRunning() ) { + self::fail( 'Worker ' . $id . " exited before the barrier:\n" . $process->getErrorOutput() . $process->getOutput() ); + } + + $waiting[] = $id; + } + + if ( ! $waiting ) { + return; + } + + if ( microtime( true ) >= $deadline ) { + self::fail( 'Workers timed out before the barrier: ' . implode( ', ', $waiting ) ); + } + + usleep( 1000 ); + } + } + + /** + * Seed one raw bounded record. + * + * @param string $address Address. + * @param int $count Failure count. + * @param int $expires Expiration timestamp. + * + * @return void + */ + private function seed_record( string $address, int $count, int $expires ): void { + $hash = hash_hmac( 'sha256', $address, wp_salt( 'nonce' ) ); + + update_option( $this->option_name( $address ), $hash . '|' . $count . '|' . $expires, false ); + } + + /** + * Get the bounded option name for an address. + * + * @param string $address Address. + * + * @return string + */ + private function option_name( string $address ): string { + $hash = hash_hmac( 'sha256', $address, wp_salt( 'nonce' ) ); + $slot = hexdec( substr( $hash, 0, 4 ) ) % LoginAttempts::SLOT_COUNT; + + return LoginAttempts::OPTION_PREFIX . sprintf( '%04d', $slot ); + } + + /** + * Assert the shared cache path was invalidated for every address. + * + * @param array $run Worker run result. + * @param string[] $addresses Addresses mutated by workers. + * @param bool $persistent_cache Whether shared cache mode is enabled. + * + * @return void + */ + private function assert_cache_invalidated( array $run, array $addresses, bool $persistent_cache ): void { + if ( ! $persistent_cache ) { + self::assertSame( [], $run['cache_deletions'] ); + + return; + } + + foreach ( $addresses as $address ) { + self::assertContains( $this->option_name( $address ), $run['cache_deletions'] ); + } + } + + /** + * Remove an isolated worker directory. + * + * @param string $directory Directory path. + * + * @return void + */ + private function remove_temp_dir( string $directory ): void { + if ( ! is_dir( $directory ) ) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $directory, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $iterator as $item ) { + if ( $item->isDir() ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Isolated concurrency-test data. + rmdir( $item->getPathname() ); + } else { + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Isolated concurrency-test data. + unlink( $item->getPathname() ); + } + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Isolated concurrency-test data. + rmdir( $directory ); + } +} diff --git a/tests/php/integration/WP/LoginTest.php b/tests/php/integration/WP/LoginTest.php index d5bfdff89..ee73fae24 100644 --- a/tests/php/integration/WP/LoginTest.php +++ b/tests/php/integration/WP/LoginTest.php @@ -10,6 +10,7 @@ use HCaptcha\Abstracts\LoginBase; use HCaptcha\AutoVerify\AutoVerify; use HCaptcha\Helpers\HCaptcha; +use HCaptcha\Helpers\LoginAttempts; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; use HCaptcha\WP\Login; use ReflectionException; @@ -40,6 +41,7 @@ public function tearDown(): void { $GLOBALS['wp_filters']['login_link_separator'] ); delete_transient( AutoVerify::TRANSIENT ); + LoginAttempts::delete_all(); parent::tearDown(); } @@ -372,27 +374,45 @@ public function test_hide_login_error(): void { * @throws ReflectionException ReflectionException. */ public function test_login(): void { - $ip = '1.1.1.1'; - $login_data[ $ip ][] = time(); - $login_data['2.2.2.2'][] = time(); - $user_login = 'test-user'; - $user = new WP_User(); + $ip = '1.1.1.1'; + $ip2 = '2.2.2.2'; + $now = time(); + $user_login = 'test-user'; + $user = new WP_User(); + + update_option( + 'hcaptcha_settings', + [ + 'login_limit' => 2, + 'login_interval' => 15, + ] + ); + hcaptcha()->init_hooks(); + + $attempts = new LoginAttempts(); + $attempts->increment( $ip, $now, 15 * MINUTE_IN_SECONDS ); + $attempts->increment( $ip2, $now, 15 * MINUTE_IN_SECONDS ); $subject = new Login(); $this->set_protected_property( $subject, 'ip', $ip ); - $this->set_protected_property( $subject, 'login_data', $login_data ); + $this->set_protected_property( + $subject, + 'login_attempts_reset_token', + $attempts->get_reset_token( $ip, $now ) + ); $subject->login( $user_login, $user ); - unset( $login_data[ $ip ] ); + self::assertSame( 0, $attempts->read( $ip, $now ) ); + self::assertSame( 1, $attempts->read( $ip2, $now ) ); - self::assertSame( $login_data, $this->get_protected_property( $subject, 'login_data' ) ); - self::assertSame( $login_data, get_option( LoginBase::LOGIN_DATA ) ); + // Check that login attempt options are not autoloading. + $alloptions = wp_load_alloptions(); - // Check that the hcaptcha_login_data option is not autoloading. - $alloptions = wp_cache_get( 'alloptions', 'options' ); - self::assertArrayNotHasKey( LoginBase::LOGIN_DATA, $alloptions ); + foreach ( array_keys( $alloptions ) as $option_name ) { + self::assertFalse( 0 === strpos( $option_name, LoginAttempts::OPTION_PREFIX ) ); + } } /** @@ -402,37 +422,277 @@ public function test_login(): void { * @throws ReflectionException ReflectionException. */ public function test_login_failed(): void { - $ip = '1.1.1.1'; - $ip2 = '2.2.2.2'; - $time = time(); - $login_interval = 15; - $login_data[ $ip ][] = $time - $login_interval * MINUTE_IN_SECONDS; - $login_data[ $ip ][] = $time - 20; - $login_data[ $ip ][] = $time - 10; - $login_data[ $ip2 ][] = $time - $login_interval * MINUTE_IN_SECONDS - 5; - $login_data[ $ip2 ][] = $time - 25; - $login_data[ $ip2 ][] = $time - 15; - $expected_login_data = $login_data; - $expected_login_data[ $ip ][] = $time; - $username = 'test_username'; - $_SERVER['REMOTE_ADDR'] = $ip; - - array_shift( $expected_login_data[ $ip ] ); - array_shift( $expected_login_data[ $ip2 ] ); - - update_option( 'hcaptcha_settings', [ 'login_interval' => $login_interval ] ); - update_option( LoginBase::LOGIN_DATA, $login_data ); + $ip = '1.1.1.1'; + $time = time(); + $login_interval = 15; + $username = 'test_username'; - $subject = new Login(); + update_option( + 'hcaptcha_settings', + [ + 'login_limit' => 2, + 'login_interval' => $login_interval, + ] + ); + hcaptcha()->init_hooks(); + + $subject = new Login(); + $subject2 = new Login(); $this->set_protected_property( $subject, 'ip', $ip ); + $this->set_protected_property( $subject2, 'ip', $ip ); FunctionMocker::replace( 'time', $time ); $subject->login_failed( $username ); + $subject2->login_failed( $username ); + + $attempts = $this->get_protected_property( $subject, 'login_attempts' ); + $method = $this->set_method_accessibility( $subject, 'is_login_limit_exceeded' ); + + self::assertSame( 2, $attempts->read( $ip, $time ) ); + self::assertTrue( $method->invoke( $subject ) ); + + $attempts->increment( $ip, $time + MINUTE_IN_SECONDS, $login_interval * MINUTE_IN_SECONDS ); + + self::assertSame( 3, $attempts->read( $ip, $time + $login_interval * MINUTE_IN_SECONDS ) ); + self::assertSame( 0, $attempts->read( $ip, $time + ( $login_interval + 1 ) * MINUTE_IN_SECONDS ) ); + + $method->setAccessible( false ); + } + + /** + * Test that shared wp_login_failed hooks count one authentication failure once. + */ + public function test_login_failed_is_deduplicated_across_integrations(): void { + $ip = '192.0.2.80'; + $wp_login_failed_hook = $GLOBALS['wp_filter']['wp_login_failed'] ?? null; + + remove_all_actions( 'wp_login_failed' ); + + update_option( + 'hcaptcha_settings', + [ + 'login_limit' => 2, + 'login_interval' => 15, + ] + ); + hcaptcha()->init_hooks(); + + $subject = new Login(); + $subject2 = new Login(); + + $this->set_protected_property( $subject, 'ip', $ip ); + $this->set_protected_property( $subject2, 'ip', $ip ); + + do_action( 'wp_login_failed', 'test-user' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $GLOBALS['wp_filter']['wp_login_failed'] = $wp_login_failed_hook; + + $attempts = $this->get_protected_property( $subject, 'login_attempts' ); + + self::assertSame( 1, $attempts->read( $ip, time() ) ); + } + + /** + * Test immediate-CAPTCHA mode does not allocate attempt state. + */ + public function test_login_limit_zero_does_not_store_failures(): void { + global $wpdb; + + update_option( + 'hcaptcha_settings', + [ + 'login_limit' => 0, + 'login_interval' => 15, + ] + ); + hcaptcha()->init_hooks(); + + $subject = new Login(); + $subject->login_failed( 'test-user' ); + + $method = $this->set_method_accessibility( $subject, 'is_login_limit_exceeded' ); + + self::assertTrue( $method->invoke( $subject ) ); + self::assertSame( + 0, + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM $wpdb->options WHERE option_name LIKE %s", + $wpdb->esc_like( LoginAttempts::OPTION_PREFIX ) . '%' + ) + ) + ); + + $method->setAccessible( false ); + } + + /** + * Test one-time bounded retirement of legacy login data. + */ + public function test_legacy_login_data_retirement_is_bounded_and_one_time(): void { + global $wpdb; + + $legacy_data = []; + + for ( $i = 0; $i < 5000; ++$i ) { + $legacy_data[ '192.0.2.' . $i ] = 0 === $i % 2 ? [] : [ time() - YEAR_IN_SECONDS ]; + } + + update_option( LoginBase::LOGIN_DATA, $legacy_data, false ); + + $queries = []; + $filter = static function ( $query ) use ( &$queries ) { + $queries[] = $query; + + return $query; + }; + + add_filter( 'query', $filter ); + + new Login(); + new Login(); + + remove_filter( 'query', $filter ); + + $legacy_selects = array_filter( + $queries, + static function ( $query ) { + return false !== stripos( $query, 'SELECT option_value' ) && + false !== stripos( $query, "option_name = 'hcaptcha_login_data'" ); + } + ); + $legacy_deletes = array_filter( + $queries, + static function ( $query ) { + return false !== stripos( $query, 'DELETE FROM' ) && + false !== stripos( $query, "option_name = 'hcaptcha_login_data'" ); + } + ); + + self::assertSame( [], array_values( $legacy_selects ) ); + self::assertCount( 1, $legacy_deletes ); + self::assertFalse( get_option( LoginBase::LOGIN_DATA, false ) ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + self::assertSame( '1', $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM $wpdb->options WHERE option_name = %s", LoginAttempts::RETIREMENT_OPTION ) ) ); + } + + /** + * Test that a full store stays bounded and fails closed at constant cost. + */ + public function test_login_attempt_store_is_globally_bounded(): void { + global $wpdb; + + $now = time(); + $owner_hash = str_repeat( 'a', 64 ); + $record = $owner_hash . '|1|' . ( $now + HOUR_IN_SECONDS ); + $values = []; + $params = []; + + for ( $slot = 0; $slot < LoginAttempts::SLOT_COUNT; ++$slot ) { + $values[] = '(%s, %s, %s)'; + $params[] = LoginAttempts::OPTION_PREFIX . sprintf( '%04d', $slot ); + $params[] = $record; + $params[] = 'no'; + } + + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( + $wpdb->prepare( + "INSERT INTO $wpdb->options (option_name, option_value, autoload) VALUES " . implode( ', ', $values ), + $params + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + + update_option( + 'hcaptcha_settings', + [ + 'login_limit' => 2, + 'login_interval' => 15, + ] + ); + hcaptcha()->init_hooks(); + + $subject = new Login(); + $this->set_protected_property( $subject, 'ip', '198.51.100.25' ); + + $operation_queries = []; + $filter = static function ( $query ) use ( &$operation_queries ) { + if ( false !== strpos( $query, LoginAttempts::OPTION_PREFIX ) ) { + $operation_queries[] = $query; + } + + return $query; + }; + + add_filter( 'query', $filter ); + $subject->login_failed( 'test-user' ); + remove_filter( 'query', $filter ); + + $method = $this->set_method_accessibility( $subject, 'is_login_limit_exceeded' ); + + self::assertCount( 2, $operation_queries ); + self::assertTrue( $method->invoke( $subject ) ); + self::assertSame( + LoginAttempts::SLOT_COUNT, + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM $wpdb->options WHERE option_name LIKE %s", + $wpdb->esc_like( LoginAttempts::OPTION_PREFIX ) . '%' + ) + ) + ); + self::assertLessThanOrEqual( + LoginAttempts::MAX_RECORD_BYTES, + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT MAX(OCTET_LENGTH(option_value)) FROM $wpdb->options WHERE option_name LIKE %s", + $wpdb->esc_like( LoginAttempts::OPTION_PREFIX ) . '%' + ) + ) + ); + + $method->setAccessible( false ); + } + + /** + * Test that the per-address counter saturates without growing its record. + */ + public function test_login_attempt_count_is_saturated(): void { + $ip = '198.51.100.80'; + $now = time(); + $address_hash = hash_hmac( 'sha256', $ip, wp_salt( 'nonce' ) ); + $slot = hexdec( substr( $address_hash, 0, 4 ) ) % LoginAttempts::SLOT_COUNT; + $option_name = LoginAttempts::OPTION_PREFIX . sprintf( '%04d', $slot ); + $expires = $now + MINUTE_IN_SECONDS; + $record = $address_hash . '|' . LoginAttempts::MAX_FAILURES . '|' . $expires; + $attempts = new LoginAttempts(); + + update_option( $option_name, $record, false ); + + $operation_queries = []; + $filter = static function ( $query ) use ( &$operation_queries, $option_name ) { + if ( false !== strpos( $query, $option_name ) ) { + $operation_queries[] = $query; + } + + return $query; + }; + + add_filter( 'query', $filter ); + $count = $attempts->increment( $ip, $now, MINUTE_IN_SECONDS ); + remove_filter( 'query', $filter ); - self::assertSame( $expected_login_data, $this->get_protected_property( $subject, 'login_data' ) ); - self::assertSame( $expected_login_data, get_option( LoginBase::LOGIN_DATA ) ); + self::assertSame( LoginAttempts::MAX_FAILURES, $count ); + self::assertCount( 2, $operation_queries ); + self::assertLessThanOrEqual( LoginAttempts::MAX_RECORD_BYTES, strlen( get_option( $option_name ) ) ); } /** diff --git a/tests/php/integration/WP/RegisterTest.php b/tests/php/integration/WP/RegisterTest.php index 70cd62179..c3338f78d 100644 --- a/tests/php/integration/WP/RegisterTest.php +++ b/tests/php/integration/WP/RegisterTest.php @@ -159,16 +159,20 @@ public function test_verify(): void { * Test verify() when the register action is submitted in POST. */ public function test_verify_with_post_action(): void { - $_POST['action'] = 'register'; + $_POST['action'] = 'register'; + $_POST['Ваше мнение'] = 'Useful feedback'; + $_POST['pass1'] = 'do-not-copy'; $this->prepare_widget_id(); $verify_called = false; + $entry_data = []; $errors = new WP_Error( 'some error' ); FunctionMocker::replace( 'HCaptcha\Helpers\API::verify', - static function () use ( &$verify_called ) { + static function ( array $entry ) use ( &$verify_called, &$entry_data ) { $verify_called = true; + $entry_data = $entry['data']; return null; } @@ -178,6 +182,8 @@ static function () use ( &$verify_called ) { self::assertSame( $errors, $subject->verify( $errors, '', '' ) ); self::assertTrue( $verify_called ); + self::assertSame( 'Useful feedback', $entry_data['Ваше мнение'] ); + self::assertArrayNotHasKey( 'pass1', $entry_data ); } /** diff --git a/tests/php/integration/WPDiscuz/CommentTest.php b/tests/php/integration/WPDiscuz/CommentTest.php index 1e688783d..e0701d461 100644 --- a/tests/php/integration/WPDiscuz/CommentTest.php +++ b/tests/php/integration/WPDiscuz/CommentTest.php @@ -38,6 +38,11 @@ class CommentTest extends HCaptchaWPTestCase { public function setUp(): void { parent::setUp(); + hcaptcha()->settings()->set( 'honeypot', 'on' ); + hcaptcha()->settings()->set( 'set_min_submit_time', 'on' ); + hcaptcha()->settings()->set( 'wpdiscuz_status', 'comment_form' ); + $this->set_protected_property( hcaptcha(), 'supported_forms', null ); + $options = Mockery::mock( 'WpdiscuzOptions' ); $options->recaptcha = [ 'siteKey' => 'some site key', @@ -104,6 +109,7 @@ static function ( $function_name ) { * Test block_recaptcha(). * * @return void + * @noinspection PhpUndefinedFieldInspection */ public function test_block_recaptcha(): void { // Ensure initial values come from setUp(). @@ -179,6 +185,7 @@ public function test_enqueue_scripts(): void { self::assertFalse( wp_script_is( 'wpdiscuz-google-recaptcha', 'registered' ) ); self::assertFalse( wp_script_is( 'wpdiscuz-google-recaptcha' ) ); + self::assertTrue( wp_script_is( 'hcaptcha-wpdiscuz-comment' ) ); } /** @@ -204,8 +211,11 @@ public function test_add_captcha(): void { ' . '

Submit
'; $subject = new Comment(); + $result = $subject->add_hcaptcha( $output, 0, false ); - self::assertSame( $expected, $subject->add_hcaptcha( $output, 0, false ) ); + self::assertSame( $expected, $result ); + self::assertStringContainsString( 'name="hcap_hp_test"', $result ); + self::assertStringContainsString( 'name="hcap_hp_sig"', $result ); } /** @@ -296,6 +306,36 @@ static function ( $name ) use ( &$die_arr ) { self::assertFalse( has_filter( 'preprocess_comment', [ $this->wp_discuz, 'validateRecaptcha' ] ) ); } + /** + * Test a filled honeypot blocks comment processing. + * + * @return void + */ + public function test_filled_honeypot_is_rejected(): void { + $comment_data = [ 'some comment data' ]; + $hcaptcha_response = 'some response'; + $die_arr = []; + + $_POST['action'] = 'wpdAddComment'; + + add_filter( 'wp_doing_ajax', '__return_true' ); + $this->prepare_verify_request( $hcaptcha_response ); + $_POST['hcap_hp_test'] = 'bot'; + + add_filter( + 'wp_die_ajax_handler', + static function () use ( &$die_arr ) { + return static function ( $message, $title, $args ) use ( &$die_arr ) { + $die_arr = [ $message, $title, $args ]; + }; + } + ); + + ( new Comment() )->verify( $comment_data ); + + self::assertSame( [ 'Anti-spam check failed.', '', [] ], $die_arr ); + } + /** * Test print_inline_styles(). * diff --git a/tests/php/integration/WPDiscuz/SubscribeTest.php b/tests/php/integration/WPDiscuz/SubscribeTest.php index 599a40f44..0136df134 100644 --- a/tests/php/integration/WPDiscuz/SubscribeTest.php +++ b/tests/php/integration/WPDiscuz/SubscribeTest.php @@ -39,6 +39,11 @@ class SubscribeTest extends HCaptchaWPTestCase { public function setUp(): void { parent::setUp(); + hcaptcha()->settings()->set( 'honeypot', 'on' ); + hcaptcha()->settings()->set( 'set_min_submit_time', 'on' ); + hcaptcha()->settings()->set( 'wpdiscuz_status', 'subscribe_form' ); + $this->set_protected_property( hcaptcha(), 'supported_forms', null ); + $options = Mockery::mock( 'WpdiscuzOptions' ); $options->recaptcha = [ 'siteKey' => 'some site key', @@ -110,6 +115,7 @@ public function test_enqueue_scripts(): void { self::assertFalse( wp_script_is( 'wpdiscuz-google-recaptcha', 'registered' ) ); self::assertFalse( wp_script_is( 'wpdiscuz-google-recaptcha' ) ); + self::assertTrue( wp_script_is( 'hcaptcha-wpdiscuz-comment' ) ); } /** @@ -132,7 +138,11 @@ public function test_add_hcaptcha(): void { $subject->add_hcaptcha(); - self::assertSame( $expected, ob_get_clean() ); + $output = (string) ob_get_clean(); + + self::assertSame( $expected, $output ); + self::assertStringContainsString( 'name="hcap_hp_test"', $output ); + self::assertStringContainsString( 'name="hcap_hp_sig"', $output ); } /** diff --git a/tests/php/integration/WPForms/FormTest.php b/tests/php/integration/WPForms/FormTest.php index 775bb9b11..209c027d8 100644 --- a/tests/php/integration/WPForms/FormTest.php +++ b/tests/php/integration/WPForms/FormTest.php @@ -194,6 +194,58 @@ public function test_verify(): void { self::assertSame( [], wpforms()->obj( 'process' )->errors ); } + /** + * Test that custom fields with the same type and label stay in the entry. + * + * @return void + */ + public function test_get_entry_preserves_custom_fields(): void { + $subject = new Form(); + $method = $this->set_method_accessibility( $subject, 'get_entry' ); + $entry = $method->invoke( + $subject, + [ + 'id' => '5', + 'fields' => [ + 1 => 'First opinion', + 2 => 'Second opinion', + 3 => 'private-password', + 4 => [ 'Red', 'Blue' ], + ], + ], + [ + 'id' => 5, + 'fields' => [ + 1 => [ + 'type' => 'textarea', + 'label' => 'Ваше мнение', + ], + 2 => [ + 'type' => 'textarea', + 'label' => 'Ваше мнение', + ], + 3 => [ + 'type' => 'password', + 'label' => 'Other', + ], + 4 => [ + 'type' => 'checkbox', + 'label' => 'Preferences', + ], + ], + ] + ); + + self::assertSame( + [ + 'Ваше мнение' => 'First opinion', + 'Ваше мнение [2]' => 'Second opinion', + 'Preferences' => [ 'Red', 'Blue' ], + ], + $entry['data'] + ); + } + /** * Test verify() when not process hcaptcha. * diff --git a/tests/php/integration/WPForo/NewTopicTest.php b/tests/php/integration/WPForo/NewTopicTest.php index 2fd2c294f..6acbf7357 100644 --- a/tests/php/integration/WPForo/NewTopicTest.php +++ b/tests/php/integration/WPForo/NewTopicTest.php @@ -14,6 +14,7 @@ namespace HCaptcha\Tests\Integration\WPForo; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use HCaptcha\WPForo\NewTopic; use tad\FunctionMocker\FunctionMocker; @@ -43,6 +44,11 @@ public function setUp(): void { parent::setUp(); + hcaptcha()->settings()->set( 'honeypot', 'on' ); + hcaptcha()->settings()->set( 'set_min_submit_time', 'on' ); + hcaptcha()->settings()->set( 'wpforo_status', [ 'new_topic', 'reply' ] ); + $this->set_protected_property( hcaptcha(), 'supported_forms', null ); + WPF()->notice = new Notices(); } @@ -80,7 +86,11 @@ public function test_add_captcha(): void { do_action( NewTopic::ADD_CAPTCHA_HOOK, $topic ); - self::assertSame( $expected, ob_get_clean() ); + $output = (string) ob_get_clean(); + + self::assertSame( $expected, $output ); + self::assertStringContainsString( 'name="hcap_hp_test"', $output ); + self::assertStringContainsString( 'name="hcap_hp_sig"', $output ); } /** @@ -120,6 +130,33 @@ public function test_verify_not_verified(): void { self::assertSame( $expected, WPF()->notice->get_notices() ); } + /** + * Test verify() with a filled honeypot. + */ + public function test_verify_filled_honeypot(): void { + $subject = new NewTopic(); + + $this->prepare_verify_post( 'hcaptcha_wpforo_new_topic_nonce', 'hcaptcha_wpforo_new_topic' ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'wpforo/wpforo.php' ], + 'form_id' => 'new_topic', + ] + ); + $_POST['hcap_hp_test'] = 'bot'; + + FunctionMocker::replace( 'wpforo_is_ajax', true ); + + WPF()->session_token = '23'; + + self::assertFalse( $subject->verify( [] ) ); + + WPF()->session_token = ''; + + self::assertSame( '

Anti-spam check failed.

', WPF()->notice->get_notices() ); + } + /** * Test print_hcaptcha_scripts(). * diff --git a/tests/php/integration/WPForo/ReplyTest.php b/tests/php/integration/WPForo/ReplyTest.php index c1ee252c3..f27d780e1 100644 --- a/tests/php/integration/WPForo/ReplyTest.php +++ b/tests/php/integration/WPForo/ReplyTest.php @@ -13,6 +13,7 @@ namespace HCaptcha\Tests\Integration\WPForo; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use HCaptcha\WPForo\Reply; use tad\FunctionMocker\FunctionMocker; @@ -42,6 +43,11 @@ public function setUp(): void { parent::setUp(); + hcaptcha()->settings()->set( 'honeypot', 'on' ); + hcaptcha()->settings()->set( 'set_min_submit_time', 'on' ); + hcaptcha()->settings()->set( 'wpforo_status', [ 'new_topic', 'reply' ] ); + $this->set_protected_property( hcaptcha(), 'supported_forms', null ); + WPF()->notice = new Notices(); } @@ -84,7 +90,11 @@ public function test_add_captcha(): void { do_action( Reply::ADD_CAPTCHA_HOOK, $topic ); - self::assertSame( $expected, ob_get_clean() ); + $output = (string) ob_get_clean(); + + self::assertSame( $expected, $output ); + self::assertStringContainsString( 'name="hcap_hp_test"', $output ); + self::assertStringContainsString( 'name="hcap_hp_sig"', $output ); } /** @@ -125,4 +135,33 @@ public function test_verify_not_verified(): void { self::assertSame( $expected, WPF()->notice->get_notices() ); } + + /** + * Test verify() with a filled honeypot. + * + * @noinspection PhpUndefinedFunctionInspection + */ + public function test_verify_filled_honeypot(): void { + $subject = new Reply(); + + $this->prepare_verify_post( 'hcaptcha_wpforo_reply_nonce', 'hcaptcha_wpforo_reply' ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'wpforo/wpforo.php' ], + 'form_id' => 21, + ] + ); + $_POST['hcap_hp_test'] = 'bot'; + + FunctionMocker::replace( 'wpforo_is_ajax', true ); + + WPF()->session_token = '23'; + + self::assertFalse( $subject->verify( [] ) ); + + WPF()->session_token = ''; + + self::assertSame( '

Anti-spam check failed.

', WPF()->notice->get_notices() ); + } } diff --git a/tests/php/integration/WPJobOpenings/FormTest.php b/tests/php/integration/WPJobOpenings/FormTest.php index 06e298ef4..624dc3163 100644 --- a/tests/php/integration/WPJobOpenings/FormTest.php +++ b/tests/php/integration/WPJobOpenings/FormTest.php @@ -12,8 +12,10 @@ namespace HCaptcha\Tests\Integration\WPJobOpenings; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; use HCaptcha\WPJobOpenings\Form; +use ReflectionException; /** * Test FormTest class. @@ -21,6 +23,20 @@ * @group job-openings */ class FormTest extends HCaptchaWPTestCase { + /** + * Set up the test. + * + * @return void + * @throws ReflectionException ReflectionException. + */ + public function setUp(): void { + parent::setUp(); + + hcaptcha()->settings()->set( 'honeypot', 'on' ); + hcaptcha()->settings()->set( 'set_min_submit_time', 'on' ); + hcaptcha()->settings()->set( 'wp_job_openings_status', 'form' ); + $this->set_protected_property( hcaptcha(), 'supported_forms', null ); + } /** * Tear down the test. @@ -29,6 +45,8 @@ class FormTest extends HCaptchaWPTestCase { */ public function tearDown(): void { unset( $GLOBALS['awsm_response'] ); + + parent::tearDown(); } /** @@ -75,7 +93,12 @@ public function test_add_captcha(): void { echo $html; $subject->add_captcha( $form_attrs ); - self::assertSame( $expected, ob_get_clean() ); + $output = (string) ob_get_clean(); + + self::assertSame( $expected, $output ); + self::assertStringContainsString( 'name="hcap_hp_test"', $output ); + self::assertStringContainsString( 'name="hcap_hp_sig"', $output ); + self::assertTrue( wp_script_is( 'hcaptcha-wp-job-openings' ) ); } /** @@ -115,4 +138,29 @@ public function test_verify_not_verified(): void { self::assertSame( [ 'error' => [ 'The hCaptcha is invalid.' ] ], $awsm_response ); } + + /** + * Test verify() with a filled honeypot. + * + * @return void + */ + public function test_verify_filled_honeypot(): void { + global $awsm_response; + + $awsm_response = []; + + $this->prepare_verify_post( Form::NONCE, Form::ACTION ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'wp-job-openings/wp-job-openings.php' ], + 'form_id' => 5, + ] + ); + $_POST['hcap_hp_test'] = 'bot'; + + ( new Form() )->verify(); + + self::assertSame( [ 'error' => [ 'Anti-spam check failed.' ] ], $awsm_response ); + } } diff --git a/tests/php/integration/function-mocker-bootstrap.php b/tests/php/integration/function-mocker-bootstrap.php index f6defed72..de40f94dd 100644 --- a/tests/php/integration/function-mocker-bootstrap.php +++ b/tests/php/integration/function-mocker-bootstrap.php @@ -82,6 +82,10 @@ function hcaptcha_get_function_mocker_external_plugin_whitelist( string $wp_root $paths[] = $wp_root_path . '/wp-content/plugins/' . $plugin_slug; } + $paths[] = $wp_root_path . '/wp-content/plugins/learnpress/inc/class-lp-checkout.php'; + $paths[] = $wp_root_path . '/wp-content/plugins/learnpress/inc/lp-core-functions.php'; + $paths[] = $wp_root_path . '/wp-content/plugins/woocommerce/includes/wc-notice-functions.php'; + return $paths; } diff --git a/tests/php/integration/includes/FunctionsTest.php b/tests/php/integration/includes/FunctionsTest.php index 38f6736be..42af12274 100644 --- a/tests/php/integration/includes/FunctionsTest.php +++ b/tests/php/integration/includes/FunctionsTest.php @@ -79,6 +79,23 @@ static function ( $hcaptcha_content ) use ( $filtered ) { self::assertSame( $expected, do_shortcode( $shortcode ) ); } + /** + * Keep the ajax shortcode shorthand without coupling the form arguments. + */ + public function test_ajax_shortcode_auto_verification_compatibility(): void { + hcaptcha()->init_hooks(); + + $implicit = do_shortcode( '[hcaptcha ajax="true"]' ); + + self::assertStringContainsString( 'data-ajax="true"', $implicit ); + self::assertStringContainsString( 'data-auto="true"', $implicit ); + + $explicit = do_shortcode( '[hcaptcha ajax="true" auto="false"]' ); + + self::assertStringContainsString( 'data-ajax="true"', $explicit ); + self::assertStringContainsString( 'data-auto="false"', $explicit ); + } + /** * Data provider for test_hcap_shortcode(). * diff --git a/tests/php/integration/includes/RequestTest.php b/tests/php/integration/includes/RequestTest.php index bb9539602..69665d6fc 100644 --- a/tests/php/integration/includes/RequestTest.php +++ b/tests/php/integration/includes/RequestTest.php @@ -7,6 +7,7 @@ namespace HCaptcha\Tests\Integration\includes; +use HCaptcha\Main; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; /** @@ -213,15 +214,44 @@ public function test_hcap_get_user_ip_with_trusted_address_headers_filter(): voi } /** - * Test hcap_get_user_ip() preserves legacy headers before migration. + * Test hcap_get_user_ip() uses REMOTE_ADDR before migration. * * @return void */ - public function test_hcap_get_user_ip_preserves_legacy_headers_before_migration(): void { + public function test_hcap_get_user_ip_uses_remote_addr_before_migration(): void { update_option( 'hcaptcha_settings', [ 'site_key' => 'some key' ] ); update_option( 'hcaptcha_versions', [ '4.26.0' => time() ] ); hcaptcha()->settings()->init(); + foreach ( hcap_get_address_headers() as $header ) { + $this->set_server_headers( + [ + $header => '7.7.7.15', + 'REMOTE_ADDR' => '7.7.7.16', + ] + ); + + self::assertSame( '7.7.7.16', hcap_get_user_ip(), $header ); + } + } + + /** + * Test a forged address header cannot deactivate hCaptcha before migration. + * + * @return void + * @throws \ReflectionException Reflection exception. + */ + public function test_forged_address_header_cannot_deactivate_hcaptcha_before_migration(): void { + update_option( + 'hcaptcha_settings', + [ + 'site_key' => 'some key', + 'secret_key' => 'some secret', + 'whitelisted_ips' => '7.7.7.15', + ] + ); + update_option( 'hcaptcha_versions', [ '4.26.0' => time() ] ); + $this->set_server_headers( [ 'HTTP_X_FORWARDED_FOR' => '7.7.7.15', @@ -229,7 +259,11 @@ public function test_hcap_get_user_ip_preserves_legacy_headers_before_migration( ] ); - self::assertSame( '7.7.7.15', hcap_get_user_ip() ); + $subject = new Main(); + $subject->init_hooks(); + + self::assertSame( '7.7.7.16', hcap_get_user_ip( false ) ); + self::assertTrue( $this->get_protected_property( $subject, 'active' ) ); } /** diff --git a/tests/php/multisite.suite.yml b/tests/php/multisite.suite.yml new file mode 100644 index 000000000..74399bcf1 --- /dev/null +++ b/tests/php/multisite.suite.yml @@ -0,0 +1,19 @@ +bootstrap: ./_bootstrap.php +modules: + enabled: + - WPLoader + config: + WPLoader: + wpRootFolder: '%WP_ROOT_PATH%' + WPMU_PLUGIN_DIR: '%WP_ROOT_PATH%/_empty_mu_plugins' + WP_PLUGIN_DIR: '%PLUGIN_ROOT_PATH%' + dbName: '%DB_NAME%' + dbHost: '%DB_HOST%' + dbUser: '%DB_USER%' + dbPassword: '%DB_PASSWORD%' + wpDebug: true + tablePrefix: '%DB_TABLE_PREFIX%' + domain: '%WP_URL%' + multisite: true + plugins: + - hcaptcha.php diff --git a/tests/php/multisite/MainTest.php b/tests/php/multisite/MainTest.php new file mode 100644 index 000000000..a0f00ca65 --- /dev/null +++ b/tests/php/multisite/MainTest.php @@ -0,0 +1,49 @@ +setAccessible( true ); + $active->setValue( $subject, false ); + $subject->load_modules(); + + self::assertSame( [ [ 'wp_status', 'signup' ], '', Signup::class ], $subject->modules['Signup Form'] ); + self::assertSame( + [ [ 'theme_my_login_status', 'signup' ], 'theme-my-login/theme-my-login.php', \HCaptcha\ThemeMyLogin\Signup::class ], + $subject->modules['Theme My Login Signup'] + ); + self::assertArrayNotHasKey( 'Theme My Login Register', $subject->modules ); + } +} diff --git a/tests/php/multisite/Settings/NetworkSettingsAuthorizationTest.php b/tests/php/multisite/Settings/NetworkSettingsAuthorizationTest.php new file mode 100644 index 000000000..3e5527ec2 --- /dev/null +++ b/tests/php/multisite/Settings/NetworkSettingsAuthorizationTest.php @@ -0,0 +1,689 @@ +settings(); + + self::assertNotNull( $settings ); + + $this->general = $settings->get_tab( General::class ); + $this->tools = $settings->get_tab( Tools::class ); + + self::assertInstanceOf( General::class, $this->general ); + self::assertInstanceOf( Tools::class, $this->tools ); + + add_action( 'wp_ajax_' . Tools::EXPORT_ACTION, [ $this->tools, 'ajax_handle_export' ] ); + + delete_site_option( PluginSettingsBase::OPTION_NAME ); + delete_site_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE ); + delete_option( PluginSettingsBase::OPTION_NAME ); + } + + /** + * Tear down test. + */ + public function tearDown(): void { + if ( $this->tools ) { + remove_action( 'wp_ajax_' . Tools::EXPORT_ACTION, [ $this->tools, 'ajax_handle_export' ] ); + } + + while ( ms_is_switched() ) { + restore_current_blog(); + } + + foreach ( $this->super_admins as $user_id ) { + revoke_super_admin( $user_id ); + } + + delete_site_option( PluginSettingsBase::OPTION_NAME ); + delete_site_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE ); + delete_option( PluginSettingsBase::OPTION_NAME ); + wp_set_current_user( 0 ); + + parent::tearDown(); + } + + /** + * Test that a main-site administrator cannot access active network settings. + */ + public function test_main_site_administrator_cannot_render_or_export_network_settings(): void { + $user_id = $this->create_main_site_administrator(); + + update_option( + PluginSettingsBase::OPTION_NAME, + [ + 'site_key' => 'site-local-key', + 'secret_key' => 'site-local-secret', + 'theme' => 'light', + ] + ); + + $nonce = wp_create_nonce( Tools::EXPORT_ACTION ); + $site_export = $this->export_settings( $nonce, true ); + + self::assertSame( $user_id, get_current_user_id() ); + self::assertSame( 'site-local-key', $site_export['keys']['site_key'] ?? null ); + self::assertSame( 'site-local-secret', $site_export['keys']['secret_key'] ?? null ); + + $this->activate_network_settings(); + + self::assertNotFalse( wp_verify_nonce( $nonce, Tools::EXPORT_ACTION ) ); + $this->assert_page_access_denied(); + $this->assert_export_access_denied( $nonce, false ); + $this->assert_export_access_denied( $nonce, true ); + $this->assert_import_access_denied(); + self::assertFalse( ( new Abilities() )->can_export_settings() ); + self::assertFalse( ( new Abilities() )->can_import_settings() ); + } + + /** + * Test that a secondary-site administrator receives no broader network access. + */ + public function test_secondary_site_administrator_cannot_render_or_export_network_settings(): void { + $blog_id = self::factory()->blog->create(); + $user_id = self::factory()->user->create( [ 'role' => 'subscriber' ] ); + $added = add_user_to_blog( $blog_id, $user_id, 'administrator' ); + + self::assertTrue( $added ); + + switch_to_blog( $blog_id ); + wp_set_current_user( 0 ); + wp_set_current_user( $user_id ); + + self::assertTrue( current_user_can( 'manage_options' ) ); + self::assertFalse( current_user_can( 'manage_network_options' ) ); + + $this->activate_network_settings(); + + $nonce = wp_create_nonce( Tools::EXPORT_ACTION ); + + $this->assert_page_access_denied(); + $this->assert_export_access_denied( $nonce, false ); + $this->assert_export_access_denied( $nonce, true ); + $this->assert_import_access_denied(); + self::assertFalse( ( new Abilities() )->can_export_settings() ); + self::assertFalse( ( new Abilities() )->can_import_settings() ); + } + + /** + * Test that a network administrator can render and export network settings. + */ + public function test_network_administrator_can_render_and_export_network_settings(): void { + $this->create_network_administrator(); + $this->activate_network_settings(); + + ob_start(); + $this->general->settings_base_page(); + $page = (string) ob_get_clean(); + + self::assertStringContainsString( 'render_secret_field(); + + self::assertStringContainsString( 'id="secret_key"', $secret_field ); + self::assertStringContainsString( 'value=""', $secret_field ); + self::assertStringNotContainsString( self::NETWORK_SECRET_KEY, $secret_field ); + + $nonce = wp_create_nonce( Tools::EXPORT_ACTION ); + $export_without_keys = $this->export_settings( $nonce, false ); + $export_with_keys = $this->export_settings( $nonce, true ); + + self::assertArrayNotHasKey( 'keys', $export_without_keys ); + self::assertStringNotContainsString( self::NETWORK_SECRET_KEY, (string) wp_json_encode( $export_without_keys ) ); + self::assertSame( self::NETWORK_SITE_KEY, $export_with_keys['keys']['site_key'] ?? null ); + self::assertSame( self::NETWORK_SECRET_KEY, $export_with_keys['keys']['secret_key'] ?? null ); + self::assertTrue( ( new Abilities() )->can_export_settings() ); + self::assertTrue( ( new Abilities() )->can_import_settings() ); + } + + /** + * Test that a blank network secret field preserves the stored secret. + */ + public function test_blank_sensitive_field_preserves_network_secret(): void { + $this->create_network_administrator(); + $this->activate_network_settings(); + + update_option( PluginSettingsBase::OPTION_NAME, [ 'theme' => 'site-local-theme' ] ); + add_filter( + 'pre_update_option_' . PluginSettingsBase::OPTION_NAME, + [ $this->general, 'pre_update_option_filter' ], + 10, + 2 + ); + + update_option( + PluginSettingsBase::OPTION_NAME, + [ + 'site_key' => self::NETWORK_SITE_KEY, + 'secret_key' => '', + 'theme' => 'dark', + SettingsBase::NETWORK_WIDE => [ 'on' ], + ] + ); + + $network_settings = get_site_option( PluginSettingsBase::OPTION_NAME, [] ); + + self::assertSame( self::NETWORK_SECRET_KEY, $network_settings['secret_key'] ?? null ); + self::assertSame( 'dark', $network_settings['theme'] ?? null ); + + update_option( + PluginSettingsBase::OPTION_NAME, + [ + 'site_key' => self::NETWORK_SITE_KEY, + 'secret_key' => 'replacement-network-secret', + 'theme' => 'dark', + SettingsBase::NETWORK_WIDE => [ 'on' ], + ] + ); + + $network_settings = get_site_option( PluginSettingsBase::OPTION_NAME, [] ); + + self::assertSame( 'replacement-network-secret', $network_settings['secret_key'] ?? null ); + self::assertSame( [ 'theme' => 'site-local-theme' ], get_option( PluginSettingsBase::OPTION_NAME ) ); + } + + /** + * Test that onboarding cannot copy network credentials into site-local settings. + */ + public function test_onboarding_cannot_copy_network_settings_to_site_option(): void { + $this->create_main_site_administrator(); + + $nonce = wp_create_nonce( OnboardingWizard::UPDATE_ACTION ); + + $this->activate_network_settings(); + add_filter( + 'pre_update_option_' . PluginSettingsBase::OPTION_NAME, + [ $this->general, 'pre_update_option_filter' ], + 10, + 2 + ); + + $network_settings = get_site_option( PluginSettingsBase::OPTION_NAME ); + $site_settings = get_option( PluginSettingsBase::OPTION_NAME ); + $_POST = [ + 'nonce' => $nonce, + 'value' => 'completed', + ]; + $did_die = false; + + self::assertNotFalse( wp_verify_nonce( $nonce, OnboardingWizard::UPDATE_ACTION ) ); + + try { + $this->_handleAjax( OnboardingWizard::UPDATE_ACTION ); + } catch ( WPAjaxDieContinueException $exception ) { + $did_die = true; + } + + self::assertTrue( $did_die ); + self::assertSame( $site_settings, get_option( PluginSettingsBase::OPTION_NAME ) ); + self::assertSame( $network_settings, get_site_option( PluginSettingsBase::OPTION_NAME ) ); + self::assertSame( [ 'on' ], get_site_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE ) ); + self::assertSame( + [ + 'success' => false, + 'data' => 'You are not allowed to perform this action.', + ], + json_decode( $this->_last_response, true ) + ); + } + + /** + * Test that automatic onboarding initialization cannot disclose network credentials. + */ + public function test_onboarding_initialization_preserves_network_ownership(): void { + $this->create_main_site_administrator(); + $this->activate_network_settings(); + add_filter( + 'pre_update_option_' . PluginSettingsBase::OPTION_NAME, + [ $this->general, 'pre_update_option_filter' ], + 10, + 2 + ); + + $network_settings = get_site_option( PluginSettingsBase::OPTION_NAME ); + $site_settings = get_option( PluginSettingsBase::OPTION_NAME ); + + ( new OnboardingWizard( $this->general ) )->init(); + + self::assertSame( $site_settings, get_option( PluginSettingsBase::OPTION_NAME ) ); + self::assertSame( $network_settings, get_site_option( PluginSettingsBase::OPTION_NAME ) ); + self::assertSame( [ 'on' ], get_site_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE ) ); + } + + /** + * Test that onboarding GET requests use the current settings ownership. + * + * @param string $action Nonce action. + * + * @dataProvider dp_test_onboarding_request_checks_network_ownership + */ + public function test_onboarding_request_checks_network_ownership( string $action ): void { + $user_id = $this->create_main_site_administrator(); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Preserve the test request. + $original_get = $_GET; + $nonce = wp_create_nonce( $action ); + + try { + $_GET[ OnboardingWizard::NONCE_PARAM ] = $nonce; + + self::assertTrue( OnboardingWizard::verify_request( $action ) ); + + $this->activate_network_settings(); + + self::assertNotFalse( wp_verify_nonce( $nonce, $action ) ); + self::assertFalse( OnboardingWizard::verify_request( $action ) ); + + grant_super_admin( $user_id ); + $this->super_admins[] = $user_id; + + self::assertTrue( OnboardingWizard::verify_request( $action ) ); + } finally { + $_GET = $original_get; + } + } + + /** + * Data provider for test_onboarding_request_checks_network_ownership(). + * + * @return array + */ + public function dp_test_onboarding_request_checks_network_ownership(): array { + return [ + 'direct step' => [ OnboardingWizard::STEP_ACTION ], + 'auto setup' => [ OnboardingWizard::AUTO_SETUP_ACTION ], + ]; + } + + /** + * Test that network administrators can still update onboarding state. + */ + public function test_network_administrator_can_update_onboarding(): void { + $this->create_network_administrator(); + $this->activate_network_settings(); + add_filter( + 'pre_update_option_' . PluginSettingsBase::OPTION_NAME, + [ $this->general, 'pre_update_option_filter' ], + 10, + 2 + ); + + $site_settings = get_option( PluginSettingsBase::OPTION_NAME ); + + ( new OnboardingWizard( $this->general ) )->init(); + + $network_settings = get_site_option( PluginSettingsBase::OPTION_NAME ); + + self::assertSame( 'completed', $network_settings[ OnboardingWizard::OPTION_NAME ] ?? null ); + self::assertSame( self::NETWORK_SECRET_KEY, $network_settings['secret_key'] ?? null ); + self::assertSame( $site_settings, get_option( PluginSettingsBase::OPTION_NAME ) ); + self::assertSame( [ 'on' ], get_site_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE ) ); + } + + /** + * Test that indirect updates cannot copy network credentials without settings-page filters. + */ + public function test_indirect_updates_preserve_network_ownership(): void { + $this->create_main_site_administrator(); + $this->activate_network_settings(); + remove_all_filters( 'pre_update_option_' . PluginSettingsBase::OPTION_NAME ); + + $network_settings = get_site_option( PluginSettingsBase::OPTION_NAME ); + $site_settings = get_option( PluginSettingsBase::OPTION_NAME ); + + $this->general->update_option( 'whats_new', 'test-version' ); + hcaptcha()->settings()->update( 'wp_status', [ 'login' ] ); + + self::assertSame( $site_settings, get_option( PluginSettingsBase::OPTION_NAME ) ); + self::assertSame( $network_settings, get_site_option( PluginSettingsBase::OPTION_NAME ) ); + self::assertSame( [ 'on' ], get_site_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE ) ); + } + + /** + * Test that blank credentials keep their active value when settings ownership changes. + * + * @param bool $network_wide Whether network-wide settings are initially active. + * + * @dataProvider dp_test_blank_secret_survives_ownership_change + */ + public function test_blank_secret_survives_ownership_change( bool $network_wide ): void { + $this->create_network_administrator(); + $this->activate_network_settings(); + update_option( + PluginSettingsBase::OPTION_NAME, + [ + 'site_key' => 'local-site-key', + 'secret_key' => 'local-secret-key', + ] + ); + update_site_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE, $network_wide ? [ 'on' ] : [] ); + $this->general->init(); + add_filter( + 'pre_update_option_' . PluginSettingsBase::OPTION_NAME, + [ $this->general, 'pre_update_option_filter' ], + 10, + 2 + ); + + $site_key = $network_wide ? self::NETWORK_SITE_KEY : 'local-site-key'; + $secret_key = $network_wide ? self::NETWORK_SECRET_KEY : 'local-secret-key'; + + update_option( + PluginSettingsBase::OPTION_NAME, + [ + 'site_key' => $site_key, + 'secret_key' => '', + SettingsBase::NETWORK_WIDE => $network_wide ? [] : [ 'on' ], + ] + ); + + $settings = $network_wide + ? get_option( PluginSettingsBase::OPTION_NAME ) + : get_site_option( PluginSettingsBase::OPTION_NAME ); + + self::assertSame( $site_key, $settings['site_key'] ?? null ); + self::assertSame( $secret_key, $settings['secret_key'] ?? null ); + self::assertSame( $network_wide ? [] : [ 'on' ], get_site_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE ) ); + } + + /** + * Data provider for test_blank_secret_survives_ownership_change(). + * + * @return array + */ + public function dp_test_blank_secret_survives_ownership_change(): array { + return [ + 'network to site' => [ true ], + 'site to network' => [ false ], + ]; + } + + /** + * Create and select an administrator on the network main site. + * + * @return int + */ + private function create_main_site_administrator(): int { + while ( ms_is_switched() ) { + restore_current_blog(); + } + + self::assertSame( get_main_site_id(), get_current_blog_id() ); + + $user_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + + wp_set_current_user( $user_id ); + + self::assertTrue( current_user_can( 'manage_options' ) ); + self::assertFalse( current_user_can( 'manage_network_options' ) ); + + return $user_id; + } + + /** + * Create and select a network administrator. + * + * @return int + */ + private function create_network_administrator(): int { + $user_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + + grant_super_admin( $user_id ); + $this->super_admins[] = $user_id; + + wp_set_current_user( $user_id ); + + self::assertTrue( current_user_can( 'manage_options' ) ); + self::assertTrue( current_user_can( 'manage_network_options' ) ); + + return $user_id; + } + + /** + * Activate network settings with known credentials. + */ + private function activate_network_settings(): void { + update_site_option( + PluginSettingsBase::OPTION_NAME, + [ + 'site_key' => self::NETWORK_SITE_KEY, + 'secret_key' => self::NETWORK_SECRET_KEY, + 'theme' => 'light', + SettingsBase::NETWORK_WIDE => [ 'on' ], + ] + ); + update_site_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE, [ 'on' ] ); + + $this->general->init(); + } + + /** + * Assert that direct settings page rendering is denied without leaking credentials. + */ + private function assert_page_access_denied(): void { + foreach ( hcaptcha()->settings()->get_tabs() as $tab ) { + $this->assert_tab_access_denied( $tab ); + } + } + + /** + * Assert that a settings tab denies direct rendering. + * + * @param PluginSettingsBase $tab Settings tab. + */ + private function assert_tab_access_denied( PluginSettingsBase $tab ): void { + $die_message = ''; + $buffer_level = ob_get_level(); + $not_ajax_filter = static function (): bool { + return false; + }; + $die_filter = static function ( $handler ) use ( &$die_message ): callable { + return static function ( $message ) use ( &$die_message ): void { + $die_message = (string) $message; + }; + }; + + add_filter( 'wp_doing_ajax', $not_ajax_filter, PHP_INT_MAX ); + add_filter( 'wp_die_handler', $die_filter, PHP_INT_MAX ); + + ob_start(); + + try { + $tab->settings_base_page(); + $output = (string) ob_get_clean(); + } finally { + while ( ob_get_level() > $buffer_level ) { + ob_end_clean(); + } + + remove_filter( 'wp_die_handler', $die_filter, PHP_INT_MAX ); + remove_filter( 'wp_doing_ajax', $not_ajax_filter, PHP_INT_MAX ); + } + + self::assertSame( 'You are not allowed to access this page.', $die_message ); + self::assertSame( '', $output ); + self::assertStringNotContainsString( self::NETWORK_SITE_KEY, $output ); + self::assertStringNotContainsString( self::NETWORK_SECRET_KEY, $output ); + } + + /** + * Assert that payload flags cannot bypass the existing network import guard. + */ + private function assert_import_access_denied(): void { + self::assertFalse( Request::is_cli() ); + + $network_settings = get_site_option( PluginSettingsBase::OPTION_NAME ); + $site_settings = get_option( PluginSettingsBase::OPTION_NAME ); + $payload = [ + 'meta' => [ + 'plugin' => hcaptcha()->settings()->get_plugin_name(), + 'schema_version' => SettingsTransfer::SCHEMA_VERSION, + ], + 'settings' => [ 'theme' => 'dark' ], + 'keys' => [ + 'site_key' => 'imported-site-key', + 'secret_key' => 'imported-secret-key', + ], + ]; + + foreach ( [ null, [], [ 'on' ], 'on' ] as $network_wide ) { + if ( null !== $network_wide ) { + $payload['settings'][ SettingsBase::NETWORK_WIDE ] = $network_wide; + } + + $result = ( new SettingsTransfer() )->apply_import_payload( $payload, true ); + + self::assertWPError( $result ); + self::assertSame( 'hcaptcha_network_settings_forbidden', $result->get_error_code() ); + self::assertSame( $network_settings, get_site_option( PluginSettingsBase::OPTION_NAME ) ); + self::assertSame( [ 'on' ], get_site_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE ) ); + self::assertSame( $site_settings, get_option( PluginSettingsBase::OPTION_NAME ) ); + } + } + + /** + * Assert that an Ajax export is denied without leaking credentials. + * + * @param string $nonce Export nonce. + * @param bool $include_keys Whether keys were requested. + */ + private function assert_export_access_denied( string $nonce, bool $include_keys ): void { + [ $response, $raw_response ] = $this->export_settings_with_raw_response( $nonce, $include_keys ); + + self::assertFalse( $response['success'] ?? true ); + self::assertSame( 'You are not allowed to perform this action.', $response['data'] ?? null ); + self::assertArrayNotHasKey( 'settings', $response ); + self::assertArrayNotHasKey( 'keys', $response ); + self::assertStringNotContainsString( self::NETWORK_SITE_KEY, $raw_response ); + self::assertStringNotContainsString( self::NETWORK_SECRET_KEY, $raw_response ); + } + + /** + * Export settings over the registered Ajax action. + * + * @param string $nonce Export nonce. + * @param bool $include_keys Whether keys should be included. + * + * @return array + */ + private function export_settings( string $nonce, bool $include_keys ): array { + [ $response ] = $this->export_settings_with_raw_response( $nonce, $include_keys ); + + return $response; + } + + /** + * Export settings and return decoded and raw Ajax responses. + * + * @param string $nonce Export nonce. + * @param bool $include_keys Whether keys should be included. + * + * @return array{0: array, 1: string} + */ + private function export_settings_with_raw_response( string $nonce, bool $include_keys ): array { + $_POST = [ + 'nonce' => $nonce, + 'include_keys' => $include_keys ? 'on' : 'off', + ]; + + $this->_last_response = ''; + $did_die = false; + + try { + $this->_handleAjax( Tools::EXPORT_ACTION ); + } catch ( WPAjaxDieContinueException $exception ) { + $did_die = true; + } + + self::assertTrue( $did_die, 'wp_send_json() must terminate the Ajax request.' ); + + $response = json_decode( $this->_last_response, true ); + + self::assertSame( JSON_ERROR_NONE, json_last_error() ); + self::assertIsArray( $response ); + + return [ $response, $this->_last_response ]; + } + + /** + * Render the configured secret field. + * + * @return string + */ + private function render_secret_field(): string { + $property = ( new ReflectionClass( $this->general ) )->getProperty( 'form_fields' ); + + $property->setAccessible( true ); + $form_fields = $property->getValue( $this->general ); + $property->setAccessible( false ); + + $arguments = $form_fields['secret_key']; + $arguments['field_id'] = 'secret_key'; + + ob_start(); + $this->general->field_callback( $arguments ); + + return (string) ob_get_clean(); + } +} diff --git a/tests/php/multisite/Settings/WPCLISettingsTransferTest.php b/tests/php/multisite/Settings/WPCLISettingsTransferTest.php new file mode 100644 index 000000000..fa1f90161 --- /dev/null +++ b/tests/php/multisite/Settings/WPCLISettingsTransferTest.php @@ -0,0 +1,113 @@ + 'cli-network-site-key', + 'secret_key' => 'cli-network-secret-key', + 'theme' => 'light', + SettingsBase::NETWORK_WIDE => [ 'on' ], + ] + ); + update_site_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE, [ 'on' ] ); + update_option( PluginSettingsBase::OPTION_NAME, [ 'theme' => 'site-local-theme' ] ); + } + + /** + * Tear down test. + */ + public function tearDown(): void { + if ( $this->file && file_exists( $this->file ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + unlink( $this->file ); + } + + delete_site_option( PluginSettingsBase::OPTION_NAME ); + delete_site_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE ); + delete_option( PluginSettingsBase::OPTION_NAME ); + WP_CLI::reset(); + + parent::tearDown(); + } + + /** + * Test that trusted WP-CLI export and import preserve network-wide mode without --user. + */ + public function test_wp_cli_export_and_import_preserve_network_wide_mode_without_user(): void { + self::assertSame( 0, get_current_user_id() ); + + ob_start(); + ( new Commands() )->export( [], [ 'include-keys' => true ] ); + $export = json_decode( (string) ob_get_clean(), true ); + + self::assertSame( JSON_ERROR_NONE, json_last_error() ); + self::assertSame( 'cli-network-site-key', $export['keys']['site_key'] ?? null ); + self::assertSame( 'cli-network-secret-key', $export['keys']['secret_key'] ?? null ); + + $export['settings']['theme'] = 'dark'; + $this->file = tempnam( sys_get_temp_dir(), 'hcap-cli-network-' ); + + self::assertIsString( $this->file ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + file_put_contents( $this->file, wp_json_encode( $export ) ); + + ( new Commands() )->import( [ $this->file ], [ 'allow-keys' => true ] ); + + $network_settings = get_site_option( PluginSettingsBase::OPTION_NAME, [] ); + + self::assertSame( [ 'on' ], get_site_option( PluginSettingsBase::OPTION_NAME . SettingsBase::NETWORK_WIDE ) ); + self::assertSame( [ 'on' ], $network_settings[ SettingsBase::NETWORK_WIDE ] ?? null ); + self::assertSame( 'dark', $network_settings['theme'] ?? null ); + self::assertSame( 'cli-network-secret-key', $network_settings['secret_key'] ?? null ); + self::assertSame( [ 'theme' => 'site-local-theme' ], get_option( PluginSettingsBase::OPTION_NAME ) ); + self::assertSame( [ 'hCaptcha settings were successfully imported.' ], WP_CLI::$success_messages ); + } +} diff --git a/tests/php/multisite/_bootstrap.php b/tests/php/multisite/_bootstrap.php new file mode 100644 index 000000000..c2935a4ed --- /dev/null +++ b/tests/php/multisite/_bootstrap.php @@ -0,0 +1,13 @@ +addPsr4( '', dirname( __DIR__ ) . '/integration/Stubs/', true ); diff --git a/tests/php/run-parallel.php b/tests/php/run-parallel.php index 643c5df80..17c0ca37d 100644 --- a/tests/php/run-parallel.php +++ b/tests/php/run-parallel.php @@ -1,6 +1,7 @@ [--processes=N] [Codeception options]\n"; + echo "Usage: php tests/php/run-parallel.php [--processes=N] [--shard=N/M] [Codeception options]\n"; return 1; } @@ -49,7 +51,7 @@ function hcaptcha_run_parallel_tests( array $arguments ): int { return 1; } - [ $process_count, $codeception_arguments ] = hcaptcha_parse_parallel_arguments( $arguments ); + [ $process_count, $shard_number, $shard_total, $codeception_arguments ] = hcaptcha_parse_parallel_arguments( $arguments ); if ( 0 === $process_count ) { return 1; @@ -71,10 +73,20 @@ function hcaptcha_run_parallel_tests( array $arguments ): int { return 1; } - $units = hcaptcha_create_parallel_units( + $units = hcaptcha_create_parallel_units( $files, hcaptcha_normalize_parallel_path( $root . '/tests/php/' . $suite ) ); + + if ( $shard_total > count( $units ) ) { + echo "The shard count cannot exceed the number of test groups.\n"; + + return 1; + } + + $units = hcaptcha_partition_parallel_units( $units, $shard_total )[ $shard_number - 1 ]; + printf( "Shard %d/%d: %d test groups.\n", $shard_number, $shard_total, count( $units ) ); + $process_count = min( $process_count, count( $units ) ); $codecept = $root . '/vendor/bin/codecept'; $temp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/hcaptcha-codeception-' . getmypid() . '-' . bin2hex( random_bytes( 4 ) ); @@ -96,6 +108,7 @@ function hcaptcha_run_parallel_tests( array $arguments ): int { $codecept, $suite, $units, + $files, $process_count, $codeception_arguments, $temp_dir @@ -104,16 +117,19 @@ function hcaptcha_run_parallel_tests( array $arguments ): int { hcaptcha_remove_parallel_temp_dir( $temp_dir ); } } +// phpcs:enable Generic.Metrics.CyclomaticComplexity.TooHigh +// phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh -- Runner option validation. /** * Parse runner-specific arguments and preserve Codeception arguments. * * @param string[] $arguments Command arguments. * - * @return array{0: int, 1: string[]} + * @return array{0: int, 1: int, 2: int, 3: string[]} */ function hcaptcha_parse_parallel_arguments( array $arguments ): array { $process_count = null; + $shard = '1/1'; $codeception_arguments = []; for ( $index = 0, $count = count( $arguments ); $index < $count; ++$index ) { @@ -131,6 +147,18 @@ function hcaptcha_parse_parallel_arguments( array $arguments ): array { continue; } + if ( 0 === strpos( $argument, '--shard=' ) ) { + $shard = substr( $argument, strlen( '--shard=' ) ); + + continue; + } + + if ( '--shard' === $argument ) { + $shard = $arguments[ ++$index ] ?? ''; + + continue; + } + $codeception_arguments[] = $argument; } @@ -151,11 +179,18 @@ function hcaptcha_parse_parallel_arguments( array $arguments ): array { if ( false === $process_count ) { echo "The process count must be an integer from 1 to 32.\n"; - return [ 0, [] ]; + return [ 0, 0, 0, [] ]; + } + + if ( ! preg_match( '/^([1-9][0-9]*)\/([1-9][0-9]*)$/', $shard, $matches ) || (int) $matches[1] > (int) $matches[2] ) { + echo "The shard must be N/M, with 1 <= N <= M.\n"; + + return [ 0, 0, 0, [] ]; } - return [ (int) $process_count, $codeception_arguments ]; + return [ (int) $process_count, (int) $matches[1], (int) $matches[2], $codeception_arguments ]; } +// phpcs:enable Generic.Metrics.CyclomaticComplexity.TooHigh /** * Detect a sensible process count for the current machine. @@ -255,6 +290,40 @@ static function ( array $left, array $right ): int { return $weighted_units; } +/** + * Assign complete test groups to balanced CI shards. + * + * @param array $units Test units. + * @param int $shard_count Number of shards. + * + * @return array> + * @throws InvalidArgumentException Invalid shard count. + */ +function hcaptcha_partition_parallel_units( array $units, int $shard_count ): array { + if ( 1 > $shard_count || $shard_count > count( $units ) ) { + throw new InvalidArgumentException( 'Invalid shard count.' ); + } + + $shards = array_fill( 0, $shard_count, [] ); + $loads = array_fill( 0, $shard_count, 0 ); + + foreach ( $units as $unit ) { + $lightest = 0; + + for ( $index = 1; $index < $shard_count; ++$index ) { + if ( $loads[ $index ] < $loads[ $lightest ] ) { + $lightest = $index; + } + } + + // Allow 70 test-method weights for each WordPress process startup. + $shards[ $lightest ][] = $unit; + $loads[ $lightest ] += 70 + $unit['weight']; + } + + return $shards; +} + /** * Prepare databases and Codeception support classes for a parallel run. * @@ -360,6 +429,7 @@ function hcaptcha_build_codeception( string $root, string $codecept ): bool { * @param string $codecept Codeception executable. * @param string $suite Suite name. * @param array $units Test units. + * @param string[] $all_files Every test file in the suite. * @param int $process_count Process count. * @param string[] $codeception_arguments Additional arguments. * @param string $temp_dir Temporary directory. @@ -371,11 +441,11 @@ function hcaptcha_run_test_processes( string $codecept, string $suite, array $units, + array $all_files, int $process_count, array $codeception_arguments, string $temp_dir ): int { - $all_files = array_merge( ...array_column( $units, 'files' ) ); $task_count = count( $units ); foreach ( $units as $task_index => &$unit ) { @@ -632,4 +702,6 @@ function hcaptcha_remove_parallel_temp_dir( string $directory ): void { rmdir( $directory ); } -exit( hcaptcha_run_parallel_tests( array_slice( $argv, 1 ) ) ); +if ( isset( $argv[0] ) && realpath( $argv[0] ) === __FILE__ ) { + exit( hcaptcha_run_parallel_tests( array_slice( $argv, 1 ) ) ); +} diff --git a/tests/php/unit/Admin/CommandPaletteTest.php b/tests/php/unit/Admin/CommandPaletteTest.php index 83e920277..7b2986b44 100644 --- a/tests/php/unit/Admin/CommandPaletteTest.php +++ b/tests/php/unit/Admin/CommandPaletteTest.php @@ -13,6 +13,8 @@ use Mockery; use ReflectionClass; use ReflectionException; +use stdClass; +use tad\FunctionMocker\FunctionMocker; use WP_Mock; /** @@ -22,6 +24,222 @@ * @group command-palette */ class CommandPaletteTest extends HCaptchaTestCase { + /** + * Test the constructor registers its admin hook. + */ + public function test_constructor_registers_hook(): void { + $subject = $this->create_subject(); + WP_Mock::expectActionAdded( 'admin_enqueue_scripts', [ $subject, 'enqueue_assets' ] ); + + ( new ReflectionClass( CommandPalette::class ) )->getConstructor()->invoke( $subject ); + } + + /** + * Test command creation from field types and options. + * + * @throws ReflectionException Reflection exception. + */ + public function test_get_field_commands(): void { + $subject = $this->create_subject(); + $method = $this->set_method_accessibility( $subject, 'get_field_commands' ); + $settings = Mockery::mock(); + $settings->shouldReceive( 'get_plugin_name' )->andReturn( 'hCaptcha' ); + $main = Mockery::mock(); + $main->shouldReceive( 'settings' )->andReturn( $settings ); + + WP_Mock::userFunction( 'hcaptcha' )->andReturn( $main ); + WP_Mock::userFunction( 'wp_strip_all_tags' )->andReturnUsing( 'strip_tags' ); + WP_Mock::userFunction( 'sanitize_key' )->andReturnUsing( 'strtolower' ); + WP_Mock::userFunction( 'esc_url_raw' )->andReturnUsing( + static function ( $url ) { + return $url; + } + ); + + $args = [ 'field', [ 'type' => 'hcaptcha' ], 'https://example.test/options', 'General', 'general' ]; + self::assertSame( [], $method->invokeArgs( $subject, $args ) ); + + $args[1] = [ 'type' => 'checkbox' ]; + self::assertSame( [], $method->invokeArgs( $subject, $args ) ); + + $args[1] = [ + 'type' => 'text', + 'label' => ' ', + ]; + self::assertSame( [], $method->invokeArgs( $subject, $args ) ); + + $args[1] = [ + 'type' => 'text', + 'label' => 'Site key', + ]; + $text_command = $method->invokeArgs( $subject, $args ); + self::assertSame( 'hCaptcha: Site key', $text_command[0]['label'] ); + self::assertSame( 'https://example.test/options#field', $text_command[0]['url'] ); + + $args[1] = [ + 'type' => 'radio', + 'label' => 'Mode', + 'options' => [ + 'live' => 'Live', + 'test' => 'Test', + 'empty' => '', + ], + ]; + $option_commands = $method->invokeArgs( $subject, $args ); + self::assertCount( 2, $option_commands ); + self::assertSame( 'hCaptcha: Mode: Live', $option_commands[0]['label'] ); + self::assertSame( 'https://example.test/options#field_2', $option_commands[1]['url'] ); + } + + /** + * Test settings tabs are converted into commands. + * + * @throws ReflectionException Reflection exception. + */ + public function test_get_commands(): void { + $tab = Mockery::mock( PluginSettingsBase::class ); + $tab->shouldReceive( 'command_palette_page_title' )->once()->andReturn( 'General' ); + $tab->shouldReceive( 'tab_name' )->once()->andReturn( 'general' ); + $tab->shouldReceive( 'command_palette_form_fields' )->once()->andReturn( + [ + 'site_key' => [ + 'type' => 'text', + 'label' => 'Site key', + ], + ] + ); + + $settings = Mockery::mock(); + $settings->shouldReceive( 'get_tabs' )->once()->andReturn( [ new stdClass(), $tab ] ); + $settings->shouldReceive( 'tab_url' )->with( get_class( $tab ) )->once()->andReturn( 'https://example.test/options' ); + $settings->shouldReceive( 'get_plugin_name' )->andReturn( 'hCaptcha' ); + $main = Mockery::mock(); + $main->shouldReceive( 'settings' )->andReturn( $settings ); + + WP_Mock::userFunction( 'hcaptcha' )->andReturn( $main ); + WP_Mock::userFunction( 'wp_strip_all_tags' )->andReturnUsing( 'strip_tags' ); + WP_Mock::userFunction( 'sanitize_key' )->andReturnUsing( 'strtolower' ); + WP_Mock::userFunction( 'esc_url_raw' )->andReturnUsing( + static function ( $url ) { + return $url; + } + ); + WP_Mock::userFunction( 'apply_filters' ) + ->andReturnUsing( + static function ( $hook, $commands ) { + return $commands; + } + ); + + $method = $this->set_method_accessibility( $this->create_subject(), 'get_commands' ); + $commands = $method->invoke( $this->create_subject() ); + + self::assertCount( 1, $commands ); + self::assertSame( 'hCaptcha: Site key', $commands[0]['label'] ); + } + + /** + * Test missing settings on both command lookup paths. + * + * @throws ReflectionException Reflection exception. + */ + public function test_missing_settings(): void { + $main = Mockery::mock(); + $main->shouldReceive( 'settings' )->twice()->andReturn( null ); + WP_Mock::userFunction( 'hcaptcha' )->twice()->andReturn( $main ); + + $subject = $this->create_subject(); + self::assertFalse( $this->set_method_accessibility( $subject, 'is_options_screen' )->invoke( $subject ) ); + self::assertSame( [], $this->set_method_accessibility( $subject, 'get_commands' )->invoke( $subject ) ); + } + + /** + * Test command palette asset registration for a settings field. + * + * @throws ReflectionException Reflection exception. + */ + public function test_enqueue_assets_with_commands(): void { + $tab = Mockery::mock( PluginSettingsBase::class ); + $tab->shouldReceive( 'is_options_screen' )->with( [] )->once()->andReturn( true ); + $tab->shouldReceive( 'command_palette_page_title' )->once()->andReturn( 'General' ); + $tab->shouldReceive( 'tab_name' )->once()->andReturn( 'general' ); + $tab->shouldReceive( 'command_palette_form_fields' )->once()->andReturn( + [ + 'site_key' => [ + 'type' => 'text', + 'label' => 'Site key', + ], + ] + ); + $settings = Mockery::mock(); + $settings->shouldReceive( 'get_tabs' )->twice()->andReturn( [ $tab ] ); + $settings->shouldReceive( 'tab_url' )->andReturn( 'https://example.test/options' ); + $settings->shouldReceive( 'get_plugin_name' )->andReturn( 'hCaptcha' ); + $main = Mockery::mock(); + $main->shouldReceive( 'settings' )->andReturn( $settings ); + + WP_Mock::userFunction( 'hcaptcha' )->andReturn( $main ); + WP_Mock::userFunction( 'current_user_can' )->with( 'manage_options' )->once()->andReturn( true ); + WP_Mock::userFunction( 'wp_script_is' )->with( 'wp-commands', 'registered' )->once()->andReturn( true ); + WP_Mock::userFunction( 'wp_strip_all_tags' )->andReturnUsing( 'strip_tags' ); + WP_Mock::userFunction( 'sanitize_key' )->andReturnUsing( 'strtolower' ); + WP_Mock::userFunction( 'esc_url_raw' )->andReturnUsing( + static function ( $url ) { + return $url; + } + ); + WP_Mock::userFunction( 'apply_filters' ) + ->andReturnUsing( + static function ( $hook, $commands ) { + return $commands; + } + ); + WP_Mock::userFunction( 'hcap_min_suffix' )->once()->andReturn( '.min' ); + FunctionMocker::replace( + 'constant', + static function ( $name ) { + return 'HCAPTCHA_URL' === $name ? 'https://example.test/plugin' : '1.0.0'; + } + ); + WP_Mock::userFunction( 'wp_enqueue_script' ) + ->with( + 'hcaptcha-command-palette', + 'https://example.test/plugin/assets/js/command-palette.min.js', + [ 'wp-commands', 'wp-data' ], + '1.0.0', + true + )->once(); + WP_Mock::userFunction( 'wp_localize_script' ) + ->with( 'hcaptcha-command-palette', 'HCaptchaCommandPaletteObject', Mockery::type( 'array' ) )->once(); + + $this->create_subject()->enqueue_assets(); + } + + /** + * Test asset enqueueing stops when no fields yield commands. + * + * @throws ReflectionException Reflection exception. + */ + public function test_enqueue_assets_without_commands(): void { + $tab = Mockery::mock( PluginSettingsBase::class ); + $tab->shouldReceive( 'is_options_screen' )->with( [] )->once()->andReturn( true ); + $tab->shouldReceive( 'command_palette_page_title' )->once()->andReturn( 'General' ); + $tab->shouldReceive( 'tab_name' )->once()->andReturn( 'general' ); + $tab->shouldReceive( 'command_palette_form_fields' )->once()->andReturn( [] ); + $settings = Mockery::mock(); + $settings->shouldReceive( 'get_tabs' )->twice()->andReturn( [ $tab ] ); + $settings->shouldReceive( 'tab_url' )->once()->andReturn( 'https://example.test/options' ); + $main = Mockery::mock(); + $main->shouldReceive( 'settings' )->andReturn( $settings ); + + WP_Mock::userFunction( 'hcaptcha' )->andReturn( $main ); + WP_Mock::userFunction( 'current_user_can' )->with( 'manage_options' )->once()->andReturn( true ); + WP_Mock::userFunction( 'wp_script_is' )->with( 'wp-commands', 'registered' )->once()->andReturn( true ); + WP_Mock::userFunction( 'apply_filters' )->andReturn( [] ); + WP_Mock::userFunction( 'wp_enqueue_script' )->never(); + + $this->create_subject()->enqueue_assets(); + } /** * Create a subject without constructor side effects. diff --git a/tests/php/unit/AntiSpam/AntiSpamTest.php b/tests/php/unit/AntiSpam/AntiSpamTest.php index f4cf43471..58b25f722 100644 --- a/tests/php/unit/AntiSpam/AntiSpamTest.php +++ b/tests/php/unit/AntiSpam/AntiSpamTest.php @@ -53,6 +53,33 @@ public function test_constructor_adds_defaults(): void { ); } + /** + * Test that additional entry fields are preserved for the provider. + * + * @throws ReflectionException Reflection exception. + */ + public function test_constructor_preserves_additional_fields(): void { + $subject = $this->make_subject( + [ + 'data' => [ 'email' => 'person@example.test' ], + 'address' => 'Riga', + 'preferences' => [ 'newsletter' => true ], + ] + ); + + self::assertSame( + [ + 'data' => [ 'email' => 'person@example.test' ], + 'name' => null, + 'email' => null, + 'form_date_gmt' => null, + 'address' => 'Riga', + 'preferences' => [ 'newsletter' => true ], + ], + $this->get_protected_property( $subject, 'entry' ) + ); + } + /** * Test init() exits when anti-spam is off. */ diff --git a/tests/php/unit/AntiSpam/HoneypotTest.php b/tests/php/unit/AntiSpam/HoneypotTest.php index 5bc7f1c99..5b067dab0 100644 --- a/tests/php/unit/AntiSpam/HoneypotTest.php +++ b/tests/php/unit/AntiSpam/HoneypotTest.php @@ -69,6 +69,182 @@ public function test_get_protected_forms( bool $honeypot, bool $fst ): void { self::assertSame( $fst, [] !== $result['fst'] ); self::assertSame( $honeypot, isset( $result['honeypot']['wp_status'] ) ); self::assertSame( $fst, isset( $result['fst']['wp_status'] ) ); + + if ( $honeypot ) { + self::assertContains( 'order_withdrawal', $result['honeypot']['woocommerce_status'] ); + self::assertSame( [ 'login', 'register' ], $result['honeypot']['affiliates_status'] ); + self::assertSame( [ 'form' ], $result['honeypot']['asgaros_status'] ); + self::assertSame( [ 'form' ], $result['honeypot']['back_in_stock_notifier_status'] ); + self::assertSame( [ 'contact', 'login' ], $result['honeypot']['beaver_builder_status'] ); + self::assertSame( [ 'form' ], $result['honeypot']['brizy_status'] ); + self::assertSame( + [ 'comment', 'contact', 'email_optin', 'login' ], + $result['honeypot']['divi_builder_status'] + ); + self::assertSame( + [ 'comment', 'contact', 'email_optin', 'login' ], + $result['honeypot']['extra_status'] + ); + self::assertSame( + [ 'contact', 'login', 'lost_pass', 'register' ], + $result['honeypot']['classified_listing_status'] + ); + self::assertSame( + [ 'login', 'lost_pass', 'register' ], + $result['honeypot']['colorlib_customizer_status'] + ); + self::assertSame( + [ 'checkout', 'login', 'lost_pass', 'register' ], + $result['honeypot']['easy_digital_downloads_status'] + ); + self::assertSame( [ 'form' ], $result['honeypot']['icegram_express_status'] ); + self::assertSame( [ 'form' ], $result['honeypot']['html_forms_status'] ); + self::assertSame( [ 'login', 'register' ], $result['honeypot']['memberpress_status'] ); + self::assertSame( + [ 'login', 'register' ], + $result['honeypot']['login_signup_popup_status'] + ); + self::assertSame( + [ 'login', 'lost_pass', 'register' ], + $result['honeypot']['profile_builder_status'] + ); + self::assertSame( [ 'protect' ], $result['honeypot']['passster_status'] ); + self::assertSame( [ 'form' ], $result['honeypot']['quform_status'] ); + self::assertSame( [ 'form' ], $result['honeypot']['simple_download_monitor_status'] ); + self::assertSame( [ 'form' ], $result['honeypot']['subscriber_status'] ); + self::assertSame( [ 'form' ], $result['honeypot']['supportcandy_status'] ); + self::assertSame( + [ 'login', 'lost_pass', 'register' ], + $result['honeypot']['simple_membership_status'] + ); + self::assertSame( + [ 'login', 'lost_pass', 'register' ], + $result['honeypot']['learn_dash_status'] + ); + self::assertSame( + [ 'return_request' ], + $result['honeypot']['woocommerce_germanized_status'] + ); + self::assertSame( + [ 'create_list' ], + $result['honeypot']['woocommerce_wishlists_status'] + ); + self::assertSame( + [ 'q&a', 'review' ], + $result['honeypot']['customer_reviews_status'] + ); + self::assertSame( [ 'booking' ], $result['honeypot']['events_manager_status'] ); + self::assertSame( + [ 'checkout', 'login', 'register' ], + $result['honeypot']['learn_press_status'] + ); + self::assertSame( + [ 'login', 'lost_pass', 'register', 'signup' ], + $result['honeypot']['theme_my_login_status'] + ); + self::assertSame( + [ 'checkout', 'login', 'lost_pass', 'register' ], + $result['honeypot']['tutor_status'] + ); + self::assertSame( + [ 'comment_form', 'subscribe_form' ], + $result['honeypot']['wpdiscuz_status'] + ); + self::assertSame( [ 'new_topic', 'reply' ], $result['honeypot']['wpforo_status'] ); + self::assertSame( [ 'form' ], $result['honeypot']['wp_job_openings_status'] ); + self::assertSame( + [ 'forgot', 'login', 'register' ], + $result['honeypot']['users_wp_status'] + ); + } + + if ( $fst ) { + self::assertContains( 'order_withdrawal', $result['fst']['woocommerce_status'] ); + self::assertSame( [ 'login', 'register' ], $result['fst']['affiliates_status'] ); + self::assertSame( [ 'form' ], $result['fst']['asgaros_status'] ); + self::assertSame( [ 'form' ], $result['fst']['back_in_stock_notifier_status'] ); + self::assertSame( [ 'contact', 'login' ], $result['fst']['beaver_builder_status'] ); + self::assertSame( [ 'form' ], $result['fst']['brizy_status'] ); + self::assertSame( + [ 'comment', 'contact', 'email_optin', 'login' ], + $result['fst']['divi_builder_status'] + ); + self::assertSame( + [ 'comment', 'contact', 'email_optin', 'login' ], + $result['fst']['extra_status'] + ); + self::assertSame( + [ 'contact', 'login', 'lost_pass', 'register' ], + $result['fst']['classified_listing_status'] + ); + self::assertSame( + [ 'login', 'lost_pass', 'register' ], + $result['fst']['colorlib_customizer_status'] + ); + self::assertSame( + [ 'checkout', 'login', 'lost_pass', 'register' ], + $result['fst']['easy_digital_downloads_status'] + ); + self::assertSame( [ 'form' ], $result['fst']['icegram_express_status'] ); + self::assertSame( [ 'form' ], $result['fst']['html_forms_status'] ); + self::assertSame( [ 'login', 'register' ], $result['fst']['memberpress_status'] ); + self::assertSame( + [ 'login', 'register' ], + $result['fst']['login_signup_popup_status'] + ); + self::assertSame( + [ 'login', 'lost_pass', 'register' ], + $result['fst']['profile_builder_status'] + ); + self::assertSame( [ 'protect' ], $result['fst']['passster_status'] ); + self::assertSame( [ 'form' ], $result['fst']['quform_status'] ); + self::assertSame( [ 'form' ], $result['fst']['simple_download_monitor_status'] ); + self::assertSame( [ 'form' ], $result['fst']['subscriber_status'] ); + self::assertSame( [ 'form' ], $result['fst']['supportcandy_status'] ); + self::assertSame( + [ 'login', 'lost_pass', 'register' ], + $result['fst']['simple_membership_status'] + ); + self::assertSame( + [ 'login', 'lost_pass', 'register' ], + $result['fst']['learn_dash_status'] + ); + self::assertSame( + [ 'return_request' ], + $result['fst']['woocommerce_germanized_status'] + ); + self::assertSame( + [ 'create_list' ], + $result['fst']['woocommerce_wishlists_status'] + ); + self::assertSame( + [ 'q&a', 'review' ], + $result['fst']['customer_reviews_status'] + ); + self::assertSame( [ 'booking' ], $result['fst']['events_manager_status'] ); + self::assertSame( + [ 'checkout', 'login', 'register' ], + $result['fst']['learn_press_status'] + ); + self::assertSame( + [ 'login', 'lost_pass', 'register', 'signup' ], + $result['fst']['theme_my_login_status'] + ); + self::assertSame( + [ 'checkout', 'login', 'lost_pass', 'register' ], + $result['fst']['tutor_status'] + ); + self::assertSame( + [ 'comment_form', 'subscribe_form' ], + $result['fst']['wpdiscuz_status'] + ); + self::assertSame( [ 'new_topic', 'reply' ], $result['fst']['wpforo_status'] ); + self::assertSame( [ 'form' ], $result['fst']['wp_job_openings_status'] ); + self::assertSame( + [ 'forgot', 'login', 'register' ], + $result['fst']['users_wp_status'] + ); + } } /** diff --git a/tests/php/unit/AutoVerify/AutoVerifyTest.php b/tests/php/unit/AutoVerify/AutoVerifyTest.php index 0ea21f298..94415700f 100644 --- a/tests/php/unit/AutoVerify/AutoVerifyTest.php +++ b/tests/php/unit/AutoVerify/AutoVerifyTest.php @@ -33,8 +33,7 @@ class AutoVerifyTest extends HCaptchaTestCase { * Tear down the test. */ public function tearDown(): void { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - unset( $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'] ); + unset( $GLOBALS['wpdb'], $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing foreach ( array_keys( $_POST ) as $key ) { @@ -309,9 +308,11 @@ static function ( $name ) use ( $plugin_url, $plugin_version ) { } ); WP_Mock::userFunction( 'hcap_min_suffix' )->andReturn( $min ); - WP_Mock::userFunction( '__' ) - ->with( 'The form was submitted successfully.', 'hcaptcha-for-forms-and-more' ) - ->andReturn( 'The form was submitted successfully.' ); + WP_Mock::userFunction( '__' )->andReturnUsing( + static function ( string $message ): string { + return $message; + } + ); WP_Mock::userFunction( 'wp_enqueue_script' ) ->with( @@ -328,7 +329,10 @@ static function ( $name ) use ( $plugin_url, $plugin_version ) { AutoVerify::HANDLE, AutoVerify::OBJECT, [ - 'successMsg' => 'The form was submitted successfully.', + 'successMsg' => 'The form was submitted successfully.', + 'submittingMsg' => 'Submitting the form...', + 'errorMsg' => 'The form could not be submitted. Please try again.', + 'networkErrorMsg' => 'Could not confirm whether the form was submitted. Check before trying again.', ] ) ->times( $times ); @@ -782,6 +786,7 @@ public function test_register_forms(): void { 'action' => $action, 'inputs' => [ 'test_input' ], 'widget_id' => self::WIDGET_ID_VALUE, + 'source' => 'post:123', 'args' => $args, ], ]; @@ -803,6 +808,7 @@ static function ( $url, $component ) { return parse_url( $url, $component ); } ); + WP_Mock::userFunction( 'get_queried_object_id' )->andReturn( 123 ); $subject = Mockery::mock( AutoVerify::class )->makePartial(); @@ -873,6 +879,9 @@ static function ( $args, $defaults ) { ->with( AutoVerify::TRANSIENT ) ->once() ->andReturn( $transient ); + WP_Mock::userFunction( 'get_option' )->andReturn( false ); + WP_Mock::userFunction( 'update_option' )->andReturn( true ); + WP_Mock::userFunction( 'delete_option' )->andReturn( true ); WP_Mock::userFunction( 'set_transient' ) ->with( AutoVerify::TRANSIENT, $expected, $day_in_seconds ) ->once(); @@ -936,6 +945,8 @@ static function ( $values, $defaults ) { ->with( AutoVerify::TRANSIENT ) ->once() ->andReturn( $transient ); + WP_Mock::userFunction( 'get_option' )->andReturn( false ); + WP_Mock::userFunction( 'update_option' )->andReturn( true ); WP_Mock::onFilter( 'hcap_auto_verify_transient_max_size' ) ->with( AutoVerify::MAX_TRANSIENT_SIZE ) ->reply( $max_size ); @@ -1156,6 +1167,7 @@ public function test_get_registered_form( $transient, string $path, array $post, ->with( AutoVerify::TRANSIENT ) ->once() ->andReturn( $transient ); + WP_Mock::userFunction( 'get_option' )->andReturn( false ); FunctionMocker::replace( '\HCaptcha\Helpers\Request::filter_input', static function ( int $type, string $var_name ) { @@ -1366,4 +1378,202 @@ class="h-captcha" '; } + + /** + * Call a private AutoVerify method. + * + * @param AutoVerify $subject Subject. + * @param string $name Method name. + * @param array $args Arguments. + * + * @return mixed + * @throws ReflectionException Reflection exception. + */ + private function call_private( AutoVerify $subject, string $name, array $args = [] ) { + return $this->set_method_accessibility( $subject, $name )->invokeArgs( $subject, $args ); + } + + /** + * Test deleting persistent registrations and the transient. + */ + public function test_delete_all(): void { + global $wpdb; + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wpdb = Mockery::mock( 'wpdb' ); + $wpdb->options = 'wp_options'; + $wpdb->shouldReceive( 'esc_like' ) + ->once() + ->with( 'hcaptcha_auto_verify_form_' ) + ->andReturn( 'escaped-prefix' ); + $wpdb->shouldReceive( 'prepare' ) + ->once() + ->with( 'SELECT option_name FROM wp_options WHERE option_name LIKE %s', 'escaped-prefix%' ) + ->andReturn( 'prepared-query' ); + $wpdb->shouldReceive( 'get_col' ) + ->once() + ->with( 'prepared-query' ) + ->andReturn( [ 'first-option', 'second-option' ] ); + WP_Mock::userFunction( 'delete_option' )->with( 'first-option' )->once(); + WP_Mock::userFunction( 'delete_option' )->with( 'second-option' )->once(); + WP_Mock::userFunction( 'delete_transient' )->with( AutoVerify::TRANSIENT )->once(); + + AutoVerify::delete_all(); + } + + /** + * Mock WordPress URL handling for canonical path tests. + */ + private function mock_url_helpers(): void { + WP_Mock::userFunction( 'wp_parse_url' ) + ->andReturnUsing( + static function ( $url, $component ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url + return parse_url( $url, $component ); + } + ); + WP_Mock::userFunction( 'untrailingslashit' ) + ->andReturnUsing( + static function ( $path ) { + return rtrim( $path, '/' ); + } + ); + } + + /** + * Find a registered form through the canonical page permalink. + * + * @throws ReflectionException Reflection exception. + */ + public function test_registered_form_uses_canonical_path(): void { + $_SERVER['REQUEST_URI'] = '/raw-path/'; + $this->mock_url_helpers(); + WP_Mock::passthruFunction( 'wp_unslash' ); + FunctionMocker::replace( '\HCaptcha\Helpers\Request::current_url', 'https://test.test/raw-path/' ); + FunctionMocker::replace( + '\HCaptcha\Helpers\Request::filter_input', + static function ( $type ) { + return INPUT_GET === $type ? 'page-slug' : 'widget-id'; + } + ); + WP_Mock::userFunction( 'url_to_postid' )->with( 'https://test.test/raw-path/' )->once()->andReturn( 0 ); + WP_Mock::userFunction( 'get_page_by_path' )->with( 'page-slug' )->once()->andReturn( (object) [ 'ID' => 123 ] ); + WP_Mock::userFunction( 'get_permalink' )->with( 123 )->once()->andReturn( 'https://test.test/canonical/' ); + + $form = [ + 'args' => [ 'auto' => true ], + 'inputs' => [], + 'widget_id' => 'widget-id', + ]; + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = 'widget-id'; + WP_Mock::userFunction( 'get_option' ) + ->with( 'hcaptcha_auto_verify_form_' . hash( 'sha256', '/raw-path' ), false ) + ->once() + ->andReturn( false ); + WP_Mock::userFunction( 'get_option' ) + ->with( 'hcaptcha_auto_verify_form_' . hash( 'sha256', '/canonical' ), false ) + ->once() + ->andReturn( [ $form ] ); + WP_Mock::userFunction( 'get_transient' )->with( AutoVerify::TRANSIENT )->once()->andReturn( false ); + + self::assertSame( $form, $this->call_private( new AutoVerify(), 'get_registered_form_for_request' ) ); + } + + /** + * Ignore a canonical page with an invalid permalink. + * + * @throws ReflectionException Reflection exception. + */ + public function test_canonical_path_rejects_non_string_permalink(): void { + FunctionMocker::replace( '\HCaptcha\Helpers\Request::current_url', 'https://test.test/page/' ); + WP_Mock::userFunction( 'url_to_postid' )->with( 'https://test.test/page/' )->once()->andReturn( 123 ); + WP_Mock::userFunction( 'get_permalink' )->with( 123 )->once()->andReturn( false ); + + self::assertSame( '', $this->call_private( new AutoVerify(), 'get_canonical_request_path' ) ); + } + + /** + * Reject an empty registered form with the bad-signature error. + * + * @throws ReflectionException Reflection exception. + */ + public function test_verify_submission_rejects_empty_registration(): void { + WP_Mock::userFunction( 'hcap_get_error_messages' ) + ->once() + ->andReturn( [ 'bad-signature' => 'Bad signature' ] ); + FunctionMocker::replace( + '\HCaptcha\Helpers\API::filtered_result', + static function ( $message, $codes ) { + self::assertSame( 'Bad signature', $message ); + self::assertSame( [ 'bad-signature' ], $codes ); + + return 'Rejected'; + } + ); + + self::assertSame( 'Rejected', $this->call_private( new AutoVerify(), 'verify_submission', [ [] ] ) ); + } + + /** + * Normalize a protocol-relative URL as a path. + * + * @throws ReflectionException Reflection exception. + */ + public function test_get_path_normalizes_protocol_relative_url(): void { + $this->mock_url_helpers(); + + self::assertSame( '/example', $this->call_private( new AutoVerify(), 'get_path', [ '//example/' ] ) ); + } + + /** + * Handle forms without input fields and inputs without a type or name. + * + * @throws ReflectionException Reflection exception. + */ + public function test_visible_input_names_with_missing_attributes(): void { + $subject = new AutoVerify(); + + self::assertSame( [], $this->call_private( $subject, 'get_visible_input_names', [ '
' ] ) ); + self::assertSame( + [ 'email' ], + $this->call_private( $subject, 'get_visible_input_names', [ '' ] ) + ); + } + + /** + * Keep registrations that do not match the current source. + * + * @throws ReflectionException Reflection exception. + */ + public function test_remove_form_registration_preserves_other_source(): void { + $subject = new AutoVerify(); + $registered_forms = [ '/form' => [ [ 'source' => 'post:1' ] ] ]; + $action_forms = $registered_forms['/form']; + $method = $this->set_method_accessibility( $subject, 'remove_form_registration' ); + + $method->invokeArgs( $subject, [ &$registered_forms, [ 'source' => 'post:2' ], '/form', $action_forms, 0 ] ); + + self::assertSame( [ '/form' => [ [ 'source' => 'post:1' ] ] ], $registered_forms ); + } + + /** + * Persist remaining registrations after one is removed. + * + * @throws ReflectionException Reflection exception. + */ + public function test_remove_form_registration_updates_remaining_forms(): void { + $subject = new AutoVerify(); + $registered_forms = [ '/form' => [ [ 'source' => 'post:1' ], [ 'source' => 'post:2' ] ] ]; + $action_forms = $registered_forms['/form']; + $option_name = 'hcaptcha_auto_verify_form_' . hash( 'sha256', '/form' ); + WP_Mock::userFunction( 'update_option' ) + ->with( $option_name, [ [ 'source' => 'post:2' ] ], false ) + ->once(); + + $method = $this->set_method_accessibility( $subject, 'remove_form_registration' ); + $method->invokeArgs( $subject, [ &$registered_forms, [ 'source' => 'post:1' ], '/form', $action_forms, 0 ] ); + + self::assertSame( [ '/form' => [ [ 'source' => 'post:2' ] ] ], $registered_forms ); + } } diff --git a/tests/php/unit/Dependencies/PluginDependencyManagerTest.php b/tests/php/unit/Dependencies/PluginDependencyManagerTest.php new file mode 100644 index 000000000..016753878 --- /dev/null +++ b/tests/php/unit/Dependencies/PluginDependencyManagerTest.php @@ -0,0 +1,186 @@ + [ 'RequiresPlugins' => [ 'base/base.php' ] ], + 'base/base.php' => [ 'Name' => 'Base' ], + ] + ); + + self::assertSame( [ 'base/base.php' ], $manager->get_dependencies( 'pro/pro.php' ) ); + } + + + /** + * Test dependencies from the plugin header and injected configuration. + */ + public function test_get_dependencies_combines_header_and_injected_configuration(): void { + $plugins = [ + 'pro/pro.php' => [ + 'Name' => 'Pro', + 'RequiresPlugins' => 'base, shared', + ], + 'base/base.php' => [ 'Name' => 'Base' ], + 'shared/plugin.php' => [ 'Name' => 'Shared' ], + 'configured/configured.php' => [ 'Name' => 'Configured' ], + ]; + $manager = new PluginDependencyManager( + $plugins, + [ + 'pro/pro.php' => 'configured/configured.php', + ] + ); + + self::assertSame( + [ + 'base/base.php', + 'shared/plugin.php', + 'configured/configured.php', + ], + $manager->get_dependencies( 'pro/pro.php' ) + ); + } + + /** + * Test activation plan contains only inactive dependencies. + */ + public function test_get_activation_plan_contains_only_inactive_dependencies(): void { + $plugins = $this->get_plugins(); + $manager = new PluginDependencyManager( $plugins, $this->get_dependencies() ); + $plan = $manager->get_activation_plan( + [ 'pro/pro.php' ], + [ 'foundation/foundation.php' ] + ); + + self::assertSame( [ 'base/base.php' ], array_column( $plan['items'], 'plugin' ) ); + self::assertSame( [ 'Base' ], array_column( $plan['items'], 'name' ) ); + } + + /** + * Test dependencies required outside the deactivation tree are blocked. + */ + public function test_get_deactivation_plan_blocks_dependencies_used_outside_tree(): void { + $plugins = $this->get_plugins(); + $manager = new PluginDependencyManager( $plugins, $this->get_dependencies() ); + $plan = $manager->get_deactivation_plan( + [ 'pro/pro.php' ], + array_keys( $plugins ) + ); + + self::assertSame( [ 'pro/pro.php' ], $plan['roots'] ); + self::assertSame( 'base/base.php', $plan['items'][0]['plugin'] ); + self::assertTrue( $plan['items'][0]['disabled'] ); + self::assertSame( [ 'Outside Add-on' ], $plan['items'][0]['requiredBy'] ); + self::assertSame( 'foundation/foundation.php', $plan['items'][1]['plugin'] ); + self::assertTrue( $plan['items'][1]['disabled'] ); + self::assertSame( [ 'Outside Add-on' ], $plan['items'][1]['requiredBy'] ); + } + + /** + * Test all theme dependency roots are optional plan items. + */ + public function test_get_deactivation_plan_can_include_roots_as_optional_items(): void { + $plugins = $this->get_plugins(); + $manager = new PluginDependencyManager( $plugins, $this->get_dependencies() ); + $plan = $manager->get_deactivation_plan( + [ 'base/base.php' ], + [ 'base/base.php', 'foundation/foundation.php' ], + true + ); + + self::assertSame( [], $plan['roots'] ); + self::assertSame( [ 'base/base.php', 'foundation/foundation.php' ], array_column( $plan['items'], 'plugin' ) ); + self::assertSame( [ 0, 1 ], array_column( $plan['items'], 'depth' ) ); + } + + /** + * Test an external theme can block deactivation of a root plugin. + */ + public function test_get_deactivation_plan_reports_blocked_root(): void { + $plugins = $this->get_plugins(); + $manager = new PluginDependencyManager( $plugins, $this->get_dependencies() ); + $plan = $manager->get_deactivation_plan( + [ 'pro/pro.php' ], + [ 'pro/pro.php', 'base/base.php', 'foundation/foundation.php' ], + false, + [ 'Active Theme' => [ 'pro/pro.php' ] ] + ); + + self::assertSame( [ 'Active Theme' ], $plan['rootBlockedBy'] ); + } + + /** + * Test unsafe partial selections are removed while the root remains selected. + */ + public function test_get_safe_deactivation_plugins_rejects_dependency_with_active_parent(): void { + $plugins = $this->get_plugins(); + $manager = new PluginDependencyManager( $plugins, $this->get_dependencies() ); + $active = [ 'pro/pro.php', 'base/base.php', 'foundation/foundation.php' ]; + $plan = $manager->get_deactivation_plan( [ 'pro/pro.php' ], $active ); + + self::assertSame( + [ 'pro/pro.php' ], + $manager->get_safe_deactivation_plugins( + $plan, + [ 'foundation/foundation.php' ], + $active + ) + ); + + self::assertSame( + [ 'pro/pro.php', 'base/base.php', 'foundation/foundation.php' ], + $manager->get_safe_deactivation_plugins( + $plan, + [ 'base/base.php', 'foundation/foundation.php' ], + $active + ) + ); + } + + /** + * Get test plugins. + * + * @return array + */ + private function get_plugins(): array { + return [ + 'pro/pro.php' => [ 'Name' => 'Pro' ], + 'base/base.php' => [ 'Name' => 'Base' ], + 'foundation/foundation.php' => [ 'Name' => 'Foundation' ], + 'outside-add-on/outside-add-on.php' => [ 'Name' => 'Outside Add-on' ], + ]; + } + + /** + * Get test additional dependencies. + * + * @return array + */ + private function get_dependencies(): array { + return [ + 'pro/pro.php' => 'base/base.php', + 'base/base.php' => 'foundation/foundation.php', + 'outside-add-on/outside-add-on.php' => 'base/base.php', + ]; + } +} diff --git a/tests/php/unit/HCaptchaTestCase.php b/tests/php/unit/HCaptchaTestCase.php index d94dc6d3f..a50d46713 100644 --- a/tests/php/unit/HCaptchaTestCase.php +++ b/tests/php/unit/HCaptchaTestCase.php @@ -268,6 +268,7 @@ protected function get_test_settings(): array { 'login', 'lost_pass', 'order_tracking', + 'order_withdrawal', 'register', ], 'woocommerce_wishlists_status' => @@ -367,10 +368,12 @@ protected function get_test_general_form_fields(): array { 'helper' => 'To fill out the site key, set Mode to Live.', ], 'secret_key' => [ - 'label' => 'Secret Key', - 'type' => 'password', - 'section' => General::SECTION_KEYS, - 'helper' => 'To fill out the secret key, set Mode to Live.', + 'label' => 'Secret Key', + 'type' => 'password', + 'placeholder' => str_repeat( '*', 35 ), + 'sensitive' => true, + 'section' => General::SECTION_KEYS, + 'helper' => 'To fill out the secret key, set Mode to Live.', ], 'sample_hcaptcha' => [ 'label' => 'Active hCaptcha to Check Site Config', @@ -486,12 +489,14 @@ protected function get_test_general_form_fields(): array { 'mi' => 'Maori', 'mr' => 'Marathi', 'mn' => 'Mongolian', + 'me' => 'Montenegrin (as Bosnian)', 'ne' => 'Nepali', 'no' => 'Norwegian', 'ny' => 'Nyanja', 'or' => 'Oriya', 'pl' => 'Polish', 'pt' => 'Portuguese', + 'pt-BR' => 'Portuguese (Brazil)', 'ps' => 'Pashto', 'pa' => 'Punjabi', 'ro' => 'Romanian', @@ -588,6 +593,25 @@ protected function get_test_general_form_fields(): array { 'type' => 'textarea', 'section' => General::SECTION_CUSTOM, ], + 'risk_score' => [ + 'label' => 'Risk Score', + 'type' => 'checkbox', + 'section' => General::SECTION_ENTERPRISE, + 'options' => [ + 'on' => 'Enable Risk Score Enforcement', + ], + 'helper' => 'Block submissions when the Enterprise risk score reaches the configured threshold. A higher score means a greater risk.', + ], + 'risk_score_threshold' => [ + 'label' => 'Risk Score Threshold', + 'type' => 'number', + 'section' => General::SECTION_ENTERPRISE, + 'default' => General::DEFAULT_RISK_SCORE_THRESHOLD, + 'min' => 0, + 'max' => 1, + 'step' => 0.1, + 'helper' => 'Scores greater than or equal to this value are blocked. If enforcement is enabled and hCaptcha returns no valid score, the submission is blocked.', + ], 'api_host' => [ 'label' => 'API Host', 'type' => 'text', @@ -671,6 +695,14 @@ protected function get_test_general_form_fields(): array { ], 'helper' => 'Do not show hCaptcha to logged-in users.', ], + 'ajax_forms' => [ + 'type' => 'checkbox', + 'section' => General::SECTION_OTHER, + 'options' => [ + 'on' => 'Submit supported forms via AJAX', + ], + 'helper' => 'AJAX submission is not available for every form. Currently, it supports the standard WordPress comment form.', + ], 'recaptcha_compat_off' => [ 'type' => 'checkbox', 'section' => General::SECTION_OTHER, @@ -702,7 +734,7 @@ protected function get_test_general_form_fields(): array { 'options' => [ 'on' => 'Enable Statistics', ], - 'helper' => 'By turning the statistics on, you agree to the collection of non-personal data to improve the plugin.', + 'helper' => "Enabling Statistics stores local event data and sends the current request's visitor IP, site URL, and plugin configuration to hCaptcha to improve the plugin. hCaptcha site and secret key values are not sent.", ], 'anonymous' => [ 'type' => 'checkbox', @@ -711,7 +743,7 @@ protected function get_test_general_form_fields(): array { 'on' => 'Collect Anonymously', ], 'default' => 'on', - 'helper' => 'Store collected IP and User Agent locally as hashed values to conform to GDPR requirements.', + 'helper' => 'Store collected IP and User Agent locally as salted hashes. This affects only local event storage and does not anonymize or disable remote Statistics reports.', ], 'collect_ip' => [ 'label' => 'Collection', @@ -746,7 +778,7 @@ protected function get_test_general_form_fields(): array { */ protected function get_test_integrations_form_fields(): array { return [ - 'show_antispam_coverage' => [ + 'show_antispam_coverage' => [ 'type' => 'checkbox', 'section' => Integrations::SECTION_HEADER, 'options' => [ @@ -754,7 +786,7 @@ protected function get_test_integrations_form_fields(): array { ], 'helper' => 'Shows icons for built-in antispam methods (Honeypot, Time check) for supported integrations, including inactive ones.', ], - 'wp_status' => + 'wp_status' => [ 'entity' => 'core', 'label' => 'WP Core', @@ -768,7 +800,7 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Register Form', ], ], - 'acfe_status' => + 'acfe_status' => [ 'label' => 'ACF Extended', 'type' => 'checkbox', @@ -777,7 +809,7 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'ACF Extended Form', ], ], - 'affiliates_status' => + 'affiliates_status' => [ 'label' => 'Affiliates', 'type' => 'checkbox', @@ -787,14 +819,14 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Affiliates Register Form', ], ], - 'asgaros_status' => [ + 'asgaros_status' => [ 'label' => 'Asgaros', 'type' => 'checkbox', 'options' => [ 'form' => 'Form', ], ], - 'avada_status' => + 'avada_status' => [ 'entity' => 'theme', 'label' => 'Avada', @@ -804,14 +836,14 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Avada Form', ], ], - 'back_in_stock_notifier_status' => [ + 'back_in_stock_notifier_status' => [ 'label' => 'Back In Stock Notifier', 'type' => 'checkbox', 'options' => [ 'form' => 'Back In Stock Notifier Form', ], ], - 'bbp_status' => + 'bbp_status' => [ 'label' => 'bbPress', 'type' => 'checkbox', @@ -824,7 +856,7 @@ protected function get_test_integrations_form_fields(): array { 'reply' => 'Reply Form', ], ], - 'beaver_builder_status' => + 'beaver_builder_status' => [ 'label' => 'Beaver Builder', 'logo' => 'svg', @@ -835,7 +867,7 @@ protected function get_test_integrations_form_fields(): array { 'login' => 'Login Form', ], ], - 'blocksy_status' => [ + 'blocksy_status' => [ 'label' => 'blocksy', 'entity' => 'theme', 'logo' => 'svg', @@ -846,7 +878,7 @@ protected function get_test_integrations_form_fields(): array { 'waitlist' => 'Waitlist Form (Pro)', ], ], - 'brizy_status' => [ + 'brizy_status' => [ 'label' => 'Brizy', 'logo' => 'svg', 'type' => 'checkbox', @@ -854,7 +886,7 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Form', ], ], - 'bp_status' => + 'bp_status' => [ 'label' => 'BuddyPress', 'logo' => 'svg', @@ -865,7 +897,7 @@ protected function get_test_integrations_form_fields(): array { 'registration' => 'Register Form', ], ], - 'classified_listing_status' => [ + 'classified_listing_status' => [ 'label' => 'Classified Listing', 'type' => 'checkbox', 'options' => [ @@ -875,14 +907,14 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Register Form', ], ], - 'coblocks_status' => [ + 'coblocks_status' => [ 'label' => 'CoBlocks', 'type' => 'checkbox', 'options' => [ 'form' => 'Form', ], ], - 'colorlib_customizer_status' => [ + 'colorlib_customizer_status' => [ 'label' => 'Colorlib Login Customizer', 'type' => 'checkbox', 'options' => [ @@ -891,7 +923,7 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Register Form', ], ], - 'cf7_status' => + 'cf7_status' => [ 'label' => 'Contact Form 7', 'logo' => 'svg', @@ -904,7 +936,7 @@ protected function get_test_integrations_form_fields(): array { 'replace_rsc' => 'Replace Really Simple CAPTCHA', ], ], - 'customer_reviews_status' => + 'customer_reviews_status' => [ 'label' => 'Customer Reviews', 'logo' => 'svg', @@ -914,7 +946,7 @@ protected function get_test_integrations_form_fields(): array { 'review' => 'Review Form', ], ], - 'divi_status' => + 'divi_status' => [ 'entity' => 'theme', 'label' => 'Divi', @@ -927,7 +959,7 @@ protected function get_test_integrations_form_fields(): array { 'login' => 'Divi Login Form', ], ], - 'divi_builder_status' => [ + 'divi_builder_status' => [ 'label' => 'Divi Builder', 'type' => 'checkbox', 'options' => [ @@ -937,7 +969,7 @@ protected function get_test_integrations_form_fields(): array { 'login' => 'Divi Builder Login Form', ], ], - 'download_manager_status' => + 'download_manager_status' => [ 'label' => 'Download Manager', 'type' => 'checkbox', @@ -946,7 +978,7 @@ protected function get_test_integrations_form_fields(): array { 'button' => 'Button', ], ], - 'easy_digital_downloads_status' => [ + 'easy_digital_downloads_status' => [ 'label' => 'Easy Digital Downloads', 'logo' => 'svg', 'type' => 'checkbox', @@ -957,7 +989,7 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Register Form', ], ], - 'elementor_pro_status' => + 'elementor_pro_status' => [ 'label' => 'Elementor Pro', 'logo' => 'svg', @@ -968,7 +1000,7 @@ protected function get_test_integrations_form_fields(): array { 'login' => 'Login', ], ], - 'essential_addons_status' => [ + 'essential_addons_status' => [ 'label' => 'Essential Addons', 'type' => 'checkbox', 'options' => [ @@ -976,14 +1008,14 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Register', ], ], - 'essential_blocks_status' => [ + 'essential_blocks_status' => [ 'label' => 'Essential Blocks', 'type' => 'checkbox', 'options' => [ 'form' => 'Form', ], ], - 'events_manager_status' => [ + 'events_manager_status' => [ 'label' => 'Events Manager', 'logo' => 'svg', 'type' => 'checkbox', @@ -991,7 +1023,7 @@ protected function get_test_integrations_form_fields(): array { 'booking' => 'Booking', ], ], - 'extra_status' => [ + 'extra_status' => [ 'entity' => 'theme', 'label' => 'Extra', 'logo' => 'svg', @@ -1003,7 +1035,7 @@ protected function get_test_integrations_form_fields(): array { 'login' => 'Extra Login Form', ], ], - 'fluent_status' => + 'fluent_status' => [ 'label' => 'Fluent Forms', 'type' => 'checkbox', @@ -1012,7 +1044,7 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Form', ], ], - 'formidable_forms_status' => [ + 'formidable_forms_status' => [ 'label' => 'Formidable Forms', 'logo' => 'svg', 'type' => 'checkbox', @@ -1020,7 +1052,7 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Form', ], ], - 'forminator_status' => + 'forminator_status' => [ 'label' => 'Forminator', 'type' => 'checkbox', @@ -1029,7 +1061,7 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Form', ], ], - 'give_wp_status' => [ + 'give_wp_status' => [ 'label' => 'GiveWP', 'logo' => 'svg', 'type' => 'checkbox', @@ -1037,7 +1069,7 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Form', ], ], - 'gravity_status' => + 'gravity_status' => [ 'label' => 'Gravity Forms', 'logo' => 'svg', @@ -1048,14 +1080,14 @@ protected function get_test_integrations_form_fields(): array { 'embed' => 'Form Embed', ], ], - 'html_forms_status' => [ + 'html_forms_status' => [ 'label' => 'HTML Forms', 'type' => 'checkbox', 'options' => [ 'form' => 'Form', ], ], - 'icegram_express_status' => + 'icegram_express_status' => [ 'label' => 'Icegram Express', 'type' => 'checkbox', @@ -1064,7 +1096,7 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Form', ], ], - 'jetpack_status' => + 'jetpack_status' => [ 'label' => 'Jetpack', 'logo' => 'svg', @@ -1074,7 +1106,7 @@ protected function get_test_integrations_form_fields(): array { 'contact' => 'Contact Form', ], ], - 'kadence_status' => + 'kadence_status' => [ 'label' => 'Kadence', 'logo' => 'svg', @@ -1085,7 +1117,7 @@ protected function get_test_integrations_form_fields(): array { 'advanced_form' => 'Kadence Advanced Form', ], ], - 'learn_dash_status' => + 'learn_dash_status' => [ 'label' => 'LearnDash LMS', 'logo' => 'svg', @@ -1097,7 +1129,7 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Register Form', ], ], - 'learn_press_status' => [ + 'learn_press_status' => [ 'label' => 'LearnPress', 'type' => 'checkbox', 'options' => [ @@ -1106,7 +1138,7 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Register Form', ], ], - 'login_signup_popup_status' => + 'login_signup_popup_status' => [ 'label' => 'Login Signup Popup', 'type' => 'checkbox', @@ -1116,7 +1148,7 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Register Form', ], ], - 'mailchimp_status' => + 'mailchimp_status' => [ 'label' => 'Mailchimp for WP', 'logo' => 'svg', @@ -1126,7 +1158,7 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Form', ], ], - 'mailpoet_status' => + 'mailpoet_status' => [ 'label' => 'MailPoet', 'logo' => 'svg', @@ -1135,7 +1167,7 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Form', ], ], - 'maintenance_status' => + 'maintenance_status' => [ 'label' => 'Maintenance', 'type' => 'checkbox', @@ -1143,7 +1175,7 @@ protected function get_test_integrations_form_fields(): array { 'login' => 'Login Form', ], ], - 'memberpress_status' => + 'memberpress_status' => [ 'label' => 'MemberPress', 'logo' => 'svg', @@ -1154,7 +1186,7 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Register Form', ], ], - 'metform_status' => + 'metform_status' => [ 'label' => 'MetForm', 'logo' => 'svg', @@ -1164,7 +1196,7 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Form', ], ], - 'ninja_status' => + 'ninja_status' => [ 'label' => 'Ninja Forms', 'type' => 'checkbox', @@ -1173,7 +1205,7 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Form', ], ], - 'otter_status' => + 'otter_status' => [ 'label' => 'Otter Blocks', 'type' => 'checkbox', @@ -1182,31 +1214,21 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Form', ], ], - 'paid_memberships_pro_status' => - [ - 'label' => 'Paid Memberships Pro', - 'logo' => 'svg', - 'type' => 'checkbox', - 'options' => [ - 'checkout' => 'Checkout Form', - 'login' => 'Login Form', - ], - ], - 'passster_status' => [ + 'passster_status' => [ 'label' => 'Passster', 'type' => 'checkbox', 'options' => [ 'protect' => 'Protection Form', ], ], - 'password_protected_status' => [ + 'password_protected_status' => [ 'label' => 'Password Protected', 'type' => 'checkbox', 'options' => [ 'protect' => 'Protection Form', ], ], - 'profile_builder_status' => [ + 'profile_builder_status' => [ 'label' => 'Profile Builder', 'type' => 'checkbox', 'options' => [ @@ -1215,7 +1237,7 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Register Form', ], ], - 'quform_status' => + 'quform_status' => [ 'label' => 'Quform', 'type' => 'checkbox', @@ -1224,7 +1246,7 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Form', ], ], - 'sendinblue_status' => + 'sendinblue_status' => [ 'label' => 'Brevo', 'logo' => 'svg', @@ -1234,21 +1256,14 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Form', ], ], - 'simple_basic_contact_form_status' => [ - 'label' => 'Simple Basic Contact Form', - 'type' => 'checkbox', - 'options' => [ - 'form' => 'Form', - ], - ], - 'simple_download_monitor_status' => [ + 'simple_download_monitor_status' => [ 'label' => 'Simple Download Monitor', 'type' => 'checkbox', 'options' => [ 'form' => 'Form', ], ], - 'simple_membership_status' => [ + 'simple_membership_status' => [ 'label' => 'Simple Membership', 'type' => 'checkbox', 'options' => [ @@ -1257,7 +1272,7 @@ protected function get_test_integrations_form_fields(): array { 'lost_pass' => 'Password Reset Form', ], ], - 'spectra_status' => [ + 'spectra_status' => [ 'label' => 'Spectra', 'logo' => 'svg', 'type' => 'checkbox', @@ -1265,7 +1280,7 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Form', ], ], - 'subscriber_status' => + 'subscriber_status' => [ 'label' => 'Subscriber', 'type' => 'checkbox', @@ -1274,14 +1289,14 @@ protected function get_test_integrations_form_fields(): array { 'form' => 'Form', ], ], - 'supportcandy_status' => [ + 'supportcandy_status' => [ 'label' => 'Support Candy', 'type' => 'checkbox', 'options' => [ 'form' => 'Form', ], ], - 'theme_my_login_status' => [ + 'theme_my_login_status' => [ 'label' => 'Theme My Login', 'type' => 'checkbox', 'options' => [ @@ -1290,7 +1305,7 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Register Form', ], ], - 'tutor_status' => [ + 'tutor_status' => [ 'label' => 'Tutor LMS', 'logo' => 'svg', 'type' => 'checkbox', @@ -1301,7 +1316,7 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Register Form', ], ], - 'ultimate_addons_status' => + 'ultimate_addons_status' => [ 'label' => 'Ultimate Addons', 'logo' => 'svg', @@ -1312,7 +1327,7 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Register Form', ], ], - 'ultimate_member_status' => + 'ultimate_member_status' => [ 'label' => 'Ultimate Member', 'type' => 'checkbox', @@ -1323,7 +1338,7 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Register Form', ], ], - 'users_wp_status' => [ + 'users_wp_status' => [ 'label' => 'Users WP', 'type' => 'checkbox', 'options' => [ @@ -1332,7 +1347,7 @@ protected function get_test_integrations_form_fields(): array { 'register' => 'Register Form', ], ], - 'woocommerce_status' => + 'woocommerce_status' => [ 'label' => 'WooCommerce', 'type' => 'checkbox', @@ -1343,10 +1358,11 @@ protected function get_test_integrations_form_fields(): array { 'login' => 'Login Form', 'lost_pass' => 'Lost Password Form', 'order_tracking' => 'Order Tracking Form', + 'order_withdrawal' => 'Order Withdrawal Form', 'register' => 'Register Form', ], ], - 'woocommerce_germanized_status' => + 'woocommerce_germanized_status' => [ 'label' => 'WooCommerce Germanized', 'type' => 'checkbox', @@ -1355,7 +1371,7 @@ protected function get_test_integrations_form_fields(): array { 'return_request' => 'Return Request Form', ], ], - 'paypal_payments_status' => + 'paypal_payments_status' => [ 'label' => 'WooCommerce PayPal Payments', 'type' => 'checkbox', @@ -1364,7 +1380,7 @@ protected function get_test_integrations_form_fields(): array { 'button' => 'PayPal Button', ], ], - 'woocommerce_wishlists_status' => + 'woocommerce_wishlists_status' => [ 'label' => 'WooCommerce Wishlists', 'type' => 'checkbox', @@ -1373,7 +1389,7 @@ protected function get_test_integrations_form_fields(): array { 'create_list' => 'Create List Form', ], ], - 'wordfence_status' => [ + 'wordfence_status' => [ 'label' => 'Wordfence', 'logo' => 'svg', 'type' => 'checkbox', @@ -1381,7 +1397,7 @@ protected function get_test_integrations_form_fields(): array { 'login' => 'Login Form', ], ], - 'wpforms_status' => + 'wpforms_status' => [ 'label' => 'WPForms', 'type' => 'checkbox', @@ -1391,7 +1407,7 @@ protected function get_test_integrations_form_fields(): array { 'embed' => 'Form Embed', ], ], - 'wpdiscuz_status' => + 'wpdiscuz_status' => [ 'label' => 'WPDiscuz', 'type' => 'checkbox', @@ -1401,7 +1417,7 @@ protected function get_test_integrations_form_fields(): array { 'subscribe_form' => 'Subscribe Form', ], ], - 'wpforo_status' => + 'wpforo_status' => [ 'label' => 'WPForo', 'type' => 'checkbox', @@ -1411,7 +1427,7 @@ protected function get_test_integrations_form_fields(): array { 'reply' => 'Reply Form', ], ], - 'wp_job_openings_status' => + 'wp_job_openings_status' => [ 'label' => 'WP Job Openings', 'type' => 'checkbox', diff --git a/tests/php/unit/MainTest.php b/tests/php/unit/MainTest.php deleted file mode 100644 index 677abf939..000000000 --- a/tests/php/unit/MainTest.php +++ /dev/null @@ -1,237 +0,0 @@ -init(); - - self::assertSame( [], $this->get_protected_property( $subject, 'loaded_classes' ) ); - } - - /** - * Test declare_wc_compatibility(). - * - * @return void - */ - public function test_declare_wc_compatibility(): void { - $mock = Mockery::mock( 'alias:Automattic\WooCommerce\Utilities\FeaturesUtil' ); - $mock->shouldReceive( 'declare_compatibility' ) - ->with( 'custom_order_tables', HCAPTCHA_TEST_FILE ) - ->andReturn( true ); - - FunctionMocker::replace( - 'constant', - static function ( $name ) { - if ( 'HCAPTCHA_FILE' === $name ) { - return HCAPTCHA_TEST_FILE; - } - - return ''; - } - ); - - $subject = new Main(); - $subject->declare_wc_compatibility(); - } - - /** - * Test register_recurring_actions() schedules cleanup when statistics are on. - * - * @return void - */ - public function test_register_recurring_actions_schedules_events_cleanup_when_statistics_are_on(): void { - $this->define_day_in_seconds(); - - $settings = Mockery::mock( Settings::class ); - $settings->shouldReceive( 'is_on' )->once()->with( 'statistics' )->andReturn( true ); - - $subject = Mockery::mock( Main::class )->makePartial(); - $subject->shouldReceive( 'settings' )->once()->andReturn( $settings ); - - WP_Mock::userFunction( 'get_option' )->once()->with( 'gmt_offset' )->andReturn( 0 ); - WP_Mock::userFunction( 'absint' )->once()->with( 0 )->andReturn( 0 ); - WP_Mock::userFunction( 'as_schedule_recurring_action' ) - ->once() - ->with( - Mockery::type( 'int' ), - 15 * DAY_IN_SECONDS, - MaxMindDb::UPDATE_ACTION, - [], - 'hcaptcha', - true - ); - WP_Mock::userFunction( 'as_schedule_recurring_action' ) - ->once() - ->with( - Mockery::type( 'int' ), - DAY_IN_SECONDS, - Events::CLEANUP_ACTION, - [], - 'hcaptcha', - true - ); - WP_Mock::userFunction( 'as_unschedule_all_actions' )->never(); - - $subject->register_recurring_actions(); - } - - /** - * Test register_recurring_actions() unschedules cleanup when statistics are off. - * - * @return void - */ - public function test_register_recurring_actions_unschedules_events_cleanup_when_statistics_are_off(): void { - $this->define_day_in_seconds(); - - $settings = Mockery::mock( Settings::class ); - $settings->shouldReceive( 'is_on' )->once()->with( 'statistics' )->andReturn( false ); - - $subject = Mockery::mock( Main::class )->makePartial(); - $subject->shouldReceive( 'settings' )->once()->andReturn( $settings ); - - WP_Mock::userFunction( 'get_option' )->once()->with( 'gmt_offset' )->andReturn( 0 ); - WP_Mock::userFunction( 'absint' )->once()->with( 0 )->andReturn( 0 ); - WP_Mock::userFunction( 'as_schedule_recurring_action' ) - ->once() - ->with( - Mockery::type( 'int' ), - 15 * DAY_IN_SECONDS, - MaxMindDb::UPDATE_ACTION, - [], - 'hcaptcha', - true - ); - WP_Mock::userFunction( 'as_unschedule_all_actions' ) - ->once() - ->with( Events::CLEANUP_ACTION, [], 'hcaptcha' ); - - $subject->register_recurring_actions(); - } - - /** - * Test get_client_country_code(). - * - * @return void - * @throws ReflectionException ReflectionException. - */ - public function test_get_client_country_code(): void { - $client_ip = '203.0.113.40'; - $error_ip = '203.0.113.41'; - - $db_path = WP_CONTENT_DIR . '/uploads/hcaptcha/GeoLite2-Country.mmdb'; - - FunctionMocker::replace( 'is_readable', true ); - - $reader = Mockery::mock( 'overload:HCaptcha\Vendors\GeoIp2\Database\Reader' ); - $reader->shouldReceive( '__construct' )->with( $db_path ); - $reader->shouldReceive( 'country' )->andReturnUsing( - static function ( string $ip ) use ( $client_ip, $error_ip ) { - if ( $error_ip === $ip ) { - throw new RuntimeException( 'Reader error.' ); - } - - self::assertSame( $client_ip, $ip ); - - return (object) [ - 'country' => (object) [ - 'isoCode' => ' us ', - ], - ]; - } - ); - $reader->shouldReceive( 'close' )->andReturnNull(); - - $subject = new Main(); - $method = $this->set_method_accessibility( $subject, 'get_client_country_code' ); - - self::assertSame( 'US', $method->invoke( $subject, $client_ip ) ); - self::assertSame( '', $method->invoke( $subject, $error_ip ) ); - } - - /** - * Test load_modules() on multisite. - * - * @return void - * @throws ReflectionException ReflectionException. - */ - public function test_load_modules_on_multisite(): void { - FunctionMocker::replace( 'is_multisite', true ); - FunctionMocker::replace( - 'function_exists', - static function ( $function_name ) { - return 'is_plugin_active' === $function_name; - } - ); - - $subject = new Main(); - - $this->set_protected_property( $subject, 'active', false ); - $subject->load_modules(); - - self::assertSame( - [ - [ 'wp_status', 'signup' ], - '', - Signup::class, - ], - $subject->modules['Signup Form'] - ); - self::assertSame( - [ - [ 'theme_my_login_status', 'signup' ], - 'theme-my-login/theme-my-login.php', - \HCaptcha\ThemeMyLogin\Signup::class, - ], - $subject->modules['Theme My Login Signup'] - ); - self::assertArrayNotHasKey( 'Theme My Login Register', $subject->modules ); - } - - /** - * Define the DAY_IN_SECONDS constant if WordPress has not defined it. - * - * @return void - */ - private function define_day_in_seconds(): void { - if ( defined( 'DAY_IN_SECONDS' ) ) { - return; - } - - define( 'DAY_IN_SECONDS', 24 * 60 * 60 ); - } -} diff --git a/tests/php/unit/MigrationWizard/Detectors/BrevoDetectorTest.php b/tests/php/unit/MigrationWizard/Detectors/BrevoDetectorTest.php index 50807fbdd..1df1761a4 100644 --- a/tests/php/unit/MigrationWizard/Detectors/BrevoDetectorTest.php +++ b/tests/php/unit/MigrationWizard/Detectors/BrevoDetectorTest.php @@ -10,6 +10,7 @@ use HCaptcha\MigrationWizard\DetectionResult; use HCaptcha\MigrationWizard\Detectors\BrevoDetector; use HCaptcha\Tests\Unit\HCaptchaTestCase; +use Mockery; use WP_Mock; /** @@ -83,12 +84,23 @@ private function setup_wpdb( ?string $recaptcha_count, ?string $turnstile_count global $wpdb; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - $wpdb = \Mockery::mock( 'wpdb' ); + $wpdb = Mockery::mock( 'wpdb' ); $wpdb->prefix = 'wp_'; + WP_Mock::userFunction( 'esc_sql' ) + ->with( 'wp_sib_model_forms' ) + ->andReturn( 'wp_sib_model_forms' ); + $wpdb->shouldReceive( 'prepare' ) ->twice() - ->andReturn( 'prepared_query' ); + ->andReturnUsing( + static function ( $query ) { + self::assertStringContainsString( 'FROM `wp_sib_model_forms`', $query ); + self::assertStringNotContainsString( '%i', $query ); + + return 'prepared_query'; + } + ); $wpdb->shouldReceive( 'get_var' ) ->with( 'prepared_query' ) @@ -129,7 +141,7 @@ public function test_detect_with_turnstile(): void { } /** - * Test detect with both reCAPTCHA and Turnstile configured. + * Test detect function with both reCAPTCHA and Turnstile configured. * * @return void */ @@ -145,7 +157,7 @@ public function test_detect_with_both_providers(): void { } /** - * Test detect when no captcha is configured. + * Test detect function when no captcha is configured. * * @return void */ diff --git a/tests/php/unit/MigrationWizard/Detectors/EssentialBlocksDetectorTest.php b/tests/php/unit/MigrationWizard/Detectors/EssentialBlocksDetectorTest.php new file mode 100644 index 000000000..d63f5217b --- /dev/null +++ b/tests/php/unit/MigrationWizard/Detectors/EssentialBlocksDetectorTest.php @@ -0,0 +1,271 @@ +get_source_plugin() ); + self::assertSame( 'Essential Blocks', $detector->get_source_name() ); + } + + /** + * Test applicability when Essential Blocks is active. + */ + public function test_is_applicable_when_active(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'active_plugins', [] ) + ->andReturn( [ 'essential-blocks/essential-blocks.php' ] ); + + self::assertTrue( ( new EssentialBlocksDetector() )->is_applicable() ); + } + + /** + * Test applicability when Essential Blocks is inactive. + */ + public function test_is_applicable_when_inactive(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'active_plugins', [] ) + ->andReturn( [] ); + + self::assertFalse( ( new EssentialBlocksDetector() )->is_applicable() ); + } + + /** + * Mock the query for candidate form posts. + * + * @param array $post_ids Candidate post IDs. + */ + private function mock_candidate_posts( array $post_ids ): void { + global $wpdb; + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wpdb = Mockery::mock( 'wpdb' ); + $wpdb->posts = 'wp_posts'; + $wpdb->shouldReceive( 'esc_like' ) + ->once() + ->with( '