Make WordPress Core

Changeset 62397


Ignore:
Timestamp:
05/21/2026 07:56:31 AM (3 months ago)
Author:
gziolo
Message:

Abilities API: Add execution lifecycle filters to WP_Ability methods

Introduce four filters that give plugins hook points across the ability execution lifecycle, complementing the existing observation-only actions
(wp_before_execute_ability, wp_after_execute_ability):

  • wp_pre_execute_ability: short-circuits execute() when it returns a value other than the supplied default.
  • wp_ability_normalize_input: transforms input inside normalize_input(), and returning WP_Error halts execution.
  • wp_ability_permission_result: overrides the permission_callback result inside check_permissions(), consistently for execute() and direct callers.
  • wp_ability_execute_result: transforms the result inside do_execute() before output validation, and can recover from execute callback failures.

The input and result filters fire before their respective schema validation steps, so validate_input() and validate_output() remain the final integrity gates. Only wp_pre_execute_ability can bypass validation, with the caller owning the returned value's shape.

Add WP_Filter_Sentinel, a reusable marker class loaded alongside WP_Hook, whose per-instance identity lets a filter default be distinguished from any
user value — including null, false, or arbitrary objects — via ===.

Update WP_REST_Abilities_V1_Run_Controller::check_ability_permissions() to propagate WP_Error results from normalize_input() directly, defaulting to
status 400 while preserving filter-set statuses (e.g. 422, 429).

Props gziolo, westonruter, migueluy.
Fixes #64989.

