diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 967f1641156b0..cc01cc274c143 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -502,7 +502,7 @@ public function validate_input( $input = null ) { * * @param callable $callback The callable to invoke. * @param mixed $input Optional. The input data for the ability. Default `null`. - * @return mixed The result of the callable execution. + * @return mixed The result of the callable execution, or a `WP_Error` if the callback threw. */ protected function invoke_callback( callable $callback, $input = null ) { $args = array(); @@ -510,7 +510,19 @@ protected function invoke_callback( callable $callback, $input = null ) { $args[] = $input; } - return $callback( ...$args ); + try { + return $callback( ...$args ); + } catch ( Throwable $e ) { + return new WP_Error( + 'ability_callback_exception', + sprintf( + /* translators: 1: Ability name, 2: Exception message. */ + __( 'Ability "%1$s" callback threw an exception: %2$s' ), + esc_html( $this->name ), + esc_html( $e->getMessage() ) + ) + ); + } } /** diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index 73a5fbf17a9ef..aea2c09624929 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -497,6 +497,54 @@ public function test_execute_no_input() { $this->assertSame( 42, $ability->execute() ); } + /** + * Tests that an exception thrown by the execute callback is converted to a WP_Error + * instead of being propagated as an uncaught throwable. + * + * @ticket 65058 + */ + public function test_execute_catches_callback_exception() { + $args = array_merge( + self::$test_ability_properties, + array( + 'execute_callback' => static function (): int { + throw new RuntimeException( 'boom' ); + }, + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + $this->assertWPError( $result, 'Ability::execute() should return WP_Error when the callback throws.' ); + $this->assertSame( 'ability_callback_exception', $result->get_error_code() ); + $this->assertStringContainsString( 'boom', $result->get_error_message() ); + } + + /** + * Tests that an exception thrown by the permission callback is converted to a WP_Error + * instead of being propagated as an uncaught throwable. + * + * @ticket 65058 + */ + public function test_check_permissions_catches_callback_exception() { + $args = array_merge( + self::$test_ability_properties, + array( + 'permission_callback' => static function (): bool { + throw new RuntimeException( 'permission exploded' ); + }, + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->check_permissions(); + + $this->assertWPError( $result, 'Ability::check_permissions() should return WP_Error when the callback throws.' ); + $this->assertSame( 'ability_callback_exception', $result->get_error_code() ); + $this->assertStringContainsString( 'permission exploded', $result->get_error_message() ); + } + /** * Tests that before_execute_ability action is fired with correct parameters. *