Commit 8f6de70fd3f for woocommerce

commit 8f6de70fd3fa35236ffaff29fe6aaabc71fb1b78
Author: Francesco <frosso@users.noreply.github.com>
Date:   Thu Nov 5 23:38:24 2026 +0100

    fix: reject REST order line items for missing products (#70033)

diff --git a/plugins/woocommerce/changelog/wooplug-2499-reject-deleted-products-in-rest-orders b/plugins/woocommerce/changelog/wooplug-2499-reject-deleted-products-in-rest-orders
new file mode 100644
index 00000000000..ce73f2615d0
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-2499-reject-deleted-products-in-rest-orders
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Return an error when REST API orders add line items for deleted or unknown products. To add an item with no product, send a name and total.
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 53c482f73bf..10ab129c3b4 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
@@ -10,6 +10,7 @@
  * @since    3.0.0
  */

+use Automattic\WooCommerce\Internal\RestApi\OrderLineItemValidationTrait;
 use Automattic\WooCommerce\Internal\Utilities\Users;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Enums\ProductTaxStatus;
@@ -27,6 +28,8 @@ if ( ! defined( 'ABSPATH' ) ) {
  */
 class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {

+	use OrderLineItemValidationTrait;
+
 	/**
 	 * Endpoint namespace.
 	 *
@@ -453,7 +456,7 @@ class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
 	/**
 	 * Prepare a single order for create.
 	 *
-	 * @throws WC_REST_Exception If the customer ID is invalid.
+	 * @throws WC_REST_Exception If the customer ID, or a new line item's product, is invalid.
 	 *
 	 * @param  WP_REST_Request $request Request object.
 	 * @return WP_Error|WC_Order $data Object.
@@ -464,6 +467,8 @@ class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
 		$schema    = $this->get_item_schema();
 		$data_keys = array_keys( array_filter( $schema['properties'], array( $this, 'filter_writable_props' ) ) );

+		$this->check_new_line_item_products( $request['line_items'] );
+
 		if ( ! is_null( $request['customer_id'] ) && 0 !== $request['customer_id'] ) {
 			if ( is_wp_error( Users::get_user_in_current_site( $request['customer_id'] ) ) ) {
 				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
@@ -628,10 +633,11 @@ class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
 			$product_id = (int) $posted['product_id'];
 		} elseif ( ! empty( $posted['variation_id'] ) ) {
 			$product_id = (int) $posted['variation_id'];
-		} elseif ( 'update' === $action ) {
+		} elseif ( 'update' === $action || $this->is_custom_line_item( $posted ) ) {
 			$product_id = 0;
 		} else {
-			throw new WC_REST_Exception( 'woocommerce_rest_required_product_reference', __( 'Product ID or SKU is required.', 'woocommerce' ), 400 );
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- REST API JSON response, not HTML.
+			throw new WC_REST_Exception( 'woocommerce_rest_required_product_reference', __( 'Product ID or SKU is required. For a custom item, send a name and total instead.', 'woocommerce' ), 400 );
 		}
 		return $product_id;
 	}
@@ -692,6 +698,12 @@ class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
 			&& array_key_exists( 'variation_id', $posted )
 			&& 0 === (int) $posted['variation_id'];

+		// Only new items are checked, so orders whose products were deleted later stay editable.
+		// prepare_item_for_database() already checks them up front, but subclasses that skip it, like Subscriptions, rely on this check.
+		if ( 'create' === $action ) {
+			$this->check_line_item_product( $posted, $product );
+		}
+
 		if ( $clear_variation_id && $product instanceof WC_Product_Variation ) {
 			$product = wc_get_product( $current_product_id );
 		}
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 d749d815708..b362d0d9ea2 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
@@ -11,6 +11,7 @@
 defined( 'ABSPATH' ) || exit;

 use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\RestApi\OrderLineItemValidationTrait;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\OrderLineMetaValidator;
 use Automattic\WooCommerce\Internal\Utilities\Users;
 use Automattic\WooCommerce\Enums\ProductType;
@@ -29,6 +30,8 @@ use Automattic\WooCommerce\Utilities\StringUtil;
 class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {

 	// phpcs:enable
+	use OrderLineItemValidationTrait;
+
 	/**
 	 * Endpoint namespace.
 	 *
@@ -726,6 +729,7 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
 	/**
 	 * Prepare a single order for create or update.
 	 *
+	 * @throws WC_REST_Exception When a new line item's product doesn't exist.
 	 * @param  WP_REST_Request $request Request object.
 	 * @param  bool            $creating If is creating a new object.
 	 * @return WP_Error|WC_Data
@@ -736,6 +740,8 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
 		$schema    = $this->get_item_schema();
 		$data_keys = array_keys( array_filter( $schema['properties'], array( $this, 'filter_writable_props' ) ) );

+		$this->check_new_line_item_products( $request['line_items'] );
+
 		$item_types = array( 'shipping_lines', 'line_items', 'fee_lines', 'coupon_lines' );

 		// Handle all writable props.
@@ -895,10 +901,11 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
 			$product_id = (int) $posted['product_id'];
 		} elseif ( ! empty( $posted['variation_id'] ) ) {
 			$product_id = (int) $posted['variation_id'];
-		} elseif ( 'update' === $action ) {
+		} elseif ( 'update' === $action || $this->is_custom_line_item( $posted ) ) {
 			$product_id = 0;
 		} else {
-			throw new WC_REST_Exception( 'woocommerce_rest_required_product_reference', __( 'Product ID or SKU is required.', 'woocommerce' ), 400 );
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- REST API JSON response, not HTML.
+			throw new WC_REST_Exception( 'woocommerce_rest_required_product_reference', __( 'Product ID or SKU is required. For a custom item, send a name and total instead.', 'woocommerce' ), 400 );
 		}
 		return $product_id;
 	}
@@ -972,6 +979,12 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
 			&& array_key_exists( 'variation_id', $posted )
 			&& 0 === (int) $posted['variation_id'];

+		// Only new items are checked, so orders whose products were deleted later stay editable.
+		// prepare_object_for_database() already checks them up front, but subclasses that skip it, like Subscriptions, rely on this check.
+		if ( 'create' === $action ) {
+			$this->check_line_item_product( $posted, $product );
+		}
+
 		if ( $clear_variation_id && $product instanceof WC_Product_Variation ) {
 			$product = wc_get_product( $current_product_id );
 		}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php
index 239be85a839..2b97d7eba8d 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php
@@ -10,6 +10,7 @@

 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
+use Automattic\WooCommerce\Internal\RestApi\OrderLineItemValidationTrait;
 use Automattic\WooCommerce\Internal\Utilities\Users;
 use Automattic\WooCommerce\Utilities\ArrayUtil;
 use Automattic\WooCommerce\Utilities\OrderUtil;
@@ -26,6 +27,7 @@ defined( 'ABSPATH' ) || exit;
  */
 class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller {
 	use CogsAwareTrait;
+	use OrderLineItemValidationTrait;

 	/**
 	 * Endpoint namespace.
@@ -115,6 +117,8 @@ class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller {
 		$schema    = $this->get_item_schema();
 		$data_keys = array_keys( array_filter( $schema['properties'], array( $this, 'filter_writable_props' ) ) );

+		$this->check_new_line_item_products( $request['line_items'] );
+
 		$item_types = array( 'shipping_lines', 'line_items', 'fee_lines' );

 		// Handle all writable props.
diff --git a/plugins/woocommerce/src/Internal/RestApi/OrderLineItemValidationTrait.php b/plugins/woocommerce/src/Internal/RestApi/OrderLineItemValidationTrait.php
new file mode 100644
index 00000000000..c6d99535806
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/RestApi/OrderLineItemValidationTrait.php
@@ -0,0 +1,89 @@
+<?php
+/**
+ * Order line item checks for REST write requests.
+ *
+ * @package WooCommerce\Internal\RestApi
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\RestApi;
+
+/**
+ * Line item checks shared by the order controllers.
+ *
+ * The checks use the controller's own get_product_id() and item_is_null(), so subclass overrides still apply.
+ */
+trait OrderLineItemValidationTrait {
+
+	/**
+	 * Checks whether a line item is a custom item: one with no product, but a name and a total.
+	 *
+	 * @param array $line_item Line item data from the request.
+	 * @return bool
+	 */
+	private function is_custom_line_item( $line_item ): bool {
+		$has_product_reference = ! empty( $line_item['product_id'] ) || ! empty( $line_item['variation_id'] ) || ! empty( $line_item['sku'] );
+		$has_name              = isset( $line_item['name'] ) && is_string( $line_item['name'] ) && '' !== trim( $line_item['name'] );
+		$has_total             = isset( $line_item['total'] ) && is_numeric( wc_format_decimal( $line_item['total'] ) );
+
+		return ! $has_product_reference && $has_name && $has_total;
+	}
+
+	/**
+	 * Checks that every new line item, other than custom items, points to an existing product, before any item is saved.
+	 * Otherwise a bad item fails the request after earlier items were already saved, along with any stock changes.
+	 *
+	 * @param mixed $line_items Line items from the request.
+	 * @return void
+	 * @throws \WC_REST_Exception When a new line item has no product reference, or its product doesn't exist.
+	 */
+	private function check_new_line_item_products( $line_items ): void {
+		if ( ! is_array( $line_items ) ) {
+			return;
+		}
+
+		foreach ( $line_items as $posted ) {
+			if ( ! is_array( $posted ) ) {
+				continue;
+			}
+
+			// Existing items can point to products deleted since the order was placed, and must stay editable.
+			if ( ! empty( $posted['id'] ) ) {
+				continue;
+			}
+
+			// Null and zero-quantity items are removals.
+			if ( $this->item_is_null( $posted ) ) {
+				continue;
+			}
+
+			if ( isset( $posted['quantity'] ) && 0 === $posted['quantity'] ) {
+				continue;
+			}
+
+			$this->check_line_item_product( $posted, wc_get_product( $this->get_product_id( $posted ) ) );
+		}
+	}
+
+	/**
+	 * Checks that a new line item points to an existing product, unless it's a custom item.
+	 *
+	 * @param array                  $line_item Line item data from the request.
+	 * @param \WC_Product|false|null $product   The product the line item resolves to.
+	 * @return void
+	 * @throws \WC_REST_Exception When the product doesn't exist.
+	 */
+	private function check_line_item_product( $line_item, $product ): void {
+		if ( $this->is_custom_line_item( $line_item ) ) {
+			return;
+		}
+
+		if ( $product && $product->exists() ) {
+			return;
+		}
+
+		// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- REST API JSON response, not HTML.
+		throw new \WC_REST_Exception( 'woocommerce_rest_invalid_product_id', __( 'Invalid product ID.', 'woocommerce' ), 400 );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/RestApi/README.md b/plugins/woocommerce/src/Internal/RestApi/README.md
index 5f710885393..854436bd604 100644
--- a/plugins/woocommerce/src/Internal/RestApi/README.md
+++ b/plugins/woocommerce/src/Internal/RestApi/README.md
@@ -8,6 +8,7 @@ Only `Routes/V4/` is the experimental v4 API. Other code in this directory is sh

 - `Refunds/`: the version-neutral refund engine used by the wc/v3 refund endpoints and the v4 routes.
 - `ProductRequestPreparationTrait.php`: used by the wc/v2 and wc/v3 product endpoints.
+- `OrderLineItemValidationTrait.php`: used by the wc/v1, wc/v2 and wc/v3 order endpoints.
 - `InteractiveSessionCheck.php`: used by the wc-admin plugin and onboarding endpoints.

 `Routes/V4/Orders/OrderLineMetaValidator.php` is also used by the wc/v2 and wc/v3 order endpoints. Move it out of `Routes/V4/` before removing the v4 routes.
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 adbd55fac80..0d8543f03c8 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
@@ -174,6 +174,88 @@ class WC_REST_Orders_V1_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$this->assertEquals( 'woocommerce_rest_invalid_customer_id', $response->get_data()['code'], 'The returned error indicates the customer ID was invalid.' );
 	}

+	/**
+	 * @testdox Creating an order with a custom line item adds an item with no product.
+	 */
+	public function test_create_order_with_custom_line_item(): void {
+		$request = new WP_REST_Request( 'POST', '/wc/v1/orders' );
+		$request->set_body_params(
+			array(
+				'line_items' => array(
+					array(
+						'name'  => 'Gift wrap',
+						'total' => '5.00',
+					),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 201, $response->get_status(), 'The order should be created.' );
+		$item = current( wc_get_order( $response->get_data()['id'] )->get_items() );
+		$this->assertSame( 0, $item->get_product_id(), 'The item should have no product.' );
+		$this->assertSame( 'Gift wrap', $item->get_name(), 'The item should keep its name.' );
+		$this->assertSame( 5.0, (float) $item->get_total(), 'The item should keep its total.' );
+	}
+
+	/**
+	 * @testdox Updating an order with a deleted product leaves its existing items unchanged.
+	 */
+	public function test_update_order_with_deleted_product_leaves_existing_items_unchanged(): void {
+		$order   = wc_create_order();
+		$item_id = $order->add_product( WC_Helper_Product::create_simple_product() );
+		$order->save();
+
+		$deleted    = WC_Helper_Product::create_simple_product();
+		$deleted_id = $deleted->get_id();
+		$deleted->delete( true );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v1/orders/' . $order->get_id() );
+		$request->set_body_params(
+			array(
+				'line_items' => array(
+					array(
+						'id'       => $item_id,
+						'quantity' => 5,
+					),
+					array( 'product_id' => $deleted_id ),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status(), 'The request should be rejected.' );
+		$this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'], 'The error should name the invalid product.' );
+		$this->assertSame( 1, ( new WC_Order_Item_Product( $item_id ) )->get_quantity(), 'The existing item should keep its quantity.' );
+	}
+
+	/**
+	 * @testdox prepare_line_items() rejects a new line item for a deleted product, for subclasses that skip the up-front check.
+	 */
+	public function test_prepare_line_items_rejects_new_line_item_with_deleted_product(): void {
+		$deleted    = WC_Helper_Product::create_simple_product();
+		$deleted_id = $deleted->get_id();
+		$deleted->delete( true );
+
+		$controller = new class() extends WC_REST_Orders_V1_Controller {
+			/**
+			 * Public wrapper for prepare_line_items().
+			 *
+			 * @param array $posted Line item data.
+			 * @return WC_Order_Item_Product
+			 */
+			public function call_prepare_line_items( array $posted ) {
+				return $this->prepare_line_items( $posted );
+			}
+		};
+
+		$this->expectException( WC_REST_Exception::class );
+		$this->expectExceptionMessage( 'Invalid product ID.' );
+		$this->expectExceptionCode( 400 );
+
+		$controller->call_prepare_line_items( array( 'product_id' => $deleted_id ) );
+	}
+
 	/**
 	 * @testdox Updating with the unchanged parent preserves the variation while ignoring view-context product ID filters.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php
index db8590752eb..32d35e7726d 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php
@@ -1,5 +1,6 @@
 <?php

+use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Proxies\LegacyProxy;

 /**
@@ -464,6 +465,49 @@ class WC_REST_Order_V2_Controller_Test extends WC_REST_Unit_Test_case {
 		);
 	}

+	/**
+	 * @testdox PUT /wc/v2/orders/<id> with a deleted product leaves earlier new items and their stock unchanged.
+	 */
+	public function test_v2_update_order_with_deleted_product_saves_no_earlier_items(): void {
+		$order = wc_create_order();
+		$order->add_product( WC_Helper_Product::create_simple_product() );
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+		$stocked = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				'stock_quantity' => 10,
+			)
+		);
+
+		$deleted    = WC_Helper_Product::create_simple_product();
+		$deleted_id = $deleted->get_id();
+		$deleted->delete( true );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v2/orders/' . $order->get_id() );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'line_items' => array(
+						array(
+							'product_id' => $stocked->get_id(),
+							'quantity'   => 2,
+						),
+						array( 'product_id' => $deleted_id ),
+					),
+				)
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status(), 'The request should be rejected.' );
+		$this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'], 'The error should name the invalid product.' );
+		$this->assertCount( 1, wc_get_order( $order->get_id() )->get_items(), 'The order should keep only its original line item.' );
+		$this->assertSame( 10, wc_get_product( $stocked->get_id() )->get_stock_quantity(), 'The stock should not change.' );
+	}
+
 	/**
 	 * Dispatch a PUT that posts a serialized value under the reserved `_taxes` meta key of one order item.
 	 *
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 77f2fa1e75c..e83788d6800 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
@@ -1754,6 +1754,177 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$this->assertTrue( $line_item_meta['display_key']['readonly'] );
 	}

+	/**
+	 * Line items that point to a missing product.
+	 *
+	 * @return array<string, array{callable(): array<string, int|string>}>
+	 */
+	public function provide_line_items_with_missing_product(): array {
+		return array(
+			'deleted variation' => array(
+				static function (): array {
+					$variable  = WC_Helper_Product::create_variation_product();
+					$variation = wc_get_product( $variable->get_children()[0] );
+					$line_item = array(
+						'product_id'   => $variable->get_id(),
+						'variation_id' => $variation->get_id(),
+					);
+					$variation->delete( true );
+
+					return $line_item;
+				},
+			),
+			'unknown SKU'       => array(
+				static function (): array {
+					return array( 'sku' => 'missing-sku' );
+				},
+			),
+		);
+	}
+
+	/**
+	 * @testdox POST /orders rejects a line item for a missing product ($_dataName), and creates no order.
+	 * @dataProvider provide_line_items_with_missing_product
+	 *
+	 * @param callable $build_line_item Builds the line item payload.
+	 */
+	public function test_create_order_rejects_line_item_with_missing_product( callable $build_line_item ): void {
+		$line_item   = $build_line_item();
+		$query_args  = array(
+			'return' => 'ids',
+			'limit'  => -1,
+		);
+		$order_count = count( wc_get_orders( $query_args ) );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/orders' );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body( wp_json_encode( array( 'line_items' => array( $line_item ) ) ) );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status(), 'The request should be rejected.' );
+		$this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'], 'The error should name the invalid product.' );
+		$this->assertCount( $order_count, wc_get_orders( $query_args ), 'No order should be created.' );
+	}
+
+	/**
+	 * @testdox POST /orders adds a custom line item that has a name and total but no product.
+	 */
+	public function test_create_order_with_custom_line_item(): void {
+		$request = new WP_REST_Request( 'POST', '/wc/v3/orders' );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'line_items' => array(
+						array(
+							'name'     => 'Gift wrap',
+							'quantity' => 2,
+							'total'    => '5.00',
+						),
+					),
+				)
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 201, $response->get_status(), 'The order should be created.' );
+		$item = current( wc_get_order( $response->get_data()['id'] )->get_items() );
+		$this->assertSame( 0, $item->get_product_id(), 'The item should have no product.' );
+		$this->assertSame( 'Gift wrap', $item->get_name(), 'The item should keep its name.' );
+		$this->assertSame( 2, $item->get_quantity(), 'The item should keep its quantity.' );
+		$this->assertSame( 5.0, (float) $item->get_total(), 'The item should keep its total.' );
+	}
+
+	/**
+	 * @testdox PUT /orders with a deleted product leaves earlier new items and their stock unchanged.
+	 */
+	public function test_update_order_with_deleted_product_saves_no_earlier_items(): void {
+		list( $order ) = $this->create_order_with_line_item();
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+		$stocked = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				'stock_quantity' => 10,
+			)
+		);
+
+		$deleted    = WC_Helper_Product::create_simple_product();
+		$deleted_id = $deleted->get_id();
+		$deleted->delete( true );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order->get_id() );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'line_items' => array(
+						array(
+							'product_id' => $stocked->get_id(),
+							'quantity'   => 2,
+						),
+						array( 'product_id' => $deleted_id ),
+					),
+				)
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status(), 'The request should be rejected.' );
+		$this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'], 'The error should name the invalid product.' );
+		$this->assertCount( 1, wc_get_order( $order->get_id() )->get_items(), 'The order should keep only its original line item.' );
+		$this->assertSame( 10, wc_get_product( $stocked->get_id() )->get_stock_quantity(), 'The stock should not change.' );
+	}
+
+	/**
+	 * @testdox PUT /orders still updates an existing line item whose product was deleted.
+	 */
+	public function test_update_existing_line_item_with_deleted_product_succeeds(): void {
+		list( $order, $item_id ) = $this->create_order_with_line_item();
+		$product_id              = ( new WC_Order_Item_Product( $item_id ) )->get_product_id();
+		wc_get_product( $product_id )->delete( true );
+
+		$response = $this->dispatch_line_item_update(
+			$order->get_id(),
+			array(
+				'id'         => $item_id,
+				'product_id' => $product_id,
+				'quantity'   => 2,
+			)
+		);
+
+		$this->assertSame( 200, $response->get_status(), 'The update should succeed.' );
+		$this->assertSame( 2, ( new WC_Order_Item_Product( $item_id ) )->get_quantity(), 'The quantity should be updated.' );
+	}
+
+	/**
+	 * @testdox prepare_line_items() rejects a new line item for a deleted product, for subclasses that skip the up-front check.
+	 */
+	public function test_prepare_line_items_rejects_new_line_item_with_deleted_product(): void {
+		$deleted    = WC_Helper_Product::create_simple_product();
+		$deleted_id = $deleted->get_id();
+		$deleted->delete( true );
+
+		$controller = new class() extends WC_REST_Orders_Controller {
+			/**
+			 * Public wrapper for prepare_line_items().
+			 *
+			 * @param array $posted Line item data.
+			 * @return WC_Order_Item_Product
+			 */
+			public function call_prepare_line_items( array $posted ) {
+				return $this->prepare_line_items( $posted );
+			}
+		};
+
+		$this->expectException( WC_REST_Exception::class );
+		$this->expectExceptionMessage( 'Invalid product ID.' );
+		$this->expectExceptionCode( 400 );
+
+		$controller->call_prepare_line_items( array( 'product_id' => $deleted_id ) );
+	}
+
 	/**
 	 * Builds a variable product with a single variation carrying a real "color" attribute.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/OrderLineItemValidationTraitTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/OrderLineItemValidationTraitTest.php
new file mode 100644
index 00000000000..a76ea020743
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/OrderLineItemValidationTraitTest.php
@@ -0,0 +1,157 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\RestApi;
+
+use Automattic\WooCommerce\Internal\RestApi\OrderLineItemValidationTrait;
+use WC_Helper_Product;
+use WC_REST_Exception;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the OrderLineItemValidationTrait trait.
+ */
+class OrderLineItemValidationTraitTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var object
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->sut = new class() {
+			use OrderLineItemValidationTrait;
+
+			/**
+			 * Stands in for the controller's lookup.
+			 *
+			 * @param array $posted Line item data.
+			 * @return int
+			 */
+			protected function get_product_id( $posted ) {
+				return (int) ( $posted['product_id'] ?? 0 );
+			}
+
+			/**
+			 * Stands in for the controller's removal check.
+			 *
+			 * @param array $item Line item data.
+			 * @return bool
+			 */
+			protected function item_is_null( $item ) {
+				return array_key_exists( 'product_id', $item ) && is_null( $item['product_id'] );
+			}
+
+			/**
+			 * Public wrapper for is_custom_line_item().
+			 *
+			 * @param array $line_item Line item data.
+			 * @return bool
+			 */
+			public function call_is_custom_line_item( $line_item ): bool {
+				return $this->is_custom_line_item( $line_item );
+			}
+
+			/**
+			 * Public wrapper for check_new_line_item_products().
+			 *
+			 * @param mixed $line_items Line items from the request.
+			 */
+			public function call_check_new_line_item_products( $line_items ): void {
+				$this->check_new_line_item_products( $line_items );
+			}
+		};
+	}
+
+	/**
+	 * @testdox Should treat a line item as custom only when it has no product reference, a name and a numeric total.
+	 * @testWith [{"name": "Gift wrap", "total": "5.00"}, true]
+	 *           [{"name": "Gift wrap", "total": "0"}, true]
+	 *           [{"product_id": 0, "name": "Gift wrap", "total": "5.00"}, true]
+	 *           [{"product_id": 123, "name": "Gift wrap", "total": "5.00"}, false]
+	 *           [{"variation_id": 123, "name": "Gift wrap", "total": "5.00"}, false]
+	 *           [{"sku": "gift-wrap", "name": "Gift wrap", "total": "5.00"}, false]
+	 *           [{"name": "Gift wrap"}, false]
+	 *           [{"name": "Gift wrap", "total": null}, false]
+	 *           [{"name": "Gift wrap", "total": ""}, false]
+	 *           [{"name": "Gift wrap", "total": "abc"}, false]
+	 *           [{"total": "5.00"}, false]
+	 *           [{"name": " ", "total": "5.00"}, false]
+	 *           [{"name": 123, "total": "5.00"}, false]
+	 *
+	 * @param array<string, mixed> $line_item Line item payload.
+	 * @param bool                 $expected  Whether it should count as a custom item.
+	 */
+	public function test_is_custom_line_item( array $line_item, bool $expected ): void {
+		$this->assertSame(
+			$expected,
+			$this->sut->call_is_custom_line_item( $line_item ),
+			'Unexpected result for ' . wp_json_encode( $line_item )
+		);
+	}
+
+	/**
+	 * @testdox Should accept new line items whose product exists.
+	 */
+	public function test_check_new_line_item_products_accepts_existing_product(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$this->expectNotToPerformAssertions();
+
+		$this->sut->call_check_new_line_item_products( array( array( 'product_id' => $product->get_id() ) ) );
+	}
+
+	/**
+	 * @testdox Should reject a new line item for a missing product, even after a valid one.
+	 */
+	public function test_check_new_line_item_products_rejects_missing_product(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$this->expectException( WC_REST_Exception::class );
+		$this->expectExceptionMessage( 'Invalid product ID.' );
+		$this->expectExceptionCode( 400 );
+
+		$this->sut->call_check_new_line_item_products(
+			array(
+				array( 'product_id' => $product->get_id() ),
+				array( 'product_id' => 999999999 ),
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should skip line items that won't be saved as new product items.
+	 * @testWith [{"id": 7, "product_id": 999999999}]
+	 *           [{"product_id": null}]
+	 *           [{"product_id": 999999999, "quantity": 0}]
+	 *           [{"name": "Gift wrap", "total": "5.00"}]
+	 *           ["not a line item"]
+	 *
+	 * @param mixed $line_item Line item payload.
+	 */
+	public function test_check_new_line_item_products_skips_items_that_are_not_new_products( $line_item ): void {
+		$this->expectNotToPerformAssertions();
+
+		$this->sut->call_check_new_line_item_products( array( $line_item ) );
+	}
+
+	/**
+	 * @testdox Should do nothing when the request has no line items.
+	 * @testWith [null]
+	 *           [[]]
+	 *
+	 * @param mixed $line_items Line items from the request.
+	 */
+	public function test_check_new_line_item_products_ignores_missing_line_items( $line_items ): void {
+		$this->expectNotToPerformAssertions();
+
+		$this->sut->call_check_new_line_item_products( $line_items );
+	}
+}