Commit 7d2a2e03d44 for woocommerce
commit 7d2a2e03d4493735104c2ecc879d4c8d1eae4b6b
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Thu Oct 8 14:12:02 2026 +0300
Refactor Order Fulfillments REST API request field handling (#69564)
* Refactor Order Fulfillments REST API request field handling
* Add changefile(s) from automation for the following project(s): woocommerce
* Re-pin new fulfillment identity before applying create metadata
* Strengthen Order Fulfillments REST regression test assertions
* Resolve fulfillment REST identity from the route, not the request body
* Drop @since from private fulfillment REST helpers
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/69564-wooplug-7936-refactor-order-fulfillments-rest-api-request-field-handling b/plugins/woocommerce/changelog/69564-wooplug-7936-refactor-order-fulfillments-rest-api-request-field-handling
new file mode 100644
index 00000000000..a8470a16358
--- /dev/null
+++ b/plugins/woocommerce/changelog/69564-wooplug-7936-refactor-order-fulfillments-rest-api-request-field-handling
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Ensure Order Fulfillments REST API requests operate on the fulfillment and order identified by the route.
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
index b56953aa635..f3bf5412473 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
@@ -164,7 +164,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
// Fetch the order first if there's an order_id in the request.
$order = null;
if ( $request->has_param( 'order_id' ) ) {
- $order_id = (int) $request->get_param( 'order_id' );
+ $order_id = $this->resolve_route_id( $request, 'order_id' );
$order = wc_get_order( $order_id );
if ( ! $order ) {
@@ -204,6 +204,38 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
);
}
+ /**
+ * Resolve a route identifier (order or fulfillment id) from the matched URL.
+ *
+ * The native v3 routes carry both ids as URL path segments; the v4 facade injects them
+ * via set_param() after deriving them from the fulfillment. Reading the URL-captured
+ * value first keeps the handler bound to the route it matched, and falls back to the
+ * injected param for the v4 path.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @param string $key Either 'order_id' or 'fulfillment_id'.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return int The resolved identifier, or 0 when absent.
+ */
+ private function resolve_route_id( WP_REST_Request $request, string $key ): int {
+ $url_params = $request->get_url_params();
+
+ return (int) ( $url_params[ $key ] ?? $request->get_param( $key ) );
+ }
+
+ /**
+ * Pick the fields a write request is allowed to change from the body.
+ *
+ * Identity fields (id, entity_id, entity_type) and raw storage props are controller-owned,
+ * so they are dropped here and never reach set_props() where they could redirect the write.
+ *
+ * @param array<string, mixed> $params The request body params.
+ * @return array<string, mixed> The mutable subset of the params.
+ */
+ private function mutable_request_props( array $params ): array {
+ return array_intersect_key( $params, array_flip( array( 'status', 'is_fulfilled' ) ) );
+ }
+
/**
* Get the fulfillments for the order.
*
@@ -215,7 +247,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
* @return WP_REST_Response The fulfillments for the order, or an error if the request fails.
*/
public function get_fulfillments( WP_REST_Request $request ): WP_REST_Response {
- $order_id = (int) $request->get_param( 'order_id' );
+ $order_id = $this->resolve_route_id( $request, 'order_id' );
$fulfillments = array();
// Fetch fulfillments for the order.
@@ -257,7 +289,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
* @return WP_REST_Response The created fulfillment, or an error if the request fails.
*/
public function create_fulfillment( WP_REST_Request $request ) {
- $order_id = (int) $request->get_param( 'order_id' );
+ $order_id = $this->resolve_route_id( $request, 'order_id' );
$notify_customer = (bool) $request->get_param( 'notify_customer' );
$order = wc_get_order( $order_id );
@@ -275,13 +307,17 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
// Create a new fulfillment.
try {
$fulfillment = new Fulfillment();
- $params = $request->get_json_params();
- $fulfillment->set_props( $params );
+ $params = (array) $request->get_json_params();
+ // Only mutable fields come from the body. Identity (id, entity_id, entity_type) and raw storage
+ // props are controller-owned and set below, so a body cannot turn create into an update or
+ // attach the fulfillment to another entity.
+ $fulfillment->set_props( $this->mutable_request_props( $params ) );
+ $fulfillment->set_id( 0 );
+ $fulfillment->set_entity_type( WC_Order::class );
+ $fulfillment->set_entity_id( "$order_id" );
if ( isset( $params['meta_data'] ) ) {
$this->apply_request_meta_data( $params['meta_data'], $fulfillment );
}
- $fulfillment->set_entity_type( WC_Order::class );
- $fulfillment->set_entity_id( "$order_id" );
$fulfillment->save();
@@ -338,8 +374,8 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
* @throws \Exception If the fulfillment is not found or is deleted.
*/
public function get_fulfillment( WP_REST_Request $request ): WP_REST_Response {
- $order_id = (int) $request->get_param( 'order_id' );
- $fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
+ $order_id = $this->resolve_route_id( $request, 'order_id' );
+ $fulfillment_id = $this->resolve_route_id( $request, 'fulfillment_id' );
// Fetch the fulfillment for the order.
try {
@@ -376,8 +412,8 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
* @return WP_REST_Response The updated fulfillment, or an error if the request fails.
*/
public function update_fulfillment( WP_REST_Request $request ): WP_REST_Response {
- $order_id = (int) $request->get_param( 'order_id' );
- $fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
+ $order_id = $this->resolve_route_id( $request, 'order_id' );
+ $fulfillment_id = $this->resolve_route_id( $request, 'fulfillment_id' );
$notify_customer = (bool) $request->get_param( 'notify_customer' );
$customer_note_raw = $request->get_param( 'customer_note' );
$customer_note = is_string( $customer_note_raw ) ? $customer_note_raw : '';
@@ -400,11 +436,18 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
$previous_status = $fulfillment->get_status() ?? 'unfulfilled';
$this->validate_fulfillment( $fulfillment, $fulfillment_id, $order_id );
- $fulfillment->set_props( $request->get_json_params() );
+ $params = (array) $request->get_json_params();
+ // Only mutable fields come from the body. Identity comes from the route and is set explicitly
+ // below, so no field (including a props/props_from_storage payload) can redirect the write to
+ // another row or move the fulfillment to another order.
+ $fulfillment->set_props( $this->mutable_request_props( $params ) );
+ $fulfillment->set_id( $fulfillment_id );
+ $fulfillment->set_entity_type( WC_Order::class );
+ $fulfillment->set_entity_id( (string) $order_id );
$next_state = $fulfillment->get_is_fulfilled();
- if ( isset( $request->get_json_params()['meta_data'] ) ) {
- $meta_data = $request->get_json_params()['meta_data'];
+ if ( isset( $params['meta_data'] ) ) {
+ $meta_data = $params['meta_data'];
$normalized_keys = $this->apply_request_meta_data( $meta_data, $fulfillment );
// Remove meta keys not in the request. Skip if all entries were malformed
@@ -489,8 +532,8 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
* @return WP_REST_Response The deleted fulfillment, or an error if the request fails.
*/
public function delete_fulfillment( WP_REST_Request $request ) {
- $order_id = (int) $request->get_param( 'order_id' );
- $fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
+ $order_id = $this->resolve_route_id( $request, 'order_id' );
+ $fulfillment_id = $this->resolve_route_id( $request, 'fulfillment_id' );
$notify_customer = (bool) $request->get_param( 'notify_customer' );
// Delete the fulfillment for the order.
@@ -547,8 +590,8 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
* @return WP_REST_Response The metadata for the fulfillment, or an error if the request fails.
*/
public function get_fulfillment_meta( WP_REST_Request $request ): WP_REST_Response {
- $order_id = (int) $request->get_param( 'order_id' );
- $fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
+ $order_id = $this->resolve_route_id( $request, 'order_id' );
+ $fulfillment_id = $this->resolve_route_id( $request, 'fulfillment_id' );
// Fetch the metadata for the fulfillment.
try {
@@ -576,8 +619,8 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
* @return WP_REST_Response The updated metadata for the fulfillment, or an error if the request fails.
*/
public function update_fulfillment_meta( WP_REST_Request $request ): WP_REST_Response {
- $order_id = (int) $request->get_param( 'order_id' );
- $fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
+ $order_id = $this->resolve_route_id( $request, 'order_id' );
+ $fulfillment_id = $this->resolve_route_id( $request, 'fulfillment_id' );
// Update the metadata for the fulfillment.
try {
@@ -627,8 +670,8 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
* @return WP_REST_Response The deleted metadata for the fulfillment, or an error if the request fails.
*/
public function delete_fulfillment_meta( WP_REST_Request $request ) {
- $order_id = (int) $request->get_param( 'order_id' );
- $fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
+ $order_id = $this->resolve_route_id( $request, 'order_id' );
+ $fulfillment_id = $this->resolve_route_id( $request, 'fulfillment_id' );
// Delete the metadata for the fulfillment.
try {
@@ -666,7 +709,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
* @return WP_REST_Response The tracking number details, or an error if the request fails.
*/
public function get_tracking_number_details( WP_REST_Request $request ) {
- $order_id = (int) $request->get_param( 'order_id' );
+ $order_id = $this->resolve_route_id( $request, 'order_id' );
$tracking_number = sanitize_text_field( $request->get_param( 'tracking_number' ) );
if ( empty( $tracking_number ) ) {
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 1e603632642..5734e12f35f 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Controller.php
@@ -124,7 +124,7 @@ class Controller extends AbstractController {
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_fulfillment' ),
'permission_callback' => array( $this, 'check_permission_for_fulfillments' ),
- 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ 'args' => $this->get_update_args(),
),
array(
'methods' => WP_REST_Server::DELETABLE,
@@ -341,7 +341,14 @@ class Controller extends AbstractController {
if ( $fulfillment_id ) {
try {
$fulfillment = new Fulfillment( $fulfillment_id );
- $order = wc_get_order( (int) $fulfillment->get_entity_id() );
+ if ( $fulfillment->get_id() && WC_Order::class !== $fulfillment->get_entity_type() ) {
+ return new WP_Error(
+ 'woocommerce_rest_invalid_entity_type',
+ esc_html__( 'The entity type must be "order".', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
+ }
+ $order = wc_get_order( (int) $fulfillment->get_entity_id() );
} catch ( ApiException $ex ) {
return new WP_Error(
$ex->getErrorCode(),
@@ -419,6 +426,21 @@ class Controller extends AbstractController {
return $this->item_schema->get_item_schema();
}
+ /**
+ * Get the writable args for the update route.
+ *
+ * A fulfillment's identity and parent order come from the route, not the body, so entity_id and
+ * entity_type are not writable on edit. They stay required on create, where the parent is taken
+ * from the body.
+ *
+ * @return array The update endpoint args.
+ */
+ private function get_update_args(): array {
+ $args = $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE );
+ unset( $args['entity_id'], $args['entity_type'] );
+ return $args;
+ }
+
/**
* Get the item response for a fulfillment.
*
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
index d21485e6442..a9eabba07f0 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Admin\Features\Fulfillments;
use Automattic\WooCommerce\Admin\Features\Fulfillments\DataStore\FulfillmentsDataStore;
+use Automattic\WooCommerce\Admin\Features\Fulfillments\Fulfillment;
use Automattic\WooCommerce\Admin\Features\Fulfillments\OrderFulfillmentsRestController;
use Automattic\WooCommerce\Tests\Admin\Features\Fulfillments\Helpers\FulfillmentsHelper;
use WC_Helper_Order;
@@ -354,6 +355,64 @@ class OrderFulfillmentsRestControllerTest extends WC_REST_Unit_Test_Case {
$this->assertEquals( WP_Http::UNAUTHORIZED, $data['data']['status'] );
}
+ /**
+ * @testdox Create ignores an id in the body: it inserts a new fulfillment and applies metadata to it, not to the supplied id.
+ */
+ public function test_create_fulfillment_ignores_body_id(): void {
+ wp_set_current_user( 1 );
+
+ $order_a = WC_Helper_Order::create_order();
+ $order_b = WC_Helper_Order::create_order();
+ $other_fulfillment = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_b->get_id() ) );
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order_a->get_id() . '/fulfillments' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'id' => $other_fulfillment->get_id(),
+ 'status' => 'unfulfilled',
+ 'is_fulfilled' => false,
+ 'meta_data' => array(
+ array(
+ 'id' => 0,
+ 'key' => 'request_create_meta',
+ 'value' => 'new_fulfillment',
+ ),
+ array(
+ 'id' => 0,
+ 'key' => '_items',
+ 'value' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 2,
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( WP_Http::CREATED, $response->get_status() );
+
+ $created = $response->get_data();
+ $this->assertNotEquals( $other_fulfillment->get_id(), $created['id'], 'Create must insert a new fulfillment, not reuse the id from the body.' );
+ $this->assertEquals( (string) $order_a->get_id(), (string) $created['entity_id'], 'The new fulfillment must belong to the routed order.' );
+
+ $response_meta_keys = wp_list_pluck( $created['meta_data'], 'key' );
+ $this->assertContains( 'request_create_meta', $response_meta_keys, 'Request metadata must be applied to the new fulfillment.' );
+ $this->assertNotContains( 'test_meta_key', $response_meta_keys, 'The new fulfillment must not inherit metadata from the fulfillment whose id was supplied in the body.' );
+
+ // The metadata must persist on the saved row, not just appear in the response.
+ $created_reloaded = new Fulfillment( (int) $created['id'] );
+ $this->assertSame( 'new_fulfillment', $created_reloaded->get_meta( 'request_create_meta' ), 'Request metadata must be saved on the new fulfillment row.' );
+
+ $other_reloaded = new Fulfillment( $other_fulfillment->get_id() );
+ $this->assertSame( (string) $order_b->get_id(), $other_reloaded->get_entity_id(), 'The fulfillment whose id was supplied in the body must be untouched.' );
+ }
+
/**
* Test creating a fulfillment (user is admin).
*/
@@ -877,6 +936,313 @@ class OrderFulfillmentsRestControllerTest extends WC_REST_Unit_Test_Case {
wp_set_current_user( self::$created_user_id );
}
+ /**
+ * @testdox The v3 update route keeps the fulfillment on its own order and ignores entity_id in the body.
+ */
+ public function test_update_fulfillment_does_not_reparent_via_entity_id(): void {
+ wp_set_current_user( 1 );
+
+ $order_a = WC_Helper_Order::create_order();
+ $order_b = WC_Helper_Order::create_order();
+ $fulfillment = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_a->get_id() ) );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order_a->get_id() . '/fulfillments/' . $fulfillment->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'entity_id' => (string) $order_b->get_id(),
+ 'entity_type' => WC_Order::class,
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ 'meta_data' => array(
+ array(
+ 'id' => 0,
+ 'key' => '_items',
+ 'value' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 2,
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( WP_Http::OK, $response->get_status(), 'The update must succeed so the reparenting guard is actually exercised.' );
+
+ $reloaded = new Fulfillment( $fulfillment->get_id() );
+ $this->assertSame(
+ (string) $order_a->get_id(),
+ $reloaded->get_entity_id(),
+ 'A v3 PUT must not move the fulfillment to a different order via the request body.'
+ );
+ }
+
+ /**
+ * @testdox The v3 update route ignores an id in the body and never writes to a different fulfillment row.
+ */
+ public function test_update_fulfillment_ignores_body_id(): void {
+ wp_set_current_user( 1 );
+
+ $order_a = WC_Helper_Order::create_order();
+ $order_b = WC_Helper_Order::create_order();
+ $fulfillment_a = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_a->get_id() ) );
+ $fulfillment_b = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_b->get_id() ) );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order_a->get_id() . '/fulfillments/' . $fulfillment_a->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'id' => $fulfillment_b->get_id(),
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ 'meta_data' => array(
+ array(
+ 'id' => 0,
+ 'key' => '_items',
+ 'value' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 2,
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( WP_Http::OK, $response->get_status(), 'The update targeted by the route must succeed.' );
+
+ $other_reloaded = new Fulfillment( $fulfillment_b->get_id() );
+ $this->assertSame(
+ (string) $order_b->get_id(),
+ $other_reloaded->get_entity_id(),
+ 'A body id must not redirect the update onto a different fulfillment row.'
+ );
+ $this->assertNotSame(
+ 'fulfilled',
+ $other_reloaded->get_status(),
+ 'A body id must not change the other fulfillment status.'
+ );
+ }
+
+ /**
+ * @testdox The v3 update route resolves the order from the URL and ignores a different order_id in the body.
+ */
+ public function test_update_fulfillment_ignores_body_order_id(): void {
+ wp_set_current_user( 1 );
+
+ $order_a = WC_Helper_Order::create_order();
+ $order_b = WC_Helper_Order::create_order();
+ $fulfillment = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_a->get_id() ) );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order_a->get_id() . '/fulfillments/' . $fulfillment->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'order_id' => $order_b->get_id(),
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ 'meta_data' => array(
+ array(
+ 'id' => 0,
+ 'key' => '_items',
+ 'value' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 2,
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( WP_Http::OK, $response->get_status(), 'The update must resolve order_id from the URL, not from the body param.' );
+
+ $reloaded = new Fulfillment( $fulfillment->get_id() );
+ $this->assertSame(
+ (string) $order_a->get_id(),
+ $reloaded->get_entity_id(),
+ 'A different order_id in the body must not change which order the fulfillment belongs to.'
+ );
+ }
+
+ /**
+ * @testdox The v3 update route ignores a nested props.id payload and never writes to a different fulfillment row.
+ */
+ public function test_update_fulfillment_ignores_nested_props_id(): void {
+ wp_set_current_user( 1 );
+
+ $order_a = WC_Helper_Order::create_order();
+ $order_b = WC_Helper_Order::create_order();
+ $fulfillment_a = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_a->get_id() ) );
+ $fulfillment_b = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_b->get_id() ) );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order_a->get_id() . '/fulfillments/' . $fulfillment_a->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'props' => array( 'id' => $fulfillment_b->get_id() ),
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ 'meta_data' => array(
+ array(
+ 'id' => 0,
+ 'key' => '_items',
+ 'value' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 2,
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( WP_Http::OK, $response->get_status(), 'The update targeted by the route must succeed.' );
+
+ $other_reloaded = new Fulfillment( $fulfillment_b->get_id() );
+ $this->assertSame(
+ (string) $order_b->get_id(),
+ $other_reloaded->get_entity_id(),
+ 'A nested props.id must not redirect the update onto a different fulfillment row.'
+ );
+ $this->assertNotSame(
+ 'fulfilled',
+ $other_reloaded->get_status(),
+ 'A nested props.id must not change the other fulfillment status.'
+ );
+ }
+
+ /**
+ * @testdox The v3 update route ignores a props_from_storage payload and does not reparent the fulfillment.
+ */
+ public function test_update_fulfillment_ignores_props_from_storage_entity(): void {
+ wp_set_current_user( 1 );
+
+ $order_a = WC_Helper_Order::create_order();
+ $order_b = WC_Helper_Order::create_order();
+ $fulfillment = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_a->get_id() ) );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order_a->get_id() . '/fulfillments/' . $fulfillment->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'props_from_storage' => array(
+ 'entity_id' => (string) $order_b->get_id(),
+ 'entity_type' => WC_Order::class,
+ ),
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ 'meta_data' => array(
+ array(
+ 'id' => 0,
+ 'key' => '_items',
+ 'value' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 2,
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( WP_Http::OK, $response->get_status(), 'The update must succeed so the re-pin is exercised.' );
+
+ $reloaded = new Fulfillment( $fulfillment->get_id() );
+ $this->assertSame(
+ (string) $order_a->get_id(),
+ $reloaded->get_entity_id(),
+ 'A props_from_storage payload must not reparent the fulfillment to another order.'
+ );
+ }
+
+ /**
+ * @testdox The v3 update route ignores a props_from_storage id in the body and reports the routed fulfillment id everywhere.
+ */
+ public function test_update_fulfillment_ignores_props_from_storage_id(): void {
+ wp_set_current_user( 1 );
+
+ $order_a = WC_Helper_Order::create_order();
+ $order_b = WC_Helper_Order::create_order();
+ $fulfillment_a = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_a->get_id() ) );
+ $other_fulfillment = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_b->get_id() ) );
+
+ $hook_id = null;
+ add_action(
+ 'woocommerce_fulfillment_after_update',
+ function ( $fulfillment ) use ( &$hook_id ) {
+ $hook_id = $fulfillment->get_id();
+ }
+ );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order_a->get_id() . '/fulfillments/' . $fulfillment_a->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'props_from_storage' => array( 'id' => $other_fulfillment->get_id() ),
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ 'meta_data' => array(
+ array(
+ 'id' => 0,
+ 'key' => '_items',
+ 'value' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 2,
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( WP_Http::OK, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertSame(
+ $fulfillment_a->get_id(),
+ $data['id'],
+ 'The response must report the routed fulfillment id, not an id supplied through props_from_storage.'
+ );
+ $this->assertSame(
+ $fulfillment_a->get_id(),
+ $hook_id,
+ 'The after_update hook must receive the routed fulfillment, not the id supplied through props_from_storage.'
+ );
+
+ $other_reloaded = new Fulfillment( $other_fulfillment->get_id() );
+ $this->assertNotSame(
+ 'fulfilled',
+ $other_reloaded->get_status(),
+ 'The other fulfillment must not be modified.'
+ );
+ }
+
/**
* Test updating a fulfillment with an invalid order ID.
*/
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 25257250f07..071d10bcff5 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
@@ -317,6 +317,40 @@ class ControllerTest extends WC_Unit_Test_Case {
$this->assertEquals( 'woocommerce_rest_fulfillment_invalid_id', $data['code'] );
}
+ /**
+ * @testdox PUT keeps the fulfillment on its own order and ignores a different entity_id in the body.
+ */
+ public function test_update_fulfillment_does_not_reparent_via_entity_id(): void {
+ wp_set_current_user( self::$admin_user_id );
+
+ $other_order = WC_Helper_Order::create_order( self::$customer_user_id );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v4/fulfillments/' . $this->test_fulfillment->get_id() );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ $this->get_test_fulfillment_data(
+ array( 'entity_id' => (string) $other_order->get_id() )
+ )
+ )
+ );
+
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status(), 'The update must succeed so the reparenting guard is actually exercised.' );
+
+ $reloaded = new Fulfillment( $this->test_fulfillment->get_id() );
+ $this->assertSame(
+ (string) $this->test_order->get_id(),
+ $reloaded->get_entity_id(),
+ 'A PUT must not move the fulfillment to a different order via the request body.'
+ );
+ $this->assertSame(
+ WC_Order::class,
+ $reloaded->get_entity_type(),
+ 'A PUT must not change the fulfillment entity type via the request body.'
+ );
+ }
+
/**
* Test delete_fulfillment endpoint
*/
@@ -492,6 +526,31 @@ class ControllerTest extends WC_Unit_Test_Case {
$this->assertArrayHasKey( 'entity_type', $post_endpoint['args'] );
}
+ /**
+ * @testdox The update endpoint schema does not expose the route-derived identity fields as writable.
+ */
+ public function test_update_fulfillment_schema_excludes_identity_fields(): void {
+ wp_set_current_user( self::$admin_user_id );
+
+ $request = new WP_REST_Request( 'OPTIONS', '/wc/v4/fulfillments/' . $this->test_fulfillment->get_id() );
+ $response = rest_get_server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertArrayHasKey( 'endpoints', $data );
+ $put_endpoint = array_filter(
+ $data['endpoints'],
+ function ( $endpoint ) {
+ return in_array( 'PUT', $endpoint['methods'], true );
+ }
+ );
+ $this->assertNotEmpty( $put_endpoint );
+ $put_endpoint = reset( $put_endpoint );
+ $this->assertArrayHasKey( 'args', $put_endpoint );
+ $this->assertArrayNotHasKey( 'entity_id', $put_endpoint['args'], 'A fulfillment cannot be reparented on edit, so entity_id must not be a writable update field.' );
+ $this->assertArrayNotHasKey( 'entity_type', $put_endpoint['args'], 'A fulfillment cannot be reparented on edit, so entity_type must not be a writable update field.' );
+ $this->assertArrayHasKey( 'status', $put_endpoint['args'], 'Mutable fields such as status must remain writable on edit.' );
+ }
+
/**
* Test error response format
*/