Commit b7939b64936 for woocommerce

commit b7939b649365c9fdb713c4c49b0f29952b712cc3
Author: Christopher Hertel <mail@christopher-hertel.de>
Date:   Mon Nov 9 19:08:12 2026 +0100

    Return a not-found error for missing orders in the order abilities (#70745)

    * Return a not-found error for missing orders in the order abilities

    * Add changelog entry for the order abilities not-found error

    * Remove the test that expected a permission error for unknown order IDs

    * Treat non-order IDs as missing and require order edit access for them

    * Keep refusing orders whose type the wc_order_types filter hides

    * Keep the woocommerce_order_not_found error code

diff --git a/plugins/woocommerce/changelog/wooai-1649-order-not-found b/plugins/woocommerce/changelog/wooai-1649-order-not-found
new file mode 100644
index 00000000000..5380e29394b
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1649-order-not-found
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Return a not-found error instead of a permission error when an order ability is given an order ID that does not exist.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
index f5d4c74008d..adfaf20604b 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
@@ -226,7 +226,8 @@ trait OrderAbilityTrait {
 		if ( ! $order instanceof \WC_Order ) {
 			return new \WP_Error(
 				'woocommerce_order_not_found',
-				__( 'Order not found.', 'woocommerce' ),
+				/* translators: %d: order ID. */
+				sprintf( __( 'Order %d does not exist. Use the woocommerce/orders-query ability to find the order ID.', 'woocommerce' ), $input['id'] ),
 				array( 'status' => 404 )
 			);
 		}
@@ -264,16 +265,22 @@ trait OrderAbilityTrait {

 	/**
 	 * Check a permission against the actual type of an order. An order whose
-	 * type the abilities do not support fails. An ID with no order is checked
-	 * as `shop_order`.
+	 * type the abilities do not support fails. An ID that wc_get_order() has no
+	 * order class for passes for users who could act on any order, so they get
+	 * the not-found error instead of a permission error.
 	 *
 	 * @param int    $order_id Order ID.
 	 * @param string $context  Permission context, such as `read` or `edit`.
 	 * @return bool
 	 */
 	protected static function check_order_permission( int $order_id, string $context ): bool {
+		$class_names = \WC_Order_Factory::get_class_names_for_order_ids( array( $order_id ) );
+
+		if ( empty( $class_names[ $order_id ] ) ) {
+			return wc_rest_check_post_permissions( 'shop_order', 'edit' === $context ? 'batch' : $context );
+		}
+
 		$type = OrderUtil::get_order_type( $order_id );
-		$type = $type ? $type : 'shop_order';

 		return in_array( $type, self::get_order_type_slugs(), true )
 			&& wc_rest_check_post_permissions( $type, $context, $order_id );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index 2f246eadc9a..8d7d186c21d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -1088,6 +1088,177 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Should return a not-found error naming the ID when an order mutation receives an ID that is not an order.
+	 *
+	 * @dataProvider provider_order_storage_engines
+	 *
+	 * @param bool $hpos_enabled Whether HPOS/COT should be enabled for the test.
+	 */
+	public function test_order_mutations_return_not_found_for_id_that_is_not_an_order( bool $hpos_enabled ): void {
+		$this->set_order_storage_for_test( $hpos_enabled );
+		$product                     = \WC_Helper_Product::create_simple_product();
+		$this->created_product_ids[] = $product->get_id();
+
+		foreach ( array( 999999, $product->get_id() ) as $id ) {
+			foreach ( $this->get_order_mutation_inputs( $id ) as $ability_id => $input ) {
+				$result = wp_get_ability( $ability_id )->execute( $input );
+
+				$this->assertWPError( $result, "{$ability_id} should refuse ID {$id}." );
+				$this->assertSame( 'woocommerce_order_not_found', $result->get_error_code(), "{$ability_id} should name missing order {$id}, not refuse permission." );
+				$this->assertStringContainsString( (string) $id, $result->get_error_message() );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Should refuse permission for an unknown order ID to users who cannot edit orders.
+	 *
+	 * @testWith ["customer"]
+	 *           ["read_private_shop_orders"]
+	 *
+	 * @param string $role_or_cap Role of the user, or the only capability added to a subscriber.
+	 */
+	public function test_order_mutations_refuse_unknown_id_to_users_who_cannot_edit_orders( string $role_or_cap ): void {
+		$user_id = 'customer' === $role_or_cap
+			? self::factory()->user->create( array( 'role' => 'customer' ) )
+			: $this->create_user_with_caps( array( $role_or_cap ) );
+		wp_set_current_user( $user_id );
+
+		foreach ( $this->get_order_mutation_inputs( 999999 ) as $ability_id => $input ) {
+			$result = wp_get_ability( $ability_id )->execute( $input );
+
+			$this->assertWPError( $result, "{$ability_id} should refuse a user who cannot edit orders." );
+			$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+		}
+	}
+
+	/**
+	 * @testdox Should refuse permission when an order mutation receives the ID of a refund.
+	 */
+	public function test_order_mutations_refuse_id_of_refund(): void {
+		$order                     = \WC_Helper_Order::create_order();
+		$this->created_order_ids[] = $order->get_id();
+		$refund                    = wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 1,
+			)
+		);
+
+		foreach ( $this->get_order_mutation_inputs( $refund->get_id() ) as $ability_id => $input ) {
+			$result = wp_get_ability( $ability_id )->execute( $input );
+
+			$this->assertWPError( $result, "{$ability_id} should refuse a refund ID." );
+			$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+		}
+	}
+
+	/**
+	 * @testdox Should refuse permission when an order mutation receives an order whose type the wc_order_types filter hides.
+	 */
+	public function test_order_mutations_refuse_order_of_type_hidden_by_filter(): void {
+		global $wc_order_types, $post_type_meta_caps, $wpdb;
+
+		// The posts data store refuses to read a type that the filter hides, so only HPOS can load the order.
+		$this->set_order_storage_for_test( true );
+		$meta_caps = $post_type_meta_caps;
+		wc_register_order_type(
+			'shop_test_hidden',
+			array(
+				'capability_type'          => 'shop_test_hidden',
+				'exclude_from_order_views' => true,
+				'add_order_meta_boxes'     => false,
+			)
+		);
+		$order                     = \WC_Helper_Order::create_order();
+		$this->created_order_ids[] = $order->get_id();
+		$wpdb->update( $wpdb->posts, array( 'post_type' => 'shop_test_hidden' ), array( 'ID' => $order->get_id() ) );
+		$wpdb->update( OrdersTableDataStore::get_orders_table_name(), array( 'type' => 'shop_test_hidden' ), array( 'id' => $order->get_id() ) );
+		clean_post_cache( $order->get_id() );
+		wp_cache_flush();
+		add_filter(
+			'wc_order_types',
+			static function ( $types ) {
+				return array_values( array_diff( $types, array( 'shop_test_hidden' ) ) );
+			}
+		);
+
+		try {
+			$this->assertSame( 'shop_test_hidden', OrderUtil::get_order_type( $order->get_id() ) );
+			$this->assertInstanceOf( \WC_Order::class, wc_get_order( $order->get_id() ) );
+
+			foreach ( $this->get_order_mutation_inputs( $order->get_id() ) as $ability_id => $input ) {
+				$result = wp_get_ability( $ability_id )->execute( $input );
+
+				$this->assertWPError( $result, "{$ability_id} should refuse an order whose type is hidden." );
+				$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+			}
+		} finally {
+			unset( $wc_order_types['shop_test_hidden'] );
+			unregister_post_type( 'shop_test_hidden' );
+			$post_type_meta_caps = $meta_caps; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- unregister_post_type() removes the shop_order meta caps too.
+		}
+	}
+
+	/**
+	 * @testdox Should refuse permission when an order mutation receives an order whose unregistered type an extension gives an order class.
+	 */
+	public function test_order_mutations_refuse_order_of_unregistered_type_with_class(): void {
+		global $wpdb;
+
+		$this->set_order_storage_for_test( true );
+		$order                     = \WC_Helper_Order::create_order();
+		$this->created_order_ids[] = $order->get_id();
+		$wpdb->update( OrdersTableDataStore::get_orders_table_name(), array( 'type' => 'shop_test_unreg' ), array( 'id' => $order->get_id() ) );
+		wp_cache_flush();
+		add_filter(
+			'woocommerce_order_class',
+			static function ( $class_name, $order_type ) {
+				return 'shop_test_unreg' === $order_type ? \WC_Order::class : $class_name;
+			},
+			10,
+			2
+		);
+
+		$this->assertSame( 'shop_test_unreg', OrderUtil::get_order_type( $order->get_id() ) );
+		$this->assertInstanceOf( \WC_Order::class, wc_get_order( $order->get_id() ) );
+
+		foreach ( $this->get_order_mutation_inputs( $order->get_id() ) as $ability_id => $input ) {
+			$result = wp_get_ability( $ability_id )->execute( $input );
+
+			$this->assertWPError( $result, "{$ability_id} should refuse an order whose type is not registered." );
+			$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+		}
+	}
+
+	/**
+	 * Valid input for each order mutation ability, keyed by ability ID.
+	 *
+	 * @param int $order_id Order ID to send.
+	 * @return array<string, array<string, mixed>>
+	 */
+	private function get_order_mutation_inputs( int $order_id ): array {
+		return array(
+			'woocommerce/order-send-email'    => array(
+				'id'    => $order_id,
+				'email' => 'invoice',
+			),
+			'woocommerce/order-update'        => array(
+				'id'       => $order_id,
+				'shipping' => array( 'city' => 'Springfield' ),
+			),
+			'woocommerce/order-update-status' => array(
+				'id'     => $order_id,
+				'status' => 'processing',
+			),
+			'woocommerce/order-add-note'      => array(
+				'id'   => $order_id,
+				'note' => 'A note.',
+			),
+		);
+	}
+
 	/**
 	 * @testdox Should require publish_products to transition an existing draft to statuses that publish capability protects.
 	 *
@@ -5238,21 +5409,6 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		$this->assertTrue( $manual_update_observed );
 	}

-	/**
-	 * @testdox Should reject status updates against unknown order IDs without leaking existence.
-	 */
-	public function test_order_update_status_rejects_unknown_id(): void {
-		$result = wp_get_ability( 'woocommerce/order-update-status' )->execute(
-			array(
-				'id'     => 999999,
-				'status' => 'processing',
-			)
-		);
-
-		$this->assertWPError( $result );
-		$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
-	}
-
 	/**
 	 * @testdox Should attribute order notes to the acting user.
 	 */