diff --git a/assets/js/paypal-express.js b/assets/js/paypal-express.js new file mode 100644 index 0000000..0767e30 --- /dev/null +++ b/assets/js/paypal-express.js @@ -0,0 +1,49 @@ +/** + * PayPal Express fetch interceptor for Woo Fraud Protection. + * + * Intercepts PayPal's ppc-create-order AJAX calls to inject the Blackbox + * session ID into the request body. This allows the server-side PayPalCompat + * handler to verify the session before PayPal order creation. + * + * Follows the same fetch interceptor pattern as PayPal's reCAPTCHA module. + * + * Resets Blackbox after the CreateOrder fetch returns so subsequent payment + * attempts (retry, different method) get a fresh session for evaluation. + */ +( function () { + if ( ! window.wcFraudProtection ) { + return; + } + + const fp = window.wcFraudProtection; + const originalFetch = window.fetch; + + window.fetch = async function ( resource, init ) { + init = init || {}; + // fetch() accepts string, URL, or Request objects. + const url = + resource instanceof Request ? resource.url : String( resource ); + + if ( ! url || url.indexOf( 'ppc-create-order' ) === -1 ) { + return originalFetch.call( this, resource, init ); + } + + // Acquire session ID (5s timeout, fail-open on timeout). + const sessionId = await fp.acquireSessionId(); + + try { + const body = JSON.parse( init.body ); + body[ fp.config.sessionIdField ] = sessionId; + init.body = JSON.stringify( body ); + } catch ( e ) { + // Fail-open: send the request without session ID. + } + + try { + return await originalFetch.call( this, resource, init ); + } finally { + // Reset Blackbox so subsequent payment attempts get a fresh session. + fp.reset(); + } + }; +} )(); diff --git a/src/Compat/PayPalCompat.php b/src/Compat/PayPalCompat.php new file mode 100644 index 0000000..cccdac8 --- /dev/null +++ b/src/Compat/PayPalCompat.php @@ -0,0 +1,356 @@ +session_verifier = $session_verifier; + $this->blocked_session_notice = $blocked_session_notice; + } + + /** + * Register hooks for PayPal express fraud protection. + * + * @internal + * + * @return void + */ + public function register(): void { + add_action( 'woocommerce_paypal_payments_create_order_request_started', array( $this, 'verify_and_block_create_order' ) ); + add_filter( 'woocommerce_fraud_protection_enqueue_blackbox_scripts', array( $this, 'should_enqueue_blackbox' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_paypal_script' ), 20 ); + add_filter( 'woocommerce_fraud_protection_skip_session_verify', array( $this, 'skip_default_verify_for_paypal_express' ), 10, 4 ); + } + + /** + * Verify the session and block the PayPal CreateOrder request if needed. + * + * Called during `woocommerce_paypal_payments_create_order_request_started`, + * which fires after nonce validation but before PayPal order creation. + * + * On BLOCK, sends a JSON error response and terminates execution via + * wp_send_json_error(). On ALLOW, returns normally and the PayPal flow + * continues. + * + * @internal + * + * @param array $data The CreateOrder request data from PayPal Payments. + * @return void + */ + public function verify_and_block_create_order( array $data ): void { + $session_id = sanitize_text_field( $data[ BlackboxScriptHandler::SESSION_ID_FIELD ] ?? '' ); + + $decision = $this->session_verifier->verify_session( $session_id, self::ORDER_CREATION_SOURCE, 0, $data ); + + // Store the verified session ID so standard protectors can skip + // redundant verification for the same Blackbox session (e.g. when + // BlocksCheckoutProtector fires after ppc-create-order for card flows). + // Stored regardless of decision: Blackbox sessions are single-use, so + // re-verifying the same ID would fail rather than produce a fresh verdict. + if ( '' !== $session_id && function_exists( 'WC' ) && WC()->session ) { + WC()->session->set( self::VERIFIED_SESSION_ID_KEY, $session_id ); + } + + if ( ApiClient::DECISION_BLOCK === $decision ) { + wp_send_json_error( + array( 'message' => $this->blocked_session_notice->get_message_plaintext( 'purchase' ) ), + 403 + ); + } + } + + /** + * Filter whether Blackbox scripts should be enqueued on the current page. + * + * Extends the default enqueue logic (checkout, pay-for-order, add-payment-method) + * to also load on pages where PayPal express buttons can trigger checkout: + * product pages, cart pages, and any page when mini-cart buttons are enabled + * (the mini-cart renders in the header/template, so buttons appear on every page). + * + * @internal + * + * @param bool $should Whether scripts are already set to be enqueued. + * @return bool + */ + public function should_enqueue_blackbox( bool $should ): bool { + if ( $should ) { + return true; + } + + if ( ! $this->is_paypal_available() ) { + return false; + } + + return is_product() + || is_cart() + || has_block( 'woocommerce/cart' ) + || $this->is_paypal_mini_cart_enabled(); + } + + /** + * Enqueue the PayPal express fetch interceptor script. + * + * Only enqueues when Blackbox init script is already loaded (which means + * Blackbox is configured and ready on the current page). + * + * @internal + * + * @return void + */ + public function enqueue_paypal_script(): void { + if ( ! wp_script_is( 'wc-fraud-protection-blackbox-init', 'enqueued' ) ) { + return; + } + + wp_enqueue_script( + 'wc-fraud-protection-paypal-express', + WC_FRAUD_PROTECTION_PLUGIN_URL . 'assets/js/paypal-express.js', + array( 'wc-fraud-protection-blackbox-init' ), + WC_FRAUD_PROTECTION_VERSION, + array( 'in_footer' => true ) + ); + } + + /** + * Skip redundant verification for PayPal flows handled by PayPalCompat. + * + * PayPal smart-button flows go through ppc-create-order where PayPalCompat + * verifies the session. Standard protectors should skip when: + * + * 1. The payment method is a PayPal gateway (ppcp-* prefix), AND + * 2. Any of: + * a. The current request IS ppc-create-order — PayPal's + * CreateOrderEndpoint fires woocommerce_checkout_process during + * form validation before our verify action runs, so the shortcode + * protector should defer to PayPalCompat. + * b. The Blackbox session ID matches one already verified during + * ppc-create-order — the standard protector captured the same + * session ID before ppc-create-order ran (e.g. card flows on + * blocks checkout where blocks-checkout.js acquires the ID first). + * c. An approved PayPal order exists in the WC session — the request + * is completing an express flow already verified at CreateOrder + * (ppc-approve-order stored the order after approval). + * + * Flows that do NOT go through ppc-create-order (Blocks "Place Order" + * with ppcp-gateway server-side redirect, APM gateways) are NOT skipped. + * + * @internal + * + * @param bool $skip Whether to skip verification. + * @param string $source Source identifier. + * @param array $request_data Request data with payment_method, payment_data, etc. + * @param string $session_id The Blackbox session ID being verified. + * @return bool + */ + public function skip_default_verify_for_paypal_express( bool $skip, string $source, array $request_data, string $session_id ): bool { + if ( $skip ) { + return true; + } + + // Don't skip PayPalCompat's own verification sources. + if ( self::ORDER_CREATION_SOURCE === $source ) { + return false; + } + + $payment_method = (string) ( $request_data['payment_method'] ?? '' ); + + // Not a PayPal gateway — nothing for this filter to do. + if ( ! $this->is_paypal_gateway( $payment_method ) ) { + return false; + } + + // During ppc-create-order: early validation fires woocommerce_checkout_process + // before PayPalCompat verifies. Skip — PayPalCompat handles it next. + if ( $this->is_paypal_create_order_request() ) { + return true; + } + + // Same Blackbox session already verified during ppc-create-order in this + // payment flow (e.g. card fields on blocks checkout where blocks-checkout.js + // captured the session ID before ppc-create-order ran). + if ( $this->was_session_verified_at_create_order( $session_id ) ) { + return true; + } + + // After express approval: PayPal order in session means ppc-create-order + // already verified (Blackbox was reset since, so session IDs won't match). + if ( $this->has_paypal_order_in_session() ) { + return true; + } + + // All other ppcp-* flows (Blocks "Place Order" with ppcp-gateway, APMs): don't skip. + return false; + } + + /** + * Check if an approved PayPal order exists in the WC session. + * + * PayPal Payments stores the approved order in the 'ppcp' session key + * after ppc-create-order and ppc-approve-order. Its presence indicates + * the checkout is completing a flow where ppc-create-order already ran. + * + * @return bool + */ + private function has_paypal_order_in_session(): bool { + if ( ! function_exists( 'WC' ) || ! WC()->session ) { + return false; + } + + $ppcp_session = WC()->session->get( 'ppcp' ); + + return is_array( $ppcp_session ) && ! empty( $ppcp_session['order'] ); + } + + /** + * Check if a Blackbox session ID was already verified during ppc-create-order. + * + * Smart-button flows (cards, wallets, express) call ppc-create-order where + * PayPalCompat verifies the session. For flows where the standard protector + * captures the same Blackbox session ID before ppc-create-order runs (e.g. + * blocks-checkout.js acquires the ID, then PayPal's createOrder callback + * fires ppc-create-order), the session IDs match and we can skip. + * + * @param string $session_id The session ID from the current verify call. + * @return bool + */ + private function was_session_verified_at_create_order( string $session_id ): bool { + if ( '' === $session_id || ! function_exists( 'WC' ) || ! WC()->session ) { + return false; + } + + return WC()->session->get( self::VERIFIED_SESSION_ID_KEY, '' ) === $session_id; + } + + /** + * Check if the current request is a PayPal ppc-create-order AJAX request. + * + * During ppc-create-order, PayPal's CreateOrderEndpoint may fire + * woocommerce_checkout_process (via validate_form()) before our + * verify_and_block_create_order action fires. The standard protector + * should skip because PayPalCompat will verify later in the same request. + * + * @return bool + */ + private function is_paypal_create_order_request(): bool { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only checking the endpoint name, not processing data. + return isset( $_GET['wc-ajax'] ) && 'ppc-create-order' === $_GET['wc-ajax']; + } + + /** + * Check if a gateway ID belongs to PayPal Payments. + * + * @param string $gateway_id The gateway ID to check. + * @return bool + */ + private function is_paypal_gateway( string $gateway_id ): bool { + return 0 === strpos( $gateway_id, self::PAYPAL_GATEWAY_PREFIX ); + } + + /** + * Check if any PayPal Payments gateway is available. + * + * @return bool + */ + private function is_paypal_available(): bool { + if ( ! function_exists( 'WC' ) || ! WC()->payment_gateways() ) { + return false; + } + + $gateways = WC()->payment_gateways()->get_available_payment_gateways(); + + foreach ( array_keys( $gateways ) as $id ) { + if ( $this->is_paypal_gateway( $id ) ) { + return true; + } + } + + return false; + } + + /** + * Check if PayPal Payments has mini-cart smart buttons enabled. + * + * When enabled, PayPal renders express buttons inside the mini-cart widget + * which appears on every frontend page (header/template part). Reads the + * same `smart_button_locations` setting that PayPal uses to decide where + * to load its scripts. + * + * @return bool + */ + private function is_paypal_mini_cart_enabled(): bool { + $ppcp_settings = get_option( 'woocommerce-ppcp-settings', array() ); + + if ( ! is_array( $ppcp_settings ) ) { + return false; + } + + $locations = $ppcp_settings['smart_button_locations'] ?? array(); + + return is_array( $locations ) && in_array( 'mini-cart', $locations, true ); + } +} diff --git a/src/FraudProtectionController.php b/src/FraudProtectionController.php index 99d4bf3..5b3a589 100644 --- a/src/FraudProtectionController.php +++ b/src/FraudProtectionController.php @@ -90,6 +90,13 @@ class FraudProtectionController /* implements RegisterHooksInterface */ { */ private SessionBlockingHandler $session_blocking_handler; + /** + * PayPal compatibility instance. + * + * @var Compat\PayPalCompat + */ + private Compat\PayPalCompat $paypal_compat; + /** * Register hooks. */ @@ -112,6 +119,7 @@ public function register(): void { * @param ShortcodeCheckoutProtector $shortcode_checkout_protector The instance of ShortcodeCheckoutProtector to use. * @param AddPaymentMethodProtector $add_payment_method_protector The instance of AddPaymentMethodProtector to use. * @param PayForOrderProtector $pay_for_order_protector The instance of PayForOrderProtector to use. + * @param Compat\PayPalCompat $paypal_compat The instance of PayPalCompat to use. */ final public function init( BlockedSessionNotice $blocked_session_notice, @@ -123,7 +131,8 @@ final public function init( BlocksCheckoutProtector $blocks_checkout_protector, ShortcodeCheckoutProtector $shortcode_checkout_protector, AddPaymentMethodProtector $add_payment_method_protector, - PayForOrderProtector $pay_for_order_protector + PayForOrderProtector $pay_for_order_protector, + Compat\PayPalCompat $paypal_compat ): void { $this->blocked_session_notice = $blocked_session_notice; $this->blackbox_script_handler = $blackbox_script_handler; @@ -135,6 +144,7 @@ final public function init( $this->shortcode_checkout_protector = $shortcode_checkout_protector; $this->add_payment_method_protector = $add_payment_method_protector; $this->pay_for_order_protector = $pay_for_order_protector; + $this->paypal_compat = $paypal_compat; } /** @@ -158,6 +168,7 @@ public function on_init(): void { $this->cart_event_tracker->register(); $this->checkout_event_tracker->register(); $this->payment_method_event_tracker->register(); + $this->paypal_compat->register(); } /** diff --git a/src/SessionVerifier.php b/src/SessionVerifier.php index 55b9466..8e44e8d 100644 --- a/src/SessionVerifier.php +++ b/src/SessionVerifier.php @@ -89,6 +89,38 @@ final public function init( * @return string The final decision: 'allow' or 'block'. */ public function verify_session( string $session_id, string $source, int $order_id = 0, array $request_data = array() ): string { + try { + /** + * Filters whether to skip session verification. + * + * Extensions that handle their own verification (e.g. PayPal express + * checkout via PayPalCompat) can return true to skip the redundant + * verify call from standard protectors. + * + * Fail-open: truthy values skip verification (session is allowed). + * + * @param bool $skip Whether to skip verification. Default false. + * @param string $source Source identifier (e.g. 'blocks_checkout'). + * @param array $request_data Request data with payment_method, payment_data, etc. + * @param string $session_id The Blackbox session ID being verified. + */ + $skip = (bool) apply_filters( 'woocommerce_fraud_protection_skip_session_verify', false, $source, $request_data, $session_id ); + + if ( $skip ) { + FraudProtectionController::log( + 'info', + sprintf( 'Session verification skipped by `woocommerce_fraud_protection_skip_session_verify` filter for source: %s', $source ) + ); + return ApiClient::DECISION_ALLOW; + } + } catch ( \Throwable $e ) { + FraudProtectionController::log( + 'warning', + '`woocommerce_fraud_protection_skip_session_verify` filter threw: ' . $e->getMessage(), + array( 'exception' => $e ) + ); + } + // Resolve payment data (fail-open). $payment_data = null; try { diff --git a/tests/js/paypal-express.test.js b/tests/js/paypal-express.test.js new file mode 100644 index 0000000..35f88c7 --- /dev/null +++ b/tests/js/paypal-express.test.js @@ -0,0 +1,147 @@ +/** + * @jest-environment jsdom + */ + +/** + * Tests for paypal-express.js — Fetch interceptor for PayPal CreateOrder AJAX. + * + * paypal-express.js is an IIFE. We test it by setting up global mocks, + * requiring the file (which executes the IIFE), and asserting on mocks. + * + * @package WooCommerce\FraudProtection + */ + +let mockAcquireSessionId; +let originalFetch; +let fetchCalls; + +beforeEach( () => { + delete window.wcFraudProtection; + + fetchCalls = []; + originalFetch = jest.fn( ( resource, init ) => { + fetchCalls.push( { resource, init } ); + return Promise.resolve( { ok: true, json: () => Promise.resolve( {} ) } ); + } ); + window.fetch = originalFetch; + + mockAcquireSessionId = jest.fn( () => Promise.resolve( 'test-session-abc' ) ); +} ); + +function setupAndLoad( config ) { + window.wcFraudProtection = { + config: config || { sessionIdField: 'wc_fraud_protection_session_id' }, + acquireSessionId: mockAcquireSessionId, + reset: jest.fn(), + }; + + jest.isolateModules( () => { + require( '../../assets/js/paypal-express' ); + } ); +} + +describe( 'paypal-express fetch interceptor', () => { + describe( 'interception', () => { + it( 'intercepts ppc-create-order requests and injects session_id', async () => { + setupAndLoad(); + + const body = JSON.stringify( { nonce: 'abc', context: 'product' } ); + await window.fetch( 'https://store.test/?wc-ajax=ppc-create-order', { body } ); + + expect( mockAcquireSessionId ).toHaveBeenCalledTimes( 1 ); + expect( fetchCalls ).toHaveLength( 1 ); + + const sentBody = JSON.parse( fetchCalls[ 0 ].init.body ); + expect( sentBody.wc_fraud_protection_session_id ).toBe( 'test-session-abc' ); + expect( sentBody.nonce ).toBe( 'abc' ); + } ); + + it( 'does not intercept non-PayPal fetch calls', async () => { + setupAndLoad(); + + await window.fetch( 'https://store.test/wp-json/wc/store/v1/checkout', { body: '{}' } ); + + expect( mockAcquireSessionId ).not.toHaveBeenCalled(); + expect( fetchCalls ).toHaveLength( 1 ); + expect( fetchCalls[ 0 ].init.body ).toBe( '{}' ); + } ); + + it( 'handles Request objects with url property', async () => { + setupAndLoad(); + + const request = new Request( 'https://store.test/?wc-ajax=ppc-create-order' ); + await window.fetch( request, { body: JSON.stringify( {} ) } ); + + expect( mockAcquireSessionId ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'handles URL objects', async () => { + setupAndLoad(); + + const url = new URL( 'https://store.test/?wc-ajax=ppc-create-order' ); + await window.fetch( url, { body: JSON.stringify( {} ) } ); + + expect( mockAcquireSessionId ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + + describe( 'fail-open', () => { + it( 'sends request without session_id when body is not valid JSON', async () => { + setupAndLoad(); + + await window.fetch( 'https://store.test/?wc-ajax=ppc-create-order', { body: 'not-json' } ); + + expect( mockAcquireSessionId ).toHaveBeenCalledTimes( 1 ); + expect( fetchCalls ).toHaveLength( 1 ); + // Body is unchanged since JSON.parse failed. + expect( fetchCalls[ 0 ].init.body ).toBe( 'not-json' ); + } ); + + it( 'does nothing when wcFraudProtection is not available', async () => { + // Don't call setupAndLoad — wcFraudProtection is not set. + const savedFetch = window.fetch; + + jest.isolateModules( () => { + require( '../../assets/js/paypal-express' ); + } ); + + // fetch should not have been replaced. + expect( window.fetch ).toBe( savedFetch ); + } ); + } ); + + describe( 'reset', () => { + it( 'calls reset after intercepted CreateOrder fetch returns', async () => { + setupAndLoad(); + + await window.fetch( 'https://store.test/?wc-ajax=ppc-create-order', { + body: JSON.stringify( {} ), + } ); + + expect( window.wcFraudProtection.reset ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'does not call reset for non-PayPal fetch calls', async () => { + setupAndLoad(); + + await window.fetch( 'https://store.test/wp-json/wc/store/v1/checkout', { + body: '{}', + } ); + + expect( window.wcFraudProtection.reset ).not.toHaveBeenCalled(); + } ); + + it( 'calls reset even when fetch rejects', async () => { + originalFetch.mockImplementationOnce( () => Promise.reject( new Error( 'Network error' ) ) ); + setupAndLoad(); + + await expect( + window.fetch( 'https://store.test/?wc-ajax=ppc-create-order', { + body: JSON.stringify( {} ), + } ) + ).rejects.toThrow( 'Network error' ); + + expect( window.wcFraudProtection.reset ).toHaveBeenCalledTimes( 1 ); + } ); + } ); +} ); diff --git a/tests/js/setup.js b/tests/js/setup.js index a04e0a7..d2e7b30 100644 --- a/tests/js/setup.js +++ b/tests/js/setup.js @@ -1,3 +1,10 @@ +// JSDOM does not provide Fetch API globals. Stub Request for instanceof checks. +global.Request = class Request { + constructor( input ) { + this.url = typeof input === 'string' ? input : String( input ); + } +}; + // Stub HTMLFormElement.prototype.submit to prevent jsdom "Not implemented" errors. // Individual tests can override form.submit with their own spy when they need to assert on it. if ( typeof HTMLFormElement !== 'undefined' ) { diff --git a/tests/php/src/Compat/PayPalCompatTest.php b/tests/php/src/Compat/PayPalCompatTest.php new file mode 100644 index 0000000..6f3f6d0 --- /dev/null +++ b/tests/php/src/Compat/PayPalCompatTest.php @@ -0,0 +1,404 @@ +session_verifier = $this->createMock( SessionVerifier::class ); + $this->blocked_session_notice = $this->createMock( BlockedSessionNotice::class ); + + $this->blocked_session_notice + ->method( 'get_message_plaintext' ) + ->willReturn( 'We are unable to process this request online. Please contact support (test@example.com) to complete your purchase.' ); + + $this->sut = new PayPalCompat(); + $this->sut->init( + $this->session_verifier, + $this->blocked_session_notice + ); + } + + /** + * Tear down after each test. + */ + public function tearDown(): void { + remove_all_filters( 'wp_doing_ajax' ); + remove_all_filters( 'wp_die_ajax_handler' ); + remove_all_filters( 'woocommerce_fraud_protection_enqueue_blackbox_scripts' ); + remove_all_filters( 'woocommerce_fraud_protection_skip_session_verify' ); + remove_all_actions( 'woocommerce_paypal_payments_create_order_request_started' ); + remove_all_actions( 'wp_enqueue_scripts' ); + wp_dequeue_script( 'wc-fraud-protection-blackbox-init' ); + wp_dequeue_script( 'wc-fraud-protection-paypal-express' ); + + if ( WC()->session ) { + WC()->session->set( 'ppcp', null ); + WC()->session->set( '_fraud_protection_paypal_verified_session_id', null ); + } + + unset( $_GET['wc-ajax'] ); + + parent::tearDown(); + } + + /* + |-------------------------------------------------------------------------- + | register() Tests + |-------------------------------------------------------------------------- + */ + + /** + * @testdox register() hooks the create_order action, enqueue filter, and script action. + */ + public function test_register_hooks(): void { + $this->sut->register(); + + $this->assertNotFalse( + has_action( 'woocommerce_paypal_payments_create_order_request_started', array( $this->sut, 'verify_and_block_create_order' ) ), + 'create_order_request_started action should be registered' + ); + $this->assertNotFalse( + has_filter( 'woocommerce_fraud_protection_enqueue_blackbox_scripts', array( $this->sut, 'should_enqueue_blackbox' ) ), + 'enqueue_blackbox_scripts filter should be registered' + ); + $this->assertNotFalse( + has_action( 'wp_enqueue_scripts', array( $this->sut, 'enqueue_paypal_script' ) ), + 'wp_enqueue_scripts action should be registered' + ); + $this->assertNotFalse( + has_filter( 'woocommerce_fraud_protection_skip_session_verify', array( $this->sut, 'skip_default_verify_for_paypal_express' ) ), + 'should_verify_session filter should be registered' + ); + } + + /* + |-------------------------------------------------------------------------- + | verify_and_block_create_order() Tests + |-------------------------------------------------------------------------- + */ + + /** + * @testdox verify_and_block_create_order() extracts session_id from data and calls verify_session — allows on ALLOW. + */ + public function test_verify_allows_on_allow_decision(): void { + $data = array( BlackboxScriptHandler::SESSION_ID_FIELD => 'test-session-abc' ); + + $this->session_verifier + ->expects( $this->once() ) + ->method( 'verify_session' ) + ->with( 'test-session-abc', 'paypal_express_order_creation', 0, $data ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + // Should return normally without terminating. + $this->sut->verify_and_block_create_order( $data ); + + $this->assertSame( 'test-session-abc', WC()->session->get( '_fraud_protection_paypal_verified_session_id' ) ); + } + + /** + * @testdox verify_and_block_create_order() does not store empty session ID in WC session. + */ + public function test_verify_does_not_store_empty_session_id(): void { + $data = array( 'context' => 'product' ); + + $this->session_verifier + ->method( 'verify_session' ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $this->sut->verify_and_block_create_order( $data ); + + $this->assertNull( WC()->session->get( '_fraud_protection_paypal_verified_session_id' ) ); + } + + /** + * @testdox verify_and_block_create_order() sends JSON error with 403 on BLOCK decision. + */ + public function test_verify_blocks_on_block_decision(): void { + $data = array( BlackboxScriptHandler::SESSION_ID_FIELD => 'test-session-blocked' ); + + $this->session_verifier + ->expects( $this->once() ) + ->method( 'verify_session' ) + ->with( 'test-session-blocked', 'paypal_express_order_creation', 0, $data ) + ->willReturn( ApiClient::DECISION_BLOCK ); + + $this->blocked_session_notice + ->expects( $this->once() ) + ->method( 'get_message_plaintext' ) + ->with( 'purchase' ); + + // wp_send_json_error() echoes JSON then calls wp_die(). Force AJAX + // context (otherwise it calls bare die()) and override the die + // handler to throw a catchable exception. + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( + 'wp_die_ajax_handler', + function () { + return function () { + throw new \WPDieException(); + }; + } + ); + + $this->expectException( \WPDieException::class ); + $this->expectOutputRegex( '/"success":false.*unable to process this request/' ); + + $this->sut->verify_and_block_create_order( $data ); + } + + /** + * @testdox verify_and_block_create_order() calls verify with empty session_id when field is missing. + */ + public function test_verify_with_missing_session_id(): void { + $data = array( 'context' => 'product' ); + + $this->session_verifier + ->expects( $this->once() ) + ->method( 'verify_session' ) + ->with( '', 'paypal_express_order_creation', 0, $data ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $this->sut->verify_and_block_create_order( $data ); + } + + /* + |-------------------------------------------------------------------------- + | should_enqueue_blackbox() Tests + |-------------------------------------------------------------------------- + */ + + /** + * @testdox should_enqueue_blackbox() returns true when already set to enqueue. + */ + public function test_should_enqueue_blackbox_passthrough_when_already_true(): void { + $this->assertTrue( $this->sut->should_enqueue_blackbox( true ) ); + } + + /** + * @testdox should_enqueue_blackbox() returns false when PayPal is not available. + */ + public function test_should_enqueue_blackbox_false_when_no_paypal(): void { + // No PayPal gateways registered by default. + $this->assertFalse( $this->sut->should_enqueue_blackbox( false ) ); + } + + /* + |-------------------------------------------------------------------------- + | enqueue_paypal_script() Tests + |-------------------------------------------------------------------------- + */ + + /** + * @testdox enqueue_paypal_script() enqueues when blackbox-init is already enqueued. + */ + public function test_enqueue_paypal_script_when_blackbox_init_enqueued(): void { + wp_enqueue_script( 'wc-fraud-protection-blackbox-init', 'https://example.com/blackbox-init.js', array(), '1.0', true ); + + $this->sut->enqueue_paypal_script(); + + $this->assertTrue( wp_script_is( 'wc-fraud-protection-paypal-express', 'enqueued' ) ); + } + + /** + * @testdox enqueue_paypal_script() does not enqueue when blackbox-init is absent. + */ + public function test_enqueue_paypal_script_skips_when_blackbox_init_absent(): void { + $this->sut->enqueue_paypal_script(); + + $this->assertFalse( wp_script_is( 'wc-fraud-protection-paypal-express', 'enqueued' ) ); + } + + /* + |-------------------------------------------------------------------------- + | skip_default_verify_for_paypal_express() Tests + |-------------------------------------------------------------------------- + */ + + /** + * @testdox skip_default_verify_for_paypal_express() returns true (skip) when PayPal gateway has approved order in session. + */ + public function test_skip_verify_skips_for_paypal_with_approved_order(): void { + WC()->session->set( 'ppcp', array( 'order' => new \stdClass() ) ); + + $gateways = array( 'ppcp-gateway', 'ppcp-credit-card-gateway', 'ppcp-applepay', 'ppcp-googlepay', 'ppcp-axo-gateway' ); + + foreach ( $gateways as $gateway ) { + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'blocks_checkout', + array( 'payment_method' => $gateway ), + 'some-session-id' + ); + + $this->assertTrue( $result, "Expected true (skip) for gateway: $gateway" ); + } + } + + /** + * @testdox skip_default_verify_for_paypal_express() returns true (skip) during ppc-create-order request. + */ + public function test_skip_verify_skips_during_create_order_request(): void { + $_GET['wc-ajax'] = 'ppc-create-order'; + + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'shortcode_checkout', + array( 'payment_method' => 'ppcp-gateway' ), + 'some-session-id' + ); + + $this->assertTrue( $result ); + } + + /** + * @testdox skip_default_verify_for_paypal_express() returns true (skip) when session ID matches one verified at ppc-create-order. + */ + public function test_skip_verify_skips_when_session_id_matches_verified(): void { + WC()->session->set( '_fraud_protection_paypal_verified_session_id', 'test-session-abc' ); + + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'blocks_checkout', + array( 'payment_method' => 'ppcp-credit-card-gateway' ), + 'test-session-abc' + ); + + $this->assertTrue( $result ); + } + + /** + * @testdox skip_default_verify_for_paypal_express() returns false (don't skip) when both session IDs are empty (no accidental blank match). + */ + public function test_skip_verify_does_not_skip_when_both_session_ids_are_empty(): void { + WC()->session->set( '_fraud_protection_paypal_verified_session_id', '' ); + + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'blocks_checkout', + array( 'payment_method' => 'ppcp-gateway' ), + '' + ); + + $this->assertFalse( $result ); + } + + /** + * @testdox skip_default_verify_for_paypal_express() returns false (don't skip) when session ID does not match (different flow). + */ + public function test_skip_verify_does_not_skip_when_session_id_does_not_match(): void { + WC()->session->set( '_fraud_protection_paypal_verified_session_id', 'old-session' ); + + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'blocks_checkout', + array( 'payment_method' => 'ppcp-ideal' ), + 'new-session' + ); + + $this->assertFalse( $result ); + } + + /** + * @testdox skip_default_verify_for_paypal_express() returns false (don't skip) for PayPal gateway without approved order or create-order request. + */ + public function test_skip_verify_does_not_skip_for_paypal_without_approved_order(): void { + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'blocks_checkout', + array( 'payment_method' => 'ppcp-gateway' ), + 'some-session-id' + ); + + $this->assertFalse( $result ); + } + + /** + * @testdox skip_default_verify_for_paypal_express() returns false (don't skip) when payment method is not PayPal. + */ + public function test_skip_verify_does_not_skip_for_non_paypal_gateway(): void { + WC()->session->set( 'ppcp', array( 'order' => new \stdClass() ) ); + + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'blocks_checkout', + array( 'payment_method' => 'stripe' ), + 'some-session-id' + ); + + $this->assertFalse( $result ); + } + + /** + * @testdox skip_default_verify_for_paypal_express() returns false (don't skip) for its own source even with approved order. + */ + public function test_skip_verify_does_not_skip_own_source(): void { + WC()->session->set( 'ppcp', array( 'order' => new \stdClass() ) ); + + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'paypal_express_order_creation', + array( 'payment_method' => 'ppcp-gateway' ), + 'some-session-id' + ); + + $this->assertFalse( $result ); + } + + /** + * @testdox skip_default_verify_for_paypal_express() passes through true (skip) from an earlier filter. + */ + public function test_skip_verify_respects_true_from_earlier_filter(): void { + $result = $this->sut->skip_default_verify_for_paypal_express( + true, + 'blocks_checkout', + array( 'payment_method' => 'stripe' ), + 'some-session-id' + ); + + $this->assertTrue( $result ); + } +} diff --git a/tests/php/src/SessionVerifierTest.php b/tests/php/src/SessionVerifierTest.php index b376a51..b38de29 100644 --- a/tests/php/src/SessionVerifierTest.php +++ b/tests/php/src/SessionVerifierTest.php @@ -81,6 +81,14 @@ public function setUp(): void { ); } + /** + * Tear down after each test. + */ + public function tearDown(): void { + remove_all_filters( 'woocommerce_fraud_protection_skip_session_verify' ); + parent::tearDown(); + } + /* |-------------------------------------------------------------------------- | Pipeline Tests @@ -324,4 +332,109 @@ public function test_verify_session_fails_open_when_decision_handler_throws(): v ); } + /* + |-------------------------------------------------------------------------- + | Should Verify Filter Tests + |-------------------------------------------------------------------------- + */ + + /** + * @testdox verify_session() skips verification and returns ALLOW when filter returns true. + */ + public function test_verify_session_skips_when_filter_returns_true(): void { + add_filter( 'woocommerce_fraud_protection_skip_session_verify', '__return_true' ); + + $this->api_client + ->expects( $this->never() ) + ->method( 'verify' ); + + $result = $this->sut->verify_session( 'test-session', 'blocks_checkout' ); + + $this->assertSame( ApiClient::DECISION_ALLOW, $result ); + $this->assertLogged( 'info', 'Session verification skipped by `woocommerce_fraud_protection_skip_session_verify` filter for source: blocks_checkout' ); + } + + /** + * @testdox verify_session() proceeds normally when filter returns false. + */ + public function test_verify_session_proceeds_when_filter_returns_false(): void { + add_filter( 'woocommerce_fraud_protection_skip_session_verify', '__return_false' ); + + $this->data_collector + ->method( 'get_collected_data' ) + ->willReturn( array() ); + + $this->api_client + ->expects( $this->once() ) + ->method( 'verify' ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $this->decision_handler + ->method( 'apply_decision' ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $result = $this->sut->verify_session( 'test-session', 'blocks_checkout' ); + + $this->assertSame( ApiClient::DECISION_ALLOW, $result ); + } + + /** + * @testdox verify_session() proceeds normally when filter returns non-bool falsy value. + */ + public function test_verify_session_proceeds_when_filter_returns_non_bool(): void { + add_filter( + 'woocommerce_fraud_protection_skip_session_verify', + function () { + return null; + } + ); + + $this->data_collector + ->method( 'get_collected_data' ) + ->willReturn( array() ); + + $this->api_client + ->expects( $this->once() ) + ->method( 'verify' ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $this->decision_handler + ->method( 'apply_decision' ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $result = $this->sut->verify_session( 'test-session', 'blocks_checkout' ); + + $this->assertSame( ApiClient::DECISION_ALLOW, $result ); + } + + /** + * @testdox verify_session() proceeds normally when filter callback throws (fail-open). + */ + public function test_verify_session_proceeds_when_filter_throws(): void { + add_filter( // @phpstan-ignore return.missing + 'woocommerce_fraud_protection_skip_session_verify', + function () { + throw new \RuntimeException( 'Filter exploded' ); + } + ); + + $this->data_collector // @phpstan-ignore deadCode.unreachable + ->method( 'get_collected_data' ) + ->willReturn( array() ); + + $this->api_client + ->expects( $this->once() ) + ->method( 'verify' ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $this->decision_handler + ->method( 'apply_decision' ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $result = $this->sut->verify_session( 'test-session', 'blocks_checkout' ); + + $this->assertSame( ApiClient::DECISION_ALLOW, $result ); + $this->assertLogged( 'warning', '`woocommerce_fraud_protection_skip_session_verify` filter threw: Filter exploded' ); + } + } diff --git a/woocommerce-fraud-protection.php b/woocommerce-fraud-protection.php index 77daf3d..743050e 100644 --- a/woocommerce-fraud-protection.php +++ b/woocommerce-fraud-protection.php @@ -41,6 +41,7 @@ require_once WC_FRAUD_PROTECTION_PLUGIN_DIR . '/src/ShortcodeCheckoutProtector.php'; require_once WC_FRAUD_PROTECTION_PLUGIN_DIR . '/src/AddPaymentMethodProtector.php'; require_once WC_FRAUD_PROTECTION_PLUGIN_DIR . '/src/PayForOrderProtector.php'; +require_once WC_FRAUD_PROTECTION_PLUGIN_DIR . '/src/Compat/PayPalCompat.php'; require_once WC_FRAUD_PROTECTION_PLUGIN_DIR . '/src/SessionBlockingHandler.php'; require_once WC_FRAUD_PROTECTION_PLUGIN_DIR . '/src/FraudProtectionController.php'; @@ -91,6 +92,9 @@ function () { $square_compat = new \Automattic\WooCommerce\FraudProtection\Compat\SquarePaymentDataCompat(); $square_compat->register(); + $paypal_compat = new \Automattic\WooCommerce\FraudProtection\Compat\PayPalCompat(); + $paypal_compat->init( $session_verifier, $blocked_notice ); + $blocks_checkout_protector = new \Automattic\WooCommerce\FraudProtection\BlocksCheckoutProtector(); $blocks_checkout_protector->init( $session_verifier, $blocked_notice ); @@ -115,7 +119,8 @@ function () { $blocks_checkout_protector, $shortcode_checkout_protector, $add_payment_method_protector, - $pay_for_order_protector + $pay_for_order_protector, + $paypal_compat ); $controller->register(); }