From db2b6446b16f6d1c1ec315488cfb9795765a3ea7 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 7 Jul 2026 13:28:50 +0100 Subject: [PATCH 1/6] Abilities API: Coerce REST run input to the ability input schema. REST GET and DELETE requests deliver every scalar in the query string as a string ("10", "true") and a comma-separated list as a single string, so an ability's permission and execute callbacks received raw strings where the input schema declared integers, booleans, or arrays. Strict checks such as true === $input['flag'] silently failed and each ability had to hand-roll its own coercion. Coerce the extracted input against the ability's registered input schema at the run controller boundary, before validation and execution, so every ability receives natively typed input regardless of transport. Coercion is applied on both the permission and execution paths via get_input_from_request(). Coercion is non-destructive with respect to validation: input is only coerced when it already validates, and any error raised while sanitizing (including one nested inside the returned value) falls back to the raw input. validate_input() therefore remains the single authority on what is accepted and still emits the user-facing error for invalid input. --- ...ss-wp-rest-abilities-v1-run-controller.php | 109 ++++- .../wpRestAbilitiesV1RunController.php | 396 ++++++++++++++++++ 2 files changed, 498 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php index 04213573b5ff1..be916ffa81b5d 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php @@ -89,7 +89,7 @@ public function execute_ability( $request ) { ); } - $input = $this->get_input_from_request( $request ); + $input = $this->get_input_from_request( $request, $ability ); $result = $ability->execute( $input ); if ( is_wp_error( $result ) ) { return $result; @@ -158,7 +158,7 @@ public function check_ability_permissions( $request ) { return $is_valid; } - $input = $this->get_input_from_request( $request ); + $input = $this->get_input_from_request( $request, $ability ); $input = $ability->normalize_input( $input ); if ( is_wp_error( $input ) ) { return $this->ensure_error_status( $input, 400 ); @@ -206,21 +206,116 @@ private function ensure_error_status( WP_Error $error, int $status ): WP_Error { /** * Extracts input parameters from the request. * + * When an ability is provided, the extracted input is coerced to the types declared + * in the ability's input schema before it is returned, so a `permission_callback` or + * `execute_callback` receives natively typed input regardless of transport. + * * @since 6.9.0 + * @since 7.1.0 Added the `$ability` parameter to coerce input to the ability schema. * * @param WP_REST_Request $request The request object. + * @param WP_Ability|null $ability Optional. The ability whose input schema the input is + * coerced against. Default `null` (no coercion). * @return mixed|null The input parameters. */ - private function get_input_from_request( $request ) { + private function get_input_from_request( $request, $ability = null ) { if ( in_array( $request->get_method(), array( 'GET', 'DELETE' ), true ) ) { // For GET and DELETE requests, look for 'input' query parameter. $query_params = $request->get_query_params(); - return $query_params['input'] ?? null; + $input = $query_params['input'] ?? null; + } else { + // For POST requests, look for 'input' in JSON body. + $json_params = $request->get_json_params(); + $input = $json_params['input'] ?? null; + } + + if ( $ability instanceof WP_Ability ) { + $input = $this->coerce_input_to_schema( $input, $ability ); + } + + return $input; + } + + /** + * Coerces raw request input to the types declared in the ability input schema. + * + * REST GET and DELETE requests deliver every scalar as a string ("10", "true") and + * comma-separated values as a single string, so without coercion an ability receives + * raw strings where its schema declares integers, booleans, or arrays. This sanitizes + * the extracted input against the ability's registered input schema — the same snapshot + * {@see WP_Ability::validate_input()} runs against — so coercion and validation always + * agree and every ability receives natively typed input regardless of transport. + * + * Coercion is non-destructive with respect to validation. Input is only coerced when it + * already validates against the schema, and any error produced while sanitizing (including + * one nested inside the returned value) causes the raw input to be returned unchanged. + * `validate_input()` therefore remains the single authority on whether input is accepted + * and continues to emit the user-facing error for invalid input. `null` input and abilities + * without an input schema are passed through untouched. + * + * @since 7.1.0 + * + * @param mixed $input Raw input extracted from the request. + * @param WP_Ability $ability The ability being executed. + * @return mixed Coerced input, or the raw input when it cannot be safely coerced. + */ + private function coerce_input_to_schema( $input, WP_Ability $ability ) { + if ( null === $input ) { + return $input; + } + + $schema = $ability->get_input_schema(); + if ( empty( $schema ) ) { + return $input; + } + + /* + * Only coerce input that already validates. Sanitizing invalid input can silently + * change which values are accepted -- `additionalProperties: false` strips unknown + * keys, and a non-numeric string casts to 0 -- so leaving invalid input untouched + * lets validate_input() reject it exactly as it does without coercion. + */ + if ( is_wp_error( rest_validate_value_from_schema( $input, $schema, 'input' ) ) ) { + return $input; + } + + $sanitized = rest_sanitize_value_from_schema( $input, $schema, 'input' ); + + /* + * Sanitizing can still surface an error the lenient validation above did not, such as + * items that are unique as strings but collide once cast to integers (`uniqueItems`). + * The error may be returned at the top level or nested inside the returned array, so + * scan recursively and fall back to the raw input on any error. + */ + if ( $this->input_contains_error( $sanitized ) ) { + return $input; + } + + return $sanitized; + } + + /** + * Determines whether a sanitized value is, or contains, a WP_Error. + * + * @since 7.1.0 + * + * @param mixed $value The value to inspect. + * @return bool True if the value is, or contains, a WP_Error. + */ + private function input_contains_error( $value ): bool { + if ( is_wp_error( $value ) ) { + return true; + } + + if ( is_array( $value ) ) { + foreach ( $value as $item ) { + if ( $this->input_contains_error( $item ) ) { + return true; + } + } } - // For POST requests, look for 'input' in JSON body. - $json_params = $request->get_json_params(); - return $json_params['input'] ?? null; + return false; } /** diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index 32c1c1cbe9364..7e74721210f1f 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -1386,4 +1386,400 @@ public function test_options_method_handling(): void { // OPTIONS requests return 200 with allowed methods $this->assertEquals( 200, $response->get_status() ); } + + /** + * Registers a read-only ability that reflects the PHP type and value of each input field. + * + * @param string $name Ability name. + * @param array $input_schema Input schema for the ability. + */ + private function register_reflecting_ability( string $name, array $input_schema ): void { + $this->register_test_ability( + $name, + array( + 'label' => 'Reflecting Ability', + 'description' => 'Reflects the received PHP types back to the caller.', + 'category' => 'general', + 'input_schema' => $input_schema, + 'execute_callback' => static function ( $input ) { + $reflected = array(); + foreach ( (array) $input as $key => $value ) { + $reflected[ $key ] = $value; + $reflected[ $key . '__type' ] = gettype( $value ); + } + return $reflected; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'annotations' => array( 'readonly' => true ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Tests that string scalars from a GET request are coerced to the schema types. + * + * PHP delivers every scalar in a GET query string as a string ("10", "true") and a + * comma-separated list as a single string, so before coercion an ability received raw + * strings where its schema declared an integer, a boolean, or an array. The controller + * now sanitizes the input against the ability's input schema before it runs. + * + * @ticket 64098 + */ + public function test_get_string_scalars_are_coerced_to_schema_types(): void { + $this->register_reflecting_ability( + 'test/typed-input', + array( + 'type' => 'object', + 'properties' => array( + 'count' => array( 'type' => 'integer' ), + 'flag' => array( 'type' => 'boolean' ), + 'tags' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), + ) + ); + + // Values as PHP delivers them from an HTTP GET query string: all strings. + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/typed-input/run' ); + $request->set_query_params( + array( + 'input' => array( + 'count' => '10', + 'flag' => 'true', + 'tags' => 'a,b', + ), + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 'integer', $data['count__type'], 'Integer field should be coerced from string.' ); + $this->assertSame( 10, $data['count'] ); + $this->assertSame( 'boolean', $data['flag__type'], 'Boolean field should be coerced from string.' ); + $this->assertTrue( $data['flag'] ); + $this->assertSame( 'array', $data['tags__type'], 'A comma-separated string should be coerced to an array.' ); + $this->assertSame( array( 'a', 'b' ), $data['tags'] ); + + // The string "false" must coerce to boolean false, not a truthy string. + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/typed-input/run' ); + $request->set_query_params( + array( + 'input' => array( 'flag' => 'false' ), + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'boolean', $data['flag__type'] ); + $this->assertFalse( $data['flag'] ); + } + + /** + * Tests that an array supplied via bracket syntax is preserved through coercion. + * + * @ticket 64098 + */ + public function test_get_bracket_array_input_is_preserved(): void { + $this->register_reflecting_ability( + 'test/typed-array', + array( + 'type' => 'object', + 'properties' => array( + 'tags' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), + ) + ); + + // input[tags][]=a&input[tags][]=b arrives as a real array. + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/typed-array/run' ); + $request->set_query_params( + array( + 'input' => array( 'tags' => array( 'a', 'b' ) ), + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 'array', $data['tags__type'] ); + $this->assertSame( array( 'a', 'b' ), $data['tags'] ); + } + + /** + * Tests that a `oneOf` input schema resolves the matching branch and coerces against it. + * + * Mirrors the two-mode schema used by real read abilities: one branch selects a single + * record by id, the other returns a collection. The branches are distinguished by their + * required keys and `additionalProperties: false`, so exactly one matches a valid request. + * + * @ticket 64098 + */ + public function test_get_oneof_schema_resolves_and_coerces_each_mode(): void { + $this->register_reflecting_ability( + 'test/one-of-input', + array( + 'type' => 'object', + 'oneOf' => array( + array( + 'title' => 'by_id', + 'type' => 'object', + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + ), + 'required' => array( 'id' ), + 'additionalProperties' => false, + ), + array( + 'title' => 'collection', + 'type' => 'object', + 'properties' => array( + 'per_page' => array( 'type' => 'integer' ), + 'fields' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), + 'required' => array( 'per_page' ), + 'additionalProperties' => false, + ), + ), + ) + ); + + // Id mode. + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/one-of-input/run' ); + $request->set_query_params( + array( + 'input' => array( 'id' => '5' ), + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'integer', $data['id__type'] ); + $this->assertSame( 5, $data['id'] ); + + // Collection mode. + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/one-of-input/run' ); + $request->set_query_params( + array( + 'input' => array( + 'per_page' => '2', + 'fields' => 'id,name', + ), + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'integer', $data['per_page__type'] ); + $this->assertSame( 2, $data['per_page'] ); + $this->assertSame( 'array', $data['fields__type'] ); + $this->assertSame( array( 'id', 'name' ), $data['fields'] ); + } + + /** + * Tests that coercion never rescues otherwise-invalid input. + * + * Sanitizing invalid input could silently change which values are accepted: an unknown + * property under `additionalProperties: false` would be stripped, and a non-numeric string + * would cast to 0. Coercion is only applied to input that already validates, so both cases + * still produce the authoritative `ability_invalid_input` (400) from validation. + * + * @ticket 64098 + */ + public function test_coercion_does_not_mask_invalid_input(): void { + $this->register_reflecting_ability( + 'test/strict-typed-input', + array( + 'type' => 'object', + 'properties' => array( + 'count' => array( 'type' => 'integer' ), + ), + 'additionalProperties' => false, + ) + ); + + // An unknown property must still be rejected, not silently stripped. + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/strict-typed-input/run' ); + $request->set_query_params( + array( + 'input' => array( + 'count' => '10', + 'unknown' => 'x', + ), + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'ability_invalid_input', $response->get_data()['code'] ); + + // A non-numeric string must still be rejected, not coerced to 0. + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/strict-typed-input/run' ); + $request->set_query_params( + array( + 'input' => array( 'count' => 'not-a-number' ), + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'ability_invalid_input', $response->get_data()['code'] ); + } + + /** + * Tests that already-typed POST input is unchanged by coercion. + * + * JSON delivers native types, so coercion is a near no-op for POST. Applying it uniformly + * keeps GET and POST consistent without altering already-typed values. + * + * @ticket 64098 + */ + public function test_post_typed_input_is_unchanged(): void { + $this->register_test_ability( + 'test/typed-input-post', + array( + 'label' => 'Typed Input POST', + 'description' => 'Reflects the received PHP types back to the caller.', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'count' => array( 'type' => 'integer' ), + 'flag' => array( 'type' => 'boolean' ), + 'tags' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), + ), + 'execute_callback' => static function ( $input ) { + return array( + 'count' => $input['count'], + 'count_type' => gettype( $input['count'] ), + 'flag' => $input['flag'], + 'flag_type' => gettype( $input['flag'] ), + 'tags' => $input['tags'], + 'tags_type' => gettype( $input['tags'] ), + ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/typed-input-post/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'count' => 10, + 'flag' => true, + 'tags' => array( 'a', 'b' ), + ), + ) + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 'integer', $data['count_type'] ); + $this->assertSame( 10, $data['count'] ); + $this->assertSame( 'boolean', $data['flag_type'] ); + $this->assertTrue( $data['flag'] ); + $this->assertSame( 'array', $data['tags_type'] ); + $this->assertSame( array( 'a', 'b' ), $data['tags'] ); + } + + /** + * Tests that an ability without an input schema is unaffected by coercion. + * + * @ticket 64098 + */ + public function test_input_without_schema_passes_through(): void { + $this->register_test_ability( + 'test/no-input-schema', + array( + 'label' => 'No Input Schema', + 'description' => 'Executes without an input schema.', + 'category' => 'general', + 'execute_callback' => static function () { + return array( 'ran' => true ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'annotations' => array( 'readonly' => true ), + 'show_in_rest' => true, + ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/no-input-schema/run' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $response->get_data()['ran'] ); + } + + /** + * Tests that a value which only fails validation once coerced falls back to the raw input. + * + * `["1", "01"]` is unique as strings (so it validates and its `oneOf`/schema branch matches) + * but collides once cast to integers, so sanitizing produces a nested `rest_duplicate_items` + * error inside the returned array. The recursive error scan detects the nested error and + * returns the raw input, so validation still accepts it and no `WP_Error` reaches the ability. + * + * @ticket 64098 + */ + public function test_nested_sanitize_error_falls_back_to_raw_input(): void { + $this->register_reflecting_ability( + 'test/unique-items-input', + array( + 'type' => 'object', + 'properties' => array( + 'include' => array( + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + 'uniqueItems' => true, + ), + ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/unique-items-input/run' ); + $request->set_query_params( + array( + 'input' => array( + 'include' => array( '1', '01' ), + ), + ) + ); + + $response = $this->server->dispatch( $request ); + + // Accepted (unique as strings) and the ability received the raw values, not a WP_Error. + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'array', $response->get_data()['include__type'] ); + $this->assertSame( array( '1', '01' ), $response->get_data()['include'] ); + } } From 4967563717140699639aacd643bc51e7fd8079f6 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 8 Jul 2026 13:45:55 +0100 Subject: [PATCH 2/6] Abilities API: Ask validate_input() what may be coerced, and coerce once. The coercion guard called rest_validate_value_from_schema() directly, which is not what decides whether an ability accepts input. validate_input() also runs the wp_ability_validate_input filter, and that filter receives the WP_Error from schema validation and may override it to true. When it did, the guard bailed out, validation then passed, and the ability received the raw query string values coercion exists to type. Ask validate_input() instead, so a filter that accepts input accepts it for coercion too, and a filter that rejects otherwise valid input leaves it untouched for validate_input() to report. Coercion validates the input, so running it on both the permission and the execution path validated a single request four times. check_ability_permissions() always runs first for a dispatch, so hand the input it coerced to execute_ability() rather than let it repeat the work. The value is keyed on the request it was computed for and rewritten on every permission check, so a reused request object cannot pick up input coerced for an earlier dispatch, and a caller reaching execute_ability() directly still coerces its own input. --- ...ss-wp-rest-abilities-v1-run-controller.php | 59 ++++++++++++++++--- .../wpRestAbilitiesV1RunController.php | 48 +++++++++++++++ 2 files changed, 98 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php index be916ffa81b5d..e87e939e50bf2 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php @@ -34,6 +34,25 @@ class WP_REST_Abilities_V1_Run_Controller extends WP_REST_Controller { */ protected $rest_base = 'abilities'; + /** + * The request the coerced input in `$coerced_input` was computed for. + * + * Written by `check_ability_permissions()` on every call, so it always describes the + * dispatch in progress rather than an earlier one that reused the same request object. + * + * @since 7.1.0 + * @var WP_REST_Request|null + */ + private $coerced_input_request = null; + + /** + * Coerced input computed for `$coerced_input_request`. + * + * @since 7.1.0 + * @var mixed + */ + private $coerced_input = null; + /** * Registers the routes for ability execution. * @@ -89,7 +108,15 @@ public function execute_ability( $request ) { ); } - $input = $this->get_input_from_request( $request, $ability ); + /* + * check_ability_permissions() always runs first for this request and has already coerced + * the input against the ability's schema. Reuse that value instead of validating and + * sanitizing a second time, falling back for callers that reach this method directly. + */ + $input = $this->coerced_input_request === $request + ? $this->coerced_input + : $this->get_input_from_request( $request, $ability ); + $result = $ability->execute( $input ); if ( is_wp_error( $result ) ) { return $result; @@ -158,7 +185,16 @@ public function check_ability_permissions( $request ) { return $is_valid; } - $input = $this->get_input_from_request( $request, $ability ); + /* + * Coercing validates the input against the ability's schema, so hand the result to + * execute_ability() rather than let it repeat the work. Assigned unconditionally: this + * method runs at the start of every dispatch, so the stored value can never describe an + * earlier dispatch that happened to reuse the same request object. + */ + $input = $this->get_input_from_request( $request, $ability ); + $this->coerced_input_request = $request; + $this->coerced_input = $input; + $input = $ability->normalize_input( $input ); if ( is_wp_error( $input ) ) { return $this->ensure_error_status( $input, 400 ); @@ -246,12 +282,12 @@ private function get_input_from_request( $request, $ability = null ) { * {@see WP_Ability::validate_input()} runs against — so coercion and validation always * agree and every ability receives natively typed input regardless of transport. * - * Coercion is non-destructive with respect to validation. Input is only coerced when it - * already validates against the schema, and any error produced while sanitizing (including - * one nested inside the returned value) causes the raw input to be returned unchanged. - * `validate_input()` therefore remains the single authority on whether input is accepted - * and continues to emit the user-facing error for invalid input. `null` input and abilities - * without an input schema are passed through untouched. + * Coercion is non-destructive with respect to validation. Input is only coerced when + * {@see WP_Ability::validate_input()} accepts it, and any error produced while sanitizing + * (including one nested inside the returned value) causes the raw input to be returned + * unchanged. `validate_input()` therefore remains the single authority on whether input is + * accepted and continues to emit the user-facing error for invalid input. `null` input and + * abilities without an input schema are passed through untouched. * * @since 7.1.0 * @@ -274,8 +310,13 @@ private function coerce_input_to_schema( $input, WP_Ability $ability ) { * change which values are accepted -- `additionalProperties: false` strips unknown * keys, and a non-numeric string casts to 0 -- so leaving invalid input untouched * lets validate_input() reject it exactly as it does without coercion. + * + * validate_input() is asked rather than rest_validate_value_from_schema() so that the + * `wp_ability_validate_input` filter decides what counts as valid here as well. A filter + * that overrides a schema failure accepts the input, so the input is coerced; a filter + * that rejects otherwise valid input leaves it untouched for validate_input() to report. */ - if ( is_wp_error( rest_validate_value_from_schema( $input, $schema, 'input' ) ) ) { + if ( is_wp_error( $ability->validate_input( $input ) ) ) { return $input; } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index 7e74721210f1f..b321417d648c4 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -1642,6 +1642,54 @@ public function test_coercion_does_not_mask_invalid_input(): void { $this->assertSame( 'ability_invalid_input', $response->get_data()['code'] ); } + /** + * Tests that `validate_input()` decides what gets coerced, filters included. + * + * Coercion asks `validate_input()` rather than `rest_validate_value_from_schema()` whether the + * input is acceptable, so a `wp_ability_validate_input` filter that overrides a schema failure + * accepts the input for coercion too. Asking the schema directly would leave input the filter + * accepts uncoerced, handing the ability the raw query string values it was meant to type. + * + * @ticket 64098 + */ + public function test_validate_input_filter_override_still_coerces(): void { + $this->register_reflecting_ability( + 'test/filtered-typed-input', + array( + 'type' => 'object', + 'properties' => array( + 'count' => array( + 'type' => 'integer', + 'minimum' => 100, + ), + ), + ) + ); + + // Below `minimum`, so the schema rejects it. + $query_params = array( 'input' => array( 'count' => '10' ) ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/filtered-typed-input/run' ); + $request->set_query_params( $query_params ); + + $response = $this->server->dispatch( $request ); + $this->assertSame( 400, $response->get_status(), 'Schema validation should reject the input on its own.' ); + $this->assertSame( 'ability_invalid_input', $response->get_data()['code'] ); + + // A filter that accepts the input makes it valid, so it must also be coerced. + add_filter( 'wp_ability_validate_input', '__return_true' ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/filtered-typed-input/run' ); + $request->set_query_params( $query_params ); + + $response = $this->server->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 'integer', $data['count__type'], 'Input a validation filter accepts should be coerced.' ); + $this->assertSame( 10, $data['count'] ); + } + /** * Tests that already-typed POST input is unchanged by coercion. * From d1c7a6c06c223476070fc6540e3c567ab0b72764 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 8 Jul 2026 14:07:49 +0100 Subject: [PATCH 3/6] Abilities API: Point the REST input coercion tests at the correct Trac ticket. The tests added for input coercion carried @ticket 64098, the ticket for the Abilities REST endpoints themselves. Coercion is tracked in 65594. --- .../rest-api/wpRestAbilitiesV1RunController.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index b321417d648c4..8f17433bafc22 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -1426,7 +1426,7 @@ private function register_reflecting_ability( string $name, array $input_schema * strings where its schema declared an integer, a boolean, or an array. The controller * now sanitizes the input against the ability's input schema before it runs. * - * @ticket 64098 + * @ticket 65594 */ public function test_get_string_scalars_are_coerced_to_schema_types(): void { $this->register_reflecting_ability( @@ -1485,7 +1485,7 @@ public function test_get_string_scalars_are_coerced_to_schema_types(): void { /** * Tests that an array supplied via bracket syntax is preserved through coercion. * - * @ticket 64098 + * @ticket 65594 */ public function test_get_bracket_array_input_is_preserved(): void { $this->register_reflecting_ability( @@ -1524,7 +1524,7 @@ public function test_get_bracket_array_input_is_preserved(): void { * record by id, the other returns a collection. The branches are distinguished by their * required keys and `additionalProperties: false`, so exactly one matches a valid request. * - * @ticket 64098 + * @ticket 65594 */ public function test_get_oneof_schema_resolves_and_coerces_each_mode(): void { $this->register_reflecting_ability( @@ -1600,7 +1600,7 @@ public function test_get_oneof_schema_resolves_and_coerces_each_mode(): void { * would cast to 0. Coercion is only applied to input that already validates, so both cases * still produce the authoritative `ability_invalid_input` (400) from validation. * - * @ticket 64098 + * @ticket 65594 */ public function test_coercion_does_not_mask_invalid_input(): void { $this->register_reflecting_ability( @@ -1650,7 +1650,7 @@ public function test_coercion_does_not_mask_invalid_input(): void { * accepts the input for coercion too. Asking the schema directly would leave input the filter * accepts uncoerced, handing the ability the raw query string values it was meant to type. * - * @ticket 64098 + * @ticket 65594 */ public function test_validate_input_filter_override_still_coerces(): void { $this->register_reflecting_ability( @@ -1696,7 +1696,7 @@ public function test_validate_input_filter_override_still_coerces(): void { * JSON delivers native types, so coercion is a near no-op for POST. Applying it uniformly * keeps GET and POST consistent without altering already-typed values. * - * @ticket 64098 + * @ticket 65594 */ public function test_post_typed_input_is_unchanged(): void { $this->register_test_ability( @@ -1762,7 +1762,7 @@ public function test_post_typed_input_is_unchanged(): void { /** * Tests that an ability without an input schema is unaffected by coercion. * - * @ticket 64098 + * @ticket 65594 */ public function test_input_without_schema_passes_through(): void { $this->register_test_ability( @@ -1797,7 +1797,7 @@ public function test_input_without_schema_passes_through(): void { * error inside the returned array. The recursive error scan detects the nested error and * returns the raw input, so validation still accepts it and no `WP_Error` reaches the ability. * - * @ticket 64098 + * @ticket 65594 */ public function test_nested_sanitize_error_falls_back_to_raw_input(): void { $this->register_reflecting_ability( From eb14e888d7ede9f6aa42a2e72f0ee2542a5866d8 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 8 Jul 2026 15:46:39 +0100 Subject: [PATCH 4/6] Abilities API: Cover coercion of deeply nested REST run input. Sanitizing walks the input schema recursively, so a GET query string is typed at every depth. Assert that against an object nested two levels deep, where an integer, a boolean, and a comma-separated list all arrive as strings. --- .../wpRestAbilitiesV1RunController.php | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index 8f17433bafc22..5c4a5ecffcd0a 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -1517,6 +1517,66 @@ public function test_get_bracket_array_input_is_preserved(): void { $this->assertSame( array( 'a', 'b' ), $data['tags'] ); } + /** + * Tests that coercion reaches values nested several levels inside the input. + * + * Sanitizing walks `properties` recursively, so a query string arriving as + * `input[a][b][id]=22&input[a][b][is_ok]=true&input[a][b][parts]=x,y` is typed at every depth + * rather than only at the top level. + * + * @ticket 65594 + */ + public function test_get_deeply_nested_input_is_coerced(): void { + $this->register_reflecting_ability( + 'test/nested-typed-input', + array( + 'type' => 'object', + 'properties' => array( + 'a' => array( + 'type' => 'object', + 'properties' => array( + 'b' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + 'is_ok' => array( 'type' => 'boolean' ), + 'parts' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), + ), + ), + ), + ), + ) + ); + + // Values as PHP delivers them from an HTTP GET query string: all strings, at every depth. + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/nested-typed-input/run' ); + $request->set_query_params( + array( + 'input' => array( + 'a' => array( + 'b' => array( + 'id' => '22', + 'is_ok' => 'true', + 'parts' => 'x,y', + ), + ), + ), + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + $nested = $response->get_data()['a']['b']; + $this->assertSame( 22, $nested['id'], 'A nested integer should be coerced from its query string value.' ); + $this->assertTrue( $nested['is_ok'], 'A nested boolean should be coerced from its query string value.' ); + $this->assertSame( array( 'x', 'y' ), $nested['parts'], 'A nested comma-separated string should be coerced to an array.' ); + } + /** * Tests that a `oneOf` input schema resolves the matching branch and coerces against it. * From e87fa2d5cb7c1aa2c76e1dbb59325bc48e5b47c7 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 8 Jul 2026 19:40:34 +0200 Subject: [PATCH 5/6] Abilities API: Move REST input coercion to the input sanitize_callback. Register the coercion as the `sanitize_callback` for the run route's `input` argument. The REST API then runs it once during `sanitize_params()`, before both `check_ability_permissions()` and `execute_ability()`, and writes the result back onto the request. Both callbacks now read natively typed input from the same request, so the permission check sees typed input too. This removes the controller-level cached state (the `$coerced_input_request` and `$coerced_input` properties and the identity guard) and keeps `execute_ability()` and `get_input_from_request()` unchanged from trunk. The coercion logic itself (`coerce_input_to_schema()` and `input_contains_error()`) is unchanged. Expand and consolidate the tests: - Cover top-level (non-object) input. An input schema can declare a bare scalar or array, so the whole `input` value is coerced, not only fields nested in an object. This includes two cases the generic union arg sanitizer gets wrong on its own: a numeric string against a `string` schema (must stay a string), and a comma-separated string against an `array` schema. - Add a test that the permission callback receives typed input. - Fold the object, deeply nested, scalar, and array coercion cases into one data provider and one test that asserts the whole coerced value with `assertSame`, so a single assertion checks both value and PHP type. This keeps the deeply nested coverage as a provider case in place of a separate test method. - Add shared `register_reflecting_ability()` and `dispatch_run()` helpers. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ss-wp-rest-abilities-v1-run-controller.php | 109 ++- .../wpRestAbilitiesV1RunController.php | 642 +++++++++--------- 2 files changed, 363 insertions(+), 388 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php index e87e939e50bf2..e1ba08f549e93 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php @@ -34,25 +34,6 @@ class WP_REST_Abilities_V1_Run_Controller extends WP_REST_Controller { */ protected $rest_base = 'abilities'; - /** - * The request the coerced input in `$coerced_input` was computed for. - * - * Written by `check_ability_permissions()` on every call, so it always describes the - * dispatch in progress rather than an earlier one that reused the same request object. - * - * @since 7.1.0 - * @var WP_REST_Request|null - */ - private $coerced_input_request = null; - - /** - * Coerced input computed for `$coerced_input_request`. - * - * @since 7.1.0 - * @var mixed - */ - private $coerced_input = null; - /** * Registers the routes for ability execution. * @@ -108,15 +89,7 @@ public function execute_ability( $request ) { ); } - /* - * check_ability_permissions() always runs first for this request and has already coerced - * the input against the ability's schema. Reuse that value instead of validating and - * sanitizing a second time, falling back for callers that reach this method directly. - */ - $input = $this->coerced_input_request === $request - ? $this->coerced_input - : $this->get_input_from_request( $request, $ability ); - + $input = $this->get_input_from_request( $request ); $result = $ability->execute( $input ); if ( is_wp_error( $result ) ) { return $result; @@ -185,16 +158,7 @@ public function check_ability_permissions( $request ) { return $is_valid; } - /* - * Coercing validates the input against the ability's schema, so hand the result to - * execute_ability() rather than let it repeat the work. Assigned unconditionally: this - * method runs at the start of every dispatch, so the stored value can never describe an - * earlier dispatch that happened to reuse the same request object. - */ - $input = $this->get_input_from_request( $request, $ability ); - $this->coerced_input_request = $request; - $this->coerced_input = $input; - + $input = $this->get_input_from_request( $request ); $input = $ability->normalize_input( $input ); if ( is_wp_error( $input ) ) { return $this->ensure_error_status( $input, 400 ); @@ -242,52 +206,58 @@ private function ensure_error_status( WP_Error $error, int $status ): WP_Error { /** * Extracts input parameters from the request. * - * When an ability is provided, the extracted input is coerced to the types declared - * in the ability's input schema before it is returned, so a `permission_callback` or - * `execute_callback` receives natively typed input regardless of transport. - * * @since 6.9.0 - * @since 7.1.0 Added the `$ability` parameter to coerce input to the ability schema. * * @param WP_REST_Request $request The request object. - * @param WP_Ability|null $ability Optional. The ability whose input schema the input is - * coerced against. Default `null` (no coercion). * @return mixed|null The input parameters. */ - private function get_input_from_request( $request, $ability = null ) { + private function get_input_from_request( $request ) { if ( in_array( $request->get_method(), array( 'GET', 'DELETE' ), true ) ) { // For GET and DELETE requests, look for 'input' query parameter. $query_params = $request->get_query_params(); - $input = $query_params['input'] ?? null; - } else { - // For POST requests, look for 'input' in JSON body. - $json_params = $request->get_json_params(); - $input = $json_params['input'] ?? null; + return $query_params['input'] ?? null; } - if ( $ability instanceof WP_Ability ) { - $input = $this->coerce_input_to_schema( $input, $ability ); + // For POST requests, look for 'input' in JSON body. + $json_params = $request->get_json_params(); + return $json_params['input'] ?? null; + } + + /** + * Sanitizes the run input by coercing it to the ability's input schema. + * + * Registered as the `input` argument `sanitize_callback` so that both + * `check_ability_permissions()` and `execute_ability()` receive natively typed input + * regardless of transport. + * + * @since 7.1.0 + * + * @see WP_REST_Abilities_V1_Run_Controller::coerce_input_to_schema() + * + * @param mixed $input Raw input extracted from the request. + * @param WP_REST_Request $request The request object. + * @return mixed Coerced input, or the raw input when it cannot be safely coerced. + */ + public function sanitize_input_for_ability( $input, $request ) { + $ability = wp_get_ability( $request['name'] ); + if ( ! $ability instanceof WP_Ability ) { + return $input; } - return $input; + return $this->coerce_input_to_schema( $input, $ability ); } /** * Coerces raw request input to the types declared in the ability input schema. * - * REST GET and DELETE requests deliver every scalar as a string ("10", "true") and - * comma-separated values as a single string, so without coercion an ability receives - * raw strings where its schema declares integers, booleans, or arrays. This sanitizes - * the extracted input against the ability's registered input schema — the same snapshot - * {@see WP_Ability::validate_input()} runs against — so coercion and validation always - * agree and every ability receives natively typed input regardless of transport. + * GET and DELETE deliver every scalar as a string ("10", "true") and a list as a single + * comma-separated string, so without coercion an ability receives raw strings where its + * schema declares integers, booleans, or arrays. * - * Coercion is non-destructive with respect to validation. Input is only coerced when - * {@see WP_Ability::validate_input()} accepts it, and any error produced while sanitizing - * (including one nested inside the returned value) causes the raw input to be returned - * unchanged. `validate_input()` therefore remains the single authority on whether input is - * accepted and continues to emit the user-facing error for invalid input. `null` input and - * abilities without an input schema are passed through untouched. + * Coercion never changes what validation accepts. Input is coerced only when + * {@see WP_Ability::validate_input()} already accepts it, and any error surfaced while + * sanitizing falls back to the raw input, so `validate_input()` stays the single authority + * on what is rejected. * * @since 7.1.0 * @@ -369,9 +339,10 @@ private function input_contains_error( $value ): bool { public function get_run_args(): array { return array( 'input' => array( - 'description' => __( 'Input parameters for the ability execution.' ), - 'type' => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ), - 'default' => null, + 'description' => __( 'Input parameters for the ability execution.' ), + 'type' => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ), + 'default' => null, + 'sanitize_callback' => array( $this, 'sanitize_input_for_ability' ), ), ); } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index 5c4a5ecffcd0a..ba4ab1dbbd322 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -1388,30 +1388,34 @@ public function test_options_method_handling(): void { } /** - * Registers a read-only ability that reflects the PHP type and value of each input field. + * Registers an ability that reflects the input value and its PHP type back to the caller. + * + * The execute callback returns the received `value` and its `gettype()`, so a test can assert + * the exact value and PHP type the ability received, whether it is a scalar, an array, or an + * object of fields. * * @param string $name Ability name. * @param array $input_schema Input schema for the ability. + * @param array $annotations Optional. Ability annotations. Defaults to a read-only ability + * (GET). Pass an empty array for a POST ability. */ - private function register_reflecting_ability( string $name, array $input_schema ): void { + private function register_reflecting_ability( string $name, array $input_schema, array $annotations = array( 'readonly' => true ) ): void { $this->register_test_ability( $name, array( 'label' => 'Reflecting Ability', - 'description' => 'Reflects the received PHP types back to the caller.', + 'description' => 'Reflects the received input value and PHP type.', 'category' => 'general', 'input_schema' => $input_schema, 'execute_callback' => static function ( $input ) { - $reflected = array(); - foreach ( (array) $input as $key => $value ) { - $reflected[ $key ] = $value; - $reflected[ $key . '__type' ] = gettype( $value ); - } - return $reflected; + return array( + 'value' => $input, + 'type' => gettype( $input ), + ); }, 'permission_callback' => '__return_true', 'meta' => array( - 'annotations' => array( 'readonly' => true ), + 'annotations' => $annotations, 'show_in_rest' => true, ), ) @@ -1419,144 +1423,173 @@ private function register_reflecting_ability( string $name, array $input_schema } /** - * Tests that string scalars from a GET request are coerced to the schema types. + * Dispatches a run request for an ability, nesting the value under the `input` parameter. * - * PHP delivers every scalar in a GET query string as a string ("10", "true") and a - * comma-separated list as a single string, so before coercion an ability received raw - * strings where its schema declared an integer, a boolean, or an array. The controller - * now sanitizes the input against the ability's input schema before it runs. + * GET and DELETE send the input as query parameters, POST sends it as a JSON body. Pass + * `null` to send no input at all. * - * @ticket 65594 + * @param string $method HTTP method. + * @param string $name Ability name. + * @param mixed $input Optional. Input value, or null to send no input. Default null. + * @return WP_REST_Response The dispatched response. */ - public function test_get_string_scalars_are_coerced_to_schema_types(): void { - $this->register_reflecting_ability( - 'test/typed-input', - array( - 'type' => 'object', - 'properties' => array( - 'count' => array( 'type' => 'integer' ), - 'flag' => array( 'type' => 'boolean' ), - 'tags' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - ), - ), - ) - ); - - // Values as PHP delivers them from an HTTP GET query string: all strings. - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/typed-input/run' ); - $request->set_query_params( - array( - 'input' => array( - 'count' => '10', - 'flag' => 'true', - 'tags' => 'a,b', - ), - ) - ); - - $response = $this->server->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - $this->assertSame( 'integer', $data['count__type'], 'Integer field should be coerced from string.' ); - $this->assertSame( 10, $data['count'] ); - $this->assertSame( 'boolean', $data['flag__type'], 'Boolean field should be coerced from string.' ); - $this->assertTrue( $data['flag'] ); - $this->assertSame( 'array', $data['tags__type'], 'A comma-separated string should be coerced to an array.' ); - $this->assertSame( array( 'a', 'b' ), $data['tags'] ); - - // The string "false" must coerce to boolean false, not a truthy string. - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/typed-input/run' ); - $request->set_query_params( - array( - 'input' => array( 'flag' => 'false' ), - ) - ); + private function dispatch_run( string $method, string $name, $input = null ) { + $request = new WP_REST_Request( $method, "/wp-abilities/v1/abilities/{$name}/run" ); + + if ( null !== $input ) { + if ( in_array( $method, array( 'GET', 'DELETE' ), true ) ) { + $request->set_query_params( array( 'input' => $input ) ); + } else { + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => $input ) ) ); + } + } - $response = $this->server->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); - $data = $response->get_data(); - $this->assertSame( 'boolean', $data['flag__type'] ); - $this->assertFalse( $data['flag'] ); + return $this->server->dispatch( $request ); } /** - * Tests that an array supplied via bracket syntax is preserved through coercion. + * Data provider for input coerced to the ability input schema over a GET request. * - * @ticket 65594 + * Covers fields nested inside an object as well as a top-level scalar or array input. + * + * @return array Schema, raw input, and the + * expected coerced value. */ - public function test_get_bracket_array_input_is_preserved(): void { - $this->register_reflecting_ability( - 'test/typed-array', - array( - 'type' => 'object', - 'properties' => array( - 'tags' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - ), + public function data_input_is_coerced(): array { + $object_schema = array( + 'type' => 'object', + 'properties' => array( + 'count' => array( 'type' => 'integer' ), + 'flag' => array( 'type' => 'boolean' ), + 'tags' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), ), - ) + ), ); - // input[tags][]=a&input[tags][]=b arrives as a real array. - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/typed-array/run' ); - $request->set_query_params( - array( - 'input' => array( 'tags' => array( 'a', 'b' ) ), - ) + $tags_object_schema = array( + 'type' => 'object', + 'properties' => array( + 'tags' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), ); - $response = $this->server->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - $this->assertSame( 'array', $data['tags__type'] ); - $this->assertSame( array( 'a', 'b' ), $data['tags'] ); - } + // Two-mode schema mirroring real read abilities: select by id, or return a collection. + $one_of_schema = array( + 'type' => 'object', + 'oneOf' => array( + array( + 'title' => 'by_id', + 'type' => 'object', + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + ), + 'required' => array( 'id' ), + 'additionalProperties' => false, + ), + array( + 'title' => 'collection', + 'type' => 'object', + 'properties' => array( + 'per_page' => array( 'type' => 'integer' ), + 'fields' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), + 'required' => array( 'per_page' ), + 'additionalProperties' => false, + ), + ), + ); - /** - * Tests that coercion reaches values nested several levels inside the input. - * - * Sanitizing walks `properties` recursively, so a query string arriving as - * `input[a][b][id]=22&input[a][b][is_ok]=true&input[a][b][parts]=x,y` is typed at every depth - * rather than only at the top level. - * - * @ticket 65594 - */ - public function test_get_deeply_nested_input_is_coerced(): void { - $this->register_reflecting_ability( - 'test/nested-typed-input', - array( - 'type' => 'object', - 'properties' => array( - 'a' => array( - 'type' => 'object', - 'properties' => array( - 'b' => array( - 'type' => 'object', - 'properties' => array( - 'id' => array( 'type' => 'integer' ), - 'is_ok' => array( 'type' => 'boolean' ), - 'parts' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - ), + // Object nested two levels deep, to confirm coercion walks `properties` recursively. + $deep_schema = array( + 'type' => 'object', + 'properties' => array( + 'a' => array( + 'type' => 'object', + 'properties' => array( + 'b' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + 'is_ok' => array( 'type' => 'boolean' ), + 'parts' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), ), ), ), ), ), - ) + ), ); - // Values as PHP delivers them from an HTTP GET query string: all strings, at every depth. - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/nested-typed-input/run' ); - $request->set_query_params( - array( - 'input' => array( + return array( + // Fields nested inside an object. + 'object integer field' => array( + $object_schema, + array( 'count' => '10' ), + array( 'count' => 10 ), + ), + 'object boolean true' => array( + $object_schema, + array( 'flag' => 'true' ), + array( 'flag' => true ), + ), + 'object boolean false' => array( + $object_schema, + array( 'flag' => 'false' ), + array( 'flag' => false ), + ), + 'object comma-separated array' => array( + $object_schema, + array( 'tags' => 'a,b' ), + array( 'tags' => array( 'a', 'b' ) ), + ), + 'object all fields at once' => array( + $object_schema, + array( + 'count' => '10', + 'flag' => 'true', + 'tags' => 'a,b', + ), + array( + 'count' => 10, + 'flag' => true, + 'tags' => array( 'a', 'b' ), + ), + ), + 'object bracket array preserved' => array( + $tags_object_schema, + array( 'tags' => array( 'a', 'b' ) ), + array( 'tags' => array( 'a', 'b' ) ), + ), + 'oneOf id mode' => array( + $one_of_schema, + array( 'id' => '5' ), + array( 'id' => 5 ), + ), + 'oneOf collection mode' => array( + $one_of_schema, + array( + 'per_page' => '2', + 'fields' => 'id,name', + ), + array( + 'per_page' => 2, + 'fields' => array( 'id', 'name' ), + ), + ), + 'deeply nested object' => array( + $deep_schema, + array( 'a' => array( 'b' => array( 'id' => '22', @@ -1565,139 +1598,109 @@ public function test_get_deeply_nested_input_is_coerced(): void { ), ), ), - ) + array( + 'a' => array( + 'b' => array( + 'id' => 22, + 'is_ok' => true, + 'parts' => array( 'x', 'y' ), + ), + ), + ), + ), + + // Top-level (non-object) input. + 'top-level integer' => array( array( 'type' => 'integer' ), '10', 10 ), + 'top-level number' => array( array( 'type' => 'number' ), '3.5', 3.5 ), + 'top-level boolean true' => array( array( 'type' => 'boolean' ), 'true', true ), + 'top-level boolean false' => array( array( 'type' => 'boolean' ), 'false', false ), + 'top-level string unchanged' => array( array( 'type' => 'string' ), 'hello', 'hello' ), + // A numeric string against a `string` schema must stay a string, not become an integer. + 'numeric string stays string' => array( array( 'type' => 'string' ), '10', '10' ), + 'top-level array of integers' => array( + array( + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + ), + '1,2,3', + array( 1, 2, 3 ), + ), ); - - $response = $this->server->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); - - $nested = $response->get_data()['a']['b']; - $this->assertSame( 22, $nested['id'], 'A nested integer should be coerced from its query string value.' ); - $this->assertTrue( $nested['is_ok'], 'A nested boolean should be coerced from its query string value.' ); - $this->assertSame( array( 'x', 'y' ), $nested['parts'], 'A nested comma-separated string should be coerced to an array.' ); } /** - * Tests that a `oneOf` input schema resolves the matching branch and coerces against it. + * Tests that GET input is coerced to the types declared in the ability input schema. * - * Mirrors the two-mode schema used by real read abilities: one branch selects a single - * record by id, the other returns a collection. The branches are distinguished by their - * required keys and `additionalProperties: false`, so exactly one matches a valid request. + * PHP delivers every scalar in a GET query string as a string ("10", "true") and a + * comma-separated list as a single string, so the whole input must arrive natively typed, + * whether it is a field nested in an object or a top-level scalar or array. * * @ticket 65594 + * + * @dataProvider data_input_is_coerced + * + * @param array $schema Ability input schema. + * @param mixed $input Raw input as delivered over a GET query string. + * @param mixed $expected Expected input value after coercion. */ - public function test_get_oneof_schema_resolves_and_coerces_each_mode(): void { - $this->register_reflecting_ability( - 'test/one-of-input', - array( - 'type' => 'object', - 'oneOf' => array( - array( - 'title' => 'by_id', - 'type' => 'object', - 'properties' => array( - 'id' => array( 'type' => 'integer' ), - ), - 'required' => array( 'id' ), - 'additionalProperties' => false, - ), - array( - 'title' => 'collection', - 'type' => 'object', - 'properties' => array( - 'per_page' => array( 'type' => 'integer' ), - 'fields' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - ), - ), - 'required' => array( 'per_page' ), - 'additionalProperties' => false, - ), - ), - ) - ); + public function test_get_input_is_coerced( array $schema, $input, $expected ): void { + $this->register_reflecting_ability( 'test/coerced-input', $schema ); - // Id mode. - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/one-of-input/run' ); - $request->set_query_params( - array( - 'input' => array( 'id' => '5' ), - ) - ); - - $response = $this->server->dispatch( $request ); + $response = $this->dispatch_run( 'GET', 'test/coerced-input', $input ); $this->assertSame( 200, $response->get_status() ); - $data = $response->get_data(); - $this->assertSame( 'integer', $data['id__type'] ); - $this->assertSame( 5, $data['id'] ); - - // Collection mode. - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/one-of-input/run' ); - $request->set_query_params( - array( - 'input' => array( - 'per_page' => '2', - 'fields' => 'id,name', - ), - ) - ); - - $response = $this->server->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); - $data = $response->get_data(); - $this->assertSame( 'integer', $data['per_page__type'] ); - $this->assertSame( 2, $data['per_page'] ); - $this->assertSame( 'array', $data['fields__type'] ); - $this->assertSame( array( 'id', 'name' ), $data['fields'] ); + $this->assertSame( $expected, $response->get_data()['value'] ); } /** - * Tests that coercion never rescues otherwise-invalid input. + * Data provider for input that coercion must not rescue. * - * Sanitizing invalid input could silently change which values are accepted: an unknown - * property under `additionalProperties: false` would be stripped, and a non-numeric string - * would cast to 0. Coercion is only applied to input that already validates, so both cases - * still produce the authoritative `ability_invalid_input` (400) from validation. - * - * @ticket 65594 + * @return array Schema and the invalid raw input. */ - public function test_coercion_does_not_mask_invalid_input(): void { - $this->register_reflecting_ability( - 'test/strict-typed-input', - array( - 'type' => 'object', - 'properties' => array( - 'count' => array( 'type' => 'integer' ), - ), - 'additionalProperties' => false, - ) + public function data_invalid_input_returns_400(): array { + $schema = array( + 'type' => 'object', + 'properties' => array( + 'count' => array( 'type' => 'integer' ), + ), + 'additionalProperties' => false, ); - // An unknown property must still be rejected, not silently stripped. - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/strict-typed-input/run' ); - $request->set_query_params( - array( - 'input' => array( + return array( + // An unknown property must still be rejected, not silently stripped. + 'unknown property under additionalProperties:false' => array( + $schema, + array( 'count' => '10', 'unknown' => 'x', ), - ) + ), + // A non-numeric string must still be rejected, not coerced to 0. + 'non-numeric string for an integer' => array( + $schema, + array( 'count' => 'not-a-number' ), + ), ); + } - $response = $this->server->dispatch( $request ); - $this->assertSame( 400, $response->get_status() ); - $this->assertSame( 'ability_invalid_input', $response->get_data()['code'] ); + /** + * Tests that coercion never rescues otherwise-invalid input. + * + * Sanitizing invalid input could silently change what is accepted, so coercion runs only on + * input that already validates. Invalid input therefore still returns the authoritative + * `ability_invalid_input` (400) from validation. + * + * @ticket 65594 + * + * @dataProvider data_invalid_input_returns_400 + * + * @param array $schema Ability input schema. + * @param array $input Invalid raw input that must not be coerced. + */ + public function test_invalid_input_returns_400( array $schema, array $input ): void { + $this->register_reflecting_ability( 'test/strict-typed-input', $schema ); - // A non-numeric string must still be rejected, not coerced to 0. - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/strict-typed-input/run' ); - $request->set_query_params( - array( - 'input' => array( 'count' => 'not-a-number' ), - ) - ); + $response = $this->dispatch_run( 'GET', 'test/strict-typed-input', $input ); - $response = $this->server->dispatch( $request ); $this->assertSame( 400, $response->get_status() ); $this->assertSame( 'ability_invalid_input', $response->get_data()['code'] ); } @@ -1705,10 +1708,8 @@ public function test_coercion_does_not_mask_invalid_input(): void { /** * Tests that `validate_input()` decides what gets coerced, filters included. * - * Coercion asks `validate_input()` rather than `rest_validate_value_from_schema()` whether the - * input is acceptable, so a `wp_ability_validate_input` filter that overrides a schema failure - * accepts the input for coercion too. Asking the schema directly would leave input the filter - * accepts uncoerced, handing the ability the raw query string values it was meant to type. + * Coercion gates on `validate_input()`, not the raw schema, so a `wp_ability_validate_input` + * filter that accepts input the schema rejects makes that input coerced too. * * @ticket 65594 */ @@ -1727,27 +1728,18 @@ public function test_validate_input_filter_override_still_coerces(): void { ); // Below `minimum`, so the schema rejects it. - $query_params = array( 'input' => array( 'count' => '10' ) ); - - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/filtered-typed-input/run' ); - $request->set_query_params( $query_params ); + $input = array( 'count' => '10' ); - $response = $this->server->dispatch( $request ); + $response = $this->dispatch_run( 'GET', 'test/filtered-typed-input', $input ); $this->assertSame( 400, $response->get_status(), 'Schema validation should reject the input on its own.' ); $this->assertSame( 'ability_invalid_input', $response->get_data()['code'] ); // A filter that accepts the input makes it valid, so it must also be coerced. add_filter( 'wp_ability_validate_input', '__return_true' ); - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/filtered-typed-input/run' ); - $request->set_query_params( $query_params ); - - $response = $this->server->dispatch( $request ); + $response = $this->dispatch_run( 'GET', 'test/filtered-typed-input', $input ); $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - $this->assertSame( 'integer', $data['count__type'], 'Input a validation filter accepts should be coerced.' ); - $this->assertSame( 10, $data['count'] ); + $this->assertSame( array( 'count' => 10 ), $response->get_data()['value'], 'Input a validation filter accepts should be coerced.' ); } /** @@ -1759,64 +1751,41 @@ public function test_validate_input_filter_override_still_coerces(): void { * @ticket 65594 */ public function test_post_typed_input_is_unchanged(): void { - $this->register_test_ability( + // An empty annotations array registers a POST (non-read-only) ability. + $this->register_reflecting_ability( 'test/typed-input-post', array( - 'label' => 'Typed Input POST', - 'description' => 'Reflects the received PHP types back to the caller.', - 'category' => 'general', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'count' => array( 'type' => 'integer' ), - 'flag' => array( 'type' => 'boolean' ), - 'tags' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - ), + 'type' => 'object', + 'properties' => array( + 'count' => array( 'type' => 'integer' ), + 'flag' => array( 'type' => 'boolean' ), + 'tags' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), ), ), - 'execute_callback' => static function ( $input ) { - return array( - 'count' => $input['count'], - 'count_type' => gettype( $input['count'] ), - 'flag' => $input['flag'], - 'flag_type' => gettype( $input['flag'] ), - 'tags' => $input['tags'], - 'tags_type' => gettype( $input['tags'] ), - ); - }, - 'permission_callback' => '__return_true', - 'meta' => array( - 'show_in_rest' => true, - ), - ) + ), + array() ); - $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/typed-input-post/run' ); - $request->set_header( 'Content-Type', 'application/json' ); - $request->set_body( - wp_json_encode( - array( - 'input' => array( - 'count' => 10, - 'flag' => true, - 'tags' => array( 'a', 'b' ), - ), - ) + $response = $this->dispatch_run( + 'POST', + 'test/typed-input-post', + array( + 'count' => 10, + 'flag' => true, + 'tags' => array( 'a', 'b' ), ) ); - - $response = $this->server->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - $this->assertSame( 'integer', $data['count_type'] ); - $this->assertSame( 10, $data['count'] ); - $this->assertSame( 'boolean', $data['flag_type'] ); - $this->assertTrue( $data['flag'] ); - $this->assertSame( 'array', $data['tags_type'] ); - $this->assertSame( array( 'a', 'b' ), $data['tags'] ); + $this->assertSame( + array( + 'count' => 10, + 'flag' => true, + 'tags' => array( 'a', 'b' ), + ), + $response->get_data()['value'] + ); } /** @@ -1842,20 +1811,65 @@ public function test_input_without_schema_passes_through(): void { ) ); - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/no-input-schema/run' ); - $response = $this->server->dispatch( $request ); + $response = $this->dispatch_run( 'GET', 'test/no-input-schema' ); $this->assertSame( 200, $response->get_status() ); $this->assertTrue( $response->get_data()['ran'] ); } + /** + * Tests that the permission callback receives natively typed input. + * + * Coercion runs before `check_ability_permissions()`, so an ability that inspects its input + * during the permission check sees the coerced integer, not the raw "10" string the GET query + * string delivered. The gate runs first, so the first recorded type is the one it acted on. + * + * @ticket 65594 + */ + public function test_permission_callback_receives_typed_input(): void { + $seen = new stdClass(); + $seen->types = array(); + + $this->register_test_ability( + 'test/permission-typed-input', + array( + 'label' => 'Permission Typed Input', + 'description' => 'Records the type the permission callback receives.', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'count' => array( 'type' => 'integer' ), + ), + ), + 'permission_callback' => static function ( $input ) use ( $seen ) { + $seen->types[] = gettype( $input['count'] ); + return true; + }, + 'execute_callback' => static function ( $input ) { + return array( 'count__type' => gettype( $input['count'] ) ); + }, + 'meta' => array( + 'annotations' => array( 'readonly' => true ), + 'show_in_rest' => true, + ), + ) + ); + + $response = $this->dispatch_run( 'GET', 'test/permission-typed-input', array( 'count' => '10' ) ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNotEmpty( $seen->types, 'The permission callback should have run.' ); + $this->assertSame( 'integer', $seen->types[0], 'The REST permission gate should receive coerced (typed) input, not a raw string.' ); + $this->assertSame( 'integer', $response->get_data()['count__type'] ); + } + /** * Tests that a value which only fails validation once coerced falls back to the raw input. * - * `["1", "01"]` is unique as strings (so it validates and its `oneOf`/schema branch matches) - * but collides once cast to integers, so sanitizing produces a nested `rest_duplicate_items` - * error inside the returned array. The recursive error scan detects the nested error and - * returns the raw input, so validation still accepts it and no `WP_Error` reaches the ability. + * `["1", "01"]` is unique as strings but collides once cast to integers, so it cannot be + * coerced without producing a `uniqueItems` error. The raw input is kept instead, so + * validation still accepts it and no `WP_Error` reaches the ability. * * @ticket 65594 */ @@ -1874,20 +1888,10 @@ public function test_nested_sanitize_error_falls_back_to_raw_input(): void { ) ); - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/unique-items-input/run' ); - $request->set_query_params( - array( - 'input' => array( - 'include' => array( '1', '01' ), - ), - ) - ); - - $response = $this->server->dispatch( $request ); + $response = $this->dispatch_run( 'GET', 'test/unique-items-input', array( 'include' => array( '1', '01' ) ) ); // Accepted (unique as strings) and the ability received the raw values, not a WP_Error. $this->assertSame( 200, $response->get_status() ); - $this->assertSame( 'array', $response->get_data()['include__type'] ); - $this->assertSame( array( '1', '01' ), $response->get_data()['include'] ); + $this->assertSame( array( 'include' => array( '1', '01' ) ), $response->get_data()['value'] ); } } From 11a638a97f2c3032897219644c1a27d1f4fe1332 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 8 Jul 2026 20:23:18 +0200 Subject: [PATCH 6/6] Abilities API: Drop the unused reflected type from the coercion tests. The reflecting ability returned a `type` field alongside `value`, but no test reads it now that the coercion cases share one whole-value `assertSame`. That assertion is type-strict and recurses into arrays, so it already checks the PHP type at every level. Return only `value`. Apply the same to the permission callback test: its execute callback now returns the input directly and the test asserts the coerced value, instead of reflecting a `gettype()` string. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rest-api/wpRestAbilitiesV1RunController.php | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index ba4ab1dbbd322..e00750d650e66 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -1388,9 +1388,9 @@ public function test_options_method_handling(): void { } /** - * Registers an ability that reflects the input value and its PHP type back to the caller. + * Registers an ability that reflects the received input value back to the caller. * - * The execute callback returns the received `value` and its `gettype()`, so a test can assert + * The execute callback returns the received input under `value`, so a test can assert * the exact value and PHP type the ability received, whether it is a scalar, an array, or an * object of fields. * @@ -1404,14 +1404,11 @@ private function register_reflecting_ability( string $name, array $input_schema, $name, array( 'label' => 'Reflecting Ability', - 'description' => 'Reflects the received input value and PHP type.', + 'description' => 'Reflects the received input value.', 'category' => 'general', 'input_schema' => $input_schema, 'execute_callback' => static function ( $input ) { - return array( - 'value' => $input, - 'type' => gettype( $input ), - ); + return array( 'value' => $input ); }, 'permission_callback' => '__return_true', 'meta' => array( @@ -1847,7 +1844,7 @@ public function test_permission_callback_receives_typed_input(): void { return true; }, 'execute_callback' => static function ( $input ) { - return array( 'count__type' => gettype( $input['count'] ) ); + return $input; }, 'meta' => array( 'annotations' => array( 'readonly' => true ), @@ -1861,7 +1858,7 @@ public function test_permission_callback_receives_typed_input(): void { $this->assertSame( 200, $response->get_status() ); $this->assertNotEmpty( $seen->types, 'The permission callback should have run.' ); $this->assertSame( 'integer', $seen->types[0], 'The REST permission gate should receive coerced (typed) input, not a raw string.' ); - $this->assertSame( 'integer', $response->get_data()['count__type'] ); + $this->assertSame( array( 'count' => 10 ), $response->get_data(), 'The execute callback should receive coerced input too.' ); } /**