Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion modules/ppcp-fraud-protection/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
$container->get( 'fraud-protection.asset_getter' ),
$container->get( 'ppcp.asset-version' ),
$container->get( 'woocommerce.logger.woocommerce' ),
$container->get( 'fraud-protection.recaptcha.rejection-counter' )
$container->get( 'fraud-protection.recaptcha.rejection-counter' ),
$container->get( 'wcgateway.settings.status' )
);
},
'fraud-protection.recaptcha.integration' => static function (): RecaptchaIntegration {
Expand Down
64 changes: 62 additions & 2 deletions modules/ppcp-fraud-protection/src/Recaptcha/Recaptcha.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use WC_Product;
use WooCommerce\PayPalCommerce\Assets\AssetGetter;
use WooCommerce\PayPalCommerce\FraudProtection\PersistentCounter;
use WooCommerce\PayPalCommerce\WcGateway\Helper\SettingsStatus;
use WP_Error;
use WP_Post;

Expand Down Expand Up @@ -40,6 +41,8 @@ class Recaptcha {

private PersistentCounter $rejection_counter;

private SettingsStatus $settings_status;

private float $last_v3_score = 0;

/**
Expand All @@ -49,14 +52,16 @@ class Recaptcha {
* @param string $asset_version
* @param LoggerInterface $logger
* @param PersistentCounter $rejection_counter
* @param SettingsStatus $settings_status
*/
public function __construct(
RecaptchaIntegration $integration,
array $payment_methods,
AssetGetter $asset_getter,
string $asset_version,
LoggerInterface $logger,
PersistentCounter $rejection_counter
PersistentCounter $rejection_counter,
SettingsStatus $settings_status
) {

$this->integration = $integration;
Expand All @@ -65,6 +70,7 @@ public function __construct(
$this->asset_version = $asset_version;
$this->logger = $logger;
$this->rejection_counter = $rejection_counter;
$this->settings_status = $settings_status;
}

protected function should_use_recaptcha(): bool {
Expand Down Expand Up @@ -101,7 +107,7 @@ public function render_settings_page_log(): string {
}

public function enqueue_scripts(): void {
if ( ! is_checkout() && ! is_cart() && ! is_product() ) {
if ( ! $this->should_enqueue_for_current_location() ) {
return;
}

Expand Down Expand Up @@ -154,9 +160,63 @@ public function render_v2_container(): string {
return '';
}

if ( ! $this->should_enqueue_for_current_location() ) {
return '';
}

return '<div id="' . esc_attr( self::V2_CONTAINER_ID ) . '" style="margin:20px 0;"></div>';
}

/**
* Returns the current page's smart button location key, or null when the
* current request is not one of the locations reCAPTCHA supports.
*
* Mirrors the location keys SmartButton uses via SettingsStatus. Checkout
* is split into classic vs. block/express since a merchant may enable
* PayPal for one but not the other.
*/
private function current_smart_button_location(): ?string {
if ( is_product() ) {
return 'product';
}

if ( is_cart() ) {
return 'cart';
}

if ( is_checkout() ) {
return has_block( 'woocommerce/checkout' ) ? 'checkout-block-express' : 'checkout';
}

return null;
}

/**
* Whether reCAPTCHA assets/markup are allowed to load for the current
* page, based on whether the merchant has PayPal enabled for this smart
* button location — the same gating SmartButton uses, so reCAPTCHA never
* loads where no PayPal button/gateway can actually be used.
*/
private function should_enqueue_for_current_location(): bool {
$location = $this->current_smart_button_location();

$should_enqueue = null !== $location
&& $this->settings_status->is_smart_button_enabled_for_location( $location );

/**
* Filters whether reCAPTCHA assets and markup should be enqueued or
* rendered for the current request.
*
* @param bool $should_enqueue Whether reCAPTCHA should be enqueued.
* @param string|null $location The detected smart button location, or null when none applies.
*/
return (bool) apply_filters(
'woocommerce_paypal_payments_recaptcha_should_enqueue',
$should_enqueue,
$location
);
}

public function intercept_paypal_ajax( array $request_data ): void {
if ( ! $this->should_use_recaptcha() ) {
return;
Expand Down
221 changes: 221 additions & 0 deletions tests/PHPUnit/FraudProtection/Recaptcha/RecaptchaTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
<?php
declare( strict_types=1 );

namespace WooCommerce\PayPalCommerce\FraudProtection\Recaptcha;

use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\Assets\AssetGetter;
use WooCommerce\PayPalCommerce\FraudProtection\PersistentCounter;
use WooCommerce\PayPalCommerce\TestCase;
use WooCommerce\PayPalCommerce\WcGateway\Helper\SettingsStatus;
use function Brain\Monkey\Functions\expect;
use function Brain\Monkey\Functions\when;

class RecaptchaTest extends TestCase {
use MockeryPHPUnitIntegration;

private $recaptcha_options = array(
'guest_only' => '',
'site_key_v3' => 'v3-site-key',
'secret_key_v3' => 'v3-secret-key',
'site_key_v2' => 'v2-site-key',
'secret_key_v2' => 'v2-secret-key',
'v2_theme' => 'light',
);

public function setUp(): void {
parent::setUp();

when( 'is_user_logged_in' )->justReturn( false );
when( 'has_block' )->justReturn( false );
}

/**
* Builds a Recaptcha instance configured so should_use_recaptcha() is true
* (valid v2/v3 keys, integration enabled), so tests only need to control
* the location-gating logic under test.
*/
private function make_testee( SettingsStatus $settings_status ): Recaptcha {
$integration = Mockery::mock( RecaptchaIntegration::class );
$integration->enabled = 'yes';
$integration->shouldReceive( 'get_option' )->andReturnUsing(
function ( string $key, $default = false ) {
return $this->recaptcha_options[ $key ] ?? $default;
}
);

$asset_getter = Mockery::mock( AssetGetter::class );
$asset_getter->shouldReceive( 'get_asset_url' )->andReturn( 'https://example.com/recaptcha-handler.js' );

$logger = Mockery::mock( LoggerInterface::class );
$rejection_counter = Mockery::mock( PersistentCounter::class );

return new Recaptcha(
$integration,
array(),
$asset_getter,
'1.0.0',
$logger,
$rejection_counter,
$settings_status
);
}

public function test_enqueue_scripts_skips_on_unsupported_page(): void {
when( 'is_product' )->justReturn( false );
when( 'is_cart' )->justReturn( false );
when( 'is_checkout' )->justReturn( false );

$settings_status = Mockery::mock( SettingsStatus::class );
$settings_status->shouldNotReceive( 'is_smart_button_enabled_for_location' );

expect( 'apply_filters' )
->once()
->with( 'woocommerce_paypal_payments_recaptcha_should_enqueue', false, null )
->andReturn( false );
expect( 'wp_enqueue_script' )->never();

$this->make_testee( $settings_status )->enqueue_scripts();
}

public function test_enqueue_scripts_skips_when_location_disabled(): void {
when( 'is_product' )->justReturn( true );
when( 'is_cart' )->justReturn( false );
when( 'is_checkout' )->justReturn( false );

$settings_status = Mockery::mock( SettingsStatus::class );
$settings_status->shouldReceive( 'is_smart_button_enabled_for_location' )
->with( 'product' )
->andReturn( false );

expect( 'apply_filters' )
->once()
->with( 'woocommerce_paypal_payments_recaptcha_should_enqueue', false, 'product' )
->andReturn( false );
expect( 'wp_enqueue_script' )->never();

$this->make_testee( $settings_status )->enqueue_scripts();
}

public function test_enqueue_scripts_runs_when_location_enabled(): void {
when( 'is_product' )->justReturn( true );
when( 'is_cart' )->justReturn( false );
when( 'is_checkout' )->justReturn( false );

$settings_status = Mockery::mock( SettingsStatus::class );
$settings_status->shouldReceive( 'is_smart_button_enabled_for_location' )
->with( 'product' )
->andReturn( true );

expect( 'apply_filters' )
->once()
->with( 'woocommerce_paypal_payments_recaptcha_should_enqueue', true, 'product' )
->andReturn( true );
expect( 'wp_enqueue_script' )->twice();
expect( 'wp_localize_script' )->once();

$this->make_testee( $settings_status )->enqueue_scripts();
}

public function test_enqueue_scripts_checks_block_express_location_on_block_checkout(): void {
when( 'is_product' )->justReturn( false );
when( 'is_cart' )->justReturn( false );
when( 'is_checkout' )->justReturn( true );
when( 'has_block' )->justReturn( true );

$settings_status = Mockery::mock( SettingsStatus::class );
$settings_status->shouldReceive( 'is_smart_button_enabled_for_location' )
->with( 'checkout-block-express' )
->andReturn( false );

expect( 'apply_filters' )
->once()
->with( 'woocommerce_paypal_payments_recaptcha_should_enqueue', false, 'checkout-block-express' )
->andReturn( false );
expect( 'wp_enqueue_script' )->never();

$this->make_testee( $settings_status )->enqueue_scripts();
}

public function test_enqueue_scripts_checks_classic_checkout_location(): void {
when( 'is_product' )->justReturn( false );
when( 'is_cart' )->justReturn( false );
when( 'is_checkout' )->justReturn( true );
when( 'has_block' )->justReturn( false );

$settings_status = Mockery::mock( SettingsStatus::class );
$settings_status->shouldReceive( 'is_smart_button_enabled_for_location' )
->with( 'checkout' )
->andReturn( true );

expect( 'apply_filters' )
->once()
->with( 'woocommerce_paypal_payments_recaptcha_should_enqueue', true, 'checkout' )
->andReturn( true );
expect( 'wp_enqueue_script' )->twice();
expect( 'wp_localize_script' )->once();

$this->make_testee( $settings_status )->enqueue_scripts();
}

public function test_filter_can_force_enqueue_on_a_disabled_location(): void {
when( 'is_product' )->justReturn( true );
when( 'is_cart' )->justReturn( false );
when( 'is_checkout' )->justReturn( false );

$settings_status = Mockery::mock( SettingsStatus::class );
$settings_status->shouldReceive( 'is_smart_button_enabled_for_location' )
->with( 'product' )
->andReturn( false );

expect( 'apply_filters' )
->once()
->with( 'woocommerce_paypal_payments_recaptcha_should_enqueue', false, 'product' )
->andReturn( true );
expect( 'wp_enqueue_script' )->twice();
expect( 'wp_localize_script' )->once();

$this->make_testee( $settings_status )->enqueue_scripts();
}

public function test_render_v2_container_returns_empty_when_location_disabled(): void {
when( 'is_product' )->justReturn( true );
when( 'is_cart' )->justReturn( false );
when( 'is_checkout' )->justReturn( false );

$settings_status = Mockery::mock( SettingsStatus::class );
$settings_status->shouldReceive( 'is_smart_button_enabled_for_location' )
->with( 'product' )
->andReturn( false );

expect( 'apply_filters' )
->once()
->with( 'woocommerce_paypal_payments_recaptcha_should_enqueue', false, 'product' )
->andReturn( false );

$this->assertSame( '', $this->make_testee( $settings_status )->render_v2_container() );
}

public function test_render_v2_container_returns_markup_when_location_enabled(): void {
when( 'is_product' )->justReturn( true );
when( 'is_cart' )->justReturn( false );
when( 'is_checkout' )->justReturn( false );

$settings_status = Mockery::mock( SettingsStatus::class );
$settings_status->shouldReceive( 'is_smart_button_enabled_for_location' )
->with( 'product' )
->andReturn( true );

expect( 'apply_filters' )
->once()
->with( 'woocommerce_paypal_payments_recaptcha_should_enqueue', true, 'product' )
->andReturn( true );

$this->assertStringContainsString(
'ppcp-recaptcha-v2-container',
$this->make_testee( $settings_status )->render_v2_container()
);
}
}
Loading