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'] );
+ }
+ }
}