Commit 8070af628af for woocommerce

commit 8070af628af79f9aed71307f36498fe6fecaf2da
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date:   Wed Aug 19 16:42:43 2026 +0300

    Fix stale variation restoration in REST order updates (#67493)

    * Fix stale variation restoration in REST order updates

    * Add changelog entry for stale variation restoration

    * Fix rejection of echoed stale variation IDs in REST order updates

    A client that reads a line item and PUTs it back unchanged echoes the
    stored variation_id. When that variation was hard-deleted, the restore
    path rethrew the validation error solely because the key was present in
    the payload, even though the guarding predicate already ensures a posted
    variation_id can only equal the stored value. Variations also inherit
    the parent SKU in view context, so a plain GET-then-PUT round trip of a
    SKU-less variation item hit this 400.

    Drop the array_key_exists clause from both catch predicates so echoed
    stale IDs demote the line item like omitted ones, matching pre-67343
    behavior. Explicitly requesting a different variation never reaches the
    catch, and extension validation for still-existing variations is still
    rethrown via the get_post_type clause. Also cover the reported scenario
    directly with a variation-deleted-before-request test in both suites.

    * Remove tests not tied to the restoration regression

    The deleted-before-request tests pass even without the fix: hydrating an
    item whose variation is already gone swallows the stale ID inside
    WC_Data::set_props(), so the restore path never runs. The extension
    rethrow test and its fixture subclass guard the catch predicate rather
    than the regression and pass both before and after the fix.

    Keep only the red-to-green coverage: demotion on a stale variation and
    on an echoed stale variation ID, in both the v1 and v3 suites.

    * Narrow changelog entry to the mid-update deletion scenario

    * Address review nits in the variation restoration fix

    Document why the v1 catch omits v2's get_post_type() recheck, drop a
    stray blank line left by the removed fixture require, and assert product
    resolution in the v1 echoed-ID test to match the v3 suite.

    * Restore coverage for the extension validation rethrow branch

    The get_post_type() clause in the v2 catch is a branch this fix
    introduces, and every other test deletes the variation, so the rethrow
    path had no coverage after the earlier test trim. Removing the test on
    the grounds that it passed before and after the fix was wrong: before
    the fix there was no catch, so everything rethrew by construction.

    Restore the subclass fixture and the test pinning that an extension's
    validation error for a still-existing variation is rethrown.

    * Log the stale variation drop in the REST swallow path

    The swallow converts a previously loud 400 into a silent 200 on a path
    that only fires under a race, leaving support nothing to diagnose a line
    item that quietly lost its variation. Record a warning with the item,
    order, and dropped variation IDs. The v2 comment also documents why a
    subclass veto coinciding with a deleted variation is swallowed rather
    than discriminated by class: rethrowing for subclasses would revive the
    400 on every store substituting order item classes.

    * Pin the extension veto swallow for already-deleted variations

    The rethrow test covers only the still-existing-variation branch. Add
    the compound case: a subclass veto reusing the core error code while the
    variation is also deleted is swallowed and the item demotes, matching
    what core itself would do for the missing post.

    * Scope changelog entry to parent-resolving payloads

diff --git a/plugins/woocommerce/changelog/fix-67417-stale-variation-restoration b/plugins/woocommerce/changelog/fix-67417-stale-variation-restoration
new file mode 100644
index 00000000000..c351294c5d8
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-67417-stale-variation-restoration
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent REST order updates from failing when a line item's variation is deleted mid-update and the posted product resolves to the parent product.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller.php
index 2fc39a5d6e0..ab69f7529ae 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller.php
@@ -660,6 +660,7 @@ class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
 		}
 	}

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- This method also throws WC_REST_Exception indirectly through get_product_id().
 	/**
 	 * Create or update a line item.
 	 *
@@ -667,6 +668,7 @@ class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
 	 * @param string $action 'create' to add line item or 'update' to update it.
 	 *
 	 * @return WC_Order_Item_Product
+	 * @throws WC_Data_Exception Invalid product data.
 	 * @throws WC_REST_Exception Invalid data, server error.
 	 */
 	protected function prepare_line_items( $posted, $action = 'create' ) {
@@ -697,7 +699,26 @@ class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
 		if ( $product && $product !== $item->get_product() ) {
 			$item->set_product( $product );
 			if ( $restore_variation_id ) {
-				$item->set_variation_id( $current_variation_id );
+				try {
+					$item->set_variation_id( $current_variation_id );
+				} catch ( WC_Data_Exception $e ) {
+					if ( 'order_item_product_invalid_variation_id' !== $e->getErrorCode() ) {
+						throw $e;
+					}
+					// The stored variation ID no longer identifies a variation. Keep set_product()'s parent demotion.
+					// Unlike v2, no get_post_type() recheck is needed: $item is always a base WC_Order_Item_Product
+					// (never a woocommerce_get_order_item_classname subclass), whose setter throws this code only
+					// when the post is not a product_variation.
+					wc_get_logger()->warning(
+						sprintf(
+							'Order item #%d (order #%d) referenced variation #%d, which no longer exists; the item was demoted to its parent product during a REST update.',
+							$item->get_id(),
+							$item->get_order_id(),
+							$current_variation_id
+						),
+						array( 'source' => 'rest-api' )
+					);
+				}
 			}

 			if ( 'create' === $action ) {
@@ -716,6 +737,8 @@ class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
 		return $item;
 	}

+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
 	/**
 	 * Create or update an order shipping method.
 	 *
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
index 325725df428..7a9f1a63bab 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
@@ -928,6 +928,7 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
 		MetaDataUtil::update( $posted['meta_data'] ?? null, $item );
 	}

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- This method also throws WC_REST_Exception indirectly through get_product_id().
 	/**
 	 * Create or update a line item.
 	 *
@@ -935,6 +936,7 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
 	 * @param string $action 'create' to add line item or 'update' to update it.
 	 * @param object $item Passed when updating an item. Null during creation.
 	 * @return WC_Order_Item_Product
+	 * @throws WC_Data_Exception Invalid product data.
 	 * @throws WC_REST_Exception Invalid data, server error.
 	 */
 	protected function prepare_line_items( $posted, $action = 'create', $item = null ) {
@@ -966,7 +968,29 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
 		if ( $product && $product !== $item->get_product() ) {
 			$item->set_product( $product );
 			if ( $restore_variation_id && $product_item ) {
-				$product_item->set_variation_id( $current_variation_id );
+				try {
+					$product_item->set_variation_id( $current_variation_id );
+				} catch ( WC_Data_Exception $e ) {
+					if (
+						'order_item_product_invalid_variation_id' !== $e->getErrorCode()
+						|| 'product_variation' === get_post_type( $current_variation_id )
+					) {
+						throw $e;
+					}
+					// The stored variation ID no longer identifies a variation. Keep set_product()'s parent demotion.
+					// A subclass veto reusing this error code for an already-deleted variation is indistinguishable
+					// from the core throw and is deliberately swallowed too: rethrowing for subclasses (e.g. via a
+					// get_class() check) would revive the 400 on every store substituting order item classes.
+					wc_get_logger()->warning(
+						sprintf(
+							'Order item #%d (order #%d) referenced variation #%d, which no longer exists; the item was demoted to its parent product during a REST update.',
+							$product_item->get_id(),
+							$product_item->get_order_id(),
+							$current_variation_id
+						),
+						array( 'source' => 'rest-api' )
+					);
+				}
 			}

 			if ( 'create' === $action ) {
@@ -986,6 +1010,8 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
 		return $item;
 	}

+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
 	/**
 	 * Create or update an order shipping method.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller-tests.php
index baf0041defd..adbd55fac80 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller-tests.php
@@ -67,6 +67,38 @@ class WC_REST_Orders_V1_Controller_Tests extends WC_REST_Unit_Test_Case {
 		return array( $parent, $variation, $order, $item );
 	}

+	/**
+	 * Dispatches a v1 line-item update after deleting its variation during product resolution.
+	 *
+	 * @param WC_Order                  $order Order to update.
+	 * @param WC_Order_Item_Product     $item Line item to update.
+	 * @param WC_Product_Variation      $variation Variation to delete.
+	 * @param array<string, int|string> $line_item Line-item payload.
+	 * @return WP_REST_Response
+	 */
+	private function dispatch_line_item_update_after_deleting_variation( WC_Order $order, WC_Order_Item_Product $item, WC_Product_Variation $variation, array $line_item ): WP_REST_Response {
+		$delete_variation = static function ( $product, $order_item ) use ( $item, $variation ) {
+			static $deleted = false;
+
+			if ( ! $deleted && $item->get_id() === $order_item->get_id() ) {
+				$deleted = true;
+				wp_delete_post( $variation->get_id(), true );
+			}
+
+			return $product;
+		};
+		add_filter( 'woocommerce_get_product_from_item', $delete_variation, 10, 2 );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v1/orders/' . $order->get_id() );
+		$request->set_body_params( array( 'line_items' => array( array_merge( array( 'id' => $item->get_id() ), $line_item ) ) ) );
+
+		try {
+			return $this->server->dispatch( $request );
+		} finally {
+			remove_filter( 'woocommerce_get_product_from_item', $delete_variation, 10 );
+		}
+	}
+
 	/**
 	 * Test that an order can be fetched via REST API V1 without triggering a deprecation notice.
 	 *
@@ -186,6 +218,60 @@ class WC_REST_Orders_V1_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$this->assertSame( $parent->get_tax_class(), $reloaded->get_tax_class(), 'The line-item tax class should retain its pre-regression resynchronization behavior.' );
 	}

+	/**
+	 * @testdox Updating with the unchanged parent demotes the line item if its variation is deleted after loading.
+	 */
+	public function test_update_line_item_demotes_when_variation_is_deleted_after_loading(): void {
+		list( $parent, $variation, $order, $item ) = $this->create_order_with_variation_line_item();
+
+		$response = $this->dispatch_line_item_update_after_deleting_variation(
+			$order,
+			$item,
+			$variation,
+			array(
+				'product_id' => $parent->get_id(),
+			)
+		);
+
+		$this->assertSame( 200, $response->get_status(), 'A variation deleted after item loading should not reject the order update.' );
+
+		$reloaded = new WC_Order_Item_Product( $item->get_id() );
+		$this->assertSame( 0, $reloaded->get_variation_id(), 'The deleted variation should not be restored.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product_id(), 'The line item should retain the parent product ID.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product()->get_id(), 'The line item should resolve to the parent product.' );
+	}
+
+	/**
+	 * @testdox Updating with an echoed variation ID demotes the line item if the variation is deleted after loading.
+	 */
+	public function test_update_line_item_with_echoed_variation_id_demotes_when_variation_is_deleted_after_loading(): void {
+		list( $parent, $variation, $order, $item ) = $this->create_order_with_variation_line_item();
+
+		$parent_sku = 'REST-V1-PARENT-' . wp_generate_uuid4();
+		$parent->set_sku( $parent_sku );
+		$parent->save();
+		$variation->set_sku( '' );
+		$variation->save();
+
+		$response = $this->dispatch_line_item_update_after_deleting_variation(
+			$order,
+			$item,
+			$variation,
+			array(
+				'product_id'   => $parent->get_id(),
+				'variation_id' => $variation->get_id(),
+				'sku'          => $parent_sku,
+			)
+		);
+
+		$this->assertSame( 200, $response->get_status(), 'Echoing the stored variation ID back should not reject the order update.' );
+
+		$reloaded = new WC_Order_Item_Product( $item->get_id() );
+		$this->assertSame( 0, $reloaded->get_variation_id(), 'The deleted variation should not be restored.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product_id(), 'The line item should retain the parent product ID.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product()->get_id(), 'The line item should resolve to the parent product.' );
+	}
+
 	/**
 	 * @testdox Updating with an explicit zero variation ID clears the variation even when SKU resolves to it.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/Fixtures/class-wc-rest-orders-controller-rejecting-order-item-product.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/Fixtures/class-wc-rest-orders-controller-rejecting-order-item-product.php
new file mode 100644
index 00000000000..77481100bcf
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/Fixtures/class-wc-rest-orders-controller-rejecting-order-item-product.php
@@ -0,0 +1,36 @@
+<?php
+
+declare( strict_types = 1 );
+
+/**
+ * Order item subclass used to verify that REST updates preserve extension validation.
+ */
+class WC_REST_Orders_Controller_Rejecting_Order_Item_Product extends WC_Order_Item_Product {
+
+	/**
+	 * Whether restoring a non-zero variation ID should fail.
+	 *
+	 * @var bool
+	 */
+	public static $reject_variation_restoration = false;
+
+	/**
+	 * Set the variation ID.
+	 *
+	 * @param int $value Variation ID.
+	 * @return void
+	 * @throws WC_Data_Exception When variation restoration is rejected.
+	 */
+	public function set_variation_id( $value ) {
+		if ( self::$reject_variation_restoration && $value > 0 ) {
+			$this->error(
+				'order_item_product_invalid_variation_id',
+				'Variation restoration rejected by an order item extension.',
+				400,
+				array( 'variation_id' => $value )
+			);
+		}
+
+		parent::set_variation_id( $value );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
index 61c54b86f90..880279c7c28 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
@@ -1,5 +1,6 @@
 <?php

+use Automattic\WooCommerce\Caches\OrderCache;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
 use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
@@ -11,6 +12,8 @@ use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;
 use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
 use Automattic\WooCommerce\Utilities\OrderUtil;

+require_once __DIR__ . '/Fixtures/class-wc-rest-orders-controller-rejecting-order-item-product.php';
+
 /**
  * class WC_REST_Orders_Controller_Tests.
  * Orders Controller tests for V3 REST API.
@@ -1194,6 +1197,35 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
 		return $this->server->dispatch( $request );
 	}

+	/**
+	 * Dispatches a v3 line-item update after deleting its variation during product resolution.
+	 *
+	 * @param int                       $order_id Order ID.
+	 * @param int                       $item_id Line item ID.
+	 * @param WC_Product_Variation      $variation Variation to delete.
+	 * @param array<string, int|string> $line_item Line-item payload.
+	 * @return WP_REST_Response
+	 */
+	private function dispatch_line_item_update_after_deleting_variation( int $order_id, int $item_id, WC_Product_Variation $variation, array $line_item ): WP_REST_Response {
+		$delete_variation = static function ( $product, $order_item ) use ( $item_id, $variation ) {
+			static $deleted = false;
+
+			if ( ! $deleted && $item_id === $order_item->get_id() ) {
+				$deleted = true;
+				wp_delete_post( $variation->get_id(), true );
+			}
+
+			return $product;
+		};
+		add_filter( 'woocommerce_get_product_from_item', $delete_variation, 10, 2 );
+
+		try {
+			return $this->dispatch_line_item_update( $order_id, array_merge( array( 'id' => $item_id ), $line_item ) );
+		} finally {
+			remove_filter( 'woocommerce_get_product_from_item', $delete_variation, 10 );
+		}
+	}
+
 	/**
 	 * @testdox PUT /orders that switches a variation line item to a simple product clears variation_id over the REST round trip.
 	 */
@@ -1263,6 +1295,158 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$this->assertSame( $parent->get_tax_class(), $reloaded->get_tax_class(), 'The line-item tax class should retain its pre-regression resynchronization behavior.' );
 	}

+	/**
+	 * @testdox PUT /orders with an unchanged parent demotes the line item if its variation is deleted after loading.
+	 */
+	public function test_update_line_item_demotes_when_variation_is_deleted_after_loading(): void {
+		list( $parent, $variation ) = $this->create_variable_product_with_color_variation();
+		list( $order, $item_id )    = $this->create_order_with_variation_line_item( $variation );
+
+		$response = $this->dispatch_line_item_update_after_deleting_variation(
+			$order->get_id(),
+			$item_id,
+			$variation,
+			array( 'product_id' => $parent->get_id() )
+		);
+
+		$this->assertSame( 200, $response->get_status(), 'A variation deleted after item loading should not reject the order update.' );
+
+		$response_item = $response->get_data()['line_items'][0];
+		$reloaded      = new WC_Order_Item_Product( $item_id );
+		$this->assertSame( 0, $reloaded->get_variation_id(), 'The deleted variation should not be restored.' );
+		$this->assertSame( 0, (int) wc_get_order_item_meta( $item_id, '_variation_id' ), 'The persisted variation ID should be cleared.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product_id(), 'The line item should retain the parent product ID.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product()->get_id(), 'The line item should resolve to the parent product.' );
+		$this->assertSame( 0, $response_item['variation_id'], 'The response should expose the demoted line item.' );
+	}
+
+	/**
+	 * @testdox PUT /orders rethrows extension validation errors while the existing variation is still valid.
+	 */
+	public function test_update_line_item_rethrows_extension_validation_error_for_existing_variation(): void {
+		list( $parent, $variation ) = $this->create_variable_product_with_color_variation();
+		list( $order, $item_id )    = $this->create_order_with_variation_line_item( $variation );
+		$extension_validation_armed = false;
+
+		$filter_item_class  = static function ( $classname, $item_type, $filtered_item_id ) use ( $item_id ) {
+			return 'line_item' === $item_type && $item_id === (int) $filtered_item_id
+				? WC_REST_Orders_Controller_Rejecting_Order_Item_Product::class
+				: $classname;
+		};
+		$reject_restoration = static function ( $product, $order_item ) use ( $item_id, &$extension_validation_armed ) {
+			if ( $item_id === $order_item->get_id() && $order_item instanceof WC_REST_Orders_Controller_Rejecting_Order_Item_Product ) {
+				WC_REST_Orders_Controller_Rejecting_Order_Item_Product::$reject_variation_restoration = true;
+				$extension_validation_armed = true;
+			}
+
+			return $product;
+		};
+
+		add_filter( 'woocommerce_get_order_item_classname', $filter_item_class, 10, 3 );
+		add_filter( 'woocommerce_get_product_from_item', $reject_restoration, 10, 2 );
+		wc_get_container()->get( OrderCache::class )->remove( $order->get_id() );
+
+		try {
+			$response = $this->dispatch_line_item_update(
+				$order->get_id(),
+				array(
+					'id'         => $item_id,
+					'product_id' => $parent->get_id(),
+				)
+			);
+		} finally {
+			remove_filter( 'woocommerce_get_order_item_classname', $filter_item_class, 10 );
+			remove_filter( 'woocommerce_get_product_from_item', $reject_restoration, 10 );
+			WC_REST_Orders_Controller_Rejecting_Order_Item_Product::$reject_variation_restoration = false;
+		}
+
+		$this->assertSame( 'product_variation', get_post_type( $variation->get_id() ), 'Precondition: the stored variation still exists.' );
+		$this->assertTrue( $extension_validation_armed, 'Precondition: the filtered order item subclass handled the update.' );
+		$this->assertSame( 400, $response->get_status(), 'Extension validation should reject the update.' );
+		$this->assertSame( 'order_item_product_invalid_variation_id', $response->get_data()['code'], 'The extension validation error should be preserved.' );
+		$this->assertSame( $variation->get_id(), (int) wc_get_order_item_meta( $item_id, '_variation_id' ), 'The failed update should not demote the line item.' );
+	}
+
+	/**
+	 * @testdox PUT /orders swallows an extension veto that coincides with a deleted variation and demotes the item.
+	 */
+	public function test_update_line_item_swallows_extension_veto_when_variation_is_also_deleted(): void {
+		list( $parent, $variation ) = $this->create_variable_product_with_color_variation();
+		list( $order, $item_id )    = $this->create_order_with_variation_line_item( $variation );
+
+		$filter_item_class = static function ( $classname, $item_type, $filtered_item_id ) use ( $item_id ) {
+			return 'line_item' === $item_type && $item_id === (int) $filtered_item_id
+				? WC_REST_Orders_Controller_Rejecting_Order_Item_Product::class
+				: $classname;
+		};
+		$arm_and_delete    = static function ( $product, $order_item ) use ( $item_id, $variation ) {
+			static $done = false;
+
+			if ( ! $done && $item_id === $order_item->get_id() && $order_item instanceof WC_REST_Orders_Controller_Rejecting_Order_Item_Product ) {
+				$done = true;
+				WC_REST_Orders_Controller_Rejecting_Order_Item_Product::$reject_variation_restoration = true;
+				wp_delete_post( $variation->get_id(), true );
+			}
+
+			return $product;
+		};
+
+		add_filter( 'woocommerce_get_order_item_classname', $filter_item_class, 10, 3 );
+		add_filter( 'woocommerce_get_product_from_item', $arm_and_delete, 10, 2 );
+		wc_get_container()->get( OrderCache::class )->remove( $order->get_id() );
+
+		try {
+			$response = $this->dispatch_line_item_update(
+				$order->get_id(),
+				array(
+					'id'         => $item_id,
+					'product_id' => $parent->get_id(),
+				)
+			);
+		} finally {
+			remove_filter( 'woocommerce_get_order_item_classname', $filter_item_class, 10 );
+			remove_filter( 'woocommerce_get_product_from_item', $arm_and_delete, 10 );
+			WC_REST_Orders_Controller_Rejecting_Order_Item_Product::$reject_variation_restoration = false;
+		}
+
+		$this->assertSame( 200, $response->get_status(), 'An extension veto for an already-deleted variation should be swallowed like the core throw.' );
+
+		$reloaded = new WC_Order_Item_Product( $item_id );
+		$this->assertSame( 0, $reloaded->get_variation_id(), 'The deleted variation should not be restored.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product_id(), 'The line item should retain the parent product ID.' );
+	}
+
+	/**
+	 * @testdox PUT /orders with an echoed variation ID demotes the line item if the variation is deleted after loading.
+	 */
+	public function test_update_line_item_with_echoed_variation_id_demotes_when_variation_is_deleted_after_loading(): void {
+		list( $parent, $variation ) = $this->create_variable_product_with_color_variation();
+
+		$parent_sku = 'REST-V3-PARENT-' . wp_generate_uuid4();
+		$parent->set_sku( $parent_sku );
+		$parent->save();
+		$variation->set_sku( '' );
+		$variation->save();
+		list( $order, $item_id ) = $this->create_order_with_variation_line_item( $variation );
+
+		$response = $this->dispatch_line_item_update_after_deleting_variation(
+			$order->get_id(),
+			$item_id,
+			$variation,
+			array(
+				'product_id'   => $parent->get_id(),
+				'variation_id' => $variation->get_id(),
+				'sku'          => $parent_sku,
+			)
+		);
+
+		$this->assertSame( 200, $response->get_status(), 'Echoing the stored variation ID back should not reject the order update.' );
+
+		$reloaded = new WC_Order_Item_Product( $item_id );
+		$this->assertSame( 0, $reloaded->get_variation_id(), 'The deleted variation should not be restored.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product_id(), 'The line item should retain the parent product ID.' );
+	}
+
 	/**
 	 * @testdox PUT /orders with an explicit zero variation ID demotes a variation even when SKU resolves to it.
 	 */