Commit 587df956226 for woocommerce

commit 587df95622697a3772e9386f6b97b755c5f02379
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date:   Wed Oct 7 13:43:31 2026 +0200

    Ensure fulfillments REST API item requests use the fulfillment in the request URL (#69528)

    Co-authored-by: Taha Paksu <3295+tpaksu@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/fix-fulfillments-item-routes b/plugins/woocommerce/changelog/fix-fulfillments-item-routes
new file mode 100644
index 00000000000..0e34d4a593c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-fulfillments-item-routes
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Ensure fulfillments REST API item requests operate on the fulfillment identified in the request URL.
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Controller.php
index 371cb168194..1e603632642 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Controller.php
@@ -226,7 +226,8 @@ class Controller extends AbstractController {
 	 * @return WP_REST_Response
 	 */
 	public function get_fulfillment( WP_REST_Request $request ): WP_REST_Response {
-		$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
+		// Use the fulfillment ID from the matched route.
+		$fulfillment_id = (int) ( $request->get_url_params()['fulfillment_id'] ?? 0 );
 		$fulfillment    = new Fulfillment( $fulfillment_id );

 		if ( ! $fulfillment->get_id() ) {
@@ -245,7 +246,9 @@ class Controller extends AbstractController {
 			);
 		}

+		// Pass the route's IDs on to the v3 controller.
 		$order_id = (int) $fulfillment->get_entity_id();
+		$request->set_param( 'fulfillment_id', $fulfillment_id );
 		$request->set_param( 'order_id', $order_id );
 		return $this->order_fulfillments_controller->get_fulfillment( $request );
 	}
@@ -257,7 +260,8 @@ class Controller extends AbstractController {
 	 * @return WP_REST_Response
 	 */
 	public function update_fulfillment( WP_REST_Request $request ): WP_REST_Response {
-		$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
+		// Use the fulfillment ID from the matched route.
+		$fulfillment_id = (int) ( $request->get_url_params()['fulfillment_id'] ?? 0 );
 		$fulfillment    = new Fulfillment( $fulfillment_id );

 		if ( ! $fulfillment->get_id() ) {
@@ -276,7 +280,9 @@ class Controller extends AbstractController {
 			);
 		}

+		// Pass the route's IDs on to the v3 controller.
 		$order_id = (int) $fulfillment->get_entity_id();
+		$request->set_param( 'fulfillment_id', $fulfillment_id );
 		$request->set_param( 'order_id', $order_id );
 		return $this->order_fulfillments_controller->update_fulfillment( $request );
 	}
@@ -288,9 +294,29 @@ class Controller extends AbstractController {
 	 * @return WP_REST_Response
 	 */
 	public function delete_fulfillment( WP_REST_Request $request ): WP_REST_Response {
-		$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
+		// Use the fulfillment ID from the matched route.
+		$fulfillment_id = (int) ( $request->get_url_params()['fulfillment_id'] ?? 0 );
 		$fulfillment    = new Fulfillment( $fulfillment_id );
-		$order_id       = (int) $fulfillment->get_entity_id();
+
+		if ( ! $fulfillment->get_id() ) {
+			return $this->prepare_error_response(
+				'woocommerce_rest_fulfillment_invalid_id',
+				__( 'Invalid fulfillment ID.', 'woocommerce' ),
+				array( 'status' => WP_Http::NOT_FOUND )
+			);
+		}
+
+		if ( $fulfillment->get_entity_type() !== WC_Order::class ) {
+			return $this->prepare_error_response(
+				'woocommerce_rest_invalid_entity_type',
+				__( 'The entity type must be "order".', 'woocommerce' ),
+				array( 'status' => WP_Http::BAD_REQUEST )
+			);
+		}
+
+		// Pass the route's IDs on to the v3 controller.
+		$order_id = (int) $fulfillment->get_entity_id();
+		$request->set_param( 'fulfillment_id', $fulfillment_id );
 		$request->set_param( 'order_id', $order_id );
 		return $this->order_fulfillments_controller->delete_fulfillment( $request );
 	}
@@ -306,23 +332,16 @@ class Controller extends AbstractController {
 	 * @throws WP_Error If the URL contains an order, but the order does not exist.
 	 */
 	public function check_permission_for_fulfillments( WP_REST_Request $request ) {
-		// Fetch the order first if there's an order_id in the request.
-		$order = null;
+		// Item routes check the fulfillment's parent order, the collection read uses order_id, and create uses entity_id from the body.
+		$order      = null;
+		$url_params = $request->get_url_params();

-		// If there's an order_id in the request, try to get the order.
-		if ( $request->has_param( 'order_id' ) ) {
-			$order_id = (int) $request->get_param( 'order_id' );
-			$order    = wc_get_order( $order_id );
-		}
-
-		// If there's a fulfillment_id in the request, try to get the order from the fulfillment.
-		if ( ! $order && $request->has_param( 'fulfillment_id' ) ) {
-			$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
+		if ( isset( $url_params['fulfillment_id'] ) ) {
+			$fulfillment_id = (int) $url_params['fulfillment_id'];
 			if ( $fulfillment_id ) {
 				try {
 					$fulfillment = new Fulfillment( $fulfillment_id );
-					$order_id    = (int) $fulfillment->get_entity_id();
-					$order       = wc_get_order( $order_id );
+					$order       = wc_get_order( (int) $fulfillment->get_entity_id() );
 				} catch ( ApiException $ex ) {
 					return new WP_Error(
 						$ex->getErrorCode(),
@@ -337,21 +356,24 @@ class Controller extends AbstractController {
 					);
 				}
 			}
-		}
+		} elseif ( WP_REST_Server::CREATABLE === $request->get_method() ) {
+			// Create: the order comes from the request body as entity_id/entity_type.
+			$body_params = $request->get_json_params();
+			if ( isset( $body_params['entity_id'] ) && isset( $body_params['entity_type'] ) ) {
+				if ( WC_Order::class !== $body_params['entity_type'] ) {
+					return new WP_Error(
+						'woocommerce_rest_invalid_entity_type',
+						esc_html__( 'The entity type must be "order".', 'woocommerce' ),
+						array( 'status' => esc_attr( WP_Http::BAD_REQUEST ) )
+					);
+				}

-		// If there's no order_id in the request, try to get it from the request body.
-		$body_params = $request->get_json_params();
-		if ( ! $order && isset( $body_params['entity_id'] ) && isset( $body_params['entity_type'] ) ) {
-			if ( WC_Order::class !== $body_params['entity_type'] ) {
-				return new WP_Error(
-					'woocommerce_rest_invalid_entity_type',
-					esc_html__( 'The entity type must be "order".', 'woocommerce' ),
-					array( 'status' => esc_attr( WP_Http::BAD_REQUEST ) )
-				);
+				$order = wc_get_order( (int) $body_params['entity_id'] );
 			}
-
-			$order_id = (int) $body_params['entity_id'];
-			$order    = wc_get_order( $order_id );
+		} else {
+			// Collection read (GET, and HEAD which core maps to GET): the order comes
+			// from the order_id query arg.
+			$order = wc_get_order( (int) $request->get_param( 'order_id' ) );
 		}

 		// If there's still no order, return an error.
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php
index 9dcf9afe5df..25257250f07 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php
@@ -577,4 +577,103 @@ class ControllerTest extends WC_Unit_Test_Case {
 			$overrides
 		);
 	}
+
+	/**
+	 * Create a second customer who owns a fresh order and fulfillment.
+	 *
+	 * @return array{user_id:int, order:WC_Order, fulfillment:Fulfillment}
+	 */
+	private function create_other_customer_with_fulfillment(): array {
+		$user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		$order   = WC_Helper_Order::create_order( $user_id );
+		$ff      = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => $order->get_id() ) );
+
+		return array(
+			'user_id'     => $user_id,
+			'order'       => $order,
+			'fulfillment' => $ff,
+		);
+	}
+
+	/**
+	 * @testdox The item route checks the fulfillment's own order when an order_id is passed.
+	 */
+	public function test_item_route_checks_fulfillment_order_when_order_id_given(): void {
+		$other_customer = $this->create_other_customer_with_fulfillment();
+		wp_set_current_user( $other_customer['user_id'] );
+
+		$request = new WP_REST_Request( 'GET', '/wc/v4/fulfillments/' . $this->test_fulfillment->get_id() );
+		$request->set_query_params( array( 'order_id' => $other_customer['order']->get_id() ) );
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertEquals( 403, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_api_v4_fulfillments_cannot_view', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox The item route checks the fulfillment's own order when no order_id is passed.
+	 */
+	public function test_item_route_checks_fulfillment_order_without_order_id(): void {
+		$other_customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $other_customer_id );
+
+		$request  = new WP_REST_Request( 'GET', '/wc/v4/fulfillments/' . $this->test_fulfillment->get_id() );
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertEquals( 403, $response->get_status() );
+	}
+
+	/**
+	 * @testdox A customer can read the fulfillment on their own order.
+	 */
+	public function test_customer_can_read_own_fulfillment(): void {
+		wp_set_current_user( self::$customer_user_id );
+
+		$request  = new WP_REST_Request( 'GET', '/wc/v4/fulfillments/' . $this->test_fulfillment->get_id() );
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( $this->test_fulfillment->get_id(), $response->get_data()['id'] );
+	}
+
+	/**
+	 * @testdox The collection read checks the order_id order only.
+	 */
+	public function test_collection_read_ignores_fulfillment_id_param(): void {
+		$other_customer = $this->create_other_customer_with_fulfillment();
+		wp_set_current_user( $other_customer['user_id'] );
+
+		$request = new WP_REST_Request( 'GET', '/wc/v4/fulfillments' );
+		$request->set_query_params(
+			array(
+				'order_id'       => $this->test_order->get_id(),
+				'fulfillment_id' => $other_customer['fulfillment']->get_id(),
+			)
+		);
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertEquals( 403, $response->get_status() );
+	}
+
+	/**
+	 * @testdox The item route returns the fulfillment named in the URL.
+	 */
+	public function test_item_route_uses_fulfillment_id_from_url(): void {
+		$other_customer = $this->create_other_customer_with_fulfillment();
+		wp_set_current_user( $other_customer['user_id'] );
+
+		$request = new WP_REST_Request( 'GET', '/wc/v4/fulfillments/' . $other_customer['fulfillment']->get_id() );
+		$request->set_query_params( array( 'fulfillment_id' => $this->test_fulfillment->get_id() ) );
+		$response = rest_get_server()->dispatch( $request );
+
+		$data = $response->get_data();
+		$this->assertNotEquals(
+			$this->test_fulfillment->get_id(),
+			$data['id'] ?? null,
+			'The item route should return the fulfillment from the URL, not the fulfillment_id query param.'
+		);
+		if ( 200 === $response->get_status() ) {
+			$this->assertEquals( $other_customer['fulfillment']->get_id(), $data['id'] );
+		}
+	}
 }