diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 4b95cddf5b111..5f3001a680ff0 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -404,7 +404,7 @@ function wp_get_ability( string $name ): ?WP_Ability { * Filtering pipeline (executed in order): * * 1. Declarative filters (`category`, `namespace`, `meta`) — per-item, AND logic between - * arg types, OR logic within multi-value `category` arrays. + * arg types. * 2. `item_include_callback` — per-item, caller-scoped. Return true to include, false to exclude. * 3. `wp_get_abilities_item_include` filter — per-item, ecosystem-scoped. Plugins can enforce * universal inclusion rules regardless of what the caller passed. @@ -421,9 +421,6 @@ function wp_get_ability( string $name ): ?WP_Ability { * // Filter by category. * $abilities = wp_get_abilities( array( 'category' => 'content' ) ); * - * // Filter by multiple categories (OR logic). - * $abilities = wp_get_abilities( array( 'category' => array( 'content', 'settings' ) ) ); - * * // Filter by namespace. * $abilities = wp_get_abilities( array( 'namespace' => 'woocommerce' ) ); * @@ -466,9 +463,8 @@ function wp_get_ability( string $name ): ?WP_Ability { * @param array $args { * Optional. Arguments to filter the returned abilities. Default empty array (returns all). * - * @type string|string[] $category Filter by category slug. A single string or an array of - * slugs — abilities matching any of the given slugs are - * included (OR logic within this arg type). + * @type string $category Filter by category slug. Only abilities whose category + * exactly matches the given slug are included. * @type string $namespace Filter by ability namespace prefix. Pass the namespace * without a trailing slash, e.g. `'woocommerce'` matches * `'woocommerce/create-order'`. @@ -495,7 +491,7 @@ function wp_get_abilities( array $args = array() ): array { $abilities = $registry->get_all_registered(); - $category = isset( $args['category'] ) ? (array) $args['category'] : array(); + $category = isset( $args['category'] ) && is_string( $args['category'] ) ? $args['category'] : ''; $namespace = isset( $args['namespace'] ) && is_string( $args['namespace'] ) ? rtrim( $args['namespace'], '/' ) . '/' : ''; $meta = isset( $args['meta'] ) && is_array( $args['meta'] ) ? $args['meta'] : array(); $item_include_callback = isset( $args['item_include_callback'] ) && is_callable( $args['item_include_callback'] ) ? $args['item_include_callback'] : null; @@ -504,8 +500,8 @@ function wp_get_abilities( array $args = array() ): array { $matched = array(); foreach ( $abilities as $name => $ability ) { - // Step 1a: Filter by category (OR logic within the arg). - if ( ! empty( $category ) && ! in_array( $ability->get_category(), $category, true ) ) { + // Step 1a: Filter by category. + if ( '' !== $category && $ability->get_category() !== $category ) { continue; } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index 9fd251815b383..b3d94be28c4f3 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -98,6 +98,11 @@ public function get_items( $request ) { $query_args['namespace'] = $request['namespace']; } + if ( ! empty( $request['meta'] ) ) { + // Merge caller meta first so the forced show_in_rest filter wins. This keeps a caller from using meta to reveal abilities hidden from REST. + $query_args['meta'] = array_merge( $request['meta'], $query_args['meta'] ); + } + $abilities = wp_get_abilities( $query_args ); $page = $request['page']; @@ -447,9 +452,23 @@ public function get_item_schema(): array { 'type' => 'object', 'properties' => array( 'annotations' => array( - 'description' => __( 'Annotations for the ability.' ), - 'type' => array( 'boolean', 'null' ), - 'default' => null, + 'description' => __( 'Behavioral annotations for the ability.' ), + 'type' => 'object', + 'properties' => array( + 'readonly' => array( + 'description' => __( 'Whether the ability does not modify its environment.' ), + 'type' => array( 'boolean', 'null' ), + ), + 'destructive' => array( + 'description' => __( 'Whether the ability may perform destructive updates to its environment.' ), + 'type' => array( 'boolean', 'null' ), + ), + 'idempotent' => array( + 'description' => __( 'Whether repeated calls with the same arguments have no additional effect.' ), + 'type' => array( 'boolean', 'null' ), + ), + ), + 'additionalProperties' => true, ), ), 'context' => array( 'view', 'edit' ), @@ -469,7 +488,7 @@ public function get_item_schema(): array { * @return array Collection parameters. */ public function get_collection_params(): array { - return array( + $query_params = array( 'context' => $this->get_context_param( array( 'default' => 'view' ) ), 'page' => array( 'description' => __( 'Current page of the collection.' ), @@ -496,6 +515,46 @@ public function get_collection_params(): array { 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ), + 'meta' => array( + 'description' => __( 'Limit results to abilities matching all of the given meta fields.' ), + 'type' => 'object', + 'properties' => array( + // show_in_rest is omitted on purpose. It is forced on and cannot be filtered by a caller. + 'annotations' => array( + 'description' => __( 'Limit results to abilities matching the given behavioral annotations.' ), + 'type' => 'object', + 'properties' => array( + 'readonly' => array( + 'description' => __( 'Whether the ability does not modify its environment.' ), + 'type' => array( 'boolean', 'null' ), + ), + 'destructive' => array( + 'description' => __( 'Whether the ability may perform destructive updates to its environment.' ), + 'type' => array( 'boolean', 'null' ), + ), + 'idempotent' => array( + 'description' => __( 'Whether repeated calls with the same arguments have no additional effect.' ), + 'type' => array( 'boolean', 'null' ), + ), + ), + 'additionalProperties' => true, + ), + ), + 'additionalProperties' => true, + ), ); + + /** + * Filters REST API collection parameters for the abilities controller. + * + * Use this to declare the schema type of a custom meta key. A declared + * type lets REST coerce a query-string value, for example "true" to a + * boolean, before the meta filter matches it. + * + * @since 7.1.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + */ + return apply_filters( 'rest_abilities_collection_params', $query_params ); } } diff --git a/tests/phpunit/tests/abilities-api/wpGetAbilities.php b/tests/phpunit/tests/abilities-api/wpGetAbilities.php index db3fc6856beaf..475ef341d869f 100644 --- a/tests/phpunit/tests/abilities-api/wpGetAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpGetAbilities.php @@ -39,6 +39,13 @@ public function set_up(): void { 'description' => 'Text operations.', ) ); + wp_register_ability_category( + 'media', + array( + 'label' => 'Media', + 'description' => 'Media operations.', + ) + ); } /** @@ -53,6 +60,7 @@ public function tear_down(): void { wp_unregister_ability_category( 'math' ); wp_unregister_ability_category( 'text' ); + wp_unregister_ability_category( 'media' ); parent::tear_down(); } @@ -135,15 +143,19 @@ static function ( WP_Ability $a ) { } /** - * Tests that passing an array of categories uses OR logic. + * Tests that a non-string category is ignored rather than treated as a multi-value filter. + * + * The declarative filters accept a single slug. Anything other than a string is ignored, + * matching how the `namespace` and `meta` args guard their own types. * * @ticket 64990 */ - public function test_filter_by_category_array_uses_or_logic(): void { + public function test_filter_by_non_string_category_is_ignored(): void { $this->simulate_wp_abilities_init(); $this->register_test_ability( 'test/math-add', array( 'category' => 'math' ) ); $this->register_test_ability( 'test/text-upper', array( 'category' => 'text' ) ); + $this->register_test_ability( 'test/media-crop', array( 'category' => 'media' ) ); $result = wp_get_abilities( array( 'category' => array( 'math', 'text' ) ) ); $names = array_map( @@ -155,6 +167,7 @@ static function ( WP_Ability $a ) { $this->assertContains( 'test/math-add', $names ); $this->assertContains( 'test/text-upper', $names ); + $this->assertContains( 'test/media-crop', $names ); } /** diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 20a773bea1628..56987fa57e36b 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -142,6 +142,30 @@ private function register_test_ability( string $name, array $args ): void { array_pop( $wp_current_filter ); } + /** + * Helper to register an ability with a custom boolean meta key. + * + * The `featured` key stands in for any plugin-defined meta. It is not part + * of the well-defined annotations, so the meta schema does not declare its + * type by default. + */ + private function register_featured_ability(): void { + $this->register_test_ability( + 'test/featured', + array( + 'label' => 'Featured', + 'description' => 'Declares a custom boolean meta value.', + 'category' => 'general', + 'execute_callback' => '__return_true', + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + 'featured' => true, + ), + ) + ); + } + /** * Register test abilities for testing. */ @@ -828,6 +852,199 @@ public function test_filter_by_namespace_still_respects_show_in_rest(): void { $this->assertNotContains( 'test/not-show-in-rest', $names ); } + /** + * Test filtering abilities by a well-defined behavioral annotation. + * + * The 'test/system-info' fixture is the only ability marked read only. The + * value is passed as a string, the way it arrives over the query string, so + * this also confirms the meta schema coerces it to a boolean before matching. + * + * @ticket 64990 + */ + public function test_filter_by_annotation(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'meta', array( 'annotations' => array( 'readonly' => 'true' ) ) ); + $request->set_param( 'per_page', 100 ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $names = wp_list_pluck( $response->get_data(), 'name' ); + + $this->assertContains( 'test/system-info', $names ); + $this->assertNotContains( 'test/calculator', $names, 'Abilities not marked read only should be excluded.' ); + } + + /** + * Test that a non-matching annotation returns empty results. + * + * No fixture marks itself destructive, so the result set is empty. + * + * @ticket 64990 + */ + public function test_filter_by_non_matching_annotation(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'meta', array( 'annotations' => array( 'destructive' => true ) ) ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertEmpty( $response->get_data() ); + } + + /** + * Test filtering abilities by several meta conditions at once. + * + * All conditions must match (AND logic). + * + * @ticket 64990 + */ + public function test_filter_by_multiple_meta_conditions(): void { + $this->register_test_ability( + 'test/read-only-idempotent', + array( + 'label' => 'Read Only and Idempotent', + 'description' => 'Marked both read only and idempotent.', + 'category' => 'general', + 'execute_callback' => '__return_true', + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => true, + 'idempotent' => true, + ), + ), + ) + ); + + $this->register_test_ability( + 'test/read-only-only', + array( + 'label' => 'Read Only', + 'description' => 'Marked read only but not idempotent.', + 'category' => 'general', + 'execute_callback' => '__return_true', + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => true, + ), + ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( + 'meta', + array( + 'annotations' => array( + 'readonly' => 'true', + 'idempotent' => 'true', + ), + ) + ); + $request->set_param( 'per_page', 100 ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $names = wp_list_pluck( $response->get_data(), 'name' ); + + $this->assertContains( 'test/read-only-idempotent', $names, 'An ability matching every condition should be included.' ); + $this->assertNotContains( 'test/read-only-only', $names, 'An ability matching only one condition should be excluded.' ); + } + + /** + * Test that a caller cannot use the meta filter to reveal abilities hidden from REST. + * + * The forced `show_in_rest => true` condition must always win, even when the + * caller passes `show_in_rest => false` through the meta parameter. + * + * @ticket 64990 + */ + public function test_filter_by_meta_cannot_override_show_in_rest(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'meta', array( 'show_in_rest' => false ) ); + $request->set_param( 'per_page', 100 ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $names = wp_list_pluck( $response->get_data(), 'name' ); + $this->assertNotContains( 'test/not-show-in-rest', $names, 'A caller must not reveal hidden abilities through meta.' ); + } + + /** + * Test the default behavior for a custom meta key with no declared type. + * + * Open-ended meta keys arrive over the query string as strings. The meta + * schema declares only the well-defined annotations, so a custom key such as + * `featured` has no declared type. REST leaves the value "true" as a string, + * and the strict meta match never equals the stored boolean. The ability is + * excluded. + * + * @ticket 64990 + */ + public function test_filter_by_custom_meta_without_declared_type_is_not_coerced(): void { + $this->register_featured_ability(); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + // The value is passed as a string, the way it arrives over the query string. + $request->set_param( 'meta', array( 'featured' => 'true' ) ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $names = wp_list_pluck( $response->get_data(), 'name' ); + $this->assertNotContains( 'test/featured', $names, 'A custom meta key without a declared type should not coerce the query-string value.' ); + } + + /** + * Test that a filter can declare a custom meta key's type so its value coerces. + * + * A plugin can declare the type for its own meta key through the + * `rest_abilities_collection_params` filter. REST then coerces the value + * "true" to a boolean before matching, so the ability is included. This is + * the supported way to make a custom meta key filterable. + * + * @ticket 64990 + */ + public function test_filter_can_declare_custom_meta_type_for_coercion(): void { + $this->register_featured_ability(); + + // Declare the type for the custom meta key so REST coerces the value first. + add_filter( + 'rest_abilities_collection_params', + static function ( array $query_params ): array { + $query_params['meta']['properties']['featured'] = array( + 'type' => array( 'boolean', 'null' ), + ); + return $query_params; + } + ); + + // Re-register the routes on a fresh server so the collection parameters pick up the filter. + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + do_action( 'rest_api_init' ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + // The value is passed as a string, the way it arrives over the query string. + $request->set_param( 'meta', array( 'featured' => 'true' ) ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $names = wp_list_pluck( $response->get_data(), 'name' ); + $this->assertContains( 'test/featured', $names, 'A declared schema type should coerce the query-string value before matching.' ); + } + /** * Test that schema keywords outside the allow-list are stripped from ability schemas in REST response. * diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..eb44f84c18dfc 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12655,6 +12655,42 @@ mockedApiResponse.Schema = { "description": "Limit results to abilities in a specific namespace.", "type": "string", "required": false + }, + "meta": { + "description": "Limit results to abilities matching all of the given meta fields.", + "type": "object", + "properties": { + "annotations": { + "description": "Limit results to abilities matching the given behavioral annotations.", + "type": "object", + "properties": { + "readonly": { + "description": "Whether the ability does not modify its environment.", + "type": [ + "boolean", + "null" + ] + }, + "destructive": { + "description": "Whether the ability may perform destructive updates to its environment.", + "type": [ + "boolean", + "null" + ] + }, + "idempotent": { + "description": "Whether repeated calls with the same arguments have no additional effect.", + "type": [ + "boolean", + "null" + ] + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true, + "required": false } } }