From 323d2ae43184821d2fcb2c4a6f92543f3b53423b Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:43:55 +0100 Subject: [PATCH 1/4] Add filters --- .../abilities-api/class-wp-ability.php | 112 ++++++++++++------ 1 file changed, 73 insertions(+), 39 deletions(-) diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index d116080c1ccdc..de0e8b5097b32 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -464,33 +464,50 @@ public function validate_input( $input = null ) { $input_schema = $this->get_input_schema(); if ( empty( $input_schema ) ) { if ( null === $input ) { - return true; + $is_valid = true; + } else { + $is_valid = new WP_Error( + 'ability_missing_input_schema', + sprintf( + /* translators: %s ability name. */ + __( 'Ability "%s" does not define an input schema required to validate the provided input.' ), + esc_html( $this->name ) + ) + ); + } + } else { + $valid_input = rest_validate_value_from_schema( $input, $input_schema, 'input' ); + if ( is_wp_error( $valid_input ) ) { + $is_valid = new WP_Error( + 'ability_invalid_input', + sprintf( + /* translators: %1$s ability name, %2$s error message. */ + __( 'Ability "%1$s" has invalid input. Reason: %2$s' ), + esc_html( $this->name ), + $valid_input->get_error_message() + ) + ); + } else { + $is_valid = true; } - - return new WP_Error( - 'ability_missing_input_schema', - sprintf( - /* translators: %s ability name. */ - __( 'Ability "%s" does not define an input schema required to validate the provided input.' ), - esc_html( $this->name ) - ) - ); - } - - $valid_input = rest_validate_value_from_schema( $input, $input_schema, 'input' ); - if ( is_wp_error( $valid_input ) ) { - return new WP_Error( - 'ability_invalid_input', - sprintf( - /* translators: %1$s ability name, %2$s error message. */ - __( 'Ability "%1$s" has invalid input. Reason: %2$s' ), - esc_html( $this->name ), - $valid_input->get_error_message() - ) - ); } - return true; + /** + * Filters the input validation result for an ability. + * + * Allows developers to add custom validation logic on top of the default + * JSON Schema validation. If default validation already failed, the filter + * receives the WP_Error object and can add additional error information or + * override it. If default validation passed, the filter can add additional + * validation checks and return a WP_Error if those checks fail. + * + * @since 7.0.0 + * + * @param true|WP_Error $is_valid The validation result from default validation. + * @param mixed $input The input data being validated. + * @param string $ability_name The name of the ability. + */ + return apply_filters( 'wp_ability_validate_input', $is_valid, $input, $this->name ); } /** @@ -567,23 +584,40 @@ protected function do_execute( $input = null ) { protected function validate_output( $output ) { $output_schema = $this->get_output_schema(); if ( empty( $output_schema ) ) { - return true; - } - - $valid_output = rest_validate_value_from_schema( $output, $output_schema, 'output' ); - if ( is_wp_error( $valid_output ) ) { - return new WP_Error( - 'ability_invalid_output', - sprintf( - /* translators: %1$s ability name, %2$s error message. */ - __( 'Ability "%1$s" has invalid output. Reason: %2$s' ), - esc_html( $this->name ), - $valid_output->get_error_message() - ) - ); + $is_valid = true; + } else { + $valid_output = rest_validate_value_from_schema( $output, $output_schema, 'output' ); + if ( is_wp_error( $valid_output ) ) { + $is_valid = new WP_Error( + 'ability_invalid_output', + sprintf( + /* translators: %1$s ability name, %2$s error message. */ + __( 'Ability "%1$s" has invalid output. Reason: %2$s' ), + esc_html( $this->name ), + $valid_output->get_error_message() + ) + ); + } else { + $is_valid = true; + } } - return true; + /** + * Filters the output validation result for an ability. + * + * Allows developers to add custom validation logic on top of the default + * JSON Schema validation. If default validation already failed, the filter + * receives the WP_Error object and can add additional error information or + * override it. If default validation passed, the filter can add additional + * validation checks and return a WP_Error if those checks fail. + * + * @since 7.0.0 + * + * @param true|WP_Error $is_valid The validation result from default validation. + * @param mixed $output The output data being validated. + * @param string $ability_name The name of the ability. + */ + return apply_filters( 'wp_ability_validate_output', $is_valid, $output, $this->name ); } /** From 19a5d113253831b9525a119ca77b3d704454cc4c Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:49:34 +0100 Subject: [PATCH 2/4] Add tests --- .../phpunit/tests/abilities-api/wpAbility.php | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index 73a5fbf17a9ef..ebcb1dff77a9b 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -778,4 +778,311 @@ public function test_after_action_not_fired_on_output_validation_error() { $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired when output validation fails' ); $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error for output validation failure' ); } + + /** + * Tests wp_ability_validate_input filter receives all parameters. + * + * @ticket 64311 + */ + public function test_validate_input_filter_receives_all_parameters() { + $captured = array(); + + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => array( + 'type' => 'string', + 'description' => 'Test input string.', + 'required' => true, + ), + 'execute_callback' => static function ( string $input ): int { + return strlen( $input ); + }, + ) + ); + + add_filter( + 'wp_ability_validate_input', + static function ( $is_valid, $input, $ability_name ) use ( &$captured ) { + $captured = array( $is_valid, $input, $ability_name ); + return $is_valid; + }, + 10, + 3 + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $ability->execute( 'hello' ); + + $this->assertTrue( $captured[0] ); + $this->assertSame( 'hello', $captured[1] ); + $this->assertSame( self::$test_ability_name, $captured[2] ); + } + + /** + * Tests wp_ability_validate_input filter can override validation failure. + * + * @ticket 64311 + */ + public function test_validate_input_filter_overrides_validation_failure() { + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => array( + 'type' => 'integer', + 'description' => 'Test input integer.', + 'required' => true, + ), + 'output_schema' => array( + 'type' => 'integer', + 'description' => 'Result integer.', + 'required' => true, + ), + 'execute_callback' => static function () { + return 99; + }, + ) + ); + + add_filter( + 'wp_ability_validate_input', + static function ( $is_valid ) { + return true; + }, + 10, + 1 + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute( 'invalid' ); + + $this->assertSame( 99, $result ); + } + + /** + * Tests wp_ability_validate_input filter receives WP_Error on validation failure. + * + * @ticket 64311 + */ + public function test_validate_input_filter_receives_error_on_invalid_input() { + $error_code = null; + + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => array( + 'type' => 'integer', + 'description' => 'Test input integer.', + 'required' => true, + ), + 'execute_callback' => static function ( int $input ): int { + return $input * 2; + }, + ) + ); + + add_filter( + 'wp_ability_validate_input', + static function ( $is_valid ) use ( &$error_code ) { + if ( is_wp_error( $is_valid ) ) { + $error_code = $is_valid->get_error_code(); + } + return $is_valid; + }, + 10, + 1 + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $ability->execute( 'invalid' ); + + $this->assertSame( 'ability_invalid_input', $error_code ); + } + + /** + * Tests wp_ability_validate_input filter can replace error with custom error. + * + * @ticket 64311 + */ + public function test_validate_input_filter_replaces_error_with_custom() { + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => array( + 'type' => 'integer', + 'description' => 'Test input integer.', + 'required' => true, + ), + 'execute_callback' => static function ( int $input ): int { + return $input * 2; + }, + ) + ); + + add_filter( + 'wp_ability_validate_input', + static function () { + return new WP_Error( 'custom_error', 'Custom message.' ); + }, + 10, + 1 + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute( 'invalid' ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'custom_error', $result->get_error_code() ); + } + + /** + * Tests wp_ability_validate_output filter receives all parameters. + * + * @ticket 64311 + */ + public function test_validate_output_filter_receives_all_parameters() { + $captured = array(); + + $args = array_merge( + self::$test_ability_properties, + array( + 'output_schema' => array( + 'type' => 'integer', + 'description' => 'The result integer.', + 'required' => true, + ), + 'execute_callback' => static function (): int { + return 42; + }, + ) + ); + + add_filter( + 'wp_ability_validate_output', + static function ( $is_valid, $output, $ability_name ) use ( &$captured ) { + $captured = array( $is_valid, $output, $ability_name ); + return $is_valid; + }, + 10, + 3 + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $ability->execute(); + + $this->assertTrue( $captured[0] ); + $this->assertSame( 42, $captured[1] ); + $this->assertSame( self::$test_ability_name, $captured[2] ); + } + + /** + * Tests wp_ability_validate_output filter can override validation failure. + * + * @ticket 64311 + */ + public function test_validate_output_filter_overrides_validation_failure() { + $args = array_merge( + self::$test_ability_properties, + array( + 'output_schema' => array( + 'type' => 'string', + 'description' => 'The result string.', + 'required' => true, + ), + 'execute_callback' => static function (): int { + return 42; + }, + ) + ); + + add_filter( + 'wp_ability_validate_output', + static function () { + return true; + }, + 10, + 1 + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + $this->assertSame( 42, $result ); + } + + /** + * Tests wp_ability_validate_output filter receives WP_Error on validation failure. + * + * @ticket 64311 + */ + public function test_validate_output_filter_receives_error_on_invalid_output() { + $error_code = null; + + $args = array_merge( + self::$test_ability_properties, + array( + 'output_schema' => array( + 'type' => 'string', + 'description' => 'The result string.', + 'required' => true, + ), + 'execute_callback' => static function (): int { + return 42; + }, + ) + ); + + add_filter( + 'wp_ability_validate_output', + static function ( $is_valid ) use ( &$error_code ) { + if ( is_wp_error( $is_valid ) ) { + $error_code = $is_valid->get_error_code(); + } + return $is_valid; + }, + 10, + 1 + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $ability->execute(); + + $this->assertSame( 'ability_invalid_output', $error_code ); + } + + /** + * Tests wp_ability_validate_output filter can replace error with custom error. + * + * @ticket 64311 + */ + public function test_validate_output_filter_replaces_error_with_custom() { + $args = array_merge( + self::$test_ability_properties, + array( + 'output_schema' => array( + 'type' => 'string', + 'description' => 'The result string.', + 'required' => true, + ), + 'execute_callback' => static function (): int { + return 42; + }, + ) + ); + + add_filter( + 'wp_ability_validate_output', + static function () { + return new WP_Error( 'custom_output_error', 'Custom output message.' ); + }, + 10, + 1 + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'custom_output_error', $result->get_error_code() ); + } } From 1c8be4ae914978692ce8546a132c6848ba1acffa Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:41:07 +0100 Subject: [PATCH 3/4] Don't run the filters if there is nothing to validate (no input/ouput or no schema) --- .../abilities-api/class-wp-ability.php | 78 +++++++++---------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index de0e8b5097b32..7a088a6bcbc70 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -464,32 +464,32 @@ public function validate_input( $input = null ) { $input_schema = $this->get_input_schema(); if ( empty( $input_schema ) ) { if ( null === $input ) { - $is_valid = true; - } else { - $is_valid = new WP_Error( - 'ability_missing_input_schema', - sprintf( - /* translators: %s ability name. */ - __( 'Ability "%s" does not define an input schema required to validate the provided input.' ), - esc_html( $this->name ) - ) - ); + return true; } + + return new WP_Error( + 'ability_missing_input_schema', + sprintf( + /* translators: %s ability name. */ + __( 'Ability "%s" does not define an input schema required to validate the provided input.' ), + esc_html( $this->name ) + ) + ); + } + + $valid_input = rest_validate_value_from_schema( $input, $input_schema, 'input' ); + if ( is_wp_error( $valid_input ) ) { + $is_valid = new WP_Error( + 'ability_invalid_input', + sprintf( + /* translators: %1$s ability name, %2$s error message. */ + __( 'Ability "%1$s" has invalid input. Reason: %2$s' ), + esc_html( $this->name ), + $valid_input->get_error_message() + ) + ); } else { - $valid_input = rest_validate_value_from_schema( $input, $input_schema, 'input' ); - if ( is_wp_error( $valid_input ) ) { - $is_valid = new WP_Error( - 'ability_invalid_input', - sprintf( - /* translators: %1$s ability name, %2$s error message. */ - __( 'Ability "%1$s" has invalid input. Reason: %2$s' ), - esc_html( $this->name ), - $valid_input->get_error_message() - ) - ); - } else { - $is_valid = true; - } + $is_valid = true; } /** @@ -584,22 +584,22 @@ protected function do_execute( $input = null ) { protected function validate_output( $output ) { $output_schema = $this->get_output_schema(); if ( empty( $output_schema ) ) { - $is_valid = true; + return true; + } + + $valid_output = rest_validate_value_from_schema( $output, $output_schema, 'output' ); + if ( is_wp_error( $valid_output ) ) { + $is_valid = new WP_Error( + 'ability_invalid_output', + sprintf( + /* translators: %1$s ability name, %2$s error message. */ + __( 'Ability "%1$s" has invalid output. Reason: %2$s' ), + esc_html( $this->name ), + $valid_output->get_error_message() + ) + ); } else { - $valid_output = rest_validate_value_from_schema( $output, $output_schema, 'output' ); - if ( is_wp_error( $valid_output ) ) { - $is_valid = new WP_Error( - 'ability_invalid_output', - sprintf( - /* translators: %1$s ability name, %2$s error message. */ - __( 'Ability "%1$s" has invalid output. Reason: %2$s' ), - esc_html( $this->name ), - $valid_output->get_error_message() - ) - ); - } else { - $is_valid = true; - } + $is_valid = true; } /** From 39ec0e95f93fd2028e2a9ad4511e3d156f34d572 Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Wed, 26 Nov 2025 18:00:22 +0100 Subject: [PATCH 4/4] Output doesn't have schema restrictions as input, so fire the hook for output even if no schema is provided --- .../abilities-api/class-wp-ability.php | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 7a088a6bcbc70..fe72a69e1d1ed 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -584,22 +584,22 @@ protected function do_execute( $input = null ) { protected function validate_output( $output ) { $output_schema = $this->get_output_schema(); if ( empty( $output_schema ) ) { - return true; - } - - $valid_output = rest_validate_value_from_schema( $output, $output_schema, 'output' ); - if ( is_wp_error( $valid_output ) ) { - $is_valid = new WP_Error( - 'ability_invalid_output', - sprintf( - /* translators: %1$s ability name, %2$s error message. */ - __( 'Ability "%1$s" has invalid output. Reason: %2$s' ), - esc_html( $this->name ), - $valid_output->get_error_message() - ) - ); - } else { $is_valid = true; + } else { + $valid_output = rest_validate_value_from_schema( $output, $output_schema, 'output' ); + if ( is_wp_error( $valid_output ) ) { + $is_valid = new WP_Error( + 'ability_invalid_output', + sprintf( + /* translators: %1$s ability name, %2$s error message. */ + __( 'Ability "%1$s" has invalid output. Reason: %2$s' ), + esc_html( $this->name ), + $valid_output->get_error_message() + ) + ); + } else { + $is_valid = true; + } } /**