Commit 3b95a3cf86 for woocommerce

commit 3b95a3cf8697d08eb33a8a38c9c394aff75a6600
Author: Luiz Reis <luiz.reis@automattic.com>
Date:   Thu Jan 22 12:15:38 2026 -0300

    Fraud Protection: Display blocked session notice on purchase-related pages (#62839)

    * Add blocked purchase notice on shop, cart, and checkout pages

    Display a blocked session notice on purchase-related pages (shop, cart,
    checkout, product taxonomies) for both blocks and shortcode implementations.

    Uses wc_add_notice() on the 'wp' hook which fires early enough to work with
    both StoreNoticesContainer (blocks) and wc_print_notices() (shortcodes).
    This also ensures notices display even when the cart is empty, since
    shortcode-specific hooks don't fire in that case.

    Includes duplicate notice prevention and updates the Store API to use
    the correct 'purchase' message context.

    * Document cart emptying to prevent express payment methods

    Express payment methods like PayPal are loaded via third-party SDKs that
    don't respect WooCommerce's payment method filtering. Emptying the cart
    when a session is blocked prevents these buttons from rendering, since
    they have no cart to process.

    * Preserve blocked session status across fraud checks

    Once a session is blocked, it stays blocked until explicitly reset.

    This prevents a race condition where emptying the cart (done during
    block_session) causes subsequent fraud checks to return "allow" due to
    the lower cart value, which would incorrectly unblock the session.

diff --git a/plugins/woocommerce/src/Internal/FraudProtection/BlockedSessionNotice.php b/plugins/woocommerce/src/Internal/FraudProtection/BlockedSessionNotice.php
index 02f9e50545..b8ae656836 100644
--- a/plugins/woocommerce/src/Internal/FraudProtection/BlockedSessionNotice.php
+++ b/plugins/woocommerce/src/Internal/FraudProtection/BlockedSessionNotice.php
@@ -52,30 +52,44 @@ class BlockedSessionNotice implements RegisterHooksInterface {
 	 * @return void
 	 */
 	public function register(): void {
-		add_action( 'woocommerce_before_checkout_form', array( $this, 'display_checkout_blocked_notice' ), 1, 0 );
-		add_action( 'before_woocommerce_add_payment_method', array( $this, 'display_generic_blocked_notice' ), 1, 0 );
+		// Shop, cart, and checkout pages (both blocks and shortcode) - add notice via wc_add_notice on wp hook.
+		add_action( 'wp', array( $this, 'maybe_add_blocked_purchase_notice' ), 10, 0 );
+
+		add_action( 'before_woocommerce_add_payment_method', array( $this, 'maybe_display_generic_blocked_notice' ), 1, 0 );
 	}

 	/**
-	 * Display blocked notice on shortcode checkout page.
+	 * Add blocked purchase notice on shop, cart, and checkout pages (both blocks and shortcode),
+	 * if the session is blocked. Skips duplicate notices.
 	 *
-	 * Shows a checkout-specific message explaining that the purchase cannot be
-	 * completed online and provides contact information for support.
+	 * Uses wc_add_notice() to add an error notice that will be rendered by:
+	 * - StoreNoticesContainer component for blocks
+	 * - wc_print_notices() for shortcodes
 	 *
 	 * @internal
 	 *
 	 * @return void
 	 */
-	public function display_checkout_blocked_notice(): void {
+	public function maybe_add_blocked_purchase_notice(): void {
 		if ( ! $this->session_manager->is_session_blocked() ) {
 			return;
 		}

-		wc_print_notice( $this->get_message_html( 'checkout' ), 'error' );
+		if ( ! is_checkout() && ! is_cart() && ! is_shop() && ! is_product_taxonomy() ) {
+			return;
+		}
+
+		$message = $this->get_message_html( 'purchase' );
+
+		if ( wc_has_notice( $message, 'error' ) ) {
+			return;
+		}
+
+		wc_add_notice( $message, 'error' );
 	}

 	/**
-	 * Display blocked notice for non-checkout pages.
+	 * Display blocked notice for non-cart/checkout pages, if the session is blocked.
 	 *
 	 * Shows a generic message explaining that the request cannot be
 	 * processed online and provides contact information for support.
@@ -84,7 +98,7 @@ class BlockedSessionNotice implements RegisterHooksInterface {
 	 *
 	 * @return void
 	 */
-	public function display_generic_blocked_notice(): void {
+	public function maybe_display_generic_blocked_notice(): void {
 		if ( ! $this->session_manager->is_session_blocked() ) {
 			return;
 		}
@@ -97,13 +111,13 @@ class BlockedSessionNotice implements RegisterHooksInterface {
 	 *
 	 * Includes a mailto link for the support email.
 	 *
-	 * @param string $context Message context: 'checkout' for purchase-specific message, 'generic' for general use.
+	 * @param string $context Message context: 'purchase' for purchase-specific message, 'generic' for general use.
 	 * @return string HTML message with mailto link.
 	 */
 	public function get_message_html( string $context = 'generic' ): string {
 		$email = WC()->mailer()->get_from_address();

-		if ( 'checkout' === $context ) {
+		if ( 'purchase' === $context ) {
 			return sprintf(
 				/* translators: %1$s: mailto link, %2$s: email address */
 				__( 'We are unable to process this request online. Please <a href="%1$s">contact support (%2$s)</a> to complete your purchase.', 'woocommerce' ),
@@ -125,13 +139,13 @@ class BlockedSessionNotice implements RegisterHooksInterface {
 	 *
 	 * Used by Store API responses where HTML is not supported.
 	 *
-	 * @param string $context Message context: 'checkout' for purchase-specific message, 'generic' for general use.
+	 * @param string $context Message context: 'purchase' for purchase-specific message, 'generic' for general use.
 	 * @return string Plaintext message with email address.
 	 */
 	public function get_message_plaintext( string $context = 'generic' ): string {
 		$email = WC()->mailer()->get_from_address();

-		if ( 'checkout' === $context ) {
+		if ( 'purchase' === $context ) {
 			return sprintf(
 				/* translators: %s: support email address */
 				__( 'We are unable to process this request online. Please contact support (%s) to complete your purchase.', 'woocommerce' ),
diff --git a/plugins/woocommerce/src/Internal/FraudProtection/DecisionHandler.php b/plugins/woocommerce/src/Internal/FraudProtection/DecisionHandler.php
index 46d5ab3a11..856a528ec2 100644
--- a/plugins/woocommerce/src/Internal/FraudProtection/DecisionHandler.php
+++ b/plugins/woocommerce/src/Internal/FraudProtection/DecisionHandler.php
@@ -139,10 +139,25 @@ class DecisionHandler {
 	/**
 	 * Update the session status based on the decision.
 	 *
+	 * Important: Once a session is blocked, it stays blocked until explicitly reset.
+	 * This prevents race conditions where emptying the cart (done during block_session)
+	 * causes subsequent fraud checks to return "allow" (due to lower cart value),
+	 * which would incorrectly unblock the session.
+	 *
 	 * @param string $decision The validated decision to apply.
 	 * @return void
 	 */
 	private function update_session_status( string $decision ): void {
+		// Don't overwrite a blocked session with an allow decision.
+		// Once blocked, a session should stay blocked until explicitly reset.
+		if ( ApiClient::DECISION_ALLOW === $decision && $this->session_manager->is_session_blocked() ) {
+			FraudProtectionController::log(
+				'info',
+				'Preserving blocked session status. Allow decision not applied to already-blocked session.'
+			);
+			return;
+		}
+
 		switch ( $decision ) {
 			case ApiClient::DECISION_ALLOW:
 				$this->session_manager->allow_session();
diff --git a/plugins/woocommerce/src/Internal/FraudProtection/SessionClearanceManager.php b/plugins/woocommerce/src/Internal/FraudProtection/SessionClearanceManager.php
index 8ac90768cb..d583c40d9a 100644
--- a/plugins/woocommerce/src/Internal/FraudProtection/SessionClearanceManager.php
+++ b/plugins/woocommerce/src/Internal/FraudProtection/SessionClearanceManager.php
@@ -88,7 +88,11 @@ class SessionClearanceManager {
 	}

 	/**
-	 * Mark the current session as blocked.
+	 * Mark the current session as blocked and empty the cart.
+	 *
+	 * Emptying the cart prevents express payment methods (e.g., PayPal) from
+	 * rendering on cart pages, as they are loaded via third-party SDKs that
+	 * don't respect WooCommerce's payment method filtering.
 	 *
 	 * @return void
 	 */
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index 9d5d11dde4..52273b5d70 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -162,7 +162,7 @@ class Checkout extends AbstractCartRoute {
 			&& wc_get_container()->get( SessionClearanceManager::class )->is_session_blocked() ) {
 			$response = $this->get_route_error_response(
 				'woocommerce_rest_checkout_error',
-				wc_get_container()->get( BlockedSessionNotice::class )->get_message_plaintext( 'checkout' ),
+				wc_get_container()->get( BlockedSessionNotice::class )->get_message_plaintext( 'purchase' ),
 				403
 			);
 		}
diff --git a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/BlockedSessionNoticeTest.php b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/BlockedSessionNoticeTest.php
index 2cc710b7b4..376fddfa0b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/BlockedSessionNoticeTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/BlockedSessionNoticeTest.php
@@ -52,38 +52,85 @@ class BlockedSessionNoticeTest extends \WC_Unit_Test_Case {
 	 */
 	public function tearDown(): void {
 		parent::tearDown();
-		remove_all_actions( 'woocommerce_before_checkout_form' );
 		remove_all_actions( 'before_woocommerce_add_payment_method' );
+		remove_all_actions( 'wp' );
 		delete_option( 'woocommerce_email_from_address' );
+		wc_clear_notices();
 	}

 	/**
-	 * @testdox Should display checkout-specific error notice when woocommerce_before_checkout_form action fires for blocked sessions.
+	 * @testdox maybe_add_blocked_purchase_notice should add notice when session is blocked and on checkout page.
 	 */
-	public function test_checkout_action_displays_blocked_message(): void {
+	public function test_blocked_purchase_notice_added_on_checkout(): void {
 		$this->mock_session_manager->method( 'is_session_blocked' )->willReturn( true );

-		ob_start();
-		do_action( 'woocommerce_before_checkout_form' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
-		$output = ob_get_clean();
+		// Mock being on checkout page.
+		add_filter( 'woocommerce_is_checkout', '__return_true' );

-		$this->assertStringContainsString( 'unable to process this request online', $output, 'Should display blocked message on checkout' );
-		$this->assertStringContainsString( 'to complete your purchase', $output, 'Should display checkout-specific message' );
-		$this->assertStringContainsString( 'support@example.com', $output, 'Should include support email in message' );
-		$this->assertStringContainsString( 'mailto:support@example.com', $output, 'Should include mailto link' );
+		do_action( 'wp' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+
+		remove_filter( 'woocommerce_is_checkout', '__return_true' );
+
+		$this->assertTrue( wc_has_notice( $this->sut->get_message_html( 'purchase' ), 'error' ), 'Should add purchase notice on checkout' );
+	}
+
+	/**
+	 * @testdox maybe_add_blocked_purchase_notice should add notice when session is blocked and on cart page.
+	 */
+	public function test_blocked_purchase_notice_added_on_cart(): void {
+		$this->mock_session_manager->method( 'is_session_blocked' )->willReturn( true );
+
+		// Mock being on cart page.
+		add_filter( 'woocommerce_is_cart', '__return_true' );
+
+		do_action( 'wp' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+
+		remove_filter( 'woocommerce_is_cart', '__return_true' );
+
+		$this->assertTrue( wc_has_notice( $this->sut->get_message_html( 'purchase' ), 'error' ), 'Should add purchase notice on cart' );
 	}

 	/**
-	 * @testdox Should not display message when checkout action fires for non-blocked sessions.
+	 * @testdox maybe_add_blocked_purchase_notice should not add notice when session is not blocked.
 	 */
-	public function test_checkout_action_no_message_for_non_blocked_session(): void {
+	public function test_blocked_purchase_notice_not_added_when_session_allowed(): void {
 		$this->mock_session_manager->method( 'is_session_blocked' )->willReturn( false );

-		ob_start();
-		do_action( 'woocommerce_before_checkout_form' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
-		$output = ob_get_clean();
+		// Mock being on checkout page.
+		add_filter( 'woocommerce_is_checkout', '__return_true' );

-		$this->assertEmpty( $output, 'Non-blocked sessions should not display any message' );
+		do_action( 'wp' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+
+		remove_filter( 'woocommerce_is_checkout', '__return_true' );
+
+		$this->assertFalse( wc_has_notice( $this->sut->get_message_html( 'purchase' ), 'error' ), 'Should not add notice when session is allowed' );
+	}
+
+	/**
+	 * @testdox maybe_add_blocked_purchase_notice should not add duplicate notices.
+	 */
+	public function test_blocked_purchase_notice_prevents_duplicates(): void {
+		$this->mock_session_manager->method( 'is_session_blocked' )->willReturn( true );
+
+		// Mock being on checkout page.
+		add_filter( 'woocommerce_is_checkout', '__return_true' );
+
+		do_action( 'wp' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		do_action( 'wp' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+
+		remove_filter( 'woocommerce_is_checkout', '__return_true' );
+
+		// Count error notices.
+		$notices      = wc_get_notices( 'error' );
+		$message      = $this->sut->get_message_html( 'purchase' );
+		$notice_count = 0;
+		foreach ( $notices as $notice ) {
+			if ( $notice['notice'] === $message ) {
+				++$notice_count;
+			}
+		}
+
+		$this->assertEquals( 1, $notice_count, 'Should only have one notice even after calling twice' );
 	}

 	/**
@@ -116,10 +163,10 @@ class BlockedSessionNoticeTest extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox get_message_html should return checkout-specific message when context is 'checkout'.
+	 * @testdox get_message_html should return purchase-specific message when context is 'purchase'.
 	 */
-	public function test_get_message_html_checkout_context(): void {
-		$message = $this->sut->get_message_html( 'checkout' );
+	public function test_get_message_html_purchase_context(): void {
+		$message = $this->sut->get_message_html( 'purchase' );

 		$this->assertEquals(
 			'We are unable to process this request online. Please <a href="mailto:support@example.com">contact support (support@example.com)</a> to complete your purchase.',
@@ -141,10 +188,10 @@ class BlockedSessionNoticeTest extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox get_message_plaintext should return checkout-specific message when context is 'checkout'.
+	 * @testdox get_message_plaintext should return purchase-specific message when context is 'purchase'.
 	 */
-	public function test_get_message_plaintext_checkout_context(): void {
-		$message = $this->sut->get_message_plaintext( 'checkout' );
+	public function test_get_message_plaintext_purchase_context(): void {
+		$message = $this->sut->get_message_plaintext( 'purchase' );

 		$this->assertEquals(
 			'We are unable to process this request online. Please contact support (support@example.com) to complete your purchase.',
diff --git a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/DecisionHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/DecisionHandlerTest.php
index 22e37ed73a..50b95db534 100644
--- a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/DecisionHandlerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/DecisionHandlerTest.php
@@ -54,9 +54,13 @@ class DecisionHandlerTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should apply allow decision and update session to allowed.
+	 * @testdox Should apply allow decision and update session to allowed when session is not blocked.
 	 */
 	public function test_apply_allow_decision(): void {
+		$this->session_manager
+			->method( 'is_session_blocked' )
+			->willReturn( false );
+
 		$this->session_manager
 			->expects( $this->once() )
 			->method( 'allow_session' );
@@ -66,6 +70,26 @@ class DecisionHandlerTest extends WC_Unit_Test_Case {
 		$this->assertSame( ApiClient::DECISION_ALLOW, $result );
 	}

+	/**
+	 * @testdox Should preserve blocked session status when allow decision is received.
+	 *
+	 * This prevents race conditions where emptying the cart during block_session
+	 * causes subsequent fraud checks to return "allow" (due to lower cart value).
+	 */
+	public function test_allow_decision_does_not_overwrite_blocked_session(): void {
+		$this->session_manager
+			->method( 'is_session_blocked' )
+			->willReturn( true );
+
+		$this->session_manager
+			->expects( $this->never() )
+			->method( 'allow_session' );
+
+		$result = $this->sut->apply_decision( ApiClient::DECISION_ALLOW, array( 'session_id' => 'test' ) );
+
+		$this->assertLogged( 'info', 'Preserving blocked session status' );
+	}
+
 	/**
 	 * @testdox Should apply block decision and update session to blocked.
 	 */
@@ -83,6 +107,10 @@ class DecisionHandlerTest extends WC_Unit_Test_Case {
 	 * @testdox Should default to allow for invalid decision and log warning.
 	 */
 	public function test_invalid_decision_defaults_to_allow(): void {
+		$this->session_manager
+			->method( 'is_session_blocked' )
+			->willReturn( false );
+
 		$this->session_manager
 			->expects( $this->once() )
 			->method( 'allow_session' );
@@ -104,6 +132,10 @@ class DecisionHandlerTest extends WC_Unit_Test_Case {
 			}
 		);

+		$this->session_manager
+			->method( 'is_session_blocked' )
+			->willReturn( false );
+
 		$this->session_manager
 			->expects( $this->once() )
 			->method( 'allow_session' );