Location:
trunk
Files:
1 added
5 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/abilities-api/class-wp-ability.php

    r62238 r62397  
    437437         * this method returns null. If input is provided, it is returned as-is.
    438438         *
    439          * @since 6.9.0
     439         * The {@see 'wp_ability_normalize_input'} filter fires after the built-in default-value handling,
     440         * allowing plugins to transform the result.
     441         *
     442         * @since 6.9.0
     443         * @since 7.1.0 Added the `wp_ability_normalize_input` filter.
    440444         *
    441445         * @param mixed $input Optional. The raw input provided for the ability. Default `null`.
    442          * @return mixed The same input, or the default from schema, or `null` if default not set.
     446         * @return mixed The normalized input, or a `WP_Error` if a filter returned one.
    443447         */
    444448        public function normalize_input( $input = null ) {
    445                 if ( null !== $input ) {
    446                         return $input;
    447                 }
    448 
    449                 $input_schema = $this->get_input_schema();
    450                 if ( ! empty( $input_schema ) && array_key_exists( 'default', $input_schema ) ) {
    451                         return $input_schema['default'];
    452                 }
    453 
    454                 return null;
     449                if ( null === $input ) {
     450                        $input_schema = $this->get_input_schema();
     451                        if ( array_key_exists( 'default', $input_schema ) ) {
     452                                $input = $input_schema['default'];
     453                        }
     454                }
     455
     456                /**
     457                 * Filters the normalized input for an ability.
     458                 *
     459                 * Fires after `normalize_input()` has applied any default value declared in the input schema,
     460                 * giving plugins a chance to adjust the input before it is consumed downstream. Common uses
     461                 * include defaulting beyond what JSON Schema can express, prompt enrichment, and injecting
     462                 * caller metadata.
     463                 *
     464                 * Returning a `WP_Error` causes callers that propagate it (such as `execute()`) to halt
     465                 * before validation, permission checks, and the registered execute callback.
     466                 *
     467                 * @since 7.1.0
     468                 *
     469                 * @param mixed      $input        The normalized input data.
     470                 * @param string     $ability_name The name of the ability.
     471                 * @param WP_Ability $ability      The ability instance.
     472                 */
     473                return apply_filters( 'wp_ability_normalize_input', $input, $this->name, $this );
    455474        }
    456475
     
    532551         * Use `validate_input()` method to validate input before calling this method if needed.
    533552         *
    534          * @since 6.9.0
     553         * The {@see 'wp_ability_permission_result'} filter fires after the registered
     554         * `permission_callback` returns, allowing plugins to override the result.
     555         *
     556         * @since 6.9.0
     557         * @since 7.1.0 Added the `wp_ability_permission_result` filter.
    535558         *
    536559         * @see validate_input()
     
    548571                }
    549572
    550                 return $this->invoke_callback( $this->permission_callback, $input );
     573                $permission = $this->invoke_callback( $this->permission_callback, $input );
     574
     575                /**
     576                 * Filters the result of an ability's permission check.
     577                 *
     578                 * Fires after the registered `permission_callback` returns. Plugins can use this to layer
     579                 * additional authorization rules on top of the ability's own permission logic — for example,
     580                 * multi-factor authorization gates or temporary permission elevation for trusted contexts.
     581                 *
     582                 * Filters can return `true` to grant, `false` to deny, or a `WP_Error` to deny with a specific
     583                 * error code and message. The filter receives whatever the `permission_callback` produced.
     584                 * Any other return value is coerced to `false`.
     585                 *
     586                 * @since 7.1.0
     587                 *
     588                 * @param bool|WP_Error $permission   The permission result returned by `permission_callback`.
     589                 * @param string        $ability_name The name of the ability.
     590                 * @param mixed         $input        The input data for the permission check.
     591                 * @param WP_Ability    $ability      The ability instance.
     592                 */
     593                $result = apply_filters( 'wp_ability_permission_result', $permission, $this->name, $input, $this );
     594                if ( ! is_bool( $result ) && ! is_wp_error( $result ) ) {
     595                        $result = false;
     596                }
     597                return $result;
    551598        }
    552599
     
    554601         * Executes the ability callback.
    555602         *
    556          * @since 6.9.0
     603         * The {@see 'wp_ability_execute_result'} filter fires before this method returns, allowing
     604         * plugins to transform the result produced by the registered `execute_callback`.
     605         *
     606         * @since 6.9.0
     607         * @since 7.1.0 Added the `wp_ability_execute_result` filter.
    557608         *
    558609         * @param mixed $input Optional. The input data for the ability. Default `null`.
     
    561612        protected function do_execute( $input = null ) {
    562613                if ( ! is_callable( $this->execute_callback ) ) {
    563                         return new WP_Error(
     614                        $result = new WP_Error(
    564615                                'ability_invalid_execute_callback',
    565616                                /* translators: %s ability name. */
    566617                                sprintf( __( 'Ability "%s" does not have a valid execute callback.' ), esc_html( $this->name ) )
    567618                        );
    568                 }
    569 
    570                 return $this->invoke_callback( $this->execute_callback, $input );
     619                } else {
     620                        $result = $this->invoke_callback( $this->execute_callback, $input );
     621                }
     622
     623                /**
     624                 * Filters the result returned by an ability's execute callback.
     625                 *
     626                 * Fires after the registered execute callback runs. Plugins can use this to transform the
     627                 * result — response formatting, stripping internal metadata, content safety filtering,
     628                 * response enrichment, or recovering from a failure by returning a successful value.
     629                 *
     630                 * The filter receives whatever the registered callback produced, including a `WP_Error`
     631                 * if execution failed. Filters may pass the `WP_Error` through unchanged, override it with
     632                 * a recovered result, or convert a successful result into a `WP_Error`.
     633                 *
     634                 * @since 7.1.0
     635                 *
     636                 * @param mixed      $result       The result returned by the registered `execute_callback`,
     637                 *                                 or a `WP_Error` if execution failed.
     638                 * @param string     $ability_name The name of the ability.
     639                 * @param mixed      $input        The normalized input data.
     640                 * @param WP_Ability $ability      The ability instance.
     641                 */
     642                return apply_filters( 'wp_ability_execute_result', $result, $this->name, $input, $this );
    571643        }
    572644
     
    606678         *
    607679         * @since 6.9.0
     680         * @since 7.1.0 Added the `wp_pre_execute_ability` filter.
    608681         *
    609682         * @param mixed $input Optional. The input data for the ability. Default `null`.
     
    611684         */
    612685        public function execute( $input = null ) {
    613                 $input    = $this->normalize_input( $input );
     686                /**
     687                 * Filters whether to short-circuit ability execution.
     688                 *
     689                 * Returning a value other than the received default bypasses the rest of `execute()` —
     690                 * input normalization, input validation, permission checks, the registered execute callback,
     691                 * output validation, and the surrounding actions — and the value is returned to the caller
     692                 * as-is. Useful for cached responses, rate limiting, maintenance mode, and test mocking.
     693                 *
     694                 * To continue with normal execution, return `$pre` unchanged. This preserves any value
     695                 * (including `null`, `false`, or arbitrary objects) as a valid short-circuit result.
     696                 *
     697                 * Because validation is bypassed, callers that short-circuit are responsible for the
     698                 * integrity of any value they consume from `$input`.
     699                 *
     700                 * @since 7.1.0
     701                 *
     702                 * @param mixed      $pre          The pre-computed result. Return this value unchanged to continue execution.
     703                 *                                 Default `WP_Filter_Sentinel` instance unique to this invocation.
     704                 * @param string     $ability_name The name of the ability.
     705                 * @param mixed      $input        The raw input passed to `execute()`.
     706                 * @param WP_Ability $ability      The ability instance.
     707                 */
     708                $pre_execute_sentinel = new WP_Filter_Sentinel();
     709                $pre                  = apply_filters( 'wp_pre_execute_ability', $pre_execute_sentinel, $this->name, $input, $this );
     710                if ( $pre !== $pre_execute_sentinel ) {
     711                        return $pre;
     712                }
     713
     714                $input = $this->normalize_input( $input );
     715                if ( is_wp_error( $input ) ) {
     716                        return $input;
     717                }
     718
    614719                $is_valid = $this->validate_input( $input );
    615720                if ( is_wp_error( $is_valid ) ) {
  • trunk/src/wp-includes/plugin.php

    r61118 r62397  
    2424// Initialize the filter globals.
    2525require __DIR__ . '/class-wp-hook.php';
     26require __DIR__ . '/class-wp-filter-sentinel.php';
    2627
    2728/** @var WP_Hook[] $wp_filter */
  • trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php

    r61047 r62397  
    159159                }
    160160
    161                 $input    = $this->get_input_from_request( $request );
    162                 $input    = $ability->normalize_input( $input );
     161                $input = $this->get_input_from_request( $request );
     162                $input = $ability->normalize_input( $input );
     163                if ( is_wp_error( $input ) ) {
     164                        $error_data = $input->get_error_data();
     165                        if ( ! is_array( $error_data ) || ! isset( $error_data['status'] ) ) {
     166                                $input->add_data( array( 'status' => 400 ) );
     167                        }
     168
     169                        return $input;
     170                }
     171
    163172                $is_valid = $ability->validate_input( $input );
    164173                if ( is_wp_error( $is_valid ) ) {
  • trunk/tests/phpunit/tests/abilities-api/wpAbility.php

    r62238 r62397  
    827827                $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error for output validation failure' );
    828828        }
     829
     830        /**
     831         * Tests that the wp_ability_normalize_input filter can transform input and receives the
     832         * expected ability name and instance (verified via args-as-guards on the transformation).
     833         *
     834         * @ticket 64989
     835         */
     836        public function test_normalize_input_filter_can_transform_input() {
     837                $args = array_merge(
     838                        self::$test_ability_properties,
     839                        array(
     840                                'input_schema'     => array(
     841                                        'type'        => 'string',
     842                                        'description' => 'Test input string.',
     843                                        'required'    => true,
     844                                ),
     845                                'output_schema'    => array(
     846                                        'type'        => 'integer',
     847                                        'description' => 'Result integer.',
     848                                        'required'    => true,
     849                                ),
     850                                'execute_callback' => static function ( string $input ): int {
     851                                        return strlen( $input );
     852                                },
     853                        )
     854                );
     855
     856                $callback = static function ( $input, $ability_name, $ability ) {
     857                        if ( self::$test_ability_name !== $ability_name || ! $ability instanceof WP_Ability ) {
     858                                return $input;
     859                        }
     860                        return $input . '-transformed';
     861                };
     862
     863                add_filter( 'wp_ability_normalize_input', $callback, 10, 3 );
     864
     865                $ability = new WP_Ability( self::$test_ability_name, $args );
     866                $result  = $ability->execute( 'hello' );
     867
     868                remove_filter( 'wp_ability_normalize_input', $callback, 10 );
     869
     870                $this->assertSame( strlen( 'hello-transformed' ), $result, 'Result should reflect the transformed input flowing through the execute callback.' );
     871        }
     872
     873        /**
     874         * Tests that returning a WP_Error from wp_ability_normalize_input halts execution.
     875         *
     876         * @ticket 64989
     877         */
     878        public function test_normalize_input_filter_wp_error_halts_execution() {
     879                $args = array_merge(
     880                        self::$test_ability_properties,
     881                        array(
     882                                'input_schema'     => array(
     883                                        'type'        => 'string',
     884                                        'description' => 'Test input string.',
     885                                        'required'    => true,
     886                                ),
     887                                'execute_callback' => static function ( string $input ) {
     888                                        return strlen( $input );
     889                                },
     890                        )
     891                );
     892
     893                $filter = static function () {
     894                        return new WP_Error( 'normalize_halt', 'Halted from filter.' );
     895                };
     896
     897                add_filter( 'wp_ability_normalize_input', $filter );
     898
     899                $ability = new WP_Ability( self::$test_ability_name, $args );
     900                $result  = $ability->execute( 'hello' );
     901
     902                remove_filter( 'wp_ability_normalize_input', $filter );
     903
     904                $this->assertInstanceOf( WP_Error::class, $result, 'Filter returning WP_Error should propagate as the execute() result.' );
     905                $this->assertSame( 'normalize_halt', $result->get_error_code(), 'WP_Error code should be preserved.' );
     906        }
     907
     908        /**
     909         * Tests that the wp_ability_permission_result filter can grant permission that the
     910         * callback denied, with the filter's args verified via args-as-guards.
     911         *
     912         * @ticket 64989
     913         */
     914        public function test_permission_result_filter_can_grant_permission() {
     915                $args = array_merge(
     916                        self::$test_ability_properties,
     917                        array(
     918                                'input_schema'        => array(
     919                                        'type'        => 'integer',
     920                                        'description' => 'Test input integer.',
     921                                        'required'    => true,
     922                                ),
     923                                'output_schema'       => array(
     924                                        'type'        => 'integer',
     925                                        'description' => 'Result integer.',
     926                                        'required'    => true,
     927                                ),
     928                                'execute_callback'    => static function ( int $input ): int {
     929                                        return $input;
     930                                },
     931                                'permission_callback' => static function (): bool {
     932                                        return false;
     933                                },
     934                        )
     935                );
     936
     937                $filter = static function ( $permission, $ability_name, $input, $ability ) {
     938                        if ( false !== $permission ) {
     939                                return $permission;
     940                        }
     941                        if ( self::$test_ability_name !== $ability_name || 7 !== $input || ! $ability instanceof WP_Ability ) {
     942                                return $permission;
     943                        }
     944                        return true;
     945                };
     946
     947                add_filter( 'wp_ability_permission_result', $filter, 10, 4 );
     948
     949                $ability = new WP_Ability( self::$test_ability_name, $args );
     950                $result  = $ability->execute( 7 );
     951
     952                remove_filter( 'wp_ability_permission_result', $filter, 10 );
     953
     954                $this->assertSame( 7, $result, 'Filter should override the permission denial; reaching the execute callback proves all args matched expectations.' );
     955        }
     956
     957        /**
     958         * Tests that the wp_ability_permission_result filter can deny permission granted by the callback.
     959         *
     960         * @ticket 64989
     961         */
     962        public function test_permission_result_filter_can_deny_permission() {
     963                $args = array_merge(
     964                        self::$test_ability_properties,
     965                        array(
     966                                'execute_callback'    => static function (): int {
     967                                        return 1;
     968                                },
     969                                'permission_callback' => static function (): bool {
     970                                        return true;
     971                                },
     972                        )
     973                );
     974
     975                $filter = static function () {
     976                        return false;
     977                };
     978
     979                add_filter( 'wp_ability_permission_result', $filter );
     980
     981                $ability = new WP_Ability( self::$test_ability_name, $args );
     982                $result  = $ability->execute();
     983
     984                remove_filter( 'wp_ability_permission_result', $filter );
     985
     986                $this->assertInstanceOf( WP_Error::class, $result, 'Denied permission should produce a WP_Error.' );
     987                $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
     988        }
     989
     990        /**
     991         * Tests that the wp_ability_permission_result filter can convert a WP_Error denial from the
     992         * callback into a grant, proving the filter receives the WP_Error verbatim.
     993         *
     994         * @ticket 64989
     995         */
     996        public function test_permission_result_filter_can_convert_wp_error_to_grant() {
     997                $args = array_merge(
     998                        self::$test_ability_properties,
     999                        array(
     1000                                'execute_callback'    => static function (): int {
     1001                                        return 1;
     1002                                },
     1003                                'permission_callback' => static function (): WP_Error {
     1004                                        return new WP_Error( 'callback_denied', 'Denied by callback.' );
     1005                                },
     1006                        )
     1007                );
     1008
     1009                $filter = static function ( $permission ) {
     1010                        if ( ! is_wp_error( $permission ) || 'callback_denied' !== $permission->get_error_code() ) {
     1011                                return $permission;
     1012                        }
     1013                        return true;
     1014                };
     1015
     1016                add_filter( 'wp_ability_permission_result', $filter );
     1017
     1018                $ability = new WP_Ability( self::$test_ability_name, $args );
     1019                $result  = $ability->execute();
     1020
     1021                remove_filter( 'wp_ability_permission_result', $filter );
     1022
     1023                $this->assertSame( 1, $result, 'Filter received the WP_Error denial and converted it to a grant; execute callback returned its value.' );
     1024        }
     1025
     1026        /**
     1027         * Tests that the wp_ability_permission_result filter fires when check_permissions() is
     1028         * called directly (not via execute()).
     1029         *
     1030         * @ticket 64989
     1031         */
     1032        public function test_permission_result_filter_fires_on_direct_check_permissions_call() {
     1033                $args = array_merge(
     1034                        self::$test_ability_properties,
     1035                        array(
     1036                                'permission_callback' => static function (): bool {
     1037                                        return true;
     1038                                },
     1039                        )
     1040                );
     1041
     1042                $filter = static function () {
     1043                        return false;
     1044                };
     1045
     1046                add_filter( 'wp_ability_permission_result', $filter );
     1047
     1048                $ability = new WP_Ability( self::$test_ability_name, $args );
     1049                $result  = $ability->check_permissions();
     1050
     1051                remove_filter( 'wp_ability_permission_result', $filter );
     1052
     1053                $this->assertFalse( $result, 'check_permissions() should return the filtered value when called directly.' );
     1054        }
     1055
     1056        /**
     1057         * Tests that a non-bool, non-WP_Error return from the wp_ability_permission_result filter is
     1058         * coerced to false so check_permissions() honors its documented bool|WP_Error return type.
     1059         *
     1060         * @ticket 64989
     1061         */
     1062        public function test_permission_result_filter_invalid_value_coerced_to_false() {
     1063                $filter = static function () {
     1064                        return 'not-a-bool';
     1065                };
     1066
     1067                add_filter( 'wp_ability_permission_result', $filter );
     1068
     1069                $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties );
     1070                $result  = $ability->check_permissions();
     1071
     1072                remove_filter( 'wp_ability_permission_result', $filter );
     1073
     1074                $this->assertFalse( $result, 'Non-bool, non-WP_Error filter return is coerced to false.' );
     1075        }
     1076
     1077        /**
     1078         * Tests that returning a custom value from wp_pre_execute_ability short-circuits the
     1079         * pipeline. The pipeline is configured to fail (permission denial) so a real run would
     1080         * surface a WP_Error; receiving the short-circuit value proves the bypass. Filter args
     1081         * are verified via args-as-guards on the short-circuit value.
     1082         *
     1083         * @ticket 64989
     1084         */
     1085        public function test_pre_execute_ability_filter_short_circuits_pipeline() {
     1086                $args = array_merge(
     1087                        self::$test_ability_properties,
     1088                        array(
     1089                                'input_schema'        => array(
     1090                                        'type'        => 'integer',
     1091                                        'description' => 'Test input integer.',
     1092                                        'required'    => true,
     1093                                ),
     1094                                'execute_callback'    => static function (): int {
     1095                                        return 1;
     1096                                },
     1097                                'permission_callback' => static function (): bool {
     1098                                        return false;
     1099                                },
     1100                        )
     1101                );
     1102
     1103                $filter = static function ( $pre, $ability_name, $input, $ability ) {
     1104                        if ( self::$test_ability_name !== $ability_name || 99 !== $input || ! $ability instanceof WP_Ability ) {
     1105                                return $pre;
     1106                        }
     1107                        return 'short-circuited';
     1108                };
     1109
     1110                add_filter( 'wp_pre_execute_ability', $filter, 10, 4 );
     1111
     1112                $ability = new WP_Ability( self::$test_ability_name, $args );
     1113                $result  = $ability->execute( 99 );
     1114
     1115                remove_filter( 'wp_pre_execute_ability', $filter, 10 );
     1116
     1117                $this->assertSame( 'short-circuited', $result, 'Short-circuit value bypasses the pipeline; matching args allowed the filter to set it.' );
     1118        }
     1119
     1120        /**
     1121         * Tests that returning the default value from wp_pre_execute_ability lets the pipeline run.
     1122         *
     1123         * @ticket 64989
     1124         */
     1125        public function test_pre_execute_ability_filter_default_value_runs_pipeline() {
     1126                $args = array_merge(
     1127                        self::$test_ability_properties,
     1128                        array(
     1129                                'execute_callback' => static function (): int {
     1130                                        return 5;
     1131                                },
     1132                        )
     1133                );
     1134
     1135                $filter = static function ( $pre ) {
     1136                        return $pre;
     1137                };
     1138
     1139                add_filter( 'wp_pre_execute_ability', $filter );
     1140
     1141                $ability = new WP_Ability( self::$test_ability_name, $args );
     1142                $result  = $ability->execute();
     1143
     1144                remove_filter( 'wp_pre_execute_ability', $filter );
     1145
     1146                $this->assertSame( 5, $result, 'Pipeline should run and return the execute_callback value when filter returns the default value.' );
     1147        }
     1148
     1149        /**
     1150         * Tests that returning null explicitly from wp_pre_execute_ability short-circuits with null.
     1151         *
     1152         * @ticket 64989
     1153         */
     1154        public function test_pre_execute_ability_filter_null_short_circuits() {
     1155                $args = array_merge(
     1156                        self::$test_ability_properties,
     1157                        array(
     1158                                'execute_callback'    => static function (): int {
     1159                                        return 1;
     1160                                },
     1161                                'permission_callback' => static function (): bool {
     1162                                        return false;
     1163                                },
     1164                        )
     1165                );
     1166
     1167                $filter = static function () {
     1168                        return null;
     1169                };
     1170
     1171                add_filter( 'wp_pre_execute_ability', $filter );
     1172
     1173                $ability = new WP_Ability( self::$test_ability_name, $args );
     1174                $result  = $ability->execute();
     1175
     1176                remove_filter( 'wp_pre_execute_ability', $filter );
     1177
     1178                $this->assertNull( $result, 'Null from filter should be returned as-is and bypass the pipeline.' );
     1179        }
     1180
     1181        /**
     1182         * Tests that returning a freshly constructed object from wp_pre_execute_ability is treated as a
     1183         * short-circuit value, not confused with the WP_Filter_Sentinel default. This proves the
     1184         * sentinel disambiguates arbitrary object returns.
     1185         *
     1186         * @ticket 64989
     1187         */
     1188        public function test_pre_execute_ability_filter_object_short_circuits() {
     1189                $args = array_merge(
     1190                        self::$test_ability_properties,
     1191                        array(
     1192                                'execute_callback'    => static function (): int {
     1193                                        return 1;
     1194                                },
     1195                                'permission_callback' => static function (): bool {
     1196                                        return false;
     1197                                },
     1198                        )
     1199                );
     1200
     1201                $envelope = (object) array( 'status' => 'approval_pending' );
     1202                $filter   = static function () use ( $envelope ) {
     1203                        return $envelope;
     1204                };
     1205
     1206                add_filter( 'wp_pre_execute_ability', $filter );
     1207
     1208                $ability = new WP_Ability( self::$test_ability_name, $args );
     1209                $result  = $ability->execute();
     1210
     1211                remove_filter( 'wp_pre_execute_ability', $filter );
     1212
     1213                $this->assertSame( $envelope, $result, 'Object from filter is returned as-is; WP_Filter_Sentinel keeps it distinct from the default.' );
     1214        }
     1215
     1216        /**
     1217         * Tests that returning a WP_Error from wp_pre_execute_ability short-circuits with the error.
     1218         *
     1219         * @ticket 64989
     1220         */
     1221        public function test_pre_execute_ability_filter_wp_error_short_circuits() {
     1222                $args = array_merge(
     1223                        self::$test_ability_properties,
     1224                        array(
     1225                                'execute_callback' => static function (): int {
     1226                                        return 1;
     1227                                },
     1228                        )
     1229                );
     1230
     1231                $filter = static function () {
     1232                        return new WP_Error( 'pre_short_circuit', 'Cached error.' );
     1233                };
     1234
     1235                add_filter( 'wp_pre_execute_ability', $filter );
     1236
     1237                $ability = new WP_Ability( self::$test_ability_name, $args );
     1238                $result  = $ability->execute();
     1239
     1240                remove_filter( 'wp_pre_execute_ability', $filter );
     1241
     1242                $this->assertInstanceOf( WP_Error::class, $result, 'WP_Error from filter should be returned as-is.' );
     1243                $this->assertSame( 'pre_short_circuit', $result->get_error_code() );
     1244        }
     1245
     1246        /**
     1247         * Tests that the wp_ability_execute_result filter can transform the result, with all
     1248         * filter args verified via args-as-guards.
     1249         *
     1250         * @ticket 64989
     1251         */
     1252        public function test_execute_result_filter_can_transform_result() {
     1253                $args = array_merge(
     1254                        self::$test_ability_properties,
     1255                        array(
     1256                                'input_schema'     => array(
     1257                                        'type'        => 'integer',
     1258                                        'description' => 'Test input integer.',
     1259                                        'required'    => true,
     1260                                ),
     1261                                'execute_callback' => static function ( int $input ): int {
     1262                                        return $input * 2;
     1263                                },
     1264                        )
     1265                );
     1266
     1267                $filter = static function ( $result, $ability_name, $input, $ability ) {
     1268                        if ( 10 !== $result ) {
     1269                                return $result;
     1270                        }
     1271                        if ( self::$test_ability_name !== $ability_name || 5 !== $input || ! $ability instanceof WP_Ability ) {
     1272                                return $result;
     1273                        }
     1274                        return 99;
     1275                };
     1276
     1277                add_filter( 'wp_ability_execute_result', $filter, 10, 4 );
     1278
     1279                $ability = new WP_Ability( self::$test_ability_name, $args );
     1280                $result  = $ability->execute( 5 );
     1281
     1282                remove_filter( 'wp_ability_execute_result', $filter, 10 );
     1283
     1284                $this->assertSame( 99, $result, 'Filter received expected args and transformed the result.' );
     1285        }
     1286
     1287        /**
     1288         * Tests that the wp_ability_execute_result filter can repair an invalid execute result so
     1289         * output validation passes — also proves the filter runs before output validation.
     1290         *
     1291         * @ticket 64989
     1292         */
     1293        public function test_execute_result_filter_can_fix_invalid_output() {
     1294                $args = array_merge(
     1295                        self::$test_ability_properties,
     1296                        array(
     1297                                'execute_callback' => static function (): string {
     1298                                        return 'not-a-number';
     1299                                },
     1300                        )
     1301                );
     1302
     1303                $filter = static function () {
     1304                        return 42;
     1305                };
     1306
     1307                add_filter( 'wp_ability_execute_result', $filter );
     1308
     1309                $ability = new WP_Ability( self::$test_ability_name, $args );
     1310                $result  = $ability->execute();
     1311
     1312                remove_filter( 'wp_ability_execute_result', $filter );
     1313
     1314                $this->assertSame( 42, $result, 'Filter should repair invalid output before validation runs.' );
     1315        }
     1316
     1317        /**
     1318         * Tests that the wp_ability_execute_result filter runs before the wp_after_execute_ability action.
     1319         *
     1320         * @ticket 64989
     1321         */
     1322        public function test_execute_result_filter_runs_before_after_execute_action() {
     1323                $order = array();
     1324
     1325                $args = array_merge(
     1326                        self::$test_ability_properties,
     1327                        array(
     1328                                'execute_callback' => static function (): int {
     1329                                        return 1;
     1330                                },
     1331                        )
     1332                );
     1333
     1334                $filter = static function ( $result ) use ( &$order ) {
     1335                        $order[] = 'filter';
     1336                        return $result;
     1337                };
     1338
     1339                $action = static function () use ( &$order ) {
     1340                        $order[] = 'action';
     1341                };
     1342
     1343                add_filter( 'wp_ability_execute_result', $filter );
     1344                add_action( 'wp_after_execute_ability', $action );
     1345
     1346                $ability = new WP_Ability( self::$test_ability_name, $args );
     1347                $ability->execute();
     1348
     1349                remove_filter( 'wp_ability_execute_result', $filter );
     1350                remove_action( 'wp_after_execute_ability', $action );
     1351
     1352                $this->assertSame( array( 'filter', 'action' ), $order, 'execute_result filter must run before wp_after_execute_ability action.' );
     1353        }
     1354
     1355        /**
     1356         * Tests that the wp_ability_execute_result filter receives a WP_Error from the execute
     1357         * callback and can pass it through (verified via args-as-guards on the WP_Error code).
     1358         *
     1359         * @ticket 64989
     1360         */
     1361        public function test_execute_result_filter_receives_wp_error_from_do_execute() {
     1362                $args = array_merge(
     1363                        self::$test_ability_properties,
     1364                        array(
     1365                                'execute_callback' => static function () {
     1366                                        return new WP_Error( 'execute_failed', 'Something went wrong.' );
     1367                                },
     1368                        )
     1369                );
     1370
     1371                $filter = static function ( $result ) {
     1372                        if ( ! is_wp_error( $result ) || 'execute_failed' !== $result->get_error_code() ) {
     1373                                return new WP_Error( 'unexpected_input' );
     1374                        }
     1375                        return $result;
     1376                };
     1377
     1378                add_filter( 'wp_ability_execute_result', $filter );
     1379
     1380                $ability = new WP_Ability( self::$test_ability_name, $args );
     1381                $result  = $ability->execute();
     1382
     1383                remove_filter( 'wp_ability_execute_result', $filter );
     1384
     1385                $this->assertInstanceOf( WP_Error::class, $result );
     1386                $this->assertSame( 'execute_failed', $result->get_error_code(), 'Filter saw the expected WP_Error and passed it through.' );
     1387        }
    8291388}
  • trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php

    r62094 r62397  
    904904
    905905        /**
     906         * Tests that a normalization filter error defaults to a 400 REST response.
     907         *
     908         * @ticket 64989
     909         */
     910        public function test_normalize_input_filter_error_defaults_to_bad_request_status(): void {
     911                $filter = static function ( $input ) {
     912                        return new WP_Error( 'normalize_rejected', 'Rejected input.' );
     913                };
     914
     915                add_filter( 'wp_ability_normalize_input', $filter );
     916
     917                $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' );
     918                $request->set_header( 'Content-Type', 'application/json' );
     919                $request->set_body(
     920                        wp_json_encode(
     921                                array(
     922                                        'input' => array(
     923                                                'a' => 5,
     924                                                'b' => 3,
     925                                        ),
     926                                )
     927                        )
     928                );
     929
     930                $response = $this->server->dispatch( $request );
     931
     932                remove_filter( 'wp_ability_normalize_input', $filter );
     933
     934                $this->assertSame( 400, $response->get_status() );
     935                $data = $response->get_data();
     936                $this->assertSame( 'normalize_rejected', $data['code'] );
     937                $this->assertSame( 'Rejected input.', $data['message'] );
     938        }
     939
     940        /**
     941         * Tests that a normalization filter error with custom status keeps that status.
     942         *
     943         * @ticket 64989
     944         */
     945        public function test_normalize_input_filter_error_preserves_custom_status(): void {
     946                $filter = static function ( $input ) {
     947                        return new WP_Error(
     948                                'normalize_unprocessable',
     949                                'Input cannot be normalized.',
     950                                array( 'status' => 422 )
     951                        );
     952                };
     953
     954                add_filter( 'wp_ability_normalize_input', $filter );
     955
     956                $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' );
     957                $request->set_header( 'Content-Type', 'application/json' );
     958                $request->set_body(
     959                        wp_json_encode(
     960                                array(
     961                                        'input' => array(
     962                                                'a' => 5,
     963                                                'b' => 3,
     964                                        ),
     965                                )
     966                        )
     967                );
     968
     969                $response = $this->server->dispatch( $request );
     970
     971                remove_filter( 'wp_ability_normalize_input', $filter );
     972
     973                $this->assertSame( 422, $response->get_status() );
     974                $data = $response->get_data();
     975                $this->assertSame( 'normalize_unprocessable', $data['code'] );
     976                $this->assertSame( 'Input cannot be normalized.', $data['message'] );
     977        }
     978
     979        /**
    906980         * Test ability without annotations defaults to POST method.
    907981         *
Note: See TracChangeset for help on using the changeset viewer.