Make WordPress Core

Changeset 62397


Ignore:
Timestamp:
05/21/2026 07:56:31 AM (45 hours 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.