Commit 18320469dfb for woocommerce

commit 18320469dfb4d0a1b2b2027b1c65c41454018d11
Author: Ahmed <ahmed.el.azzabi@automattic.com>
Date:   Wed Sep 2 10:02:54 2026 +0100

    Fix false error after order cancellation refresh (#68141)

    * fix: prevent repeated order cancellation requests

    Cancellation requests without a custom redirect kept their state-changing query arguments after WooCommerce handled them. A browser refresh then processed the cancelled order again and displayed a false error.\n\nRedirect to the current URL after WooCommerce removes the cancellation arguments. This preserves explicit redirects and extension-filtered cancel pages while making refresh safe.\n\nRefs #26743

    * chore: add cancellation refresh changelog

    * fix: harden order cancellation redirects

    Fresh guest sessions could drop cancellation notices after the redirect, and a missing request URI could leave the redirect target empty.

    Create a cookie-backed session before notices and use cart and home fallbacks when the cleaned request URI is unavailable. Extend tests for guest notice persistence, empty request URIs, and filtered URL cancellation.

    This preserves explicit redirects and extension-filtered URL bases.

    * fix: preserve direct order cancellation callers

    Direct callers without a redirect historically regained control after the order cancellation completed. The new browser fallback redirect caused those calls to exit and could stop REST integrations from returning their response.

    Apply the clean fallback redirect only when the handler runs in its registered wp_loaded context. Explicit redirects keep their existing behavior, and normal browser cancellation requests still redirect to a safe URL.

    Refs #26743

diff --git a/plugins/woocommerce/changelog/fix-cancel-order-refresh-error b/plugins/woocommerce/changelog/fix-cancel-order-refresh-error
new file mode 100644
index 00000000000..93ce7249ce0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-cancel-order-refresh-error
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent a false order cancellation error after shoppers refresh the return page.
diff --git a/plugins/woocommerce/includes/class-wc-form-handler.php b/plugins/woocommerce/includes/class-wc-form-handler.php
index f47c54ce4e5..1e964a9bdf9 100644
--- a/plugins/woocommerce/includes/class-wc-form-handler.php
+++ b/plugins/woocommerce/includes/class-wc-form-handler.php
@@ -882,6 +882,10 @@ class WC_Form_Handler {
 			$order_can_cancel = $order->has_status( $valid_statuses );
 			$redirect         = isset( $_GET['redirect'] ) ? wp_unslash( $_GET['redirect'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

+			if ( WC()->session instanceof WC_Session_Handler && ! WC()->session->has_session() ) {
+				WC()->session->set_customer_session_cookie( true );
+			}
+
 			if ( $user_can_cancel && $order_can_cancel && $order->get_id() === $order_id && hash_equals( $order->get_order_key(), $order_key ) ) {

 				// Cancel the order + restore stock.
@@ -898,10 +902,20 @@ class WC_Form_Handler {
 				wc_add_notice( __( 'Invalid order.', 'woocommerce' ), 'error' );
 			}

-			if ( $redirect ) {
-				wp_safe_redirect( $redirect );
-				exit;
+			if ( ! $redirect && ! doing_action( 'wp_loaded' ) ) {
+				// Preserve the historical return behavior for extensions that call this public method directly.
+				return;
 			}
+
+			if ( ! $redirect ) {
+				$request_uri = wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+				$redirect    = remove_query_arg( array( 'cancel_order', 'order', 'order_id', 'redirect', '_wpnonce' ), $request_uri );
+			}
+
+			$redirect = $redirect ? $redirect : wc_get_cart_url();
+			$redirect = $redirect ? $redirect : home_url();
+			wp_safe_redirect( $redirect );
+			exit;
 		}
 	}

diff --git a/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php b/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
index cb0026d7d75..3cd2fd69526 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
@@ -7,11 +7,27 @@

 declare( strict_types = 1 );

+use Automattic\WooCommerce\Enums\OrderStatus;
+
 /**
  * WC_Form_Handler tests.
  */
 class WC_Form_Handler_Test extends WC_Unit_Test_Case {

+	/**
+	 * Original GET data.
+	 *
+	 * @var array<string,mixed>
+	 */
+	private array $original_get = array();
+
+	/**
+	 * Original request URI.
+	 *
+	 * @var string|null
+	 */
+	private ?string $original_request_uri = null;
+
 	/**
 	 * Original POST data.
 	 *
@@ -39,6 +55,9 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
 	public function setUp(): void {
 		parent::setUp();

+		$this->original_request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : null;
+
+		$this->original_get     = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
 		$this->original_post    = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing
 		$this->original_request = $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
 		$this->original_session = WC()->session;
@@ -57,6 +76,12 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
 	public function tearDown(): void {
 		remove_filter( 'wp_redirect', array( $this, 'intercept_redirect' ) );

+		$_GET = $this->original_get;
+		if ( null === $this->original_request_uri ) {
+			unset( $_SERVER['REQUEST_URI'] );
+		} else {
+			$_SERVER['REQUEST_URI'] = $this->original_request_uri;
+		}
 		$_POST    = $this->original_post;
 		$_REQUEST = $this->original_request;

@@ -78,6 +103,167 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
 		throw new RuntimeException( esc_url_raw( $location ) );
 	}

+	/**
+	 * @testdox cancel_order() redirects to a clean endpoint when no custom redirect is provided.
+	 *
+	 * @covers WC_Form_Handler::cancel_order()
+	 */
+	public function test_cancel_order_redirects_to_clean_endpoint_without_custom_redirect(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $user_id );
+		$order = WC_Helper_Order::create_order( $user_id );
+
+		$this->prepare_cancel_order_request( $order );
+		$this->dispatch_cancel_order_expecting_redirect( wp_make_link_relative( $order->get_cancel_endpoint() ) );
+
+		$this->assertTrue( wc_get_order( $order->get_id() )->has_status( OrderStatus::CANCELLED ), 'The order should be cancelled before the clean redirect.' );
+	}
+
+	/**
+	 * @testdox cancel_order() preserves a filtered cancel URL base when it removes request arguments.
+	 *
+	 * @covers WC_Form_Handler::cancel_order()
+	 */
+	public function test_cancel_order_preserves_filtered_cancel_url_base(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $user_id );
+		$order             = WC_Helper_Order::create_order( $user_id );
+		$filtered_endpoint = home_url( '/filtered-cancel-page/' );
+		$filter            = static function ( string $url ) use ( $filtered_endpoint ): string {
+			$query = wp_parse_url( $url, PHP_URL_QUERY );
+			return $filtered_endpoint . '?' . $query;
+		};
+
+		add_filter( 'woocommerce_get_cancel_order_url_raw', $filter );
+		$this->prepare_cancel_order_request( $order );
+		remove_filter( 'woocommerce_get_cancel_order_url_raw', $filter );
+
+		$this->dispatch_cancel_order_expecting_redirect( wp_make_link_relative( $filtered_endpoint ) );
+
+		$this->assertTrue( wc_get_order( $order->get_id() )->has_status( OrderStatus::CANCELLED ), 'The order should be cancelled before the filtered redirect.' );
+	}
+
+	/**
+	 * @testdox cancel_order() preserves a custom redirect.
+	 *
+	 * @covers WC_Form_Handler::cancel_order()
+	 */
+	public function test_cancel_order_preserves_custom_redirect(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $user_id );
+		$order       = WC_Helper_Order::create_order( $user_id );
+		$redirect_to = wc_get_page_permalink( 'myaccount' );
+
+		$this->prepare_cancel_order_request( $order, $redirect_to );
+		$this->dispatch_cancel_order_expecting_redirect( $redirect_to );
+
+		$this->assertTrue( wc_get_order( $order->get_id() )->has_status( OrderStatus::CANCELLED ), 'The order should be cancelled before the custom redirect.' );
+	}
+
+	/**
+	 * @testdox cancel_order() returns to direct callers when no custom redirect is provided.
+	 *
+	 * @covers WC_Form_Handler::cancel_order()
+	 */
+	public function test_cancel_order_returns_to_direct_callers_without_custom_redirect(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $user_id );
+		$order = WC_Helper_Order::create_order( $user_id );
+
+		$this->prepare_cancel_order_request( $order );
+		WC_Form_Handler::cancel_order();
+
+		$this->assertTrue( wc_get_order( $order->get_id() )->has_status( OrderStatus::CANCELLED ), 'The direct call should cancel the order and return control to the caller.' );
+	}
+
+	/**
+	 * @testdox cancel_order() redirects to the cart when the request URI is unavailable.
+	 * @dataProvider unavailable_request_uri_provider
+	 *
+	 * @covers WC_Form_Handler::cancel_order()
+	 *
+	 * @param string|null $request_uri Request URI to use, or null to remove it.
+	 */
+	public function test_cancel_order_redirects_to_cart_when_request_uri_is_unavailable( ?string $request_uri ): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $user_id );
+		$order = WC_Helper_Order::create_order( $user_id );
+
+		$this->prepare_cancel_order_request( $order );
+		if ( null === $request_uri ) {
+			unset( $_SERVER['REQUEST_URI'] );
+		} else {
+			$_SERVER['REQUEST_URI'] = $request_uri;
+		}
+
+		$this->dispatch_cancel_order_expecting_redirect( wc_get_cart_url() );
+	}
+
+	/**
+	 * Provides unavailable request URI values.
+	 *
+	 * @return array<string,array{string|null}>
+	 */
+	public function unavailable_request_uri_provider(): array {
+		return array(
+			'missing request URI' => array( null ),
+			'empty request URI'   => array( '' ),
+		);
+	}
+
+	/**
+	 * @testdox cancel_order() persists the cancellation notice for a fresh guest session.
+	 *
+	 * @covers WC_Form_Handler::cancel_order()
+	 */
+	public function test_cancel_order_persists_notice_for_fresh_guest_session(): void {
+		wp_set_current_user( 0 );
+		WC()->session = new WC_Session_Handler();
+		WC()->session->init_session_cookie();
+
+		$this->assertFalse( WC()->session->has_session(), 'The guest should start without a cookie-backed session.' );
+
+		$order = WC_Helper_Order::create_order( 0 );
+		$this->prepare_cancel_order_request( $order );
+		$this->dispatch_cancel_order_expecting_redirect( wp_make_link_relative( $order->get_cancel_endpoint() ) );
+
+		$this->assertTrue( WC()->session->has_session(), 'Cancelling the guest order should establish a session.' );
+
+		WC()->session->save_data();
+		$saved_session_data = WC()->session->get_session( WC()->session->get_customer_id(), array() );
+		WC()->session->destroy_session();
+		$saved_notices = maybe_unserialize( $saved_session_data['wc_notices'] ?? array() );
+
+		$this->assertNotEmpty( $saved_notices['notice'] ?? array(), 'The cancellation notice should be saved for the redirect.' );
+	}
+
+	/**
+	 * @testdox cancel_order() redirects safely when the order ID belongs to a refund.
+	 *
+	 * @covers WC_Form_Handler::cancel_order()
+	 */
+	public function test_cancel_order_redirects_safely_for_refund_id(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $user_id );
+		$order  = WC_Helper_Order::create_order( $user_id );
+		$refund = wc_create_refund(
+			array(
+				'amount'   => 1,
+				'order_id' => $order->get_id(),
+				'reason'   => 'Test refund',
+			)
+		);
+
+		$this->assertInstanceOf( WC_Order_Refund::class, $refund, 'The test requires a refund order.' );
+		$this->prepare_cancel_order_request( $order );
+		$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
+
+		$_GET['order_id']       = (string) $refund->get_id();
+		$_SERVER['REQUEST_URI'] = add_query_arg( 'order_id', $refund->get_id(), $request_uri );
+
+		$this->dispatch_cancel_order_expecting_redirect( wp_make_link_relative( $order->get_cancel_endpoint() ) );
+	}
+
 	/**
 	 * @testdox save_account_details() saves other account fields when an email-like display name is unchanged.
 	 *
@@ -250,6 +436,42 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Prepares a cancel-order request from the public order URL.
+	 *
+	 * @param WC_Order $order    Order to cancel.
+	 * @param string   $redirect Optional redirect URL.
+	 */
+	private function prepare_cancel_order_request( WC_Order $order, string $redirect = '' ): void {
+		$url   = $order->get_cancel_order_url_raw( $redirect );
+		$query = wp_parse_url( $url, PHP_URL_QUERY );
+		parse_str( (string) $query, $_GET ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The test builds a signed cancellation request.
+		$_SERVER['REQUEST_URI'] = wp_make_link_relative( $url );
+	}
+
+	/**
+	 * Dispatches the cancel-order handler and expects a redirect.
+	 *
+	 * @param string $expected_redirect Expected redirect URL.
+	 */
+	private function dispatch_cancel_order_expecting_redirect( string $expected_redirect ): void {
+		global $wp_current_filter;
+
+		$current_filter_backup = $wp_current_filter;
+		$wp_current_filter[]   = 'wp_loaded'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The test dispatches the handler in its registered action context.
+
+		try {
+			WC_Form_Handler::cancel_order();
+		} catch ( RuntimeException $e ) {
+			$this->assertSame( $expected_redirect, $e->getMessage(), 'The cancellation request should redirect to a clean URL.' );
+			return;
+		} finally {
+			$wp_current_filter = $current_filter_backup; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the action stack after the simulated dispatch.
+		}
+
+		$this->fail( 'Expected cancel_order() to redirect after handling the request.' );
+	}
+
 	/**
 	 * Dispatches the account-details save handler and expects its success redirect.
 	 */