From c5ffcc37caa1d424e0be42496a8aa0673bcdbc8e Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Wed, 19 Nov 2025 19:57:22 +0100 Subject: [PATCH 1/4] Add filters for input and output validation --- includes/abilities-api/class-wp-ability.php | 68 +++++++++++++++------ 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index d116080..831feb9 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -479,7 +479,7 @@ public function validate_input( $input = null ) { $valid_input = rest_validate_value_from_schema( $input, $input_schema, 'input' ); if ( is_wp_error( $valid_input ) ) { - return new WP_Error( + $is_valid = new WP_Error( 'ability_invalid_input', sprintf( /* translators: %1$s ability name, %2$s error message. */ @@ -488,9 +488,26 @@ public function validate_input( $input = null ) { $valid_input->get_error_message() ) ); + } else { + $is_valid = true; } - 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 the default validation already failed, the filter receives the WP_Error object and can + * add additional error information or override it. If the 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. + * @param WP_Ability $ability The ability instance. + */ + return apply_filters( 'wp_ability_validate_input', $is_valid, $input, $this->name, $this ); } /** @@ -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 the default validation already failed, the filter receives the WP_Error object and can + * add additional error information or override it. If the 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. + * @param WP_Ability $ability The ability instance. + */ + return apply_filters( 'wp_ability_validate_output', $is_valid, $output, $this->name, $this ); } /** From 59215aa62f855ec8e9a8c2417933ee3d269c5fab Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Wed, 19 Nov 2025 20:44:57 +0100 Subject: [PATCH 2/4] Add tests --- tests/unit/abilities-api/wpAbility.php | 293 +++++++++++++++++++++++++ 1 file changed, 293 insertions(+) diff --git a/tests/unit/abilities-api/wpAbility.php b/tests/unit/abilities-api/wpAbility.php index 73a5fbf..b06bca5 100644 --- a/tests/unit/abilities-api/wpAbility.php +++ b/tests/unit/abilities-api/wpAbility.php @@ -778,4 +778,297 @@ 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. + */ + 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, $ability ) use ( &$captured ) { + $captured = array( $is_valid, $input, $ability_name, $ability ); + return $is_valid; + }, + 10, + 4 + ); + + $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] ); + $this->assertInstanceOf( WP_Ability::class, $captured[3] ); + } + + /** + * Tests wp_ability_validate_input filter can override validation failure. + */ + 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; // Override any validation error with pass. + }, + 10, + 1 + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute( 'invalid' ); + + $this->assertSame( 99, $result ); + } + + /** + * Tests wp_ability_validate_output filter receives all parameters. + */ + 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, $ability ) use ( &$captured ) { + $captured = array( $is_valid, $output, $ability_name, $ability ); + return $is_valid; + }, + 10, + 4 + ); + + $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] ); + $this->assertInstanceOf( WP_Ability::class, $captured[3] ); + } + + /** + * Tests wp_ability_validate_output filter can override validation failure. + */ + 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; // Override any validation error with pass. + }, + 10, + 1 + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + $this->assertSame( 42, $result ); + } + + /** + * Tests wp_ability_validate_input filter receives WP_Error on validation failure. + */ + 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. + */ + 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 WP_Error on validation failure. + */ + 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. + */ + 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 618d161169c5d764b67ea42a8dcc7d27973bc0a4 Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Thu, 20 Nov 2025 18:47:56 +0100 Subject: [PATCH 3/4] Don't pass the class instance to the ability --- includes/abilities-api/class-wp-ability.php | 6 ++---- tests/unit/abilities-api/wpAbility.php | 14 ++++++-------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 831feb9..4209674 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -505,9 +505,8 @@ public function validate_input( $input = null ) { * @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. - * @param WP_Ability $ability The ability instance. */ - return apply_filters( 'wp_ability_validate_input', $is_valid, $input, $this->name, $this ); + return apply_filters( 'wp_ability_validate_input', $is_valid, $input, $this->name ); } /** @@ -615,9 +614,8 @@ protected function validate_output( $output ) { * @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. - * @param WP_Ability $ability The ability instance. */ - return apply_filters( 'wp_ability_validate_output', $is_valid, $output, $this->name, $this ); + return apply_filters( 'wp_ability_validate_output', $is_valid, $output, $this->name ); } /** diff --git a/tests/unit/abilities-api/wpAbility.php b/tests/unit/abilities-api/wpAbility.php index b06bca5..b2d0d42 100644 --- a/tests/unit/abilities-api/wpAbility.php +++ b/tests/unit/abilities-api/wpAbility.php @@ -801,12 +801,12 @@ public function test_validate_input_filter_receives_all_parameters() { add_filter( 'wp_ability_validate_input', - static function ( $is_valid, $input, $ability_name, $ability ) use ( &$captured ) { - $captured = array( $is_valid, $input, $ability_name, $ability ); + static function ( $is_valid, $input, $ability_name ) use ( &$captured ) { + $captured = array( $is_valid, $input, $ability_name ); return $is_valid; }, 10, - 4 + 3 ); $ability = new WP_Ability( self::$test_ability_name, $args ); @@ -815,7 +815,6 @@ static function ( $is_valid, $input, $ability_name, $ability ) use ( &$captured $this->assertTrue( $captured[0] ); $this->assertSame( 'hello', $captured[1] ); $this->assertSame( self::$test_ability_name, $captured[2] ); - $this->assertInstanceOf( WP_Ability::class, $captured[3] ); } /** @@ -878,12 +877,12 @@ public function test_validate_output_filter_receives_all_parameters() { add_filter( 'wp_ability_validate_output', - static function ( $is_valid, $output, $ability_name, $ability ) use ( &$captured ) { - $captured = array( $is_valid, $output, $ability_name, $ability ); + static function ( $is_valid, $output, $ability_name ) use ( &$captured ) { + $captured = array( $is_valid, $output, $ability_name ); return $is_valid; }, 10, - 4 + 3 ); $ability = new WP_Ability( self::$test_ability_name, $args ); @@ -892,7 +891,6 @@ static function ( $is_valid, $output, $ability_name, $ability ) use ( &$captured $this->assertTrue( $captured[0] ); $this->assertSame( 42, $captured[1] ); $this->assertSame( self::$test_ability_name, $captured[2] ); - $this->assertInstanceOf( WP_Ability::class, $captured[3] ); } /** From 3302da2b216417360b7b1afef5dd02e74449d719 Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Wed, 26 Nov 2025 18:12:34 +0100 Subject: [PATCH 4/4] Add @ticket 64311 to validation filter tests --- tests/unit/abilities-api/wpAbility.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/unit/abilities-api/wpAbility.php b/tests/unit/abilities-api/wpAbility.php index b2d0d42..f860890 100644 --- a/tests/unit/abilities-api/wpAbility.php +++ b/tests/unit/abilities-api/wpAbility.php @@ -781,6 +781,8 @@ public function test_after_action_not_fired_on_output_validation_error() { /** * Tests wp_ability_validate_input filter receives all parameters. + * + * @ticket 64311 */ public function test_validate_input_filter_receives_all_parameters() { $captured = array(); @@ -819,6 +821,8 @@ static function ( $is_valid, $input, $ability_name ) use ( &$captured ) { /** * Tests wp_ability_validate_input filter can override validation failure. + * + * @ticket 64311 */ public function test_validate_input_filter_overrides_validation_failure() { $args = array_merge( @@ -857,6 +861,8 @@ static function ( $is_valid ) { /** * Tests wp_ability_validate_output filter receives all parameters. + * + * @ticket 64311 */ public function test_validate_output_filter_receives_all_parameters() { $captured = array(); @@ -895,6 +901,8 @@ static function ( $is_valid, $output, $ability_name ) use ( &$captured ) { /** * Tests wp_ability_validate_output filter can override validation failure. + * + * @ticket 64311 */ public function test_validate_output_filter_overrides_validation_failure() { $args = array_merge( @@ -928,6 +936,8 @@ static function () { /** * 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; @@ -966,6 +976,8 @@ static function ( $is_valid ) use ( &$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( @@ -1000,6 +1012,8 @@ static function () { /** * 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; @@ -1038,6 +1052,8 @@ static function ( $is_valid ) use ( &$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(