From 4dfc347ff12f1042f40a5decdfaee2c95319ca9c Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 17 Jun 2026 00:17:40 +0100 Subject: [PATCH 01/69] Abilities API: Add a core/content ability Adds a read-only `core/content` ability that retrieves one or more posts of a post type exposed to abilities via a new `show_in_abilities` post type argument (enabled for `post` and `page` by default). Fetch a single post by ID or by slug, or query multiple posts filtered by post type, status, author, or parent, selecting a support-aware set of fields per post. Permissions follow the REST posts model: a coarse status/capability gate plus an authoritative per-post read_post check, with password-protected content withheld from users who cannot edit the post and a uniform not-found response to avoid leaking the existence of posts. --- src/wp-includes/abilities.php | 12 + .../abilities/class-wp-content-abilities.php | 703 ++++++++++++++++++ src/wp-includes/class-wp-post-type.php | 13 + src/wp-includes/post.php | 7 + .../wpRegisterCoreContentAbility.php | 582 +++++++++++++++ .../wpRestAbilitiesContentController.php | 192 +++++ 6 files changed, 1509 insertions(+) create mode 100644 src/wp-includes/abilities/class-wp-content-abilities.php create mode 100644 tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php create mode 100644 tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 5fa42fea2befb..4411361f79d86 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -9,6 +9,7 @@ declare( strict_types = 1 ); +require_once __DIR__ . '/abilities/class-wp-content-abilities.php'; require_once __DIR__ . '/abilities/class-wp-abilities-settings.php'; /** @@ -34,6 +35,14 @@ function wp_register_core_ability_categories(): void { ) ); + wp_register_ability_category( + 'content', + array( + 'label' => __( 'Content' ), + 'description' => __( 'Abilities that retrieve or manage posts and other content.' ), + ) + ); + wp_register_ability_category( 'uncategorized', array( @@ -366,4 +375,7 @@ function wp_register_core_abilities(): void { // Register the settings abilities (currently the read-only `core/settings-get`). ( new WP_Abilities_Settings() )->register(); + + // Register the content abilities (currently the read-only `core/content`). + WP_Content_Abilities::register(); } diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php new file mode 100644 index 0000000000000..d6a94cbd4f7f0 --- /dev/null +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -0,0 +1,703 @@ + __( 'Get Content' ), + 'description' => __( 'Retrieves one or more posts of a post type exposed to abilities. Fetch a single post by ID or by slug, or query multiple posts filtered by post type, status, author, or parent. Returns a basic, support-aware set of fields per post.' ), + 'category' => self::CATEGORY, + 'input_schema' => self::get_content_input_schema( $post_types, $statuses ), + 'output_schema' => self::get_content_output_schema(), + 'execute_callback' => array( self::class, 'execute_get_content' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + // Opt into REST-level pagination: query mode accepts `page`/`per_page` + // and returns `total`/`total_pages`, which the run controller turns into + // the standard X-WP-Total / X-WP-TotalPages response headers. + 'pagination' => true, + ), + ) + ); + } + + /** + * Permission callback for the `core/content` ability. + * + * Implements defense in depth: this gate decides whether the request may proceed at + * all (coarse, by post type capabilities and requested statuses), while the per-post + * `read_post` meta capability check in {@see self::execute_get_content()} is the + * authoritative, row-level enforcement of author-scoped visibility. + * + * @since 7.1.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return bool True if the request may proceed, false otherwise. + */ + public static function check_permission( $input = array() ): bool { + $input = is_array( $input ) ? $input : array(); + $exposed = self::get_exposed_post_types(); + + // Single-post mode (by ID). + if ( ! empty( $input['id'] ) ) { + $post = get_post( (int) $input['id'] ); + + /* + * For a missing post, an unexposed post type, or a post type that does not + * match the requested one, fall back to a generic capability check rather + * than a row-level check on a guessed ID, so the response cannot be used to + * enumerate IDs or probe post-type membership. Execution returns a uniform + * 404 in these cases. + */ + if ( ! $post + || ! isset( $exposed[ $post->post_type ] ) + || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) + ) { + return current_user_can( 'read' ); + } + + return current_user_can( 'read_post', $post->ID ); + } + + // Query / slug mode requires an exposed post type. + $post_type = isset( $input['post_type'] ) ? (string) $input['post_type'] : ''; + if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { + return false; + } + + $post_type_object = $exposed[ $post_type ]; + + // Base gate: must be able to read this post type at all. + if ( ! current_user_can( $post_type_object->cap->read ?? 'read' ) ) { + return false; + } + + $statuses = self::normalize_statuses( $input ); + + // Only published posts requested: always allowed for readers. + if ( array( 'publish' ) === $statuses ) { + return true; + } + + // Editors/authors of this post type may request any status set. + if ( current_user_can( $post_type_object->cap->edit_posts ?? 'edit_posts' ) ) { + return true; + } + + // Otherwise, private posts are allowed only with read_private_posts. + if ( current_user_can( $post_type_object->cap->read_private_posts ?? 'read_private_posts' ) ) { + foreach ( $statuses as $status ) { + if ( 'private' !== $status && 'publish' !== $status ) { + return false; + } + } + return true; + } + + return false; + } + + /** + * Executes the `core/content` ability. + * + * @since 7.1.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return array|WP_Error A map with a `posts` list, or a WP_Error on failure. + */ + public static function execute_get_content( $input = array() ) { + $input = is_array( $input ) ? $input : array(); + $exposed = self::get_exposed_post_types(); + $fields = self::normalize_fields( $input ); + + // Single-post mode (by ID). + if ( ! empty( $input['id'] ) ) { + $post = get_post( (int) $input['id'] ); + + if ( ! $post + || ! isset( $exposed[ $post->post_type ] ) + || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) + || ! current_user_can( 'read_post', $post->ID ) + ) { + return self::not_found_error(); + } + + return array( + 'posts' => array( self::format_post( $post, $fields ) ), + 'total' => 1, + 'total_pages' => 1, + ); + } + + // Query / slug mode. + $post_type = isset( $input['post_type'] ) ? (string) $input['post_type'] : ''; + if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { + return self::not_found_error(); + } + + $per_page = self::normalize_per_page( $input ); + $page = isset( $input['page'] ) ? max( 1, (int) $input['page'] ) : 1; + + $query_args = array( + 'post_type' => $post_type, + 'post_status' => self::normalize_statuses( $input ), + 'posts_per_page' => $per_page, + 'paged' => $page, + 'ignore_sticky_posts' => true, + ); + + if ( ! empty( $input['slug'] ) ) { + $query_args['name'] = sanitize_title( (string) $input['slug'] ); + } + + if ( ! empty( $input['author'] ) ) { + $query_args['author'] = (int) $input['author']; + } + + if ( isset( $input['parent'] ) ) { + $query_args['post_parent'] = (int) $input['parent']; + } + + $query = new WP_Query( $query_args ); + + $posts = array(); + foreach ( $query->posts as $post ) { + // Authoritative, row-level visibility check (author/status scoped). + if ( ! current_user_can( 'read_post', $post->ID ) ) { + continue; + } + $posts[] = self::format_post( $post, $fields ); + } + + return array( + 'posts' => $posts, + 'total' => (int) $query->found_posts, + 'total_pages' => (int) $query->max_num_pages, + ); + } + + /** + * Normalizes the requested per-page value to the supported bounds. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return int The clamped per-page value. + */ + protected static function normalize_per_page( array $input ): int { + $per_page = isset( $input['per_page'] ) ? (int) $input['per_page'] : self::DEFAULT_PER_PAGE; + + return max( 1, min( self::MAX_PER_PAGE, $per_page ) ); + } + + /** + * Returns the post types exposed through the Abilities API, keyed by name. + * + * Only post types whose `show_in_abilities` argument is truthy are exposed. + * + * @since 7.1.0 + * + * @return array Exposed post type objects keyed by name. + */ + protected static function get_exposed_post_types(): array { + $exposed = array(); + + foreach ( get_post_types( array(), 'objects' ) as $post_type_object ) { + if ( empty( $post_type_object->show_in_abilities ) ) { + continue; + } + $exposed[ $post_type_object->name ] = $post_type_object; + } + + return $exposed; + } + + /** + * Returns the post statuses that may be requested through the ability. + * + * Internal statuses (auto-draft, inherit, trash) are excluded. + * + * @since 7.1.0 + * + * @return string[] List of public, non-internal post status slugs. + */ + protected static function get_available_statuses(): array { + return array_values( get_post_stati( array( 'internal' => false ) ) ); + } + + /** + * Normalizes the requested statuses to a non-empty, sanitized list defaulting to publish. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return string[] Normalized list of post status slugs. + */ + protected static function normalize_statuses( array $input ): array { + $statuses = $input['status'] ?? array( 'publish' ); + if ( ! is_array( $statuses ) || array() === $statuses ) { + return array( 'publish' ); + } + + return array_map( 'sanitize_key', $statuses ); + } + + /** + * Normalizes the requested fields to the supported set, defaulting to all fields. + * + * An empty or absent `fields` value selects every field. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return string[] List of requested field names. + */ + protected static function normalize_fields( array $input ): array { + if ( empty( $input['fields'] ) || ! is_array( $input['fields'] ) ) { + return self::FIELDS; + } + + $fields = array_intersect( self::FIELDS, array_map( 'strval', $input['fields'] ) ); + + return array() === $fields ? self::FIELDS : array_values( $fields ); + } + + /** + * Builds the input schema for the `core/content` ability. + * + * @since 7.1.0 + * + * @param string[] $post_types Exposed post type names. + * @param string[] $statuses Requestable post status slugs. + * @return array The input JSON Schema. + */ + protected static function get_content_input_schema( array $post_types, array $statuses ): array { + return array( + 'type' => 'object', + 'default' => array(), + // `post_type` is required unless a single post is requested by `id`. + 'anyOf' => array( + array( 'required' => array( 'id' ) ), + array( 'required' => array( 'post_type' ) ), + ), + 'properties' => array( + 'post_type' => array( + 'type' => 'string', + 'enum' => $post_types, + 'description' => __( 'Post type to retrieve. Required unless `id` is provided.' ), + ), + 'id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Retrieve a single post by ID. When provided, `post_type` is optional.' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'Retrieve posts by slug. Requires `post_type`, as slugs are not unique across post types.' ), + ), + 'status' => array( + 'type' => 'array', + 'uniqueItems' => true, + 'default' => array( 'publish' ), + 'items' => array( + 'type' => 'string', + 'enum' => $statuses, + ), + 'description' => __( 'Filter by one or more post statuses. Defaults to publish. Non-published statuses require the appropriate capabilities.' ), + ), + 'author' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Filter by author user ID.' ), + ), + 'parent' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Filter by parent post ID, for hierarchical post types. Use 0 for top-level posts.' ), + ), + 'fields' => array( + 'type' => 'array', + 'uniqueItems' => true, + 'items' => array( + 'type' => 'string', + 'enum' => self::FIELDS, + ), + 'description' => __( 'Limit each returned post to these fields. If omitted, all supported fields are returned.' ), + ), + 'page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'default' => 1, + 'description' => __( 'Page of results to return in query mode. Ignored when retrieving a single post by ID.' ), + ), + 'per_page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => self::MAX_PER_PAGE, + 'default' => self::DEFAULT_PER_PAGE, + 'description' => __( 'Maximum number of posts to return per page in query mode.' ), + ), + ), + 'additionalProperties' => false, + ); + } + + /** + * Builds the output schema for the `core/content` ability. + * + * No field is marked required because the `fields` input lets the caller request any + * subset, and a field is only present when its post type supports it. + * + * @since 7.1.0 + * + * @return array The output JSON Schema. + */ + protected static function get_content_output_schema(): array { + $post_schema = array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The post ID.' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'The post type.' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'The post status.' ), + ), + 'date' => array( + 'type' => 'string', + 'description' => __( 'The publication date, in ISO 8601 format (GMT).' ), + ), + 'modified' => array( + 'type' => 'string', + 'description' => __( 'The last modified date, in ISO 8601 format (GMT).' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'The post slug.' ), + ), + 'link' => array( + 'type' => 'string', + 'description' => __( 'The permalink URL.' ), + ), + 'title' => array( + 'type' => 'string', + 'description' => __( 'The post title. Present when the post type supports titles.' ), + ), + 'excerpt' => array( + 'type' => 'string', + 'description' => __( 'The post excerpt. Present when the post type supports excerpts. Empty when withheld for a password-protected post.' ), + ), + 'raw_content' => array( + 'type' => 'string', + 'description' => __( 'The raw, unfiltered post content (block markup). Present when the post type supports the editor. Empty when withheld for a password-protected post.' ), + ), + 'author' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The author user ID.' ), + ), + 'display_name' => array( + 'type' => 'string', + 'description' => __( 'The author display name.' ), + ), + ), + 'description' => __( 'The post author. Present when the post type supports authors.' ), + ), + 'parent' => array( + 'type' => 'integer', + 'description' => __( 'The parent post ID. Present for hierarchical post types.' ), + ), + ), + ); + + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'posts' => array( + 'type' => 'array', + 'description' => __( 'The posts matching the request. A single-element list when requested by ID.' ), + 'items' => $post_schema, + ), + 'total' => array( + 'type' => 'integer', + 'description' => __( 'Total number of posts matching the query, across all pages. Surfaced over REST as the X-WP-Total header.' ), + ), + 'total_pages' => array( + 'type' => 'integer', + 'description' => __( 'Total number of pages available. Surfaced over REST as the X-WP-TotalPages header.' ), + ), + ), + ); + } + + /** + * Formats a post into the ability output shape. + * + * Only the requested fields that the post type supports are included. Content and + * excerpt are withheld for password-protected posts unless the current user can edit + * the post, mirroring the REST API behavior. + * + * @since 7.1.0 + * + * @param WP_Post $post The post object. + * @param string[] $fields The requested field names. + * @return array The formatted post data. + */ + protected static function format_post( WP_Post $post, array $fields ): array { + $type = $post->post_type; + $wants = static function ( string $field ) use ( $fields ): bool { + return in_array( $field, $fields, true ); + }; + $protected = post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ); + + $data = array(); + + if ( $wants( 'id' ) ) { + $data['id'] = (int) $post->ID; + } + if ( $wants( 'type' ) ) { + $data['type'] = $type; + } + if ( $wants( 'status' ) ) { + $data['status'] = $post->post_status; + } + if ( $wants( 'date' ) ) { + $data['date'] = self::format_gmt_date( $post, 'date' ); + } + if ( $wants( 'modified' ) ) { + $data['modified'] = self::format_gmt_date( $post, 'modified' ); + } + if ( $wants( 'slug' ) ) { + $data['slug'] = $post->post_name; + } + if ( $wants( 'link' ) ) { + $data['link'] = (string) get_permalink( $post ); + } + + if ( $wants( 'title' ) && post_type_supports( $type, 'title' ) ) { + $data['title'] = self::get_title( $post ); + } + + if ( $wants( 'excerpt' ) && post_type_supports( $type, 'excerpt' ) ) { + $data['excerpt'] = $protected ? '' : (string) get_the_excerpt( $post ); + } + + if ( $wants( 'raw_content' ) && post_type_supports( $type, 'editor' ) ) { + $data['raw_content'] = $protected ? '' : (string) $post->post_content; + } + + if ( $wants( 'author' ) && post_type_supports( $type, 'author' ) ) { + $author = get_userdata( (int) $post->post_author ); + $data['author'] = array( + 'id' => (int) $post->post_author, + 'display_name' => $author ? $author->display_name : '', + ); + } + + if ( $wants( 'parent' ) && is_post_type_hierarchical( $type ) ) { + $data['parent'] = (int) $post->post_parent; + } + + return $data; + } + + /** + * Returns the post title with the protected/private prefixes stripped. + * + * Mirrors the REST API, which removes the "Protected: " / "Private: " prefixes for + * machine consumers while still applying the_title filters. + * + * @since 7.1.0 + * + * @param WP_Post $post The post object. + * @return string The post title. + */ + protected static function get_title( WP_Post $post ): string { + $strip = array( self::class, 'return_raw_title_format' ); + add_filter( 'protected_title_format', $strip ); + add_filter( 'private_title_format', $strip ); + $title = get_the_title( $post ); + remove_filter( 'protected_title_format', $strip ); + remove_filter( 'private_title_format', $strip ); + + return $title; + } + + /** + * Returns the raw title format, used to strip protected/private title prefixes. + * + * @since 7.1.0 + * + * @return string The unprefixed title format. + */ + public static function return_raw_title_format(): string { + return '%s'; + } + + /** + * Formats a post date field as an ISO 8601 string in GMT. + * + * Uses get_post_datetime() so that posts without a GMT timestamp (e.g. some drafts) + * still resolve to a valid date. + * + * @since 7.1.0 + * + * @param WP_Post $post The post object. + * @param string $field Either 'date' or 'modified'. + * @return string The ISO 8601 date, or an empty string if unavailable. + */ + protected static function format_gmt_date( WP_Post $post, string $field ): string { + $datetime = get_post_datetime( $post, $field, 'gmt' ); + if ( $datetime ) { + return $datetime->format( 'c' ); + } + + // Fallback for posts without a resolvable timestamp. + $local = 'modified' === $field ? $post->post_modified : $post->post_date; + $timestamp = mysql2date( 'U', $local, false ); + + return $timestamp ? gmdate( 'c', (int) $timestamp ) : ''; + } + + /** + * Builds the uniform not-found error. + * + * The same generic 404 is returned for a missing post, an unexposed post type, a + * type mismatch, or a post the user cannot read, so the ability cannot be used to + * enumerate IDs or probe post-type membership. + * + * @since 7.1.0 + * + * @return WP_Error The not-found error. + */ + protected static function not_found_error(): WP_Error { + return new WP_Error( + 'content_not_found', + __( 'The requested content was not found.' ), + array( 'status' => 404 ) + ); + } +} diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index f1919062686d0..f5b7b60a6df01 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -373,6 +373,18 @@ final class WP_Post_Type { */ public $show_in_rest; + /** + * Whether this post type should be exposed through the Abilities API. + * + * Default false. When truthy, the post type's readable posts can be retrieved + * through the read-only `core/content` ability, subject to per-post capability + * checks. May be an array to enable specific operations in the future. + * + * @since 7.1.0 + * @var bool|array $show_in_abilities + */ + public $show_in_abilities; + /** * The base path for this post type's REST API endpoints. * @@ -553,6 +565,7 @@ public function set_props( $args ) { 'can_export' => true, 'delete_with_user' => null, 'show_in_rest' => false, + 'show_in_abilities' => false, 'rest_base' => false, 'rest_namespace' => false, 'rest_controller_class' => false, diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 8d9b58c037501..ee83b5a3da21c 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -50,6 +50,7 @@ function create_initial_post_types() { 'post-formats', ), 'show_in_rest' => true, + 'show_in_abilities' => true, 'rest_base' => 'posts', 'rest_controller_class' => 'WP_REST_Posts_Controller', ) @@ -84,6 +85,7 @@ function create_initial_post_types() { 'revisions', ), 'show_in_rest' => true, + 'show_in_abilities' => true, 'rest_base' => 'pages', 'rest_controller_class' => 'WP_REST_Posts_Controller', ) @@ -1691,6 +1693,7 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * @since 5.0.0 The `template` and `template_lock` arguments were added. * @since 5.3.0 The `supports` argument will now accept an array of arguments for a feature. * @since 5.9.0 The `rest_namespace` argument was added. + * @since 7.1.0 The `show_in_abilities` argument was added. * * @global array $wp_post_types List of post types. * @@ -1736,6 +1739,10 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * of $show_in_menu. * @type bool $show_in_rest Whether to include the post type in the REST API. Set this to true * for the post type to be available in the block editor. + * @type bool|array $show_in_abilities Whether to expose this post type through the Abilities API, so its + * readable posts can be retrieved via the read-only `core/content` + * ability (subject to per-post capability checks). Accepts a boolean + * or an array reserved for enabling specific operations. Default false. * @type string $rest_base To change the base URL of REST API route. Default is $post_type. * @type string $rest_namespace To change the namespace URL of REST API route. Default is wp/v2. * @type string $rest_controller_class REST API controller class name. Default is 'WP_REST_Posts_Controller'. diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php new file mode 100644 index 0000000000000..ad539c0936df5 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -0,0 +1,582 @@ + true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor', 'excerpt', 'author' ), + ) + ); + + register_post_type( + self::HIDDEN_CPT, + array( + 'public' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + // Temporarily remove the unhook functions so we can register core abilities. + remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); + add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + /** + * Cleans up registered abilities, categories, and post types. + * + * @since 7.1.0 + */ + public static function tear_down_after_class(): void { + add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + unregister_post_type( self::EXPOSED_CPT ); + unregister_post_type( self::HIDDEN_CPT ); + + parent::tear_down_after_class(); + } + + /** + * Logs in as a user with the given role and returns the user ID. + * + * @param string $role The role to create the user with. + * @return int The new user ID. + */ + private function login_as( string $role ): int { + $user_id = self::factory()->user->create( array( 'role' => $role ) ); + wp_set_current_user( $user_id ); + return $user_id; + } + + /** + * Convenience accessor for the ability. + * + * @return WP_Ability The core/content ability. + */ + private function ability(): WP_Ability { + return wp_get_ability( 'core/content' ); + } + + /* + * ------------------------------------------------------------------------- + * Registration & schema + * ------------------------------------------------------------------------- + */ + + public function test_ability_is_registered_readonly_in_content_category(): void { + $ability = $this->ability(); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertSame( 'content', $ability->get_category() ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertTrue( $annotations['readonly'] ); + $this->assertFalse( $annotations['destructive'] ); + $this->assertTrue( $annotations['idempotent'] ); + } + + public function test_input_schema_requires_id_or_post_type(): void { + $schema = $this->ability()->get_input_schema(); + + $this->assertSame( 'object', $schema['type'] ); + $this->assertSame( + array( + array( 'required' => array( 'id' ) ), + array( 'required' => array( 'post_type' ) ), + ), + $schema['anyOf'] + ); + $this->assertFalse( $schema['additionalProperties'] ); + } + + public function test_input_schema_post_type_enum_only_includes_exposed_types(): void { + $enum = $this->ability()->get_input_schema()['properties']['post_type']['enum']; + + $this->assertContains( 'post', $enum ); + $this->assertContains( 'page', $enum ); + $this->assertContains( self::EXPOSED_CPT, $enum ); + $this->assertNotContains( self::HIDDEN_CPT, $enum ); + $this->assertNotContains( 'revision', $enum ); + } + + public function test_input_schema_status_and_fields_enums(): void { + $properties = $this->ability()->get_input_schema()['properties']; + + $status_enum = $properties['status']['items']['enum']; + $this->assertContains( 'publish', $status_enum ); + $this->assertContains( 'draft', $status_enum ); + $this->assertContains( 'private', $status_enum ); + $this->assertNotContains( 'trash', $status_enum ); + $this->assertNotContains( 'auto-draft', $status_enum ); + $this->assertSame( array( 'publish' ), $properties['status']['default'] ); + + $fields_enum = $properties['fields']['items']['enum']; + $this->assertContains( 'raw_content', $fields_enum ); + $this->assertContains( 'title', $fields_enum ); + $this->assertContains( 'author', $fields_enum ); + } + + public function test_output_schema_has_no_required_fields(): void { + $schema = $this->ability()->get_output_schema(); + $post_item = $schema['properties']['posts']['items']; + + $this->assertArrayNotHasKey( 'required', $post_item ); + $this->assertFalse( $post_item['additionalProperties'] ); + $this->assertArrayHasKey( 'raw_content', $post_item['properties'] ); + } + + /* + * ------------------------------------------------------------------------- + * Single-post retrieval + * ------------------------------------------------------------------------- + */ + + public function test_get_single_published_post_by_id(): void { + $this->login_as( 'administrator' ); + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Hello Content', + 'post_content' => 'Body here.', + 'post_status' => 'publish', + ) + ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result ); + $this->assertCount( 1, $result['posts'] ); + $this->assertSame( $post_id, $result['posts'][0]['id'] ); + $this->assertSame( 'Hello Content', $result['posts'][0]['title'] ); + $this->assertSame( 'Body here.', $result['posts'][0]['raw_content'] ); + $this->assertSame( 'post', $result['posts'][0]['type'] ); + } + + public function test_get_by_id_with_mismatched_post_type_returns_not_found(): void { + $this->login_as( 'administrator' ); + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'post_type' => 'page', + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'content_not_found', $result->get_error_code() ); + } + + public function test_get_by_missing_id_returns_generic_not_found(): void { + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( array( 'id' => 999999 ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'content_not_found', $result->get_error_code() ); + $this->assertSame( 404, $result->get_error_data()['status'] ); + } + + /* + * ------------------------------------------------------------------------- + * Query mode + * ------------------------------------------------------------------------- + */ + + public function test_query_returns_only_published_by_default(): void { + $this->login_as( 'administrator' ); + $published = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $draft = self::factory()->post->create( array( 'post_status' => 'draft' ) ); + + $result = $this->ability()->execute( array( 'post_type' => 'post' ) ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $published, $ids ); + $this->assertNotContains( $draft, $ids ); + } + + public function test_query_by_slug_requires_post_type(): void { + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( array( 'slug' => 'whatever' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + public function test_query_by_slug_within_post_type(): void { + $this->login_as( 'administrator' ); + $post_id = self::factory()->post->create( + array( + 'post_name' => 'find-me', + 'post_status' => 'publish', + ) + ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'slug' => 'find-me', + ) + ); + + $this->assertCount( 1, $result['posts'] ); + $this->assertSame( $post_id, $result['posts'][0]['id'] ); + } + + public function test_query_filters_by_author(): void { + $author_a = self::factory()->user->create( array( 'role' => 'author' ) ); + $author_b = self::factory()->user->create( array( 'role' => 'author' ) ); + $post_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'publish', + ) + ); + self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'author' => $author_a, + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertSame( array( $post_a ), $ids ); + } + + public function test_query_filters_by_parent_for_hierarchical_types(): void { + $this->login_as( 'administrator' ); + $parent = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + $child = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_parent' => $parent, + 'post_status' => 'publish', + ) + ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'page', + 'parent' => $parent, + ) + ); + + $this->assertCount( 1, $result['posts'] ); + $this->assertSame( $child, $result['posts'][0]['id'] ); + $this->assertSame( $parent, $result['posts'][0]['parent'] ); + } + + /* + * ------------------------------------------------------------------------- + * fields filter + * ------------------------------------------------------------------------- + */ + + public function test_fields_filter_limits_returned_keys(): void { + $this->login_as( 'administrator' ); + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'title' ), + ) + ); + + $this->assertSame( array( 'id', 'title' ), array_keys( $result['posts'][0] ) ); + } + + public function test_unsupported_fields_are_omitted_for_post_type(): void { + $this->login_as( 'administrator' ); + // Pages do not support excerpt by default in this CPT, but `post` does; use the + // exposed CPT which does not support `comments`/`parent` to confirm omission. + $post_id = self::factory()->post->create( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + ) + ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + // `post` is not hierarchical, so `parent` must be absent even though requested implicitly. + $this->assertArrayNotHasKey( 'parent', $result['posts'][0] ); + } + + /* + * ------------------------------------------------------------------------- + * Permissions & visibility (security) + * ------------------------------------------------------------------------- + */ + + public function test_logged_out_user_is_denied(): void { + wp_set_current_user( 0 ); + + $result = $this->ability()->execute( array( 'post_type' => 'post' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + public function test_subscriber_can_read_published_posts(): void { + $published = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $this->login_as( 'subscriber' ); + + $result = $this->ability()->execute( array( 'post_type' => 'post' ) ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $published, $ids ); + } + + public function test_subscriber_cannot_request_draft_status(): void { + $this->login_as( 'subscriber' ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + public function test_subscriber_cannot_request_private_status(): void { + $this->login_as( 'subscriber' ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'status' => array( 'private' ), + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + public function test_author_cannot_see_other_authors_drafts(): void { + $author_a = self::factory()->user->create( array( 'role' => 'author' ) ); + $author_b = self::factory()->user->create( array( 'role' => 'author' ) ); + + $draft_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'draft', + ) + ); + $draft_b = self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'draft', + ) + ); + + // Author B can pass the status gate (has edit_posts) but only sees their own draft. + wp_set_current_user( $author_b ); + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $draft_b, $ids ); + $this->assertNotContains( $draft_a, $ids ); + } + + public function test_administrator_can_read_private_posts(): void { + $private = self::factory()->post->create( array( 'post_status' => 'private' ) ); + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'status' => array( 'private' ), + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $private, $ids ); + } + + public function test_unexposed_post_type_is_rejected_by_input_schema(): void { + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( array( 'post_type' => self::HIDDEN_CPT ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + /* + * ------------------------------------------------------------------------- + * Password-protected posts + * ------------------------------------------------------------------------- + */ + + public function test_password_protected_content_withheld_from_non_editor(): void { + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'Top secret body.', + 'post_excerpt' => 'Secret excerpt.', + ) + ); + + $this->login_as( 'subscriber' ); + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'raw_content', 'excerpt' ), + ) + ); + + $this->assertSame( '', $result['posts'][0]['raw_content'] ); + $this->assertSame( '', $result['posts'][0]['excerpt'] ); + } + + public function test_password_protected_content_visible_to_editor(): void { + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'Top secret body.', + ) + ); + + $this->login_as( 'editor' ); + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'raw_content' ), + ) + ); + + $this->assertSame( 'Top secret body.', $result['posts'][0]['raw_content'] ); + } + + /* + * ------------------------------------------------------------------------- + * Pagination + * ------------------------------------------------------------------------- + */ + + public function test_query_paginates_and_reports_totals(): void { + $this->login_as( 'administrator' ); + self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $page1 = $this->ability()->execute( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 1, + ) + ); + + $this->assertCount( 2, $page1['posts'] ); + $this->assertGreaterThanOrEqual( 3, $page1['total'] ); + $this->assertSame( (int) ceil( $page1['total'] / 2 ), $page1['total_pages'] ); + + $page2 = $this->ability()->execute( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 2, + ) + ); + + $this->assertNotEmpty( $page2['posts'] ); + $this->assertSame( $page1['total'], $page2['total'] ); + } + + public function test_per_page_is_capped(): void { + $this->login_as( 'administrator' ); + + $schema = $this->ability()->get_input_schema(); + + $this->assertSame( WP_Content_Abilities::MAX_PER_PAGE, $schema['properties']['per_page']['maximum'] ); + $this->assertSame( WP_Content_Abilities::DEFAULT_PER_PAGE, $schema['properties']['per_page']['default'] ); + } + + public function test_single_post_reports_totals(): void { + $this->login_as( 'administrator' ); + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + $this->assertSame( 1, $result['total'] ); + $this->assertSame( 1, $result['total_pages'] ); + } + + public function test_ability_opts_into_pagination(): void { + $this->assertTrue( (bool) $this->ability()->get_meta_item( 'pagination', false ) ); + } +} diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php new file mode 100644 index 0000000000000..837bea2b4639a --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -0,0 +1,192 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); + add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + /** + * Cleans up registered abilities and categories. + * + * @since 7.1.0 + */ + public static function tear_down_after_class(): void { + add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + parent::tear_down_after_class(); + } + + public function set_up(): void { + parent::set_up(); + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + do_action( 'rest_api_init' ); + + wp_set_current_user( self::$admin_id ); + } + + public function tear_down(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Builds a GET run request with the given ability input. + * + * @param array $input The ability input. + * @return WP_REST_Request The request. + */ + private function run_request( array $input ): WP_REST_Request { + $request = new WP_REST_Request( 'GET', self::RUN_ROUTE ); + $request->set_query_params( array( 'input' => $input ) ); + return $request; + } + + public function test_logged_out_user_receives_401(): void { + wp_set_current_user( 0 ); + + $response = $this->server->dispatch( $this->run_request( array( 'post_type' => 'post' ) ) ); + + $this->assertSame( 401, $response->get_status() ); + } + + public function test_subscriber_requesting_drafts_receives_403(): void { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ) + ); + + $this->assertSame( 403, $response->get_status() ); + } + + public function test_admin_query_returns_published_posts(): void { + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Published via REST', + 'post_status' => 'publish', + ) + ); + + $response = $this->server->dispatch( $this->run_request( array( 'post_type' => 'post' ) ) ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'posts', $data ); + $this->assertContains( $post_id, wp_list_pluck( $data['posts'], 'id' ) ); + } + + public function test_get_single_post_by_id(): void { + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $response = $this->server->dispatch( $this->run_request( array( 'id' => $post_id ) ) ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 1, $data['posts'] ); + $this->assertSame( $post_id, $data['posts'][0]['id'] ); + } + + public function test_wrong_http_method_returns_405(): void { + $request = new WP_REST_Request( 'POST', self::RUN_ROUTE ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => array( 'post_type' => 'post' ) ) ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 405, $response->get_status() ); + $this->assertSame( 'rest_ability_invalid_method', $response->get_data()['code'] ); + } + + public function test_pagination_returns_totals_in_body(): void { + self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 1, + ) + ) + ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 2, $data['posts'] ); + $this->assertGreaterThanOrEqual( 3, $data['total'] ); + $this->assertSame( (int) ceil( $data['total'] / 2 ), $data['total_pages'] ); + } +} From c978bdc9e49cc053615e9d7e07318b9cfd1079e0 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 17 Jun 2026 15:49:11 +0100 Subject: [PATCH 02/69] Abilities API: apply core/settings review feedback to core/content. Mirrors the refinements from the core/settings review that also apply to core/content: - Memoize the exposed post types so the input schema and the permission/execute callbacks derive from a single walk of the registered post types. - Default the input schema to an empty object so the type:object default serializes as {}. - Harden input/value handling (type guards, a capability resolver, and a non-negative integer helper) against loosely-typed request data. --- .../abilities/class-wp-content-abilities.php | 96 ++++++++++++++----- 1 file changed, 73 insertions(+), 23 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index d6a94cbd4f7f0..091d445743a3b 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -81,6 +81,17 @@ class WP_Content_Abilities { */ const MAX_PER_PAGE = 100; + /** + * Post types exposed through the Abilities API, computed once at registration. + * + * Cached so the input schema and the permission/execute callbacks derive from the exact + * same set, and the post type list is only walked once per request. + * + * @since 7.1.0 + * @var array|null + */ + private static ?array $exposed_post_types = null; + /** * Registers all content abilities. * @@ -105,7 +116,10 @@ public static function register(): void { * @since 7.1.0 */ public static function register_get_content(): void { - $post_types = array_keys( self::get_exposed_post_types() ); + // Compute once; check_permission()/execute_get_content() reuse this set. + self::$exposed_post_types = self::get_exposed_post_types(); + + $post_types = array_keys( self::$exposed_post_types ); $statuses = self::get_available_statuses(); wp_register_ability( @@ -149,11 +163,11 @@ public static function register_get_content(): void { */ public static function check_permission( $input = array() ): bool { $input = is_array( $input ) ? $input : array(); - $exposed = self::get_exposed_post_types(); + $exposed = self::$exposed_post_types ?? self::get_exposed_post_types(); // Single-post mode (by ID). if ( ! empty( $input['id'] ) ) { - $post = get_post( (int) $input['id'] ); + $post = get_post( self::input_int( $input['id'] ) ); /* * For a missing post, an unexposed post type, or a post type that does not @@ -173,7 +187,7 @@ public static function check_permission( $input = array() ): bool { } // Query / slug mode requires an exposed post type. - $post_type = isset( $input['post_type'] ) ? (string) $input['post_type'] : ''; + $post_type = isset( $input['post_type'] ) && is_string( $input['post_type'] ) ? $input['post_type'] : ''; if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { return false; } @@ -181,7 +195,7 @@ public static function check_permission( $input = array() ): bool { $post_type_object = $exposed[ $post_type ]; // Base gate: must be able to read this post type at all. - if ( ! current_user_can( $post_type_object->cap->read ?? 'read' ) ) { + if ( ! current_user_can( self::capability( $post_type_object, 'read', 'read' ) ) ) { return false; } @@ -193,12 +207,12 @@ public static function check_permission( $input = array() ): bool { } // Editors/authors of this post type may request any status set. - if ( current_user_can( $post_type_object->cap->edit_posts ?? 'edit_posts' ) ) { + if ( current_user_can( self::capability( $post_type_object, 'edit_posts', 'edit_posts' ) ) ) { return true; } // Otherwise, private posts are allowed only with read_private_posts. - if ( current_user_can( $post_type_object->cap->read_private_posts ?? 'read_private_posts' ) ) { + if ( current_user_can( self::capability( $post_type_object, 'read_private_posts', 'read_private_posts' ) ) ) { foreach ( $statuses as $status ) { if ( 'private' !== $status && 'publish' !== $status ) { return false; @@ -210,6 +224,34 @@ public static function check_permission( $input = array() ): bool { return false; } + /** + * Resolves a capability name from a post type's capability object, with a fallback. + * + * @since 7.1.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @param string $name Capability key on the post type's `cap` object. + * @param string $fallback Fallback capability name if unset or non-string. + * @return string The resolved capability name. + */ + protected static function capability( WP_Post_Type $post_type_object, string $name, string $fallback ): string { + $capability = $post_type_object->cap->$name ?? $fallback; + + return is_string( $capability ) ? $capability : $fallback; + } + + /** + * Casts a raw input value to a non-negative integer. + * + * @since 7.1.0 + * + * @param mixed $value The raw input value. + * @return int The value as a non-negative integer, or 0 when not scalar. + */ + protected static function input_int( $value ): int { + return is_scalar( $value ) ? absint( $value ) : 0; + } + /** * Executes the `core/content` ability. * @@ -220,12 +262,12 @@ public static function check_permission( $input = array() ): bool { */ public static function execute_get_content( $input = array() ) { $input = is_array( $input ) ? $input : array(); - $exposed = self::get_exposed_post_types(); + $exposed = self::$exposed_post_types ?? self::get_exposed_post_types(); $fields = self::normalize_fields( $input ); // Single-post mode (by ID). if ( ! empty( $input['id'] ) ) { - $post = get_post( (int) $input['id'] ); + $post = get_post( self::input_int( $input['id'] ) ); if ( ! $post || ! isset( $exposed[ $post->post_type ] ) @@ -243,13 +285,13 @@ public static function execute_get_content( $input = array() ) { } // Query / slug mode. - $post_type = isset( $input['post_type'] ) ? (string) $input['post_type'] : ''; + $post_type = isset( $input['post_type'] ) && is_string( $input['post_type'] ) ? $input['post_type'] : ''; if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { return self::not_found_error(); } $per_page = self::normalize_per_page( $input ); - $page = isset( $input['page'] ) ? max( 1, (int) $input['page'] ) : 1; + $page = isset( $input['page'] ) ? max( 1, self::input_int( $input['page'] ) ) : 1; $query_args = array( 'post_type' => $post_type, @@ -259,22 +301,25 @@ public static function execute_get_content( $input = array() ) { 'ignore_sticky_posts' => true, ); - if ( ! empty( $input['slug'] ) ) { - $query_args['name'] = sanitize_title( (string) $input['slug'] ); + if ( ! empty( $input['slug'] ) && is_string( $input['slug'] ) ) { + $query_args['name'] = sanitize_title( $input['slug'] ); } if ( ! empty( $input['author'] ) ) { - $query_args['author'] = (int) $input['author']; + $query_args['author'] = self::input_int( $input['author'] ); } if ( isset( $input['parent'] ) ) { - $query_args['post_parent'] = (int) $input['parent']; + $query_args['post_parent'] = self::input_int( $input['parent'] ); } $query = new WP_Query( $query_args ); $posts = array(); foreach ( $query->posts as $post ) { + if ( ! $post instanceof WP_Post ) { + continue; + } // Authoritative, row-level visibility check (author/status scoped). if ( ! current_user_can( 'read_post', $post->ID ) ) { continue; @@ -294,11 +339,11 @@ public static function execute_get_content( $input = array() ) { * * @since 7.1.0 * - * @param array $input The ability input. + * @param array $input The ability input. * @return int The clamped per-page value. */ protected static function normalize_per_page( array $input ): int { - $per_page = isset( $input['per_page'] ) ? (int) $input['per_page'] : self::DEFAULT_PER_PAGE; + $per_page = isset( $input['per_page'] ) ? self::input_int( $input['per_page'] ) : self::DEFAULT_PER_PAGE; return max( 1, min( self::MAX_PER_PAGE, $per_page ) ); } @@ -343,16 +388,18 @@ protected static function get_available_statuses(): array { * * @since 7.1.0 * - * @param array $input The ability input. + * @param array $input The ability input. * @return string[] Normalized list of post status slugs. */ protected static function normalize_statuses( array $input ): array { $statuses = $input['status'] ?? array( 'publish' ); - if ( ! is_array( $statuses ) || array() === $statuses ) { + if ( ! is_array( $statuses ) ) { return array( 'publish' ); } - return array_map( 'sanitize_key', $statuses ); + $statuses = array_values( array_filter( $statuses, 'is_string' ) ); + + return array() === $statuses ? array( 'publish' ) : array_map( 'sanitize_key', $statuses ); } /** @@ -362,7 +409,7 @@ protected static function normalize_statuses( array $input ): array { * * @since 7.1.0 * - * @param array $input The ability input. + * @param array $input The ability input. * @return string[] List of requested field names. */ protected static function normalize_fields( array $input ): array { @@ -370,7 +417,8 @@ protected static function normalize_fields( array $input ): array { return self::FIELDS; } - $fields = array_intersect( self::FIELDS, array_map( 'strval', $input['fields'] ) ); + $requested = array_filter( $input['fields'], 'is_string' ); + $fields = array_intersect( self::FIELDS, $requested ); return array() === $fields ? self::FIELDS : array_values( $fields ); } @@ -387,7 +435,8 @@ protected static function normalize_fields( array $input ): array { protected static function get_content_input_schema( array $post_types, array $statuses ): array { return array( 'type' => 'object', - 'default' => array(), + // Object (not array()) so the serialized schema default is {}, consistent with type:object. + 'default' => (object) array(), // `post_type` is required unless a single post is requested by `id`. 'anyOf' => array( array( 'required' => array( 'id' ) ), @@ -670,6 +719,7 @@ public static function return_raw_title_format(): string { * @return string The ISO 8601 date, or an empty string if unavailable. */ protected static function format_gmt_date( WP_Post $post, string $field ): string { + $field = 'modified' === $field ? 'modified' : 'date'; $datetime = get_post_datetime( $post, $field, 'gmt' ); if ( $datetime ) { return $datetime->format( 'c' ); From ffa6e54591faee21182b8395f360d2f4f89876b7 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 16:33:12 +0100 Subject: [PATCH 03/69] Abilities API: make WP_Content_Abilities a final instance class. Convert WP_Content_Abilities from a static class to a final, instance-based one, matching WP_Settings_Abilities and the canonical abilities pattern: register() is now invoked via ( new WP_Content_Abilities() )->register() from wp_register_core_abilities(). The externally-invoked entry points (register, check_permission, execute_get_content, return_raw_title_format) stay public; register_get_content() and the shared helpers become private; CATEGORY and the per-page bounds become private consts; FIELDS becomes a private instance property; and the cached exposed post types become instance state. Behaviour is unchanged. The per-page assertions in the test read the now-private constants by value. --- src/wp-includes/abilities.php | 2 +- .../abilities/class-wp-content-abilities.php | 152 +++++++++--------- .../wpRegisterCoreContentAbility.php | 4 +- 3 files changed, 79 insertions(+), 79 deletions(-) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 4411361f79d86..a50af3541d592 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -377,5 +377,5 @@ function wp_register_core_abilities(): void { ( new WP_Abilities_Settings() )->register(); // Register the content abilities (currently the read-only `core/content`). - WP_Content_Abilities::register(); + ( new WP_Content_Abilities() )->register(); } diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 091d445743a3b..fee5866f19e0e 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -29,7 +29,7 @@ * * @access private */ -class WP_Content_Abilities { +final class WP_Content_Abilities { /** * The ability category used for content abilities. @@ -37,7 +37,25 @@ class WP_Content_Abilities { * @since 7.1.0 * @var string */ - const CATEGORY = 'content'; + private const CATEGORY = 'content'; + + /** + * Default number of posts returned per page in query mode. + * + * @since 7.1.0 + * @var int + */ + private const DEFAULT_PER_PAGE = 10; + + /** + * Maximum number of posts returned per page in query mode. + * + * Mirrors the REST API collection ceiling. + * + * @since 7.1.0 + * @var int + */ + private const MAX_PER_PAGE = 100; /** * The fields a post object may expose, in output order. @@ -48,7 +66,7 @@ class WP_Content_Abilities { * @since 7.1.0 * @var string[] */ - const FIELDS = array( + private array $fields = array( 'id', 'type', 'status', @@ -63,24 +81,6 @@ class WP_Content_Abilities { 'parent', ); - /** - * Default number of posts returned per page in query mode. - * - * @since 7.1.0 - * @var int - */ - const DEFAULT_PER_PAGE = 10; - - /** - * Maximum number of posts returned per page in query mode. - * - * Mirrors the REST API collection ceiling. - * - * @since 7.1.0 - * @var int - */ - const MAX_PER_PAGE = 100; - /** * Post types exposed through the Abilities API, computed once at registration. * @@ -90,7 +90,7 @@ class WP_Content_Abilities { * @since 7.1.0 * @var array|null */ - private static ?array $exposed_post_types = null; + private ?array $exposed_post_types = null; /** * Registers all content abilities. @@ -99,14 +99,14 @@ class WP_Content_Abilities { * * @since 7.1.0 */ - public static function register(): void { - self::register_get_content(); + public function register(): void { + $this->register_get_content(); /* * A future write-oriented ability can be registered here, reusing the shared * helpers below (get_exposed_post_types(), format_post(), check_permission()): * - * self::register_manage_content(); + * $this->register_manage_content(); */ } @@ -115,12 +115,12 @@ public static function register(): void { * * @since 7.1.0 */ - public static function register_get_content(): void { + private function register_get_content(): void { // Compute once; check_permission()/execute_get_content() reuse this set. - self::$exposed_post_types = self::get_exposed_post_types(); + $this->exposed_post_types = $this->get_exposed_post_types(); - $post_types = array_keys( self::$exposed_post_types ); - $statuses = self::get_available_statuses(); + $post_types = array_keys( $this->exposed_post_types ); + $statuses = $this->get_available_statuses(); wp_register_ability( 'core/content', @@ -128,10 +128,10 @@ public static function register_get_content(): void { 'label' => __( 'Get Content' ), 'description' => __( 'Retrieves one or more posts of a post type exposed to abilities. Fetch a single post by ID or by slug, or query multiple posts filtered by post type, status, author, or parent. Returns a basic, support-aware set of fields per post.' ), 'category' => self::CATEGORY, - 'input_schema' => self::get_content_input_schema( $post_types, $statuses ), - 'output_schema' => self::get_content_output_schema(), - 'execute_callback' => array( self::class, 'execute_get_content' ), - 'permission_callback' => array( self::class, 'check_permission' ), + 'input_schema' => $this->get_content_input_schema( $post_types, $statuses ), + 'output_schema' => $this->get_content_output_schema(), + 'execute_callback' => array( $this, 'execute_get_content' ), + 'permission_callback' => array( $this, 'check_permission' ), 'meta' => array( 'annotations' => array( 'readonly' => true, @@ -161,13 +161,13 @@ public static function register_get_content(): void { * @param mixed $input Optional. The ability input. Default empty array. * @return bool True if the request may proceed, false otherwise. */ - public static function check_permission( $input = array() ): bool { + public function check_permission( $input = array() ): bool { $input = is_array( $input ) ? $input : array(); - $exposed = self::$exposed_post_types ?? self::get_exposed_post_types(); + $exposed = $this->exposed_post_types ?? $this->get_exposed_post_types(); // Single-post mode (by ID). if ( ! empty( $input['id'] ) ) { - $post = get_post( self::input_int( $input['id'] ) ); + $post = get_post( $this->input_int( $input['id'] ) ); /* * For a missing post, an unexposed post type, or a post type that does not @@ -195,11 +195,11 @@ public static function check_permission( $input = array() ): bool { $post_type_object = $exposed[ $post_type ]; // Base gate: must be able to read this post type at all. - if ( ! current_user_can( self::capability( $post_type_object, 'read', 'read' ) ) ) { + if ( ! current_user_can( $this->capability( $post_type_object, 'read', 'read' ) ) ) { return false; } - $statuses = self::normalize_statuses( $input ); + $statuses = $this->normalize_statuses( $input ); // Only published posts requested: always allowed for readers. if ( array( 'publish' ) === $statuses ) { @@ -207,12 +207,12 @@ public static function check_permission( $input = array() ): bool { } // Editors/authors of this post type may request any status set. - if ( current_user_can( self::capability( $post_type_object, 'edit_posts', 'edit_posts' ) ) ) { + if ( current_user_can( $this->capability( $post_type_object, 'edit_posts', 'edit_posts' ) ) ) { return true; } // Otherwise, private posts are allowed only with read_private_posts. - if ( current_user_can( self::capability( $post_type_object, 'read_private_posts', 'read_private_posts' ) ) ) { + if ( current_user_can( $this->capability( $post_type_object, 'read_private_posts', 'read_private_posts' ) ) ) { foreach ( $statuses as $status ) { if ( 'private' !== $status && 'publish' !== $status ) { return false; @@ -234,7 +234,7 @@ public static function check_permission( $input = array() ): bool { * @param string $fallback Fallback capability name if unset or non-string. * @return string The resolved capability name. */ - protected static function capability( WP_Post_Type $post_type_object, string $name, string $fallback ): string { + private function capability( WP_Post_Type $post_type_object, string $name, string $fallback ): string { $capability = $post_type_object->cap->$name ?? $fallback; return is_string( $capability ) ? $capability : $fallback; @@ -248,7 +248,7 @@ protected static function capability( WP_Post_Type $post_type_object, string $na * @param mixed $value The raw input value. * @return int The value as a non-negative integer, or 0 when not scalar. */ - protected static function input_int( $value ): int { + private function input_int( $value ): int { return is_scalar( $value ) ? absint( $value ) : 0; } @@ -260,25 +260,25 @@ protected static function input_int( $value ): int { * @param mixed $input Optional. The ability input. Default empty array. * @return array|WP_Error A map with a `posts` list, or a WP_Error on failure. */ - public static function execute_get_content( $input = array() ) { + public function execute_get_content( $input = array() ) { $input = is_array( $input ) ? $input : array(); - $exposed = self::$exposed_post_types ?? self::get_exposed_post_types(); - $fields = self::normalize_fields( $input ); + $exposed = $this->exposed_post_types ?? $this->get_exposed_post_types(); + $fields = $this->normalize_fields( $input ); // Single-post mode (by ID). if ( ! empty( $input['id'] ) ) { - $post = get_post( self::input_int( $input['id'] ) ); + $post = get_post( $this->input_int( $input['id'] ) ); if ( ! $post || ! isset( $exposed[ $post->post_type ] ) || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) || ! current_user_can( 'read_post', $post->ID ) ) { - return self::not_found_error(); + return $this->not_found_error(); } return array( - 'posts' => array( self::format_post( $post, $fields ) ), + 'posts' => array( $this->format_post( $post, $fields ) ), 'total' => 1, 'total_pages' => 1, ); @@ -287,15 +287,15 @@ public static function execute_get_content( $input = array() ) { // Query / slug mode. $post_type = isset( $input['post_type'] ) && is_string( $input['post_type'] ) ? $input['post_type'] : ''; if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { - return self::not_found_error(); + return $this->not_found_error(); } - $per_page = self::normalize_per_page( $input ); - $page = isset( $input['page'] ) ? max( 1, self::input_int( $input['page'] ) ) : 1; + $per_page = $this->normalize_per_page( $input ); + $page = isset( $input['page'] ) ? max( 1, $this->input_int( $input['page'] ) ) : 1; $query_args = array( 'post_type' => $post_type, - 'post_status' => self::normalize_statuses( $input ), + 'post_status' => $this->normalize_statuses( $input ), 'posts_per_page' => $per_page, 'paged' => $page, 'ignore_sticky_posts' => true, @@ -306,11 +306,11 @@ public static function execute_get_content( $input = array() ) { } if ( ! empty( $input['author'] ) ) { - $query_args['author'] = self::input_int( $input['author'] ); + $query_args['author'] = $this->input_int( $input['author'] ); } if ( isset( $input['parent'] ) ) { - $query_args['post_parent'] = self::input_int( $input['parent'] ); + $query_args['post_parent'] = $this->input_int( $input['parent'] ); } $query = new WP_Query( $query_args ); @@ -324,7 +324,7 @@ public static function execute_get_content( $input = array() ) { if ( ! current_user_can( 'read_post', $post->ID ) ) { continue; } - $posts[] = self::format_post( $post, $fields ); + $posts[] = $this->format_post( $post, $fields ); } return array( @@ -342,8 +342,8 @@ public static function execute_get_content( $input = array() ) { * @param array $input The ability input. * @return int The clamped per-page value. */ - protected static function normalize_per_page( array $input ): int { - $per_page = isset( $input['per_page'] ) ? self::input_int( $input['per_page'] ) : self::DEFAULT_PER_PAGE; + private function normalize_per_page( array $input ): int { + $per_page = isset( $input['per_page'] ) ? $this->input_int( $input['per_page'] ) : self::DEFAULT_PER_PAGE; return max( 1, min( self::MAX_PER_PAGE, $per_page ) ); } @@ -357,7 +357,7 @@ protected static function normalize_per_page( array $input ): int { * * @return array Exposed post type objects keyed by name. */ - protected static function get_exposed_post_types(): array { + private function get_exposed_post_types(): array { $exposed = array(); foreach ( get_post_types( array(), 'objects' ) as $post_type_object ) { @@ -379,7 +379,7 @@ protected static function get_exposed_post_types(): array { * * @return string[] List of public, non-internal post status slugs. */ - protected static function get_available_statuses(): array { + private function get_available_statuses(): array { return array_values( get_post_stati( array( 'internal' => false ) ) ); } @@ -391,7 +391,7 @@ protected static function get_available_statuses(): array { * @param array $input The ability input. * @return string[] Normalized list of post status slugs. */ - protected static function normalize_statuses( array $input ): array { + private function normalize_statuses( array $input ): array { $statuses = $input['status'] ?? array( 'publish' ); if ( ! is_array( $statuses ) ) { return array( 'publish' ); @@ -412,15 +412,15 @@ protected static function normalize_statuses( array $input ): array { * @param array $input The ability input. * @return string[] List of requested field names. */ - protected static function normalize_fields( array $input ): array { + private function normalize_fields( array $input ): array { if ( empty( $input['fields'] ) || ! is_array( $input['fields'] ) ) { - return self::FIELDS; + return $this->fields; } $requested = array_filter( $input['fields'], 'is_string' ); - $fields = array_intersect( self::FIELDS, $requested ); + $fields = array_intersect( $this->fields, $requested ); - return array() === $fields ? self::FIELDS : array_values( $fields ); + return array() === $fields ? $this->fields : array_values( $fields ); } /** @@ -432,7 +432,7 @@ protected static function normalize_fields( array $input ): array { * @param string[] $statuses Requestable post status slugs. * @return array The input JSON Schema. */ - protected static function get_content_input_schema( array $post_types, array $statuses ): array { + private function get_content_input_schema( array $post_types, array $statuses ): array { return array( 'type' => 'object', // Object (not array()) so the serialized schema default is {}, consistent with type:object. @@ -482,7 +482,7 @@ protected static function get_content_input_schema( array $post_types, array $st 'uniqueItems' => true, 'items' => array( 'type' => 'string', - 'enum' => self::FIELDS, + 'enum' => $this->fields, ), 'description' => __( 'Limit each returned post to these fields. If omitted, all supported fields are returned.' ), ), @@ -514,7 +514,7 @@ protected static function get_content_input_schema( array $post_types, array $st * * @return array The output JSON Schema. */ - protected static function get_content_output_schema(): array { + private function get_content_output_schema(): array { $post_schema = array( 'type' => 'object', 'additionalProperties' => false, @@ -615,7 +615,7 @@ protected static function get_content_output_schema(): array { * @param string[] $fields The requested field names. * @return array The formatted post data. */ - protected static function format_post( WP_Post $post, array $fields ): array { + private function format_post( WP_Post $post, array $fields ): array { $type = $post->post_type; $wants = static function ( string $field ) use ( $fields ): bool { return in_array( $field, $fields, true ); @@ -634,10 +634,10 @@ protected static function format_post( WP_Post $post, array $fields ): array { $data['status'] = $post->post_status; } if ( $wants( 'date' ) ) { - $data['date'] = self::format_gmt_date( $post, 'date' ); + $data['date'] = $this->format_gmt_date( $post, 'date' ); } if ( $wants( 'modified' ) ) { - $data['modified'] = self::format_gmt_date( $post, 'modified' ); + $data['modified'] = $this->format_gmt_date( $post, 'modified' ); } if ( $wants( 'slug' ) ) { $data['slug'] = $post->post_name; @@ -647,7 +647,7 @@ protected static function format_post( WP_Post $post, array $fields ): array { } if ( $wants( 'title' ) && post_type_supports( $type, 'title' ) ) { - $data['title'] = self::get_title( $post ); + $data['title'] = $this->get_title( $post ); } if ( $wants( 'excerpt' ) && post_type_supports( $type, 'excerpt' ) ) { @@ -684,8 +684,8 @@ protected static function format_post( WP_Post $post, array $fields ): array { * @param WP_Post $post The post object. * @return string The post title. */ - protected static function get_title( WP_Post $post ): string { - $strip = array( self::class, 'return_raw_title_format' ); + private function get_title( WP_Post $post ): string { + $strip = array( $this, 'return_raw_title_format' ); add_filter( 'protected_title_format', $strip ); add_filter( 'private_title_format', $strip ); $title = get_the_title( $post ); @@ -702,7 +702,7 @@ protected static function get_title( WP_Post $post ): string { * * @return string The unprefixed title format. */ - public static function return_raw_title_format(): string { + public function return_raw_title_format(): string { return '%s'; } @@ -718,7 +718,7 @@ public static function return_raw_title_format(): string { * @param string $field Either 'date' or 'modified'. * @return string The ISO 8601 date, or an empty string if unavailable. */ - protected static function format_gmt_date( WP_Post $post, string $field ): string { + private function format_gmt_date( WP_Post $post, string $field ): string { $field = 'modified' === $field ? 'modified' : 'date'; $datetime = get_post_datetime( $post, $field, 'gmt' ); if ( $datetime ) { @@ -743,7 +743,7 @@ protected static function format_gmt_date( WP_Post $post, string $field ): strin * * @return WP_Error The not-found error. */ - protected static function not_found_error(): WP_Error { + private function not_found_error(): WP_Error { return new WP_Error( 'content_not_found', __( 'The requested content was not found.' ), diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index ad539c0936df5..8687ab8a0fd0b 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -562,8 +562,8 @@ public function test_per_page_is_capped(): void { $schema = $this->ability()->get_input_schema(); - $this->assertSame( WP_Content_Abilities::MAX_PER_PAGE, $schema['properties']['per_page']['maximum'] ); - $this->assertSame( WP_Content_Abilities::DEFAULT_PER_PAGE, $schema['properties']['per_page']['default'] ); + $this->assertSame( 100, $schema['properties']['per_page']['maximum'] ); + $this->assertSame( 10, $schema['properties']['per_page']['default'] ); } public function test_single_post_reports_totals(): void { From a2ab609f2144acc3288051eb7a64241b6c264e65 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 16:51:52 +0100 Subject: [PATCH 04/69] Abilities API: model the core/content input as two mutually exclusive modes. Replace the flat anyOf(id|post_type) input schema with a oneOf of two modes, each with additionalProperties:false: - Get a single post by id (optionally guarded by post_type), plus fields. - Query a set of posts by post_type plus slug/status/author/parent/page/per_page, plus fields. Invalid combinations (e.g. per_page alongside id) now fail validation instead of being silently ignored. Update wpRegisterCoreContentAbility accordingly and add coverage for the id-mode rejecting query-only params and accepting a post_type guard. --- .../abilities/class-wp-content-abilities.php | 150 ++++++++++-------- .../wpRegisterCoreContentAbility.php | 60 +++++-- 2 files changed, 138 insertions(+), 72 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index fee5866f19e0e..95066b13e2973 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -426,6 +426,16 @@ private function normalize_fields( array $input ): array { /** * Builds the input schema for the `core/content` ability. * + * The ability has two mutually exclusive modes, modeled as a `oneOf` so invalid + * combinations are rejected rather than silently ignored: + * + * - Get a single post by `id` (optionally guarded by `post_type`). + * - Query a set of posts by `post_type` plus filters (`slug`, `status`, `author`, + * `parent`, `page`, `per_page`). + * + * Each mode sets `additionalProperties: false`, so e.g. passing `per_page` alongside `id` + * fails validation instead of being dropped. `fields` is accepted in both modes. + * * @since 7.1.0 * * @param string[] $post_types Exposed post type names. @@ -433,74 +443,90 @@ private function normalize_fields( array $input ): array { * @return array The input JSON Schema. */ private function get_content_input_schema( array $post_types, array $statuses ): array { - return array( - 'type' => 'object', - // Object (not array()) so the serialized schema default is {}, consistent with type:object. - 'default' => (object) array(), - // `post_type` is required unless a single post is requested by `id`. - 'anyOf' => array( - array( 'required' => array( 'id' ) ), - array( 'required' => array( 'post_type' ) ), + $fields = array( + 'type' => 'array', + 'uniqueItems' => true, + 'items' => array( + 'type' => 'string', + 'enum' => $this->fields, ), - 'properties' => array( - 'post_type' => array( - 'type' => 'string', - 'enum' => $post_types, - 'description' => __( 'Post type to retrieve. Required unless `id` is provided.' ), - ), - 'id' => array( - 'type' => 'integer', - 'minimum' => 1, - 'description' => __( 'Retrieve a single post by ID. When provided, `post_type` is optional.' ), - ), - 'slug' => array( - 'type' => 'string', - 'description' => __( 'Retrieve posts by slug. Requires `post_type`, as slugs are not unique across post types.' ), - ), - 'status' => array( - 'type' => 'array', - 'uniqueItems' => true, - 'default' => array( 'publish' ), - 'items' => array( - 'type' => 'string', - 'enum' => $statuses, + 'description' => __( 'Limit each returned post to these fields. If omitted, all supported fields are returned.' ), + ); + + return array( + 'type' => 'object', + 'oneOf' => array( + // Mode 1: retrieve a single post by ID. + array( + 'title' => __( 'Get a single post by ID' ), + 'required' => array( 'id' ), + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Retrieve a single post by ID.' ), + ), + 'post_type' => array( + 'type' => 'string', + 'enum' => $post_types, + 'description' => __( 'Optional. Restrict the lookup to this post type; the post is returned only if it matches.' ), + ), + 'fields' => $fields, ), - 'description' => __( 'Filter by one or more post statuses. Defaults to publish. Non-published statuses require the appropriate capabilities.' ), - ), - 'author' => array( - 'type' => 'integer', - 'minimum' => 1, - 'description' => __( 'Filter by author user ID.' ), ), - 'parent' => array( - 'type' => 'integer', - 'minimum' => 0, - 'description' => __( 'Filter by parent post ID, for hierarchical post types. Use 0 for top-level posts.' ), - ), - 'fields' => array( - 'type' => 'array', - 'uniqueItems' => true, - 'items' => array( - 'type' => 'string', - 'enum' => $this->fields, + // Mode 2: query a set of posts by post type and filters. + array( + 'title' => __( 'Query posts by type and filters' ), + 'required' => array( 'post_type' ), + 'additionalProperties' => false, + 'properties' => array( + 'post_type' => array( + 'type' => 'string', + 'enum' => $post_types, + 'description' => __( 'Post type to query.' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'Filter by slug. Combined with `post_type`, as slugs are not unique across post types.' ), + ), + 'status' => array( + 'type' => 'array', + 'uniqueItems' => true, + 'default' => array( 'publish' ), + 'items' => array( + 'type' => 'string', + 'enum' => $statuses, + ), + 'description' => __( 'Filter by one or more post statuses. Defaults to publish. Non-published statuses require the appropriate capabilities.' ), + ), + 'author' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Filter by author user ID.' ), + ), + 'parent' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Filter by parent post ID, for hierarchical post types. Use 0 for top-level posts.' ), + ), + 'fields' => $fields, + 'page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'default' => 1, + 'description' => __( 'Page of results to return.' ), + ), + 'per_page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => self::MAX_PER_PAGE, + 'default' => self::DEFAULT_PER_PAGE, + 'description' => __( 'Maximum number of posts to return per page.' ), + ), ), - 'description' => __( 'Limit each returned post to these fields. If omitted, all supported fields are returned.' ), - ), - 'page' => array( - 'type' => 'integer', - 'minimum' => 1, - 'default' => 1, - 'description' => __( 'Page of results to return in query mode. Ignored when retrieving a single post by ID.' ), - ), - 'per_page' => array( - 'type' => 'integer', - 'minimum' => 1, - 'maximum' => self::MAX_PER_PAGE, - 'default' => self::DEFAULT_PER_PAGE, - 'description' => __( 'Maximum number of posts to return per page in query mode.' ), ), ), - 'additionalProperties' => false, ); } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 8687ab8a0fd0b..743cf84f33017 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -126,22 +126,62 @@ public function test_ability_is_registered_readonly_in_content_category(): void $this->assertTrue( $annotations['idempotent'] ); } - public function test_input_schema_requires_id_or_post_type(): void { + public function test_input_schema_models_mutually_exclusive_modes(): void { $schema = $this->ability()->get_input_schema(); $this->assertSame( 'object', $schema['type'] ); - $this->assertSame( + $this->assertCount( 2, $schema['oneOf'] ); + + [ $by_id, $by_type ] = $schema['oneOf']; + + // Mode 1 requires `id`; Mode 2 requires `post_type`. Both reject extra properties. + $this->assertSame( array( 'id' ), $by_id['required'] ); + $this->assertSame( array( 'post_type' ), $by_type['required'] ); + $this->assertFalse( $by_id['additionalProperties'] ); + $this->assertFalse( $by_type['additionalProperties'] ); + + // Query-only filters live only in the query mode, not the by-ID mode. + $this->assertArrayHasKey( 'per_page', $by_type['properties'] ); + $this->assertArrayNotHasKey( 'per_page', $by_id['properties'] ); + } + + public function test_id_mode_rejects_query_only_params(): void { + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( array( - array( 'required' => array( 'id' ) ), - array( 'required' => array( 'post_type' ) ), - ), - $schema['anyOf'] + 'id' => 1, + 'per_page' => 10, + ) ); - $this->assertFalse( $schema['additionalProperties'] ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + public function test_id_mode_accepts_post_type_guard(): void { + $this->login_as( 'administrator' ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + ) + ); + + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'post_type' => 'post', + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( $post_id, $result['posts'][0]['id'] ); } public function test_input_schema_post_type_enum_only_includes_exposed_types(): void { - $enum = $this->ability()->get_input_schema()['properties']['post_type']['enum']; + $enum = $this->ability()->get_input_schema()['oneOf'][1]['properties']['post_type']['enum']; $this->assertContains( 'post', $enum ); $this->assertContains( 'page', $enum ); @@ -151,7 +191,7 @@ public function test_input_schema_post_type_enum_only_includes_exposed_types(): } public function test_input_schema_status_and_fields_enums(): void { - $properties = $this->ability()->get_input_schema()['properties']; + $properties = $this->ability()->get_input_schema()['oneOf'][1]['properties']; $status_enum = $properties['status']['items']['enum']; $this->assertContains( 'publish', $status_enum ); @@ -560,7 +600,7 @@ public function test_query_paginates_and_reports_totals(): void { public function test_per_page_is_capped(): void { $this->login_as( 'administrator' ); - $schema = $this->ability()->get_input_schema(); + $schema = $this->ability()->get_input_schema()['oneOf'][1]; $this->assertSame( 100, $schema['properties']['per_page']['maximum'] ); $this->assertSame( 10, $schema['properties']['per_page']['default'] ); From e5c1cf34519d5f962d206e3c080c091628a669d6 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 19:44:10 +0100 Subject: [PATCH 05/69] Abilities API: Require edit access for content ability --- .../abilities/class-wp-content-abilities.php | 106 ++++++------------ src/wp-includes/class-wp-post-type.php | 2 +- src/wp-includes/post.php | 2 +- .../wpRegisterCoreContentAbility.php | 61 ++++++---- .../wpRestAbilitiesContentController.php | 10 +- 5 files changed, 89 insertions(+), 92 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 95066b13e2973..6845a5375bdd4 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -12,10 +12,10 @@ /** * Core class used to register content-related abilities. * - * Provides the read-only `core/content` ability, which retrieves one or more posts of a - * post type that opts in via the `show_in_abilities` argument. It supports fetching a - * single post by ID or slug, or querying multiple posts with a small set of filters, and - * returns a basic, support-aware set of fields per post. + * Provides the read-only `core/content` ability, which retrieves one or more editable + * posts of a post type that opts in via the `show_in_abilities` argument. It supports + * fetching a single editable post by ID or slug, or querying multiple editable posts + * with a small set of filters, and returns a basic, support-aware set of fields per post. * * The class is intentionally structured around shared building blocks (exposed post type * discovery, schema generation, per-post formatting and permission checks) so a future @@ -126,7 +126,7 @@ private function register_get_content(): void { 'core/content', array( 'label' => __( 'Get Content' ), - 'description' => __( 'Retrieves one or more posts of a post type exposed to abilities. Fetch a single post by ID or by slug, or query multiple posts filtered by post type, status, author, or parent. Returns a basic, support-aware set of fields per post.' ), + 'description' => __( 'Retrieves one or more editable posts of a post type exposed to abilities. Fetch a single editable post by ID or by slug, or query multiple editable posts filtered by post type, status, author, or parent. Returns a basic, support-aware set of fields per post.' ), 'category' => self::CATEGORY, 'input_schema' => $this->get_content_input_schema( $post_types, $statuses ), 'output_schema' => $this->get_content_output_schema(), @@ -152,9 +152,9 @@ private function register_get_content(): void { * Permission callback for the `core/content` ability. * * Implements defense in depth: this gate decides whether the request may proceed at - * all (coarse, by post type capabilities and requested statuses), while the per-post - * `read_post` meta capability check in {@see self::execute_get_content()} is the - * authoritative, row-level enforcement of author-scoped visibility. + * all (coarse, by post type capabilities), while the per-post `edit_post` meta + * capability check in {@see self::execute_get_content()} is the authoritative, + * row-level enforcement of author-scoped visibility. * * @since 7.1.0 * @@ -169,21 +169,14 @@ public function check_permission( $input = array() ): bool { if ( ! empty( $input['id'] ) ) { $post = get_post( $this->input_int( $input['id'] ) ); - /* - * For a missing post, an unexposed post type, or a post type that does not - * match the requested one, fall back to a generic capability check rather - * than a row-level check on a guessed ID, so the response cannot be used to - * enumerate IDs or probe post-type membership. Execution returns a uniform - * 404 in these cases. - */ if ( ! $post || ! isset( $exposed[ $post->post_type ] ) || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) ) { - return current_user_can( 'read' ); + return false; } - return current_user_can( 'read_post', $post->ID ); + return current_user_can( 'edit_post', $post->ID ); } // Query / slug mode requires an exposed post type. @@ -192,36 +185,10 @@ public function check_permission( $input = array() ): bool { return false; } - $post_type_object = $exposed[ $post_type ]; + $post_type_object = $exposed[ $post_type ]; + $edit_posts_capability = $this->capability( $post_type_object, 'edit_posts', 'edit_posts' ); - // Base gate: must be able to read this post type at all. - if ( ! current_user_can( $this->capability( $post_type_object, 'read', 'read' ) ) ) { - return false; - } - - $statuses = $this->normalize_statuses( $input ); - - // Only published posts requested: always allowed for readers. - if ( array( 'publish' ) === $statuses ) { - return true; - } - - // Editors/authors of this post type may request any status set. - if ( current_user_can( $this->capability( $post_type_object, 'edit_posts', 'edit_posts' ) ) ) { - return true; - } - - // Otherwise, private posts are allowed only with read_private_posts. - if ( current_user_can( $this->capability( $post_type_object, 'read_private_posts', 'read_private_posts' ) ) ) { - foreach ( $statuses as $status ) { - if ( 'private' !== $status && 'publish' !== $status ) { - return false; - } - } - return true; - } - - return false; + return current_user_can( $edit_posts_capability ); } /** @@ -272,7 +239,7 @@ public function execute_get_content( $input = array() ) { if ( ! $post || ! isset( $exposed[ $post->post_type ] ) || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) - || ! current_user_can( 'read_post', $post->ID ) + || ! current_user_can( 'edit_post', $post->ID ) ) { return $this->not_found_error(); } @@ -298,6 +265,7 @@ public function execute_get_content( $input = array() ) { 'post_status' => $this->normalize_statuses( $input ), 'posts_per_page' => $per_page, 'paged' => $page, + 'perm' => 'editable', 'ignore_sticky_posts' => true, ); @@ -320,8 +288,7 @@ public function execute_get_content( $input = array() ) { if ( ! $post instanceof WP_Post ) { continue; } - // Authoritative, row-level visibility check (author/status scoped). - if ( ! current_user_can( 'read_post', $post->ID ) ) { + if ( ! current_user_can( 'edit_post', $post->ID ) ) { continue; } $posts[] = $this->format_post( $post, $fields ); @@ -429,9 +396,9 @@ private function normalize_fields( array $input ): array { * The ability has two mutually exclusive modes, modeled as a `oneOf` so invalid * combinations are rejected rather than silently ignored: * - * - Get a single post by `id` (optionally guarded by `post_type`). - * - Query a set of posts by `post_type` plus filters (`slug`, `status`, `author`, - * `parent`, `page`, `per_page`). + * - Get a single editable post by `id` (optionally guarded by `post_type`). + * - Query a set of editable posts by `post_type` plus filters (`slug`, `status`, + * `author`, `parent`, `page`, `per_page`). * * Each mode sets `additionalProperties: false`, so e.g. passing `per_page` alongside `id` * fails validation instead of being dropped. `fields` is accepted in both modes. @@ -456,35 +423,35 @@ private function get_content_input_schema( array $post_types, array $statuses ): return array( 'type' => 'object', 'oneOf' => array( - // Mode 1: retrieve a single post by ID. + // Mode 1: retrieve a single editable post by ID. array( - 'title' => __( 'Get a single post by ID' ), + 'title' => __( 'Get a single editable post by ID' ), 'required' => array( 'id' ), 'additionalProperties' => false, 'properties' => array( 'id' => array( 'type' => 'integer', 'minimum' => 1, - 'description' => __( 'Retrieve a single post by ID.' ), + 'description' => __( 'Retrieve a single editable post by ID.' ), ), 'post_type' => array( 'type' => 'string', 'enum' => $post_types, - 'description' => __( 'Optional. Restrict the lookup to this post type; the post is returned only if it matches.' ), + 'description' => __( 'Optional. Restrict the lookup to this post type; the post is returned only if it matches and the current user can edit it.' ), ), 'fields' => $fields, ), ), - // Mode 2: query a set of posts by post type and filters. + // Mode 2: query a set of editable posts by post type and filters. array( - 'title' => __( 'Query posts by type and filters' ), + 'title' => __( 'Query editable posts by type and filters' ), 'required' => array( 'post_type' ), 'additionalProperties' => false, 'properties' => array( 'post_type' => array( 'type' => 'string', 'enum' => $post_types, - 'description' => __( 'Post type to query.' ), + 'description' => __( 'Post type to query for editable posts.' ), ), 'slug' => array( 'type' => 'string', @@ -498,7 +465,7 @@ private function get_content_input_schema( array $post_types, array $statuses ): 'type' => 'string', 'enum' => $statuses, ), - 'description' => __( 'Filter by one or more post statuses. Defaults to publish. Non-published statuses require the appropriate capabilities.' ), + 'description' => __( 'Filter editable posts by one or more post statuses. Defaults to publish. Non-published statuses require the appropriate capabilities.' ), ), 'author' => array( 'type' => 'integer', @@ -583,7 +550,7 @@ private function get_content_output_schema(): array { ), 'raw_content' => array( 'type' => 'string', - 'description' => __( 'The raw, unfiltered post content (block markup). Present when the post type supports the editor. Empty when withheld for a password-protected post.' ), + 'description' => __( 'The raw, unfiltered post content (block markup). Present when the post type supports the editor.' ), ), 'author' => array( 'type' => 'object', @@ -613,16 +580,16 @@ private function get_content_output_schema(): array { 'properties' => array( 'posts' => array( 'type' => 'array', - 'description' => __( 'The posts matching the request. A single-element list when requested by ID.' ), + 'description' => __( 'The editable posts matching the request. A single-element list when requested by ID.' ), 'items' => $post_schema, ), 'total' => array( 'type' => 'integer', - 'description' => __( 'Total number of posts matching the query, across all pages. Surfaced over REST as the X-WP-Total header.' ), + 'description' => __( 'Total number of posts matching the query, across all pages, after applying the editable permission filter to the query. Surfaced over REST as the X-WP-Total header.' ), ), 'total_pages' => array( 'type' => 'integer', - 'description' => __( 'Total number of pages available. Surfaced over REST as the X-WP-TotalPages header.' ), + 'description' => __( 'Total number of query result pages available after applying the editable permission filter to the query. Surfaced over REST as the X-WP-TotalPages header.' ), ), ), ); @@ -646,7 +613,8 @@ private function format_post( WP_Post $post, array $fields ): array { $wants = static function ( string $field ) use ( $fields ): bool { return in_array( $field, $fields, true ); }; - $protected = post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ); + $can_edit = current_user_can( 'edit_post', $post->ID ); + $protected = post_password_required( $post ) && ! $can_edit; $data = array(); @@ -681,7 +649,7 @@ private function format_post( WP_Post $post, array $fields ): array { } if ( $wants( 'raw_content' ) && post_type_supports( $type, 'editor' ) ) { - $data['raw_content'] = $protected ? '' : (string) $post->post_content; + $data['raw_content'] = $can_edit && ! $protected ? (string) $post->post_content : ''; } if ( $wants( 'author' ) && post_type_supports( $type, 'author' ) ) { @@ -761,9 +729,9 @@ private function format_gmt_date( WP_Post $post, string $field ): string { /** * Builds the uniform not-found error. * - * The same generic 404 is returned for a missing post, an unexposed post type, a - * type mismatch, or a post the user cannot read, so the ability cannot be used to - * enumerate IDs or probe post-type membership. + * Used by execution when content cannot be resolved or edited after permission + * checks. The permission callback fails closed for uncertain by-ID lookups before + * execution runs. * * @since 7.1.0 * diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index f5b7b60a6df01..4146ddea80cd0 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -376,7 +376,7 @@ final class WP_Post_Type { /** * Whether this post type should be exposed through the Abilities API. * - * Default false. When truthy, the post type's readable posts can be retrieved + * Default false. When truthy, the post type's editable posts can be retrieved * through the read-only `core/content` ability, subject to per-post capability * checks. May be an array to enable specific operations in the future. * diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index ee83b5a3da21c..7bfb358317f2e 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -1740,7 +1740,7 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * @type bool $show_in_rest Whether to include the post type in the REST API. Set this to true * for the post type to be available in the block editor. * @type bool|array $show_in_abilities Whether to expose this post type through the Abilities API, so its - * readable posts can be retrieved via the read-only `core/content` + * editable posts can be retrieved via the read-only `core/content` * ability (subject to per-post capability checks). Accepts a boolean * or an array reserved for enabling specific operations. Default false. * @type string $rest_base To change the base URL of REST API route. Default is $post_type. diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 743cf84f33017..ab188fdde44b9 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -24,7 +24,7 @@ class Tests_Abilities_API_WpRegisterCoreContentAbility extends WP_UnitTestCase { * * @var string */ - const HIDDEN_CPT = 'content_ability_hidden_cpt'; + const HIDDEN_CPT = 'content_hidden_cpt'; /** * Registers post types and the core abilities once, before the schema is built. @@ -242,7 +242,7 @@ public function test_get_single_published_post_by_id(): void { $this->assertSame( 'post', $result['posts'][0]['type'] ); } - public function test_get_by_id_with_mismatched_post_type_returns_not_found(): void { + public function test_get_by_id_with_mismatched_post_type_is_denied(): void { $this->login_as( 'administrator' ); $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); @@ -254,17 +254,32 @@ public function test_get_by_id_with_mismatched_post_type_returns_not_found(): vo ); $this->assertWPError( $result ); - $this->assertSame( 'content_not_found', $result->get_error_code() ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); } - public function test_get_by_missing_id_returns_generic_not_found(): void { + public function test_get_by_missing_id_is_denied(): void { $this->login_as( 'administrator' ); $result = $this->ability()->execute( array( 'id' => 999999 ) ); $this->assertWPError( $result ); - $this->assertSame( 'content_not_found', $result->get_error_code() ); - $this->assertSame( 404, $result->get_error_data()['status'] ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + public function test_get_by_id_for_unexposed_post_type_is_denied(): void { + $post_id = self::factory()->post->create( + array( + 'post_type' => self::HIDDEN_CPT, + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); } /* @@ -422,14 +437,23 @@ public function test_logged_out_user_is_denied(): void { $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); } - public function test_subscriber_can_read_published_posts(): void { - $published = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + public function test_subscriber_cannot_request_published_content(): void { $this->login_as( 'subscriber' ); $result = $this->ability()->execute( array( 'post_type' => 'post' ) ); - $ids = wp_list_pluck( $result['posts'], 'id' ); - $this->assertContains( $published, $ids ); + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + public function test_subscriber_cannot_get_single_published_post_by_id(): void { + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $this->login_as( 'subscriber' ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); } public function test_subscriber_cannot_request_draft_status(): void { @@ -491,7 +515,7 @@ public function test_author_cannot_see_other_authors_drafts(): void { $this->assertNotContains( $draft_a, $ids ); } - public function test_administrator_can_read_private_posts(): void { + public function test_administrator_can_access_private_posts(): void { $private = self::factory()->post->create( array( 'post_status' => 'private' ) ); $this->login_as( 'administrator' ); @@ -521,26 +545,23 @@ public function test_unexposed_post_type_is_rejected_by_input_schema(): void { * ------------------------------------------------------------------------- */ - public function test_password_protected_content_withheld_from_non_editor(): void { + public function test_raw_content_visible_to_editor(): void { $post_id = self::factory()->post->create( array( - 'post_status' => 'publish', - 'post_password' => 'secret', - 'post_content' => 'Top secret body.', - 'post_excerpt' => 'Secret excerpt.', + 'post_status' => 'publish', + 'post_content' => 'Public body with raw block markup.', ) ); - $this->login_as( 'subscriber' ); + $this->login_as( 'editor' ); $result = $this->ability()->execute( array( 'id' => $post_id, - 'fields' => array( 'id', 'raw_content', 'excerpt' ), + 'fields' => array( 'id', 'raw_content' ), ) ); - $this->assertSame( '', $result['posts'][0]['raw_content'] ); - $this->assertSame( '', $result['posts'][0]['excerpt'] ); + $this->assertSame( 'Public body with raw block markup.', $result['posts'][0]['raw_content'] ); } public function test_password_protected_content_visible_to_editor(): void { diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php index 837bea2b4639a..d2dda82176b96 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -132,6 +132,14 @@ public function test_subscriber_requesting_drafts_receives_403(): void { $this->assertSame( 403, $response->get_status() ); } + public function test_subscriber_requesting_published_posts_receives_403(): void { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->server->dispatch( $this->run_request( array( 'post_type' => 'post' ) ) ); + + $this->assertSame( 403, $response->get_status() ); + } + public function test_admin_query_returns_published_posts(): void { $post_id = self::factory()->post->create( array( @@ -182,7 +190,7 @@ public function test_pagination_returns_totals_in_body(): void { ) ) ); - $data = $response->get_data(); + $data = $response->get_data(); $this->assertSame( 200, $response->get_status() ); $this->assertCount( 2, $data['posts'] ); From 573b8da256d0030381bf4209131c404896ade882 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 19:53:21 +0100 Subject: [PATCH 06/69] Abilities API: Align content input schema defaults --- .../abilities/class-wp-content-abilities.php | 3 --- .../abilities-api/wpRegisterCoreContentAbility.php | 10 ++++++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 6845a5375bdd4..3da941f6a6eec 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -460,7 +460,6 @@ private function get_content_input_schema( array $post_types, array $statuses ): 'status' => array( 'type' => 'array', 'uniqueItems' => true, - 'default' => array( 'publish' ), 'items' => array( 'type' => 'string', 'enum' => $statuses, @@ -481,14 +480,12 @@ private function get_content_input_schema( array $post_types, array $statuses ): 'page' => array( 'type' => 'integer', 'minimum' => 1, - 'default' => 1, 'description' => __( 'Page of results to return.' ), ), 'per_page' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_PER_PAGE, - 'default' => self::DEFAULT_PER_PAGE, 'description' => __( 'Maximum number of posts to return per page.' ), ), ), diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index ab188fdde44b9..4c5af4e27451b 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -199,7 +199,6 @@ public function test_input_schema_status_and_fields_enums(): void { $this->assertContains( 'private', $status_enum ); $this->assertNotContains( 'trash', $status_enum ); $this->assertNotContains( 'auto-draft', $status_enum ); - $this->assertSame( array( 'publish' ), $properties['status']['default'] ); $fields_enum = $properties['fields']['items']['enum']; $this->assertContains( 'raw_content', $fields_enum ); @@ -207,6 +206,14 @@ public function test_input_schema_status_and_fields_enums(): void { $this->assertContains( 'author', $fields_enum ); } + public function test_input_schema_omits_oneof_branch_defaults(): void { + $properties = $this->ability()->get_input_schema()['oneOf'][1]['properties']; + + $this->assertArrayNotHasKey( 'default', $properties['status'] ); + $this->assertArrayNotHasKey( 'default', $properties['page'] ); + $this->assertArrayNotHasKey( 'default', $properties['per_page'] ); + } + public function test_output_schema_has_no_required_fields(): void { $schema = $this->ability()->get_output_schema(); $post_item = $schema['properties']['posts']['items']; @@ -624,7 +631,6 @@ public function test_per_page_is_capped(): void { $schema = $this->ability()->get_input_schema()['oneOf'][1]; $this->assertSame( 100, $schema['properties']['per_page']['maximum'] ); - $this->assertSame( 10, $schema['properties']['per_page']['default'] ); } public function test_single_post_reports_totals(): void { From cfeb69d0c096184058afa444a55fde33c1db4201 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 24 Jun 2026 09:38:02 +0100 Subject: [PATCH 07/69] Abilities API: Expose readable content fields --- .../abilities/class-wp-content-abilities.php | 419 ++++++++++++++---- .../wpRegisterCoreContentAbility.php | 124 +++++- 2 files changed, 438 insertions(+), 105 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 3da941f6a6eec..cfd6acb8e8119 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -12,10 +12,11 @@ /** * Core class used to register content-related abilities. * - * Provides the read-only `core/content` ability, which retrieves one or more editable + * Provides the read-only `core/content` ability, which retrieves one or more readable * posts of a post type that opts in via the `show_in_abilities` argument. It supports - * fetching a single editable post by ID or slug, or querying multiple editable posts - * with a small set of filters, and returns a basic, support-aware set of fields per post. + * fetching a single readable post by ID or slug, or querying multiple readable posts + * with a small set of filters, and returns a support-aware set of fields per post. + * Raw fields are only returned for posts the current user can edit. * * The class is intentionally structured around shared building blocks (exposed post type * discovery, schema generation, per-post formatting and permission checks) so a future @@ -57,11 +58,28 @@ final class WP_Content_Abilities { */ private const MAX_PER_PAGE = 100; + /** + * Fields that expose edit-context post data. + * + * Requests that explicitly include any of these fields require edit access. When + * fields are omitted, these fields are returned only for posts the current user + * can edit. + * + * @since 7.1.0 + * @var string[] + */ + private const EDIT_FIELDS = array( + 'title_raw', + 'excerpt_raw', + 'content_raw', + ); + /** * The fields a post object may expose, in output order. * - * Base fields (id, type, status, date, modified, slug, link) are always available. - * The remaining fields are only returned when the post type supports them. + * Read-context fields are returned for readable posts. Edit-context fields are + * returned only when explicitly requested by a user with edit access, or when + * fields are omitted and the user can edit the post. * * @since 7.1.0 * @var string[] @@ -71,12 +89,19 @@ final class WP_Content_Abilities { 'type', 'status', 'date', + 'date_gmt', 'modified', + 'modified_gmt', 'slug', 'link', - 'title', - 'excerpt', - 'raw_content', + 'title_raw', + 'title_rendered', + 'excerpt_raw', + 'excerpt_rendered', + 'excerpt_protected', + 'content_raw', + 'content_rendered', + 'content_protected', 'author', 'parent', ); @@ -126,7 +151,7 @@ private function register_get_content(): void { 'core/content', array( 'label' => __( 'Get Content' ), - 'description' => __( 'Retrieves one or more editable posts of a post type exposed to abilities. Fetch a single editable post by ID or by slug, or query multiple editable posts filtered by post type, status, author, or parent. Returns a basic, support-aware set of fields per post.' ), + 'description' => __( 'Retrieves one or more readable posts of a post type exposed to abilities. Fetch a single readable post by ID or by slug, or query multiple readable posts filtered by post type, status, author, or parent. Returns a basic, support-aware set of fields per post, with raw fields limited to users who can edit the post.' ), 'category' => self::CATEGORY, 'input_schema' => $this->get_content_input_schema( $post_types, $statuses ), 'output_schema' => $this->get_content_output_schema(), @@ -152,9 +177,9 @@ private function register_get_content(): void { * Permission callback for the `core/content` ability. * * Implements defense in depth: this gate decides whether the request may proceed at - * all (coarse, by post type capabilities), while the per-post `edit_post` meta - * capability check in {@see self::execute_get_content()} is the authoritative, - * row-level enforcement of author-scoped visibility. + * all, while the per-post read/edit checks in {@see self::execute_get_content()} + * are the authoritative, row-level enforcement. Requests that explicitly ask for + * edit-context fields require edit access before execution. * * @since 7.1.0 * @@ -165,6 +190,12 @@ public function check_permission( $input = array() ): bool { $input = is_array( $input ) ? $input : array(); $exposed = $this->exposed_post_types ?? $this->get_exposed_post_types(); + if ( ! is_user_logged_in() ) { + return false; + } + + $requires_edit = $this->has_explicit_edit_fields( $input ); + // Single-post mode (by ID). if ( ! empty( $input['id'] ) ) { $post = get_post( $this->input_int( $input['id'] ) ); @@ -176,7 +207,7 @@ public function check_permission( $input = array() ): bool { return false; } - return current_user_can( 'edit_post', $post->ID ); + return $requires_edit ? current_user_can( 'edit_post', $post->ID ) : $this->check_read_permission( $post ); } // Query / slug mode requires an exposed post type. @@ -185,10 +216,14 @@ public function check_permission( $input = array() ): bool { return false; } - $post_type_object = $exposed[ $post_type ]; - $edit_posts_capability = $this->capability( $post_type_object, 'edit_posts', 'edit_posts' ); + $post_type_object = $exposed[ $post_type ]; + if ( $requires_edit ) { + $edit_posts_capability = $this->capability( $post_type_object, 'edit_posts', 'edit_posts' ); - return current_user_can( $edit_posts_capability ); + return current_user_can( $edit_posts_capability ); + } + + return $this->can_query_statuses( $input, $post_type_object ); } /** @@ -219,6 +254,99 @@ private function input_int( $value ): int { return is_scalar( $value ) ? absint( $value ) : 0; } + /** + * Checks whether the input explicitly requests edit-context fields. + * + * Omitted fields are not treated as edit-intent: default responses include the + * fields visible for each individual post. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return bool True if edit-context fields were explicitly requested. + */ + private function has_explicit_edit_fields( array $input ): bool { + if ( empty( $input['fields'] ) || ! is_array( $input['fields'] ) ) { + return false; + } + + $requested = array_filter( $input['fields'], 'is_string' ); + + return array() !== array_intersect( self::EDIT_FIELDS, $requested ); + } + + /** + * Checks whether the current user may query the requested statuses. + * + * This mirrors the REST posts controller's conservative collection-status gate: + * requesting non-default statuses requires edit access, except `private`, which + * may be queried by users who can read private posts. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @param WP_Post_Type $post_type_object The post type object. + * @return bool True if the requested statuses may be queried. + */ + private function can_query_statuses( array $input, WP_Post_Type $post_type_object ): bool { + $edit_posts_capability = $this->capability( $post_type_object, 'edit_posts', 'edit_posts' ); + $read_private_capability = $this->capability( $post_type_object, 'read_private_posts', 'read_private_posts' ); + + foreach ( $this->normalize_statuses( $input ) as $status ) { + if ( 'publish' === $status ) { + continue; + } + + if ( 'private' === $status && current_user_can( $read_private_capability ) ) { + continue; + } + + if ( current_user_can( $edit_posts_capability ) ) { + continue; + } + + return false; + } + + return true; + } + + /** + * Checks if a post can be read by the current user. + * + * Mirrors the REST posts controller's read permission, while keeping this ability + * authenticated-only via {@see self::check_permission()}. + * + * @since 7.1.0 + * + * @param WP_Post $post Post object. + * @return bool Whether the post can be read. + */ + private function check_read_permission( WP_Post $post ): bool { + $post_type = get_post_type_object( $post->post_type ); + if ( ! $post_type instanceof WP_Post_Type || empty( $post_type->show_in_abilities ) ) { + return false; + } + + if ( 'publish' === $post->post_status || current_user_can( 'read_post', $post->ID ) ) { + return true; + } + + $post_status_object = get_post_status_object( $post->post_status ); + if ( $post_status_object && $post_status_object->public ) { + return true; + } + + if ( 'inherit' === $post->post_status && $post->post_parent > 0 ) { + $parent = get_post( $post->post_parent ); + if ( $parent instanceof WP_Post ) { + return $this->check_read_permission( $parent ); + } + } + + return 'inherit' === $post->post_status; + } + /** * Executes the `core/content` ability. * @@ -228,9 +356,10 @@ private function input_int( $value ): int { * @return array|WP_Error A map with a `posts` list, or a WP_Error on failure. */ public function execute_get_content( $input = array() ) { - $input = is_array( $input ) ? $input : array(); - $exposed = $this->exposed_post_types ?? $this->get_exposed_post_types(); - $fields = $this->normalize_fields( $input ); + $input = is_array( $input ) ? $input : array(); + $exposed = $this->exposed_post_types ?? $this->get_exposed_post_types(); + $fields = $this->normalize_fields( $input ); + $requires_edit = $this->has_explicit_edit_fields( $input ); // Single-post mode (by ID). if ( ! empty( $input['id'] ) ) { @@ -239,7 +368,8 @@ public function execute_get_content( $input = array() ) { if ( ! $post || ! isset( $exposed[ $post->post_type ] ) || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) - || ! current_user_can( 'edit_post', $post->ID ) + || ( $requires_edit && ! current_user_can( 'edit_post', $post->ID ) ) + || ( ! $requires_edit && ! $this->check_read_permission( $post ) ) ) { return $this->not_found_error(); } @@ -261,12 +391,14 @@ public function execute_get_content( $input = array() ) { $page = isset( $input['page'] ) ? max( 1, $this->input_int( $input['page'] ) ) : 1; $query_args = array( - 'post_type' => $post_type, - 'post_status' => $this->normalize_statuses( $input ), - 'posts_per_page' => $per_page, - 'paged' => $page, - 'perm' => 'editable', - 'ignore_sticky_posts' => true, + 'post_type' => $post_type, + 'post_status' => $this->normalize_statuses( $input ), + 'posts_per_page' => $per_page, + 'paged' => $page, + 'perm' => $requires_edit ? 'editable' : 'readable', + 'ignore_sticky_posts' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, ); if ( ! empty( $input['slug'] ) && is_string( $input['slug'] ) ) { @@ -288,10 +420,17 @@ public function execute_get_content( $input = array() ) { if ( ! $post instanceof WP_Post ) { continue; } - if ( ! current_user_can( 'edit_post', $post->ID ) ) { + if ( $requires_edit && ! current_user_can( 'edit_post', $post->ID ) ) { + continue; + } + if ( ! $requires_edit && ! $this->check_read_permission( $post ) ) { continue; } - $posts[] = $this->format_post( $post, $fields ); + $formatted = $this->format_post( $post, $fields ); + if ( array() === $formatted ) { + continue; + } + $posts[] = $formatted; } return array( @@ -372,7 +511,8 @@ private function normalize_statuses( array $input ): array { /** * Normalizes the requested fields to the supported set, defaulting to all fields. * - * An empty or absent `fields` value selects every field. + * An empty or absent `fields` value selects every field. Edit-context fields are + * still omitted per post when the current user cannot edit that post. * * @since 7.1.0 * @@ -396,8 +536,8 @@ private function normalize_fields( array $input ): array { * The ability has two mutually exclusive modes, modeled as a `oneOf` so invalid * combinations are rejected rather than silently ignored: * - * - Get a single editable post by `id` (optionally guarded by `post_type`). - * - Query a set of editable posts by `post_type` plus filters (`slug`, `status`, + * - Get a single readable post by `id` (optionally guarded by `post_type`). + * - Query a set of readable posts by `post_type` plus filters (`slug`, `status`, * `author`, `parent`, `page`, `per_page`). * * Each mode sets `additionalProperties: false`, so e.g. passing `per_page` alongside `id` @@ -417,41 +557,41 @@ private function get_content_input_schema( array $post_types, array $statuses ): 'type' => 'string', 'enum' => $this->fields, ), - 'description' => __( 'Limit each returned post to these fields. If omitted, all supported fields are returned.' ), + 'description' => __( 'Limit each returned post to these fields. If omitted, all fields visible to the current user are returned. Explicit raw field requests require edit access.' ), ); return array( 'type' => 'object', 'oneOf' => array( - // Mode 1: retrieve a single editable post by ID. + // Mode 1: retrieve a single readable post by ID. array( - 'title' => __( 'Get a single editable post by ID' ), + 'title' => __( 'Get a single readable post by ID' ), 'required' => array( 'id' ), 'additionalProperties' => false, 'properties' => array( 'id' => array( 'type' => 'integer', 'minimum' => 1, - 'description' => __( 'Retrieve a single editable post by ID.' ), + 'description' => __( 'Retrieve a single readable post by ID.' ), ), 'post_type' => array( 'type' => 'string', 'enum' => $post_types, - 'description' => __( 'Optional. Restrict the lookup to this post type; the post is returned only if it matches and the current user can edit it.' ), + 'description' => __( 'Optional. Restrict the lookup to this post type; the post is returned only if it matches and the current user can read it.' ), ), 'fields' => $fields, ), ), - // Mode 2: query a set of editable posts by post type and filters. + // Mode 2: query a set of readable posts by post type and filters. array( - 'title' => __( 'Query editable posts by type and filters' ), + 'title' => __( 'Query readable posts by type and filters' ), 'required' => array( 'post_type' ), 'additionalProperties' => false, 'properties' => array( 'post_type' => array( 'type' => 'string', 'enum' => $post_types, - 'description' => __( 'Post type to query for editable posts.' ), + 'description' => __( 'Post type to query for readable posts.' ), ), 'slug' => array( 'type' => 'string', @@ -464,7 +604,7 @@ private function get_content_input_schema( array $post_types, array $statuses ): 'type' => 'string', 'enum' => $statuses, ), - 'description' => __( 'Filter editable posts by one or more post statuses. Defaults to publish. Non-published statuses require the appropriate capabilities.' ), + 'description' => __( 'Filter readable posts by one or more post statuses. Defaults to publish. Non-published statuses require the appropriate capabilities.' ), ), 'author' => array( 'type' => 'integer', @@ -509,47 +649,75 @@ private function get_content_output_schema(): array { 'type' => 'object', 'additionalProperties' => false, 'properties' => array( - 'id' => array( + 'id' => array( 'type' => 'integer', 'description' => __( 'The post ID.' ), ), - 'type' => array( + 'type' => array( 'type' => 'string', 'description' => __( 'The post type.' ), ), - 'status' => array( + 'status' => array( 'type' => 'string', 'description' => __( 'The post status.' ), ), - 'date' => array( + 'date' => array( + 'type' => 'string', + 'description' => __( "The publication date, in ISO 8601 format using the site's timezone." ), + ), + 'date_gmt' => array( + 'type' => 'string', + 'description' => __( 'The publication date, in ISO 8601 format as GMT.' ), + ), + 'modified' => array( 'type' => 'string', - 'description' => __( 'The publication date, in ISO 8601 format (GMT).' ), + 'description' => __( "The last modified date, in ISO 8601 format using the site's timezone." ), ), - 'modified' => array( + 'modified_gmt' => array( 'type' => 'string', - 'description' => __( 'The last modified date, in ISO 8601 format (GMT).' ), + 'description' => __( 'The last modified date, in ISO 8601 format as GMT.' ), ), - 'slug' => array( + 'slug' => array( 'type' => 'string', 'description' => __( 'The post slug.' ), ), - 'link' => array( + 'link' => array( 'type' => 'string', 'description' => __( 'The permalink URL.' ), ), - 'title' => array( + 'title_raw' => array( + 'type' => 'string', + 'description' => __( 'The raw post title. Present when the post type supports titles and the current user can edit the post.' ), + ), + 'title_rendered' => array( 'type' => 'string', - 'description' => __( 'The post title. Present when the post type supports titles.' ), + 'description' => __( 'The rendered post title. Present when the post type supports titles.' ), ), - 'excerpt' => array( + 'excerpt_raw' => array( 'type' => 'string', - 'description' => __( 'The post excerpt. Present when the post type supports excerpts. Empty when withheld for a password-protected post.' ), + 'description' => __( 'The raw post excerpt. Present when the post type supports excerpts and the current user can edit the post.' ), ), - 'raw_content' => array( + 'excerpt_rendered' => array( 'type' => 'string', - 'description' => __( 'The raw, unfiltered post content (block markup). Present when the post type supports the editor.' ), + 'description' => __( 'The rendered post excerpt. Present when the post type supports excerpts. Empty when withheld for a password-protected post.' ), ), - 'author' => array( + 'excerpt_protected' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the excerpt is protected with a password. Present when the post type supports excerpts.' ), + ), + 'content_raw' => array( + 'type' => 'string', + 'description' => __( 'The raw, unfiltered post content (block markup). Present when the post type supports the editor and the current user can edit the post.' ), + ), + 'content_rendered' => array( + 'type' => 'string', + 'description' => __( 'The rendered post content. Present when the post type supports the editor. Empty when withheld for a password-protected post.' ), + ), + 'content_protected' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the content is protected with a password. Present when the post type supports the editor.' ), + ), + 'author' => array( 'type' => 'object', 'additionalProperties' => false, 'properties' => array( @@ -564,39 +732,41 @@ private function get_content_output_schema(): array { ), 'description' => __( 'The post author. Present when the post type supports authors.' ), ), - 'parent' => array( + 'parent' => array( 'type' => 'integer', 'description' => __( 'The parent post ID. Present for hierarchical post types.' ), ), ), ); - return array( - 'type' => 'object', - 'additionalProperties' => false, - 'properties' => array( - 'posts' => array( - 'type' => 'array', - 'description' => __( 'The editable posts matching the request. A single-element list when requested by ID.' ), - 'items' => $post_schema, - ), - 'total' => array( - 'type' => 'integer', - 'description' => __( 'Total number of posts matching the query, across all pages, after applying the editable permission filter to the query. Surfaced over REST as the X-WP-Total header.' ), - ), - 'total_pages' => array( - 'type' => 'integer', - 'description' => __( 'Total number of query result pages available after applying the editable permission filter to the query. Surfaced over REST as the X-WP-TotalPages header.' ), + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'posts', 'total', 'total_pages' ), + 'properties' => array( + 'posts' => array( + 'type' => 'array', + 'description' => __( 'The readable posts matching the request. A single-element list when requested by ID.' ), + 'items' => $post_schema, + ), + 'total' => array( + 'type' => 'integer', + 'description' => __( 'Total number of posts matching the query, across all pages, after applying the permission filter to the query. Surfaced over REST as the X-WP-Total header.' ), + ), + 'total_pages' => array( + 'type' => 'integer', + 'description' => __( 'Total number of query result pages available after applying the permission filter to the query. Surfaced over REST as the X-WP-TotalPages header.' ), + ), ), - ), - ); + ); } /** * Formats a post into the ability output shape. * - * Only the requested fields that the post type supports are included. Content and - * excerpt are withheld for password-protected posts unless the current user can edit + * Only the requested fields that the post type supports and the current user can see + * are included. Raw fields are edit-context fields; rendered fields are read-context + * fields and are withheld for password-protected posts unless the current user can edit * the post, mirroring the REST API behavior. * * @since 7.1.0 @@ -625,10 +795,16 @@ private function format_post( WP_Post $post, array $fields ): array { $data['status'] = $post->post_status; } if ( $wants( 'date' ) ) { - $data['date'] = $this->format_gmt_date( $post, 'date' ); + $data['date'] = $this->format_local_date( $post, 'date' ); + } + if ( $wants( 'date_gmt' ) ) { + $data['date_gmt'] = $this->format_gmt_date( $post, 'date' ); } if ( $wants( 'modified' ) ) { - $data['modified'] = $this->format_gmt_date( $post, 'modified' ); + $data['modified'] = $this->format_local_date( $post, 'modified' ); + } + if ( $wants( 'modified_gmt' ) ) { + $data['modified_gmt'] = $this->format_gmt_date( $post, 'modified' ); } if ( $wants( 'slug' ) ) { $data['slug'] = $post->post_name; @@ -637,16 +813,36 @@ private function format_post( WP_Post $post, array $fields ): array { $data['link'] = (string) get_permalink( $post ); } - if ( $wants( 'title' ) && post_type_supports( $type, 'title' ) ) { - $data['title'] = $this->get_title( $post ); + if ( $wants( 'title_raw' ) && post_type_supports( $type, 'title' ) && $can_edit ) { + $data['title_raw'] = $post->post_title; + } + + if ( $wants( 'title_rendered' ) && post_type_supports( $type, 'title' ) ) { + $data['title_rendered'] = $this->get_title( $post ); + } + + if ( $wants( 'excerpt_raw' ) && post_type_supports( $type, 'excerpt' ) && $can_edit ) { + $data['excerpt_raw'] = $post->post_excerpt; + } + + if ( $wants( 'excerpt_rendered' ) && post_type_supports( $type, 'excerpt' ) ) { + $data['excerpt_rendered'] = $protected ? '' : (string) get_the_excerpt( $post ); } - if ( $wants( 'excerpt' ) && post_type_supports( $type, 'excerpt' ) ) { - $data['excerpt'] = $protected ? '' : (string) get_the_excerpt( $post ); + if ( $wants( 'excerpt_protected' ) && post_type_supports( $type, 'excerpt' ) ) { + $data['excerpt_protected'] = (bool) $post->post_password; } - if ( $wants( 'raw_content' ) && post_type_supports( $type, 'editor' ) ) { - $data['raw_content'] = $can_edit && ! $protected ? (string) $post->post_content : ''; + if ( $wants( 'content_raw' ) && post_type_supports( $type, 'editor' ) && $can_edit ) { + $data['content_raw'] = $post->post_content; + } + + if ( $wants( 'content_rendered' ) && post_type_supports( $type, 'editor' ) ) { + $data['content_rendered'] = $protected ? '' : $this->get_rendered_content( $post ); + } + + if ( $wants( 'content_protected' ) && post_type_supports( $type, 'editor' ) ) { + $data['content_protected'] = (bool) $post->post_password; } if ( $wants( 'author' ) && post_type_supports( $type, 'author' ) ) { @@ -697,6 +893,59 @@ public function return_raw_title_format(): string { return '%s'; } + /** + * Returns post content transformed for display. + * + * Mirrors the REST posts controller by preparing post globals before applying + * `the_content`, then restoring the previous global post context. + * + * @since 7.1.0 + * + * @param WP_Post $post The post object. + * @return string Rendered post content. + */ + private function get_rendered_content( WP_Post $post ): string { + $previous_post = $GLOBALS['post'] ?? null; + + $GLOBALS['post'] = $post; + setup_postdata( $post ); + + /** This filter is documented in wp-includes/post-template.php. */ + $content = apply_filters( 'the_content', $post->post_content ); + + if ( $previous_post instanceof WP_Post ) { + $GLOBALS['post'] = $previous_post; + setup_postdata( $previous_post ); + } else { + unset( $GLOBALS['post'] ); + wp_reset_postdata(); + } + + return (string) $content; + } + + /** + * Formats a post date field as an ISO 8601 string in the site's timezone. + * + * @since 7.1.0 + * + * @param WP_Post $post The post object. + * @param string $field Either 'date' or 'modified'. + * @return string The ISO 8601 date, or an empty string if unavailable. + */ + private function format_local_date( WP_Post $post, string $field ): string { + $field = 'modified' === $field ? 'modified' : 'date'; + $datetime = get_post_datetime( $post, $field, 'local' ); + if ( $datetime ) { + return $datetime->format( 'c' ); + } + + $local = 'modified' === $field ? $post->post_modified : $post->post_date; + $timestamp = mysql2date( 'U', $local, false ); + + return $timestamp ? wp_date( 'c', (int) $timestamp ) : ''; + } + /** * Formats a post date field as an ISO 8601 string in GMT. * diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 4c5af4e27451b..608f6e94ca6ec 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -201,8 +201,10 @@ public function test_input_schema_status_and_fields_enums(): void { $this->assertNotContains( 'auto-draft', $status_enum ); $fields_enum = $properties['fields']['items']['enum']; - $this->assertContains( 'raw_content', $fields_enum ); - $this->assertContains( 'title', $fields_enum ); + $this->assertContains( 'content_raw', $fields_enum ); + $this->assertContains( 'content_rendered', $fields_enum ); + $this->assertContains( 'title_raw', $fields_enum ); + $this->assertContains( 'title_rendered', $fields_enum ); $this->assertContains( 'author', $fields_enum ); } @@ -218,9 +220,11 @@ public function test_output_schema_has_no_required_fields(): void { $schema = $this->ability()->get_output_schema(); $post_item = $schema['properties']['posts']['items']; + $this->assertSame( array( 'posts', 'total', 'total_pages' ), $schema['required'] ); $this->assertArrayNotHasKey( 'required', $post_item ); $this->assertFalse( $post_item['additionalProperties'] ); - $this->assertArrayHasKey( 'raw_content', $post_item['properties'] ); + $this->assertArrayHasKey( 'content_raw', $post_item['properties'] ); + $this->assertArrayHasKey( 'content_rendered', $post_item['properties'] ); } /* @@ -244,8 +248,10 @@ public function test_get_single_published_post_by_id(): void { $this->assertIsArray( $result ); $this->assertCount( 1, $result['posts'] ); $this->assertSame( $post_id, $result['posts'][0]['id'] ); - $this->assertSame( 'Hello Content', $result['posts'][0]['title'] ); - $this->assertSame( 'Body here.', $result['posts'][0]['raw_content'] ); + $this->assertSame( 'Hello Content', $result['posts'][0]['title_raw'] ); + $this->assertSame( 'Hello Content', $result['posts'][0]['title_rendered'] ); + $this->assertSame( 'Body here.', $result['posts'][0]['content_raw'] ); + $this->assertStringContainsString( 'Body here.', $result['posts'][0]['content_rendered'] ); $this->assertSame( 'post', $result['posts'][0]['type'] ); } @@ -405,11 +411,11 @@ public function test_fields_filter_limits_returned_keys(): void { $result = $this->ability()->execute( array( 'id' => $post_id, - 'fields' => array( 'id', 'title' ), + 'fields' => array( 'id', 'title_rendered' ), ) ); - $this->assertSame( array( 'id', 'title' ), array_keys( $result['posts'][0] ) ); + $this->assertSame( array( 'id', 'title_rendered' ), array_keys( $result['posts'][0] ) ); } public function test_unsupported_fields_are_omitted_for_post_type(): void { @@ -444,20 +450,76 @@ public function test_logged_out_user_is_denied(): void { $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); } - public function test_subscriber_cannot_request_published_content(): void { + public function test_subscriber_can_request_published_content(): void { + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Visible to subscribers', + 'post_content' => 'Rendered body for subscribers.', + 'post_status' => 'publish', + ) + ); $this->login_as( 'subscriber' ); - $result = $this->ability()->execute( array( 'post_type' => 'post' ) ); + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'fields' => array( 'id', 'title_rendered', 'content_rendered' ), + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $post_id, $ids ); + $post_index = array_search( $post_id, $ids, true ); + $this->assertIsInt( $post_index ); + $post = $result['posts'][ $post_index ]; + $this->assertSame( 'Visible to subscribers', $post['title_rendered'] ); + $this->assertStringContainsString( 'Rendered body for subscribers.', $post['content_rendered'] ); + $this->assertArrayNotHasKey( 'content_raw', $post ); + } + + public function test_subscriber_can_get_single_published_post_by_id(): void { + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Readable single', + 'post_content' => 'Readable single body.', + 'post_status' => 'publish', + ) + ); + $this->login_as( 'subscriber' ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Readable single', $result['posts'][0]['title_rendered'] ); + $this->assertStringContainsString( 'Readable single body.', $result['posts'][0]['content_rendered'] ); + $this->assertArrayNotHasKey( 'title_raw', $result['posts'][0] ); + $this->assertArrayNotHasKey( 'content_raw', $result['posts'][0] ); + } + + public function test_subscriber_cannot_request_raw_fields_in_query_mode(): void { + $this->login_as( 'subscriber' ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'fields' => array( 'content_raw' ), + ) + ); $this->assertWPError( $result ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); } - public function test_subscriber_cannot_get_single_published_post_by_id(): void { + public function test_subscriber_cannot_request_raw_fields_for_single_post(): void { $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); $this->login_as( 'subscriber' ); - $result = $this->ability()->execute( array( 'id' => $post_id ) ); + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'content_raw' ), + ) + ); $this->assertWPError( $result ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); @@ -561,14 +623,14 @@ public function test_raw_content_visible_to_editor(): void { ); $this->login_as( 'editor' ); - $result = $this->ability()->execute( - array( - 'id' => $post_id, - 'fields' => array( 'id', 'raw_content' ), - ) - ); + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'content_raw' ), + ) + ); - $this->assertSame( 'Public body with raw block markup.', $result['posts'][0]['raw_content'] ); + $this->assertSame( 'Public body with raw block markup.', $result['posts'][0]['content_raw'] ); } public function test_password_protected_content_visible_to_editor(): void { @@ -581,14 +643,36 @@ public function test_password_protected_content_visible_to_editor(): void { ); $this->login_as( 'editor' ); + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'content_raw', 'content_rendered' ), + ) + ); + + $this->assertSame( 'Top secret body.', $result['posts'][0]['content_raw'] ); + $this->assertStringContainsString( 'Top secret body.', $result['posts'][0]['content_rendered'] ); + } + + public function test_password_protected_rendered_content_is_empty_for_subscriber(): void { + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'Hidden rendered body.', + ) + ); + + $this->login_as( 'subscriber' ); $result = $this->ability()->execute( array( 'id' => $post_id, - 'fields' => array( 'id', 'raw_content' ), + 'fields' => array( 'id', 'content_rendered', 'content_protected' ), ) ); - $this->assertSame( 'Top secret body.', $result['posts'][0]['raw_content'] ); + $this->assertSame( '', $result['posts'][0]['content_rendered'] ); + $this->assertTrue( $result['posts'][0]['content_protected'] ); } /* From 05b5c8b6d5793afed21b62203dae8663e016021f Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 24 Jun 2026 15:52:23 +0100 Subject: [PATCH 08/69] Tests: Update content ability REST permissions --- .../wpRestAbilitiesContentController.php | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php index d2dda82176b96..41f1937600f2c 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -132,10 +132,50 @@ public function test_subscriber_requesting_drafts_receives_403(): void { $this->assertSame( 403, $response->get_status() ); } - public function test_subscriber_requesting_published_posts_receives_403(): void { + public function test_subscriber_requesting_published_posts_receives_readable_fields(): void { + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Published for subscriber via REST', + 'post_content' => 'Subscriber REST body.', + 'post_status' => 'publish', + ) + ); + wp_set_current_user( self::$subscriber_id ); - $response = $this->server->dispatch( $this->run_request( array( 'post_type' => 'post' ) ) ); + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'fields' => array( 'id', 'title_rendered', 'content_rendered' ), + ) + ) + ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertContains( $post_id, wp_list_pluck( $data['posts'], 'id' ) ); + + $post_index = array_search( $post_id, wp_list_pluck( $data['posts'], 'id' ), true ); + $this->assertIsInt( $post_index ); + + $post = $data['posts'][ $post_index ]; + $this->assertSame( 'Published for subscriber via REST', $post['title_rendered'] ); + $this->assertStringContainsString( 'Subscriber REST body.', $post['content_rendered'] ); + $this->assertArrayNotHasKey( 'content_raw', $post ); + } + + public function test_subscriber_requesting_raw_fields_receives_403(): void { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'fields' => array( 'content_raw' ), + ) + ) + ); $this->assertSame( 403, $response->get_status() ); } From b249fd6fc275039b415aaf78189f848db6f2c215 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 24 Jun 2026 16:43:05 +0100 Subject: [PATCH 09/69] Abilities API: Address content review follow-ups --- .../abilities/class-wp-content-abilities.php | 109 ++++++------------ .../wpRegisterCoreContentAbility.php | 98 +++++++++++++++- 2 files changed, 129 insertions(+), 78 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index cfd6acb8e8119..623a66738efb9 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -145,7 +145,7 @@ private function register_get_content(): void { $this->exposed_post_types = $this->get_exposed_post_types(); $post_types = array_keys( $this->exposed_post_types ); - $statuses = $this->get_available_statuses(); + $statuses = array_values( get_post_stati( array( 'internal' => false ) ) ); wp_register_ability( 'core/content', @@ -218,30 +218,12 @@ public function check_permission( $input = array() ): bool { $post_type_object = $exposed[ $post_type ]; if ( $requires_edit ) { - $edit_posts_capability = $this->capability( $post_type_object, 'edit_posts', 'edit_posts' ); - - return current_user_can( $edit_posts_capability ); + return current_user_can( $post_type_object->cap->edit_posts ); } return $this->can_query_statuses( $input, $post_type_object ); } - /** - * Resolves a capability name from a post type's capability object, with a fallback. - * - * @since 7.1.0 - * - * @param WP_Post_Type $post_type_object The post type object. - * @param string $name Capability key on the post type's `cap` object. - * @param string $fallback Fallback capability name if unset or non-string. - * @return string The resolved capability name. - */ - private function capability( WP_Post_Type $post_type_object, string $name, string $fallback ): string { - $capability = $post_type_object->cap->$name ?? $fallback; - - return is_string( $capability ) ? $capability : $fallback; - } - /** * Casts a raw input value to a non-negative integer. * @@ -270,9 +252,9 @@ private function has_explicit_edit_fields( array $input ): bool { return false; } - $requested = array_filter( $input['fields'], 'is_string' ); + $requested_fields = array_filter( $input['fields'], 'is_string' ); - return array() !== array_intersect( self::EDIT_FIELDS, $requested ); + return array() !== array_intersect( self::EDIT_FIELDS, $requested_fields ); } /** @@ -289,19 +271,16 @@ private function has_explicit_edit_fields( array $input ): bool { * @return bool True if the requested statuses may be queried. */ private function can_query_statuses( array $input, WP_Post_Type $post_type_object ): bool { - $edit_posts_capability = $this->capability( $post_type_object, 'edit_posts', 'edit_posts' ); - $read_private_capability = $this->capability( $post_type_object, 'read_private_posts', 'read_private_posts' ); - foreach ( $this->normalize_statuses( $input ) as $status ) { if ( 'publish' === $status ) { continue; } - if ( 'private' === $status && current_user_can( $read_private_capability ) ) { + if ( 'private' === $status && current_user_can( $post_type_object->cap->read_private_posts ) ) { continue; } - if ( current_user_can( $edit_posts_capability ) ) { + if ( current_user_can( $post_type_object->cap->edit_posts ) ) { continue; } @@ -464,29 +443,13 @@ private function normalize_per_page( array $input ): int { * @return array Exposed post type objects keyed by name. */ private function get_exposed_post_types(): array { - $exposed = array(); + $exposed_post_types = array(); - foreach ( get_post_types( array(), 'objects' ) as $post_type_object ) { - if ( empty( $post_type_object->show_in_abilities ) ) { - continue; - } - $exposed[ $post_type_object->name ] = $post_type_object; + foreach ( get_post_types( array( 'show_in_abilities' => true ), 'objects' ) as $post_type_object ) { + $exposed_post_types[ $post_type_object->name ] = $post_type_object; } - return $exposed; - } - - /** - * Returns the post statuses that may be requested through the ability. - * - * Internal statuses (auto-draft, inherit, trash) are excluded. - * - * @since 7.1.0 - * - * @return string[] List of public, non-internal post status slugs. - */ - private function get_available_statuses(): array { - return array_values( get_post_stati( array( 'internal' => false ) ) ); + return $exposed_post_types; } /** @@ -524,8 +487,8 @@ private function normalize_fields( array $input ): array { return $this->fields; } - $requested = array_filter( $input['fields'], 'is_string' ); - $fields = array_intersect( $this->fields, $requested ); + $requested_fields = array_filter( $input['fields'], 'is_string' ); + $fields = array_intersect( $this->fields, $requested_fields ); return array() === $fields ? $this->fields : array_values( $fields ); } @@ -776,76 +739,76 @@ private function get_content_output_schema(): array { * @return array The formatted post data. */ private function format_post( WP_Post $post, array $fields ): array { - $type = $post->post_type; - $wants = static function ( string $field ) use ( $fields ): bool { + $post_type = $post->post_type; + $fields_requested = static function ( string $field ) use ( $fields ): bool { return in_array( $field, $fields, true ); }; - $can_edit = current_user_can( 'edit_post', $post->ID ); - $protected = post_password_required( $post ) && ! $can_edit; + $can_edit = current_user_can( 'edit_post', $post->ID ); + $protected = post_password_required( $post ) && ! $can_edit; $data = array(); - if ( $wants( 'id' ) ) { + if ( $fields_requested( 'id' ) ) { $data['id'] = (int) $post->ID; } - if ( $wants( 'type' ) ) { - $data['type'] = $type; + if ( $fields_requested( 'type' ) ) { + $data['type'] = $post_type; } - if ( $wants( 'status' ) ) { + if ( $fields_requested( 'status' ) ) { $data['status'] = $post->post_status; } - if ( $wants( 'date' ) ) { + if ( $fields_requested( 'date' ) ) { $data['date'] = $this->format_local_date( $post, 'date' ); } - if ( $wants( 'date_gmt' ) ) { + if ( $fields_requested( 'date_gmt' ) ) { $data['date_gmt'] = $this->format_gmt_date( $post, 'date' ); } - if ( $wants( 'modified' ) ) { + if ( $fields_requested( 'modified' ) ) { $data['modified'] = $this->format_local_date( $post, 'modified' ); } - if ( $wants( 'modified_gmt' ) ) { + if ( $fields_requested( 'modified_gmt' ) ) { $data['modified_gmt'] = $this->format_gmt_date( $post, 'modified' ); } - if ( $wants( 'slug' ) ) { + if ( $fields_requested( 'slug' ) ) { $data['slug'] = $post->post_name; } - if ( $wants( 'link' ) ) { + if ( $fields_requested( 'link' ) ) { $data['link'] = (string) get_permalink( $post ); } - if ( $wants( 'title_raw' ) && post_type_supports( $type, 'title' ) && $can_edit ) { + if ( $fields_requested( 'title_raw' ) && post_type_supports( $post_type, 'title' ) && $can_edit ) { $data['title_raw'] = $post->post_title; } - if ( $wants( 'title_rendered' ) && post_type_supports( $type, 'title' ) ) { + if ( $fields_requested( 'title_rendered' ) && post_type_supports( $post_type, 'title' ) ) { $data['title_rendered'] = $this->get_title( $post ); } - if ( $wants( 'excerpt_raw' ) && post_type_supports( $type, 'excerpt' ) && $can_edit ) { + if ( $fields_requested( 'excerpt_raw' ) && post_type_supports( $post_type, 'excerpt' ) && $can_edit ) { $data['excerpt_raw'] = $post->post_excerpt; } - if ( $wants( 'excerpt_rendered' ) && post_type_supports( $type, 'excerpt' ) ) { + if ( $fields_requested( 'excerpt_rendered' ) && post_type_supports( $post_type, 'excerpt' ) ) { $data['excerpt_rendered'] = $protected ? '' : (string) get_the_excerpt( $post ); } - if ( $wants( 'excerpt_protected' ) && post_type_supports( $type, 'excerpt' ) ) { + if ( $fields_requested( 'excerpt_protected' ) && post_type_supports( $post_type, 'excerpt' ) ) { $data['excerpt_protected'] = (bool) $post->post_password; } - if ( $wants( 'content_raw' ) && post_type_supports( $type, 'editor' ) && $can_edit ) { + if ( $fields_requested( 'content_raw' ) && post_type_supports( $post_type, 'editor' ) && $can_edit ) { $data['content_raw'] = $post->post_content; } - if ( $wants( 'content_rendered' ) && post_type_supports( $type, 'editor' ) ) { + if ( $fields_requested( 'content_rendered' ) && post_type_supports( $post_type, 'editor' ) ) { $data['content_rendered'] = $protected ? '' : $this->get_rendered_content( $post ); } - if ( $wants( 'content_protected' ) && post_type_supports( $type, 'editor' ) ) { + if ( $fields_requested( 'content_protected' ) && post_type_supports( $post_type, 'editor' ) ) { $data['content_protected'] = (bool) $post->post_password; } - if ( $wants( 'author' ) && post_type_supports( $type, 'author' ) ) { + if ( $fields_requested( 'author' ) && post_type_supports( $post_type, 'author' ) ) { $author = get_userdata( (int) $post->post_author ); $data['author'] = array( 'id' => (int) $post->post_author, @@ -853,7 +816,7 @@ private function format_post( WP_Post $post, array $fields ): array { ); } - if ( $wants( 'parent' ) && is_post_type_hierarchical( $type ) ) { + if ( $fields_requested( 'parent' ) && is_post_type_hierarchical( $post_type ) ) { $data['parent'] = (int) $post->post_parent; } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 608f6e94ca6ec..7102fcdbc0711 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -98,6 +98,25 @@ private function login_as( string $role ): int { return $user_id; } + /** + * Returns roles that can read public posts but cannot edit another user's post. + * + * @return array Role test cases. + */ + public function data_roles_without_edit_access_to_other_users_posts(): array { + return array( + 'subscriber' => array( + 'role' => 'subscriber', + ), + 'contributor' => array( + 'role' => 'contributor', + ), + 'author' => array( + 'role' => 'author', + ), + ); + } + /** * Convenience accessor for the ability. * @@ -525,6 +544,66 @@ public function test_subscriber_cannot_request_raw_fields_for_single_post(): voi $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); } + /** + * Users who cannot edit another user's post do not receive raw fields by default. + * + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * + * @param string $role The role to test. + */ + public function test_default_fields_omit_raw_fields_for_roles_without_edit_access_to_other_users_posts( string $role ): void { + $post_owner_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $post_id = self::factory()->post->create( + array( + 'post_author' => $post_owner_id, + 'post_title' => 'Readable title', + 'post_content' => 'Readable body for limited role.', + 'post_excerpt' => 'Readable excerpt.', + 'post_status' => 'publish', + ) + ); + + $this->login_as( $role ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result, 'The readable published post should be returned.' ); + $this->assertSame( 'Readable title', $result['posts'][0]['title_rendered'], 'Rendered title should remain visible.' ); + $this->assertStringContainsString( 'Readable body for limited role.', $result['posts'][0]['content_rendered'], 'Rendered content should remain visible.' ); + $this->assertArrayNotHasKey( 'title_raw', $result['posts'][0], 'Raw title should be omitted.' ); + $this->assertArrayNotHasKey( 'excerpt_raw', $result['posts'][0], 'Raw excerpt should be omitted.' ); + $this->assertArrayNotHasKey( 'content_raw', $result['posts'][0], 'Raw content should be omitted.' ); + } + + /** + * Users who cannot edit another user's post cannot explicitly request raw fields. + * + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * + * @param string $role The role to test. + */ + public function test_raw_field_requests_are_denied_for_roles_without_edit_access_to_other_users_posts( string $role ): void { + $post_owner_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $post_id = self::factory()->post->create( + array( + 'post_author' => $post_owner_id, + 'post_status' => 'publish', + ) + ); + + $this->login_as( $role ); + + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'content_raw' ), + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Raw field requests should require edit access to the post.' ); + } + public function test_subscriber_cannot_request_draft_status(): void { $this->login_as( 'subscriber' ); @@ -654,16 +733,25 @@ public function test_password_protected_content_visible_to_editor(): void { $this->assertStringContainsString( 'Top secret body.', $result['posts'][0]['content_rendered'] ); } - public function test_password_protected_rendered_content_is_empty_for_subscriber(): void { - $post_id = self::factory()->post->create( + /** + * Password-protected rendered content is withheld from users who cannot edit the post. + * + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * + * @param string $role The role to test. + */ + public function test_password_protected_rendered_content_is_empty_for_roles_without_edit_access_to_other_users_posts( string $role ): void { + $post_owner_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $post_id = self::factory()->post->create( array( + 'post_author' => $post_owner_id, 'post_status' => 'publish', 'post_password' => 'secret', 'post_content' => 'Hidden rendered body.', ) ); - $this->login_as( 'subscriber' ); + $this->login_as( $role ); $result = $this->ability()->execute( array( 'id' => $post_id, @@ -671,8 +759,8 @@ public function test_password_protected_rendered_content_is_empty_for_subscriber ) ); - $this->assertSame( '', $result['posts'][0]['content_rendered'] ); - $this->assertTrue( $result['posts'][0]['content_protected'] ); + $this->assertSame( '', $result['posts'][0]['content_rendered'], 'Password-protected rendered content should be withheld.' ); + $this->assertTrue( $result['posts'][0]['content_protected'], 'The protected flag should reveal the field is password-protected.' ); } /* From 4b7b2a1fa157f3702be91e7c1f447b96b1fa440d Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 29 Jun 2026 17:27:04 +0100 Subject: [PATCH 10/69] Abilities API: Rename content read ability --- src/wp-includes/abilities.php | 2 +- .../abilities/class-wp-content-abilities.php | 16 ++++++++-------- src/wp-includes/class-wp-post-type.php | 2 +- src/wp-includes/post.php | 2 +- .../wpRegisterCoreContentAbility.php | 6 +++--- .../wpRestAbilitiesContentController.php | 6 +++--- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index a50af3541d592..40cb3ab3b6228 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -376,6 +376,6 @@ function wp_register_core_abilities(): void { // Register the settings abilities (currently the read-only `core/settings-get`). ( new WP_Abilities_Settings() )->register(); - // Register the content abilities (currently the read-only `core/content`). + // Register the content abilities (currently the read-only `core/read-content`). ( new WP_Content_Abilities() )->register(); } diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 623a66738efb9..91e560ede1696 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -12,7 +12,7 @@ /** * Core class used to register content-related abilities. * - * Provides the read-only `core/content` ability, which retrieves one or more readable + * Provides the read-only `core/read-content` ability, which retrieves one or more readable * posts of a post type that opts in via the `show_in_abilities` argument. It supports * fetching a single readable post by ID or slug, or querying multiple readable posts * with a small set of filters, and returns a support-aware set of fields per post. @@ -136,7 +136,7 @@ public function register(): void { } /** - * Registers the read-only `core/content` ability. + * Registers the read-only `core/read-content` ability. * * @since 7.1.0 */ @@ -148,9 +148,9 @@ private function register_get_content(): void { $statuses = array_values( get_post_stati( array( 'internal' => false ) ) ); wp_register_ability( - 'core/content', + 'core/read-content', array( - 'label' => __( 'Get Content' ), + 'label' => __( 'Read Content' ), 'description' => __( 'Retrieves one or more readable posts of a post type exposed to abilities. Fetch a single readable post by ID or by slug, or query multiple readable posts filtered by post type, status, author, or parent. Returns a basic, support-aware set of fields per post, with raw fields limited to users who can edit the post.' ), 'category' => self::CATEGORY, 'input_schema' => $this->get_content_input_schema( $post_types, $statuses ), @@ -174,7 +174,7 @@ private function register_get_content(): void { } /** - * Permission callback for the `core/content` ability. + * Permission callback for the `core/read-content` ability. * * Implements defense in depth: this gate decides whether the request may proceed at * all, while the per-post read/edit checks in {@see self::execute_get_content()} @@ -327,7 +327,7 @@ private function check_read_permission( WP_Post $post ): bool { } /** - * Executes the `core/content` ability. + * Executes the `core/read-content` ability. * * @since 7.1.0 * @@ -494,7 +494,7 @@ private function normalize_fields( array $input ): array { } /** - * Builds the input schema for the `core/content` ability. + * Builds the input schema for the `core/read-content` ability. * * The ability has two mutually exclusive modes, modeled as a `oneOf` so invalid * combinations are rejected rather than silently ignored: @@ -598,7 +598,7 @@ private function get_content_input_schema( array $post_types, array $statuses ): } /** - * Builds the output schema for the `core/content` ability. + * Builds the output schema for the `core/read-content` ability. * * No field is marked required because the `fields` input lets the caller request any * subset, and a field is only present when its post type supports it. diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index 4146ddea80cd0..2ec1565fc734c 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -377,7 +377,7 @@ final class WP_Post_Type { * Whether this post type should be exposed through the Abilities API. * * Default false. When truthy, the post type's editable posts can be retrieved - * through the read-only `core/content` ability, subject to per-post capability + * through the read-only `core/read-content` ability, subject to per-post capability * checks. May be an array to enable specific operations in the future. * * @since 7.1.0 diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 7bfb358317f2e..7a0346747560d 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -1740,7 +1740,7 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * @type bool $show_in_rest Whether to include the post type in the REST API. Set this to true * for the post type to be available in the block editor. * @type bool|array $show_in_abilities Whether to expose this post type through the Abilities API, so its - * editable posts can be retrieved via the read-only `core/content` + * editable posts can be retrieved via the read-only `core/read-content` * ability (subject to per-post capability checks). Accepts a boolean * or an array reserved for enabling specific operations. Default false. * @type string $rest_base To change the base URL of REST API route. Default is $post_type. diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 7102fcdbc0711..1f1567e2b887d 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -3,7 +3,7 @@ declare( strict_types=1 ); /** - * Tests for the core/content ability shipped with the Abilities API. + * Tests for the core/read-content ability shipped with the Abilities API. * * @covers wp_register_core_abilities * @covers WP_Content_Abilities @@ -120,10 +120,10 @@ public function data_roles_without_edit_access_to_other_users_posts(): array { /** * Convenience accessor for the ability. * - * @return WP_Ability The core/content ability. + * @return WP_Ability The core/read-content ability. */ private function ability(): WP_Ability { - return wp_get_ability( 'core/content' ); + return wp_get_ability( 'core/read-content' ); } /* diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php index 41f1937600f2c..9b26a07f60988 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -3,7 +3,7 @@ declare( strict_types=1 ); /** - * Tests dispatching the core/content ability through the Abilities REST run endpoint. + * Tests dispatching the core/read-content ability through the Abilities REST run endpoint. * * @covers WP_Content_Abilities * @@ -34,11 +34,11 @@ class Tests_REST_API_WpRestAbilitiesContentController extends WP_UnitTestCase { protected static $subscriber_id; /** - * The run route for the core/content ability. + * The run route for the core/read-content ability. * * @var string */ - const RUN_ROUTE = '/wp-abilities/v1/abilities/core/content/run'; + const RUN_ROUTE = '/wp-abilities/v1/abilities/core/read-content/run'; /** * Sets up users and registers the core abilities. From 2761c0bba1788ee9a145d88b700dc1394b438966 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 29 Jun 2026 17:31:01 +0100 Subject: [PATCH 11/69] Abilities API: Split read content modes --- .../abilities/class-wp-content-abilities.php | 235 +++++++++++++----- 1 file changed, 178 insertions(+), 57 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 91e560ede1696..37a64f12a9fd4 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -12,11 +12,11 @@ /** * Core class used to register content-related abilities. * - * Provides the read-only `core/read-content` ability, which retrieves one or more readable - * posts of a post type that opts in via the `show_in_abilities` argument. It supports - * fetching a single readable post by ID or slug, or querying multiple readable posts - * with a small set of filters, and returns a support-aware set of fields per post. - * Raw fields are only returned for posts the current user can edit. + * Provides the read-only `core/read-content` ability, which retrieves readable posts of a + * post type that opts in via the `show_in_abilities` argument. It supports fetching a single + * readable post by ID or by post type and slug, or querying multiple readable posts filtered + * by post type, status, author, parent, or included IDs. Raw fields are only returned for + * posts the current user can edit. * * The class is intentionally structured around shared building blocks (exposed post type * discovery, schema generation, per-post formatting and permission checks) so a future @@ -61,9 +61,7 @@ final class WP_Content_Abilities { /** * Fields that expose edit-context post data. * - * Requests that explicitly include any of these fields require edit access. When - * fields are omitted, these fields are returned only for posts the current user - * can edit. + * Requests that explicitly include any of these fields require edit access. * * @since 7.1.0 * @var string[] @@ -106,6 +104,21 @@ final class WP_Content_Abilities { 'parent', ); + /** + * Default fields returned when the caller does not request a field subset. + * + * @since 7.1.0 + * @var string[] + */ + private array $default_fields = array( + 'id', + 'type', + 'status', + 'date', + 'slug', + 'title_rendered', + ); + /** * Post types exposed through the Abilities API, computed once at registration. * @@ -151,7 +164,7 @@ private function register_get_content(): void { 'core/read-content', array( 'label' => __( 'Read Content' ), - 'description' => __( 'Retrieves one or more readable posts of a post type exposed to abilities. Fetch a single readable post by ID or by slug, or query multiple readable posts filtered by post type, status, author, or parent. Returns a basic, support-aware set of fields per post, with raw fields limited to users who can edit the post.' ), + 'description' => __( 'Reads content from post types exposed to abilities. Single-post lookups by ID or by post type and slug return the post object directly. Query mode returns readable posts filtered by post type, status, author, parent, or included IDs.' ), 'category' => self::CATEGORY, 'input_schema' => $this->get_content_input_schema( $post_types, $statuses ), 'output_schema' => $this->get_content_output_schema(), @@ -210,12 +223,21 @@ public function check_permission( $input = array() ): bool { return $requires_edit ? current_user_can( 'edit_post', $post->ID ) : $this->check_read_permission( $post ); } - // Query / slug mode requires an exposed post type. + // Single-post mode (by slug) and query mode require an exposed post type. $post_type = isset( $input['post_type'] ) && is_string( $input['post_type'] ) ? $input['post_type'] : ''; if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { return false; } + if ( ! empty( $input['slug'] ) && is_string( $input['slug'] ) ) { + $post = $this->get_post_by_slug( $post_type, $input['slug'] ); + if ( ! $post ) { + return false; + } + + return $requires_edit ? current_user_can( 'edit_post', $post->ID ) : $this->check_read_permission( $post ); + } + $post_type_object = $exposed[ $post_type ]; if ( $requires_edit ) { return current_user_can( $post_type_object->cap->edit_posts ); @@ -332,7 +354,7 @@ private function check_read_permission( WP_Post $post ): bool { * @since 7.1.0 * * @param mixed $input Optional. The ability input. Default empty array. - * @return array|WP_Error A map with a `posts` list, or a WP_Error on failure. + * @return array|WP_Error A post object in single-post mode, a map with a `posts` list in query mode, or a WP_Error on failure. */ public function execute_get_content( $input = array() ) { $input = is_array( $input ) ? $input : array(); @@ -353,21 +375,31 @@ public function execute_get_content( $input = array() ) { return $this->not_found_error(); } - return array( - 'posts' => array( $this->format_post( $post, $fields ) ), - 'total' => 1, - 'total_pages' => 1, - ); + return $this->format_post( $post, $fields ); } - // Query / slug mode. + // Single-post mode (by slug) and query mode. $post_type = isset( $input['post_type'] ) && is_string( $input['post_type'] ) ? $input['post_type'] : ''; if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { return $this->not_found_error(); } + if ( ! empty( $input['slug'] ) && is_string( $input['slug'] ) ) { + $post = $this->get_post_by_slug( $post_type, $input['slug'] ); + + if ( ! $post + || ( $requires_edit && ! current_user_can( 'edit_post', $post->ID ) ) + || ( ! $requires_edit && ! $this->check_read_permission( $post ) ) + ) { + return $this->not_found_error(); + } + + return $this->format_post( $post, $fields ); + } + $per_page = $this->normalize_per_page( $input ); $page = isset( $input['page'] ) ? max( 1, $this->input_int( $input['page'] ) ) : 1; + $include = $this->normalize_include( $input ); $query_args = array( 'post_type' => $post_type, @@ -380,8 +412,9 @@ public function execute_get_content( $input = array() ) { 'update_post_term_cache' => false, ); - if ( ! empty( $input['slug'] ) && is_string( $input['slug'] ) ) { - $query_args['name'] = sanitize_title( $input['slug'] ); + if ( array() !== $include ) { + $query_args['post__in'] = $include; + $query_args['orderby'] = 'post__in'; } if ( ! empty( $input['author'] ) ) { @@ -405,11 +438,11 @@ public function execute_get_content( $input = array() ) { if ( ! $requires_edit && ! $this->check_read_permission( $post ) ) { continue; } - $formatted = $this->format_post( $post, $fields ); + $formatted = $this->format_post( $post, $fields ); if ( array() === $formatted ) { continue; } - $posts[] = $formatted; + $posts[] = $formatted; } return array( @@ -433,6 +466,34 @@ private function normalize_per_page( array $input ): int { return max( 1, min( self::MAX_PER_PAGE, $per_page ) ); } + /** + * Looks up a single post by post type and slug. + * + * @since 7.1.0 + * + * @param string $post_type The post type. + * @param string $slug The post slug. + * @return WP_Post|null The matching post, or null when none exists. + */ + private function get_post_by_slug( string $post_type, string $slug ): ?WP_Post { + $query = new WP_Query( + array( + 'post_type' => $post_type, + 'name' => sanitize_title( $slug ), + 'post_status' => array_values( get_post_stati( array( 'internal' => false ) ) ), + 'posts_per_page' => 1, + 'no_found_rows' => true, + 'ignore_sticky_posts' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + + $post = $query->posts[0] ?? null; + + return $post instanceof WP_Post ? $post : null; + } + /** * Returns the post types exposed through the Abilities API, keyed by name. * @@ -472,10 +533,34 @@ private function normalize_statuses( array $input ): array { } /** - * Normalizes the requested fields to the supported set, defaulting to all fields. + * Normalizes query-mode included post IDs. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return int[] Unique positive post IDs in caller-provided order. + */ + private function normalize_include( array $input ): array { + if ( empty( $input['include'] ) || ! is_array( $input['include'] ) ) { + return array(); + } + + $ids = array_map( array( $this, 'input_int' ), $input['include'] ); + $ids = array_filter( + $ids, + static function ( int $id ): bool { + return $id > 0; + } + ); + + return array_values( array_unique( $ids ) ); + } + + /** + * Normalizes the requested fields to the supported set, defaulting to a lean field set. * - * An empty or absent `fields` value selects every field. Edit-context fields are - * still omitted per post when the current user cannot edit that post. + * An empty or absent `fields` value selects common read-context fields. Edit-context + * fields remain available when explicitly requested by a user who can edit the post. * * @since 7.1.0 * @@ -484,27 +569,28 @@ private function normalize_statuses( array $input ): array { */ private function normalize_fields( array $input ): array { if ( empty( $input['fields'] ) || ! is_array( $input['fields'] ) ) { - return $this->fields; + return $this->default_fields; } $requested_fields = array_filter( $input['fields'], 'is_string' ); $fields = array_intersect( $this->fields, $requested_fields ); - return array() === $fields ? $this->fields : array_values( $fields ); + return array() === $fields ? $this->default_fields : array_values( $fields ); } /** * Builds the input schema for the `core/read-content` ability. * - * The ability has two mutually exclusive modes, modeled as a `oneOf` so invalid + * The ability has three mutually exclusive modes, modeled as a `oneOf` so invalid * combinations are rejected rather than silently ignored: * * - Get a single readable post by `id` (optionally guarded by `post_type`). - * - Query a set of readable posts by `post_type` plus filters (`slug`, `status`, - * `author`, `parent`, `page`, `per_page`). + * - Get a single readable post by `post_type` and `slug`. + * - Query a set of readable posts by `post_type` plus filters (`status`, `author`, + * `parent`, `include`, `page`, `per_page`). * * Each mode sets `additionalProperties: false`, so e.g. passing `per_page` alongside `id` - * fails validation instead of being dropped. `fields` is accepted in both modes. + * fails validation instead of being dropped. `fields` is accepted in every mode. * * @since 7.1.0 * @@ -513,14 +599,24 @@ private function normalize_fields( array $input ): array { * @return array The input JSON Schema. */ private function get_content_input_schema( array $post_types, array $statuses ): array { - $fields = array( + $fields = array( 'type' => 'array', 'uniqueItems' => true, 'items' => array( 'type' => 'string', 'enum' => $this->fields, ), - 'description' => __( 'Limit each returned post to these fields. If omitted, all fields visible to the current user are returned. Explicit raw field requests require edit access.' ), + 'description' => __( 'Limit each returned post to these fields. If omitted, a lean set of common read fields is returned. Explicit raw field requests require edit access.' ), + ); + $include = array( + 'type' => 'array', + 'minItems' => 1, + 'uniqueItems' => true, + 'items' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'description' => __( 'Limit the query to these post IDs. Results preserve this order where possible and still respect post type and read permissions.' ), ); return array( @@ -545,20 +641,35 @@ private function get_content_input_schema( array $post_types, array $statuses ): 'fields' => $fields, ), ), - // Mode 2: query a set of readable posts by post type and filters. + // Mode 2: retrieve a single readable post by post type and slug. array( - 'title' => __( 'Query readable posts by type and filters' ), - 'required' => array( 'post_type' ), + 'title' => __( 'Get a single readable post by slug' ), + 'required' => array( 'post_type', 'slug' ), 'additionalProperties' => false, 'properties' => array( 'post_type' => array( 'type' => 'string', 'enum' => $post_types, - 'description' => __( 'Post type to query for readable posts.' ), + 'description' => __( 'Post type containing the slug. Slugs are not unique across post types.' ), ), 'slug' => array( 'type' => 'string', - 'description' => __( 'Filter by slug. Combined with `post_type`, as slugs are not unique across post types.' ), + 'minLength' => 1, + 'description' => __( 'Retrieve a single readable post by slug.' ), + ), + 'fields' => $fields, + ), + ), + // Mode 3: query a set of readable posts by post type and filters. + array( + 'title' => __( 'Query readable posts by type and filters' ), + 'required' => array( 'post_type' ), + 'additionalProperties' => false, + 'properties' => array( + 'post_type' => array( + 'type' => 'string', + 'enum' => $post_types, + 'description' => __( 'Post type to query for readable posts.' ), ), 'status' => array( 'type' => 'array', @@ -579,6 +690,7 @@ private function get_content_input_schema( array $post_types, array $statuses ): 'minimum' => 0, 'description' => __( 'Filter by parent post ID, for hierarchical post types. Use 0 for top-level posts.' ), ), + 'include' => $include, 'fields' => $fields, 'page' => array( 'type' => 'integer', @@ -601,7 +713,8 @@ private function get_content_input_schema( array $post_types, array $statuses ): * Builds the output schema for the `core/read-content` ability. * * No field is marked required because the `fields` input lets the caller request any - * subset, and a field is only present when its post type supports it. + * subset, and a field is only present when its post type supports it. Single-post + * mode returns the post object directly, while query mode returns a paginated wrapper. * * @since 7.1.0 * @@ -702,26 +815,34 @@ private function get_content_output_schema(): array { ), ); - return array( - 'type' => 'object', - 'additionalProperties' => false, - 'required' => array( 'posts', 'total', 'total_pages' ), - 'properties' => array( - 'posts' => array( - 'type' => 'array', - 'description' => __( 'The readable posts matching the request. A single-element list when requested by ID.' ), - 'items' => $post_schema, - ), - 'total' => array( - 'type' => 'integer', - 'description' => __( 'Total number of posts matching the query, across all pages, after applying the permission filter to the query. Surfaced over REST as the X-WP-Total header.' ), - ), - 'total_pages' => array( - 'type' => 'integer', - 'description' => __( 'Total number of query result pages available after applying the permission filter to the query. Surfaced over REST as the X-WP-TotalPages header.' ), - ), + $query_schema = array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'posts', 'total', 'total_pages' ), + 'properties' => array( + 'posts' => array( + 'type' => 'array', + 'description' => __( 'The readable posts matching the query.' ), + 'items' => $post_schema, ), - ); + 'total' => array( + 'type' => 'integer', + 'description' => __( 'Total number of posts matching the query, across all pages, after applying the permission filter to the query. Surfaced over REST as the X-WP-Total header.' ), + ), + 'total_pages' => array( + 'type' => 'integer', + 'description' => __( 'Total number of query result pages available after applying the permission filter to the query. Surfaced over REST as the X-WP-TotalPages header.' ), + ), + ), + ); + + return array( + 'type' => 'object', + 'oneOf' => array( + $post_schema, + $query_schema, + ), + ); } /** From 19432e28d14dfde5fed522e86c0742a394bf0272 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 29 Jun 2026 17:37:05 +0100 Subject: [PATCH 12/69] Tests: Cover read content ability modes --- .../wpRegisterCoreContentAbility.php | 311 ++++++++++++++---- .../wpRestAbilitiesContentController.php | 57 +++- 2 files changed, 298 insertions(+), 70 deletions(-) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 1f1567e2b887d..608096f0fe9c1 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -58,6 +58,13 @@ public static function set_up_before_class(): void { remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); do_action( 'wp_abilities_api_categories_init' ); @@ -149,19 +156,32 @@ public function test_input_schema_models_mutually_exclusive_modes(): void { $schema = $this->ability()->get_input_schema(); $this->assertSame( 'object', $schema['type'] ); - $this->assertCount( 2, $schema['oneOf'] ); + $this->assertCount( 3, $schema['oneOf'] ); - [ $by_id, $by_type ] = $schema['oneOf']; + [ $by_id, $by_slug, $query ] = $schema['oneOf']; - // Mode 1 requires `id`; Mode 2 requires `post_type`. Both reject extra properties. $this->assertSame( array( 'id' ), $by_id['required'] ); - $this->assertSame( array( 'post_type' ), $by_type['required'] ); + $this->assertSame( array( 'post_type', 'slug' ), $by_slug['required'] ); + $this->assertSame( array( 'post_type' ), $query['required'] ); $this->assertFalse( $by_id['additionalProperties'] ); - $this->assertFalse( $by_type['additionalProperties'] ); + $this->assertFalse( $by_slug['additionalProperties'] ); + $this->assertFalse( $query['additionalProperties'] ); - // Query-only filters live only in the query mode, not the by-ID mode. - $this->assertArrayHasKey( 'per_page', $by_type['properties'] ); + // Query-only filters live only in the query mode, not the single-post modes. + $this->assertArrayHasKey( 'include', $query['properties'] ); + $this->assertArrayHasKey( 'per_page', $query['properties'] ); $this->assertArrayNotHasKey( 'per_page', $by_id['properties'] ); + $this->assertArrayNotHasKey( 'include', $by_slug['properties'] ); + $this->assertArrayNotHasKey( 'slug', $query['properties'] ); + + $this->assertContains( 'post', $query['properties']['post_type']['enum'] ); + $this->assertContains( 'page', $by_id['properties']['post_type']['enum'] ); + $this->assertContains( 'page', $by_slug['properties']['post_type']['enum'] ); + + $this->assertSame( 1, $query['properties']['include']['minItems'] ); + $this->assertTrue( $query['properties']['include']['uniqueItems'] ); + $this->assertSame( 'integer', $query['properties']['include']['items']['type'] ); + $this->assertSame( 1, $query['properties']['include']['items']['minimum'] ); } public function test_id_mode_rejects_query_only_params(): void { @@ -196,11 +216,12 @@ public function test_id_mode_accepts_post_type_guard(): void { ); $this->assertIsArray( $result ); - $this->assertSame( $post_id, $result['posts'][0]['id'] ); + $this->assertSame( $post_id, $result['id'] ); + $this->assertArrayNotHasKey( 'posts', $result ); } public function test_input_schema_post_type_enum_only_includes_exposed_types(): void { - $enum = $this->ability()->get_input_schema()['oneOf'][1]['properties']['post_type']['enum']; + $enum = $this->ability()->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; $this->assertContains( 'post', $enum ); $this->assertContains( 'page', $enum ); @@ -209,8 +230,35 @@ public function test_input_schema_post_type_enum_only_includes_exposed_types(): $this->assertNotContains( 'revision', $enum ); } + public function test_query_exposed_custom_post_type(): void { + $this->login_as( 'administrator' ); + + if ( ! post_type_exists( self::EXPOSED_CPT ) ) { + register_post_type( + self::EXPOSED_CPT, + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor', 'excerpt', 'author' ), + ) + ); + } + + $post_id = self::factory()->post->create( + array( + 'post_type' => self::EXPOSED_CPT, + 'post_status' => 'publish', + ) + ); + + $result = $this->ability()->execute( array( 'post_type' => self::EXPOSED_CPT ) ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $post_id, $ids ); + } + public function test_input_schema_status_and_fields_enums(): void { - $properties = $this->ability()->get_input_schema()['oneOf'][1]['properties']; + $properties = $this->ability()->get_input_schema()['oneOf'][2]['properties']; $status_enum = $properties['status']['items']['enum']; $this->assertContains( 'publish', $status_enum ); @@ -228,22 +276,28 @@ public function test_input_schema_status_and_fields_enums(): void { } public function test_input_schema_omits_oneof_branch_defaults(): void { - $properties = $this->ability()->get_input_schema()['oneOf'][1]['properties']; + $properties = $this->ability()->get_input_schema()['oneOf'][2]['properties']; $this->assertArrayNotHasKey( 'default', $properties['status'] ); $this->assertArrayNotHasKey( 'default', $properties['page'] ); $this->assertArrayNotHasKey( 'default', $properties['per_page'] ); } - public function test_output_schema_has_no_required_fields(): void { - $schema = $this->ability()->get_output_schema(); - $post_item = $schema['properties']['posts']['items']; + public function test_output_schema_describes_single_post_and_query_responses(): void { + $schema = $this->ability()->get_output_schema(); + $post_schema = $schema['oneOf'][0]; + $query_schema = $schema['oneOf'][1]; - $this->assertSame( array( 'posts', 'total', 'total_pages' ), $schema['required'] ); - $this->assertArrayNotHasKey( 'required', $post_item ); - $this->assertFalse( $post_item['additionalProperties'] ); - $this->assertArrayHasKey( 'content_raw', $post_item['properties'] ); - $this->assertArrayHasKey( 'content_rendered', $post_item['properties'] ); + $this->assertSame( 'object', $schema['type'] ); + $this->assertCount( 2, $schema['oneOf'] ); + $this->assertSame( 'object', $post_schema['type'] ); + $this->assertArrayNotHasKey( 'required', $post_schema ); + $this->assertFalse( $post_schema['additionalProperties'] ); + $this->assertArrayHasKey( 'content_raw', $post_schema['properties'] ); + $this->assertArrayHasKey( 'content_rendered', $post_schema['properties'] ); + $this->assertSame( array( 'posts', 'total', 'total_pages' ), $query_schema['required'] ); + $this->assertArrayHasKey( 'total', $query_schema['properties'] ); + $this->assertArrayHasKey( 'total_pages', $query_schema['properties'] ); } /* @@ -265,13 +319,13 @@ public function test_get_single_published_post_by_id(): void { $result = $this->ability()->execute( array( 'id' => $post_id ) ); $this->assertIsArray( $result ); - $this->assertCount( 1, $result['posts'] ); - $this->assertSame( $post_id, $result['posts'][0]['id'] ); - $this->assertSame( 'Hello Content', $result['posts'][0]['title_raw'] ); - $this->assertSame( 'Hello Content', $result['posts'][0]['title_rendered'] ); - $this->assertSame( 'Body here.', $result['posts'][0]['content_raw'] ); - $this->assertStringContainsString( 'Body here.', $result['posts'][0]['content_rendered'] ); - $this->assertSame( 'post', $result['posts'][0]['type'] ); + $this->assertSame( $post_id, $result['id'] ); + $this->assertSame( 'Hello Content', $result['title_rendered'] ); + $this->assertSame( + array( 'id', 'type', 'status', 'date', 'slug', 'title_rendered' ), + array_keys( $result ) + ); + $this->assertArrayNotHasKey( 'posts', $result ); } public function test_get_by_id_with_mismatched_post_type_is_denied(): void { @@ -332,7 +386,79 @@ public function test_query_returns_only_published_by_default(): void { $this->assertNotContains( $draft, $ids ); } - public function test_query_by_slug_requires_post_type(): void { + public function test_query_include_limits_results_and_preserves_order(): void { + $this->login_as( 'administrator' ); + + $first = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $second = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $third = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'include' => array( $third, $first ), + 'fields' => array( 'id' ), + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertSame( array( $third, $first ), $ids ); + $this->assertNotContains( $second, $ids ); + } + + public function test_query_include_respects_requested_post_type(): void { + $this->login_as( 'administrator' ); + + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'include' => array( $page_id, $post_id ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $post_id ), wp_list_pluck( $result['posts'], 'id' ) ); + } + + public function test_query_include_respects_row_level_permissions(): void { + $author_a = self::factory()->user->create( array( 'role' => 'author' ) ); + $author_b = self::factory()->user->create( array( 'role' => 'author' ) ); + + $draft_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'draft', + ) + ); + $draft_b = self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'draft', + ) + ); + + wp_set_current_user( $author_b ); + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + 'include' => array( $draft_a, $draft_b ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $draft_b ), wp_list_pluck( $result['posts'], 'id' ) ); + } + + public function test_slug_mode_requires_post_type(): void { $this->login_as( 'administrator' ); $result = $this->ability()->execute( array( 'slug' => 'whatever' ) ); @@ -341,11 +467,12 @@ public function test_query_by_slug_requires_post_type(): void { $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); } - public function test_query_by_slug_within_post_type(): void { + public function test_get_single_published_post_by_slug(): void { $this->login_as( 'administrator' ); $post_id = self::factory()->post->create( array( - 'post_name' => 'find-me', + 'post_name' => 'content-slug-mode', + 'post_title' => 'Content Slug Mode', 'post_status' => 'publish', ) ); @@ -353,12 +480,55 @@ public function test_query_by_slug_within_post_type(): void { $result = $this->ability()->execute( array( 'post_type' => 'post', - 'slug' => 'find-me', + 'slug' => 'content-slug-mode', ) ); - $this->assertCount( 1, $result['posts'] ); - $this->assertSame( $post_id, $result['posts'][0]['id'] ); + $this->assertIsArray( $result ); + $this->assertSame( $post_id, $result['id'] ); + $this->assertSame( 'content-slug-mode', $result['slug'] ); + $this->assertArrayNotHasKey( 'posts', $result ); + $this->assertArrayNotHasKey( 'total', $result ); + } + + public function test_slug_mode_rejects_query_only_params(): void { + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'slug' => 'whatever', + 'per_page' => 10, + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + public function test_include_cannot_be_combined_with_single_post_modes(): void { + $this->login_as( 'administrator' ); + + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $by_id = $this->ability()->execute( + array( + 'id' => $post_id, + 'include' => array( $post_id ), + ) + ); + $by_slug = $this->ability()->execute( + array( + 'post_type' => 'post', + 'slug' => 'whatever', + 'include' => array( $post_id ), + ) + ); + + $this->assertWPError( $by_id ); + $this->assertSame( 'ability_invalid_input', $by_id->get_error_code() ); + $this->assertWPError( $by_slug ); + $this->assertSame( 'ability_invalid_input', $by_slug->get_error_code() ); } public function test_query_filters_by_author(): void { @@ -409,6 +579,7 @@ public function test_query_filters_by_parent_for_hierarchical_types(): void { array( 'post_type' => 'page', 'parent' => $parent, + 'fields' => array( 'id', 'parent' ), ) ); @@ -434,13 +605,11 @@ public function test_fields_filter_limits_returned_keys(): void { ) ); - $this->assertSame( array( 'id', 'title_rendered' ), array_keys( $result['posts'][0] ) ); + $this->assertSame( array( 'id', 'title_rendered' ), array_keys( $result ) ); } public function test_unsupported_fields_are_omitted_for_post_type(): void { $this->login_as( 'administrator' ); - // Pages do not support excerpt by default in this CPT, but `post` does; use the - // exposed CPT which does not support `comments`/`parent` to confirm omission. $post_id = self::factory()->post->create( array( 'post_type' => 'post', @@ -448,10 +617,15 @@ public function test_unsupported_fields_are_omitted_for_post_type(): void { ) ); - $result = $this->ability()->execute( array( 'id' => $post_id ) ); + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'parent' ), + ) + ); - // `post` is not hierarchical, so `parent` must be absent even though requested implicitly. - $this->assertArrayNotHasKey( 'parent', $result['posts'][0] ); + // `post` is not hierarchical, so `parent` must be absent even when requested. + $this->assertArrayNotHasKey( 'parent', $result ); } /* @@ -509,10 +683,10 @@ public function test_subscriber_can_get_single_published_post_by_id(): void { $result = $this->ability()->execute( array( 'id' => $post_id ) ); $this->assertIsArray( $result ); - $this->assertSame( 'Readable single', $result['posts'][0]['title_rendered'] ); - $this->assertStringContainsString( 'Readable single body.', $result['posts'][0]['content_rendered'] ); - $this->assertArrayNotHasKey( 'title_raw', $result['posts'][0] ); - $this->assertArrayNotHasKey( 'content_raw', $result['posts'][0] ); + $this->assertSame( 'Readable single', $result['title_rendered'] ); + $this->assertArrayNotHasKey( 'title_raw', $result ); + $this->assertArrayNotHasKey( 'content_raw', $result ); + $this->assertArrayNotHasKey( 'content_rendered', $result ); } public function test_subscriber_cannot_request_raw_fields_in_query_mode(): void { @@ -568,11 +742,11 @@ public function test_default_fields_omit_raw_fields_for_roles_without_edit_acces $result = $this->ability()->execute( array( 'id' => $post_id ) ); $this->assertIsArray( $result, 'The readable published post should be returned.' ); - $this->assertSame( 'Readable title', $result['posts'][0]['title_rendered'], 'Rendered title should remain visible.' ); - $this->assertStringContainsString( 'Readable body for limited role.', $result['posts'][0]['content_rendered'], 'Rendered content should remain visible.' ); - $this->assertArrayNotHasKey( 'title_raw', $result['posts'][0], 'Raw title should be omitted.' ); - $this->assertArrayNotHasKey( 'excerpt_raw', $result['posts'][0], 'Raw excerpt should be omitted.' ); - $this->assertArrayNotHasKey( 'content_raw', $result['posts'][0], 'Raw content should be omitted.' ); + $this->assertSame( 'Readable title', $result['title_rendered'], 'Rendered title should remain visible.' ); + $this->assertArrayNotHasKey( 'title_raw', $result, 'Raw title should be omitted.' ); + $this->assertArrayNotHasKey( 'excerpt_raw', $result, 'Raw excerpt should be omitted.' ); + $this->assertArrayNotHasKey( 'content_raw', $result, 'Raw content should be omitted.' ); + $this->assertArrayNotHasKey( 'content_rendered', $result, 'Rendered content should be omitted from the lean default field set.' ); } /** @@ -702,14 +876,14 @@ public function test_raw_content_visible_to_editor(): void { ); $this->login_as( 'editor' ); - $result = $this->ability()->execute( - array( - 'id' => $post_id, - 'fields' => array( 'id', 'content_raw' ), - ) - ); + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'content_raw' ), + ) + ); - $this->assertSame( 'Public body with raw block markup.', $result['posts'][0]['content_raw'] ); + $this->assertSame( 'Public body with raw block markup.', $result['content_raw'] ); } public function test_password_protected_content_visible_to_editor(): void { @@ -722,15 +896,15 @@ public function test_password_protected_content_visible_to_editor(): void { ); $this->login_as( 'editor' ); - $result = $this->ability()->execute( - array( - 'id' => $post_id, - 'fields' => array( 'id', 'content_raw', 'content_rendered' ), - ) - ); + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'content_raw', 'content_rendered' ), + ) + ); - $this->assertSame( 'Top secret body.', $result['posts'][0]['content_raw'] ); - $this->assertStringContainsString( 'Top secret body.', $result['posts'][0]['content_rendered'] ); + $this->assertSame( 'Top secret body.', $result['content_raw'] ); + $this->assertStringContainsString( 'Top secret body.', $result['content_rendered'] ); } /** @@ -759,8 +933,8 @@ public function test_password_protected_rendered_content_is_empty_for_roles_with ) ); - $this->assertSame( '', $result['posts'][0]['content_rendered'], 'Password-protected rendered content should be withheld.' ); - $this->assertTrue( $result['posts'][0]['content_protected'], 'The protected flag should reveal the field is password-protected.' ); + $this->assertSame( '', $result['content_rendered'], 'Password-protected rendered content should be withheld.' ); + $this->assertTrue( $result['content_protected'], 'The protected flag should reveal the field is password-protected.' ); } /* @@ -800,19 +974,20 @@ public function test_query_paginates_and_reports_totals(): void { public function test_per_page_is_capped(): void { $this->login_as( 'administrator' ); - $schema = $this->ability()->get_input_schema()['oneOf'][1]; + $schema = $this->ability()->get_input_schema()['oneOf'][2]; $this->assertSame( 100, $schema['properties']['per_page']['maximum'] ); } - public function test_single_post_reports_totals(): void { + public function test_single_post_does_not_return_query_totals(): void { $this->login_as( 'administrator' ); $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); $result = $this->ability()->execute( array( 'id' => $post_id ) ); - $this->assertSame( 1, $result['total'] ); - $this->assertSame( 1, $result['total_pages'] ); + $this->assertArrayNotHasKey( 'posts', $result ); + $this->assertArrayNotHasKey( 'total', $result ); + $this->assertArrayNotHasKey( 'total_pages', $result ); } public function test_ability_opts_into_pagination(): void { diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php index 9b26a07f60988..97dd5ffd6616b 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -54,6 +54,13 @@ public static function set_up_before_class(): void { remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); do_action( 'wp_abilities_api_categories_init' ); @@ -196,6 +203,27 @@ public function test_admin_query_returns_published_posts(): void { $this->assertContains( $post_id, wp_list_pluck( $data['posts'], 'id' ) ); } + public function test_admin_query_include_limits_results(): void { + $first = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $second = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $third = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'include' => array( $third, $first ), + 'fields' => array( 'id' ), + ) + ) + ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array( $third, $first ), wp_list_pluck( $data['posts'], 'id' ) ); + $this->assertNotContains( $second, wp_list_pluck( $data['posts'], 'id' ) ); + } + public function test_get_single_post_by_id(): void { $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); @@ -203,8 +231,33 @@ public function test_get_single_post_by_id(): void { $data = $response->get_data(); $this->assertSame( 200, $response->get_status() ); - $this->assertCount( 1, $data['posts'] ); - $this->assertSame( $post_id, $data['posts'][0]['id'] ); + $this->assertSame( $post_id, $data['id'] ); + $this->assertArrayNotHasKey( 'posts', $data ); + $this->assertArrayNotHasKey( 'total', $data ); + } + + public function test_get_single_post_by_slug(): void { + $post_id = self::factory()->post->create( + array( + 'post_name' => 'rest-content-slug', + 'post_status' => 'publish', + ) + ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'slug' => 'rest-content-slug', + ) + ) + ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $post_id, $data['id'] ); + $this->assertSame( 'rest-content-slug', $data['slug'] ); + $this->assertArrayNotHasKey( 'posts', $data ); } public function test_wrong_http_method_returns_405(): void { From 6beefc3488e410354e0e80bd9590d470ddb5272f Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 10 Jul 2026 17:00:36 +0100 Subject: [PATCH 13/69] Abilities API: Sync read content ability with plugin --- .../abilities/class-wp-content-abilities.php | 1159 +++++-- .../wpRegisterCoreContentAbility.php | 3003 +++++++++++++---- .../wpRestAbilitiesContentController.php | 41 +- 3 files changed, 3271 insertions(+), 932 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 37a64f12a9fd4..d80d357c57225 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -12,8 +12,8 @@ /** * Core class used to register content-related abilities. * - * Provides the read-only `core/read-content` ability, which retrieves readable posts of a - * post type that opts in via the `show_in_abilities` argument. It supports fetching a single + * Registers the read-only `core/read-content` ability, which retrieves readable posts of a + * post type exposed to abilities via `show_in_abilities`. Supports fetching a single * readable post by ID or by post type and slug, or querying multiple readable posts filtered * by post type, status, author, parent, or included IDs. Raw fields are only returned for * posts the current user can edit. @@ -51,8 +51,6 @@ final class WP_Content_Abilities { /** * Maximum number of posts returned per page in query mode. * - * Mirrors the REST API collection ceiling. - * * @since 7.1.0 * @var int */ @@ -64,72 +62,51 @@ final class WP_Content_Abilities { * Requests that explicitly include any of these fields require edit access. * * @since 7.1.0 - * @var string[] + * @var list */ - private const EDIT_FIELDS = array( + private array $edit_fields = array( 'title_raw', 'excerpt_raw', 'content_raw', ); /** - * The fields a post object may expose, in output order. + * Fields whose rendering may read post meta or terms. * - * Read-context fields are returned for readable posts. Edit-context fields are - * returned only when explicitly requested by a user with edit access, or when - * fields are omitted and the user can edit the post. + * Requests that include any of these prime the post meta and term caches for the + * page. Other rendered fields, such as the title, do not need that cache priming. * * @since 7.1.0 - * @var string[] + * @var list */ - private array $fields = array( - 'id', - 'type', - 'status', - 'date', - 'date_gmt', - 'modified', - 'modified_gmt', - 'slug', - 'link', - 'title_raw', - 'title_rendered', - 'excerpt_raw', + private array $cache_priming_fields = array( 'excerpt_rendered', - 'excerpt_protected', - 'content_raw', 'content_rendered', - 'content_protected', - 'author', - 'parent', ); + /** + * Cached post field definitions, keyed by field name in output order. + * + * @since 7.1.0 + * @var array|null + */ + private ?array $post_properties = null; + /** * Default fields returned when the caller does not request a field subset. * * @since 7.1.0 - * @var string[] + * @var list */ private array $default_fields = array( 'id', - 'type', + 'post_type', 'status', 'date', 'slug', 'title_rendered', ); - /** - * Post types exposed through the Abilities API, computed once at registration. - * - * Cached so the input schema and the permission/execute callbacks derive from the exact - * same set, and the post type list is only walked once per request. - * - * @since 7.1.0 - * @var array|null - */ - private ?array $exposed_post_types = null; - /** * Registers all content abilities. * @@ -138,7 +115,7 @@ final class WP_Content_Abilities { * @since 7.1.0 */ public function register(): void { - $this->register_get_content(); + $this->register_read_content(); /* * A future write-oriented ability can be registered here, reusing the shared @@ -153,34 +130,43 @@ public function register(): void { * * @since 7.1.0 */ - private function register_get_content(): void { - // Compute once; check_permission()/execute_get_content() reuse this set. - $this->exposed_post_types = $this->get_exposed_post_types(); + private function register_read_content(): void { + /* + * Post types must be registered with `show_in_abilities` before the ability is + * registered so they are included in its input schema. + */ + $post_types = array_keys( $this->get_exposed_post_types() ); + if ( empty( $post_types ) ) { + return; + } - $post_types = array_keys( $this->exposed_post_types ); - $statuses = array_values( get_post_stati( array( 'internal' => false ) ) ); + /* + * Internal statuses (e.g. `inherit`) are excluded, so post types that rely on + * them (attachments) are only reachable by ID. Revisit if such a post type is + * ever exposed via `show_in_abilities`. + */ + $statuses = array_values( get_post_stati( array( 'internal' => false ) ) ); wp_register_ability( 'core/read-content', array( 'label' => __( 'Read Content' ), - 'description' => __( 'Reads content from post types exposed to abilities. Single-post lookups by ID or by post type and slug return the post object directly. Query mode returns readable posts filtered by post type, status, author, parent, or included IDs.' ), + 'description' => __( 'Reads content from post types exposed to abilities. Single-post lookups by ID or by post type and slug return the post object directly. Query mode returns readable posts filtered by post type, status, author, parent, or included IDs. Requires an authenticated user. Lookups and filters are exact-match only; the ability does not perform full-text search.' ), 'category' => self::CATEGORY, - 'input_schema' => $this->get_content_input_schema( $post_types, $statuses ), - 'output_schema' => $this->get_content_output_schema(), - 'execute_callback' => array( $this, 'execute_get_content' ), + 'input_schema' => $this->get_read_content_input_schema( $post_types, $statuses ), + 'output_schema' => $this->get_read_content_output_schema(), + 'execute_callback' => array( $this, 'execute_read_content' ), 'permission_callback' => array( $this, 'check_permission' ), 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, + // MCP clients assume open-world (may reach external systems) when the + // hint is absent; this ability only reads the local database. + 'open_world' => false, ), 'show_in_rest' => true, - // Opt into REST-level pagination: query mode accepts `page`/`per_page` - // and returns `total`/`total_pages`, which the run controller turns into - // the standard X-WP-Total / X-WP-TotalPages response headers. - 'pagination' => true, ), ) ); @@ -189,10 +175,12 @@ private function register_get_content(): void { /** * Permission callback for the `core/read-content` ability. * - * Implements defense in depth: this gate decides whether the request may proceed at - * all, while the per-post read/edit checks in {@see self::execute_get_content()} - * are the authoritative, row-level enforcement. Requests that explicitly ask for - * edit-context fields require edit access before execution. + * This gate is the authoritative permission decision for single-post modes: it + * resolves the requested post and denies missing, mismatched, or unreadable posts + * before execution. Query mode is only gated coarsely here (collection status + * capabilities); {@see self::execute_read_content()} enforces row-level read/edit + * permissions, since individual rows are unknown until the query runs. Requests + * that explicitly ask for edit-context fields require edit access before execution. * * @since 7.1.0 * @@ -200,8 +188,8 @@ private function register_get_content(): void { * @return bool True if the request may proceed, false otherwise. */ public function check_permission( $input = array() ): bool { - $input = is_array( $input ) ? $input : array(); - $exposed = $this->exposed_post_types ?? $this->get_exposed_post_types(); + $input = rest_sanitize_object( $input ); + $exposed = $this->get_exposed_post_types(); if ( ! is_user_logged_in() ) { return false; @@ -229,7 +217,7 @@ public function check_permission( $input = array() ): bool { return false; } - if ( ! empty( $input['slug'] ) && is_string( $input['slug'] ) ) { + if ( isset( $input['slug'] ) && is_string( $input['slug'] ) && '' !== $input['slug'] ) { $post = $this->get_post_by_slug( $post_type, $input['slug'] ); if ( ! $post ) { return false; @@ -240,7 +228,7 @@ public function check_permission( $input = array() ): bool { $post_type_object = $exposed[ $post_type ]; if ( $requires_edit ) { - return current_user_can( $post_type_object->cap->edit_posts ); + return current_user_can( $this->post_type_cap( $post_type_object, 'edit_posts' ) ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. } return $this->can_query_statuses( $input, $post_type_object ); @@ -258,6 +246,76 @@ private function input_int( $value ): int { return is_scalar( $value ) ? absint( $value ) : 0; } + /** + * Parses a raw filter value into an integer of at least a minimum, or null when invalid. + * + * Unlike {@see self::input_int()}, which coerces any non-integer to 0, this rejects + * values that are not integers so a filter whose value cannot be honored can fail + * loudly instead of silently widening the query: `author => 0` drops the author + * filter (matching every author) and `post_parent => 0` becomes a top-level query. + * Accepts native integers and unsigned integer strings, mirroring how the JSON + * Schema `integer` type and the query-string transport respectively deliver them. + * + * @since 7.1.0 + * + * @param mixed $value The raw input value. + * @param int $min The smallest acceptable value. + * @return int|null The parsed integer, or null when the value is not an integer >= $min. + */ + private function parse_filter_int( $value, int $min ): ?int { + if ( is_int( $value ) ) { + return $value >= $min ? $value : null; + } + + if ( is_string( $value ) && '' !== $value && ctype_digit( $value ) ) { + $int = (int) $value; + + return $int >= $min ? $int : null; + } + + return null; + } + + /** + * Resolves a capability name from a post type's capability map. + * + * The capability map is a plain object with untyped properties, so guard the + * lookup and fail closed with `do_not_allow` when the name cannot be resolved. + * + * @since 7.1.0 + * + * @param \WP_Post_Type $post_type_object The post type object. + * @param string $capability The capability key, e.g. 'edit_posts'. + * @return string The resolved capability name, or 'do_not_allow' when unresolved. + */ + private function post_type_cap( \WP_Post_Type $post_type_object, string $capability ): string { + $cap = $post_type_object->cap->$capability ?? null; + + return is_string( $cap ) && '' !== $cap ? $cap : 'do_not_allow'; + } + + /** + * Parses a raw list input into a list of strings. + * + * A GET request delivers list inputs as scalar/CSV strings; this parses them the + * same way schema validation did (wp_parse_list) so they are honored regardless of + * transport, until core sanitizes ability input itself. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @param string $key The input key holding the list. + * @return list The parsed string values; empty when absent or unparseable. + */ + private function parse_list_input( array $input, string $key ): array { + $value = $input[ $key ] ?? null; + if ( ! is_array( $value ) && ! is_string( $value ) ) { + return array(); + } + + return array_values( array_filter( wp_parse_list( $value ), 'is_string' ) ); + } + /** * Checks whether the input explicitly requests edit-context fields. * @@ -270,13 +328,7 @@ private function input_int( $value ): int { * @return bool True if edit-context fields were explicitly requested. */ private function has_explicit_edit_fields( array $input ): bool { - if ( empty( $input['fields'] ) || ! is_array( $input['fields'] ) ) { - return false; - } - - $requested_fields = array_filter( $input['fields'], 'is_string' ); - - return array() !== array_intersect( self::EDIT_FIELDS, $requested_fields ); + return array() !== array_intersect( $this->edit_fields, $this->parse_list_input( $input, 'fields' ) ); } /** @@ -288,21 +340,23 @@ private function has_explicit_edit_fields( array $input ): bool { * * @since 7.1.0 * - * @param array $input The ability input. - * @param WP_Post_Type $post_type_object The post type object. + * @param array $input The ability input. + * @param \WP_Post_Type $post_type_object The post type object. * @return bool True if the requested statuses may be queried. */ - private function can_query_statuses( array $input, WP_Post_Type $post_type_object ): bool { + private function can_query_statuses( array $input, \WP_Post_Type $post_type_object ): bool { foreach ( $this->normalize_statuses( $input ) as $status ) { if ( 'publish' === $status ) { continue; } - if ( 'private' === $status && current_user_can( $post_type_object->cap->read_private_posts ) ) { + // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. + if ( 'private' === $status && current_user_can( $this->post_type_cap( $post_type_object, 'read_private_posts' ) ) ) { continue; } - if ( current_user_can( $post_type_object->cap->edit_posts ) ) { + // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. + if ( current_user_can( $this->post_type_cap( $post_type_object, 'edit_posts' ) ) ) { continue; } @@ -320,45 +374,99 @@ private function can_query_statuses( array $input, WP_Post_Type $post_type_objec * * @since 7.1.0 * - * @param WP_Post $post Post object. + * @param \WP_Post $post Post object. + * @param array $checked_post_ids Post IDs already checked while walking inherited parents. * @return bool Whether the post can be read. */ - private function check_read_permission( WP_Post $post ): bool { + private function check_read_permission( WP_Post $post, array $checked_post_ids = array() ): bool { + if ( isset( $checked_post_ids[ $post->ID ] ) ) { + return false; + } + + $checked_post_ids[ $post->ID ] = true; + $post_type = get_post_type_object( $post->post_type ); - if ( ! $post_type instanceof WP_Post_Type || empty( $post_type->show_in_abilities ) ) { + if ( ! $post_type instanceof \WP_Post_Type || empty( $post_type->show_in_abilities ) ) { return false; } - if ( 'publish' === $post->post_status || current_user_can( 'read_post', $post->ID ) ) { + /* + * Treat publicly viewable posts as readable. This checks both the post type + * and post status using Core's viewability helpers, which is stricter than + * checking the status object's `public` flag alone. + */ + if ( is_post_publicly_viewable( $post ) ) { return true; } - $post_status_object = get_post_status_object( $post->post_status ); - if ( $post_status_object && $post_status_object->public ) { + /* + * Use the normalized status for the status object lookup. For attachments, + * get_post_status() resolves `inherit` through the parent before returning. + */ + $post_status = get_post_status( $post ); + if ( ! is_string( $post_status ) ) { + return false; + } + + $post_status_object = get_post_status_object( $post_status ); + if ( ! $post_status_object instanceof \stdClass ) { + return false; + } + + /* + * Core maps `read_post` for public statuses to the post type's plain `read` + * capability. Publicly viewable posts already returned above, so a remaining + * public status is public but not viewable and should require edit access. + */ + if ( $post_status_object->public ) { + return current_user_can( 'edit_post', $post->ID ); + } + + /* + * For non-public statuses, defer to Core's meta-capability mapping. This + * handles own drafts, private posts, and statuses that require edit access. + */ + if ( current_user_can( 'read_post', $post->ID ) ) { return true; } - if ( 'inherit' === $post->post_status && $post->post_parent > 0 ) { + /* + * Mirror the REST posts controller's inherited-parent behavior, but keep the + * ability fail-closed for missing parents or parent loops. + */ + if ( + 'inherit' === $post->post_status && + $post->post_parent > 0 && + (int) $post->post_parent !== (int) $post->ID + ) { $parent = get_post( $post->post_parent ); if ( $parent instanceof WP_Post ) { - return $this->check_read_permission( $parent ); + return $this->check_read_permission( $parent, $checked_post_ids ); } } - return 'inherit' === $post->post_status; + return false; } /** * Executes the `core/read-content` ability. * + * {@see WP_Ability::execute()} always runs {@see self::check_permission()} first, so the + * single-post modes only re-validate the lookup itself: existence, exposure, and a + * matching post type. Query mode still filters every row by read or edit permission, + * because the gate cannot resolve rows before the query runs. + * + * A post is returned as an empty object when its field projection is empty, so callers + * must not assume array access on a post. See {@see self::to_output_post()}. + * * @since 7.1.0 * * @param mixed $input Optional. The ability input. Default empty array. - * @return array|WP_Error A post object in single-post mode, a map with a `posts` list in query mode, or a WP_Error on failure. + * @return array|\stdClass|\WP_Error A single post, a `posts` list with totals in query mode, or a WP_Error. */ - public function execute_get_content( $input = array() ) { - $input = is_array( $input ) ? $input : array(); - $exposed = $this->exposed_post_types ?? $this->get_exposed_post_types(); + public function execute_read_content( $input = array() ) { + $input = rest_sanitize_object( $input ); + $exposed = $this->get_exposed_post_types(); $fields = $this->normalize_fields( $input ); $requires_edit = $this->has_explicit_edit_fields( $input ); @@ -369,13 +477,11 @@ public function execute_get_content( $input = array() ) { if ( ! $post || ! isset( $exposed[ $post->post_type ] ) || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) - || ( $requires_edit && ! current_user_can( 'edit_post', $post->ID ) ) - || ( ! $requires_edit && ! $this->check_read_permission( $post ) ) ) { return $this->not_found_error(); } - return $this->format_post( $post, $fields ); + return $this->to_output_post( $this->format_post( $post, $fields ) ); } // Single-post mode (by slug) and query mode. @@ -384,23 +490,87 @@ public function execute_get_content( $input = array() ) { return $this->not_found_error(); } - if ( ! empty( $input['slug'] ) && is_string( $input['slug'] ) ) { + if ( isset( $input['slug'] ) && is_string( $input['slug'] ) && '' !== $input['slug'] ) { $post = $this->get_post_by_slug( $post_type, $input['slug'] ); - if ( ! $post - || ( $requires_edit && ! current_user_can( 'edit_post', $post->ID ) ) - || ( ! $requires_edit && ! $this->check_read_permission( $post ) ) - ) { + if ( ! $post ) { return $this->not_found_error(); } - return $this->format_post( $post, $fields ); + return $this->to_output_post( $this->format_post( $post, $fields ) ); } - $per_page = $this->normalize_per_page( $input ); + /* + * REST only registers the equivalent collection filters for post types that + * support them; a shared input schema cannot express that per post type. On + * transports that skip schema validation a malformed value would otherwise + * coerce to a benign default and silently *widen* the query (`author => 0` + * drops the author filter, an empty `post__in` is ignored, `post_parent => 0` + * becomes a top-level query). Reject unsupported filters and invalid filter + * values loudly so a filter that cannot be honored fails closed instead. + */ + $parent = null; + if ( isset( $input['parent'] ) ) { + if ( ! is_post_type_hierarchical( $post_type ) ) { + return new WP_Error( + 'content_invalid_filter', + __( 'The parent filter is only supported for hierarchical post types.' ), + array( 'status' => 400 ) + ); + } + + $parent = $this->parse_filter_int( $input['parent'], 0 ); + if ( null === $parent ) { + return new WP_Error( + 'content_invalid_filter', + __( 'The parent filter must be a non-negative integer.' ), + array( 'status' => 400 ) + ); + } + } + + $author = null; + if ( isset( $input['author'] ) ) { + if ( ! post_type_supports( $post_type, 'author' ) ) { + return new WP_Error( + 'content_invalid_filter', + __( 'The author filter is only supported for post types that support authors.' ), + array( 'status' => 400 ) + ); + } + + $author = $this->parse_filter_int( $input['author'], 1 ); + if ( null === $author ) { + return new WP_Error( + 'content_invalid_filter', + __( 'The author filter must be a positive integer.' ), + array( 'status' => 400 ) + ); + } + } + + $include = $this->normalize_include( $input ); + + /* + * An include filter that was supplied but parsed to no valid IDs must not fall + * through to an unrestricted query: WP_Query ignores an empty `post__in`, which + * would return every post of the type — the opposite of the caller's intent. + */ + if ( isset( $input['include'] ) && array() === $include ) { + return new WP_Error( + 'content_invalid_filter', + __( 'The include filter must list one or more valid post IDs.' ), + array( 'status' => 400 ) + ); + } + + $per_page = $this->normalize_per_page( $input, $include ); $page = isset( $input['page'] ) ? max( 1, $this->input_int( $input['page'] ) ) : 1; - $include = $this->normalize_include( $input ); + $prime_post_caches = $this->should_prime_post_caches( $fields ); + + // `orderby` is left unset, which orders by `post_date` descending, matching the + // default of the REST posts controller. $query_args = array( 'post_type' => $post_type, 'post_status' => $this->normalize_statuses( $input ), @@ -408,24 +578,52 @@ public function execute_get_content( $input = array() ) { 'paged' => $page, 'perm' => $requires_edit ? 'editable' : 'readable', 'ignore_sticky_posts' => true, - 'update_post_meta_cache' => false, - 'update_post_term_cache' => false, + 'update_post_meta_cache' => $prime_post_caches, + 'update_post_term_cache' => $prime_post_caches, ); if ( array() !== $include ) { $query_args['post__in'] = $include; - $query_args['orderby'] = 'post__in'; } - if ( ! empty( $input['author'] ) ) { - $query_args['author'] = $this->input_int( $input['author'] ); + if ( null !== $author ) { + $query_args['author'] = $author; } - if ( isset( $input['parent'] ) ) { - $query_args['post_parent'] = $this->input_int( $input['parent'] ); + if ( null !== $parent ) { + $query_args['post_parent'] = $parent; + } + + $query = new WP_Query( $query_args ); + $total = $this->get_query_total( $query, $query_args, $page ); + $total_pages = $total > 0 ? (int) ceil( $total / $per_page ) : 0; + + /* + * Paging past the last page is a caller error rather than an empty collection, so + * report it instead of returning a bare empty list. A genuinely empty result set + * still returns zero totals and no error. + */ + if ( $total > 0 && $page > $total_pages ) { + return new WP_Error( + 'content_invalid_page_number', + __( 'The page number requested is larger than the number of pages available.' ), + array( 'status' => 400 ) + ); } - $query = new WP_Query( $query_args ); + /* + * Prime the author caches with a single query instead of one user lookup + * per post, mirroring the REST posts controller. + */ + if ( in_array( 'author', $fields, true ) && post_type_supports( $post_type, 'author' ) ) { + $query_posts = array_filter( + $query->posts, + static function ( $queried_post ): bool { + return $queried_post instanceof WP_Post; + } + ); + update_post_author_caches( $query_posts ); + } $posts = array(); foreach ( $query->posts as $post ) { @@ -438,50 +636,120 @@ public function execute_get_content( $input = array() ) { if ( ! $requires_edit && ! $this->check_read_permission( $post ) ) { continue; } - $formatted = $this->format_post( $post, $fields ); - if ( array() === $formatted ) { - continue; - } - $posts[] = $formatted; + // Keep rows whose field projection is empty so a caller can still count them. + $posts[] = $this->to_output_post( $this->format_post( $post, $fields ) ); } + /* + * Mirror the REST posts controller: totals come from the underlying WP_Query, + * while row-level permission checks above may withhold individual returned rows. + */ return array( 'posts' => $posts, - 'total' => (int) $query->found_posts, - 'total_pages' => (int) $query->max_num_pages, + 'total' => $total, + 'total_pages' => $total_pages, ); } /** * Normalizes the requested per-page value to the supported bounds. * + * An explicit `per_page` always wins. Otherwise an `include` request pages to the + * number of requested IDs, so a caller loading a known set of posts receives all of + * them in one call rather than silently losing the ones past the default page size. + * The input schema caps `include` at {@see self::MAX_PER_PAGE} so it always fits. + * * @since 7.1.0 * - * @param array $input The ability input. + * @param array $input The ability input. + * @param list $include_ids Normalized included post IDs; empty when not requested. * @return int The clamped per-page value. */ - private function normalize_per_page( array $input ): int { - $per_page = isset( $input['per_page'] ) ? $this->input_int( $input['per_page'] ) : self::DEFAULT_PER_PAGE; + private function normalize_per_page( array $input, array $include_ids = array() ): int { + if ( isset( $input['per_page'] ) ) { + return max( 1, min( self::MAX_PER_PAGE, $this->input_int( $input['per_page'] ) ) ); + } - return max( 1, min( self::MAX_PER_PAGE, $per_page ) ); + if ( array() !== $include_ids ) { + return max( 1, min( self::MAX_PER_PAGE, count( $include_ids ) ) ); + } + + return self::DEFAULT_PER_PAGE; } /** - * Looks up a single post by post type and slug. + * Returns the query total, recovering it when WP_Query skipped the count. + * + * WP_Query leaves `found_posts` at 0 when a requested page has no rows. Re-run a + * minimal unpaged query so the caller can distinguish an out-of-range page from + * an empty result set, matching the REST posts controller behavior. + * + * @since 7.1.0 + * + * @param \WP_Query $query The executed query. + * @param array $query_args The arguments used for the executed query. + * @param int $page The requested page. + * @return int Total matching rows across all pages. + */ + private function get_query_total( WP_Query $query, array $query_args, int $page ): int { + $total = (int) $query->found_posts; + + if ( $total > 0 || $page <= 1 ) { + return $total; + } + + $count_args = $query_args; + $count_args['fields'] = 'ids'; + $count_args['posts_per_page'] = 1; + $count_args['update_post_meta_cache'] = false; + $count_args['update_post_term_cache'] = false; + unset( $count_args['paged'] ); + + $count_query = new WP_Query( $count_args ); + + return (int) $count_query->found_posts; + } + + /** + * Checks whether requested fields benefit from page-level cache priming. + * + * @since 7.1.0 + * + * @param list $fields The requested field names. + * @return bool True when post meta and term caches should be primed. + */ + private function should_prime_post_caches( array $fields ): bool { + return array() !== array_intersect( $this->cache_priming_fields, $fields ); + } + + /** + * Looks up the single post a slug request resolves to. + * + * Slugs are not unique across statuses (drafts skip slug uniqueness), so the + * lookup returns the newest match the current user can read, preferring + * publicly viewable posts — a newer draft sharing the slug cannot shadow a + * published post. This mirrors the REST API, where slug queries default to + * the `publish` status. Which post a slug resolves to is independent of the + * requested fields; edit-field requests are gated afterwards on the resolved + * post by {@see self::check_permission()}. * * @since 7.1.0 * * @param string $post_type The post type. * @param string $slug The post slug. - * @return WP_Post|null The matching post, or null when none exists. + * @return \WP_Post|null The matching readable post, or null when none exists. */ private function get_post_by_slug( string $post_type, string $slug ): ?WP_Post { + $name = sanitize_title( $slug ); + if ( '' === $name ) { + return null; + } + $query = new WP_Query( array( 'post_type' => $post_type, - 'name' => sanitize_title( $slug ), + 'name' => $name, 'post_status' => array_values( get_post_stati( array( 'internal' => false ) ) ), - 'posts_per_page' => 1, 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'update_post_meta_cache' => false, @@ -489,19 +757,43 @@ private function get_post_by_slug( string $post_type, string $slug ): ?WP_Post { ) ); - $post = $query->posts[0] ?? null; + $viewable = array(); + $hidden = array(); + foreach ( $query->posts as $candidate ) { + if ( ! $candidate instanceof WP_Post ) { + continue; + } + + if ( is_post_publicly_viewable( $candidate ) ) { + $viewable[] = $candidate; + continue; + } + + $hidden[] = $candidate; + } + + // Both groups keep the query's newest-first ordering. + foreach ( array_merge( $viewable, $hidden ) as $candidate ) { + if ( ! $this->check_read_permission( $candidate ) ) { + continue; + } + + return $candidate; + } - return $post instanceof WP_Post ? $post : null; + return null; } /** * Returns the post types exposed through the Abilities API, keyed by name. * - * Only post types whose `show_in_abilities` argument is truthy are exposed. + * Deliberately resolved on every call rather than cached: post types can be + * unregistered or re-registered with different arguments between the ability + * being registered and the ability being used. * * @since 7.1.0 * - * @return array Exposed post type objects keyed by name. + * @return array Exposed post type objects keyed by name. */ private function get_exposed_post_types(): array { $exposed_post_types = array(); @@ -519,15 +811,10 @@ private function get_exposed_post_types(): array { * @since 7.1.0 * * @param array $input The ability input. - * @return string[] Normalized list of post status slugs. + * @return list Normalized list of post status slugs. */ private function normalize_statuses( array $input ): array { - $statuses = $input['status'] ?? array( 'publish' ); - if ( ! is_array( $statuses ) ) { - return array( 'publish' ); - } - - $statuses = array_values( array_filter( $statuses, 'is_string' ) ); + $statuses = $this->parse_list_input( $input, 'status' ); return array() === $statuses ? array( 'publish' ) : array_map( 'sanitize_key', $statuses ); } @@ -538,44 +825,145 @@ private function normalize_statuses( array $input ): array { * @since 7.1.0 * * @param array $input The ability input. - * @return int[] Unique positive post IDs in caller-provided order. + * @return list Unique positive post IDs. */ private function normalize_include( array $input ): array { - if ( empty( $input['include'] ) || ! is_array( $input['include'] ) ) { + $include = $input['include'] ?? null; + if ( ! is_array( $include ) && ! is_string( $include ) ) { return array(); } - $ids = array_map( array( $this, 'input_int' ), $input['include'] ); - $ids = array_filter( - $ids, - static function ( int $id ): bool { - return $id > 0; - } - ); - - return array_values( array_unique( $ids ) ); + // A GET request delivers list inputs as scalar/CSV strings; wp_parse_id_list() + // accepts both and yields unique positive IDs, matching schema validation. + return array_values( array_filter( wp_parse_id_list( $include ) ) ); } /** - * Normalizes the requested fields to the supported set, defaulting to a lean field set. + * Returns the requested fields, or a lean default set when none are given. * - * An empty or absent `fields` value selects common read-context fields. Edit-context - * fields remain available when explicitly requested by a user who can edit the post. + * An empty or absent `fields` value selects a lean set of common read fields. + * Otherwise the requested fields are returned as-is. The input schema has already + * validated them against the supported set before the ability executes. * * @since 7.1.0 * * @param array $input The ability input. - * @return string[] List of requested field names. + * @return list List of requested field names. */ private function normalize_fields( array $input ): array { - if ( empty( $input['fields'] ) || ! is_array( $input['fields'] ) ) { - return $this->default_fields; + $fields = $this->parse_list_input( $input, 'fields' ); + + return array() === $fields ? $this->default_fields : $fields; + } + + /** + * Returns the post field definitions, keyed by field name in output order. + * + * This is the single source of truth for the ability's post fields: the output + * schema uses the definitions directly, while the input schema fields enum uses + * the keys. Read-context fields are returned for readable posts; the edit-context + * fields listed in {@see self::$edit_fields} additionally require edit access. + * + * @since 7.1.0 + * + * @return array Post field definitions. + */ + private function get_post_properties(): array { + if ( null !== $this->post_properties ) { + return $this->post_properties; } - $requested_fields = array_filter( $input['fields'], 'is_string' ); - $fields = array_intersect( $this->fields, $requested_fields ); + $this->post_properties = array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The post ID.' ), + ), + 'post_type' => array( + 'type' => 'string', + 'description' => __( 'The post type.' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'The post status.' ), + ), + 'date' => array( + 'type' => 'string', + 'description' => __( "The publication date, in ISO 8601 format using the site's timezone. Empty string when the date cannot be resolved." ), + ), + 'date_gmt' => array( + 'type' => 'string', + 'description' => __( 'The publication date, in ISO 8601 format as GMT. Empty string when the date cannot be resolved.' ), + ), + 'modified' => array( + 'type' => 'string', + 'description' => __( "The last modified date, in ISO 8601 format using the site's timezone. Empty string when the date cannot be resolved." ), + ), + 'modified_gmt' => array( + 'type' => 'string', + 'description' => __( 'The last modified date, in ISO 8601 format as GMT. Empty string when the date cannot be resolved.' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'The post slug.' ), + ), + 'link' => array( + 'type' => 'string', + 'description' => __( 'The permalink URL.' ), + ), + 'title_raw' => array( + 'type' => 'string', + 'description' => __( 'The raw post title. Present when the post type supports titles and the current user can edit the post.' ), + ), + 'title_rendered' => array( + 'type' => 'string', + 'description' => __( 'The rendered post title. Present when the post type supports titles.' ), + ), + 'excerpt_raw' => array( + 'type' => 'string', + 'description' => __( 'The raw post excerpt. Present when the post type supports excerpts and the current user can edit the post.' ), + ), + 'excerpt_rendered' => array( + 'type' => 'string', + 'description' => __( 'The rendered post excerpt (HTML). Present when the post type supports excerpts. Empty when withheld for a password-protected post.' ), + ), + 'excerpt_protected' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the excerpt is protected with a password. Present when the post type supports excerpts.' ), + ), + 'content_raw' => array( + 'type' => 'string', + 'description' => __( 'The raw, unfiltered post content (block markup). Present when the post type supports the editor and the current user can edit the post.' ), + ), + 'content_rendered' => array( + 'type' => 'string', + 'description' => __( 'The rendered post content. Present when the post type supports the editor. Empty when withheld for a password-protected post.' ), + ), + 'content_protected' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the content is protected with a password. Present when the post type supports the editor.' ), + ), + 'author' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The author user ID.' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'The author display name.' ), + ), + ), + 'description' => __( 'The post author. Present when the post type supports authors.' ), + ), + 'parent' => array( + 'type' => 'integer', + 'description' => __( 'The parent post ID. Present for hierarchical post types.' ), + ), + ); - return array() === $fields ? $this->default_fields : array_values( $fields ); + return $this->post_properties; } /** @@ -584,39 +972,40 @@ private function normalize_fields( array $input ): array { * The ability has three mutually exclusive modes, modeled as a `oneOf` so invalid * combinations are rejected rather than silently ignored: * - * - Get a single readable post by `id` (optionally guarded by `post_type`). - * - Get a single readable post by `post_type` and `slug`. - * - Query a set of readable posts by `post_type` plus filters (`status`, `author`, - * `parent`, `include`, `page`, `per_page`). + * - Get a single post by `id` (optionally guarded by `post_type`). + * - Get a single post by `post_type` and `slug`. + * - Query a set of posts by `post_type` plus filters (`status`, `author`, `parent`, + * `include`, `page`, `per_page`). * * Each mode sets `additionalProperties: false`, so e.g. passing `per_page` alongside `id` * fails validation instead of being dropped. `fields` is accepted in every mode. * * @since 7.1.0 * - * @param string[] $post_types Exposed post type names. - * @param string[] $statuses Requestable post status slugs. + * @param list $post_types Exposed post type names. + * @param list $statuses Requestable post status slugs. * @return array The input JSON Schema. */ - private function get_content_input_schema( array $post_types, array $statuses ): array { + private function get_read_content_input_schema( array $post_types, array $statuses ): array { $fields = array( 'type' => 'array', 'uniqueItems' => true, 'items' => array( 'type' => 'string', - 'enum' => $this->fields, + 'enum' => array_keys( $this->get_post_properties() ), ), 'description' => __( 'Limit each returned post to these fields. If omitted, a lean set of common read fields is returned. Explicit raw field requests require edit access.' ), ); $include = array( 'type' => 'array', 'minItems' => 1, + 'maxItems' => self::MAX_PER_PAGE, 'uniqueItems' => true, 'items' => array( 'type' => 'integer', 'minimum' => 1, ), - 'description' => __( 'Limit the query to these post IDs. Results preserve this order where possible and still respect post type and read permissions.' ), + 'description' => __( 'Limit the query to these post IDs. The order of the IDs does not affect the order of the results. If `per_page` is omitted, the page size defaults to the number of included IDs, capped at the maximum.' ), ); return array( @@ -655,14 +1044,14 @@ private function get_content_input_schema( array $post_types, array $statuses ): 'slug' => array( 'type' => 'string', 'minLength' => 1, - 'description' => __( 'Retrieve a single readable post by slug.' ), + 'description' => __( 'Retrieve a single readable post by slug. Resolves to the newest readable match, preferring published posts.' ), ), 'fields' => $fields, ), ), // Mode 3: query a set of readable posts by post type and filters. array( - 'title' => __( 'Query readable posts by type and filters' ), + 'title' => __( 'Query readable posts by post type and filters' ), 'required' => array( 'post_type' ), 'additionalProperties' => false, 'properties' => array( @@ -683,19 +1072,19 @@ private function get_content_input_schema( array $post_types, array $statuses ): 'author' => array( 'type' => 'integer', 'minimum' => 1, - 'description' => __( 'Filter by author user ID.' ), + 'description' => __( 'Filter by author user ID. Only supported for post types that support authors.' ), ), 'parent' => array( 'type' => 'integer', 'minimum' => 0, - 'description' => __( 'Filter by parent post ID, for hierarchical post types. Use 0 for top-level posts.' ), + 'description' => __( 'Filter by parent post ID. Only supported for hierarchical post types. Use 0 for top-level posts.' ), ), 'include' => $include, 'fields' => $fields, 'page' => array( 'type' => 'integer', 'minimum' => 1, - 'description' => __( 'Page of results to return.' ), + 'description' => __( 'Page of results to return. Requesting a page beyond the last one is an error. Check `total_pages` before requesting later pages.' ), ), 'per_page' => array( 'type' => 'integer', @@ -720,99 +1109,11 @@ private function get_content_input_schema( array $post_types, array $statuses ): * * @return array The output JSON Schema. */ - private function get_content_output_schema(): array { + private function get_read_content_output_schema(): array { $post_schema = array( 'type' => 'object', 'additionalProperties' => false, - 'properties' => array( - 'id' => array( - 'type' => 'integer', - 'description' => __( 'The post ID.' ), - ), - 'type' => array( - 'type' => 'string', - 'description' => __( 'The post type.' ), - ), - 'status' => array( - 'type' => 'string', - 'description' => __( 'The post status.' ), - ), - 'date' => array( - 'type' => 'string', - 'description' => __( "The publication date, in ISO 8601 format using the site's timezone." ), - ), - 'date_gmt' => array( - 'type' => 'string', - 'description' => __( 'The publication date, in ISO 8601 format as GMT.' ), - ), - 'modified' => array( - 'type' => 'string', - 'description' => __( "The last modified date, in ISO 8601 format using the site's timezone." ), - ), - 'modified_gmt' => array( - 'type' => 'string', - 'description' => __( 'The last modified date, in ISO 8601 format as GMT.' ), - ), - 'slug' => array( - 'type' => 'string', - 'description' => __( 'The post slug.' ), - ), - 'link' => array( - 'type' => 'string', - 'description' => __( 'The permalink URL.' ), - ), - 'title_raw' => array( - 'type' => 'string', - 'description' => __( 'The raw post title. Present when the post type supports titles and the current user can edit the post.' ), - ), - 'title_rendered' => array( - 'type' => 'string', - 'description' => __( 'The rendered post title. Present when the post type supports titles.' ), - ), - 'excerpt_raw' => array( - 'type' => 'string', - 'description' => __( 'The raw post excerpt. Present when the post type supports excerpts and the current user can edit the post.' ), - ), - 'excerpt_rendered' => array( - 'type' => 'string', - 'description' => __( 'The rendered post excerpt. Present when the post type supports excerpts. Empty when withheld for a password-protected post.' ), - ), - 'excerpt_protected' => array( - 'type' => 'boolean', - 'description' => __( 'Whether the excerpt is protected with a password. Present when the post type supports excerpts.' ), - ), - 'content_raw' => array( - 'type' => 'string', - 'description' => __( 'The raw, unfiltered post content (block markup). Present when the post type supports the editor and the current user can edit the post.' ), - ), - 'content_rendered' => array( - 'type' => 'string', - 'description' => __( 'The rendered post content. Present when the post type supports the editor. Empty when withheld for a password-protected post.' ), - ), - 'content_protected' => array( - 'type' => 'boolean', - 'description' => __( 'Whether the content is protected with a password. Present when the post type supports the editor.' ), - ), - 'author' => array( - 'type' => 'object', - 'additionalProperties' => false, - 'properties' => array( - 'id' => array( - 'type' => 'integer', - 'description' => __( 'The author user ID.' ), - ), - 'display_name' => array( - 'type' => 'string', - 'description' => __( 'The author display name.' ), - ), - ), - 'description' => __( 'The post author. Present when the post type supports authors.' ), - ), - 'parent' => array( - 'type' => 'integer', - 'description' => __( 'The parent post ID. Present for hierarchical post types.' ), - ), - ), + 'properties' => $this->get_post_properties(), ); $query_schema = array( @@ -822,16 +1123,16 @@ private function get_content_output_schema(): array { 'properties' => array( 'posts' => array( 'type' => 'array', - 'description' => __( 'The readable posts matching the query.' ), + 'description' => __( 'The readable posts matching the query, ordered by post date, newest first.' ), 'items' => $post_schema, ), 'total' => array( 'type' => 'integer', - 'description' => __( 'Total number of posts matching the query, across all pages, after applying the permission filter to the query. Surfaced over REST as the X-WP-Total header.' ), + 'description' => __( 'Total number of posts matching the underlying query, across all pages. May exceed the number of returned posts when row-level permission checks withhold some of them.' ), ), 'total_pages' => array( 'type' => 'integer', - 'description' => __( 'Total number of query result pages available after applying the permission filter to the query. Surfaced over REST as the X-WP-TotalPages header.' ), + 'description' => __( 'Total number of query result pages available for the underlying query. May include pages whose rows are withheld by row-level permission checks.' ), ), ), ); @@ -845,99 +1146,163 @@ private function get_content_output_schema(): array { ); } + /** + * Prepares a formatted post for output. + * + * A field projection can legitimately be empty, for example when the only requested + * field is one the post type does not support. An empty PHP array encodes as `[]`, + * which would break the `object` output schema, so return an empty object instead. + * + * This deliberately improves on the REST posts controller, which encodes the same + * case as `[]` even though it types the response as an object + * (`GET /wp/v2/posts/?_fields=parent` on a non-hierarchical post type). + * + * @since 7.1.0 + * + * @param array $formatted The formatted post data. + * @return array|\stdClass The post data, or an empty object when the projection is empty. + */ + private function to_output_post( array $formatted ) { + return array() === $formatted ? (object) array() : $formatted; + } + /** * Formats a post into the ability output shape. * - * Only the requested fields that the post type supports and the current user can see - * are included. Raw fields are edit-context fields; rendered fields are read-context - * fields and are withheld for password-protected posts unless the current user can edit - * the post, mirroring the REST API behavior. + * For an editor of a password-protected post, the cookie-based password gate is suspended + * while the fields are built so rendered fields resolve to real values instead of + * protected-post placeholders. The field projection itself is delegated to + * {@see self::build_post_fields()}. * * @since 7.1.0 * - * @param WP_Post $post The post object. - * @param string[] $fields The requested field names. + * @param \WP_Post $post The post object. + * @param list $fields The requested field names. * @return array The formatted post data. */ private function format_post( WP_Post $post, array $fields ): array { - $post_type = $post->post_type; - $fields_requested = static function ( string $field ) use ( $fields ): bool { - return in_array( $field, $fields, true ); - }; - $can_edit = current_user_can( 'edit_post', $post->ID ); - $protected = post_password_required( $post ) && ! $can_edit; + $can_edit = current_user_can( 'edit_post', $post->ID ); + $password_required = post_password_required( $post ); + $protected = $password_required && ! $can_edit; + + /* + * Suspend the cookie-based password gate for an editor of this protected post, so + * helpers with their own gate (e.g. get_the_excerpt()) resolve the real values. The + * filter unlocks only posts the current user can edit, mirroring the REST posts + * controller's check_password_required(): an unconditional bypass (e.g. __return_false) + * would also expose other protected posts that the content filter may render, such as + * posts pulled in by a Query Loop block. The filter is removed in a finally block so a + * throw mid-render cannot leave the gate globally disabled for the rest of the request. + */ + if ( $password_required && $can_edit ) { + add_filter( 'post_password_required', array( $this, 'allow_password_content' ), 10, 2 ); - $data = array(); + try { + return $this->build_post_fields( $post, $fields, $can_edit, $protected ); + } finally { + remove_filter( 'post_password_required', array( $this, 'allow_password_content' ), 10 ); + } + } + + return $this->build_post_fields( $post, $fields, $can_edit, $protected ); + } - if ( $fields_requested( 'id' ) ) { + /** + * Builds the requested field projection for a post. + * + * Only the requested fields that the post type supports and the current user can see are + * included. Raw fields are edit-context fields; rendered fields are read-context fields and + * are withheld for password-protected posts unless the current user can edit the post, + * mirroring the REST API behavior. + * + * @since 7.1.0 + * + * @param \WP_Post $post The post object. + * @param list $fields The requested field names. + * @param bool $can_edit Whether the current user can edit the post. + * @param bool $is_protected Whether rendered fields must be withheld as password-protected. + * @return array The formatted post data. + */ + private function build_post_fields( WP_Post $post, array $fields, bool $can_edit, bool $is_protected ): array { + $post_type = $post->post_type; + + // Edit-context fields require edit access; drop them so $edit_fields is the single gate. + if ( ! $can_edit ) { + $fields = array_diff( $fields, $this->edit_fields ); + } + + $requested = array_flip( $fields ); + $data = array(); + + if ( isset( $requested['id'] ) ) { $data['id'] = (int) $post->ID; } - if ( $fields_requested( 'type' ) ) { - $data['type'] = $post_type; + if ( isset( $requested['post_type'] ) ) { + $data['post_type'] = $post_type; } - if ( $fields_requested( 'status' ) ) { + if ( isset( $requested['status'] ) ) { $data['status'] = $post->post_status; } - if ( $fields_requested( 'date' ) ) { + if ( isset( $requested['date'] ) ) { $data['date'] = $this->format_local_date( $post, 'date' ); } - if ( $fields_requested( 'date_gmt' ) ) { + if ( isset( $requested['date_gmt'] ) ) { $data['date_gmt'] = $this->format_gmt_date( $post, 'date' ); } - if ( $fields_requested( 'modified' ) ) { + if ( isset( $requested['modified'] ) ) { $data['modified'] = $this->format_local_date( $post, 'modified' ); } - if ( $fields_requested( 'modified_gmt' ) ) { + if ( isset( $requested['modified_gmt'] ) ) { $data['modified_gmt'] = $this->format_gmt_date( $post, 'modified' ); } - if ( $fields_requested( 'slug' ) ) { + if ( isset( $requested['slug'] ) ) { $data['slug'] = $post->post_name; } - if ( $fields_requested( 'link' ) ) { + if ( isset( $requested['link'] ) ) { $data['link'] = (string) get_permalink( $post ); } - if ( $fields_requested( 'title_raw' ) && post_type_supports( $post_type, 'title' ) && $can_edit ) { + if ( isset( $requested['title_raw'] ) && post_type_supports( $post_type, 'title' ) ) { $data['title_raw'] = $post->post_title; } - if ( $fields_requested( 'title_rendered' ) && post_type_supports( $post_type, 'title' ) ) { + if ( isset( $requested['title_rendered'] ) && post_type_supports( $post_type, 'title' ) ) { $data['title_rendered'] = $this->get_title( $post ); } - if ( $fields_requested( 'excerpt_raw' ) && post_type_supports( $post_type, 'excerpt' ) && $can_edit ) { + if ( isset( $requested['excerpt_raw'] ) && post_type_supports( $post_type, 'excerpt' ) ) { $data['excerpt_raw'] = $post->post_excerpt; } - if ( $fields_requested( 'excerpt_rendered' ) && post_type_supports( $post_type, 'excerpt' ) ) { - $data['excerpt_rendered'] = $protected ? '' : (string) get_the_excerpt( $post ); + if ( isset( $requested['excerpt_rendered'] ) && post_type_supports( $post_type, 'excerpt' ) ) { + $data['excerpt_rendered'] = $is_protected ? '' : $this->get_rendered_excerpt( $post ); } - if ( $fields_requested( 'excerpt_protected' ) && post_type_supports( $post_type, 'excerpt' ) ) { + if ( isset( $requested['excerpt_protected'] ) && post_type_supports( $post_type, 'excerpt' ) ) { $data['excerpt_protected'] = (bool) $post->post_password; } - if ( $fields_requested( 'content_raw' ) && post_type_supports( $post_type, 'editor' ) && $can_edit ) { + if ( isset( $requested['content_raw'] ) && post_type_supports( $post_type, 'editor' ) ) { $data['content_raw'] = $post->post_content; } - if ( $fields_requested( 'content_rendered' ) && post_type_supports( $post_type, 'editor' ) ) { - $data['content_rendered'] = $protected ? '' : $this->get_rendered_content( $post ); + if ( isset( $requested['content_rendered'] ) && post_type_supports( $post_type, 'editor' ) ) { + $data['content_rendered'] = $is_protected ? '' : $this->get_rendered_content( $post ); } - if ( $fields_requested( 'content_protected' ) && post_type_supports( $post_type, 'editor' ) ) { + if ( isset( $requested['content_protected'] ) && post_type_supports( $post_type, 'editor' ) ) { $data['content_protected'] = (bool) $post->post_password; } - if ( $fields_requested( 'author' ) && post_type_supports( $post_type, 'author' ) ) { + if ( isset( $requested['author'] ) && post_type_supports( $post_type, 'author' ) ) { $author = get_userdata( (int) $post->post_author ); $data['author'] = array( - 'id' => (int) $post->post_author, - 'display_name' => $author ? $author->display_name : '', + 'id' => (int) $post->post_author, + 'name' => $author ? $author->display_name : '', ); } - if ( $fields_requested( 'parent' ) && is_post_type_hierarchical( $post_type ) ) { + if ( isset( $requested['parent'] ) && is_post_type_hierarchical( $post_type ) ) { $data['parent'] = (int) $post->post_parent; } @@ -945,25 +1310,50 @@ private function format_post( WP_Post $post, array $fields ): array { } /** - * Returns the post title with the protected/private prefixes stripped. + * Filters {@see post_password_required()} to unlock only posts the current user can edit. * - * Mirrors the REST API, which removes the "Protected: " / "Private: " prefixes for - * machine consumers while still applying the_title filters. + * Added by {@see self::format_post()} while formatting a password-protected post the + * current user can edit, so rendered fields resolve to real values without also unlocking + * other protected posts that the content filter may render. Mirrors the REST posts + * controller's check_password_required(). * * @since 7.1.0 * - * @param WP_Post $post The post object. + * @param mixed $required Whether the post currently requires a password. + * @param mixed $post The post being checked; a WP_Post when invoked by the core filter. + * @return bool Whether the post still requires a password. + */ + public function allow_password_content( $required, $post ): bool { + if ( ! $required || ! $post instanceof WP_Post ) { + return (bool) $required; + } + + return ! current_user_can( 'edit_post', $post->ID ); + } + + /** + * Returns the post title with the protected/private prefixes stripped. + * + * @since 7.1.0 + * + * @param \WP_Post $post The post object. * @return string The post title. */ private function get_title( WP_Post $post ): string { $strip = array( $this, 'return_raw_title_format' ); add_filter( 'protected_title_format', $strip ); add_filter( 'private_title_format', $strip ); - $title = get_the_title( $post ); - remove_filter( 'protected_title_format', $strip ); - remove_filter( 'private_title_format', $strip ); - return $title; + /* + * The format filters are removed in a finally block so a throw from a title + * filter cannot leave them attached for the rest of the request. + */ + try { + return get_the_title( $post ); + } finally { + remove_filter( 'protected_title_format', $strip ); + remove_filter( 'private_title_format', $strip ); + } } /** @@ -977,6 +1367,53 @@ public function return_raw_title_format(): string { return '%s'; } + /** + * Returns the post excerpt transformed for display. + * + * Mirrors the REST posts controller by preparing post globals before applying + * the `get_the_excerpt` and `the_excerpt` filter chains, then restoring the + * previous global post context. This ensures filters that rely on loop globals + * render against the requested post. + * + * @since 7.1.0 + * + * @param \WP_Post $post The post object. + * @return string Rendered post excerpt. + */ + private function get_rendered_excerpt( WP_Post $post ): string { + $previous_post = $GLOBALS['post'] ?? null; + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily mirrors REST post context for excerpt rendering. + $GLOBALS['post'] = $post; + setup_postdata( $post ); + + /* + * The global post context is restored in a finally block so a throw from an + * excerpt filter cannot leave it pointing at the rendered post for the rest + * of the request. + */ + try { + /** This filter is documented in wp-includes/post-template.php. */ + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core excerpt filter to mirror REST rendering. + $excerpt = apply_filters( 'get_the_excerpt', $post->post_excerpt, $post ); + + /** This filter is documented in wp-includes/post-template.php. */ + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core excerpt filter to mirror REST rendering. + $excerpt = apply_filters( 'the_excerpt', $excerpt ); + + return is_string( $excerpt ) ? $excerpt : ''; + } finally { + if ( $previous_post instanceof WP_Post ) { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the previous global post context. + $GLOBALS['post'] = $previous_post; + setup_postdata( $previous_post ); + } else { + unset( $GLOBALS['post'] ); + wp_reset_postdata(); + } + } + } + /** * Returns post content transformed for display. * @@ -985,27 +1422,37 @@ public function return_raw_title_format(): string { * * @since 7.1.0 * - * @param WP_Post $post The post object. + * @param \WP_Post $post The post object. * @return string Rendered post content. */ private function get_rendered_content( WP_Post $post ): string { $previous_post = $GLOBALS['post'] ?? null; + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily mirrors REST post context for content rendering. $GLOBALS['post'] = $post; setup_postdata( $post ); - /** This filter is documented in wp-includes/post-template.php. */ - $content = apply_filters( 'the_content', $post->post_content ); - - if ( $previous_post instanceof WP_Post ) { - $GLOBALS['post'] = $previous_post; - setup_postdata( $previous_post ); - } else { - unset( $GLOBALS['post'] ); - wp_reset_postdata(); + /* + * The global post context is restored in a finally block so a throw from a + * content filter cannot leave it pointing at the rendered post for the rest + * of the request. + */ + try { + /** This filter is documented in wp-includes/post-template.php. */ + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core content filter to mirror REST rendering. + $content = apply_filters( 'the_content', $post->post_content ); + + return is_string( $content ) ? $content : ''; + } finally { + if ( $previous_post instanceof WP_Post ) { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the previous global post context. + $GLOBALS['post'] = $previous_post; + setup_postdata( $previous_post ); + } else { + unset( $GLOBALS['post'] ); + wp_reset_postdata(); + } } - - return (string) $content; } /** @@ -1013,59 +1460,81 @@ private function get_rendered_content( WP_Post $post ): string { * * @since 7.1.0 * - * @param WP_Post $post The post object. - * @param string $field Either 'date' or 'modified'. + * @param \WP_Post $post The post object. + * @param string $field Either 'date' or 'modified'. Default 'date'. * @return string The ISO 8601 date, or an empty string if unavailable. */ - private function format_local_date( WP_Post $post, string $field ): string { + private function format_local_date( WP_Post $post, string $field = 'date' ): string { $field = 'modified' === $field ? 'modified' : 'date'; $datetime = get_post_datetime( $post, $field, 'local' ); - if ( $datetime ) { - return $datetime->format( 'c' ); - } - $local = 'modified' === $field ? $post->post_modified : $post->post_date; - $timestamp = mysql2date( 'U', $local, false ); - - return $timestamp ? wp_date( 'c', (int) $timestamp ) : ''; + return $datetime ? $datetime->format( 'c' ) : ''; } /** * Formats a post date field as an ISO 8601 string in GMT. * - * Uses get_post_datetime() so that posts without a GMT timestamp (e.g. some drafts) - * still resolve to a valid date. + * Reads the stored GMT date directly, deriving it from the local date when missing + * (e.g. drafts), mirroring the REST posts controller. get_post_datetime() is avoided + * here because it reprojects even GMT-sourced dates into the site timezone, which + * would label the returned instant with the site offset instead of UTC. * * @since 7.1.0 * - * @param WP_Post $post The post object. - * @param string $field Either 'date' or 'modified'. + * @param \WP_Post $post The post object. + * @param string $field Either 'date' or 'modified'. Default 'date'. * @return string The ISO 8601 date, or an empty string if unavailable. */ - private function format_gmt_date( WP_Post $post, string $field ): string { - $field = 'modified' === $field ? 'modified' : 'date'; - $datetime = get_post_datetime( $post, $field, 'gmt' ); - if ( $datetime ) { - return $datetime->format( 'c' ); + private function format_gmt_date( WP_Post $post, string $field = 'date' ): string { + $field = 'modified' === $field ? 'modified' : 'date'; + $gmt = 'modified' === $field ? $post->post_modified_gmt : $post->post_date_gmt; + + if ( ! $this->is_usable_date( $gmt ) ) { + $local = 'modified' === $field ? $post->post_modified : $post->post_date; + $gmt = $this->is_usable_date( $local ) ? get_gmt_from_date( $local ) : ''; } - // Fallback for posts without a resolvable timestamp. - $local = 'modified' === $field ? $post->post_modified : $post->post_date; - $timestamp = mysql2date( 'U', $local, false ); + /* + * Guard the empty string before `strtotime()`: `strtotime( ' UTC' )` resolves to the + * current time, which would report a fabricated date instead of the documented + * empty-string sentinel. + */ + $timestamp = '' === $gmt ? false : strtotime( $gmt . ' UTC' ); - return $timestamp ? gmdate( 'c', (int) $timestamp ) : ''; + return false === $timestamp ? '' : gmdate( 'c', $timestamp ); + } + + /** + * Checks whether a raw post date column holds a usable date. + * + * The columns are `NOT NULL` in core's schema, but a post object can reach this class + * from a filter or an in-memory row where a date is null or a zero date. + * + * @since 7.1.0 + * + * @param mixed $date The raw date column value. + * @return bool True when the value is a non-empty, non-zero date string. + */ + private function is_usable_date( $date ): bool { + return is_string( $date ) && '' !== $date && '0000-00-00 00:00:00' !== $date; } /** * Builds the uniform not-found error. * - * Used by execution when content cannot be resolved or edited after permission - * checks. The permission callback fails closed for uncertain by-ID lookups before - * execution runs. + * Unreachable through gated transports, which run {@see self::check_permission()} + * first and deny the same lookups. It is kept so that a direct call to the execute + * callback still fails closed on a structural lookup failure: a missing post, a post + * type that is not exposed, or a post type that does not match the requested one. + * + * This is not a permission check. The execute callback deliberately does not repeat + * the read/edit checks that {@see self::check_permission()} already performed, so a + * direct call bypasses them. Only invoke the callback through + * {@see WP_Ability::execute()}, which always runs the permission callback first. * * @since 7.1.0 * - * @return WP_Error The not-found error. + * @return \WP_Error The not-found error. */ private function not_found_error(): WP_Error { return new WP_Error( diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 608096f0fe9c1..d63e1b2de984a 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -5,7 +5,6 @@ /** * Tests for the core/read-content ability shipped with the Abilities API. * - * @covers wp_register_core_abilities * @covers WP_Content_Abilities * * @group abilities-api @@ -13,94 +12,184 @@ class Tests_Abilities_API_WpRegisterCoreContentAbility extends WP_UnitTestCase { /** - * An exposed custom post type used to verify post-type-agnostic behavior. + * Shared user IDs keyed by role or fixture name. * - * @var string + * @since 7.1.0 + * + * @var array */ - const EXPOSED_CPT = 'content_ability_cpt'; + private static $user_ids = array(); /** - * A custom post type that is NOT exposed to abilities. + * Shared post IDs keyed by fixture name. + * + * @since 7.1.0 * - * @var string + * @var array */ - const HIDDEN_CPT = 'content_hidden_cpt'; + private static $post_ids = array(); /** - * Registers post types and the core abilities once, before the schema is built. - * - * The ability builds its `post_type`/`status`/`fields` schema at registration time, - * so any custom post type must be registered before the abilities are registered. + * Creates shared users and posts for the content ability tests. * * @since 7.1.0 + * + * @param \WP_UnitTest_Factory $factory The unit test factory. */ - public static function set_up_before_class(): void { - parent::set_up_before_class(); - - register_post_type( - self::EXPOSED_CPT, - array( - 'public' => true, - 'show_in_abilities' => true, - 'supports' => array( 'title', 'editor', 'excerpt', 'author' ), - ) + public static function wpSetUpBeforeClass( $factory ): void { + self::$user_ids = array( + 'administrator' => $factory->user->create( array( 'role' => 'administrator' ) ), + 'editor' => $factory->user->create( array( 'role' => 'editor' ) ), + 'subscriber' => $factory->user->create( array( 'role' => 'subscriber' ) ), + 'contributor' => $factory->user->create( array( 'role' => 'contributor' ) ), + 'author' => $factory->user->create( array( 'role' => 'author' ) ), + 'author_secondary' => $factory->user->create( array( 'role' => 'author' ) ), ); - register_post_type( - self::HIDDEN_CPT, - array( - 'public' => true, - 'supports' => array( 'title', 'editor' ), - ) + self::$post_ids = array( + 'published' => $factory->post->create( array( 'post_status' => 'publish' ) ), + 'published_content' => $factory->post->create( + array( + 'post_title' => 'Hello Content', + 'post_content' => 'Body here.', + 'post_status' => 'publish', + ) + ), + 'subscriber_content' => $factory->post->create( + array( + 'post_title' => 'Visible to subscribers', + 'post_content' => 'Rendered body for subscribers.', + 'post_status' => 'publish', + ) + ), + 'readable_single' => $factory->post->create( + array( + 'post_title' => 'Readable single', + 'post_content' => 'Readable single body.', + 'post_status' => 'publish', + ) + ), + 'limited_role_content' => $factory->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_title' => 'Readable title', + 'post_content' => 'Readable body for limited role.', + 'post_excerpt' => 'Readable excerpt.', + 'post_status' => 'publish', + ) + ), + 'raw_content' => $factory->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'Public body with raw block markup.', + ) + ), + 'password_protected_editor' => $factory->post->create( + array( + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'Top secret body.', + ) + ), + 'password_protected_limited' => $factory->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'Hidden rendered body.', + ) + ), ); + } - // Temporarily remove the unhook functions so we can register core abilities. - remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); - remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); - foreach ( wp_get_abilities() as $ability ) { - wp_unregister_ability( $ability->get_name() ); - } - foreach ( wp_get_ability_categories() as $ability_category ) { - wp_unregister_ability_category( $ability_category->get_slug() ); + /** + * Sets up the content ability category for each test. + * + * @since 7.1.0 + */ + public function setUp(): void { + parent::setUp(); + + if ( wp_has_ability( 'core/read-content' ) ) { + wp_unregister_ability( 'core/read-content' ); } - add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); - add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); - do_action( 'wp_abilities_api_categories_init' ); - do_action( 'wp_abilities_api_init' ); + $this->ensure_ability_category( 'content' ); } /** - * Cleans up registered abilities, categories, and post types. + * Restores ability and post type state after each test. * * @since 7.1.0 */ - public static function tear_down_after_class(): void { - add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); - add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + public function tearDown(): void { + if ( wp_has_ability( 'core/read-content' ) ) { + wp_unregister_ability( 'core/read-content' ); + } - foreach ( wp_get_abilities() as $ability ) { - wp_unregister_ability( $ability->get_name() ); + foreach ( array( 'post', 'page' ) as $post_type ) { + $object = get_post_type_object( $post_type ); + if ( $object ) { + $object->show_in_abilities = true; + } } - foreach ( wp_get_ability_categories() as $ability_category ) { - wp_unregister_ability_category( $ability_category->get_slug() ); + + wp_set_current_user( 0 ); + + parent::tearDown(); + } + + /** + * Ensures an ability category exists for an ability to attach to. + * + * @since 7.1.0 + * + * @param string $slug The ability category slug. + */ + private function ensure_ability_category( string $slug ): void { + if ( wp_has_ability_category( $slug ) ) { + return; } - unregister_post_type( self::EXPOSED_CPT ); - unregister_post_type( self::HIDDEN_CPT ); + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_categories_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + wp_register_ability_category( + $slug, + array( + 'label' => ucfirst( $slug ), + 'description' => ucfirst( $slug ) . '.', + ) + ); + } finally { + array_pop( $wp_current_filter ); + } + } - parent::tear_down_after_class(); + /** + * Registers the core/read-content ability inside a faked init action. + * + * @since 7.1.0 + */ + private function register_ability(): void { + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + ( new WP_Content_Abilities() )->register(); + } finally { + array_pop( $wp_current_filter ); + } } /** * Logs in as a user with the given role and returns the user ID. * - * @param string $role The role to create the user with. - * @return int The new user ID. + * @param string $role The role to log in as. + * @return int The user ID. */ private function login_as( string $role ): int { - $user_id = self::factory()->user->create( array( 'role' => $role ) ); + $user_id = self::$user_ids[ $role ] ?? self::factory()->user->create( array( 'role' => $role ) ); wp_set_current_user( $user_id ); return $user_id; } @@ -125,872 +214,2620 @@ public function data_roles_without_edit_access_to_other_users_posts(): array { } /** - * Convenience accessor for the ability. + * The ability is registered in the `content` category and flagged read-only. * - * @return WP_Ability The core/read-content ability. + * @since 7.1.0 */ - private function ability(): WP_Ability { - return wp_get_ability( 'core/read-content' ); + public function test_registers_core_read_content_ability(): void { + $this->register_ability(); + + $ability = wp_get_ability( 'core/read-content' ); + + $this->assertNotNull( $ability, 'The core/read-content ability should be registered.' ); + $this->assertSame( 'core/read-content', $ability->get_name(), 'The registered ability should use the expected name.' ); + $this->assertSame( 'content', $ability->get_category(), 'The registered ability should use the content category.' ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ), 'The ability should be exposed in REST.' ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertTrue( $annotations['readonly'], 'The ability should be marked read-only.' ); + $this->assertFalse( $annotations['destructive'], 'The ability should be marked non-destructive.' ); + $this->assertTrue( $annotations['idempotent'], 'The ability should be marked idempotent.' ); + $this->assertFalse( $annotations['open_world'], 'The ability should be marked closed-world; it only reads the local database.' ); } - /* - * ------------------------------------------------------------------------- - * Registration & schema - * ------------------------------------------------------------------------- + /** + * The content ability is not registered when no post types are exposed to it. + * + * @since 7.1.0 */ + public function test_does_not_register_core_read_content_ability_without_exposed_post_types(): void { + foreach ( array( 'post', 'page' ) as $post_type ) { + $object = get_post_type_object( $post_type ); + $this->assertNotFalse( $object, "Precondition: the {$post_type} post type should exist." ); - public function test_ability_is_registered_readonly_in_content_category(): void { - $ability = $this->ability(); + $object->show_in_abilities = false; + } - $this->assertInstanceOf( WP_Ability::class, $ability ); - $this->assertSame( 'content', $ability->get_category() ); - $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); + $this->register_ability(); - $annotations = $ability->get_meta_item( 'annotations', array() ); - $this->assertTrue( $annotations['readonly'] ); - $this->assertFalse( $annotations['destructive'] ); - $this->assertTrue( $annotations['idempotent'] ); + $this->assertFalse( wp_has_ability( 'core/read-content' ), 'The content ability should not register without any exposed post types.' ); } + /** + * The input schema models mutually exclusive ID, slug, and query modes, each + * rejecting the other modes' properties and exposing only marked types. + * + * @since 7.1.0 + */ public function test_input_schema_models_mutually_exclusive_modes(): void { - $schema = $this->ability()->get_input_schema(); + $this->register_ability(); - $this->assertSame( 'object', $schema['type'] ); - $this->assertCount( 3, $schema['oneOf'] ); + $schema = wp_get_ability( 'core/read-content' )->get_input_schema(); + + $this->assertSame( 'object', $schema['type'], 'The input schema should describe an object.' ); + $this->assertCount( 3, $schema['oneOf'], 'The input schema should expose exactly three modes.' ); [ $by_id, $by_slug, $query ] = $schema['oneOf']; - $this->assertSame( array( 'id' ), $by_id['required'] ); - $this->assertSame( array( 'post_type', 'slug' ), $by_slug['required'] ); - $this->assertSame( array( 'post_type' ), $query['required'] ); - $this->assertFalse( $by_id['additionalProperties'] ); - $this->assertFalse( $by_slug['additionalProperties'] ); - $this->assertFalse( $query['additionalProperties'] ); + // All modes reject properties from the other modes. + $this->assertSame( array( 'id' ), $by_id['required'], 'The by-ID mode should require an ID.' ); + $this->assertSame( array( 'post_type', 'slug' ), $by_slug['required'], 'The slug mode should require post type and slug.' ); + $this->assertSame( array( 'post_type' ), $query['required'], 'The query mode should require a post type.' ); + $this->assertFalse( $by_id['additionalProperties'], 'The by-ID mode should reject unrelated properties.' ); + $this->assertFalse( $by_slug['additionalProperties'], 'The slug mode should reject unrelated properties.' ); + $this->assertFalse( $query['additionalProperties'], 'The query mode should reject unrelated properties.' ); // Query-only filters live only in the query mode, not the single-post modes. - $this->assertArrayHasKey( 'include', $query['properties'] ); - $this->assertArrayHasKey( 'per_page', $query['properties'] ); - $this->assertArrayNotHasKey( 'per_page', $by_id['properties'] ); - $this->assertArrayNotHasKey( 'include', $by_slug['properties'] ); - $this->assertArrayNotHasKey( 'slug', $query['properties'] ); + $this->assertArrayHasKey( 'include', $query['properties'], 'The query mode should support included post IDs.' ); + $this->assertArrayHasKey( 'per_page', $query['properties'], 'The query mode should support pagination.' ); + $this->assertArrayNotHasKey( 'per_page', $by_id['properties'], 'The by-ID mode should not accept query-only pagination.' ); + $this->assertArrayNotHasKey( 'include', $by_slug['properties'], 'The slug mode should not accept query-only included IDs.' ); + $this->assertArrayNotHasKey( 'slug', $query['properties'], 'The query mode should not accept slug; slug is a single-post mode.' ); + + // Exposed post types appear in all modes that accept `post_type`. + $this->assertContains( 'post', $query['properties']['post_type']['enum'], 'The query mode should include exposed posts.' ); + $this->assertContains( 'page', $by_id['properties']['post_type']['enum'], 'The by-ID guard should include exposed pages.' ); + $this->assertContains( 'page', $by_slug['properties']['post_type']['enum'], 'The slug mode should include exposed pages.' ); + + $this->assertSame( 1, $query['properties']['include']['minItems'], 'The include option should require at least one post ID.' ); + $this->assertTrue( $query['properties']['include']['uniqueItems'], 'The include option should reject duplicate post IDs.' ); + $this->assertSame( 'integer', $query['properties']['include']['items']['type'], 'The include option should contain post IDs.' ); + $this->assertSame( 1, $query['properties']['include']['items']['minimum'], 'The include option should contain positive post IDs.' ); + + $fields_enum = $query['properties']['fields']['items']['enum']; + $this->assertContains( 'post_type', $fields_enum, 'The fields enum should expose the post type as post_type.' ); + $this->assertNotContains( 'type', $fields_enum, 'The fields enum should not expose the post type as type.' ); + $this->assertContains( 'content_raw', $fields_enum, 'The fields enum should include raw content.' ); + $this->assertContains( 'content_rendered', $fields_enum, 'The fields enum should include rendered content.' ); + $this->assertContains( 'title_raw', $fields_enum, 'The fields enum should include raw titles.' ); + $this->assertContains( 'title_rendered', $fields_enum, 'The fields enum should include rendered titles.' ); + } - $this->assertContains( 'post', $query['properties']['post_type']['enum'] ); - $this->assertContains( 'page', $by_id['properties']['post_type']['enum'] ); - $this->assertContains( 'page', $by_slug['properties']['post_type']['enum'] ); + /** + * Branch-local defaults are omitted so the schema can compile in the client-side + * Abilities API validator. Runtime defaults are still applied by the ability. + * + * @since 7.1.0 + */ + public function test_input_schema_omits_oneof_branch_defaults(): void { + $this->register_ability(); + + $schema = wp_get_ability( 'core/read-content' )->get_input_schema(); + $query = $schema['oneOf'][2]; - $this->assertSame( 1, $query['properties']['include']['minItems'] ); - $this->assertTrue( $query['properties']['include']['uniqueItems'] ); - $this->assertSame( 'integer', $query['properties']['include']['items']['type'] ); - $this->assertSame( 1, $query['properties']['include']['items']['minimum'] ); + $this->assertArrayNotHasKey( 'default', $query['properties']['status'], 'Status should rely on runtime defaults, not schema defaults.' ); + $this->assertArrayNotHasKey( 'default', $query['properties']['page'], 'Page should rely on runtime defaults, not schema defaults.' ); + $this->assertArrayNotHasKey( 'default', $query['properties']['per_page'], 'Per-page should rely on runtime defaults, not schema defaults.' ); } + /** + * Query-mode filters cannot be combined with a by-ID lookup: passing `per_page` alongside + * `id` is rejected outright rather than silently ignored. + * + * @since 7.1.0 + */ public function test_id_mode_rejects_query_only_params(): void { $this->login_as( 'administrator' ); + $this->register_ability(); - $result = $this->ability()->execute( + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => 1, 'per_page' => 10, ) ); - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + $this->assertWPError( $result, 'Combining by-ID mode with query-only params should fail validation.' ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'Invalid mode combinations should return an input error.' ); } + /** + * `post_type` is accepted alongside `id` as a guard: the by-ID mode still resolves the post. + * + * @since 7.1.0 + */ public function test_id_mode_accepts_post_type_guard(): void { $this->login_as( 'administrator' ); + $this->register_ability(); - $post_id = self::factory()->post->create( - array( - 'post_type' => 'post', - 'post_status' => 'publish', - ) - ); + $post_id = self::$post_ids['published']; - $result = $this->ability()->execute( + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id, 'post_type' => 'post', ) ); - $this->assertIsArray( $result ); - $this->assertSame( $post_id, $result['id'] ); - $this->assertArrayNotHasKey( 'posts', $result ); + $this->assertIsArray( $result, 'A matching post type guard should allow the by-ID lookup.' ); + $this->assertSame( $post_id, $result['id'], 'The guarded by-ID lookup should return the requested post directly.' ); + $this->assertArrayNotHasKey( 'posts', $result, 'The guarded by-ID lookup should not return the query wrapper.' ); } - public function test_input_schema_post_type_enum_only_includes_exposed_types(): void { - $enum = $this->ability()->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; + /** + * The output schema describes single-post and query response shapes. + * + * @since 7.1.0 + */ + public function test_output_schema_describes_single_post_and_query_responses(): void { + $this->register_ability(); + + $ability = wp_get_ability( 'core/read-content' ); + $input_schema = $ability->get_input_schema(); + $schema = $ability->get_output_schema(); + $post_schema = $schema['oneOf'][0]; + $query_schema = $schema['oneOf'][1]; - $this->assertContains( 'post', $enum ); - $this->assertContains( 'page', $enum ); - $this->assertContains( self::EXPOSED_CPT, $enum ); - $this->assertNotContains( self::HIDDEN_CPT, $enum ); - $this->assertNotContains( 'revision', $enum ); + $this->assertSame( 'object', $schema['type'], 'The output schema should describe object responses.' ); + $this->assertCount( 2, $schema['oneOf'], 'The output schema should describe single-post and query responses.' ); + $this->assertSame( 'object', $post_schema['type'], 'The single-post response should be described as an object.' ); + $this->assertArrayNotHasKey( 'required', $post_schema, 'Individual post fields should remain optional.' ); + $this->assertFalse( $post_schema['additionalProperties'], 'Returned posts should not allow unknown properties.' ); + $this->assertArrayHasKey( 'post_type', $post_schema['properties'], 'The post schema should describe the post type as post_type.' ); + $this->assertArrayNotHasKey( 'type', $post_schema['properties'], 'The post schema should not expose the post type as type.' ); + $this->assertSame( + $input_schema['oneOf'][2]['properties']['fields']['items']['enum'], + array_keys( $post_schema['properties'] ), + 'The fields enum should match the post output schema properties.' + ); + $this->assertArrayHasKey( 'content_raw', $post_schema['properties'], 'The post schema should describe raw content.' ); + $this->assertArrayHasKey( 'content_rendered', $post_schema['properties'], 'The post schema should describe rendered content.' ); + $this->assertSame( array( 'posts', 'total', 'total_pages' ), $query_schema['required'], 'The query wrapper should require all top-level properties.' ); + $this->assertArrayHasKey( 'total', $query_schema['properties'], 'The query schema should describe the total count.' ); + $this->assertArrayHasKey( 'total_pages', $query_schema['properties'], 'The query schema should describe page count.' ); } - public function test_query_exposed_custom_post_type(): void { - $this->login_as( 'administrator' ); + /** + * A post type registered by another active plugin and flagged `show_in_abilities` + * is exposed by the ability, both in the input enum and in query results. + * + * @since 7.1.0 + */ + public function test_exposes_a_post_type_registered_by_another_plugin(): void { + register_post_type( + 'wpai_content_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); - if ( ! post_type_exists( self::EXPOSED_CPT ) ) { - register_post_type( - self::EXPOSED_CPT, + try { + $this->login_as( 'administrator' ); + $this->register_ability(); + + // Query mode is the third `oneOf` branch; its `post_type` enum lists exposed types. + $enum = wp_get_ability( 'core/read-content' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; + $this->assertContains( 'wpai_content_cpt', $enum, 'Custom post types marked show_in_abilities should appear in the query enum.' ); + + $post_id = self::factory()->post->create( array( - 'public' => true, - 'show_in_abilities' => true, - 'supports' => array( 'title', 'editor', 'excerpt', 'author' ), + 'post_type' => 'wpai_content_cpt', + 'post_status' => 'publish', ) ); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'post_type' => 'wpai_content_cpt' ) ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $post_id, $ids, 'The custom post type should be queryable through the content ability.' ); + } finally { + unregister_post_type( 'wpai_content_cpt' ); } + } - $post_id = self::factory()->post->create( - array( - 'post_type' => self::EXPOSED_CPT, - 'post_status' => 'publish', - ) - ); + /** + * A schema filter can expose a post type that is registered after the ability. + * + * @since 7.1.0 + */ + public function test_schema_filter_exposes_late_registered_post_type(): void { + $this->login_as( 'administrator' ); - $result = $this->ability()->execute( array( 'post_type' => self::EXPOSED_CPT ) ); - $ids = wp_list_pluck( $result['posts'], 'id' ); + $amend_schema = static function ( array $args, string $name ): array { + if ( 'core/read-content' !== $name ) { + return $args; + } - $this->assertContains( $post_id, $ids ); - } + foreach ( $args['input_schema']['oneOf'] as $index => $mode ) { + $args['input_schema']['oneOf'][ $index ]['properties']['post_type']['enum'][] = 'wpai_late_cpt'; + } - public function test_input_schema_status_and_fields_enums(): void { - $properties = $this->ability()->get_input_schema()['oneOf'][2]['properties']; + return $args; + }; + add_filter( 'wp_register_ability_args', $amend_schema, 10, 2 ); - $status_enum = $properties['status']['items']['enum']; - $this->assertContains( 'publish', $status_enum ); - $this->assertContains( 'draft', $status_enum ); - $this->assertContains( 'private', $status_enum ); - $this->assertNotContains( 'trash', $status_enum ); - $this->assertNotContains( 'auto-draft', $status_enum ); + try { + $this->register_ability(); + } finally { + remove_filter( 'wp_register_ability_args', $amend_schema, 10 ); + } - $fields_enum = $properties['fields']['items']['enum']; - $this->assertContains( 'content_raw', $fields_enum ); - $this->assertContains( 'content_rendered', $fields_enum ); - $this->assertContains( 'title_raw', $fields_enum ); - $this->assertContains( 'title_rendered', $fields_enum ); - $this->assertContains( 'author', $fields_enum ); - } + $enum = wp_get_ability( 'core/read-content' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; + $this->assertContains( 'wpai_late_cpt', $enum, 'The ability args filter should amend the frozen schema enum.' ); - public function test_input_schema_omits_oneof_branch_defaults(): void { - $properties = $this->ability()->get_input_schema()['oneOf'][2]['properties']; + register_post_type( + 'wpai_late_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); - $this->assertArrayNotHasKey( 'default', $properties['status'] ); - $this->assertArrayNotHasKey( 'default', $properties['page'] ); - $this->assertArrayNotHasKey( 'default', $properties['per_page'] ); - } + try { + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wpai_late_cpt', + 'post_status' => 'publish', + ) + ); - public function test_output_schema_describes_single_post_and_query_responses(): void { - $schema = $this->ability()->get_output_schema(); - $post_schema = $schema['oneOf'][0]; - $query_schema = $schema['oneOf'][1]; + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'wpai_late_cpt', + 'fields' => array( 'id' ), + ) + ); - $this->assertSame( 'object', $schema['type'] ); - $this->assertCount( 2, $schema['oneOf'] ); - $this->assertSame( 'object', $post_schema['type'] ); - $this->assertArrayNotHasKey( 'required', $post_schema ); - $this->assertFalse( $post_schema['additionalProperties'] ); - $this->assertArrayHasKey( 'content_raw', $post_schema['properties'] ); - $this->assertArrayHasKey( 'content_rendered', $post_schema['properties'] ); - $this->assertSame( array( 'posts', 'total', 'total_pages' ), $query_schema['required'] ); - $this->assertArrayHasKey( 'total', $query_schema['properties'] ); - $this->assertArrayHasKey( 'total_pages', $query_schema['properties'] ); + $this->assertSame( array( $post_id ), wp_list_pluck( $result['posts'], 'id' ), 'The late post type should be queryable after it becomes exposed.' ); + } finally { + unregister_post_type( 'wpai_late_cpt' ); + } } - /* - * ------------------------------------------------------------------------- - * Single-post retrieval - * ------------------------------------------------------------------------- + /** + * A published post can be fetched by ID. + * + * @since 7.1.0 */ - public function test_get_single_published_post_by_id(): void { $this->login_as( 'administrator' ); - $post_id = self::factory()->post->create( - array( - 'post_title' => 'Hello Content', - 'post_content' => 'Body here.', - 'post_status' => 'publish', - ) - ); + $this->register_ability(); - $result = $this->ability()->execute( array( 'id' => $post_id ) ); + $post_id = self::$post_ids['published_content']; - $this->assertIsArray( $result ); - $this->assertSame( $post_id, $result['id'] ); - $this->assertSame( 'Hello Content', $result['title_rendered'] ); + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result, 'The by-ID lookup should return a post array.' ); + $this->assertSame( $post_id, $result['id'], 'The by-ID lookup should return the requested post directly.' ); + $this->assertSame( 'Hello Content', $result['title_rendered'], 'Rendered titles should be returned by default.' ); $this->assertSame( - array( 'id', 'type', 'status', 'date', 'slug', 'title_rendered' ), - array_keys( $result ) + array( 'id', 'post_type', 'status', 'date', 'slug', 'title_rendered' ), + array_keys( $result ), + 'Omitted fields should return the lean default field set.' ); - $this->assertArrayNotHasKey( 'posts', $result ); + $this->assertArrayNotHasKey( 'posts', $result, 'The by-ID lookup should not return the query wrapper.' ); } - public function test_get_by_id_with_mismatched_post_type_is_denied(): void { + /** + * Schema-valid object input behaves like its array form. + * + * WP_Ability validates `stdClass` as object input but does not coerce the value before + * passing it to the permission and execute callbacks, so both must preserve its fields. + * + * @since 7.1.0 + */ + public function test_get_single_published_post_by_id_accepts_object_input(): void { $this->login_as( 'administrator' ); - $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $this->register_ability(); - $result = $this->ability()->execute( - array( - 'id' => $post_id, - 'post_type' => 'page', - ) + $post_id = self::$post_ids['published_content']; + $ability = wp_get_ability( 'core/read-content' ); + $input = (object) array( + 'id' => $post_id, + 'fields' => array( 'id', 'title_rendered' ), ); - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + $this->assertTrue( $ability->validate_input( $input ), 'Object input should pass the registered schema.' ); + + $result = $ability->execute( $input ); + + $this->assertIsArray( $result, 'Object input should execute the by-ID lookup.' ); + $this->assertSame( $post_id, $result['id'], 'Object input should preserve the requested post ID.' ); + $this->assertSame( 'Hello Content', $result['title_rendered'], 'Object input should preserve the requested fields.' ); + $this->assertSame( array( 'id', 'title_rendered' ), array_keys( $result ), 'Object input should use the requested field projection.' ); } - public function test_get_by_missing_id_is_denied(): void { + /** + * A single post fetched by ID can return explicitly requested rendered and raw content. + * + * @since 7.1.0 + */ + public function test_get_single_published_post_by_id_can_return_content_fields(): void { $this->login_as( 'administrator' ); + $this->register_ability(); - $result = $this->ability()->execute( array( 'id' => 999999 ) ); - - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); - } + $post_id = self::$post_ids['published_content']; - public function test_get_by_id_for_unexposed_post_type_is_denied(): void { - $post_id = self::factory()->post->create( + $result = wp_get_ability( 'core/read-content' )->execute( array( - 'post_type' => self::HIDDEN_CPT, - 'post_status' => 'publish', + 'id' => $post_id, + 'fields' => array( 'id', 'post_type', 'content_rendered', 'content_raw' ), ) ); - $this->login_as( 'administrator' ); - - $result = $this->ability()->execute( array( 'id' => $post_id ) ); - - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + $this->assertSame( $post_id, $result['id'], 'The by-ID lookup should return the requested post.' ); + $this->assertSame( 'post', $result['post_type'], 'The by-ID lookup should return the post type as post_type.' ); + $this->assertStringContainsString( 'Body here.', $result['content_rendered'], 'Explicit content fields should include rendered content.' ); + $this->assertSame( 'Body here.', $result['content_raw'], 'Explicit content fields should include raw content.' ); + $this->assertArrayNotHasKey( 'posts', $result, 'The by-ID lookup should not return the query wrapper.' ); } - /* - * ------------------------------------------------------------------------- - * Query mode - * ------------------------------------------------------------------------- + /** + * A missing post ID is denied before execution can probe the requested object. + * + * @since 7.1.0 */ - - public function test_query_returns_only_published_by_default(): void { + public function test_get_by_missing_id_is_denied(): void { $this->login_as( 'administrator' ); - $published = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $draft = self::factory()->post->create( array( 'post_status' => 'draft' ) ); + $this->register_ability(); - $result = $this->ability()->execute( array( 'post_type' => 'post' ) ); - $ids = wp_list_pluck( $result['posts'], 'id' ); + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => 999999 ) ); - $this->assertContains( $published, $ids ); - $this->assertNotContains( $draft, $ids ); + $this->assertWPError( $result, 'Missing posts should be denied before execution probes object details.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Missing posts should fail closed as a permission error.' ); } - public function test_query_include_limits_results_and_preserves_order(): void { + /** + * A post type guard mismatch is denied before execution can probe the requested object. + * + * @since 7.1.0 + */ + public function test_get_by_id_with_mismatched_post_type_is_denied(): void { $this->login_as( 'administrator' ); + $this->register_ability(); - $first = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $second = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $third = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $post_id = self::$post_ids['published']; - $result = $this->ability()->execute( + $result = wp_get_ability( 'core/read-content' )->execute( array( - 'post_type' => 'post', - 'include' => array( $third, $first ), - 'fields' => array( 'id' ), + 'id' => $post_id, + 'post_type' => 'page', ) ); - $ids = wp_list_pluck( $result['posts'], 'id' ); - $this->assertSame( array( $third, $first ), $ids ); - $this->assertNotContains( $second, $ids ); + $this->assertWPError( $result, 'Mismatched post type guards should deny the lookup.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Mismatched post type guards should fail closed as a permission error.' ); } - public function test_query_include_respects_requested_post_type(): void { - $this->login_as( 'administrator' ); - - $page_id = self::factory()->post->create( + /** + * A post from a post type not exposed to abilities is denied. + * + * @since 7.1.0 + */ + public function test_get_by_id_for_unexposed_post_type_is_denied(): void { + register_post_type( + 'wpai_hidden_cpt', array( - 'post_type' => 'page', - 'post_status' => 'publish', + 'public' => true, + 'show_in_rest' => false, + 'supports' => array( 'title', 'editor' ), ) ); - $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $result = $this->ability()->execute( - array( - 'post_type' => 'post', - 'include' => array( $page_id, $post_id ), - 'fields' => array( 'id' ), - ) - ); + try { + $this->login_as( 'administrator' ); - $this->assertSame( array( $post_id ), wp_list_pluck( $result['posts'], 'id' ) ); - } + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wpai_hidden_cpt', + 'post_status' => 'publish', + ) + ); + $this->assertGreaterThan( 0, $post_id, 'The hidden custom post should be created for the denial check.' ); - public function test_query_include_respects_row_level_permissions(): void { - $author_a = self::factory()->user->create( array( 'role' => 'author' ) ); - $author_b = self::factory()->user->create( array( 'role' => 'author' ) ); + $this->register_ability(); - $draft_a = self::factory()->post->create( + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + + $this->assertWPError( $result, 'Posts from unexposed post types should be denied.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Unexposed post types should fail closed as a permission error.' ); + } finally { + unregister_post_type( 'wpai_hidden_cpt' ); + } + } + + /** + * A status that is public but not viewable is not exposed to read-only users. + * + * @since 7.1.0 + */ + public function test_public_non_viewable_status_is_denied_for_read_only_users(): void { + register_post_status( + 'wpai_public_hidden', array( - 'post_author' => $author_a, - 'post_status' => 'draft', + 'label' => 'Public hidden', + 'public' => true, + 'publicly_queryable' => false, ) ); - $draft_b = self::factory()->post->create( - array( - 'post_author' => $author_b, - 'post_status' => 'draft', + + try { + $post_id = self::factory()->post->create( + array( + 'post_status' => 'wpai_public_hidden', + ) + ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + + $this->assertWPError( $result, 'Read-only users should not receive public statuses that are not viewable.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Non-viewable public statuses should fail closed for read-only users.' ); + } finally { + unset( $GLOBALS['wp_post_statuses']['wpai_public_hidden'] ); + } + } + + /** + * A status that is public but not viewable remains available to users who can edit it. + * + * @since 7.1.0 + */ + public function test_public_non_viewable_status_is_readable_with_edit_access(): void { + register_post_status( + 'wpai_public_hidden', + array( + 'label' => 'Public hidden', + 'public' => true, + 'publicly_queryable' => false, ) ); - wp_set_current_user( $author_b ); - $result = $this->ability()->execute( + try { + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Hidden public status', + 'post_status' => 'wpai_public_hidden', + ) + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result, 'Editors should be able to access posts they can edit even when the status is not publicly viewable.' ); + $this->assertSame( $post_id, $result['id'], 'The editable post should be returned.' ); + $this->assertSame( 'Hidden public status', $result['title_rendered'], 'The editable post should include normal default fields.' ); + } finally { + unset( $GLOBALS['wp_post_statuses']['wpai_public_hidden'] ); + } + } + + /** + * A post that inherits its status from a readable parent is readable. + * + * @since 7.1.0 + */ + public function test_inherited_post_is_readable_when_parent_is_readable(): void { + register_post_type( + 'wpai_inherit_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + try { + $parent_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_type' => 'wpai_inherit_cpt', + 'post_status' => 'publish', + ) + ); + $child_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_type' => 'wpai_inherit_cpt', + 'post_parent' => $parent_id, + 'post_status' => 'inherit', + 'post_title' => 'Inherited child', + ) + ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $child_id ) ); + + $this->assertIsArray( $result, 'Inherited posts should be readable when their parent is readable.' ); + $this->assertSame( $child_id, $result['id'], 'The inherited child should be returned.' ); + $this->assertSame( 'Inherited child', $result['title_rendered'], 'The inherited child should include normal default fields.' ); + } finally { + unregister_post_type( 'wpai_inherit_cpt' ); + } + } + + /** + * A post with an inherited status but no readable parent is denied. + * + * @since 7.1.0 + */ + public function test_inherited_post_without_parent_is_denied_for_read_only_users(): void { + register_post_type( + 'wpai_inherit_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + try { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_type' => 'wpai_inherit_cpt', + 'post_status' => 'inherit', + ) + ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + + $this->assertWPError( $result, 'Inherited posts without a readable parent should be denied.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Orphaned inherited posts should fail closed.' ); + } finally { + unregister_post_type( 'wpai_inherit_cpt' ); + } + } + + /** + * Query mode returns only published posts by default. + * + * @since 7.1.0 + */ + public function test_query_returns_only_published_by_default(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $published = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $draft = self::factory()->post->create( array( 'post_status' => 'draft' ) ); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'post_type' => 'post' ) ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $published, $ids, 'Published posts should be returned by default.' ); + $this->assertNotContains( $draft, $ids, 'Draft posts should not be returned by default.' ); + } + + /** + * Query mode can limit results to included IDs. + * + * @since 7.1.0 + */ + public function test_query_include_limits_results(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $first = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $second = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $third = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'post_type' => 'post', - 'status' => array( 'draft' ), - 'include' => array( $draft_a, $draft_b ), + 'include' => array( $third, $first ), 'fields' => array( 'id' ), ) ); + $ids = wp_list_pluck( $result['posts'], 'id' ); - $this->assertSame( array( $draft_b ), wp_list_pluck( $result['posts'], 'id' ) ); + sort( $ids ); + $expected = array( $first, $third ); + sort( $expected ); + + $this->assertSame( $expected, $ids, 'Included post IDs should limit results without requiring caller order.' ); + $this->assertNotContains( $second, $ids, 'Posts outside include should not be returned.' ); } - public function test_slug_mode_requires_post_type(): void { + /** + * Query results are ordered by post date, newest first, whatever order `include` uses. + * + * Pins both halves of what the schema advertises. The ability leaves `orderby` at the + * WP_Query default, matching the REST posts controller, and `include` only filters the + * query, so a caller that passes IDs in a chosen order must not expect them back in it. + * + * @since 7.1.0 + */ + public function test_query_orders_posts_newest_first_regardless_of_include_order(): void { $this->login_as( 'administrator' ); + $this->register_ability(); + + $oldest = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + $middle = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-02-01 10:00:00', + ) + ); + $newest = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-03-01 10:00:00', + ) + ); - $result = $this->ability()->execute( array( 'slug' => 'whatever' ) ); + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + // Deliberately neither date order nor ID order. + 'include' => array( $middle, $newest, $oldest ), + 'fields' => array( 'id' ), + ) + ); - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + $this->assertSame( + array( $newest, $middle, $oldest ), + wp_list_pluck( $result['posts'], 'id' ), + 'Results should be ordered by post date, newest first, not by the order of the include list.' + ); } - public function test_get_single_published_post_by_slug(): void { + /** + * Query include still respects the requested post type. + * + * @since 7.1.0 + */ + public function test_query_include_respects_requested_post_type(): void { $this->login_as( 'administrator' ); - $post_id = self::factory()->post->create( + $this->register_ability(); + + $page_id = self::factory()->post->create( array( - 'post_name' => 'content-slug-mode', - 'post_title' => 'Content Slug Mode', + 'post_type' => 'page', 'post_status' => 'publish', ) ); + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $result = $this->ability()->execute( + $result = wp_get_ability( 'core/read-content' )->execute( array( 'post_type' => 'post', - 'slug' => 'content-slug-mode', + 'include' => array( $page_id, $post_id ), + 'fields' => array( 'id' ), ) ); - $this->assertIsArray( $result ); - $this->assertSame( $post_id, $result['id'] ); - $this->assertSame( 'content-slug-mode', $result['slug'] ); - $this->assertArrayNotHasKey( 'posts', $result ); - $this->assertArrayNotHasKey( 'total', $result ); + $this->assertSame( array( $post_id ), wp_list_pluck( $result['posts'], 'id' ), 'Include should not leak posts from other post types.' ); } - public function test_slug_mode_rejects_query_only_params(): void { - $this->login_as( 'administrator' ); + /** + * Query include still respects row-level permissions. + * + * @since 7.1.0 + */ + public function test_query_include_respects_row_level_permissions(): void { + $author_a = self::$user_ids['author']; + $author_b = self::$user_ids['author_secondary']; + + $draft_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'draft', + ) + ); + $draft_b = self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'draft', + ) + ); + + wp_set_current_user( $author_b ); + $this->register_ability(); - $result = $this->ability()->execute( + $result = wp_get_ability( 'core/read-content' )->execute( array( 'post_type' => 'post', - 'slug' => 'whatever', - 'per_page' => 10, + 'status' => array( 'draft' ), + 'include' => array( $draft_a, $draft_b ), + 'fields' => array( 'id' ), ) ); - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + $this->assertSame( array( $draft_b ), wp_list_pluck( $result['posts'], 'id' ), 'Include should not bypass row-level draft permissions.' ); } - public function test_include_cannot_be_combined_with_single_post_modes(): void { + /** + * Query mode can return included drafts with explicitly requested rendered and raw content. + * + * @since 7.1.0 + */ + public function test_query_draft_include_can_return_content_fields(): void { $this->login_as( 'administrator' ); + $this->register_ability(); - $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - - $by_id = $this->ability()->execute( + $draft = self::factory()->post->create( array( - 'id' => $post_id, - 'include' => array( $post_id ), + 'post_title' => 'Draft content fields', + 'post_content' => 'Draft body for content fields.', + 'post_status' => 'draft', ) ); - $by_slug = $this->ability()->execute( + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'post_type' => 'post', - 'slug' => 'whatever', - 'include' => array( $post_id ), + 'status' => array( 'draft' ), + 'include' => array( $draft ), + 'fields' => array( 'id', 'post_type', 'status', 'content_rendered', 'content_raw' ), ) ); - $this->assertWPError( $by_id ); - $this->assertSame( 'ability_invalid_input', $by_id->get_error_code() ); - $this->assertWPError( $by_slug ); - $this->assertSame( 'ability_invalid_input', $by_slug->get_error_code() ); + $this->assertSame( array( $draft ), wp_list_pluck( $result['posts'], 'id' ), 'The draft query should return only the included draft.' ); + $this->assertSame( 'post', $result['posts'][0]['post_type'], 'Query responses should return the post type as post_type.' ); + $this->assertSame( 'draft', $result['posts'][0]['status'], 'The draft query should expose the requested draft status.' ); + $this->assertStringContainsString( 'Draft body for content fields.', $result['posts'][0]['content_rendered'], 'Draft query results should include rendered content when requested.' ); + $this->assertSame( 'Draft body for content fields.', $result['posts'][0]['content_raw'], 'Draft query results should include raw content when requested.' ); } - public function test_query_filters_by_author(): void { - $author_a = self::factory()->user->create( array( 'role' => 'author' ) ); - $author_b = self::factory()->user->create( array( 'role' => 'author' ) ); - $post_a = self::factory()->post->create( + /** + * Querying by slug without a post type is rejected by the input schema. + * + * @since 7.1.0 + */ + public function test_slug_mode_requires_post_type(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'slug' => 'whatever' ) ); + + $this->assertWPError( $result, 'Slug queries without a post type should fail validation.' ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'Invalid slug queries should return an input error.' ); + } + + /** + * Slug mode returns a single post directly when paired with a post type. + * + * @since 7.1.0 + */ + public function test_get_single_published_post_by_slug(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( array( - 'post_author' => $author_a, + 'post_name' => 'content-slug-mode', + 'post_title' => 'Content Slug Mode', 'post_status' => 'publish', ) ); - self::factory()->post->create( + + $result = wp_get_ability( 'core/read-content' )->execute( array( - 'post_author' => $author_b, - 'post_status' => 'publish', + 'post_type' => 'post', + 'slug' => 'content-slug-mode', ) ); + $this->assertIsArray( $result, 'The slug lookup should return a post array.' ); + $this->assertSame( $post_id, $result['id'], 'The slug lookup should return the requested post directly.' ); + $this->assertSame( 'content-slug-mode', $result['slug'], 'The slug lookup should return the matching slug.' ); + $this->assertArrayNotHasKey( 'posts', $result, 'The slug lookup should not return the query wrapper.' ); + $this->assertArrayNotHasKey( 'total', $result, 'The slug lookup should not return query totals.' ); + } + + /** + * A single post fetched by slug can return explicitly requested rendered and raw content. + * + * @since 7.1.0 + */ + public function test_get_single_published_post_by_slug_can_return_content_fields(): void { $this->login_as( 'administrator' ); - $result = $this->ability()->execute( + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_name' => 'content-slug-fields', + 'post_title' => 'Content Slug Fields', + 'post_content' => 'Slug body for content fields.', + 'post_status' => 'publish', + ) + ); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'post_type' => 'post', - 'author' => $author_a, + 'slug' => 'content-slug-fields', + 'fields' => array( 'id', 'post_type', 'slug', 'content_rendered', 'content_raw' ), ) ); - $ids = wp_list_pluck( $result['posts'], 'id' ); - $this->assertSame( array( $post_a ), $ids ); + $this->assertSame( $post_id, $result['id'], 'The slug lookup should return the requested post.' ); + $this->assertSame( 'post', $result['post_type'], 'The slug lookup should return the post type as post_type.' ); + $this->assertSame( 'content-slug-fields', $result['slug'], 'The slug lookup should return the matching slug.' ); + $this->assertStringContainsString( 'Slug body for content fields.', $result['content_rendered'], 'Slug lookups should include rendered content when requested.' ); + $this->assertSame( 'Slug body for content fields.', $result['content_raw'], 'Slug lookups should include raw content when requested.' ); + $this->assertArrayNotHasKey( 'posts', $result, 'The slug lookup should not return the query wrapper.' ); } - public function test_query_filters_by_parent_for_hierarchical_types(): void { + /** + * A published post is not hidden behind more same-slug drafts than a page holds. + * + * The slug lookup is a singular WP_Query, so it returns every matching row and no page + * size applies. Bounding it with `post_name__in` and a page size would page straight past + * an older published post, because the query is ordered newest first. + * + * @since 7.1.0 + */ + public function test_slug_lookup_is_not_bounded_by_a_page_size(): void { + global $wpdb; + + // Author every post as the administrator, so the subscriber who reads them below can + // only see the published one. $this->login_as( 'administrator' ); - $parent = self::factory()->post->create( + + // The published post owns the slug and is the oldest of the group. + $published = self::factory()->post->create( array( - 'post_type' => 'page', 'post_status' => 'publish', + 'post_name' => 'wpai-slug-not-bounded', + 'post_date' => '2026-01-01 10:00:00', ) ); - $child = self::factory()->post->create( + + // Drafts skip slug uniqueness, so they can all share the slug. Create more of them + // than the largest page the ability will ever return. + for ( $i = 0; $i < 110; $i++ ) { + self::factory()->post->create( + array( + 'post_status' => 'draft', + 'post_name' => 'wpai-slug-not-bounded', + 'post_date' => '2026-03-01 10:00:00', + ) + ); + } + + $sharing = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_name = %s", 'wpai-slug-not-bounded' ) ); // phpcs:ignore WordPress.DB + $this->assertGreaterThan( 100, $sharing, 'Precondition: more posts share the slug than a single page holds.' ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( - 'post_type' => 'page', - 'post_parent' => $parent, + 'post_type' => 'post', + 'slug' => 'wpai-slug-not-bounded', + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'The published post should resolve even though the drafts fill more than a page.' ); + $this->assertSame( $published, $result['id'], 'The readable published post should still resolve.' ); + } + + /** + * A post whose slug is the literal string "0" is fetched in single-post slug mode. + * + * @since 7.1.0 + */ + public function test_get_single_post_by_slug_zero(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + // Core regenerates an "empty" post_name from the title, so a post titled "0" + // ends up with the literal slug "0". + $post_id = self::factory()->post->create( + array( + 'post_title' => '0', + 'post_name' => '0', 'post_status' => 'publish', ) ); - $result = $this->ability()->execute( + $this->assertSame( '0', get_post( $post_id )->post_name, 'Precondition: the post slug should be the literal string "0".' ); + + $result = wp_get_ability( 'core/read-content' )->execute( array( - 'post_type' => 'page', - 'parent' => $parent, - 'fields' => array( 'id', 'parent' ), + 'post_type' => 'post', + 'slug' => '0', ) ); - $this->assertCount( 1, $result['posts'] ); - $this->assertSame( $child, $result['posts'][0]['id'] ); - $this->assertSame( $parent, $result['posts'][0]['parent'] ); + $this->assertIsArray( $result, 'The slug lookup should return a post array.' ); + $this->assertSame( $post_id, $result['id'], 'Slug mode should resolve the literal "0" slug to the post.' ); + $this->assertArrayNotHasKey( 'posts', $result, 'A "0" slug should not fall through to the query wrapper.' ); } - /* - * ------------------------------------------------------------------------- - * fields filter - * ------------------------------------------------------------------------- + /** + * Query-only filters cannot be combined with slug mode. + * + * @since 7.1.0 */ + public function test_slug_mode_rejects_query_only_params(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'slug' => 'content-slug-mode', + 'per_page' => 10, + ) + ); + + $this->assertWPError( $result, 'Combining slug mode with query-only params should fail validation.' ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'Invalid slug mode combinations should return an input error.' ); + } + + /** + * A newer draft sharing a published post's slug does not shadow the published post. + * + * @since 7.1.0 + */ + public function test_slug_lookup_prefers_published_post_over_newer_draft(): void { + $published_id = self::factory()->post->create( + array( + 'post_name' => 'shadowed-slug', + 'post_status' => 'publish', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + // Drafts skip slug uniqueness, so a newer draft can share the published slug. + $draft_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_name' => 'shadowed-slug', + 'post_status' => 'draft', + 'post_date' => '2026-06-01 10:00:00', + ) + ); + + $this->assertSame( 'shadowed-slug', get_post( $draft_id )->post_name, 'Precondition: the draft should share the published slug.' ); + $this->register_ability(); + + foreach ( array( 'subscriber', 'editor' ) as $role ) { + $this->login_as( $role ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'slug' => 'shadowed-slug', + ) + ); + + $this->assertIsArray( $result, "The slug lookup should succeed for a {$role}." ); + $this->assertSame( $published_id, $result['id'], "The slug lookup should resolve to the published post for a {$role}." ); + } + } + + /** + * A slug held only by a draft resolves for its author and stays denied for readers. + * + * @since 7.1.0 + */ + public function test_slug_lookup_resolves_draft_only_slug_by_readability(): void { + $author_id = self::$user_ids['author']; + $draft_id = self::factory()->post->create( + array( + 'post_author' => $author_id, + 'post_name' => 'draft-only-slug', + 'post_status' => 'draft', + ) + ); + + wp_set_current_user( $author_id ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'slug' => 'draft-only-slug', + ) + ); + + $this->assertIsArray( $result, 'The draft author should resolve their own draft by slug.' ); + $this->assertSame( $draft_id, $result['id'], 'The draft author should receive their own draft.' ); + + $this->login_as( 'subscriber' ); + + $denied = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'slug' => 'draft-only-slug', + ) + ); + + $this->assertWPError( $denied, 'Readers should not resolve a slug held only by an unreadable draft.' ); + $this->assertSame( 'ability_invalid_permissions', $denied->get_error_code(), 'Unreadable slug lookups should fail closed as a permission error.' ); + } + + /** + * Include is a query-only option and cannot be combined with single-post modes. + * + * @since 7.1.0 + */ + public function test_include_cannot_be_combined_with_single_post_modes(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $by_id = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => self::$post_ids['published'], + 'include' => array( self::$post_ids['published'] ), + ) + ); + $by_slug = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'slug' => 'whatever', + 'include' => array( self::$post_ids['published'] ), + ) + ); + + $this->assertWPError( $by_id, 'Include should fail validation in ID mode.' ); + $this->assertSame( 'ability_invalid_input', $by_id->get_error_code(), 'ID plus include should return an input error.' ); + $this->assertWPError( $by_slug, 'Include should fail validation in slug mode.' ); + $this->assertSame( 'ability_invalid_input', $by_slug->get_error_code(), 'Slug plus include should return an input error.' ); + } + /** + * The `fields` filter limits the returned keys. + * + * @since 7.1.0 + */ public function test_fields_filter_limits_returned_keys(): void { $this->login_as( 'administrator' ); - $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $this->register_ability(); + + $post_id = self::$post_ids['published_content']; + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'title_rendered' ), + ) + ); + + $this->assertSame( + array( 'id', 'title_rendered' ), + array_keys( $result ), + 'The fields filter should limit the response to exactly the requested keys.' + ); + } + + /** + * An unknown requested field name fails schema validation. + * + * Unlike fields a post type does not support, which are omitted per post, a field + * name that is not part of the supported set is rejected before the ability executes. + * + * @since 7.1.0 + */ + public function test_unknown_requested_field_fails_schema_validation(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => self::$post_ids['published_content'], + 'fields' => array( 'id', 'bogus_field' ), + ) + ); + + $this->assertWPError( $result, 'An unknown requested field should fail the request.' ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'Unknown fields should use the invalid input error.' ); + } + + /** + * Logged-out users cannot run the ability. + * + * @since 7.1.0 + */ + public function test_logged_out_user_is_denied(): void { + wp_set_current_user( 0 ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'post_type' => 'post' ) ); + + $this->assertWPError( $result, 'Logged-out users should not be allowed to run the content ability.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Logged-out users should receive a permission error.' ); + } + + /** + * Subscribers can request rendered published content. + * + * @since 7.1.0 + */ + public function test_subscriber_can_request_published_content(): void { + $post_id = self::$post_ids['subscriber_content']; + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'fields' => array( 'id', 'title_rendered', 'content_rendered' ), + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $post_id, $ids, 'Subscribers should be able to query readable published posts.' ); + $post_index = array_search( $post_id, $ids, true ); + $this->assertIsInt( $post_index, 'The published post should be present in the subscriber query response.' ); + $post = $result['posts'][ $post_index ]; + $this->assertSame( 'Visible to subscribers', $post['title_rendered'], 'Subscribers should receive rendered titles.' ); + $this->assertStringContainsString( 'Rendered body for subscribers.', $post['content_rendered'], 'Subscribers should receive rendered content.' ); + $this->assertArrayNotHasKey( 'content_raw', $post, 'Subscribers should not receive raw content without edit access.' ); + } + + /** + * Subscribers can fetch a published post by ID. + * + * @since 7.1.0 + */ + public function test_subscriber_can_get_single_published_post_by_id(): void { + $post_id = self::$post_ids['readable_single']; + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result, 'Subscribers should be able to fetch a readable published post by ID.' ); + $this->assertSame( 'Readable single', $result['title_rendered'], 'Subscribers should receive the rendered title.' ); + $this->assertArrayNotHasKey( 'title_raw', $result, 'Subscribers should not receive raw titles without edit access.' ); + $this->assertArrayNotHasKey( 'content_raw', $result, 'Subscribers should not receive raw content without edit access.' ); + $this->assertArrayNotHasKey( 'content_rendered', $result, 'Rendered content should require an explicit field request.' ); + } + + /** + * Subscribers cannot request edit-context raw fields in query mode. + * + * @since 7.1.0 + */ + public function test_subscriber_cannot_request_raw_fields_in_query_mode(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'fields' => array( 'content_raw' ), + ) + ); + + $this->assertWPError( $result, 'Subscribers should not be able to request raw fields in query mode.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Subscriber raw-field query requests should return a permission error.' ); + } + + /** + * Subscribers cannot request edit-context raw fields for a single post. + * + * @since 7.1.0 + */ + public function test_subscriber_cannot_request_raw_fields_for_single_post(): void { + $post_id = self::$post_ids['published']; + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'content_raw' ), + ) + ); + + $this->assertWPError( $result, 'Subscribers should not be able to request raw fields by ID.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Subscriber raw-field by-ID requests should return a permission error.' ); + } + + /** + * Users who cannot edit another user's post do not receive raw fields by default. + * + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * + * @param string $role The role to test. + */ + public function test_default_fields_omit_raw_fields_for_roles_without_edit_access_to_other_users_posts( string $role ): void { + $post_id = self::$post_ids['limited_role_content']; + + $this->login_as( $role ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result, 'The readable published post should be returned.' ); + $this->assertSame( 'Readable title', $result['title_rendered'], 'Rendered title should remain visible.' ); + $this->assertArrayNotHasKey( 'title_raw', $result, 'Raw title should be omitted.' ); + $this->assertArrayNotHasKey( 'excerpt_raw', $result, 'Raw excerpt should be omitted.' ); + $this->assertArrayNotHasKey( 'content_raw', $result, 'Raw content should be omitted.' ); + $this->assertArrayNotHasKey( 'content_rendered', $result, 'Rendered content should be omitted from the lean default field set.' ); + } + + /** + * Users who cannot edit another user's post cannot explicitly request raw fields. + * + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * + * @param string $role The role to test. + */ + public function test_raw_field_requests_are_denied_for_roles_without_edit_access_to_other_users_posts( string $role ): void { + $post_id = self::$post_ids['limited_role_content']; + + $this->login_as( $role ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'content_raw' ), + ) + ); + + $this->assertWPError( $result, 'Raw field requests should fail for users without edit access.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Raw field requests should require edit access to the post.' ); + } + + /** + * Subscribers cannot request draft posts. + * + * @since 7.1.0 + */ + public function test_subscriber_cannot_request_draft_status(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ); + + $this->assertWPError( $result, 'Subscribers should not be allowed to query draft posts.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Subscriber draft queries should return a permission error.' ); + } + + /** + * Subscribers cannot request private posts. + * + * @since 7.1.0 + */ + public function test_subscriber_cannot_request_private_status(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'status' => array( 'private' ), + ) + ); + + $this->assertWPError( $result, 'Subscribers should not be allowed to query private posts.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Subscriber private queries should return a permission error.' ); + } + + /** + * An author can pass the draft gate but only sees their own drafts. + * + * @since 7.1.0 + */ + public function test_author_cannot_see_other_authors_drafts(): void { + $author_a = self::$user_ids['author']; + $author_b = self::$user_ids['author_secondary']; + + $draft_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'draft', + ) + ); + $draft_b = self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'draft', + ) + ); + + wp_set_current_user( $author_b ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $draft_b, $ids, 'Authors should see their own drafts.' ); + $this->assertNotContains( $draft_a, $ids, 'Authors should not see another author\'s drafts.' ); + } + + /** + * Query totals mirror WP_Query even when row-level permissions withhold rows. + * + * This matches the REST posts controller: `posts` only contains rows the current + * user can read, while `total` and `total_pages` describe the underlying query. + * + * @since 7.1.0 + */ + public function test_query_totals_may_include_rows_withheld_by_row_level_permissions(): void { + $author_a = self::$user_ids['author']; + $author_b = self::$user_ids['author_secondary']; + + $draft_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'draft', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + $draft_b = self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'draft', + 'post_date' => '2026-01-02 10:00:00', + ) + ); + + wp_set_current_user( $author_b ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + 'per_page' => 1, + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $draft_b ), wp_list_pluck( $result['posts'], 'id' ), 'Authors should receive only drafts they can read.' ); + $this->assertNotContains( $draft_a, wp_list_pluck( $result['posts'], 'id' ), 'Rows withheld by row-level permissions should not be returned.' ); + $this->assertGreaterThan( count( $result['posts'] ), $result['total'], 'Totals may include rows withheld by row-level permission checks.' ); + $this->assertSame( 2, $result['total_pages'], 'Page counts should be based on the underlying query total, matching REST behavior.' ); + } + + /** + * The parent filter is rejected for non-hierarchical post types, mirroring REST. + * + * @since 7.1.0 + */ + public function test_query_mode_rejects_parent_filter_for_non_hierarchical_post_type(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'parent' => 0, + ) + ); + + $this->assertWPError( $result, 'The parent filter should be rejected for non-hierarchical post types.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'Unsupported parent filters should return a filter error.' ); + } + + /** + * The parent filter narrows hierarchical queries to children of the given post. + * + * @since 7.1.0 + */ + public function test_query_mode_filters_pages_by_parent(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $parent_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + $child_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_parent' => $parent_id, + 'post_status' => 'publish', + ) + ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'page', + 'parent' => $parent_id, + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertSame( array( $child_id ), $ids, 'The parent filter should return only the children of the given page.' ); + } + + /** + * The author filter is rejected for post types without author support, mirroring REST. + * + * @since 7.1.0 + */ + public function test_query_mode_rejects_author_filter_for_post_type_without_author_support(): void { + register_post_type( + 'wpai_no_author_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title' ), + ) + ); + + try { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'wpai_no_author_cpt', + 'author' => self::$user_ids['author'], + ) + ); + + $this->assertWPError( $result, 'The author filter should be rejected for post types without author support.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'Unsupported author filters should return a filter error.' ); + } finally { + unregister_post_type( 'wpai_no_author_cpt' ); + } + } + + /** + * The author filter narrows queries to posts by the given author. + * + * @since 7.1.0 + */ + public function test_query_mode_filters_posts_by_author(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $mine_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'publish', + ) + ); + $other_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author_secondary'], + 'post_status' => 'publish', + ) + ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'author' => self::$user_ids['author'], + 'per_page' => 100, + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $mine_id, $ids, 'The author filter should include the author\'s posts.' ); + $this->assertNotContains( $other_id, $ids, 'The author filter should exclude other authors\' posts.' ); + } + + /** + * Raw content is available to users who can edit the post. + * + * @since 7.1.0 + */ + public function test_raw_content_visible_to_editor(): void { + $post_id = self::$post_ids['raw_content']; + + $this->login_as( 'editor' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'content_raw' ), + ) + ); + + $this->assertSame( + 'Public body with raw block markup.', + $result['content_raw'], + 'Editors should receive explicitly requested raw content.' + ); + } + + /** + * Password-protected content is visible to users who can edit the post. + * + * @since 7.1.0 + */ + public function test_password_protected_content_visible_to_editor(): void { + $post_id = self::$post_ids['password_protected_editor']; + + $this->login_as( 'editor' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'content_raw', 'content_rendered' ), + ) + ); + + $this->assertSame( + 'Top secret body.', + $result['content_raw'], + 'Editors should receive raw password-protected content.' + ); + $this->assertStringContainsString( + 'Top secret body.', + $result['content_rendered'], + 'Editors should receive rendered password-protected content.' + ); + } + + /** + * Password-protected rendered content is withheld from users who cannot edit the post. + * + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * + * @param string $role The role to test. + */ + public function test_password_protected_rendered_content_is_empty_for_roles_without_edit_access_to_other_users_posts( string $role ): void { + $post_id = self::$post_ids['password_protected_limited']; + + $this->login_as( $role ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'content_rendered', 'content_protected' ), + ) + ); + + $this->assertSame( '', $result['content_rendered'], 'Password-protected rendered content should be withheld.' ); + $this->assertTrue( $result['content_protected'], 'The protected flag should reveal the field is password-protected.' ); + } + + /** + * Password-protected excerpts render for users who can edit the post. + * + * @since 7.1.0 + */ + public function test_password_protected_excerpt_visible_to_editor(): void { + $this->login_as( 'editor' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_excerpt' => 'Top secret excerpt.', + ) + ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'excerpt_rendered', 'excerpt_protected' ), + ) + ); + + $this->assertSame( + "

Top secret excerpt.

\n", + $result['excerpt_rendered'], + 'Editors should receive the real rendered excerpt for password-protected posts.' + ); + $this->assertTrue( $result['excerpt_protected'], 'The protected flag should reveal the excerpt is password-protected.' ); + } + + /** + * Password-protected rendered excerpts are withheld from users who cannot edit the post. + * + * @since 7.1.0 + */ + public function test_password_protected_rendered_excerpt_is_empty_for_subscriber(): void { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_excerpt' => 'Hidden excerpt.', + ) + ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'excerpt_rendered', 'excerpt_protected' ), + ) + ); + + $this->assertSame( '', $result['excerpt_rendered'], 'Password-protected rendered excerpts should be withheld.' ); + $this->assertTrue( $result['excerpt_protected'], 'The protected flag should reveal the excerpt is password-protected.' ); + } + + /** + * Rendered excerpts carry the REST API's `the_excerpt` markup (paragraph wrapping). + * + * @since 7.1.0 + */ + public function test_excerpt_rendered_applies_the_excerpt_filters(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => self::$post_ids['limited_role_content'], + 'fields' => array( 'id', 'excerpt_rendered' ), + ) + ); + + $this->assertSame( + "

Readable excerpt.

\n", + $result['excerpt_rendered'], + 'Rendered excerpts should match the REST API excerpt filter output.' + ); + } + + /** + * Rendered excerpt filters run with the requested post as the global context and restore + * the context that was active before the ability executed. + * + * @since 7.1.0 + */ + public function test_excerpt_rendered_uses_and_restores_requested_post_context(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $target_id = self::$post_ids['limited_role_content']; + $surrounding = get_post( self::$post_ids['published'] ); + $previous_post = $GLOBALS['post'] ?? null; + + $this->assertInstanceOf( \WP_Post::class, $surrounding, 'The surrounding post fixture should exist.' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Establishes a distinct context to verify the ability restores it. + $GLOBALS['post'] = $surrounding; + setup_postdata( $surrounding ); + + $append_context_id = static function ( $excerpt ): string { + return (string) $excerpt . ''; + }; + add_filter( 'the_excerpt', $append_context_id, 20 ); + + try { + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $target_id, + 'fields' => array( 'id', 'excerpt_rendered' ), + ) + ); + $restored_context_id = get_the_ID(); + } finally { + remove_filter( 'the_excerpt', $append_context_id, 20 ); + + if ( $previous_post instanceof \WP_Post ) { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the context that preceded the test. + $GLOBALS['post'] = $previous_post; + setup_postdata( $previous_post ); + } else { + unset( $GLOBALS['post'] ); + wp_reset_postdata(); + } + } + + $this->assertStringContainsString( + '', + $result['excerpt_rendered'], + 'Excerpt filters should see the requested post as the current post.' + ); + $this->assertSame( + $surrounding->ID, + $restored_context_id, + 'The surrounding post context should be restored after rendering.' + ); + } + + /** + * The password gate is suspended only for posts the current user can edit. + * + * @since 7.1.0 + */ + public function test_allow_password_content_only_unlocks_editable_posts(): void { + $owned_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'publish', + 'post_password' => 'secret', + ) + ); + $other_id = self::$post_ids['password_protected_limited']; + + $this->login_as( 'author' ); + + $ability = new WP_Content_Abilities(); + + $this->assertFalse( + $ability->allow_password_content( true, get_post( $owned_id ) ), + 'The filter should unlock a protected post the current user can edit.' + ); + $this->assertTrue( + $ability->allow_password_content( true, get_post( $other_id ) ), + 'The filter should keep the gate on a protected post the current user cannot edit.' + ); + $this->assertFalse( + $ability->allow_password_content( false, get_post( $other_id ) ), + 'The filter should leave posts that do not require a password ungated.' + ); + } + + /** + * Rendering an editable protected post must not unlock other protected posts embedded in + * its content (e.g. through a shortcode or Query Loop block). + * + * @since 7.1.0 + */ + public function test_password_filter_does_not_leak_other_protected_posts(): void { + $hidden_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'NESTED_SECRET_MARKER', + ) + ); + + $author_id = $this->login_as( 'author' ); + + $owned_id = self::factory()->post->create( + array( + 'post_author' => $author_id, + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => '[read_content_nested id="' . $hidden_id . '"]', + ) + ); + + add_shortcode( + 'read_content_nested', + static function ( $atts ): string { + $id = is_array( $atts ) && isset( $atts['id'] ) ? (int) $atts['id'] : 0; + + return post_password_required( $id ) ? 'GATED' : (string) get_post_field( 'post_content', $id ); + } + ); + + $this->register_ability(); + + try { + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $owned_id, + 'fields' => array( 'id', 'content_rendered' ), + ) + ); + } finally { + remove_shortcode( 'read_content_nested' ); + } + + $this->assertStringNotContainsString( + 'NESTED_SECRET_MARKER', + $result['content_rendered'], + 'Rendering an editable protected post must not unlock another protected post it embeds.' + ); + $this->assertStringContainsString( + 'GATED', + $result['content_rendered'], + 'The embedded protected post should still report as password-gated.' + ); + } + + /** + * Query mode paginates with `page`/`per_page` and reports totals. + * + * @since 7.1.0 + */ + public function test_query_paginates_and_reports_totals(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $page1 = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 1, + ) + ); + + $this->assertCount( 2, $page1['posts'], 'The first page should honor the requested per_page value.' ); + $this->assertGreaterThanOrEqual( 3, $page1['total'], 'The query should report the total matching post count.' ); + $this->assertSame( (int) ceil( $page1['total'] / 2 ), $page1['total_pages'], 'The query should report the computed total page count.' ); + + $page2 = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 2, + ) + ); + + $this->assertNotEmpty( $page2['posts'], 'The second page should return remaining posts.' ); + $this->assertSame( $page1['total'], $page2['total'], 'Pagination should keep total counts stable across pages.' ); + } + + /** + * Query mode reports a total that matches the returned posts for an uncapped query. + * + * @since 7.1.0 + */ + public function test_query_total_matches_returned_posts_when_uncapped(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'per_page' => 100, + ) + ); + + $this->assertNotEmpty( $result['posts'], 'An uncapped query should return the readable posts.' ); + $this->assertSame( count( $result['posts'] ), $result['total'], 'An uncapped query should report a total equal to the number of returned posts.' ); + $this->assertSame( 1, $result['total_pages'], 'An uncapped query should fit on a single page.' ); + } + + /** + * The last page still reports the totals of the underlying query. + * + * Guards the boundary next to the out-of-range page error: the final page must not be + * mistaken for an overshoot. + * + * @since 7.1.0 + */ + public function test_query_last_page_reports_totals(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'per_page' => 2, + 'page' => 2, + 'fields' => array( 'id' ), + ) + ); + + $this->assertCount( 1, $result['posts'], 'The last page should return the remaining post.' ); + $this->assertSame( 3, $result['total'], 'The last page should report the full total.' ); + $this->assertSame( 2, $result['total_pages'], 'The last page should report the full page count.' ); + } + + /** + * Paging past the last page reports an error rather than an empty collection. + * + * `WP_Query::set_found_posts()` skips the count when a page yields no rows, so without + * recovering the total an out-of-range page would report `total: 0, total_pages: 0`, + * which is indistinguishable from an empty collection. Match the REST posts controller + * by reporting this as a caller error instead. + * + * @since 7.1.0 + */ + public function test_query_out_of_range_page_is_rejected_rather_than_reported_as_empty(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'per_page' => 2, + 'page' => 99, + 'fields' => array( 'id' ), + ) + ); + + $this->assertWPError( $result, 'A page beyond the last one should fail rather than return an empty list.' ); + $this->assertSame( 'content_invalid_page_number', $result->get_error_code(), 'Out-of-range pages should report a dedicated error code.' ); + $this->assertSame( 400, $result->get_error_data()['status'], 'An out-of-range page is a caller error.' ); + } + + /** + * A genuinely empty result set beyond the first page reports zero totals, not an error. + * + * The out-of-range guard only fires when the underlying query actually matched rows. + * + * @since 7.1.0 + */ + public function test_query_empty_result_beyond_first_page_reports_zero_totals(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => array( 999999 ), + 'page' => 2, + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'An empty result set should not be treated as an out-of-range page.' ); + $this->assertSame( array(), $result['posts'], 'No posts match the query.' ); + $this->assertSame( 0, $result['total'], 'An empty result set reports a zero total.' ); + $this->assertSame( 0, $result['total_pages'], 'An empty result set reports zero pages.' ); + } + + /** + * Include returns every requested post when `per_page` is omitted. + * + * Without this the default page size silently truncates a batch load: a caller asking + * for a known set of IDs would receive only the first `per_page` of them. + * + * @since 7.1.0 + */ + public function test_query_include_returns_every_requested_post_without_per_page(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + // More than DEFAULT_PER_PAGE (10) so truncation would be visible. + $ids = self::factory()->post->create_many( 15, array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'fields' => array( 'id' ), + ) + ); + + $returned = wp_list_pluck( $result['posts'], 'id' ); + sort( $returned ); + sort( $ids ); + + $this->assertSame( $ids, $returned, 'Every requested post ID should be returned on a single page.' ); + $this->assertSame( 15, $result['total'], 'The total should cover every requested post.' ); + $this->assertSame( 1, $result['total_pages'], 'Included posts should fit on a single page by default.' ); + } + + /** + * An explicit `per_page` still paginates an include request. + * + * @since 7.1.0 + */ + public function test_query_include_honors_an_explicit_per_page(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 5, array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'per_page' => 2, + 'fields' => array( 'id' ), + ) + ); + + $this->assertCount( 2, $result['posts'], 'An explicit per_page should paginate included posts.' ); + $this->assertSame( 5, $result['total'], 'The total should still cover every requested post.' ); + $this->assertSame( 3, $result['total_pages'], 'Page counts should follow the explicit per_page.' ); + } + + /** + * The include list is capped at the maximum page size. + * + * @since 7.1.0 + */ + public function test_query_include_is_capped_at_the_maximum_page_size(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $schema = wp_get_ability( 'core/read-content' )->get_input_schema(); + $query = $schema['oneOf'][2]; + + $this->assertSame( $query['properties']['per_page']['maximum'], $query['properties']['include']['maxItems'], 'The include list should be capped at the maximum page size.' ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => range( 1, $query['properties']['include']['maxItems'] + 1 ), + ) + ); + + $this->assertWPError( $result, 'An include list beyond the cap should be rejected as invalid input.' ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'The cap should be enforced by schema validation.' ); + } + + /** + * Requesting rendered fields primes the post meta cache for the whole page. + * + * The rendered filter chains may read post meta, so priming avoids one lazy meta + * query per returned row. + * + * @since 7.1.0 + */ + public function test_query_rendered_fields_prime_the_post_meta_cache(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $postmeta_queries = $this->count_post_meta_queries( + static function () use ( $ids ) { + return wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'fields' => array( 'id', 'content_rendered' ), + ) + ); + }, + $result + ); + + $this->assertCount( 3, $result['posts'], 'Precondition: the query should return the seeded posts.' ); + + /* + * The rendered filter chains can read post meta per post. Without priming, each + * row lazily primes its own meta, which is one query per returned post. + */ + $this->assertSame( 1, $postmeta_queries, 'Rendered field requests should prime post meta with a single batched query, not one per returned post.' ); + } + + /** + * A lean projection keeps skipping the post meta cache priming. + * + * Nothing in the default field set renders a post, so the extra lookup stays skipped. + * + * @since 7.1.0 + */ + public function test_query_lean_projection_does_not_prime_the_post_meta_cache(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $postmeta_queries = $this->count_post_meta_queries( + static function () use ( $ids ) { + return wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'fields' => array( 'id' ), + ) + ); + }, + $result + ); + + $this->assertCount( 3, $result['posts'], 'Precondition: the query should return the seeded posts.' ); + $this->assertSame( 0, $postmeta_queries, 'A lean projection should not read post meta at all.' ); + + foreach ( $ids as $id ) { + $this->assertFalse( wp_cache_get( $id, 'post_meta' ), 'A lean projection should not prime the post meta cache.' ); + } + } + + /** + * Counts the post meta queries issued while running the given callback. + * + * Counts during the call rather than checking the cache afterwards: the rendered + * filter chains prime meta lazily, so an after-the-fact cache check passes either way. + * + * @since 7.1.0 + * + * @param callable $callback Callback to run. + * @param mixed $result Set to the callback's return value. + * @return int Number of post meta queries issued. + */ + private function count_post_meta_queries( callable $callback, &$result ): int { + global $wpdb; + + $postmeta_queries = 0; + $spy = static function ( $query ) use ( &$postmeta_queries, $wpdb ) { + if ( is_string( $query ) && preg_match( '/FROM\s+`?' . preg_quote( $wpdb->postmeta, '/' ) . '`?/i', $query ) ) { + ++$postmeta_queries; + } + + return $query; + }; + + wp_cache_flush(); + add_filter( 'query', $spy ); + try { + $result = $callback(); + } finally { + remove_filter( 'query', $spy ); + } + + return $postmeta_queries; + } + + /** + * Query rows are kept, not dropped, when the requested fields project to nothing. + * + * @since 7.1.0 + */ + public function test_query_keeps_posts_with_empty_field_projection(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); - $result = $this->ability()->execute( + // `parent` never applies to the non-hierarchical `post` type, so every row + // projects to an empty object. + $result = wp_get_ability( 'core/read-content' )->execute( array( - 'id' => $post_id, - 'fields' => array( 'id', 'title_rendered' ), + 'post_type' => 'post', + 'per_page' => 100, + 'fields' => array( 'parent' ), ) ); - $this->assertSame( array( 'id', 'title_rendered' ), array_keys( $result ) ); + $this->assertNotEmpty( $result['posts'], 'Posts with an empty field projection should still be returned.' ); + $this->assertSame( count( $result['posts'] ), $result['total'], 'The reported total should match the returned posts when projections are empty.' ); + + foreach ( $result['posts'] as $post_entry ) { + $this->assertEquals( (object) array(), $post_entry, 'A post whose requested fields do not apply should be returned as an empty object.' ); + } } - public function test_unsupported_fields_are_omitted_for_post_type(): void { + /** + * A single post whose requested fields project to nothing is returned as an empty object. + * + * @since 7.1.0 + */ + public function test_single_post_returns_empty_object_for_empty_field_projection(): void { $this->login_as( 'administrator' ); - $post_id = self::factory()->post->create( - array( - 'post_type' => 'post', - 'post_status' => 'publish', - ) - ); + $this->register_ability(); - $result = $this->ability()->execute( + // `parent` never applies to the non-hierarchical `post` type, so the projection is empty. + $result = wp_get_ability( 'core/read-content' )->execute( array( - 'id' => $post_id, - 'fields' => array( 'id', 'parent' ), + 'id' => self::$post_ids['published'], + 'fields' => array( 'parent' ), ) ); - // `post` is not hierarchical, so `parent` must be absent even when requested. - $this->assertArrayNotHasKey( 'parent', $result ); + $this->assertEquals( (object) array(), $result, 'An empty single-post field projection should be returned as an empty object so it serializes as `{}`.' ); } - /* - * ------------------------------------------------------------------------- - * Permissions & visibility (security) - * ------------------------------------------------------------------------- + /** + * A single post fetched by ID is returned directly without query totals. + * + * @since 7.1.0 */ + public function test_single_post_returns_direct_post_object(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); - public function test_logged_out_user_is_denied(): void { - wp_set_current_user( 0 ); + $post_id = self::$post_ids['published']; - $result = $this->ability()->execute( array( 'post_type' => 'post' ) ); + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + $this->assertSame( $post_id, $result['id'], 'Single-post responses should include the requested post ID.' ); + $this->assertArrayNotHasKey( 'posts', $result, 'Single-post responses should not include the query posts wrapper.' ); + $this->assertArrayNotHasKey( 'total', $result, 'Single-post responses should not include query totals.' ); + $this->assertArrayNotHasKey( 'total_pages', $result, 'Single-post responses should not include query page totals.' ); } - public function test_subscriber_can_request_published_content(): void { + /** + * Local and GMT date fields report the correct instant and offset on non-UTC sites. + * + * @since 7.1.0 + */ + public function test_gmt_dates_are_utc_on_non_utc_sites(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + update_option( 'timezone_string', 'America/New_York' ); + $post_id = self::factory()->post->create( array( - 'post_title' => 'Visible to subscribers', - 'post_content' => 'Rendered body for subscribers.', - 'post_status' => 'publish', + 'post_status' => 'publish', + 'post_date' => '2026-01-15 10:00:00', ) ); - $this->login_as( 'subscriber' ); - $result = $this->ability()->execute( + /* + * On insert, `wp_insert_post()` copies the post date onto the modified columns. Give + * the modified columns their own instant, so a field that read the post date where it + * meant the modified date cannot pass. + */ + $this->replace_cached_post_date_columns( + $post_id, array( - 'post_type' => 'post', - 'fields' => array( 'id', 'title_rendered', 'content_rendered' ), + 'post_modified' => '2026-01-16 11:00:00', + 'post_modified_gmt' => '2026-01-16 16:00:00', ) ); - $ids = wp_list_pluck( $result['posts'], 'id' ); - - $this->assertContains( $post_id, $ids ); - $post_index = array_search( $post_id, $ids, true ); - $this->assertIsInt( $post_index ); - $post = $result['posts'][ $post_index ]; - $this->assertSame( 'Visible to subscribers', $post['title_rendered'] ); - $this->assertStringContainsString( 'Rendered body for subscribers.', $post['content_rendered'] ); - $this->assertArrayNotHasKey( 'content_raw', $post ); - } - public function test_subscriber_can_get_single_published_post_by_id(): void { - $post_id = self::factory()->post->create( + $result = wp_get_ability( 'core/read-content' )->execute( array( - 'post_title' => 'Readable single', - 'post_content' => 'Readable single body.', - 'post_status' => 'publish', + 'id' => $post_id, + 'fields' => array( 'id', 'date', 'date_gmt', 'modified', 'modified_gmt' ), ) ); - $this->login_as( 'subscriber' ); - $result = $this->ability()->execute( array( 'id' => $post_id ) ); - - $this->assertIsArray( $result ); - $this->assertSame( 'Readable single', $result['title_rendered'] ); - $this->assertArrayNotHasKey( 'title_raw', $result ); - $this->assertArrayNotHasKey( 'content_raw', $result ); - $this->assertArrayNotHasKey( 'content_rendered', $result ); + $this->assertSame( '2026-01-15T10:00:00-05:00', $result['date'], 'The local date should carry the site timezone offset.' ); + $this->assertSame( '2026-01-15T15:00:00+00:00', $result['date_gmt'], 'The GMT date should be the UTC instant with a UTC offset.' ); + $this->assertSame( '2026-01-16T11:00:00-05:00', $result['modified'], 'The local modified date should carry the site timezone offset.' ); + $this->assertSame( '2026-01-16T16:00:00+00:00', $result['modified_gmt'], 'The GMT modified date should be the UTC instant with a UTC offset.' ); } - public function test_subscriber_cannot_request_raw_fields_in_query_mode(): void { - $this->login_as( 'subscriber' ); + /** + * Drafts without a stored GMT date derive it from the local date and the site timezone. + * + * @since 7.1.0 + */ + public function test_gmt_date_is_derived_from_local_date_for_drafts(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); - $result = $this->ability()->execute( + update_option( 'timezone_string', 'America/New_York' ); + + $post_id = self::factory()->post->create( array( - 'post_type' => 'post', - 'fields' => array( 'content_raw' ), + 'post_status' => 'draft', + 'post_date' => '2026-01-15 10:00:00', ) ); - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); - } - - public function test_subscriber_cannot_request_raw_fields_for_single_post(): void { - $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $this->login_as( 'subscriber' ); + $this->assertSame( + '0000-00-00 00:00:00', + get_post( $post_id )->post_date_gmt, + 'Precondition: drafts should have no stored GMT date.' + ); - $result = $this->ability()->execute( + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id, - 'fields' => array( 'content_raw' ), + 'fields' => array( 'id', 'date_gmt' ), ) ); - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + $this->assertSame( + '2026-01-15T15:00:00+00:00', + $result['date_gmt'], + 'The GMT date should be derived from the local date using the site timezone, not read the local wall-clock as UTC.' + ); } /** - * Users who cannot edit another user's post do not receive raw fields by default. + * Replaces cached post columns so the ability sees a post object with custom dates. * - * @dataProvider data_roles_without_edit_access_to_other_users_posts + * Core's schema keeps the date columns `NOT NULL`, but a post object can still reach + * the ability from a filter or an in-memory row where a date is null or otherwise + * differs from the database row. * - * @param string $role The role to test. + * @since 7.1.0 + * + * @param int $post_id The post ID. + * @param array $columns Post column values keyed by column name. */ - public function test_default_fields_omit_raw_fields_for_roles_without_edit_access_to_other_users_posts( string $role ): void { - $post_owner_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); - $post_id = self::factory()->post->create( - array( - 'post_author' => $post_owner_id, - 'post_title' => 'Readable title', - 'post_content' => 'Readable body for limited role.', - 'post_excerpt' => 'Readable excerpt.', - 'post_status' => 'publish', - ) - ); + private function replace_cached_post_date_columns( int $post_id, array $columns ): void { + get_post( $post_id ); - $this->login_as( $role ); + $cached = wp_cache_get( $post_id, 'posts' ); + $this->assertInstanceOf( \stdClass::class, $cached, 'Precondition: the raw post row should be cached.' ); + $this->assertSame( 'raw', $cached->filter, 'Precondition: the cached row should be unsanitized.' ); - $result = $this->ability()->execute( array( 'id' => $post_id ) ); + foreach ( $columns as $column => $value ) { + $cached->$column = $value; + } - $this->assertIsArray( $result, 'The readable published post should be returned.' ); - $this->assertSame( 'Readable title', $result['title_rendered'], 'Rendered title should remain visible.' ); - $this->assertArrayNotHasKey( 'title_raw', $result, 'Raw title should be omitted.' ); - $this->assertArrayNotHasKey( 'excerpt_raw', $result, 'Raw excerpt should be omitted.' ); - $this->assertArrayNotHasKey( 'content_raw', $result, 'Raw content should be omitted.' ); - $this->assertArrayNotHasKey( 'content_rendered', $result, 'Rendered content should be omitted from the lean default field set.' ); + wp_cache_set( $post_id, $cached, 'posts' ); + + foreach ( $columns as $column => $value ) { + $this->assertSame( $value, get_post( $post_id )->$column, "Precondition: {$column} should have the test value." ); + } } /** - * Users who cannot edit another user's post cannot explicitly request raw fields. + * Data provider for GMT date fields. * - * @dataProvider data_roles_without_edit_access_to_other_users_posts + * @since 7.1.0 * - * @param string $role The role to test. + * @return array */ - public function test_raw_field_requests_are_denied_for_roles_without_edit_access_to_other_users_posts( string $role ): void { - $post_owner_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); - $post_id = self::factory()->post->create( + public function data_gmt_date_fields(): array { + return array( + 'date_gmt' => array( + 'field' => 'date_gmt', + 'gmt_column' => 'post_date_gmt', + 'local_column' => 'post_date', + 'local_date' => '2026-01-15 10:00:00', + 'expected' => '2026-01-15T15:00:00+00:00', + ), + 'modified_gmt' => array( + 'field' => 'modified_gmt', + 'gmt_column' => 'post_modified_gmt', + 'local_column' => 'post_modified', + 'local_date' => '2026-01-16 11:00:00', + 'expected' => '2026-01-16T16:00:00+00:00', + ), + ); + } + + /** + * A null stored GMT date falls back to the local date instead of the current time. + * + * `strtotime( ' UTC' )` resolves to the current time, so an unguarded null would + * report a fabricated "now" as the publication date. + * + * @since 7.1.0 + * + * @dataProvider data_gmt_date_fields + * + * @param string $field The ability output field to request. + * @param string $gmt_column The cached GMT post column to null out. + * @param string $local_column The cached local post column to derive the GMT date from. + * @param string $local_date The local date column value. + * @param string $expected The expected GMT output. + */ + public function test_gmt_date_recovers_from_a_null_stored_gmt_date( string $field, string $gmt_column, string $local_column, string $local_date, string $expected ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + update_option( 'timezone_string', 'America/New_York' ); + + $post_id = self::factory()->post->create( array( - 'post_author' => $post_owner_id, 'post_status' => 'publish', + 'post_date' => '2026-01-15 10:00:00', ) ); - $this->login_as( $role ); + $this->replace_cached_post_date_columns( + $post_id, + array( + $gmt_column => null, + $local_column => $local_date, + ) + ); - $result = $this->ability()->execute( + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id, - 'fields' => array( 'content_raw' ), + 'fields' => array( 'id', $field ), ) ); - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Raw field requests should require edit access to the post.' ); + $this->assertSame( + $expected, + $result[ $field ], + 'A null stored GMT date should be derived from the local date, not resolved to the current time.' + ); } - public function test_subscriber_cannot_request_draft_status(): void { - $this->login_as( 'subscriber' ); + /** + * A post with no usable date at all reports the documented empty-string sentinel. + * + * @since 7.1.0 + * + * @dataProvider data_gmt_date_fields + * + * @param string $field The ability output field to request. + * @param string $gmt_column The cached GMT post column to null out. + * @param string $local_column The cached local post column to null out. + * @param string $local_date Unused. Present to match the shared data provider shape. + * @param string $expected Unused. Present to match the shared data provider shape. + */ + public function test_gmt_date_is_empty_when_no_usable_date_exists( string $field, string $gmt_column, string $local_column, string $local_date, string $expected ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $result = $this->ability()->execute( + $this->replace_cached_post_date_columns( + $post_id, array( - 'post_type' => 'post', - 'status' => array( 'draft' ), + $gmt_column => null, + $local_column => null, ) ); - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); - } - - public function test_subscriber_cannot_request_private_status(): void { - $this->login_as( 'subscriber' ); - - $result = $this->ability()->execute( + $result = wp_get_ability( 'core/read-content' )->execute( array( - 'post_type' => 'post', - 'status' => array( 'private' ), + 'id' => $post_id, + 'fields' => array( 'id', $field ), ) ); - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + $this->assertSame( '', $result[ $field ], 'An unresolvable GMT date should be the empty-string sentinel.' ); } - public function test_author_cannot_see_other_authors_drafts(): void { - $author_a = self::factory()->user->create( array( 'role' => 'author' ) ); - $author_b = self::factory()->user->create( array( 'role' => 'author' ) ); + /** + * The execute callback re-validates the lookup structurally when invoked directly. + * + * Gated transports never reach these branches: check_permission() resolves and + * denies the same lookups first. The registered callback still fails closed on + * structural lookup errors when invoked directly. + * + * @since 7.1.0 + */ + public function test_execute_callback_returns_not_found_for_structural_lookup_failures(): void { + $this->login_as( 'administrator' ); - $draft_a = self::factory()->post->create( - array( - 'post_author' => $author_a, - 'post_status' => 'draft', - ) - ); - $draft_b = self::factory()->post->create( + $content = new WP_Content_Abilities(); + + $missing = $content->execute_read_content( array( 'id' => 999999 ) ); + $this->assertWPError( $missing, 'A nonexistent post ID should fail the lookup.' ); + $this->assertSame( 'content_not_found', $missing->get_error_code(), 'Missing posts should map to the uniform not-found error.' ); + + $mismatched = $content->execute_read_content( array( - 'post_author' => $author_b, - 'post_status' => 'draft', + 'id' => self::$post_ids['published'], + 'post_type' => 'page', ) ); + $this->assertWPError( $mismatched, 'A post type mismatch should fail the lookup.' ); + $this->assertSame( 'content_not_found', $mismatched->get_error_code(), 'Mismatched post types should map to the uniform not-found error.' ); - // Author B can pass the status gate (has edit_posts) but only sees their own draft. - wp_set_current_user( $author_b ); - $result = $this->ability()->execute( + $missing_slug = $content->execute_read_content( array( 'post_type' => 'post', - 'status' => array( 'draft' ), + 'slug' => 'no-such-slug', ) ); - $ids = wp_list_pluck( $result['posts'], 'id' ); - - $this->assertContains( $draft_b, $ids ); - $this->assertNotContains( $draft_a, $ids ); + $this->assertWPError( $missing_slug, 'An unmatched slug should fail the lookup.' ); + $this->assertSame( 'content_not_found', $missing_slug->get_error_code(), 'Unmatched slugs should map to the uniform not-found error.' ); } - public function test_administrator_can_access_private_posts(): void { - $private = self::factory()->post->create( array( 'post_status' => 'private' ) ); + /** + * An author filter that does not resolve to a positive integer is rejected + * rather than silently dropped. + * + * On transports that skip schema validation a non-integer `author` would coerce + * to 0, which WP_Query treats as "no author filter" — returning every author's + * posts. The filter must fail closed instead of widening the result set. + * + * @since 7.1.0 + */ + public function test_execute_callback_rejects_non_integer_author_filter(): void { $this->login_as( 'administrator' ); + $content = new WP_Content_Abilities(); - $result = $this->ability()->execute( + $result = $content->execute_read_content( array( 'post_type' => 'post', - 'status' => array( 'private' ), + 'author' => 'not-a-number', ) ); - $ids = wp_list_pluck( $result['posts'], 'id' ); - $this->assertContains( $private, $ids ); + $this->assertWPError( $result, 'A non-integer author filter must not silently widen the query to all authors.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An unhonorable author filter should fail closed as an invalid filter.' ); } - public function test_unexposed_post_type_is_rejected_by_input_schema(): void { - $this->login_as( 'administrator' ); - - $result = $this->ability()->execute( array( 'post_type' => self::HIDDEN_CPT ) ); - - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); - } - - /* - * ------------------------------------------------------------------------- - * Password-protected posts - * ------------------------------------------------------------------------- + /** + * A parent filter that is not a non-negative integer is rejected rather than + * coerced to 0 (top-level). + * + * Because 0 is a legitimate parent value (top-level posts), a non-integer value + * cannot be detected by a numeric bound; it must be rejected on the raw value so + * garbage does not silently become a top-level query. + * + * @since 7.1.0 */ + public function test_execute_callback_rejects_non_integer_parent_filter(): void { + $this->login_as( 'administrator' ); + $content = new WP_Content_Abilities(); - public function test_raw_content_visible_to_editor(): void { - $post_id = self::factory()->post->create( - array( - 'post_status' => 'publish', - 'post_content' => 'Public body with raw block markup.', - ) - ); - - $this->login_as( 'editor' ); - $result = $this->ability()->execute( + $result = $content->execute_read_content( array( - 'id' => $post_id, - 'fields' => array( 'id', 'content_raw' ), + 'post_type' => 'page', + 'parent' => 'not-a-number', ) ); - $this->assertSame( 'Public body with raw block markup.', $result['content_raw'] ); + $this->assertWPError( $result, 'A non-integer parent filter must not silently coerce to a top-level (0) query.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An unhonorable parent filter should fail closed as an invalid filter.' ); } - public function test_password_protected_content_visible_to_editor(): void { - $post_id = self::factory()->post->create( - array( - 'post_status' => 'publish', - 'post_password' => 'secret', - 'post_content' => 'Top secret body.', - ) - ); + /** + * An include filter that parses to no valid IDs is rejected rather than + * returning an unrestricted result set. + * + * WP_Query ignores an empty `post__in`, so an include list with no valid IDs + * would otherwise return every post of the type — the opposite of the caller's + * intent. The filter must fail closed instead. + * + * @since 7.1.0 + */ + public function test_execute_callback_rejects_include_with_no_valid_ids(): void { + $this->login_as( 'administrator' ); - $this->login_as( 'editor' ); - $result = $this->ability()->execute( + self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $content = new WP_Content_Abilities(); + + $result = $content->execute_read_content( array( - 'id' => $post_id, - 'fields' => array( 'id', 'content_raw', 'content_rendered' ), + 'post_type' => 'post', + 'include' => array( 0 ), ) ); - $this->assertSame( 'Top secret body.', $result['content_raw'] ); - $this->assertStringContainsString( 'Top secret body.', $result['content_rendered'] ); + $this->assertWPError( $result, 'An include filter with no valid IDs must not fall through to an unrestricted query.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An empty-after-parsing include should fail closed as an invalid filter.' ); } /** - * Password-protected rendered content is withheld from users who cannot edit the post. + * Valid filter values delivered as strings are still honored. * - * @dataProvider data_roles_without_edit_access_to_other_users_posts + * The schema-less query-string transport delivers integers as strings; the + * stricter filter parsing must accept those so it only rejects genuinely + * unhonorable values, not well-formed ones. * - * @param string $role The role to test. + * @since 7.1.0 */ - public function test_password_protected_rendered_content_is_empty_for_roles_without_edit_access_to_other_users_posts( string $role ): void { - $post_owner_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); - $post_id = self::factory()->post->create( - array( - 'post_author' => $post_owner_id, - 'post_status' => 'publish', - 'post_password' => 'secret', - 'post_content' => 'Hidden rendered body.', - ) - ); + public function test_execute_callback_honors_string_author_filter(): void { + $author_a = self::$user_ids['author']; + $author_b = self::$user_ids['author_secondary']; - $this->login_as( $role ); - $result = $this->ability()->execute( + $post_a = self::factory()->post->create( array( - 'id' => $post_id, - 'fields' => array( 'id', 'content_rendered', 'content_protected' ), + 'post_author' => $author_a, + 'post_status' => 'publish', ) ); - - $this->assertSame( '', $result['content_rendered'], 'Password-protected rendered content should be withheld.' ); - $this->assertTrue( $result['content_protected'], 'The protected flag should reveal the field is password-protected.' ); - } - - /* - * ------------------------------------------------------------------------- - * Pagination - * ------------------------------------------------------------------------- - */ - - public function test_query_paginates_and_reports_totals(): void { - $this->login_as( 'administrator' ); - self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); - - $page1 = $this->ability()->execute( + self::factory()->post->create( array( - 'post_type' => 'post', - 'per_page' => 2, - 'page' => 1, + 'post_author' => $author_b, + 'post_status' => 'publish', ) ); - $this->assertCount( 2, $page1['posts'] ); - $this->assertGreaterThanOrEqual( 3, $page1['total'] ); - $this->assertSame( (int) ceil( $page1['total'] / 2 ), $page1['total_pages'] ); + $this->login_as( 'administrator' ); + $content = new WP_Content_Abilities(); - $page2 = $this->ability()->execute( + $result = $content->execute_read_content( array( 'post_type' => 'post', - 'per_page' => 2, - 'page' => 2, + 'author' => (string) $author_a, + 'fields' => array( 'id' ), ) ); - $this->assertNotEmpty( $page2['posts'] ); - $this->assertSame( $page1['total'], $page2['total'] ); - } - - public function test_per_page_is_capped(): void { - $this->login_as( 'administrator' ); - - $schema = $this->ability()->get_input_schema()['oneOf'][2]; - - $this->assertSame( 100, $schema['properties']['per_page']['maximum'] ); - } - - public function test_single_post_does_not_return_query_totals(): void { - $this->login_as( 'administrator' ); - $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - - $result = $this->ability()->execute( array( 'id' => $post_id ) ); - - $this->assertArrayNotHasKey( 'posts', $result ); - $this->assertArrayNotHasKey( 'total', $result ); - $this->assertArrayNotHasKey( 'total_pages', $result ); - } - - public function test_ability_opts_into_pagination(): void { - $this->assertTrue( (bool) $this->ability()->get_meta_item( 'pagination', false ) ); + $this->assertIsArray( $result, 'A valid numeric-string author filter should be honored, not rejected.' ); + $this->assertSame( array( $post_a ), wp_list_pluck( $result['posts'], 'id' ), 'The author filter should restrict results to the requested author.' ); } } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php index 97dd5ffd6616b..a16f69ac6dd97 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -204,15 +204,31 @@ public function test_admin_query_returns_published_posts(): void { } public function test_admin_query_include_limits_results(): void { - $first = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $second = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $third = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $first = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + $second = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-02-01 10:00:00', + ) + ); + $third = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-03-01 10:00:00', + ) + ); $response = $this->server->dispatch( $this->run_request( array( 'post_type' => 'post', - 'include' => array( $third, $first ), + // Deliberately pass IDs in the opposite of the expected date order. + 'include' => array( $first, $third ), 'fields' => array( 'id' ), ) ) @@ -290,4 +306,21 @@ public function test_pagination_returns_totals_in_body(): void { $this->assertGreaterThanOrEqual( 3, $data['total'] ); $this->assertSame( (int) ceil( $data['total'] / 2 ), $data['total_pages'] ); } + + public function test_out_of_range_page_returns_400(): void { + self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'per_page' => 1, + 'page' => 999, + ) + ) + ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'content_invalid_page_number', $response->get_data()['code'] ); + } } From b884fef119707e0aedcb9d0093dad86e7488a4c6 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 10 Jul 2026 17:17:12 +0100 Subject: [PATCH 14/69] Abilities API: Clarify readable content exposure --- src/wp-includes/class-wp-post-type.php | 2 +- src/wp-includes/post.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index 2ec1565fc734c..353524caa6d67 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -376,7 +376,7 @@ final class WP_Post_Type { /** * Whether this post type should be exposed through the Abilities API. * - * Default false. When truthy, the post type's editable posts can be retrieved + * Default false. When truthy, the post type's readable posts can be retrieved * through the read-only `core/read-content` ability, subject to per-post capability * checks. May be an array to enable specific operations in the future. * diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 7a0346747560d..8d5b253495e2c 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -1740,7 +1740,7 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * @type bool $show_in_rest Whether to include the post type in the REST API. Set this to true * for the post type to be available in the block editor. * @type bool|array $show_in_abilities Whether to expose this post type through the Abilities API, so its - * editable posts can be retrieved via the read-only `core/read-content` + * readable posts can be retrieved via the read-only `core/read-content` * ability (subject to per-post capability checks). Accepts a boolean * or an array reserved for enabling specific operations. Default false. * @type string $rest_base To change the base URL of REST API route. Default is $post_type. From 17aea7c5d438723145a37c75cdbae7bdeb2c9933 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 5 Oct 2026 23:07:50 +0100 Subject: [PATCH 15/69] Abilities API: Sync content query ability with plugin Renames the ability from `core/read-content` to `core/content-query` to match the AI plugin (WordPress/ai#1002), and ports the plugin's lean projection test, which checks the query arguments the ability builds instead of counting post meta queries. Also drops the trailing period from the hook documentation references, which the PHPStan hook docblock rule reads as part of the file path. --- src/wp-includes/abilities.php | 2 +- .../abilities/class-wp-content-abilities.php | 40 +-- src/wp-includes/class-wp-post-type.php | 2 +- src/wp-includes/post.php | 2 +- .../wpRegisterCoreContentAbility.php | 249 +++++++++--------- .../wpRestAbilitiesContentController.php | 6 +- 6 files changed, 156 insertions(+), 145 deletions(-) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 40cb3ab3b6228..cc1fa65875917 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -376,6 +376,6 @@ function wp_register_core_abilities(): void { // Register the settings abilities (currently the read-only `core/settings-get`). ( new WP_Abilities_Settings() )->register(); - // Register the content abilities (currently the read-only `core/read-content`). + // Register the content abilities (currently the read-only `core/content-query`). ( new WP_Content_Abilities() )->register(); } diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index d80d357c57225..49f5d657dab26 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -12,7 +12,7 @@ /** * Core class used to register content-related abilities. * - * Registers the read-only `core/read-content` ability, which retrieves readable posts of a + * Registers the read-only `core/content-query` ability, which retrieves readable posts of a * post type exposed to abilities via `show_in_abilities`. Supports fetching a single * readable post by ID or by post type and slug, or querying multiple readable posts filtered * by post type, status, author, parent, or included IDs. Raw fields are only returned for @@ -115,7 +115,7 @@ final class WP_Content_Abilities { * @since 7.1.0 */ public function register(): void { - $this->register_read_content(); + $this->register_content_query(); /* * A future write-oriented ability can be registered here, reusing the shared @@ -126,11 +126,11 @@ public function register(): void { } /** - * Registers the read-only `core/read-content` ability. + * Registers the read-only `core/content-query` ability. * * @since 7.1.0 */ - private function register_read_content(): void { + private function register_content_query(): void { /* * Post types must be registered with `show_in_abilities` before the ability is * registered so they are included in its input schema. @@ -148,14 +148,14 @@ private function register_read_content(): void { $statuses = array_values( get_post_stati( array( 'internal' => false ) ) ); wp_register_ability( - 'core/read-content', + 'core/content-query', array( - 'label' => __( 'Read Content' ), + 'label' => __( 'Content Query' ), 'description' => __( 'Reads content from post types exposed to abilities. Single-post lookups by ID or by post type and slug return the post object directly. Query mode returns readable posts filtered by post type, status, author, parent, or included IDs. Requires an authenticated user. Lookups and filters are exact-match only; the ability does not perform full-text search.' ), 'category' => self::CATEGORY, - 'input_schema' => $this->get_read_content_input_schema( $post_types, $statuses ), - 'output_schema' => $this->get_read_content_output_schema(), - 'execute_callback' => array( $this, 'execute_read_content' ), + 'input_schema' => $this->get_content_query_input_schema( $post_types, $statuses ), + 'output_schema' => $this->get_content_query_output_schema(), + 'execute_callback' => array( $this, 'execute_content_query' ), 'permission_callback' => array( $this, 'check_permission' ), 'meta' => array( 'annotations' => array( @@ -173,12 +173,12 @@ private function register_read_content(): void { } /** - * Permission callback for the `core/read-content` ability. + * Permission callback for the `core/content-query` ability. * * This gate is the authoritative permission decision for single-post modes: it * resolves the requested post and denies missing, mismatched, or unreadable posts * before execution. Query mode is only gated coarsely here (collection status - * capabilities); {@see self::execute_read_content()} enforces row-level read/edit + * capabilities); {@see self::execute_content_query()} enforces row-level read/edit * permissions, since individual rows are unknown until the query runs. Requests * that explicitly ask for edit-context fields require edit access before execution. * @@ -449,7 +449,7 @@ private function check_read_permission( WP_Post $post, array $checked_post_ids = } /** - * Executes the `core/read-content` ability. + * Executes the `core/content-query` ability. * * {@see WP_Ability::execute()} always runs {@see self::check_permission()} first, so the * single-post modes only re-validate the lookup itself: existence, exposure, and a @@ -464,7 +464,7 @@ private function check_read_permission( WP_Post $post, array $checked_post_ids = * @param mixed $input Optional. The ability input. Default empty array. * @return array|\stdClass|\WP_Error A single post, a `posts` list with totals in query mode, or a WP_Error. */ - public function execute_read_content( $input = array() ) { + public function execute_content_query( $input = array() ) { $input = rest_sanitize_object( $input ); $exposed = $this->get_exposed_post_types(); $fields = $this->normalize_fields( $input ); @@ -967,7 +967,7 @@ private function get_post_properties(): array { } /** - * Builds the input schema for the `core/read-content` ability. + * Builds the input schema for the `core/content-query` ability. * * The ability has three mutually exclusive modes, modeled as a `oneOf` so invalid * combinations are rejected rather than silently ignored: @@ -986,7 +986,7 @@ private function get_post_properties(): array { * @param list $statuses Requestable post status slugs. * @return array The input JSON Schema. */ - private function get_read_content_input_schema( array $post_types, array $statuses ): array { + private function get_content_query_input_schema( array $post_types, array $statuses ): array { $fields = array( 'type' => 'array', 'uniqueItems' => true, @@ -1099,7 +1099,7 @@ private function get_read_content_input_schema( array $post_types, array $status } /** - * Builds the output schema for the `core/read-content` ability. + * Builds the output schema for the `core/content-query` ability. * * No field is marked required because the `fields` input lets the caller request any * subset, and a field is only present when its post type supports it. Single-post @@ -1109,7 +1109,7 @@ private function get_read_content_input_schema( array $post_types, array $status * * @return array The output JSON Schema. */ - private function get_read_content_output_schema(): array { + private function get_content_query_output_schema(): array { $post_schema = array( 'type' => 'object', 'additionalProperties' => false, @@ -1393,11 +1393,11 @@ private function get_rendered_excerpt( WP_Post $post ): string { * of the request. */ try { - /** This filter is documented in wp-includes/post-template.php. */ + /** This filter is documented in wp-includes/post-template.php */ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core excerpt filter to mirror REST rendering. $excerpt = apply_filters( 'get_the_excerpt', $post->post_excerpt, $post ); - /** This filter is documented in wp-includes/post-template.php. */ + /** This filter is documented in wp-includes/post-template.php */ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core excerpt filter to mirror REST rendering. $excerpt = apply_filters( 'the_excerpt', $excerpt ); @@ -1438,7 +1438,7 @@ private function get_rendered_content( WP_Post $post ): string { * of the request. */ try { - /** This filter is documented in wp-includes/post-template.php. */ + /** This filter is documented in wp-includes/post-template.php */ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core content filter to mirror REST rendering. $content = apply_filters( 'the_content', $post->post_content ); diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index 353524caa6d67..5bcc34a706b00 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -377,7 +377,7 @@ final class WP_Post_Type { * Whether this post type should be exposed through the Abilities API. * * Default false. When truthy, the post type's readable posts can be retrieved - * through the read-only `core/read-content` ability, subject to per-post capability + * through the read-only `core/content-query` ability, subject to per-post capability * checks. May be an array to enable specific operations in the future. * * @since 7.1.0 diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 8d5b253495e2c..c1b1bd952afb9 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -1740,7 +1740,7 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * @type bool $show_in_rest Whether to include the post type in the REST API. Set this to true * for the post type to be available in the block editor. * @type bool|array $show_in_abilities Whether to expose this post type through the Abilities API, so its - * readable posts can be retrieved via the read-only `core/read-content` + * readable posts can be retrieved via the read-only `core/content-query` * ability (subject to per-post capability checks). Accepts a boolean * or an array reserved for enabling specific operations. Default false. * @type string $rest_base To change the base URL of REST API route. Default is $post_type. diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index d63e1b2de984a..6e73594bbea60 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -3,7 +3,7 @@ declare( strict_types=1 ); /** - * Tests for the core/read-content ability shipped with the Abilities API. + * Tests for the core/content-query ability shipped with the Abilities API. * * @covers WP_Content_Abilities * @@ -102,7 +102,6 @@ public static function wpSetUpBeforeClass( $factory ): void { ); } - /** * Sets up the content ability category for each test. * @@ -111,8 +110,8 @@ public static function wpSetUpBeforeClass( $factory ): void { public function setUp(): void { parent::setUp(); - if ( wp_has_ability( 'core/read-content' ) ) { - wp_unregister_ability( 'core/read-content' ); + if ( wp_has_ability( 'core/content-query' ) ) { + wp_unregister_ability( 'core/content-query' ); } $this->ensure_ability_category( 'content' ); @@ -124,8 +123,8 @@ public function setUp(): void { * @since 7.1.0 */ public function tearDown(): void { - if ( wp_has_ability( 'core/read-content' ) ) { - wp_unregister_ability( 'core/read-content' ); + if ( wp_has_ability( 'core/content-query' ) ) { + wp_unregister_ability( 'core/content-query' ); } foreach ( array( 'post', 'page' ) as $post_type ) { @@ -168,7 +167,7 @@ private function ensure_ability_category( string $slug ): void { } /** - * Registers the core/read-content ability inside a faked init action. + * Registers the core/content-query ability inside a faked init action. * * @since 7.1.0 */ @@ -218,13 +217,13 @@ public function data_roles_without_edit_access_to_other_users_posts(): array { * * @since 7.1.0 */ - public function test_registers_core_read_content_ability(): void { + public function test_registers_core_content_query_ability(): void { $this->register_ability(); - $ability = wp_get_ability( 'core/read-content' ); + $ability = wp_get_ability( 'core/content-query' ); - $this->assertNotNull( $ability, 'The core/read-content ability should be registered.' ); - $this->assertSame( 'core/read-content', $ability->get_name(), 'The registered ability should use the expected name.' ); + $this->assertNotNull( $ability, 'The core/content-query ability should be registered.' ); + $this->assertSame( 'core/content-query', $ability->get_name(), 'The registered ability should use the expected name.' ); $this->assertSame( 'content', $ability->get_category(), 'The registered ability should use the content category.' ); $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ), 'The ability should be exposed in REST.' ); @@ -240,7 +239,7 @@ public function test_registers_core_read_content_ability(): void { * * @since 7.1.0 */ - public function test_does_not_register_core_read_content_ability_without_exposed_post_types(): void { + public function test_does_not_register_core_content_query_ability_without_exposed_post_types(): void { foreach ( array( 'post', 'page' ) as $post_type ) { $object = get_post_type_object( $post_type ); $this->assertNotFalse( $object, "Precondition: the {$post_type} post type should exist." ); @@ -250,7 +249,7 @@ public function test_does_not_register_core_read_content_ability_without_exposed $this->register_ability(); - $this->assertFalse( wp_has_ability( 'core/read-content' ), 'The content ability should not register without any exposed post types.' ); + $this->assertFalse( wp_has_ability( 'core/content-query' ), 'The content ability should not register without any exposed post types.' ); } /** @@ -262,7 +261,7 @@ public function test_does_not_register_core_read_content_ability_without_exposed public function test_input_schema_models_mutually_exclusive_modes(): void { $this->register_ability(); - $schema = wp_get_ability( 'core/read-content' )->get_input_schema(); + $schema = wp_get_ability( 'core/content-query' )->get_input_schema(); $this->assertSame( 'object', $schema['type'], 'The input schema should describe an object.' ); $this->assertCount( 3, $schema['oneOf'], 'The input schema should expose exactly three modes.' ); @@ -312,7 +311,7 @@ public function test_input_schema_models_mutually_exclusive_modes(): void { public function test_input_schema_omits_oneof_branch_defaults(): void { $this->register_ability(); - $schema = wp_get_ability( 'core/read-content' )->get_input_schema(); + $schema = wp_get_ability( 'core/content-query' )->get_input_schema(); $query = $schema['oneOf'][2]; $this->assertArrayNotHasKey( 'default', $query['properties']['status'], 'Status should rely on runtime defaults, not schema defaults.' ); @@ -330,7 +329,7 @@ public function test_id_mode_rejects_query_only_params(): void { $this->login_as( 'administrator' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => 1, 'per_page' => 10, @@ -352,7 +351,7 @@ public function test_id_mode_accepts_post_type_guard(): void { $post_id = self::$post_ids['published']; - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, 'post_type' => 'post', @@ -372,7 +371,7 @@ public function test_id_mode_accepts_post_type_guard(): void { public function test_output_schema_describes_single_post_and_query_responses(): void { $this->register_ability(); - $ability = wp_get_ability( 'core/read-content' ); + $ability = wp_get_ability( 'core/content-query' ); $input_schema = $ability->get_input_schema(); $schema = $ability->get_output_schema(); $post_schema = $schema['oneOf'][0]; @@ -418,7 +417,7 @@ public function test_exposes_a_post_type_registered_by_another_plugin(): void { $this->register_ability(); // Query mode is the third `oneOf` branch; its `post_type` enum lists exposed types. - $enum = wp_get_ability( 'core/read-content' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; + $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; $this->assertContains( 'wpai_content_cpt', $enum, 'Custom post types marked show_in_abilities should appear in the query enum.' ); $post_id = self::factory()->post->create( @@ -428,7 +427,7 @@ public function test_exposes_a_post_type_registered_by_another_plugin(): void { ) ); - $result = wp_get_ability( 'core/read-content' )->execute( array( 'post_type' => 'wpai_content_cpt' ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'wpai_content_cpt' ) ); $ids = wp_list_pluck( $result['posts'], 'id' ); $this->assertContains( $post_id, $ids, 'The custom post type should be queryable through the content ability.' ); @@ -446,7 +445,7 @@ public function test_schema_filter_exposes_late_registered_post_type(): void { $this->login_as( 'administrator' ); $amend_schema = static function ( array $args, string $name ): array { - if ( 'core/read-content' !== $name ) { + if ( 'core/content-query' !== $name ) { return $args; } @@ -464,7 +463,7 @@ public function test_schema_filter_exposes_late_registered_post_type(): void { remove_filter( 'wp_register_ability_args', $amend_schema, 10 ); } - $enum = wp_get_ability( 'core/read-content' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; + $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; $this->assertContains( 'wpai_late_cpt', $enum, 'The ability args filter should amend the frozen schema enum.' ); register_post_type( @@ -484,7 +483,7 @@ public function test_schema_filter_exposes_late_registered_post_type(): void { ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'wpai_late_cpt', 'fields' => array( 'id' ), @@ -508,7 +507,7 @@ public function test_get_single_published_post_by_id(): void { $post_id = self::$post_ids['published_content']; - $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); $this->assertIsArray( $result, 'The by-ID lookup should return a post array.' ); $this->assertSame( $post_id, $result['id'], 'The by-ID lookup should return the requested post directly.' ); @@ -534,7 +533,7 @@ public function test_get_single_published_post_by_id_accepts_object_input(): voi $this->register_ability(); $post_id = self::$post_ids['published_content']; - $ability = wp_get_ability( 'core/read-content' ); + $ability = wp_get_ability( 'core/content-query' ); $input = (object) array( 'id' => $post_id, 'fields' => array( 'id', 'title_rendered' ), @@ -561,7 +560,7 @@ public function test_get_single_published_post_by_id_can_return_content_fields() $post_id = self::$post_ids['published_content']; - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, 'fields' => array( 'id', 'post_type', 'content_rendered', 'content_raw' ), @@ -584,7 +583,7 @@ public function test_get_by_missing_id_is_denied(): void { $this->login_as( 'administrator' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => 999999 ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => 999999 ) ); $this->assertWPError( $result, 'Missing posts should be denied before execution probes object details.' ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Missing posts should fail closed as a permission error.' ); @@ -601,7 +600,7 @@ public function test_get_by_id_with_mismatched_post_type_is_denied(): void { $post_id = self::$post_ids['published']; - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, 'post_type' => 'page', @@ -640,7 +639,7 @@ public function test_get_by_id_for_unexposed_post_type_is_denied(): void { $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); $this->assertWPError( $result, 'Posts from unexposed post types should be denied.' ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Unexposed post types should fail closed as a permission error.' ); @@ -674,7 +673,7 @@ public function test_public_non_viewable_status_is_denied_for_read_only_users(): $this->login_as( 'subscriber' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); $this->assertWPError( $result, 'Read-only users should not receive public statuses that are not viewable.' ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Non-viewable public statuses should fail closed for read-only users.' ); @@ -709,7 +708,7 @@ public function test_public_non_viewable_status_is_readable_with_edit_access(): $this->login_as( 'administrator' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); $this->assertIsArray( $result, 'Editors should be able to access posts they can edit even when the status is not publicly viewable.' ); $this->assertSame( $post_id, $result['id'], 'The editable post should be returned.' ); @@ -755,7 +754,7 @@ public function test_inherited_post_is_readable_when_parent_is_readable(): void $this->login_as( 'subscriber' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $child_id ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $child_id ) ); $this->assertIsArray( $result, 'Inherited posts should be readable when their parent is readable.' ); $this->assertSame( $child_id, $result['id'], 'The inherited child should be returned.' ); @@ -792,7 +791,7 @@ public function test_inherited_post_without_parent_is_denied_for_read_only_users $this->login_as( 'subscriber' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); $this->assertWPError( $result, 'Inherited posts without a readable parent should be denied.' ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Orphaned inherited posts should fail closed.' ); @@ -813,7 +812,7 @@ public function test_query_returns_only_published_by_default(): void { $published = self::factory()->post->create( array( 'post_status' => 'publish' ) ); $draft = self::factory()->post->create( array( 'post_status' => 'draft' ) ); - $result = wp_get_ability( 'core/read-content' )->execute( array( 'post_type' => 'post' ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post' ) ); $ids = wp_list_pluck( $result['posts'], 'id' ); $this->assertContains( $published, $ids, 'Published posts should be returned by default.' ); @@ -833,7 +832,7 @@ public function test_query_include_limits_results(): void { $second = self::factory()->post->create( array( 'post_status' => 'publish' ) ); $third = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'include' => array( $third, $first ), @@ -882,7 +881,7 @@ public function test_query_orders_posts_newest_first_regardless_of_include_order ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', // Deliberately neither date order nor ID order. @@ -915,7 +914,7 @@ public function test_query_include_respects_requested_post_type(): void { ); $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'include' => array( $page_id, $post_id ), @@ -951,7 +950,7 @@ public function test_query_include_respects_row_level_permissions(): void { wp_set_current_user( $author_b ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'status' => array( 'draft' ), @@ -980,7 +979,7 @@ public function test_query_draft_include_can_return_content_fields(): void { ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'status' => array( 'draft' ), @@ -1005,7 +1004,7 @@ public function test_slug_mode_requires_post_type(): void { $this->login_as( 'administrator' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( array( 'slug' => 'whatever' ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'slug' => 'whatever' ) ); $this->assertWPError( $result, 'Slug queries without a post type should fail validation.' ); $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'Invalid slug queries should return an input error.' ); @@ -1028,7 +1027,7 @@ public function test_get_single_published_post_by_slug(): void { ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'slug' => 'content-slug-mode', @@ -1060,7 +1059,7 @@ public function test_get_single_published_post_by_slug_can_return_content_fields ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'slug' => 'content-slug-fields', @@ -1119,7 +1118,7 @@ public function test_slug_lookup_is_not_bounded_by_a_page_size(): void { $this->login_as( 'subscriber' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'slug' => 'wpai-slug-not-bounded', @@ -1152,7 +1151,7 @@ public function test_get_single_post_by_slug_zero(): void { $this->assertSame( '0', get_post( $post_id )->post_name, 'Precondition: the post slug should be the literal string "0".' ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'slug' => '0', @@ -1173,7 +1172,7 @@ public function test_slug_mode_rejects_query_only_params(): void { $this->login_as( 'administrator' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'slug' => 'content-slug-mode', @@ -1214,7 +1213,7 @@ public function test_slug_lookup_prefers_published_post_over_newer_draft(): void foreach ( array( 'subscriber', 'editor' ) as $role ) { $this->login_as( $role ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'slug' => 'shadowed-slug', @@ -1244,7 +1243,7 @@ public function test_slug_lookup_resolves_draft_only_slug_by_readability(): void wp_set_current_user( $author_id ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'slug' => 'draft-only-slug', @@ -1256,7 +1255,7 @@ public function test_slug_lookup_resolves_draft_only_slug_by_readability(): void $this->login_as( 'subscriber' ); - $denied = wp_get_ability( 'core/read-content' )->execute( + $denied = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'slug' => 'draft-only-slug', @@ -1276,13 +1275,13 @@ public function test_include_cannot_be_combined_with_single_post_modes(): void { $this->login_as( 'administrator' ); $this->register_ability(); - $by_id = wp_get_ability( 'core/read-content' )->execute( + $by_id = wp_get_ability( 'core/content-query' )->execute( array( 'id' => self::$post_ids['published'], 'include' => array( self::$post_ids['published'] ), ) ); - $by_slug = wp_get_ability( 'core/read-content' )->execute( + $by_slug = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'slug' => 'whatever', @@ -1307,7 +1306,7 @@ public function test_fields_filter_limits_returned_keys(): void { $post_id = self::$post_ids['published_content']; - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, 'fields' => array( 'id', 'title_rendered' ), @@ -1333,7 +1332,7 @@ public function test_unknown_requested_field_fails_schema_validation(): void { $this->login_as( 'administrator' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => self::$post_ids['published_content'], 'fields' => array( 'id', 'bogus_field' ), @@ -1353,7 +1352,7 @@ public function test_logged_out_user_is_denied(): void { wp_set_current_user( 0 ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( array( 'post_type' => 'post' ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post' ) ); $this->assertWPError( $result, 'Logged-out users should not be allowed to run the content ability.' ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Logged-out users should receive a permission error.' ); @@ -1370,7 +1369,7 @@ public function test_subscriber_can_request_published_content(): void { $this->login_as( 'subscriber' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'fields' => array( 'id', 'title_rendered', 'content_rendered' ), @@ -1398,7 +1397,7 @@ public function test_subscriber_can_get_single_published_post_by_id(): void { $this->login_as( 'subscriber' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); $this->assertIsArray( $result, 'Subscribers should be able to fetch a readable published post by ID.' ); $this->assertSame( 'Readable single', $result['title_rendered'], 'Subscribers should receive the rendered title.' ); @@ -1416,7 +1415,7 @@ public function test_subscriber_cannot_request_raw_fields_in_query_mode(): void $this->login_as( 'subscriber' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'fields' => array( 'content_raw' ), @@ -1438,7 +1437,7 @@ public function test_subscriber_cannot_request_raw_fields_for_single_post(): voi $this->login_as( 'subscriber' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, 'fields' => array( 'content_raw' ), @@ -1462,7 +1461,7 @@ public function test_default_fields_omit_raw_fields_for_roles_without_edit_acces $this->login_as( $role ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); $this->assertIsArray( $result, 'The readable published post should be returned.' ); $this->assertSame( 'Readable title', $result['title_rendered'], 'Rendered title should remain visible.' ); @@ -1485,7 +1484,7 @@ public function test_raw_field_requests_are_denied_for_roles_without_edit_access $this->login_as( $role ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, 'fields' => array( 'content_raw' ), @@ -1505,7 +1504,7 @@ public function test_subscriber_cannot_request_draft_status(): void { $this->login_as( 'subscriber' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'status' => array( 'draft' ), @@ -1525,7 +1524,7 @@ public function test_subscriber_cannot_request_private_status(): void { $this->login_as( 'subscriber' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'status' => array( 'private' ), @@ -1561,7 +1560,7 @@ public function test_author_cannot_see_other_authors_drafts(): void { wp_set_current_user( $author_b ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'status' => array( 'draft' ), @@ -1603,7 +1602,7 @@ public function test_query_totals_may_include_rows_withheld_by_row_level_permiss wp_set_current_user( $author_b ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'status' => array( 'draft' ), @@ -1627,7 +1626,7 @@ public function test_query_mode_rejects_parent_filter_for_non_hierarchical_post_ $this->login_as( 'administrator' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'parent' => 0, @@ -1661,7 +1660,7 @@ public function test_query_mode_filters_pages_by_parent(): void { ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'page', 'parent' => $parent_id, @@ -1691,7 +1690,7 @@ public function test_query_mode_rejects_author_filter_for_post_type_without_auth $this->login_as( 'administrator' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'wpai_no_author_cpt', 'author' => self::$user_ids['author'], @@ -1727,7 +1726,7 @@ public function test_query_mode_filters_posts_by_author(): void { ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'author' => self::$user_ids['author'], @@ -1751,7 +1750,7 @@ public function test_raw_content_visible_to_editor(): void { $this->login_as( 'editor' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, 'fields' => array( 'id', 'content_raw' ), @@ -1776,7 +1775,7 @@ public function test_password_protected_content_visible_to_editor(): void { $this->login_as( 'editor' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, 'fields' => array( 'id', 'content_raw', 'content_rendered' ), @@ -1808,7 +1807,7 @@ public function test_password_protected_rendered_content_is_empty_for_roles_with $this->login_as( $role ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, 'fields' => array( 'id', 'content_rendered', 'content_protected' ), @@ -1836,7 +1835,7 @@ public function test_password_protected_excerpt_visible_to_editor(): void { ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, 'fields' => array( 'id', 'excerpt_rendered', 'excerpt_protected' ), @@ -1869,7 +1868,7 @@ public function test_password_protected_rendered_excerpt_is_empty_for_subscriber $this->login_as( 'subscriber' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, 'fields' => array( 'id', 'excerpt_rendered', 'excerpt_protected' ), @@ -1889,7 +1888,7 @@ public function test_excerpt_rendered_applies_the_excerpt_filters(): void { $this->login_as( 'subscriber' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => self::$post_ids['limited_role_content'], 'fields' => array( 'id', 'excerpt_rendered' ), @@ -1929,7 +1928,7 @@ public function test_excerpt_rendered_uses_and_restores_requested_post_context() add_filter( 'the_excerpt', $append_context_id, 20 ); try { - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $target_id, 'fields' => array( 'id', 'excerpt_rendered' ), @@ -2033,7 +2032,7 @@ static function ( $atts ): string { $this->register_ability(); try { - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $owned_id, 'fields' => array( 'id', 'content_rendered' ), @@ -2066,7 +2065,7 @@ public function test_query_paginates_and_reports_totals(): void { self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); - $page1 = wp_get_ability( 'core/read-content' )->execute( + $page1 = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'per_page' => 2, @@ -2078,7 +2077,7 @@ public function test_query_paginates_and_reports_totals(): void { $this->assertGreaterThanOrEqual( 3, $page1['total'], 'The query should report the total matching post count.' ); $this->assertSame( (int) ceil( $page1['total'] / 2 ), $page1['total_pages'], 'The query should report the computed total page count.' ); - $page2 = wp_get_ability( 'core/read-content' )->execute( + $page2 = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'per_page' => 2, @@ -2099,7 +2098,7 @@ public function test_query_total_matches_returned_posts_when_uncapped(): void { $this->login_as( 'administrator' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'per_page' => 100, @@ -2125,7 +2124,7 @@ public function test_query_last_page_reports_totals(): void { $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'include' => $ids, @@ -2156,7 +2155,7 @@ public function test_query_out_of_range_page_is_rejected_rather_than_reported_as $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'include' => $ids, @@ -2182,7 +2181,7 @@ public function test_query_empty_result_beyond_first_page_reports_zero_totals(): $this->login_as( 'administrator' ); $this->register_ability(); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'include' => array( 999999 ), @@ -2212,7 +2211,7 @@ public function test_query_include_returns_every_requested_post_without_per_page // More than DEFAULT_PER_PAGE (10) so truncation would be visible. $ids = self::factory()->post->create_many( 15, array( 'post_status' => 'publish' ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'include' => $ids, @@ -2240,7 +2239,7 @@ public function test_query_include_honors_an_explicit_per_page(): void { $ids = self::factory()->post->create_many( 5, array( 'post_status' => 'publish' ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'include' => $ids, @@ -2263,12 +2262,12 @@ public function test_query_include_is_capped_at_the_maximum_page_size(): void { $this->login_as( 'administrator' ); $this->register_ability(); - $schema = wp_get_ability( 'core/read-content' )->get_input_schema(); + $schema = wp_get_ability( 'core/content-query' )->get_input_schema(); $query = $schema['oneOf'][2]; $this->assertSame( $query['properties']['per_page']['maximum'], $query['properties']['include']['maxItems'], 'The include list should be capped at the maximum page size.' ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'include' => range( 1, $query['properties']['include']['maxItems'] + 1 ), @@ -2295,7 +2294,7 @@ public function test_query_rendered_fields_prime_the_post_meta_cache(): void { $postmeta_queries = $this->count_post_meta_queries( static function () use ( $ids ) { - return wp_get_ability( 'core/read-content' )->execute( + return wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'include' => $ids, @@ -2316,37 +2315,49 @@ static function () use ( $ids ) { } /** - * A lean projection keeps skipping the post meta cache priming. + * A lean projection does not ask for post meta priming. + * + * Nothing in the default field set renders a post, so the ability leaves priming off + * when it builds the query. * - * Nothing in the default field set renders a post, so the extra lookup stays skipped. + * The check is on what the ability asks for, not on the queries that follow. Honoring + * the request belongs to whoever runs the query, and it may prime more for its own + * reasons, so counting queries here would describe that layer rather than this one. * * @since 7.1.0 */ - public function test_query_lean_projection_does_not_prime_the_post_meta_cache(): void { + public function test_query_lean_projection_does_not_request_post_meta_priming(): void { $this->login_as( 'administrator' ); $this->register_ability(); $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); - $postmeta_queries = $this->count_post_meta_queries( - static function () use ( $ids ) { - return wp_get_ability( 'core/read-content' )->execute( - array( - 'post_type' => 'post', - 'include' => $ids, - 'fields' => array( 'id' ), - ) - ); - }, - $result - ); + // A non-empty `post__in` identifies the query the ability built for this request. + $priming = array(); + $spy = static function ( $query ) use ( &$priming ): void { + if ( array() === (array) $query->get( 'post__in' ) ) { + return; + } - $this->assertCount( 3, $result['posts'], 'Precondition: the query should return the seeded posts.' ); - $this->assertSame( 0, $postmeta_queries, 'A lean projection should not read post meta at all.' ); + $priming[] = $query->get( 'update_post_meta_cache' ); + }; - foreach ( $ids as $id ) { - $this->assertFalse( wp_cache_get( $id, 'post_meta' ), 'A lean projection should not prime the post meta cache.' ); + add_action( 'pre_get_posts', $spy ); + try { + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'fields' => array( 'id' ), + ) + ); + } finally { + remove_action( 'pre_get_posts', $spy ); } + + $this->assertCount( 3, $result['posts'], 'Precondition: the query should return the seeded posts.' ); + $this->assertNotEmpty( $priming, 'Precondition: the ability should query for the included posts.' ); + $this->assertSame( array(), array_filter( $priming ), 'A lean projection should leave post meta priming off.' ); } /** @@ -2395,7 +2406,7 @@ public function test_query_keeps_posts_with_empty_field_projection(): void { // `parent` never applies to the non-hierarchical `post` type, so every row // projects to an empty object. - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', 'per_page' => 100, @@ -2421,7 +2432,7 @@ public function test_single_post_returns_empty_object_for_empty_field_projection $this->register_ability(); // `parent` never applies to the non-hierarchical `post` type, so the projection is empty. - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => self::$post_ids['published'], 'fields' => array( 'parent' ), @@ -2442,7 +2453,7 @@ public function test_single_post_returns_direct_post_object(): void { $post_id = self::$post_ids['published']; - $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); $this->assertSame( $post_id, $result['id'], 'Single-post responses should include the requested post ID.' ); $this->assertArrayNotHasKey( 'posts', $result, 'Single-post responses should not include the query posts wrapper.' ); @@ -2481,7 +2492,7 @@ public function test_gmt_dates_are_utc_on_non_utc_sites(): void { ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, 'fields' => array( 'id', 'date', 'date_gmt', 'modified', 'modified_gmt' ), @@ -2518,7 +2529,7 @@ public function test_gmt_date_is_derived_from_local_date_for_drafts(): void { 'Precondition: drafts should have no stored GMT date.' ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, 'fields' => array( 'id', 'date_gmt' ), @@ -2625,7 +2636,7 @@ public function test_gmt_date_recovers_from_a_null_stored_gmt_date( string $fiel ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, 'fields' => array( 'id', $field ), @@ -2666,7 +2677,7 @@ public function test_gmt_date_is_empty_when_no_usable_date_exists( string $field ) ); - $result = wp_get_ability( 'core/read-content' )->execute( + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, 'fields' => array( 'id', $field ), @@ -2690,11 +2701,11 @@ public function test_execute_callback_returns_not_found_for_structural_lookup_fa $content = new WP_Content_Abilities(); - $missing = $content->execute_read_content( array( 'id' => 999999 ) ); + $missing = $content->execute_content_query( array( 'id' => 999999 ) ); $this->assertWPError( $missing, 'A nonexistent post ID should fail the lookup.' ); $this->assertSame( 'content_not_found', $missing->get_error_code(), 'Missing posts should map to the uniform not-found error.' ); - $mismatched = $content->execute_read_content( + $mismatched = $content->execute_content_query( array( 'id' => self::$post_ids['published'], 'post_type' => 'page', @@ -2703,7 +2714,7 @@ public function test_execute_callback_returns_not_found_for_structural_lookup_fa $this->assertWPError( $mismatched, 'A post type mismatch should fail the lookup.' ); $this->assertSame( 'content_not_found', $mismatched->get_error_code(), 'Mismatched post types should map to the uniform not-found error.' ); - $missing_slug = $content->execute_read_content( + $missing_slug = $content->execute_content_query( array( 'post_type' => 'post', 'slug' => 'no-such-slug', @@ -2727,7 +2738,7 @@ public function test_execute_callback_rejects_non_integer_author_filter(): void $this->login_as( 'administrator' ); $content = new WP_Content_Abilities(); - $result = $content->execute_read_content( + $result = $content->execute_content_query( array( 'post_type' => 'post', 'author' => 'not-a-number', @@ -2752,7 +2763,7 @@ public function test_execute_callback_rejects_non_integer_parent_filter(): void $this->login_as( 'administrator' ); $content = new WP_Content_Abilities(); - $result = $content->execute_read_content( + $result = $content->execute_content_query( array( 'post_type' => 'page', 'parent' => 'not-a-number', @@ -2779,7 +2790,7 @@ public function test_execute_callback_rejects_include_with_no_valid_ids(): void self::factory()->post->create( array( 'post_status' => 'publish' ) ); $content = new WP_Content_Abilities(); - $result = $content->execute_read_content( + $result = $content->execute_content_query( array( 'post_type' => 'post', 'include' => array( 0 ), @@ -2819,7 +2830,7 @@ public function test_execute_callback_honors_string_author_filter(): void { $this->login_as( 'administrator' ); $content = new WP_Content_Abilities(); - $result = $content->execute_read_content( + $result = $content->execute_content_query( array( 'post_type' => 'post', 'author' => (string) $author_a, diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php index a16f69ac6dd97..fe3e1181a9134 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -3,7 +3,7 @@ declare( strict_types=1 ); /** - * Tests dispatching the core/read-content ability through the Abilities REST run endpoint. + * Tests dispatching the core/content-query ability through the Abilities REST run endpoint. * * @covers WP_Content_Abilities * @@ -34,11 +34,11 @@ class Tests_REST_API_WpRestAbilitiesContentController extends WP_UnitTestCase { protected static $subscriber_id; /** - * The run route for the core/read-content ability. + * The run route for the core/content-query ability. * * @var string */ - const RUN_ROUTE = '/wp-abilities/v1/abilities/core/read-content/run'; + const RUN_ROUTE = '/wp-abilities/v1/abilities/core/content-query/run'; /** * Sets up users and registers the core abilities. From 53c2118cf41a45ce9bf166f96742d4c6784731de Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 5 Oct 2026 23:15:01 +0100 Subject: [PATCH 16/69] Abilities API: Use the public meta flag for the content query ability Declares `meta.public` instead of `meta.show_in_rest`, matching the other core abilities since [62737]. The flag still seeds `show_in_rest`, so REST exposure is unchanged. --- src/wp-includes/abilities/class-wp-content-abilities.php | 4 ++-- .../tests/abilities-api/wpRegisterCoreContentAbility.php | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 49f5d657dab26..ae40ff8f273f9 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -158,7 +158,7 @@ private function register_content_query(): void { 'execute_callback' => array( $this, 'execute_content_query' ), 'permission_callback' => array( $this, 'check_permission' ), 'meta' => array( - 'annotations' => array( + 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, @@ -166,7 +166,7 @@ private function register_content_query(): void { // hint is absent; this ability only reads the local database. 'open_world' => false, ), - 'show_in_rest' => true, + 'public' => true, ), ) ); diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 6e73594bbea60..75d0c03113bf6 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -225,6 +225,7 @@ public function test_registers_core_content_query_ability(): void { $this->assertNotNull( $ability, 'The core/content-query ability should be registered.' ); $this->assertSame( 'core/content-query', $ability->get_name(), 'The registered ability should use the expected name.' ); $this->assertSame( 'content', $ability->get_category(), 'The registered ability should use the content category.' ); + $this->assertTrue( $ability->get_meta_item( 'public', false ), 'The ability should be marked public.' ); $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ), 'The ability should be exposed in REST.' ); $annotations = $ability->get_meta_item( 'annotations', array() ); From 5780e8d3ef407193dfd75aeaa7b31b1104ec87e0 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 5 Oct 2026 23:15:01 +0100 Subject: [PATCH 17/69] Abilities API: Update the content query ability since tags to 7.2.0 --- .../abilities/class-wp-content-abilities.php | 86 ++++----- src/wp-includes/class-wp-post-type.php | 2 +- src/wp-includes/post.php | 2 +- .../wpRegisterCoreContentAbility.php | 178 +++++++++--------- .../wpRestAbilitiesContentController.php | 4 +- 5 files changed, 136 insertions(+), 136 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index ae40ff8f273f9..eca112c256126 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage Abilities API - * @since 7.1.0 + * @since 7.2.0 */ declare( strict_types = 1 ); @@ -26,7 +26,7 @@ * not part of the public API. It may be changed or removed at any time without notice. * Do not use it directly or rely on its existence. * - * @since 7.1.0 + * @since 7.2.0 * * @access private */ @@ -35,7 +35,7 @@ final class WP_Content_Abilities { /** * The ability category used for content abilities. * - * @since 7.1.0 + * @since 7.2.0 * @var string */ private const CATEGORY = 'content'; @@ -43,7 +43,7 @@ final class WP_Content_Abilities { /** * Default number of posts returned per page in query mode. * - * @since 7.1.0 + * @since 7.2.0 * @var int */ private const DEFAULT_PER_PAGE = 10; @@ -51,7 +51,7 @@ final class WP_Content_Abilities { /** * Maximum number of posts returned per page in query mode. * - * @since 7.1.0 + * @since 7.2.0 * @var int */ private const MAX_PER_PAGE = 100; @@ -61,7 +61,7 @@ final class WP_Content_Abilities { * * Requests that explicitly include any of these fields require edit access. * - * @since 7.1.0 + * @since 7.2.0 * @var list */ private array $edit_fields = array( @@ -76,7 +76,7 @@ final class WP_Content_Abilities { * Requests that include any of these prime the post meta and term caches for the * page. Other rendered fields, such as the title, do not need that cache priming. * - * @since 7.1.0 + * @since 7.2.0 * @var list */ private array $cache_priming_fields = array( @@ -87,7 +87,7 @@ final class WP_Content_Abilities { /** * Cached post field definitions, keyed by field name in output order. * - * @since 7.1.0 + * @since 7.2.0 * @var array|null */ private ?array $post_properties = null; @@ -95,7 +95,7 @@ final class WP_Content_Abilities { /** * Default fields returned when the caller does not request a field subset. * - * @since 7.1.0 + * @since 7.2.0 * @var list */ private array $default_fields = array( @@ -112,7 +112,7 @@ final class WP_Content_Abilities { * * Must run on the `wp_abilities_api_init` hook. * - * @since 7.1.0 + * @since 7.2.0 */ public function register(): void { $this->register_content_query(); @@ -128,7 +128,7 @@ public function register(): void { /** * Registers the read-only `core/content-query` ability. * - * @since 7.1.0 + * @since 7.2.0 */ private function register_content_query(): void { /* @@ -182,7 +182,7 @@ private function register_content_query(): void { * permissions, since individual rows are unknown until the query runs. Requests * that explicitly ask for edit-context fields require edit access before execution. * - * @since 7.1.0 + * @since 7.2.0 * * @param mixed $input Optional. The ability input. Default empty array. * @return bool True if the request may proceed, false otherwise. @@ -237,7 +237,7 @@ public function check_permission( $input = array() ): bool { /** * Casts a raw input value to a non-negative integer. * - * @since 7.1.0 + * @since 7.2.0 * * @param mixed $value The raw input value. * @return int The value as a non-negative integer, or 0 when not scalar. @@ -256,7 +256,7 @@ private function input_int( $value ): int { * Accepts native integers and unsigned integer strings, mirroring how the JSON * Schema `integer` type and the query-string transport respectively deliver them. * - * @since 7.1.0 + * @since 7.2.0 * * @param mixed $value The raw input value. * @param int $min The smallest acceptable value. @@ -282,7 +282,7 @@ private function parse_filter_int( $value, int $min ): ?int { * The capability map is a plain object with untyped properties, so guard the * lookup and fail closed with `do_not_allow` when the name cannot be resolved. * - * @since 7.1.0 + * @since 7.2.0 * * @param \WP_Post_Type $post_type_object The post type object. * @param string $capability The capability key, e.g. 'edit_posts'. @@ -301,7 +301,7 @@ private function post_type_cap( \WP_Post_Type $post_type_object, string $capabil * same way schema validation did (wp_parse_list) so they are honored regardless of * transport, until core sanitizes ability input itself. * - * @since 7.1.0 + * @since 7.2.0 * * @param array $input The ability input. * @param string $key The input key holding the list. @@ -322,7 +322,7 @@ private function parse_list_input( array $input, string $key ): array { * Omitted fields are not treated as edit-intent: default responses include the * fields visible for each individual post. * - * @since 7.1.0 + * @since 7.2.0 * * @param array $input The ability input. * @return bool True if edit-context fields were explicitly requested. @@ -338,7 +338,7 @@ private function has_explicit_edit_fields( array $input ): bool { * requesting non-default statuses requires edit access, except `private`, which * may be queried by users who can read private posts. * - * @since 7.1.0 + * @since 7.2.0 * * @param array $input The ability input. * @param \WP_Post_Type $post_type_object The post type object. @@ -372,7 +372,7 @@ private function can_query_statuses( array $input, \WP_Post_Type $post_type_obje * Mirrors the REST posts controller's read permission, while keeping this ability * authenticated-only via {@see self::check_permission()}. * - * @since 7.1.0 + * @since 7.2.0 * * @param \WP_Post $post Post object. * @param array $checked_post_ids Post IDs already checked while walking inherited parents. @@ -459,7 +459,7 @@ private function check_read_permission( WP_Post $post, array $checked_post_ids = * A post is returned as an empty object when its field projection is empty, so callers * must not assume array access on a post. See {@see self::to_output_post()}. * - * @since 7.1.0 + * @since 7.2.0 * * @param mixed $input Optional. The ability input. Default empty array. * @return array|\stdClass|\WP_Error A single post, a `posts` list with totals in query mode, or a WP_Error. @@ -659,7 +659,7 @@ static function ( $queried_post ): bool { * them in one call rather than silently losing the ones past the default page size. * The input schema caps `include` at {@see self::MAX_PER_PAGE} so it always fits. * - * @since 7.1.0 + * @since 7.2.0 * * @param array $input The ability input. * @param list $include_ids Normalized included post IDs; empty when not requested. @@ -684,7 +684,7 @@ private function normalize_per_page( array $input, array $include_ids = array() * minimal unpaged query so the caller can distinguish an out-of-range page from * an empty result set, matching the REST posts controller behavior. * - * @since 7.1.0 + * @since 7.2.0 * * @param \WP_Query $query The executed query. * @param array $query_args The arguments used for the executed query. @@ -713,7 +713,7 @@ private function get_query_total( WP_Query $query, array $query_args, int $page /** * Checks whether requested fields benefit from page-level cache priming. * - * @since 7.1.0 + * @since 7.2.0 * * @param list $fields The requested field names. * @return bool True when post meta and term caches should be primed. @@ -733,7 +733,7 @@ private function should_prime_post_caches( array $fields ): bool { * requested fields; edit-field requests are gated afterwards on the resolved * post by {@see self::check_permission()}. * - * @since 7.1.0 + * @since 7.2.0 * * @param string $post_type The post type. * @param string $slug The post slug. @@ -791,7 +791,7 @@ private function get_post_by_slug( string $post_type, string $slug ): ?WP_Post { * unregistered or re-registered with different arguments between the ability * being registered and the ability being used. * - * @since 7.1.0 + * @since 7.2.0 * * @return array Exposed post type objects keyed by name. */ @@ -808,7 +808,7 @@ private function get_exposed_post_types(): array { /** * Normalizes the requested statuses to a non-empty, sanitized list defaulting to publish. * - * @since 7.1.0 + * @since 7.2.0 * * @param array $input The ability input. * @return list Normalized list of post status slugs. @@ -822,7 +822,7 @@ private function normalize_statuses( array $input ): array { /** * Normalizes query-mode included post IDs. * - * @since 7.1.0 + * @since 7.2.0 * * @param array $input The ability input. * @return list Unique positive post IDs. @@ -845,7 +845,7 @@ private function normalize_include( array $input ): array { * Otherwise the requested fields are returned as-is. The input schema has already * validated them against the supported set before the ability executes. * - * @since 7.1.0 + * @since 7.2.0 * * @param array $input The ability input. * @return list List of requested field names. @@ -864,7 +864,7 @@ private function normalize_fields( array $input ): array { * the keys. Read-context fields are returned for readable posts; the edit-context * fields listed in {@see self::$edit_fields} additionally require edit access. * - * @since 7.1.0 + * @since 7.2.0 * * @return array Post field definitions. */ @@ -980,7 +980,7 @@ private function get_post_properties(): array { * Each mode sets `additionalProperties: false`, so e.g. passing `per_page` alongside `id` * fails validation instead of being dropped. `fields` is accepted in every mode. * - * @since 7.1.0 + * @since 7.2.0 * * @param list $post_types Exposed post type names. * @param list $statuses Requestable post status slugs. @@ -1105,7 +1105,7 @@ private function get_content_query_input_schema( array $post_types, array $statu * subset, and a field is only present when its post type supports it. Single-post * mode returns the post object directly, while query mode returns a paginated wrapper. * - * @since 7.1.0 + * @since 7.2.0 * * @return array The output JSON Schema. */ @@ -1157,7 +1157,7 @@ private function get_content_query_output_schema(): array { * case as `[]` even though it types the response as an object * (`GET /wp/v2/posts/?_fields=parent` on a non-hierarchical post type). * - * @since 7.1.0 + * @since 7.2.0 * * @param array $formatted The formatted post data. * @return array|\stdClass The post data, or an empty object when the projection is empty. @@ -1174,7 +1174,7 @@ private function to_output_post( array $formatted ) { * protected-post placeholders. The field projection itself is delegated to * {@see self::build_post_fields()}. * - * @since 7.1.0 + * @since 7.2.0 * * @param \WP_Post $post The post object. * @param list $fields The requested field names. @@ -1215,7 +1215,7 @@ private function format_post( WP_Post $post, array $fields ): array { * are withheld for password-protected posts unless the current user can edit the post, * mirroring the REST API behavior. * - * @since 7.1.0 + * @since 7.2.0 * * @param \WP_Post $post The post object. * @param list $fields The requested field names. @@ -1317,7 +1317,7 @@ private function build_post_fields( WP_Post $post, array $fields, bool $can_edit * other protected posts that the content filter may render. Mirrors the REST posts * controller's check_password_required(). * - * @since 7.1.0 + * @since 7.2.0 * * @param mixed $required Whether the post currently requires a password. * @param mixed $post The post being checked; a WP_Post when invoked by the core filter. @@ -1334,7 +1334,7 @@ public function allow_password_content( $required, $post ): bool { /** * Returns the post title with the protected/private prefixes stripped. * - * @since 7.1.0 + * @since 7.2.0 * * @param \WP_Post $post The post object. * @return string The post title. @@ -1359,7 +1359,7 @@ private function get_title( WP_Post $post ): string { /** * Returns the raw title format, used to strip protected/private title prefixes. * - * @since 7.1.0 + * @since 7.2.0 * * @return string The unprefixed title format. */ @@ -1375,7 +1375,7 @@ public function return_raw_title_format(): string { * previous global post context. This ensures filters that rely on loop globals * render against the requested post. * - * @since 7.1.0 + * @since 7.2.0 * * @param \WP_Post $post The post object. * @return string Rendered post excerpt. @@ -1420,7 +1420,7 @@ private function get_rendered_excerpt( WP_Post $post ): string { * Mirrors the REST posts controller by preparing post globals before applying * `the_content`, then restoring the previous global post context. * - * @since 7.1.0 + * @since 7.2.0 * * @param \WP_Post $post The post object. * @return string Rendered post content. @@ -1458,7 +1458,7 @@ private function get_rendered_content( WP_Post $post ): string { /** * Formats a post date field as an ISO 8601 string in the site's timezone. * - * @since 7.1.0 + * @since 7.2.0 * * @param \WP_Post $post The post object. * @param string $field Either 'date' or 'modified'. Default 'date'. @@ -1479,7 +1479,7 @@ private function format_local_date( WP_Post $post, string $field = 'date' ): str * here because it reprojects even GMT-sourced dates into the site timezone, which * would label the returned instant with the site offset instead of UTC. * - * @since 7.1.0 + * @since 7.2.0 * * @param \WP_Post $post The post object. * @param string $field Either 'date' or 'modified'. Default 'date'. @@ -1510,7 +1510,7 @@ private function format_gmt_date( WP_Post $post, string $field = 'date' ): strin * The columns are `NOT NULL` in core's schema, but a post object can reach this class * from a filter or an in-memory row where a date is null or a zero date. * - * @since 7.1.0 + * @since 7.2.0 * * @param mixed $date The raw date column value. * @return bool True when the value is a non-empty, non-zero date string. @@ -1532,7 +1532,7 @@ private function is_usable_date( $date ): bool { * direct call bypasses them. Only invoke the callback through * {@see WP_Ability::execute()}, which always runs the permission callback first. * - * @since 7.1.0 + * @since 7.2.0 * * @return \WP_Error The not-found error. */ diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index 5bcc34a706b00..29fd12faa8644 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -380,7 +380,7 @@ final class WP_Post_Type { * through the read-only `core/content-query` ability, subject to per-post capability * checks. May be an array to enable specific operations in the future. * - * @since 7.1.0 + * @since 7.2.0 * @var bool|array $show_in_abilities */ public $show_in_abilities; diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index c1b1bd952afb9..b8f3b2a8709ca 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -1693,7 +1693,7 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * @since 5.0.0 The `template` and `template_lock` arguments were added. * @since 5.3.0 The `supports` argument will now accept an array of arguments for a feature. * @since 5.9.0 The `rest_namespace` argument was added. - * @since 7.1.0 The `show_in_abilities` argument was added. + * @since 7.2.0 The `show_in_abilities` argument was added. * * @global array $wp_post_types List of post types. * diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 75d0c03113bf6..737d11168631e 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -14,7 +14,7 @@ class Tests_Abilities_API_WpRegisterCoreContentAbility extends WP_UnitTestCase { /** * Shared user IDs keyed by role or fixture name. * - * @since 7.1.0 + * @since 7.2.0 * * @var array */ @@ -23,7 +23,7 @@ class Tests_Abilities_API_WpRegisterCoreContentAbility extends WP_UnitTestCase { /** * Shared post IDs keyed by fixture name. * - * @since 7.1.0 + * @since 7.2.0 * * @var array */ @@ -32,7 +32,7 @@ class Tests_Abilities_API_WpRegisterCoreContentAbility extends WP_UnitTestCase { /** * Creates shared users and posts for the content ability tests. * - * @since 7.1.0 + * @since 7.2.0 * * @param \WP_UnitTest_Factory $factory The unit test factory. */ @@ -105,7 +105,7 @@ public static function wpSetUpBeforeClass( $factory ): void { /** * Sets up the content ability category for each test. * - * @since 7.1.0 + * @since 7.2.0 */ public function setUp(): void { parent::setUp(); @@ -120,7 +120,7 @@ public function setUp(): void { /** * Restores ability and post type state after each test. * - * @since 7.1.0 + * @since 7.2.0 */ public function tearDown(): void { if ( wp_has_ability( 'core/content-query' ) ) { @@ -142,7 +142,7 @@ public function tearDown(): void { /** * Ensures an ability category exists for an ability to attach to. * - * @since 7.1.0 + * @since 7.2.0 * * @param string $slug The ability category slug. */ @@ -169,7 +169,7 @@ private function ensure_ability_category( string $slug ): void { /** * Registers the core/content-query ability inside a faked init action. * - * @since 7.1.0 + * @since 7.2.0 */ private function register_ability(): void { global $wp_current_filter; @@ -215,7 +215,7 @@ public function data_roles_without_edit_access_to_other_users_posts(): array { /** * The ability is registered in the `content` category and flagged read-only. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_registers_core_content_query_ability(): void { $this->register_ability(); @@ -238,7 +238,7 @@ public function test_registers_core_content_query_ability(): void { /** * The content ability is not registered when no post types are exposed to it. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_does_not_register_core_content_query_ability_without_exposed_post_types(): void { foreach ( array( 'post', 'page' ) as $post_type ) { @@ -257,7 +257,7 @@ public function test_does_not_register_core_content_query_ability_without_expose * The input schema models mutually exclusive ID, slug, and query modes, each * rejecting the other modes' properties and exposing only marked types. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_input_schema_models_mutually_exclusive_modes(): void { $this->register_ability(); @@ -307,7 +307,7 @@ public function test_input_schema_models_mutually_exclusive_modes(): void { * Branch-local defaults are omitted so the schema can compile in the client-side * Abilities API validator. Runtime defaults are still applied by the ability. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_input_schema_omits_oneof_branch_defaults(): void { $this->register_ability(); @@ -324,7 +324,7 @@ public function test_input_schema_omits_oneof_branch_defaults(): void { * Query-mode filters cannot be combined with a by-ID lookup: passing `per_page` alongside * `id` is rejected outright rather than silently ignored. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_id_mode_rejects_query_only_params(): void { $this->login_as( 'administrator' ); @@ -344,7 +344,7 @@ public function test_id_mode_rejects_query_only_params(): void { /** * `post_type` is accepted alongside `id` as a guard: the by-ID mode still resolves the post. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_id_mode_accepts_post_type_guard(): void { $this->login_as( 'administrator' ); @@ -367,7 +367,7 @@ public function test_id_mode_accepts_post_type_guard(): void { /** * The output schema describes single-post and query response shapes. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_output_schema_describes_single_post_and_query_responses(): void { $this->register_ability(); @@ -401,7 +401,7 @@ public function test_output_schema_describes_single_post_and_query_responses(): * A post type registered by another active plugin and flagged `show_in_abilities` * is exposed by the ability, both in the input enum and in query results. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_exposes_a_post_type_registered_by_another_plugin(): void { register_post_type( @@ -440,7 +440,7 @@ public function test_exposes_a_post_type_registered_by_another_plugin(): void { /** * A schema filter can expose a post type that is registered after the ability. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_schema_filter_exposes_late_registered_post_type(): void { $this->login_as( 'administrator' ); @@ -500,7 +500,7 @@ public function test_schema_filter_exposes_late_registered_post_type(): void { /** * A published post can be fetched by ID. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_get_single_published_post_by_id(): void { $this->login_as( 'administrator' ); @@ -527,7 +527,7 @@ public function test_get_single_published_post_by_id(): void { * WP_Ability validates `stdClass` as object input but does not coerce the value before * passing it to the permission and execute callbacks, so both must preserve its fields. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_get_single_published_post_by_id_accepts_object_input(): void { $this->login_as( 'administrator' ); @@ -553,7 +553,7 @@ public function test_get_single_published_post_by_id_accepts_object_input(): voi /** * A single post fetched by ID can return explicitly requested rendered and raw content. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_get_single_published_post_by_id_can_return_content_fields(): void { $this->login_as( 'administrator' ); @@ -578,7 +578,7 @@ public function test_get_single_published_post_by_id_can_return_content_fields() /** * A missing post ID is denied before execution can probe the requested object. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_get_by_missing_id_is_denied(): void { $this->login_as( 'administrator' ); @@ -593,7 +593,7 @@ public function test_get_by_missing_id_is_denied(): void { /** * A post type guard mismatch is denied before execution can probe the requested object. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_get_by_id_with_mismatched_post_type_is_denied(): void { $this->login_as( 'administrator' ); @@ -615,7 +615,7 @@ public function test_get_by_id_with_mismatched_post_type_is_denied(): void { /** * A post from a post type not exposed to abilities is denied. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_get_by_id_for_unexposed_post_type_is_denied(): void { register_post_type( @@ -652,7 +652,7 @@ public function test_get_by_id_for_unexposed_post_type_is_denied(): void { /** * A status that is public but not viewable is not exposed to read-only users. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_public_non_viewable_status_is_denied_for_read_only_users(): void { register_post_status( @@ -686,7 +686,7 @@ public function test_public_non_viewable_status_is_denied_for_read_only_users(): /** * A status that is public but not viewable remains available to users who can edit it. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_public_non_viewable_status_is_readable_with_edit_access(): void { register_post_status( @@ -722,7 +722,7 @@ public function test_public_non_viewable_status_is_readable_with_edit_access(): /** * A post that inherits its status from a readable parent is readable. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_inherited_post_is_readable_when_parent_is_readable(): void { register_post_type( @@ -768,7 +768,7 @@ public function test_inherited_post_is_readable_when_parent_is_readable(): void /** * A post with an inherited status but no readable parent is denied. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_inherited_post_without_parent_is_denied_for_read_only_users(): void { register_post_type( @@ -804,7 +804,7 @@ public function test_inherited_post_without_parent_is_denied_for_read_only_users /** * Query mode returns only published posts by default. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_returns_only_published_by_default(): void { $this->login_as( 'administrator' ); @@ -823,7 +823,7 @@ public function test_query_returns_only_published_by_default(): void { /** * Query mode can limit results to included IDs. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_include_limits_results(): void { $this->login_as( 'administrator' ); @@ -857,7 +857,7 @@ public function test_query_include_limits_results(): void { * WP_Query default, matching the REST posts controller, and `include` only filters the * query, so a caller that passes IDs in a chosen order must not expect them back in it. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_orders_posts_newest_first_regardless_of_include_order(): void { $this->login_as( 'administrator' ); @@ -901,7 +901,7 @@ public function test_query_orders_posts_newest_first_regardless_of_include_order /** * Query include still respects the requested post type. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_include_respects_requested_post_type(): void { $this->login_as( 'administrator' ); @@ -929,7 +929,7 @@ public function test_query_include_respects_requested_post_type(): void { /** * Query include still respects row-level permissions. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_include_respects_row_level_permissions(): void { $author_a = self::$user_ids['author']; @@ -966,7 +966,7 @@ public function test_query_include_respects_row_level_permissions(): void { /** * Query mode can return included drafts with explicitly requested rendered and raw content. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_draft_include_can_return_content_fields(): void { $this->login_as( 'administrator' ); @@ -999,7 +999,7 @@ public function test_query_draft_include_can_return_content_fields(): void { /** * Querying by slug without a post type is rejected by the input schema. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_slug_mode_requires_post_type(): void { $this->login_as( 'administrator' ); @@ -1014,7 +1014,7 @@ public function test_slug_mode_requires_post_type(): void { /** * Slug mode returns a single post directly when paired with a post type. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_get_single_published_post_by_slug(): void { $this->login_as( 'administrator' ); @@ -1045,7 +1045,7 @@ public function test_get_single_published_post_by_slug(): void { /** * A single post fetched by slug can return explicitly requested rendered and raw content. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_get_single_published_post_by_slug_can_return_content_fields(): void { $this->login_as( 'administrator' ); @@ -1083,7 +1083,7 @@ public function test_get_single_published_post_by_slug_can_return_content_fields * size applies. Bounding it with `post_name__in` and a page size would page straight past * an older published post, because the query is ordered newest first. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_slug_lookup_is_not_bounded_by_a_page_size(): void { global $wpdb; @@ -1134,7 +1134,7 @@ public function test_slug_lookup_is_not_bounded_by_a_page_size(): void { /** * A post whose slug is the literal string "0" is fetched in single-post slug mode. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_get_single_post_by_slug_zero(): void { $this->login_as( 'administrator' ); @@ -1167,7 +1167,7 @@ public function test_get_single_post_by_slug_zero(): void { /** * Query-only filters cannot be combined with slug mode. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_slug_mode_rejects_query_only_params(): void { $this->login_as( 'administrator' ); @@ -1188,7 +1188,7 @@ public function test_slug_mode_rejects_query_only_params(): void { /** * A newer draft sharing a published post's slug does not shadow the published post. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_slug_lookup_prefers_published_post_over_newer_draft(): void { $published_id = self::factory()->post->create( @@ -1229,7 +1229,7 @@ public function test_slug_lookup_prefers_published_post_over_newer_draft(): void /** * A slug held only by a draft resolves for its author and stays denied for readers. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_slug_lookup_resolves_draft_only_slug_by_readability(): void { $author_id = self::$user_ids['author']; @@ -1270,7 +1270,7 @@ public function test_slug_lookup_resolves_draft_only_slug_by_readability(): void /** * Include is a query-only option and cannot be combined with single-post modes. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_include_cannot_be_combined_with_single_post_modes(): void { $this->login_as( 'administrator' ); @@ -1299,7 +1299,7 @@ public function test_include_cannot_be_combined_with_single_post_modes(): void { /** * The `fields` filter limits the returned keys. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_fields_filter_limits_returned_keys(): void { $this->login_as( 'administrator' ); @@ -1327,7 +1327,7 @@ public function test_fields_filter_limits_returned_keys(): void { * Unlike fields a post type does not support, which are omitted per post, a field * name that is not part of the supported set is rejected before the ability executes. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_unknown_requested_field_fails_schema_validation(): void { $this->login_as( 'administrator' ); @@ -1347,7 +1347,7 @@ public function test_unknown_requested_field_fails_schema_validation(): void { /** * Logged-out users cannot run the ability. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_logged_out_user_is_denied(): void { wp_set_current_user( 0 ); @@ -1362,7 +1362,7 @@ public function test_logged_out_user_is_denied(): void { /** * Subscribers can request rendered published content. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_subscriber_can_request_published_content(): void { $post_id = self::$post_ids['subscriber_content']; @@ -1390,7 +1390,7 @@ public function test_subscriber_can_request_published_content(): void { /** * Subscribers can fetch a published post by ID. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_subscriber_can_get_single_published_post_by_id(): void { $post_id = self::$post_ids['readable_single']; @@ -1410,7 +1410,7 @@ public function test_subscriber_can_get_single_published_post_by_id(): void { /** * Subscribers cannot request edit-context raw fields in query mode. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_subscriber_cannot_request_raw_fields_in_query_mode(): void { $this->login_as( 'subscriber' ); @@ -1430,7 +1430,7 @@ public function test_subscriber_cannot_request_raw_fields_in_query_mode(): void /** * Subscribers cannot request edit-context raw fields for a single post. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_subscriber_cannot_request_raw_fields_for_single_post(): void { $post_id = self::$post_ids['published']; @@ -1499,7 +1499,7 @@ public function test_raw_field_requests_are_denied_for_roles_without_edit_access /** * Subscribers cannot request draft posts. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_subscriber_cannot_request_draft_status(): void { $this->login_as( 'subscriber' ); @@ -1519,7 +1519,7 @@ public function test_subscriber_cannot_request_draft_status(): void { /** * Subscribers cannot request private posts. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_subscriber_cannot_request_private_status(): void { $this->login_as( 'subscriber' ); @@ -1539,7 +1539,7 @@ public function test_subscriber_cannot_request_private_status(): void { /** * An author can pass the draft gate but only sees their own drafts. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_author_cannot_see_other_authors_drafts(): void { $author_a = self::$user_ids['author']; @@ -1579,7 +1579,7 @@ public function test_author_cannot_see_other_authors_drafts(): void { * This matches the REST posts controller: `posts` only contains rows the current * user can read, while `total` and `total_pages` describe the underlying query. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_totals_may_include_rows_withheld_by_row_level_permissions(): void { $author_a = self::$user_ids['author']; @@ -1621,7 +1621,7 @@ public function test_query_totals_may_include_rows_withheld_by_row_level_permiss /** * The parent filter is rejected for non-hierarchical post types, mirroring REST. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_mode_rejects_parent_filter_for_non_hierarchical_post_type(): void { $this->login_as( 'administrator' ); @@ -1641,7 +1641,7 @@ public function test_query_mode_rejects_parent_filter_for_non_hierarchical_post_ /** * The parent filter narrows hierarchical queries to children of the given post. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_mode_filters_pages_by_parent(): void { $this->login_as( 'administrator' ); @@ -1675,7 +1675,7 @@ public function test_query_mode_filters_pages_by_parent(): void { /** * The author filter is rejected for post types without author support, mirroring REST. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_mode_rejects_author_filter_for_post_type_without_author_support(): void { register_post_type( @@ -1708,7 +1708,7 @@ public function test_query_mode_rejects_author_filter_for_post_type_without_auth /** * The author filter narrows queries to posts by the given author. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_mode_filters_posts_by_author(): void { $this->login_as( 'administrator' ); @@ -1743,7 +1743,7 @@ public function test_query_mode_filters_posts_by_author(): void { /** * Raw content is available to users who can edit the post. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_raw_content_visible_to_editor(): void { $post_id = self::$post_ids['raw_content']; @@ -1768,7 +1768,7 @@ public function test_raw_content_visible_to_editor(): void { /** * Password-protected content is visible to users who can edit the post. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_password_protected_content_visible_to_editor(): void { $post_id = self::$post_ids['password_protected_editor']; @@ -1822,7 +1822,7 @@ public function test_password_protected_rendered_content_is_empty_for_roles_with /** * Password-protected excerpts render for users who can edit the post. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_password_protected_excerpt_visible_to_editor(): void { $this->login_as( 'editor' ); @@ -1854,7 +1854,7 @@ public function test_password_protected_excerpt_visible_to_editor(): void { /** * Password-protected rendered excerpts are withheld from users who cannot edit the post. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_password_protected_rendered_excerpt_is_empty_for_subscriber(): void { $post_id = self::factory()->post->create( @@ -1883,7 +1883,7 @@ public function test_password_protected_rendered_excerpt_is_empty_for_subscriber /** * Rendered excerpts carry the REST API's `the_excerpt` markup (paragraph wrapping). * - * @since 7.1.0 + * @since 7.2.0 */ public function test_excerpt_rendered_applies_the_excerpt_filters(): void { $this->login_as( 'subscriber' ); @@ -1907,7 +1907,7 @@ public function test_excerpt_rendered_applies_the_excerpt_filters(): void { * Rendered excerpt filters run with the requested post as the global context and restore * the context that was active before the ability executed. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_excerpt_rendered_uses_and_restores_requested_post_context(): void { $this->login_as( 'subscriber' ); @@ -1964,7 +1964,7 @@ public function test_excerpt_rendered_uses_and_restores_requested_post_context() /** * The password gate is suspended only for posts the current user can edit. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_allow_password_content_only_unlocks_editable_posts(): void { $owned_id = self::factory()->post->create( @@ -1998,7 +1998,7 @@ public function test_allow_password_content_only_unlocks_editable_posts(): void * Rendering an editable protected post must not unlock other protected posts embedded in * its content (e.g. through a shortcode or Query Loop block). * - * @since 7.1.0 + * @since 7.2.0 */ public function test_password_filter_does_not_leak_other_protected_posts(): void { $hidden_id = self::factory()->post->create( @@ -2058,7 +2058,7 @@ static function ( $atts ): string { /** * Query mode paginates with `page`/`per_page` and reports totals. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_paginates_and_reports_totals(): void { $this->login_as( 'administrator' ); @@ -2093,7 +2093,7 @@ public function test_query_paginates_and_reports_totals(): void { /** * Query mode reports a total that matches the returned posts for an uncapped query. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_total_matches_returned_posts_when_uncapped(): void { $this->login_as( 'administrator' ); @@ -2117,7 +2117,7 @@ public function test_query_total_matches_returned_posts_when_uncapped(): void { * Guards the boundary next to the out-of-range page error: the final page must not be * mistaken for an overshoot. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_last_page_reports_totals(): void { $this->login_as( 'administrator' ); @@ -2148,7 +2148,7 @@ public function test_query_last_page_reports_totals(): void { * which is indistinguishable from an empty collection. Match the REST posts controller * by reporting this as a caller error instead. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_out_of_range_page_is_rejected_rather_than_reported_as_empty(): void { $this->login_as( 'administrator' ); @@ -2176,7 +2176,7 @@ public function test_query_out_of_range_page_is_rejected_rather_than_reported_as * * The out-of-range guard only fires when the underlying query actually matched rows. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_empty_result_beyond_first_page_reports_zero_totals(): void { $this->login_as( 'administrator' ); @@ -2203,7 +2203,7 @@ public function test_query_empty_result_beyond_first_page_reports_zero_totals(): * Without this the default page size silently truncates a batch load: a caller asking * for a known set of IDs would receive only the first `per_page` of them. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_include_returns_every_requested_post_without_per_page(): void { $this->login_as( 'administrator' ); @@ -2232,7 +2232,7 @@ public function test_query_include_returns_every_requested_post_without_per_page /** * An explicit `per_page` still paginates an include request. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_include_honors_an_explicit_per_page(): void { $this->login_as( 'administrator' ); @@ -2257,7 +2257,7 @@ public function test_query_include_honors_an_explicit_per_page(): void { /** * The include list is capped at the maximum page size. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_include_is_capped_at_the_maximum_page_size(): void { $this->login_as( 'administrator' ); @@ -2285,7 +2285,7 @@ public function test_query_include_is_capped_at_the_maximum_page_size(): void { * The rendered filter chains may read post meta, so priming avoids one lazy meta * query per returned row. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_rendered_fields_prime_the_post_meta_cache(): void { $this->login_as( 'administrator' ); @@ -2325,7 +2325,7 @@ static function () use ( $ids ) { * the request belongs to whoever runs the query, and it may prime more for its own * reasons, so counting queries here would describe that layer rather than this one. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_lean_projection_does_not_request_post_meta_priming(): void { $this->login_as( 'administrator' ); @@ -2367,7 +2367,7 @@ public function test_query_lean_projection_does_not_request_post_meta_priming(): * Counts during the call rather than checking the cache afterwards: the rendered * filter chains prime meta lazily, so an after-the-fact cache check passes either way. * - * @since 7.1.0 + * @since 7.2.0 * * @param callable $callback Callback to run. * @param mixed $result Set to the callback's return value. @@ -2399,7 +2399,7 @@ private function count_post_meta_queries( callable $callback, &$result ): int { /** * Query rows are kept, not dropped, when the requested fields project to nothing. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_query_keeps_posts_with_empty_field_projection(): void { $this->login_as( 'administrator' ); @@ -2426,7 +2426,7 @@ public function test_query_keeps_posts_with_empty_field_projection(): void { /** * A single post whose requested fields project to nothing is returned as an empty object. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_single_post_returns_empty_object_for_empty_field_projection(): void { $this->login_as( 'administrator' ); @@ -2446,7 +2446,7 @@ public function test_single_post_returns_empty_object_for_empty_field_projection /** * A single post fetched by ID is returned directly without query totals. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_single_post_returns_direct_post_object(): void { $this->login_as( 'administrator' ); @@ -2465,7 +2465,7 @@ public function test_single_post_returns_direct_post_object(): void { /** * Local and GMT date fields report the correct instant and offset on non-UTC sites. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_gmt_dates_are_utc_on_non_utc_sites(): void { $this->login_as( 'administrator' ); @@ -2509,7 +2509,7 @@ public function test_gmt_dates_are_utc_on_non_utc_sites(): void { /** * Drafts without a stored GMT date derive it from the local date and the site timezone. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_gmt_date_is_derived_from_local_date_for_drafts(): void { $this->login_as( 'administrator' ); @@ -2551,7 +2551,7 @@ public function test_gmt_date_is_derived_from_local_date_for_drafts(): void { * the ability from a filter or an in-memory row where a date is null or otherwise * differs from the database row. * - * @since 7.1.0 + * @since 7.2.0 * * @param int $post_id The post ID. * @param array $columns Post column values keyed by column name. @@ -2577,7 +2577,7 @@ private function replace_cached_post_date_columns( int $post_id, array $columns /** * Data provider for GMT date fields. * - * @since 7.1.0 + * @since 7.2.0 * * @return array */ @@ -2606,7 +2606,7 @@ public function data_gmt_date_fields(): array { * `strtotime( ' UTC' )` resolves to the current time, so an unguarded null would * report a fabricated "now" as the publication date. * - * @since 7.1.0 + * @since 7.2.0 * * @dataProvider data_gmt_date_fields * @@ -2654,7 +2654,7 @@ public function test_gmt_date_recovers_from_a_null_stored_gmt_date( string $fiel /** * A post with no usable date at all reports the documented empty-string sentinel. * - * @since 7.1.0 + * @since 7.2.0 * * @dataProvider data_gmt_date_fields * @@ -2695,7 +2695,7 @@ public function test_gmt_date_is_empty_when_no_usable_date_exists( string $field * denies the same lookups first. The registered callback still fails closed on * structural lookup errors when invoked directly. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_execute_callback_returns_not_found_for_structural_lookup_failures(): void { $this->login_as( 'administrator' ); @@ -2733,7 +2733,7 @@ public function test_execute_callback_returns_not_found_for_structural_lookup_fa * to 0, which WP_Query treats as "no author filter" — returning every author's * posts. The filter must fail closed instead of widening the result set. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_execute_callback_rejects_non_integer_author_filter(): void { $this->login_as( 'administrator' ); @@ -2758,7 +2758,7 @@ public function test_execute_callback_rejects_non_integer_author_filter(): void * cannot be detected by a numeric bound; it must be rejected on the raw value so * garbage does not silently become a top-level query. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_execute_callback_rejects_non_integer_parent_filter(): void { $this->login_as( 'administrator' ); @@ -2783,7 +2783,7 @@ public function test_execute_callback_rejects_non_integer_parent_filter(): void * would otherwise return every post of the type — the opposite of the caller's * intent. The filter must fail closed instead. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_execute_callback_rejects_include_with_no_valid_ids(): void { $this->login_as( 'administrator' ); @@ -2809,7 +2809,7 @@ public function test_execute_callback_rejects_include_with_no_valid_ids(): void * stricter filter parsing must accept those so it only rejects genuinely * unhonorable values, not well-formed ones. * - * @since 7.1.0 + * @since 7.2.0 */ public function test_execute_callback_honors_string_author_filter(): void { $author_a = self::$user_ids['author']; diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php index fe3e1181a9134..6238504bd9f82 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -43,7 +43,7 @@ class Tests_REST_API_WpRestAbilitiesContentController extends WP_UnitTestCase { /** * Sets up users and registers the core abilities. * - * @since 7.1.0 + * @since 7.2.0 */ public static function set_up_before_class(): void { parent::set_up_before_class(); @@ -70,7 +70,7 @@ public static function set_up_before_class(): void { /** * Cleans up registered abilities and categories. * - * @since 7.1.0 + * @since 7.2.0 */ public static function tear_down_after_class(): void { add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); From 88fb8c4926e8afa26667011e463bd6bf9ef9f1d6 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 00:05:54 +0100 Subject: [PATCH 18/69] Abilities API: Drop hook name phpcs ignores from the content query ability Core's coding standards do not run the `PrefixAllGlobals` sniff, so the ignores only separated each "This filter is documented in" reference from its `apply_filters()` call, which keeps the docs parser from linking them. --- src/wp-includes/abilities/class-wp-content-abilities.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index eca112c256126..bc91f32ff23a7 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -1394,11 +1394,9 @@ private function get_rendered_excerpt( WP_Post $post ): string { */ try { /** This filter is documented in wp-includes/post-template.php */ - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core excerpt filter to mirror REST rendering. $excerpt = apply_filters( 'get_the_excerpt', $post->post_excerpt, $post ); /** This filter is documented in wp-includes/post-template.php */ - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core excerpt filter to mirror REST rendering. $excerpt = apply_filters( 'the_excerpt', $excerpt ); return is_string( $excerpt ) ? $excerpt : ''; @@ -1439,7 +1437,6 @@ private function get_rendered_content( WP_Post $post ): string { */ try { /** This filter is documented in wp-includes/post-template.php */ - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core content filter to mirror REST rendering. $content = apply_filters( 'the_content', $post->post_content ); return is_string( $content ) ? $content : ''; From 5106f2eb1f9fafaf2fff58a780da2b5798114348 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 00:06:35 +0100 Subject: [PATCH 19/69] Tests: Add assertion messages to the content query REST tests --- .../wpRestAbilitiesContentController.php | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php index 6238504bd9f82..78723fef7870a 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -160,16 +160,16 @@ public function test_subscriber_requesting_published_posts_receives_readable_fie ); $data = $response->get_data(); - $this->assertSame( 200, $response->get_status() ); - $this->assertContains( $post_id, wp_list_pluck( $data['posts'], 'id' ) ); + $this->assertSame( 200, $response->get_status(), 'A subscriber should be able to query published posts.' ); + $this->assertContains( $post_id, wp_list_pluck( $data['posts'], 'id' ), 'The published post should be among the returned posts.' ); $post_index = array_search( $post_id, wp_list_pluck( $data['posts'], 'id' ), true ); - $this->assertIsInt( $post_index ); + $this->assertIsInt( $post_index, 'Precondition: the published post should have a position in the posts list.' ); $post = $data['posts'][ $post_index ]; - $this->assertSame( 'Published for subscriber via REST', $post['title_rendered'] ); - $this->assertStringContainsString( 'Subscriber REST body.', $post['content_rendered'] ); - $this->assertArrayNotHasKey( 'content_raw', $post ); + $this->assertSame( 'Published for subscriber via REST', $post['title_rendered'], 'The rendered title should be returned to a subscriber.' ); + $this->assertStringContainsString( 'Subscriber REST body.', $post['content_rendered'], 'The rendered content should be returned to a subscriber.' ); + $this->assertArrayNotHasKey( 'content_raw', $post, 'Raw content should not be returned to a subscriber.' ); } public function test_subscriber_requesting_raw_fields_receives_403(): void { @@ -198,9 +198,9 @@ public function test_admin_query_returns_published_posts(): void { $response = $this->server->dispatch( $this->run_request( array( 'post_type' => 'post' ) ) ); $data = $response->get_data(); - $this->assertSame( 200, $response->get_status() ); - $this->assertArrayHasKey( 'posts', $data ); - $this->assertContains( $post_id, wp_list_pluck( $data['posts'], 'id' ) ); + $this->assertSame( 200, $response->get_status(), 'An administrator should be able to query published posts.' ); + $this->assertArrayHasKey( 'posts', $data, 'Query mode should return a posts list.' ); + $this->assertContains( $post_id, wp_list_pluck( $data['posts'], 'id' ), 'The published post should be among the returned posts.' ); } public function test_admin_query_include_limits_results(): void { @@ -235,9 +235,9 @@ public function test_admin_query_include_limits_results(): void { ); $data = $response->get_data(); - $this->assertSame( 200, $response->get_status() ); - $this->assertSame( array( $third, $first ), wp_list_pluck( $data['posts'], 'id' ) ); - $this->assertNotContains( $second, wp_list_pluck( $data['posts'], 'id' ) ); + $this->assertSame( 200, $response->get_status(), 'The include query should succeed.' ); + $this->assertSame( array( $third, $first ), wp_list_pluck( $data['posts'], 'id' ), 'Included posts should be returned newest first, regardless of the include order.' ); + $this->assertNotContains( $second, wp_list_pluck( $data['posts'], 'id' ), 'Posts outside the include list should not be returned.' ); } public function test_get_single_post_by_id(): void { @@ -246,10 +246,10 @@ public function test_get_single_post_by_id(): void { $response = $this->server->dispatch( $this->run_request( array( 'id' => $post_id ) ) ); $data = $response->get_data(); - $this->assertSame( 200, $response->get_status() ); - $this->assertSame( $post_id, $data['id'] ); - $this->assertArrayNotHasKey( 'posts', $data ); - $this->assertArrayNotHasKey( 'total', $data ); + $this->assertSame( 200, $response->get_status(), 'The ID lookup should succeed.' ); + $this->assertSame( $post_id, $data['id'], 'The ID lookup should return the requested post.' ); + $this->assertArrayNotHasKey( 'posts', $data, 'A single post lookup should not return a posts list.' ); + $this->assertArrayNotHasKey( 'total', $data, 'A single post lookup should not return query totals.' ); } public function test_get_single_post_by_slug(): void { @@ -270,10 +270,10 @@ public function test_get_single_post_by_slug(): void { ); $data = $response->get_data(); - $this->assertSame( 200, $response->get_status() ); - $this->assertSame( $post_id, $data['id'] ); - $this->assertSame( 'rest-content-slug', $data['slug'] ); - $this->assertArrayNotHasKey( 'posts', $data ); + $this->assertSame( 200, $response->get_status(), 'The slug lookup should succeed.' ); + $this->assertSame( $post_id, $data['id'], 'The slug lookup should return the matching post.' ); + $this->assertSame( 'rest-content-slug', $data['slug'], 'The slug lookup should return the post slug.' ); + $this->assertArrayNotHasKey( 'posts', $data, 'A slug lookup should not return a posts list.' ); } public function test_wrong_http_method_returns_405(): void { @@ -283,8 +283,8 @@ public function test_wrong_http_method_returns_405(): void { $response = $this->server->dispatch( $request ); - $this->assertSame( 405, $response->get_status() ); - $this->assertSame( 'rest_ability_invalid_method', $response->get_data()['code'] ); + $this->assertSame( 405, $response->get_status(), 'A POST request to the read-only ability should be rejected.' ); + $this->assertSame( 'rest_ability_invalid_method', $response->get_data()['code'], 'The error should identify the invalid HTTP method.' ); } public function test_pagination_returns_totals_in_body(): void { @@ -301,10 +301,10 @@ public function test_pagination_returns_totals_in_body(): void { ); $data = $response->get_data(); - $this->assertSame( 200, $response->get_status() ); - $this->assertCount( 2, $data['posts'] ); - $this->assertGreaterThanOrEqual( 3, $data['total'] ); - $this->assertSame( (int) ceil( $data['total'] / 2 ), $data['total_pages'] ); + $this->assertSame( 200, $response->get_status(), 'The paginated query should succeed.' ); + $this->assertCount( 2, $data['posts'], 'The first page should be limited to per_page posts.' ); + $this->assertGreaterThanOrEqual( 3, $data['total'], 'The total should count posts across all pages.' ); + $this->assertSame( (int) ceil( $data['total'] / 2 ), $data['total_pages'], 'The total pages should match the total and per_page.' ); } public function test_out_of_range_page_returns_400(): void { @@ -320,7 +320,7 @@ public function test_out_of_range_page_returns_400(): void { ) ); - $this->assertSame( 400, $response->get_status() ); - $this->assertSame( 'content_invalid_page_number', $response->get_data()['code'] ); + $this->assertSame( 400, $response->get_status(), 'Requesting a page past the last one should return a 400 error.' ); + $this->assertSame( 'content_invalid_page_number', $response->get_data()['code'], 'The error should identify the invalid page number.' ); } } From bdade24cd68a81ac5b6588ea1c2bcb678a1af1e3 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 00:07:37 +0100 Subject: [PATCH 20/69] Tests: Stop popping the faked init actions in content query tests The test case restores `$wp_current_filter` after each test, so the helpers no longer need a `try`/`finally` to pop the faked action. --- .../wpRegisterCoreContentAbility.php | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 737d11168631e..c0a9b8fddc288 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -153,17 +153,13 @@ private function ensure_ability_category( string $slug ): void { global $wp_current_filter; $wp_current_filter[] = 'wp_abilities_api_categories_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. - try { - wp_register_ability_category( - $slug, - array( - 'label' => ucfirst( $slug ), - 'description' => ucfirst( $slug ) . '.', - ) - ); - } finally { - array_pop( $wp_current_filter ); - } + wp_register_ability_category( + $slug, + array( + 'label' => ucfirst( $slug ), + 'description' => ucfirst( $slug ) . '.', + ) + ); } /** @@ -174,11 +170,7 @@ private function ensure_ability_category( string $slug ): void { private function register_ability(): void { global $wp_current_filter; $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. - try { - ( new WP_Content_Abilities() )->register(); - } finally { - array_pop( $wp_current_filter ); - } + ( new WP_Content_Abilities() )->register(); } /** From c7acb0d910d35e81f6b1a28f272835966a153ee5 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 00:10:21 +0100 Subject: [PATCH 21/69] Tests: Rely on the test case to reset content query post types The test case unregisters non-default post types and statuses before each test, so the `try`/`finally` blocks that removed them by hand can go. The `tearDown()` reset of `show_in_abilities` on `post` and `page` stays: those built-in objects are only recreated in the next `set_up()`, after the next class's `set_up_before_class()` may already have registered the core abilities. --- .../wpRegisterCoreContentAbility.php | 242 ++++++++---------- 1 file changed, 105 insertions(+), 137 deletions(-) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index c0a9b8fddc288..468ca89aceed9 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -405,28 +405,24 @@ public function test_exposes_a_post_type_registered_by_another_plugin(): void { ) ); - try { - $this->login_as( 'administrator' ); - $this->register_ability(); + $this->login_as( 'administrator' ); + $this->register_ability(); - // Query mode is the third `oneOf` branch; its `post_type` enum lists exposed types. - $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; - $this->assertContains( 'wpai_content_cpt', $enum, 'Custom post types marked show_in_abilities should appear in the query enum.' ); + // Query mode is the third `oneOf` branch; its `post_type` enum lists exposed types. + $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; + $this->assertContains( 'wpai_content_cpt', $enum, 'Custom post types marked show_in_abilities should appear in the query enum.' ); - $post_id = self::factory()->post->create( - array( - 'post_type' => 'wpai_content_cpt', - 'post_status' => 'publish', - ) - ); + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wpai_content_cpt', + 'post_status' => 'publish', + ) + ); - $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'wpai_content_cpt' ) ); - $ids = wp_list_pluck( $result['posts'], 'id' ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'wpai_content_cpt' ) ); + $ids = wp_list_pluck( $result['posts'], 'id' ); - $this->assertContains( $post_id, $ids, 'The custom post type should be queryable through the content ability.' ); - } finally { - unregister_post_type( 'wpai_content_cpt' ); - } + $this->assertContains( $post_id, $ids, 'The custom post type should be queryable through the content ability.' ); } /** @@ -468,25 +464,21 @@ public function test_schema_filter_exposes_late_registered_post_type(): void { ) ); - try { - $post_id = self::factory()->post->create( - array( - 'post_type' => 'wpai_late_cpt', - 'post_status' => 'publish', - ) - ); + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wpai_late_cpt', + 'post_status' => 'publish', + ) + ); - $result = wp_get_ability( 'core/content-query' )->execute( - array( - 'post_type' => 'wpai_late_cpt', - 'fields' => array( 'id' ), - ) - ); + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'post_type' => 'wpai_late_cpt', + 'fields' => array( 'id' ), + ) + ); - $this->assertSame( array( $post_id ), wp_list_pluck( $result['posts'], 'id' ), 'The late post type should be queryable after it becomes exposed.' ); - } finally { - unregister_post_type( 'wpai_late_cpt' ); - } + $this->assertSame( array( $post_id ), wp_list_pluck( $result['posts'], 'id' ), 'The late post type should be queryable after it becomes exposed.' ); } /** @@ -619,26 +611,22 @@ public function test_get_by_id_for_unexposed_post_type_is_denied(): void { ) ); - try { - $this->login_as( 'administrator' ); + $this->login_as( 'administrator' ); - $post_id = self::factory()->post->create( - array( - 'post_type' => 'wpai_hidden_cpt', - 'post_status' => 'publish', - ) - ); - $this->assertGreaterThan( 0, $post_id, 'The hidden custom post should be created for the denial check.' ); + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wpai_hidden_cpt', + 'post_status' => 'publish', + ) + ); + $this->assertGreaterThan( 0, $post_id, 'The hidden custom post should be created for the denial check.' ); - $this->register_ability(); + $this->register_ability(); - $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); - $this->assertWPError( $result, 'Posts from unexposed post types should be denied.' ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Unexposed post types should fail closed as a permission error.' ); - } finally { - unregister_post_type( 'wpai_hidden_cpt' ); - } + $this->assertWPError( $result, 'Posts from unexposed post types should be denied.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Unexposed post types should fail closed as a permission error.' ); } /** @@ -656,23 +644,19 @@ public function test_public_non_viewable_status_is_denied_for_read_only_users(): ) ); - try { - $post_id = self::factory()->post->create( - array( - 'post_status' => 'wpai_public_hidden', - ) - ); + $post_id = self::factory()->post->create( + array( + 'post_status' => 'wpai_public_hidden', + ) + ); - $this->login_as( 'subscriber' ); - $this->register_ability(); + $this->login_as( 'subscriber' ); + $this->register_ability(); - $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); - $this->assertWPError( $result, 'Read-only users should not receive public statuses that are not viewable.' ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Non-viewable public statuses should fail closed for read-only users.' ); - } finally { - unset( $GLOBALS['wp_post_statuses']['wpai_public_hidden'] ); - } + $this->assertWPError( $result, 'Read-only users should not receive public statuses that are not viewable.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Non-viewable public statuses should fail closed for read-only users.' ); } /** @@ -690,25 +674,21 @@ public function test_public_non_viewable_status_is_readable_with_edit_access(): ) ); - try { - $post_id = self::factory()->post->create( - array( - 'post_title' => 'Hidden public status', - 'post_status' => 'wpai_public_hidden', - ) - ); + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Hidden public status', + 'post_status' => 'wpai_public_hidden', + ) + ); - $this->login_as( 'administrator' ); - $this->register_ability(); + $this->login_as( 'administrator' ); + $this->register_ability(); - $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); - $this->assertIsArray( $result, 'Editors should be able to access posts they can edit even when the status is not publicly viewable.' ); - $this->assertSame( $post_id, $result['id'], 'The editable post should be returned.' ); - $this->assertSame( 'Hidden public status', $result['title_rendered'], 'The editable post should include normal default fields.' ); - } finally { - unset( $GLOBALS['wp_post_statuses']['wpai_public_hidden'] ); - } + $this->assertIsArray( $result, 'Editors should be able to access posts they can edit even when the status is not publicly viewable.' ); + $this->assertSame( $post_id, $result['id'], 'The editable post should be returned.' ); + $this->assertSame( 'Hidden public status', $result['title_rendered'], 'The editable post should include normal default fields.' ); } /** @@ -726,35 +706,31 @@ public function test_inherited_post_is_readable_when_parent_is_readable(): void ) ); - try { - $parent_id = self::factory()->post->create( - array( - 'post_author' => self::$user_ids['administrator'], - 'post_type' => 'wpai_inherit_cpt', - 'post_status' => 'publish', - ) - ); - $child_id = self::factory()->post->create( - array( - 'post_author' => self::$user_ids['administrator'], - 'post_type' => 'wpai_inherit_cpt', - 'post_parent' => $parent_id, - 'post_status' => 'inherit', - 'post_title' => 'Inherited child', - ) - ); + $parent_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_type' => 'wpai_inherit_cpt', + 'post_status' => 'publish', + ) + ); + $child_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_type' => 'wpai_inherit_cpt', + 'post_parent' => $parent_id, + 'post_status' => 'inherit', + 'post_title' => 'Inherited child', + ) + ); - $this->login_as( 'subscriber' ); - $this->register_ability(); + $this->login_as( 'subscriber' ); + $this->register_ability(); - $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $child_id ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $child_id ) ); - $this->assertIsArray( $result, 'Inherited posts should be readable when their parent is readable.' ); - $this->assertSame( $child_id, $result['id'], 'The inherited child should be returned.' ); - $this->assertSame( 'Inherited child', $result['title_rendered'], 'The inherited child should include normal default fields.' ); - } finally { - unregister_post_type( 'wpai_inherit_cpt' ); - } + $this->assertIsArray( $result, 'Inherited posts should be readable when their parent is readable.' ); + $this->assertSame( $child_id, $result['id'], 'The inherited child should be returned.' ); + $this->assertSame( 'Inherited child', $result['title_rendered'], 'The inherited child should include normal default fields.' ); } /** @@ -772,25 +748,21 @@ public function test_inherited_post_without_parent_is_denied_for_read_only_users ) ); - try { - $post_id = self::factory()->post->create( - array( - 'post_author' => self::$user_ids['administrator'], - 'post_type' => 'wpai_inherit_cpt', - 'post_status' => 'inherit', - ) - ); + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_type' => 'wpai_inherit_cpt', + 'post_status' => 'inherit', + ) + ); - $this->login_as( 'subscriber' ); - $this->register_ability(); + $this->login_as( 'subscriber' ); + $this->register_ability(); - $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); - $this->assertWPError( $result, 'Inherited posts without a readable parent should be denied.' ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Orphaned inherited posts should fail closed.' ); - } finally { - unregister_post_type( 'wpai_inherit_cpt' ); - } + $this->assertWPError( $result, 'Inherited posts without a readable parent should be denied.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Orphaned inherited posts should fail closed.' ); } /** @@ -1679,22 +1651,18 @@ public function test_query_mode_rejects_author_filter_for_post_type_without_auth ) ); - try { - $this->login_as( 'administrator' ); - $this->register_ability(); + $this->login_as( 'administrator' ); + $this->register_ability(); - $result = wp_get_ability( 'core/content-query' )->execute( - array( - 'post_type' => 'wpai_no_author_cpt', - 'author' => self::$user_ids['author'], - ) - ); + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'post_type' => 'wpai_no_author_cpt', + 'author' => self::$user_ids['author'], + ) + ); - $this->assertWPError( $result, 'The author filter should be rejected for post types without author support.' ); - $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'Unsupported author filters should return a filter error.' ); - } finally { - unregister_post_type( 'wpai_no_author_cpt' ); - } + $this->assertWPError( $result, 'The author filter should be rejected for post types without author support.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'Unsupported author filters should return a filter error.' ); } /** From f8943de35cb495b306c941cbd17de566f06ade8d Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 00:11:08 +0100 Subject: [PATCH 22/69] Abilities API: Use the multi-line comment format in the content query ability --- .../abilities/class-wp-content-abilities.php | 18 +++++++++----- .../wpRegisterCoreContentAbility.php | 24 ++++++++++++------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index bc91f32ff23a7..3b46a00efaa7b 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -162,8 +162,10 @@ private function register_content_query(): void { 'readonly' => true, 'destructive' => false, 'idempotent' => true, - // MCP clients assume open-world (may reach external systems) when the - // hint is absent; this ability only reads the local database. + /* + * MCP clients assume open-world (may reach external systems) when the + * hint is absent; this ability only reads the local database. + */ 'open_world' => false, ), 'public' => true, @@ -569,8 +571,10 @@ public function execute_content_query( $input = array() ) { $prime_post_caches = $this->should_prime_post_caches( $fields ); - // `orderby` is left unset, which orders by `post_date` descending, matching the - // default of the REST posts controller. + /* + * `orderby` is left unset, which orders by `post_date` descending, matching the + * default of the REST posts controller. + */ $query_args = array( 'post_type' => $post_type, 'post_status' => $this->normalize_statuses( $input ), @@ -833,8 +837,10 @@ private function normalize_include( array $input ): array { return array(); } - // A GET request delivers list inputs as scalar/CSV strings; wp_parse_id_list() - // accepts both and yields unique positive IDs, matching schema validation. + /* + * A GET request delivers list inputs as scalar/CSV strings; wp_parse_id_list() + * accepts both and yields unique positive IDs, matching schema validation. + */ return array_values( array_filter( wp_parse_id_list( $include ) ) ); } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 468ca89aceed9..dc8be2b6fa059 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -1052,8 +1052,10 @@ public function test_get_single_published_post_by_slug_can_return_content_fields public function test_slug_lookup_is_not_bounded_by_a_page_size(): void { global $wpdb; - // Author every post as the administrator, so the subscriber who reads them below can - // only see the published one. + /* + * Author every post as the administrator, so the subscriber who reads them below can + * only see the published one. + */ $this->login_as( 'administrator' ); // The published post owns the slug and is the oldest of the group. @@ -1065,8 +1067,10 @@ public function test_slug_lookup_is_not_bounded_by_a_page_size(): void { ) ); - // Drafts skip slug uniqueness, so they can all share the slug. Create more of them - // than the largest page the ability will ever return. + /* + * Drafts skip slug uniqueness, so they can all share the slug. Create more of them + * than the largest page the ability will ever return. + */ for ( $i = 0; $i < 110; $i++ ) { self::factory()->post->create( array( @@ -1104,8 +1108,10 @@ public function test_get_single_post_by_slug_zero(): void { $this->login_as( 'administrator' ); $this->register_ability(); - // Core regenerates an "empty" post_name from the title, so a post titled "0" - // ends up with the literal slug "0". + /* + * Core regenerates an "empty" post_name from the title, so a post titled "0" + * ends up with the literal slug "0". + */ $post_id = self::factory()->post->create( array( 'post_title' => '0', @@ -2365,8 +2371,10 @@ public function test_query_keeps_posts_with_empty_field_projection(): void { $this->login_as( 'administrator' ); $this->register_ability(); - // `parent` never applies to the non-hierarchical `post` type, so every row - // projects to an empty object. + /* + * `parent` never applies to the non-hierarchical `post` type, so every row + * projects to an empty object. + */ $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', From da628688c55fef18cd9f1a11e89d71688df0afff Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 00:11:38 +0100 Subject: [PATCH 23/69] Abilities API: Unset no_found_rows in the content query count query --- src/wp-includes/abilities/class-wp-content-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 3b46a00efaa7b..7fe817a984f3f 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -707,7 +707,7 @@ private function get_query_total( WP_Query $query, array $query_args, int $page $count_args['posts_per_page'] = 1; $count_args['update_post_meta_cache'] = false; $count_args['update_post_term_cache'] = false; - unset( $count_args['paged'] ); + unset( $count_args['paged'], $count_args['no_found_rows'] ); $count_query = new WP_Query( $count_args ); From 674daef4eb06279f9d755ab1d3713af8c8d5cd89 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 00:12:58 +0100 Subject: [PATCH 24/69] Tests: Add the Trac ticket to the content query tests --- .../wpRegisterCoreContentAbility.php | 82 +++++++++++++++++++ .../wpRestAbilitiesContentController.php | 33 ++++++++ 2 files changed, 115 insertions(+) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index dc8be2b6fa059..daa71fc689bca 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -207,6 +207,7 @@ public function data_roles_without_edit_access_to_other_users_posts(): array { /** * The ability is registered in the `content` category and flagged read-only. * + * @ticket 64606 * @since 7.2.0 */ public function test_registers_core_content_query_ability(): void { @@ -230,6 +231,7 @@ public function test_registers_core_content_query_ability(): void { /** * The content ability is not registered when no post types are exposed to it. * + * @ticket 64606 * @since 7.2.0 */ public function test_does_not_register_core_content_query_ability_without_exposed_post_types(): void { @@ -249,6 +251,7 @@ public function test_does_not_register_core_content_query_ability_without_expose * The input schema models mutually exclusive ID, slug, and query modes, each * rejecting the other modes' properties and exposing only marked types. * + * @ticket 64606 * @since 7.2.0 */ public function test_input_schema_models_mutually_exclusive_modes(): void { @@ -299,6 +302,7 @@ public function test_input_schema_models_mutually_exclusive_modes(): void { * Branch-local defaults are omitted so the schema can compile in the client-side * Abilities API validator. Runtime defaults are still applied by the ability. * + * @ticket 64606 * @since 7.2.0 */ public function test_input_schema_omits_oneof_branch_defaults(): void { @@ -316,6 +320,7 @@ public function test_input_schema_omits_oneof_branch_defaults(): void { * Query-mode filters cannot be combined with a by-ID lookup: passing `per_page` alongside * `id` is rejected outright rather than silently ignored. * + * @ticket 64606 * @since 7.2.0 */ public function test_id_mode_rejects_query_only_params(): void { @@ -336,6 +341,7 @@ public function test_id_mode_rejects_query_only_params(): void { /** * `post_type` is accepted alongside `id` as a guard: the by-ID mode still resolves the post. * + * @ticket 64606 * @since 7.2.0 */ public function test_id_mode_accepts_post_type_guard(): void { @@ -359,6 +365,7 @@ public function test_id_mode_accepts_post_type_guard(): void { /** * The output schema describes single-post and query response shapes. * + * @ticket 64606 * @since 7.2.0 */ public function test_output_schema_describes_single_post_and_query_responses(): void { @@ -393,6 +400,7 @@ public function test_output_schema_describes_single_post_and_query_responses(): * A post type registered by another active plugin and flagged `show_in_abilities` * is exposed by the ability, both in the input enum and in query results. * + * @ticket 64606 * @since 7.2.0 */ public function test_exposes_a_post_type_registered_by_another_plugin(): void { @@ -428,6 +436,7 @@ public function test_exposes_a_post_type_registered_by_another_plugin(): void { /** * A schema filter can expose a post type that is registered after the ability. * + * @ticket 64606 * @since 7.2.0 */ public function test_schema_filter_exposes_late_registered_post_type(): void { @@ -484,6 +493,7 @@ public function test_schema_filter_exposes_late_registered_post_type(): void { /** * A published post can be fetched by ID. * + * @ticket 64606 * @since 7.2.0 */ public function test_get_single_published_post_by_id(): void { @@ -511,6 +521,7 @@ public function test_get_single_published_post_by_id(): void { * WP_Ability validates `stdClass` as object input but does not coerce the value before * passing it to the permission and execute callbacks, so both must preserve its fields. * + * @ticket 64606 * @since 7.2.0 */ public function test_get_single_published_post_by_id_accepts_object_input(): void { @@ -537,6 +548,7 @@ public function test_get_single_published_post_by_id_accepts_object_input(): voi /** * A single post fetched by ID can return explicitly requested rendered and raw content. * + * @ticket 64606 * @since 7.2.0 */ public function test_get_single_published_post_by_id_can_return_content_fields(): void { @@ -562,6 +574,7 @@ public function test_get_single_published_post_by_id_can_return_content_fields() /** * A missing post ID is denied before execution can probe the requested object. * + * @ticket 64606 * @since 7.2.0 */ public function test_get_by_missing_id_is_denied(): void { @@ -577,6 +590,7 @@ public function test_get_by_missing_id_is_denied(): void { /** * A post type guard mismatch is denied before execution can probe the requested object. * + * @ticket 64606 * @since 7.2.0 */ public function test_get_by_id_with_mismatched_post_type_is_denied(): void { @@ -599,6 +613,7 @@ public function test_get_by_id_with_mismatched_post_type_is_denied(): void { /** * A post from a post type not exposed to abilities is denied. * + * @ticket 64606 * @since 7.2.0 */ public function test_get_by_id_for_unexposed_post_type_is_denied(): void { @@ -632,6 +647,7 @@ public function test_get_by_id_for_unexposed_post_type_is_denied(): void { /** * A status that is public but not viewable is not exposed to read-only users. * + * @ticket 64606 * @since 7.2.0 */ public function test_public_non_viewable_status_is_denied_for_read_only_users(): void { @@ -662,6 +678,7 @@ public function test_public_non_viewable_status_is_denied_for_read_only_users(): /** * A status that is public but not viewable remains available to users who can edit it. * + * @ticket 64606 * @since 7.2.0 */ public function test_public_non_viewable_status_is_readable_with_edit_access(): void { @@ -694,6 +711,7 @@ public function test_public_non_viewable_status_is_readable_with_edit_access(): /** * A post that inherits its status from a readable parent is readable. * + * @ticket 64606 * @since 7.2.0 */ public function test_inherited_post_is_readable_when_parent_is_readable(): void { @@ -736,6 +754,7 @@ public function test_inherited_post_is_readable_when_parent_is_readable(): void /** * A post with an inherited status but no readable parent is denied. * + * @ticket 64606 * @since 7.2.0 */ public function test_inherited_post_without_parent_is_denied_for_read_only_users(): void { @@ -768,6 +787,7 @@ public function test_inherited_post_without_parent_is_denied_for_read_only_users /** * Query mode returns only published posts by default. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_returns_only_published_by_default(): void { @@ -787,6 +807,7 @@ public function test_query_returns_only_published_by_default(): void { /** * Query mode can limit results to included IDs. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_include_limits_results(): void { @@ -821,6 +842,7 @@ public function test_query_include_limits_results(): void { * WP_Query default, matching the REST posts controller, and `include` only filters the * query, so a caller that passes IDs in a chosen order must not expect them back in it. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_orders_posts_newest_first_regardless_of_include_order(): void { @@ -865,6 +887,7 @@ public function test_query_orders_posts_newest_first_regardless_of_include_order /** * Query include still respects the requested post type. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_include_respects_requested_post_type(): void { @@ -893,6 +916,7 @@ public function test_query_include_respects_requested_post_type(): void { /** * Query include still respects row-level permissions. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_include_respects_row_level_permissions(): void { @@ -930,6 +954,7 @@ public function test_query_include_respects_row_level_permissions(): void { /** * Query mode can return included drafts with explicitly requested rendered and raw content. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_draft_include_can_return_content_fields(): void { @@ -963,6 +988,7 @@ public function test_query_draft_include_can_return_content_fields(): void { /** * Querying by slug without a post type is rejected by the input schema. * + * @ticket 64606 * @since 7.2.0 */ public function test_slug_mode_requires_post_type(): void { @@ -978,6 +1004,7 @@ public function test_slug_mode_requires_post_type(): void { /** * Slug mode returns a single post directly when paired with a post type. * + * @ticket 64606 * @since 7.2.0 */ public function test_get_single_published_post_by_slug(): void { @@ -1009,6 +1036,7 @@ public function test_get_single_published_post_by_slug(): void { /** * A single post fetched by slug can return explicitly requested rendered and raw content. * + * @ticket 64606 * @since 7.2.0 */ public function test_get_single_published_post_by_slug_can_return_content_fields(): void { @@ -1047,6 +1075,7 @@ public function test_get_single_published_post_by_slug_can_return_content_fields * size applies. Bounding it with `post_name__in` and a page size would page straight past * an older published post, because the query is ordered newest first. * + * @ticket 64606 * @since 7.2.0 */ public function test_slug_lookup_is_not_bounded_by_a_page_size(): void { @@ -1102,6 +1131,7 @@ public function test_slug_lookup_is_not_bounded_by_a_page_size(): void { /** * A post whose slug is the literal string "0" is fetched in single-post slug mode. * + * @ticket 64606 * @since 7.2.0 */ public function test_get_single_post_by_slug_zero(): void { @@ -1137,6 +1167,7 @@ public function test_get_single_post_by_slug_zero(): void { /** * Query-only filters cannot be combined with slug mode. * + * @ticket 64606 * @since 7.2.0 */ public function test_slug_mode_rejects_query_only_params(): void { @@ -1158,6 +1189,7 @@ public function test_slug_mode_rejects_query_only_params(): void { /** * A newer draft sharing a published post's slug does not shadow the published post. * + * @ticket 64606 * @since 7.2.0 */ public function test_slug_lookup_prefers_published_post_over_newer_draft(): void { @@ -1199,6 +1231,7 @@ public function test_slug_lookup_prefers_published_post_over_newer_draft(): void /** * A slug held only by a draft resolves for its author and stays denied for readers. * + * @ticket 64606 * @since 7.2.0 */ public function test_slug_lookup_resolves_draft_only_slug_by_readability(): void { @@ -1240,6 +1273,7 @@ public function test_slug_lookup_resolves_draft_only_slug_by_readability(): void /** * Include is a query-only option and cannot be combined with single-post modes. * + * @ticket 64606 * @since 7.2.0 */ public function test_include_cannot_be_combined_with_single_post_modes(): void { @@ -1269,6 +1303,7 @@ public function test_include_cannot_be_combined_with_single_post_modes(): void { /** * The `fields` filter limits the returned keys. * + * @ticket 64606 * @since 7.2.0 */ public function test_fields_filter_limits_returned_keys(): void { @@ -1297,6 +1332,7 @@ public function test_fields_filter_limits_returned_keys(): void { * Unlike fields a post type does not support, which are omitted per post, a field * name that is not part of the supported set is rejected before the ability executes. * + * @ticket 64606 * @since 7.2.0 */ public function test_unknown_requested_field_fails_schema_validation(): void { @@ -1317,6 +1353,7 @@ public function test_unknown_requested_field_fails_schema_validation(): void { /** * Logged-out users cannot run the ability. * + * @ticket 64606 * @since 7.2.0 */ public function test_logged_out_user_is_denied(): void { @@ -1332,6 +1369,7 @@ public function test_logged_out_user_is_denied(): void { /** * Subscribers can request rendered published content. * + * @ticket 64606 * @since 7.2.0 */ public function test_subscriber_can_request_published_content(): void { @@ -1360,6 +1398,7 @@ public function test_subscriber_can_request_published_content(): void { /** * Subscribers can fetch a published post by ID. * + * @ticket 64606 * @since 7.2.0 */ public function test_subscriber_can_get_single_published_post_by_id(): void { @@ -1380,6 +1419,7 @@ public function test_subscriber_can_get_single_published_post_by_id(): void { /** * Subscribers cannot request edit-context raw fields in query mode. * + * @ticket 64606 * @since 7.2.0 */ public function test_subscriber_cannot_request_raw_fields_in_query_mode(): void { @@ -1400,6 +1440,7 @@ public function test_subscriber_cannot_request_raw_fields_in_query_mode(): void /** * Subscribers cannot request edit-context raw fields for a single post. * + * @ticket 64606 * @since 7.2.0 */ public function test_subscriber_cannot_request_raw_fields_for_single_post(): void { @@ -1422,6 +1463,7 @@ public function test_subscriber_cannot_request_raw_fields_for_single_post(): voi /** * Users who cannot edit another user's post do not receive raw fields by default. * + * @ticket 64606 * @dataProvider data_roles_without_edit_access_to_other_users_posts * * @param string $role The role to test. @@ -1445,6 +1487,7 @@ public function test_default_fields_omit_raw_fields_for_roles_without_edit_acces /** * Users who cannot edit another user's post cannot explicitly request raw fields. * + * @ticket 64606 * @dataProvider data_roles_without_edit_access_to_other_users_posts * * @param string $role The role to test. @@ -1469,6 +1512,7 @@ public function test_raw_field_requests_are_denied_for_roles_without_edit_access /** * Subscribers cannot request draft posts. * + * @ticket 64606 * @since 7.2.0 */ public function test_subscriber_cannot_request_draft_status(): void { @@ -1489,6 +1533,7 @@ public function test_subscriber_cannot_request_draft_status(): void { /** * Subscribers cannot request private posts. * + * @ticket 64606 * @since 7.2.0 */ public function test_subscriber_cannot_request_private_status(): void { @@ -1509,6 +1554,7 @@ public function test_subscriber_cannot_request_private_status(): void { /** * An author can pass the draft gate but only sees their own drafts. * + * @ticket 64606 * @since 7.2.0 */ public function test_author_cannot_see_other_authors_drafts(): void { @@ -1549,6 +1595,7 @@ public function test_author_cannot_see_other_authors_drafts(): void { * This matches the REST posts controller: `posts` only contains rows the current * user can read, while `total` and `total_pages` describe the underlying query. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_totals_may_include_rows_withheld_by_row_level_permissions(): void { @@ -1591,6 +1638,7 @@ public function test_query_totals_may_include_rows_withheld_by_row_level_permiss /** * The parent filter is rejected for non-hierarchical post types, mirroring REST. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_mode_rejects_parent_filter_for_non_hierarchical_post_type(): void { @@ -1611,6 +1659,7 @@ public function test_query_mode_rejects_parent_filter_for_non_hierarchical_post_ /** * The parent filter narrows hierarchical queries to children of the given post. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_mode_filters_pages_by_parent(): void { @@ -1645,6 +1694,7 @@ public function test_query_mode_filters_pages_by_parent(): void { /** * The author filter is rejected for post types without author support, mirroring REST. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_mode_rejects_author_filter_for_post_type_without_author_support(): void { @@ -1674,6 +1724,7 @@ public function test_query_mode_rejects_author_filter_for_post_type_without_auth /** * The author filter narrows queries to posts by the given author. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_mode_filters_posts_by_author(): void { @@ -1709,6 +1760,7 @@ public function test_query_mode_filters_posts_by_author(): void { /** * Raw content is available to users who can edit the post. * + * @ticket 64606 * @since 7.2.0 */ public function test_raw_content_visible_to_editor(): void { @@ -1734,6 +1786,7 @@ public function test_raw_content_visible_to_editor(): void { /** * Password-protected content is visible to users who can edit the post. * + * @ticket 64606 * @since 7.2.0 */ public function test_password_protected_content_visible_to_editor(): void { @@ -1764,6 +1817,7 @@ public function test_password_protected_content_visible_to_editor(): void { /** * Password-protected rendered content is withheld from users who cannot edit the post. * + * @ticket 64606 * @dataProvider data_roles_without_edit_access_to_other_users_posts * * @param string $role The role to test. @@ -1788,6 +1842,7 @@ public function test_password_protected_rendered_content_is_empty_for_roles_with /** * Password-protected excerpts render for users who can edit the post. * + * @ticket 64606 * @since 7.2.0 */ public function test_password_protected_excerpt_visible_to_editor(): void { @@ -1820,6 +1875,7 @@ public function test_password_protected_excerpt_visible_to_editor(): void { /** * Password-protected rendered excerpts are withheld from users who cannot edit the post. * + * @ticket 64606 * @since 7.2.0 */ public function test_password_protected_rendered_excerpt_is_empty_for_subscriber(): void { @@ -1849,6 +1905,7 @@ public function test_password_protected_rendered_excerpt_is_empty_for_subscriber /** * Rendered excerpts carry the REST API's `the_excerpt` markup (paragraph wrapping). * + * @ticket 64606 * @since 7.2.0 */ public function test_excerpt_rendered_applies_the_excerpt_filters(): void { @@ -1873,6 +1930,7 @@ public function test_excerpt_rendered_applies_the_excerpt_filters(): void { * Rendered excerpt filters run with the requested post as the global context and restore * the context that was active before the ability executed. * + * @ticket 64606 * @since 7.2.0 */ public function test_excerpt_rendered_uses_and_restores_requested_post_context(): void { @@ -1930,6 +1988,7 @@ public function test_excerpt_rendered_uses_and_restores_requested_post_context() /** * The password gate is suspended only for posts the current user can edit. * + * @ticket 64606 * @since 7.2.0 */ public function test_allow_password_content_only_unlocks_editable_posts(): void { @@ -1964,6 +2023,7 @@ public function test_allow_password_content_only_unlocks_editable_posts(): void * Rendering an editable protected post must not unlock other protected posts embedded in * its content (e.g. through a shortcode or Query Loop block). * + * @ticket 64606 * @since 7.2.0 */ public function test_password_filter_does_not_leak_other_protected_posts(): void { @@ -2024,6 +2084,7 @@ static function ( $atts ): string { /** * Query mode paginates with `page`/`per_page` and reports totals. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_paginates_and_reports_totals(): void { @@ -2059,6 +2120,7 @@ public function test_query_paginates_and_reports_totals(): void { /** * Query mode reports a total that matches the returned posts for an uncapped query. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_total_matches_returned_posts_when_uncapped(): void { @@ -2083,6 +2145,7 @@ public function test_query_total_matches_returned_posts_when_uncapped(): void { * Guards the boundary next to the out-of-range page error: the final page must not be * mistaken for an overshoot. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_last_page_reports_totals(): void { @@ -2114,6 +2177,7 @@ public function test_query_last_page_reports_totals(): void { * which is indistinguishable from an empty collection. Match the REST posts controller * by reporting this as a caller error instead. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_out_of_range_page_is_rejected_rather_than_reported_as_empty(): void { @@ -2142,6 +2206,7 @@ public function test_query_out_of_range_page_is_rejected_rather_than_reported_as * * The out-of-range guard only fires when the underlying query actually matched rows. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_empty_result_beyond_first_page_reports_zero_totals(): void { @@ -2169,6 +2234,7 @@ public function test_query_empty_result_beyond_first_page_reports_zero_totals(): * Without this the default page size silently truncates a batch load: a caller asking * for a known set of IDs would receive only the first `per_page` of them. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_include_returns_every_requested_post_without_per_page(): void { @@ -2198,6 +2264,7 @@ public function test_query_include_returns_every_requested_post_without_per_page /** * An explicit `per_page` still paginates an include request. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_include_honors_an_explicit_per_page(): void { @@ -2223,6 +2290,7 @@ public function test_query_include_honors_an_explicit_per_page(): void { /** * The include list is capped at the maximum page size. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_include_is_capped_at_the_maximum_page_size(): void { @@ -2251,6 +2319,7 @@ public function test_query_include_is_capped_at_the_maximum_page_size(): void { * The rendered filter chains may read post meta, so priming avoids one lazy meta * query per returned row. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_rendered_fields_prime_the_post_meta_cache(): void { @@ -2291,6 +2360,7 @@ static function () use ( $ids ) { * the request belongs to whoever runs the query, and it may prime more for its own * reasons, so counting queries here would describe that layer rather than this one. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_lean_projection_does_not_request_post_meta_priming(): void { @@ -2365,6 +2435,7 @@ private function count_post_meta_queries( callable $callback, &$result ): int { /** * Query rows are kept, not dropped, when the requested fields project to nothing. * + * @ticket 64606 * @since 7.2.0 */ public function test_query_keeps_posts_with_empty_field_projection(): void { @@ -2394,6 +2465,7 @@ public function test_query_keeps_posts_with_empty_field_projection(): void { /** * A single post whose requested fields project to nothing is returned as an empty object. * + * @ticket 64606 * @since 7.2.0 */ public function test_single_post_returns_empty_object_for_empty_field_projection(): void { @@ -2414,6 +2486,7 @@ public function test_single_post_returns_empty_object_for_empty_field_projection /** * A single post fetched by ID is returned directly without query totals. * + * @ticket 64606 * @since 7.2.0 */ public function test_single_post_returns_direct_post_object(): void { @@ -2433,6 +2506,7 @@ public function test_single_post_returns_direct_post_object(): void { /** * Local and GMT date fields report the correct instant and offset on non-UTC sites. * + * @ticket 64606 * @since 7.2.0 */ public function test_gmt_dates_are_utc_on_non_utc_sites(): void { @@ -2477,6 +2551,7 @@ public function test_gmt_dates_are_utc_on_non_utc_sites(): void { /** * Drafts without a stored GMT date derive it from the local date and the site timezone. * + * @ticket 64606 * @since 7.2.0 */ public function test_gmt_date_is_derived_from_local_date_for_drafts(): void { @@ -2574,6 +2649,7 @@ public function data_gmt_date_fields(): array { * `strtotime( ' UTC' )` resolves to the current time, so an unguarded null would * report a fabricated "now" as the publication date. * + * @ticket 64606 * @since 7.2.0 * * @dataProvider data_gmt_date_fields @@ -2622,6 +2698,7 @@ public function test_gmt_date_recovers_from_a_null_stored_gmt_date( string $fiel /** * A post with no usable date at all reports the documented empty-string sentinel. * + * @ticket 64606 * @since 7.2.0 * * @dataProvider data_gmt_date_fields @@ -2663,6 +2740,7 @@ public function test_gmt_date_is_empty_when_no_usable_date_exists( string $field * denies the same lookups first. The registered callback still fails closed on * structural lookup errors when invoked directly. * + * @ticket 64606 * @since 7.2.0 */ public function test_execute_callback_returns_not_found_for_structural_lookup_failures(): void { @@ -2701,6 +2779,7 @@ public function test_execute_callback_returns_not_found_for_structural_lookup_fa * to 0, which WP_Query treats as "no author filter" — returning every author's * posts. The filter must fail closed instead of widening the result set. * + * @ticket 64606 * @since 7.2.0 */ public function test_execute_callback_rejects_non_integer_author_filter(): void { @@ -2726,6 +2805,7 @@ public function test_execute_callback_rejects_non_integer_author_filter(): void * cannot be detected by a numeric bound; it must be rejected on the raw value so * garbage does not silently become a top-level query. * + * @ticket 64606 * @since 7.2.0 */ public function test_execute_callback_rejects_non_integer_parent_filter(): void { @@ -2751,6 +2831,7 @@ public function test_execute_callback_rejects_non_integer_parent_filter(): void * would otherwise return every post of the type — the opposite of the caller's * intent. The filter must fail closed instead. * + * @ticket 64606 * @since 7.2.0 */ public function test_execute_callback_rejects_include_with_no_valid_ids(): void { @@ -2777,6 +2858,7 @@ public function test_execute_callback_rejects_include_with_no_valid_ids(): void * stricter filter parsing must accept those so it only rejects genuinely * unhonorable values, not well-formed ones. * + * @ticket 64606 * @since 7.2.0 */ public function test_execute_callback_honors_string_author_filter(): void { diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php index 78723fef7870a..4abe778a9b16b 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -116,6 +116,9 @@ private function run_request( array $input ): WP_REST_Request { return $request; } + /** + * @ticket 64606 + */ public function test_logged_out_user_receives_401(): void { wp_set_current_user( 0 ); @@ -124,6 +127,9 @@ public function test_logged_out_user_receives_401(): void { $this->assertSame( 401, $response->get_status() ); } + /** + * @ticket 64606 + */ public function test_subscriber_requesting_drafts_receives_403(): void { wp_set_current_user( self::$subscriber_id ); @@ -139,6 +145,9 @@ public function test_subscriber_requesting_drafts_receives_403(): void { $this->assertSame( 403, $response->get_status() ); } + /** + * @ticket 64606 + */ public function test_subscriber_requesting_published_posts_receives_readable_fields(): void { $post_id = self::factory()->post->create( array( @@ -172,6 +181,9 @@ public function test_subscriber_requesting_published_posts_receives_readable_fie $this->assertArrayNotHasKey( 'content_raw', $post, 'Raw content should not be returned to a subscriber.' ); } + /** + * @ticket 64606 + */ public function test_subscriber_requesting_raw_fields_receives_403(): void { wp_set_current_user( self::$subscriber_id ); @@ -187,6 +199,9 @@ public function test_subscriber_requesting_raw_fields_receives_403(): void { $this->assertSame( 403, $response->get_status() ); } + /** + * @ticket 64606 + */ public function test_admin_query_returns_published_posts(): void { $post_id = self::factory()->post->create( array( @@ -203,6 +218,9 @@ public function test_admin_query_returns_published_posts(): void { $this->assertContains( $post_id, wp_list_pluck( $data['posts'], 'id' ), 'The published post should be among the returned posts.' ); } + /** + * @ticket 64606 + */ public function test_admin_query_include_limits_results(): void { $first = self::factory()->post->create( array( @@ -240,6 +258,9 @@ public function test_admin_query_include_limits_results(): void { $this->assertNotContains( $second, wp_list_pluck( $data['posts'], 'id' ), 'Posts outside the include list should not be returned.' ); } + /** + * @ticket 64606 + */ public function test_get_single_post_by_id(): void { $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); @@ -252,6 +273,9 @@ public function test_get_single_post_by_id(): void { $this->assertArrayNotHasKey( 'total', $data, 'A single post lookup should not return query totals.' ); } + /** + * @ticket 64606 + */ public function test_get_single_post_by_slug(): void { $post_id = self::factory()->post->create( array( @@ -276,6 +300,9 @@ public function test_get_single_post_by_slug(): void { $this->assertArrayNotHasKey( 'posts', $data, 'A slug lookup should not return a posts list.' ); } + /** + * @ticket 64606 + */ public function test_wrong_http_method_returns_405(): void { $request = new WP_REST_Request( 'POST', self::RUN_ROUTE ); $request->set_header( 'Content-Type', 'application/json' ); @@ -287,6 +314,9 @@ public function test_wrong_http_method_returns_405(): void { $this->assertSame( 'rest_ability_invalid_method', $response->get_data()['code'], 'The error should identify the invalid HTTP method.' ); } + /** + * @ticket 64606 + */ public function test_pagination_returns_totals_in_body(): void { self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); @@ -307,6 +337,9 @@ public function test_pagination_returns_totals_in_body(): void { $this->assertSame( (int) ceil( $data['total'] / 2 ), $data['total_pages'], 'The total pages should match the total and per_page.' ); } + /** + * @ticket 64606 + */ public function test_out_of_range_page_returns_400(): void { self::factory()->post->create( array( 'post_status' => 'publish' ) ); From 108327b8bd0f886c97553e44eeae932d6fb46b3c Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 00:17:21 +0100 Subject: [PATCH 25/69] Tests: Unregister the content category after each content query test `setUp()` registers the `content` category when it is missing, but nothing removed it, so it leaked into later classes. In a random test order that broke `Tests_Abilities_API_WpRegisterAbilityCategory::test_get_all_categories`, which expects only the categories it registers. --- .../tests/abilities-api/wpRegisterCoreContentAbility.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index daa71fc689bca..afa2d04d702b0 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -118,7 +118,7 @@ public function setUp(): void { } /** - * Restores ability and post type state after each test. + * Restores ability, ability category, and post type state after each test. * * @since 7.2.0 */ @@ -127,6 +127,10 @@ public function tearDown(): void { wp_unregister_ability( 'core/content-query' ); } + if ( wp_has_ability_category( 'content' ) ) { + wp_unregister_ability_category( 'content' ); + } + foreach ( array( 'post', 'page' ) as $post_type ) { $object = get_post_type_object( $post_type ); if ( $object ) { From ebd3691f9035ade9f5312b8e9b5b84a026cca0a9 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 16:55:19 +0100 Subject: [PATCH 26/69] Abilities API: Drop post_type_cap() from the content query ability `get_post_type_capabilities()` always sets `edit_posts` and `read_private_posts` for registered post types, and the REST posts controller reads them directly too, so the guard isn't needed. This also drops the `WordPress.WP.Capabilities.Undetermined` ignores, since core's PHPCS ruleset doesn't run that sniff. --- .../abilities/class-wp-content-abilities.php | 26 +++---------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 7fe817a984f3f..2a192a181696e 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -230,7 +230,7 @@ public function check_permission( $input = array() ): bool { $post_type_object = $exposed[ $post_type ]; if ( $requires_edit ) { - return current_user_can( $this->post_type_cap( $post_type_object, 'edit_posts' ) ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. + return current_user_can( $post_type_object->cap->edit_posts ); } return $this->can_query_statuses( $input, $post_type_object ); @@ -278,24 +278,6 @@ private function parse_filter_int( $value, int $min ): ?int { return null; } - /** - * Resolves a capability name from a post type's capability map. - * - * The capability map is a plain object with untyped properties, so guard the - * lookup and fail closed with `do_not_allow` when the name cannot be resolved. - * - * @since 7.2.0 - * - * @param \WP_Post_Type $post_type_object The post type object. - * @param string $capability The capability key, e.g. 'edit_posts'. - * @return string The resolved capability name, or 'do_not_allow' when unresolved. - */ - private function post_type_cap( \WP_Post_Type $post_type_object, string $capability ): string { - $cap = $post_type_object->cap->$capability ?? null; - - return is_string( $cap ) && '' !== $cap ? $cap : 'do_not_allow'; - } - /** * Parses a raw list input into a list of strings. * @@ -352,13 +334,11 @@ private function can_query_statuses( array $input, \WP_Post_Type $post_type_obje continue; } - // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. - if ( 'private' === $status && current_user_can( $this->post_type_cap( $post_type_object, 'read_private_posts' ) ) ) { + if ( 'private' === $status && current_user_can( $post_type_object->cap->read_private_posts ) ) { continue; } - // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. - if ( current_user_can( $this->post_type_cap( $post_type_object, 'edit_posts' ) ) ) { + if ( current_user_can( $post_type_object->cap->edit_posts ) ) { continue; } From 9d3493837e49dffe3139b6703277b50afcf0a7ba Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 19:11:44 +0100 Subject: [PATCH 27/69] Abilities API: Share the post and post type lookups in the content query ability The content query now resolves the requested post with `get_content_by_id()` and the post type with `get_exposed_post_type()`, and builds its `fields` input and post output schemas with `get_fields_input_schema()` and `get_content_output_schema()`, as the AI plugin does so its content write abilities can reuse them (https://github.com/WordPress/ai/pull/1025). `get_content_by_id()` parses the ID the way the integer filters are parsed, so only an integer or an unsigned integer string resolves a post. --- .../abilities/class-wp-content-abilities.php | 123 +++++++++++++----- .../wpRegisterCoreContentAbility.php | 25 ++++ 2 files changed, 112 insertions(+), 36 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 2a192a181696e..45c9748b2f931 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -190,8 +190,7 @@ private function register_content_query(): void { * @return bool True if the request may proceed, false otherwise. */ public function check_permission( $input = array() ): bool { - $input = rest_sanitize_object( $input ); - $exposed = $this->get_exposed_post_types(); + $input = rest_sanitize_object( $input ); if ( ! is_user_logged_in() ) { return false; @@ -201,12 +200,8 @@ public function check_permission( $input = array() ): bool { // Single-post mode (by ID). if ( ! empty( $input['id'] ) ) { - $post = get_post( $this->input_int( $input['id'] ) ); - - if ( ! $post - || ! isset( $exposed[ $post->post_type ] ) - || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) - ) { + $post = $this->get_content_by_id( $input ); + if ( ! $post ) { return false; } @@ -214,11 +209,12 @@ public function check_permission( $input = array() ): bool { } // Single-post mode (by slug) and query mode require an exposed post type. - $post_type = isset( $input['post_type'] ) && is_string( $input['post_type'] ) ? $input['post_type'] : ''; - if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { + $post_type_object = $this->get_exposed_post_type( $input['post_type'] ?? null ); + if ( ! $post_type_object ) { return false; } + $post_type = $post_type_object->name; if ( isset( $input['slug'] ) && is_string( $input['slug'] ) && '' !== $input['slug'] ) { $post = $this->get_post_by_slug( $post_type, $input['slug'] ); if ( ! $post ) { @@ -228,7 +224,6 @@ public function check_permission( $input = array() ): bool { return $requires_edit ? current_user_can( 'edit_post', $post->ID ) : $this->check_read_permission( $post ); } - $post_type_object = $exposed[ $post_type ]; if ( $requires_edit ) { return current_user_can( $post_type_object->cap->edit_posts ); } @@ -367,8 +362,7 @@ private function check_read_permission( WP_Post $post, array $checked_post_ids = $checked_post_ids[ $post->ID ] = true; - $post_type = get_post_type_object( $post->post_type ); - if ( ! $post_type instanceof \WP_Post_Type || empty( $post_type->show_in_abilities ) ) { + if ( ! $this->get_exposed_post_type( $post->post_type ) ) { return false; } @@ -448,18 +442,13 @@ private function check_read_permission( WP_Post $post, array $checked_post_ids = */ public function execute_content_query( $input = array() ) { $input = rest_sanitize_object( $input ); - $exposed = $this->get_exposed_post_types(); $fields = $this->normalize_fields( $input ); $requires_edit = $this->has_explicit_edit_fields( $input ); // Single-post mode (by ID). if ( ! empty( $input['id'] ) ) { - $post = get_post( $this->input_int( $input['id'] ) ); - - if ( ! $post - || ! isset( $exposed[ $post->post_type ] ) - || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) - ) { + $post = $this->get_content_by_id( $input ); + if ( ! $post ) { return $this->not_found_error(); } @@ -467,11 +456,12 @@ public function execute_content_query( $input = array() ) { } // Single-post mode (by slug) and query mode. - $post_type = isset( $input['post_type'] ) && is_string( $input['post_type'] ) ? $input['post_type'] : ''; - if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { + $post_type_object = $this->get_exposed_post_type( $input['post_type'] ?? null ); + if ( ! $post_type_object ) { return $this->not_found_error(); } + $post_type = $post_type_object->name; if ( isset( $input['slug'] ) && is_string( $input['slug'] ) && '' !== $input['slug'] ) { $post = $this->get_post_by_slug( $post_type, $input['slug'] ); @@ -706,6 +696,30 @@ private function should_prime_post_caches( array $fields ): bool { return array() !== array_intersect( $this->cache_priming_fields, $fields ); } + /** + * Looks up the single post an ID request resolves to. + * + * The post must exist, belong to a post type exposed to abilities, and match the + * `post_type` guard when one is given. As with the integer filters, only an integer or + * an unsigned integer string is accepted, so a malformed ID or one beyond the integer + * range cannot be coerced onto another post. + * + * @since 7.2.0 + * + * @param array $input The ability input. + * @return \WP_Post|null The post, or null when it cannot be resolved. + */ + private function get_content_by_id( array $input ): ?WP_Post { + $post_id = isset( $input['id'] ) ? $this->parse_filter_int( $input['id'], 1 ) : null; + $post = null === $post_id ? null : get_post( $post_id ); + + if ( ! $post instanceof WP_Post || ! $this->get_exposed_post_type( $post->post_type ) ) { + return null; + } + + return empty( $input['post_type'] ) || $post->post_type === $input['post_type'] ? $post : null; + } + /** * Looks up the single post a slug request resolves to. * @@ -789,6 +803,18 @@ private function get_exposed_post_types(): array { return $exposed_post_types; } + /** + * Returns a post type exposed through the Abilities API. + * + * @since 7.2.0 + * + * @param mixed $post_type The post type name. + * @return \WP_Post_Type|null The post type object, or null when the post type is not exposed. + */ + private function get_exposed_post_type( $post_type ): ?\WP_Post_Type { + return is_string( $post_type ) ? ( $this->get_exposed_post_types()[ $post_type ] ?? null ) : null; + } + /** * Normalizes the requested statuses to a non-empty, sanitized list defaulting to publish. * @@ -952,6 +978,25 @@ private function get_post_properties(): array { return $this->post_properties; } + /** + * Builds the schema of the `fields` input, shared by all content abilities. + * + * @since 7.2.0 + * + * @return array The `fields` JSON Schema. + */ + private function get_fields_input_schema(): array { + return array( + 'type' => 'array', + 'uniqueItems' => true, + 'items' => array( + 'type' => 'string', + 'enum' => array_keys( $this->get_post_properties() ), + ), + 'description' => __( 'Limit each returned post to these fields. If omitted, a lean set of common read fields is returned. Explicit raw field requests require edit access.' ), + ); + } + /** * Builds the input schema for the `core/content-query` ability. * @@ -973,15 +1018,7 @@ private function get_post_properties(): array { * @return array The input JSON Schema. */ private function get_content_query_input_schema( array $post_types, array $statuses ): array { - $fields = array( - 'type' => 'array', - 'uniqueItems' => true, - 'items' => array( - 'type' => 'string', - 'enum' => array_keys( $this->get_post_properties() ), - ), - 'description' => __( 'Limit each returned post to these fields. If omitted, a lean set of common read fields is returned. Explicit raw field requests require edit access.' ), - ); + $fields = $this->get_fields_input_schema(); $include = array( 'type' => 'array', 'minItems' => 1, @@ -1084,6 +1121,24 @@ private function get_content_query_input_schema( array $post_types, array $statu ); } + /** + * Builds the output schema of a single post, shared by all content abilities. + * + * No field is marked required because the `fields` input lets the caller request any + * subset, and a field is only present when its post type supports it. + * + * @since 7.2.0 + * + * @return array The post JSON Schema. + */ + private function get_content_output_schema(): array { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => $this->get_post_properties(), + ); + } + /** * Builds the output schema for the `core/content-query` ability. * @@ -1096,11 +1151,7 @@ private function get_content_query_input_schema( array $post_types, array $statu * @return array The output JSON Schema. */ private function get_content_query_output_schema(): array { - $post_schema = array( - 'type' => 'object', - 'additionalProperties' => false, - 'properties' => $this->get_post_properties(), - ); + $post_schema = $this->get_content_output_schema(); $query_schema = array( 'type' => 'object', diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index afa2d04d702b0..a48a05632354a 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -591,6 +591,31 @@ public function test_get_by_missing_id_is_denied(): void { $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Missing posts should fail closed as a permission error.' ); } + /** + * An ID beyond the integer range is denied instead of wrapping around onto another post. + * + * @ticket 64606 + * @since 7.2.0 + */ + public function test_get_by_id_beyond_the_integer_range_is_denied(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + // Floats near 2^64 are 4096 apart, so 2^64 + N is exact for a multiple of 4096 and casts to N. + $aliased_id = self::factory()->post->create( + array( + 'import_id' => 4096 * 1024, + 'post_status' => 'publish', + ) + ); + $this->assertSame( 4096 * 1024, $aliased_id, 'The aliased post should have the requested ID.' ); + + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => 2 ** 64 + $aliased_id ) ); + + $this->assertWPError( $result, 'An ID beyond the integer range should not resolve a post.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'An ID beyond the integer range should fail closed as a permission error.' ); + } + /** * A post type guard mismatch is denied before execution can probe the requested object. * From 81a94436051193698f093a6a477656b395bc74fe Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 19:15:48 +0100 Subject: [PATCH 28/69] Abilities API: Filter and return the author by slug in the content query ability The query took an author user ID as its filter and returned the author as `author`, an object with the user ID and display name. It now takes and returns `author_slug`, the user's nicename, which `core/users-query` returns as `slug`, as the AI plugin does (https://github.com/WordPress/ai/pull/1025). The slug must match exactly one user, or the filter fails as `content_invalid_filter`. A user the caller may not see in `core/users-query` is reported like a missing one, so the filter reveals no hidden user. --- .../abilities/class-wp-content-abilities.php | 117 ++++++++++----- .../wpRegisterCoreContentAbility.php | 134 +++++++++++------- 2 files changed, 160 insertions(+), 91 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 45c9748b2f931..815a15b88aa39 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -502,20 +502,20 @@ public function execute_content_query( $input = array() ) { } $author = null; - if ( isset( $input['author'] ) ) { + if ( isset( $input['author_slug'] ) ) { if ( ! post_type_supports( $post_type, 'author' ) ) { return new WP_Error( 'content_invalid_filter', - __( 'The author filter is only supported for post types that support authors.' ), + __( 'The author_slug filter is only supported for post types that support authors.' ), array( 'status' => 400 ) ); } - $author = $this->parse_filter_int( $input['author'], 1 ); - if ( null === $author ) { + $author = $this->get_author_by_slug( $input['author_slug'], $post_type_object ); + if ( ! $author ) { return new WP_Error( 'content_invalid_filter', - __( 'The author filter must be a positive integer.' ), + __( 'The author_slug filter must be the slug of an existing user.' ), array( 'status' => 400 ) ); } @@ -561,7 +561,7 @@ public function execute_content_query( $input = array() ) { } if ( null !== $author ) { - $query_args['author'] = $author; + $query_args['author'] = $author->ID; } if ( null !== $parent ) { @@ -589,7 +589,7 @@ public function execute_content_query( $input = array() ) { * Prime the author caches with a single query instead of one user lookup * per post, mirroring the REST posts controller. */ - if ( in_array( 'author', $fields, true ) && post_type_supports( $post_type, 'author' ) ) { + if ( in_array( 'author_slug', $fields, true ) && post_type_supports( $post_type, 'author' ) ) { $query_posts = array_filter( $query->posts, static function ( $queried_post ): bool { @@ -720,6 +720,59 @@ private function get_content_by_id( array $input ): ?WP_Post { return empty( $input['post_type'] ) || $post->post_type === $input['post_type'] ? $post : null; } + /** + * Looks up the user an author slug names. + * + * The slug is the user's nicename, which `core/users-query` returns as `slug`. It must match + * exactly one user. A user the current user may not see is reported like a missing one, + * so the lookup reveals no hidden user: the current user can see themselves, any user when + * they can list users or edit others' posts of the post type, and authors with posts in a + * publicly viewable post type, as in `core/users-query`. + * + * @since 7.2.0 + * + * @param mixed $slug The author slug. + * @param \WP_Post_Type $post_type_object The post type the author is looked up for. + * @return \WP_User|null The user, or null when the slug does not name exactly one visible user. + */ + private function get_author_by_slug( $slug, \WP_Post_Type $post_type_object ): ?\WP_User { + if ( ! is_string( $slug ) || '' === $slug ) { + return null; + } + + // The database compares nicenames without regard to case, so keep the exact matches only. + $users = array_values( + array_filter( + get_users( + array( + 'blog_id' => 0, + 'nicename' => $slug, + 'count_total' => false, + ) + ), + static function ( $user ) use ( $slug ): bool { + return $user instanceof \WP_User && $user->user_nicename === $slug; + } + ) + ); + + if ( 1 !== count( $users ) ) { + return null; + } + + $user = $users[0]; + if ( get_current_user_id() === $user->ID + || current_user_can( 'list_users' ) + || current_user_can( $post_type_object->cap->edit_others_posts ) + ) { + return $user; + } + + $public_post_types = array_values( array_filter( get_post_types(), 'is_post_type_viewable' ) ); + + return array() !== $public_post_types && count_user_posts( $user->ID, $public_post_types ) > 0 ? $user : null; + } + /** * Looks up the single post a slug request resolves to. * @@ -954,20 +1007,9 @@ private function get_post_properties(): array { 'type' => 'boolean', 'description' => __( 'Whether the content is protected with a password. Present when the post type supports the editor.' ), ), - 'author' => array( - 'type' => 'object', - 'additionalProperties' => false, - 'properties' => array( - 'id' => array( - 'type' => 'integer', - 'description' => __( 'The author user ID.' ), - ), - 'name' => array( - 'type' => 'string', - 'description' => __( 'The author display name.' ), - ), - ), - 'description' => __( 'The post author. Present when the post type supports authors.' ), + 'author_slug' => array( + 'type' => 'string', + 'description' => __( "The author's user slug, as core/users-query returns it. Present when the post type supports authors. Empty when the author no longer exists." ), ), 'parent' => array( 'type' => 'integer', @@ -1005,7 +1047,7 @@ private function get_fields_input_schema(): array { * * - Get a single post by `id` (optionally guarded by `post_type`). * - Get a single post by `post_type` and `slug`. - * - Query a set of posts by `post_type` plus filters (`status`, `author`, `parent`, + * - Query a set of posts by `post_type` plus filters (`status`, `author_slug`, `parent`, * `include`, `page`, `per_page`). * * Each mode sets `additionalProperties: false`, so e.g. passing `per_page` alongside `id` @@ -1078,12 +1120,12 @@ private function get_content_query_input_schema( array $post_types, array $statu 'required' => array( 'post_type' ), 'additionalProperties' => false, 'properties' => array( - 'post_type' => array( + 'post_type' => array( 'type' => 'string', 'enum' => $post_types, 'description' => __( 'Post type to query for readable posts.' ), ), - 'status' => array( + 'status' => array( 'type' => 'array', 'uniqueItems' => true, 'items' => array( @@ -1092,24 +1134,24 @@ private function get_content_query_input_schema( array $post_types, array $statu ), 'description' => __( 'Filter readable posts by one or more post statuses. Defaults to publish. Non-published statuses require the appropriate capabilities.' ), ), - 'author' => array( - 'type' => 'integer', - 'minimum' => 1, - 'description' => __( 'Filter by author user ID. Only supported for post types that support authors.' ), + 'author_slug' => array( + 'type' => 'string', + 'minLength' => 1, + 'description' => __( "Filter by the author's user slug, as core/users-query returns it. Only supported for post types that support authors." ), ), - 'parent' => array( + 'parent' => array( 'type' => 'integer', 'minimum' => 0, 'description' => __( 'Filter by parent post ID. Only supported for hierarchical post types. Use 0 for top-level posts.' ), ), - 'include' => $include, - 'fields' => $fields, - 'page' => array( + 'include' => $include, + 'fields' => $fields, + 'page' => array( 'type' => 'integer', 'minimum' => 1, 'description' => __( 'Page of results to return. Requesting a page beyond the last one is an error. Check `total_pages` before requesting later pages.' ), ), - 'per_page' => array( + 'per_page' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_PER_PAGE, @@ -1331,12 +1373,9 @@ private function build_post_fields( WP_Post $post, array $fields, bool $can_edit $data['content_protected'] = (bool) $post->post_password; } - if ( isset( $requested['author'] ) && post_type_supports( $post_type, 'author' ) ) { - $author = get_userdata( (int) $post->post_author ); - $data['author'] = array( - 'id' => (int) $post->post_author, - 'name' => $author ? $author->display_name : '', - ); + if ( isset( $requested['author_slug'] ) && post_type_supports( $post_type, 'author' ) ) { + $author = get_userdata( (int) $post->post_author ); + $data['author_slug'] = $author ? $author->user_nicename : ''; } if ( isset( $requested['parent'] ) && is_post_type_hierarchical( $post_type ) ) { diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index a48a05632354a..1f6593dbde12c 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -1721,7 +1721,7 @@ public function test_query_mode_filters_pages_by_parent(): void { } /** - * The author filter is rejected for post types without author support, mirroring REST. + * The author_slug filter is rejected for post types without author support, mirroring REST. * * @ticket 64606 * @since 7.2.0 @@ -1741,17 +1741,18 @@ public function test_query_mode_rejects_author_filter_for_post_type_without_auth $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'wpai_no_author_cpt', - 'author' => self::$user_ids['author'], + 'post_type' => 'wpai_no_author_cpt', + 'author_slug' => get_userdata( self::$user_ids['author'] )->user_nicename, ) ); - $this->assertWPError( $result, 'The author filter should be rejected for post types without author support.' ); + $this->assertWPError( $result, 'The author_slug filter should be rejected for post types without author support.' ); $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'Unsupported author filters should return a filter error.' ); } /** - * The author filter narrows queries to posts by the given author. + * The author_slug filter narrows queries to posts by the given author, and each post + * returns its author's slug. * * @ticket 64606 * @since 7.2.0 @@ -1760,13 +1761,14 @@ public function test_query_mode_filters_posts_by_author(): void { $this->login_as( 'administrator' ); $this->register_ability(); - $mine_id = self::factory()->post->create( + $author_slug = get_userdata( self::$user_ids['author'] )->user_nicename; + $mine_id = self::factory()->post->create( array( 'post_author' => self::$user_ids['author'], 'post_status' => 'publish', ) ); - $other_id = self::factory()->post->create( + $other_id = self::factory()->post->create( array( 'post_author' => self::$user_ids['author_secondary'], 'post_status' => 'publish', @@ -1775,15 +1777,17 @@ public function test_query_mode_filters_posts_by_author(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'author' => self::$user_ids['author'], - 'per_page' => 100, + 'post_type' => 'post', + 'author_slug' => $author_slug, + 'per_page' => 100, + 'fields' => array( 'id', 'author_slug' ), ) ); $ids = wp_list_pluck( $result['posts'], 'id' ); - $this->assertContains( $mine_id, $ids, 'The author filter should include the author\'s posts.' ); - $this->assertNotContains( $other_id, $ids, 'The author filter should exclude other authors\' posts.' ); + $this->assertContains( $mine_id, $ids, 'The author_slug filter should include the author\'s posts.' ); + $this->assertNotContains( $other_id, $ids, 'The author_slug filter should exclude other authors\' posts.' ); + $this->assertSame( array( $author_slug ), array_unique( wp_list_pluck( $result['posts'], 'author_slug' ) ), 'Each post should return its author\'s slug.' ); } /** @@ -2801,29 +2805,54 @@ public function test_execute_callback_returns_not_found_for_structural_lookup_fa } /** - * An author filter that does not resolve to a positive integer is rejected - * rather than silently dropped. + * Returns author slugs that do not name exactly one user. * - * On transports that skip schema validation a non-integer `author` would coerce - * to 0, which WP_Query treats as "no author filter" — returning every author's - * posts. The filter must fail closed instead of widening the result set. + * @return array The author slug filter value. + */ + public function data_author_slugs_that_name_no_single_user(): array { + return array( + 'unknown slug' => array( 'no-such-user' ), + 'shared slug' => array( 'wpai-shared-slug' ), + 'slug in capitals' => array( 'WPAI-AUTHOR-SLUG' ), + 'not a string' => array( 5 ), + ); + } + + /** + * An author_slug filter that does not name exactly one user is rejected rather than + * silently dropped, which would widen the query to every author's posts. * * @ticket 64606 - * @since 7.2.0 + * @dataProvider data_author_slugs_that_name_no_single_user + * + * @param mixed $author_slug The author slug filter value. */ - public function test_execute_callback_rejects_non_integer_author_filter(): void { + public function test_execute_callback_rejects_an_author_slug_that_names_no_single_user( $author_slug ): void { + global $wpdb; + + // Core keeps nicenames unique, so give two users the same one through the database. + $nicenames = array( + self::$user_ids['author'] => 'wpai-author-slug', + self::$user_ids['contributor'] => 'wpai-shared-slug', + self::$user_ids['author_secondary'] => 'wpai-shared-slug', + ); + foreach ( $nicenames as $user_id => $nicename ) { + $wpdb->update( $wpdb->users, array( 'user_nicename' => $nicename ), array( 'ID' => $user_id ) ); + clean_user_cache( $user_id ); + } + $this->login_as( 'administrator' ); $content = new WP_Content_Abilities(); $result = $content->execute_content_query( array( - 'post_type' => 'post', - 'author' => 'not-a-number', + 'post_type' => 'post', + 'author_slug' => $author_slug, ) ); - $this->assertWPError( $result, 'A non-integer author filter must not silently widen the query to all authors.' ); - $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An unhonorable author filter should fail closed as an invalid filter.' ); + $this->assertWPError( $result, 'An author_slug that names no single user must not silently widen the query to all authors.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An unhonorable author_slug filter should fail closed as an invalid filter.' ); } /** @@ -2881,44 +2910,45 @@ public function test_execute_callback_rejects_include_with_no_valid_ids(): void } /** - * Valid filter values delivered as strings are still honored. - * - * The schema-less query-string transport delivers integers as strings; the - * stricter filter parsing must accept those so it only rejects genuinely - * unhonorable values, not well-formed ones. + * An author_slug filter names only users the current user may see, as in core/users-query: + * a user without published posts is reported like a missing one to a subscriber, but not + * to an editor, who can edit their posts. * * @ticket 64606 * @since 7.2.0 */ - public function test_execute_callback_honors_string_author_filter(): void { - $author_a = self::$user_ids['author']; - $author_b = self::$user_ids['author_secondary']; - - $post_a = self::factory()->post->create( - array( - 'post_author' => $author_a, - 'post_status' => 'publish', - ) - ); - self::factory()->post->create( + public function test_author_slug_filter_only_names_users_the_current_user_may_see(): void { + $published_id = self::factory()->post->create( array( 'post_author' => self::$user_ids['author'] ) ); + $draft_id = self::factory()->post->create( array( - 'post_author' => $author_b, - 'post_status' => 'publish', + 'post_author' => self::$user_ids['author_secondary'], + 'post_status' => 'draft', ) ); - $this->login_as( 'administrator' ); - $content = new WP_Content_Abilities(); + $query = static function ( array $statuses, int $user_id ) { + return ( new WP_Content_Abilities() )->execute_content_query( + array( + 'post_type' => 'post', + 'status' => $statuses, + 'author_slug' => get_userdata( $user_id )->user_nicename, + 'fields' => array( 'id' ), + ) + ); + }; - $result = $content->execute_content_query( - array( - 'post_type' => 'post', - 'author' => (string) $author_a, - 'fields' => array( 'id' ), - ) - ); + $this->login_as( 'subscriber' ); + $public = $query( array( 'publish' ), self::$user_ids['author'] ); + $this->assertIsArray( $public, 'A subscriber should filter by an author with published posts.' ); + $this->assertSame( array( $published_id ), wp_list_pluck( $public['posts'], 'id' ), 'The filter should return that author\'s posts.' ); - $this->assertIsArray( $result, 'A valid numeric-string author filter should be honored, not rejected.' ); - $this->assertSame( array( $post_a ), wp_list_pluck( $result['posts'], 'id' ), 'The author filter should restrict results to the requested author.' ); + $hidden = $query( array( 'publish' ), self::$user_ids['author_secondary'] ); + $this->assertWPError( $hidden, 'A subscriber should not learn that an author without published posts exists.' ); + $this->assertSame( 'content_invalid_filter', $hidden->get_error_code(), 'A hidden author should be reported like a missing one.' ); + + $this->login_as( 'editor' ); + $drafts = $query( array( 'draft' ), self::$user_ids['author_secondary'] ); + $this->assertIsArray( $drafts, 'An editor should filter by an author without published posts.' ); + $this->assertSame( array( $draft_id ), wp_list_pluck( $drafts['posts'], 'id' ), 'The filter should return that author\'s drafts.' ); } } From 3b4605ccde00326c9af94b2fed5816e9e0464833 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 19:16:34 +0100 Subject: [PATCH 29/69] Abilities API: Return an empty rendered title when a title filter returns a non-string `get_title()` returned whatever the `the_title` filters returned, so a filter that returns a non-string failed its string return type and turned the query into an `ability_callback_exception`. The rendered excerpt and content are already guarded this way, as the AI plugin does (https://github.com/WordPress/ai/pull/1025). --- .../abilities/class-wp-content-abilities.php | 8 +++++- .../wpRegisterCoreContentAbility.php | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 815a15b88aa39..7e2a766985aad 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -1425,7 +1425,13 @@ private function get_title( WP_Post $post ): string { * filter cannot leave them attached for the rest of the request. */ try { - return get_the_title( $post ); + $title = get_the_title( $post ); + + /* + * A title filter that returns a non-string would fail the return type, so guard + * it as the excerpt and content are. + */ + return is_string( $title ) ? $title : ''; } finally { remove_filter( 'protected_title_format', $strip ); remove_filter( 'private_title_format', $strip ); diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 1f6593dbde12c..d0eeda630e09e 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -1959,6 +1959,31 @@ public function test_excerpt_rendered_applies_the_excerpt_filters(): void { ); } + /** + * A title filter that returns a non-string empties the rendered title instead of failing + * the query. + * + * @ticket 64606 + * @since 7.2.0 + */ + public function test_rendered_title_is_empty_when_a_title_filter_returns_a_non_string(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + // Runs after core's own title filters, which expect a string. + add_filter( 'the_title', '__return_null', 20 ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'id' => self::$post_ids['published'], + 'fields' => array( 'id', 'title_rendered' ), + ) + ); + + $this->assertIsArray( $result, 'A non-string rendered title should not fail the query.' ); + $this->assertSame( '', $result['title_rendered'], 'A non-string rendered title should be returned as an empty string.' ); + } + /** * Rendered excerpt filters run with the requested post as the global context and restore * the context that was active before the ability executed. From 63c2b606ac061b7b3f07561f29fe6dd4cf7da879 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 19:59:34 +0100 Subject: [PATCH 30/69] Abilities API: Restore the hooks the content REST tests change to register the core abilities `set_up_before_class()` removed the test suite's unhook callbacks and added the core registration callbacks, and only `tear_down_after_class()` put the unhook callbacks back. The first test of a run snapshots the hooks that every test is reset to, so when this class ran first, the changed hooks leaked into the rest of the run: later tests registered the core abilities whenever the registry initialized, and `test_get_existing_ability_using_callback` failed because the `site` category was not registered. The hooks are now restored as soon as the core abilities are registered. --- .../rest-api/wpRestAbilitiesContentController.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php index 4abe778a9b16b..6d17f5ec60a46 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -65,6 +65,16 @@ public static function set_up_before_class(): void { add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); do_action( 'wp_abilities_api_categories_init' ); do_action( 'wp_abilities_api_init' ); + + /* + * Restore the hooks right away instead of after the class. The first test of a run + * snapshots the hooks and every test resets them to that snapshot, so changes left + * here would leak into every later test whenever this class runs first. + */ + remove_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); + remove_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); + add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); } /** @@ -73,9 +83,6 @@ public static function set_up_before_class(): void { * @since 7.2.0 */ public static function tear_down_after_class(): void { - add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); - add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); - foreach ( wp_get_abilities() as $ability ) { wp_unregister_ability( $ability->get_name() ); } From c447cc97243b63f6f5039816da70fb9e56b8ee79 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 21:18:18 +0100 Subject: [PATCH 31/69] Abilities API: Look up an exposed post type directly in the content query ability `check_read_permission()` calls `get_exposed_post_type()` for every post a query returns, up to 100 per page, and each call rebuilt the map of every exposed post type. It now reads the post type object and checks `show_in_abilities`, the same truthy rule `get_post_types()` applies, as the AI plugin does (https://github.com/WordPress/ai/pull/1025). `get_exposed_post_types()` was then only used for the post type names at registration, which `get_post_types()` returns directly, so it is removed. --- .../abilities/class-wp-content-abilities.php | 27 +++++-------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 7e2a766985aad..f04161994c152 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -119,7 +119,7 @@ public function register(): void { /* * A future write-oriented ability can be registered here, reusing the shared - * helpers below (get_exposed_post_types(), format_post(), check_permission()): + * helpers below (get_exposed_post_type(), format_post(), check_permission()): * * $this->register_manage_content(); */ @@ -135,7 +135,7 @@ private function register_content_query(): void { * Post types must be registered with `show_in_abilities` before the ability is * registered so they are included in its input schema. */ - $post_types = array_keys( $this->get_exposed_post_types() ); + $post_types = array_values( get_post_types( array( 'show_in_abilities' => true ) ) ); if ( empty( $post_types ) ) { return; } @@ -836,7 +836,7 @@ private function get_post_by_slug( string $post_type, string $slug ): ?WP_Post { } /** - * Returns the post types exposed through the Abilities API, keyed by name. + * Returns a post type exposed through the Abilities API. * * Deliberately resolved on every call rather than cached: post types can be * unregistered or re-registered with different arguments between the ability @@ -844,28 +844,13 @@ private function get_post_by_slug( string $post_type, string $slug ): ?WP_Post { * * @since 7.2.0 * - * @return array Exposed post type objects keyed by name. - */ - private function get_exposed_post_types(): array { - $exposed_post_types = array(); - - foreach ( get_post_types( array( 'show_in_abilities' => true ), 'objects' ) as $post_type_object ) { - $exposed_post_types[ $post_type_object->name ] = $post_type_object; - } - - return $exposed_post_types; - } - - /** - * Returns a post type exposed through the Abilities API. - * - * @since 7.2.0 - * * @param mixed $post_type The post type name. * @return \WP_Post_Type|null The post type object, or null when the post type is not exposed. */ private function get_exposed_post_type( $post_type ): ?\WP_Post_Type { - return is_string( $post_type ) ? ( $this->get_exposed_post_types()[ $post_type ] ?? null ) : null; + $post_type_object = is_string( $post_type ) ? get_post_type_object( $post_type ) : null; + + return empty( $post_type_object->show_in_abilities ) ? null : $post_type_object; } /** From 1a4346aa5d7e91527b76fc964cbb4d1d0f57eb55 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 21:18:31 +0100 Subject: [PATCH 32/69] Abilities API: Say that editors can tell whether an author slug exists The `get_author_by_slug()` docblock said the lookup reveals no hidden user, as in `core/users-query`. A user who can edit others' posts of the post type, such as an editor, resolves any slug, since they may make any user the author, while `core/users-query` hides users like subscribers from them. The docblock now says so, as the AI plugin does (https://github.com/WordPress/ai/pull/1025). --- src/wp-includes/abilities/class-wp-content-abilities.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index f04161994c152..b9b839a2d492a 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -724,10 +724,11 @@ private function get_content_by_id( array $input ): ?WP_Post { * Looks up the user an author slug names. * * The slug is the user's nicename, which `core/users-query` returns as `slug`. It must match - * exactly one user. A user the current user may not see is reported like a missing one, - * so the lookup reveals no hidden user: the current user can see themselves, any user when - * they can list users or edit others' posts of the post type, and authors with posts in a - * publicly viewable post type, as in `core/users-query`. + * exactly one user. A user the current user may not see is reported like a missing one. As + * in `core/users-query`, the current user can see themselves, any user when they can list + * users, and authors with posts in a publicly viewable post type. Unlike there, a user who + * can edit others' posts of the post type can see any user, since they may make any user + * the author, so for them the lookup does tell whether an account exists. * * @since 7.2.0 * From 7287278a95799eebcbde21838e98a29955f99d62 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 22:35:18 +0100 Subject: [PATCH 33/69] Abilities API: Stop citing the author filter in parse_filter_int()'s docblock The docblock said the parser keeps `author => 0` from dropping the author filter, but the author filter is now `author_slug`, resolved by `get_author_by_slug()`. `parse_filter_int()` only reads the `id` input and the `parent` filter, so the docblock now describes those, as the AI plugin does (https://github.com/WordPress/ai/pull/1025). --- src/wp-includes/abilities/class-wp-content-abilities.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index b9b839a2d492a..38644ccba4b81 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -244,12 +244,11 @@ private function input_int( $value ): int { } /** - * Parses a raw filter value into an integer of at least a minimum, or null when invalid. + * Parses a raw input value into an integer of at least a minimum, or null when invalid. * * Unlike {@see self::input_int()}, which coerces any non-integer to 0, this rejects - * values that are not integers so a filter whose value cannot be honored can fail - * loudly instead of silently widening the query: `author => 0` drops the author - * filter (matching every author) and `post_parent => 0` becomes a top-level query. + * values that are not integers, so an ID or a parent that cannot be honored fails + * loudly instead of being read as 0: a `parent` filter of 0 asks for top-level posts. * Accepts native integers and unsigned integer strings, mirroring how the JSON * Schema `integer` type and the query-string transport respectively deliver them. * From b58e993cc780dc03a2f717f8da7ef4bbc13a5da7 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 16:00:46 +0100 Subject: [PATCH 34/69] Abilities API: Document `show_in_abilities` as a boolean The post type argument was documented as a boolean or an array "reserved for enabling specific operations". No such operations exist, so any non-empty array exposed the post type in full: `get_post_types()` compares the argument with `==`, and the content query ability checks it with `empty()`. Giving arrays a per-operation meaning later would change the behavior of any plugin passing one today. The argument is now documented as a boolean, as `show_in_rest` is for post types. Accepting an array can still be added later without breaking anyone. --- src/wp-includes/class-wp-post-type.php | 7 +++---- src/wp-includes/post.php | 5 ++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index 29fd12faa8644..1781191dc517a 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -376,12 +376,11 @@ final class WP_Post_Type { /** * Whether this post type should be exposed through the Abilities API. * - * Default false. When truthy, the post type's readable posts can be retrieved - * through the read-only `core/content-query` ability, subject to per-post capability - * checks. May be an array to enable specific operations in the future. + * Default false. If true, the post type's readable posts can be retrieved through + * the read-only `core/content-query` ability, subject to per-post capability checks. * * @since 7.2.0 - * @var bool|array $show_in_abilities + * @var bool $show_in_abilities */ public $show_in_abilities; diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index b8f3b2a8709ca..4a26a9f2bc9df 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -1739,10 +1739,9 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * of $show_in_menu. * @type bool $show_in_rest Whether to include the post type in the REST API. Set this to true * for the post type to be available in the block editor. - * @type bool|array $show_in_abilities Whether to expose this post type through the Abilities API, so its + * @type bool $show_in_abilities Whether to expose this post type through the Abilities API, so its * readable posts can be retrieved via the read-only `core/content-query` - * ability (subject to per-post capability checks). Accepts a boolean - * or an array reserved for enabling specific operations. Default false. + * ability (subject to per-post capability checks). Default false. * @type string $rest_base To change the base URL of REST API route. Default is $post_type. * @type string $rest_namespace To change the namespace URL of REST API route. Default is wp/v2. * @type string $rest_controller_class REST API controller class name. Default is 'WP_REST_Posts_Controller'. From 6d080b00966ff7cfd46e96a8942c46ed6404e221 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 16:00:46 +0100 Subject: [PATCH 35/69] Abilities API: Prime the caches that permalinks read in the content query ability `get_permalink()` reads the post's terms for `%category%` permalinks and its author for `%author%` permalinks. A query requesting `link` primed neither, so each returned post ran its own term or user query, up to 100 per page. `link` now primes the post meta and term caches, as the rendered excerpt and content do, and the author caches, as `author_slug` does. --- .../abilities/class-wp-content-abilities.php | 13 ++- .../wpRegisterCoreContentAbility.php | 86 +++++++++++++++++-- 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 38644ccba4b81..8abc67b4f2dbd 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -71,15 +71,18 @@ final class WP_Content_Abilities { ); /** - * Fields whose rendering may read post meta or terms. + * Fields whose output may read post meta or terms. * * Requests that include any of these prime the post meta and term caches for the - * page. Other rendered fields, such as the title, do not need that cache priming. + * page. Rendered excerpts and content may read either, and permalinks read terms + * when the permalink structure contains `%category%`. Other fields, such as the + * rendered title, do not need that cache priming. * * @since 7.2.0 * @var list */ private array $cache_priming_fields = array( + 'link', 'excerpt_rendered', 'content_rendered', ); @@ -586,9 +589,11 @@ public function execute_content_query( $input = array() ) { /* * Prime the author caches with a single query instead of one user lookup - * per post, mirroring the REST posts controller. + * per post, mirroring the REST posts controller. Besides `author_slug`, + * permalinks read the author when the permalink structure contains `%author%`. */ - if ( in_array( 'author_slug', $fields, true ) && post_type_supports( $post_type, 'author' ) ) { + $author_fields = array_intersect( array( 'author_slug', 'link' ), $fields ); + if ( array() !== $author_fields && post_type_supports( $post_type, 'author' ) ) { $query_posts = array_filter( $query->posts, static function ( $queried_post ): bool { diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index d0eeda630e09e..79f4f7598e995 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -2386,7 +2386,8 @@ public function test_query_rendered_fields_prime_the_post_meta_cache(): void { $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); - $postmeta_queries = $this->count_post_meta_queries( + $postmeta_queries = $this->count_queries( + 'postmeta', static function () use ( $ids ) { return wp_get_ability( 'core/content-query' )->execute( array( @@ -2456,24 +2457,91 @@ public function test_query_lean_projection_does_not_request_post_meta_priming(): } /** - * Counts the post meta queries issued while running the given callback. + * Returns permalink structures that read data related to each post. + * + * @return array Permalink test cases. + */ + public function data_permalink_structures_that_read_related_data(): array { + return array( + 'category permalinks' => array( + 'permalink_structure' => '/%category%/%postname%/', + 'table' => 'term_relationships', + ), + 'author permalinks' => array( + 'permalink_structure' => '/%author%/%postname%/', + 'table' => 'users', + ), + ); + } + + /** + * Requesting `link` primes the caches that permalinks read for the whole page. + * + * Permalinks read the post's terms for `%category%` and its author for `%author%`, + * so without priming, each returned row runs its own query. + * + * @ticket 64606 + * @dataProvider data_permalink_structures_that_read_related_data + * + * @param string $permalink_structure The permalink structure. + * @param string $table The wpdb property naming the table the permalinks read. + */ + public function test_query_link_primes_the_caches_permalinks_read( string $permalink_structure, string $table ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + $this->set_permalink_structure( $permalink_structure ); + + $category_id = self::factory()->category->create(); + $ids = array(); + foreach ( array( 'author', 'author_secondary', 'editor' ) as $role ) { + $ids[] = self::factory()->post->create( + array( + 'post_author' => self::$user_ids[ $role ], + 'post_category' => array( $category_id ), + 'post_status' => 'publish', + ) + ); + } + + $queries = $this->count_queries( + $table, + static function () use ( $ids ) { + return wp_get_ability( 'core/content-query' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'fields' => array( 'id', 'link' ), + ) + ); + }, + $result + ); + + $this->assertCount( 3, $result['posts'], 'Precondition: the query should return the seeded posts.' ); + $this->assertSame( 1, $queries, 'Permalinks should read primed caches, not query once per returned post.' ); + } + + /** + * Counts the queries against a table issued while running the given callback. * * Counts during the call rather than checking the cache afterwards: the rendered * filter chains prime meta lazily, so an after-the-fact cache check passes either way. * * @since 7.2.0 * + * @param string $table The wpdb property naming the table, such as `postmeta`. * @param callable $callback Callback to run. * @param mixed $result Set to the callback's return value. - * @return int Number of post meta queries issued. + * @return int Number of queries issued against the table. */ - private function count_post_meta_queries( callable $callback, &$result ): int { + private function count_queries( string $table, callable $callback, &$result ): int { global $wpdb; - $postmeta_queries = 0; - $spy = static function ( $query ) use ( &$postmeta_queries, $wpdb ) { - if ( is_string( $query ) && preg_match( '/FROM\s+`?' . preg_quote( $wpdb->postmeta, '/' ) . '`?/i', $query ) ) { - ++$postmeta_queries; + $queries = 0; + $pattern = '/(?:FROM|JOIN)\s+`?' . preg_quote( $wpdb->$table, '/' ) . '\b/i'; + $spy = static function ( $query ) use ( &$queries, $pattern ) { + if ( is_string( $query ) && preg_match( $pattern, $query ) ) { + ++$queries; } return $query; @@ -2487,7 +2555,7 @@ private function count_post_meta_queries( callable $callback, &$result ): int { remove_filter( 'query', $spy ); } - return $postmeta_queries; + return $queries; } /** From 95b4d9f38fbf443079fde5dc6f36ab880b99530c Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 16:00:47 +0100 Subject: [PATCH 36/69] Abilities API: Update the content query comments on string input The comments said a GET request delivers list inputs as comma-separated strings and integers as strings, "until core sanitizes ability input itself". Since 7.1, the REST run controller converts input to the ability's input schema (`sanitize_input_for_ability()`), so REST input arrives typed. Schema validation still accepts integer and comma-separated strings, so a caller that bypasses the run controller, such as a direct `WP_Ability::execute()` call, can still pass them. The comments now say so. --- .../abilities/class-wp-content-abilities.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 8abc67b4f2dbd..86eb12201a718 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -252,8 +252,9 @@ private function input_int( $value ): int { * Unlike {@see self::input_int()}, which coerces any non-integer to 0, this rejects * values that are not integers, so an ID or a parent that cannot be honored fails * loudly instead of being read as 0: a `parent` filter of 0 asks for top-level posts. - * Accepts native integers and unsigned integer strings, mirroring how the JSON - * Schema `integer` type and the query-string transport respectively deliver them. + * Accepts native integers and unsigned integer strings. Schema validation accepts an + * integer string, and only the REST run controller converts input to the schema types, + * so callers that bypass it, such as a direct WP_Ability::execute() call, can pass one. * * @since 7.2.0 * @@ -278,9 +279,10 @@ private function parse_filter_int( $value, int $min ): ?int { /** * Parses a raw list input into a list of strings. * - * A GET request delivers list inputs as scalar/CSV strings; this parses them the - * same way schema validation did (wp_parse_list) so they are honored regardless of - * transport, until core sanitizes ability input itself. + * Schema validation accepts a list given as a scalar or comma-separated string, and + * only the REST run controller converts input to the schema types, so callers that + * bypass it, such as a direct WP_Ability::execute() call, can pass one. This parses + * it the same way validation did, with wp_parse_list(). * * @since 7.2.0 * @@ -887,8 +889,9 @@ private function normalize_include( array $input ): array { } /* - * A GET request delivers list inputs as scalar/CSV strings; wp_parse_id_list() - * accepts both and yields unique positive IDs, matching schema validation. + * Schema validation also accepts a comma-separated string, which callers that + * bypass the REST run controller can pass; wp_parse_id_list() accepts both forms + * and yields unique positive IDs. */ return array_values( array_filter( wp_parse_id_list( $include ) ) ); } From c3bb895985e0efc3e660dbc96c22cafb164a25f1 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 16:00:47 +0100 Subject: [PATCH 37/69] Abilities API: Align the `@param` tags in the content query ability The `list` type of `$fields` is wider than the other types, so the `@param` tags of `format_post()` and `build_post_fields()` were misaligned. --- src/wp-includes/abilities/class-wp-content-abilities.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 86eb12201a718..28053dd9de359 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -1248,7 +1248,7 @@ private function to_output_post( array $formatted ) { * * @since 7.2.0 * - * @param \WP_Post $post The post object. + * @param \WP_Post $post The post object. * @param list $fields The requested field names. * @return array The formatted post data. */ @@ -1289,10 +1289,10 @@ private function format_post( WP_Post $post, array $fields ): array { * * @since 7.2.0 * - * @param \WP_Post $post The post object. + * @param \WP_Post $post The post object. * @param list $fields The requested field names. - * @param bool $can_edit Whether the current user can edit the post. - * @param bool $is_protected Whether rendered fields must be withheld as password-protected. + * @param bool $can_edit Whether the current user can edit the post. + * @param bool $is_protected Whether rendered fields must be withheld as password-protected. * @return array The formatted post data. */ private function build_post_fields( WP_Post $post, array $fields, bool $can_edit, bool $is_protected ): array { From ec31fab5f9d1594249079419c8c3b2e539ac91c9 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 16:00:47 +0100 Subject: [PATCH 38/69] Abilities API: Drop the `open_world` annotation from the content query ability `WP_Ability` only documents the `readonly`, `destructive`, and `idempotent` annotations, and no other core ability sets `open_world`. Supporting it belongs in `WP_Ability`, in its own ticket. --- src/wp-includes/abilities/class-wp-content-abilities.php | 5 ----- .../tests/abilities-api/wpRegisterCoreContentAbility.php | 1 - 2 files changed, 6 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 28053dd9de359..0118d4be442ce 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -165,11 +165,6 @@ private function register_content_query(): void { 'readonly' => true, 'destructive' => false, 'idempotent' => true, - /* - * MCP clients assume open-world (may reach external systems) when the - * hint is absent; this ability only reads the local database. - */ - 'open_world' => false, ), 'public' => true, ), diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 79f4f7598e995..00a74725e7bc4 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -229,7 +229,6 @@ public function test_registers_core_content_query_ability(): void { $this->assertTrue( $annotations['readonly'], 'The ability should be marked read-only.' ); $this->assertFalse( $annotations['destructive'], 'The ability should be marked non-destructive.' ); $this->assertTrue( $annotations['idempotent'], 'The ability should be marked idempotent.' ); - $this->assertFalse( $annotations['open_world'], 'The ability should be marked closed-world; it only reads the local database.' ); } /** From 99f8d1c2a666573c6f98e6eae12f68f51e34e06f Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 16:40:31 +0100 Subject: [PATCH 39/69] Tests: Count only the callback's queries in the content query tests on multisite `count_queries()` flushes the object cache before it runs the callback. On multisite, `current_user_can()` then loads the current user again, because `WP_User::has_cap()` checks `is_super_admin()`. That extra users query failed the "author permalinks" case of `test_query_link_primes_the_caches_permalinks_read()` on every multisite job. The helper now loads the current user again right after the flush. --- .../tests/abilities-api/wpRegisterCoreContentAbility.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 00a74725e7bc4..861fe552c9ab1 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -2547,6 +2547,14 @@ private function count_queries( string $table, callable $callback, &$result ): i }; wp_cache_flush(); + + /* + * On multisite, capability checks load the current user to check whether they are a + * super admin. Load the user again after the flush so only the callback's own queries + * are counted. + */ + get_userdata( get_current_user_id() ); + add_filter( 'query', $spy ); try { $result = $callback(); From 04e40a2fe8fd92dbc2ea378a2084cfa0a2f17764 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 16:42:41 +0100 Subject: [PATCH 40/69] Abilities API: Restore every loop global after rendering content query fields The rendered excerpt and content set the global post and call `setup_postdata()`, then restored only the global post. When the main query has no post, as in a REST request, `wp_reset_postdata()` does nothing, so `$id`, `$authordata`, `$currentday`, `$currentmonth`, `$page`, `$pages`, `$multipage`, `$more`, and `$numpages` kept the rendered post's data. When a post was set up before, running `setup_postdata()` for it again recomputed those globals instead of restoring them, undoing a template's `$more = 1`. `set_up_post_context()` now saves the loop globals before rendering, and `restore_post_context()` gives each its previous value back or unsets it again. When a post was set up before, `setup_postdata()` still runs for it first, so callbacks on `the_post` can restore their own globals. --- .../abilities/class-wp-content-abilities.php | 103 ++++++++++---- .../wpRegisterCoreContentAbility.php | 126 ++++++++++++++++++ 2 files changed, 205 insertions(+), 24 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 0118d4be442ce..6620186d03f92 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -110,6 +110,26 @@ final class WP_Content_Abilities { 'title_rendered', ); + /** + * Globals that rendering a post changes: the global post and the globals that + * setup_postdata() populates. + * + * @since 7.2.0 + * @var list + */ + private array $loop_globals = array( + 'post', + 'id', + 'authordata', + 'currentday', + 'currentmonth', + 'page', + 'pages', + 'multipage', + 'more', + 'numpages', + ); + /** * Registers all content abilities. * @@ -1451,11 +1471,7 @@ public function return_raw_title_format(): string { * @return string Rendered post excerpt. */ private function get_rendered_excerpt( WP_Post $post ): string { - $previous_post = $GLOBALS['post'] ?? null; - - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily mirrors REST post context for excerpt rendering. - $GLOBALS['post'] = $post; - setup_postdata( $post ); + $previous_context = $this->set_up_post_context( $post ); /* * The global post context is restored in a finally block so a throw from an @@ -1471,14 +1487,7 @@ private function get_rendered_excerpt( WP_Post $post ): string { return is_string( $excerpt ) ? $excerpt : ''; } finally { - if ( $previous_post instanceof WP_Post ) { - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the previous global post context. - $GLOBALS['post'] = $previous_post; - setup_postdata( $previous_post ); - } else { - unset( $GLOBALS['post'] ); - wp_reset_postdata(); - } + $this->restore_post_context( $previous_context ); } } @@ -1494,11 +1503,7 @@ private function get_rendered_excerpt( WP_Post $post ): string { * @return string Rendered post content. */ private function get_rendered_content( WP_Post $post ): string { - $previous_post = $GLOBALS['post'] ?? null; - - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily mirrors REST post context for content rendering. - $GLOBALS['post'] = $post; - setup_postdata( $post ); + $previous_context = $this->set_up_post_context( $post ); /* * The global post context is restored in a finally block so a throw from a @@ -1511,13 +1516,63 @@ private function get_rendered_content( WP_Post $post ): string { return is_string( $content ) ? $content : ''; } finally { - if ( $previous_post instanceof WP_Post ) { - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the previous global post context. - $GLOBALS['post'] = $previous_post; - setup_postdata( $previous_post ); + $this->restore_post_context( $previous_context ); + } + } + + /** + * Sets up the global post context for rendering a post. + * + * Sets the global post and calls setup_postdata(), as the REST posts controller does + * before it renders a post, so filters that rely on loop globals render against the + * requested post. + * + * @since 7.2.0 + * + * @param \WP_Post $post The post to render. + * @return array The previous loop globals, keyed by name, leaving out those that were not set. + */ + private function set_up_post_context( WP_Post $post ): array { + $previous_context = array(); + foreach ( $this->loop_globals as $name ) { + if ( array_key_exists( $name, $GLOBALS ) ) { + $previous_context[ $name ] = $GLOBALS[ $name ]; + } + } + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily mirrors REST post context for rendering. + $GLOBALS['post'] = $post; + setup_postdata( $post ); + + return $previous_context; + } + + /** + * Restores the global post context saved by {@see self::set_up_post_context()}. + * + * Each loop global gets its previous value back, or is unset again when it was not set. + * wp_reset_postdata() alone is not enough: it does nothing when the main query has no + * post, which would leave the rendered post's data in the globals. When a post was set + * up before, setup_postdata() first runs for it again, as wp_reset_postdata() would, so + * callbacks on the `the_post` action can restore their own globals too. + * + * @since 7.2.0 + * + * @param array $previous_context The loop globals that set_up_post_context() returned. + */ + private function restore_post_context( array $previous_context ): void { + $previous_post = $previous_context['post'] ?? null; + if ( $previous_post instanceof WP_Post ) { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the previous global post context. + $GLOBALS['post'] = $previous_post; + setup_postdata( $previous_post ); + } + + foreach ( $this->loop_globals as $name ) { + if ( array_key_exists( $name, $previous_context ) ) { + $GLOBALS[ $name ] = $previous_context[ $name ]; } else { - unset( $GLOBALS['post'] ); - wp_reset_postdata(); + unset( $GLOBALS[ $name ] ); } } } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 861fe552c9ab1..f4ad71def1443 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -11,6 +11,27 @@ */ class Tests_Abilities_API_WpRegisterCoreContentAbility extends WP_UnitTestCase { + /** + * Globals that rendering a post changes: the global post and the globals that + * setup_postdata() populates. + * + * @since 7.2.0 + * + * @var list + */ + private const LOOP_GLOBALS = array( + 'post', + 'id', + 'authordata', + 'currentday', + 'currentmonth', + 'page', + 'pages', + 'multipage', + 'more', + 'numpages', + ); + /** * Shared user IDs keyed by role or fixture name. * @@ -2042,6 +2063,111 @@ public function test_excerpt_rendered_uses_and_restores_requested_post_context() ); } + /** + * Returns the rendered fields, whose filters run with the requested post set up as the + * global post. + * + * @return array Rendered field test cases. + */ + public function data_rendered_fields(): array { + return array( + 'content_rendered' => array( + 'field' => 'content_rendered', + ), + 'excerpt_rendered' => array( + 'field' => 'excerpt_rendered', + ), + ); + } + + /** + * Rendering a field unsets the loop globals that were not set before. + * + * Without a surrounding post, as in a REST request, wp_reset_postdata() cannot restore + * the globals that setup_postdata() populates, because the main query has no post. + * + * @ticket 64606 + * @dataProvider data_rendered_fields + * @since 7.2.0 + * + * @param string $field The rendered field to request. + */ + public function test_rendered_fields_unset_loop_globals_that_were_not_set( string $field ): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + foreach ( self::LOOP_GLOBALS as $name ) { + unset( $GLOBALS[ $name ] ); + } + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'id' => self::$post_ids['limited_role_content'], + 'fields' => array( $field ), + ) + ); + + $this->assertArrayHasKey( $field, $result, 'Precondition: the rendered field should be returned.' ); + $this->assertSame( array(), $this->get_loop_globals(), 'Loop globals that were not set should be unset again after rendering.' ); + } + + /** + * Rendering a field restores the surrounding loop globals as they were, rather than as + * setup_postdata() computes them for the surrounding post. + * + * @ticket 64606 + * @dataProvider data_rendered_fields + * @since 7.2.0 + * + * @param string $field The rendered field to request. + */ + public function test_rendered_fields_restore_surrounding_loop_globals( string $field ): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $surrounding = get_post( self::$post_ids['published'] ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Establishes a surrounding context to verify the ability restores it. + $GLOBALS['post'] = $surrounding; + setup_postdata( $surrounding ); + + /* + * A template can show the full content outside a single post view by setting `$more`, + * which running setup_postdata() for the surrounding post again would undo. + */ + $GLOBALS['more'] = 1; + + $surrounding_globals = $this->get_loop_globals(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'id' => self::$post_ids['limited_role_content'], + 'fields' => array( $field ), + ) + ); + + $this->assertArrayHasKey( $field, $result, 'Precondition: the rendered field should be returned.' ); + $this->assertSame( $surrounding_globals, $this->get_loop_globals(), 'The surrounding loop globals should be restored as they were.' ); + } + + /** + * Returns the loop globals that are set, keyed by name. + * + * @since 7.2.0 + * + * @return array The loop globals that are set. + */ + private function get_loop_globals(): array { + $globals = array(); + foreach ( self::LOOP_GLOBALS as $name ) { + if ( array_key_exists( $name, $GLOBALS ) ) { + $globals[ $name ] = $GLOBALS[ $name ]; + } + } + + return $globals; + } + /** * The password gate is suspended only for posts the current user can edit. * From 0fad0bcc4bd64945d4408b0efa89b6b4d72cb9e9 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 16:43:17 +0100 Subject: [PATCH 41/69] Abilities API: Describe author slugs without `core/users-query` The `author_slug` input and output descriptions, and the `get_author_by_slug()` docblock, said the slug is the one `core/users-query` returns. That ability is not in Core, so an agent would be pointed at an ability that does not exist. They now describe the slug as the user's nicename, which the REST API users endpoint returns as `slug`. --- .../abilities/class-wp-content-abilities.php | 16 ++++++++-------- .../wpRegisterCoreContentAbility.php | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 6620186d03f92..2d832703159ee 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -744,12 +744,12 @@ private function get_content_by_id( array $input ): ?WP_Post { /** * Looks up the user an author slug names. * - * The slug is the user's nicename, which `core/users-query` returns as `slug`. It must match - * exactly one user. A user the current user may not see is reported like a missing one. As - * in `core/users-query`, the current user can see themselves, any user when they can list - * users, and authors with posts in a publicly viewable post type. Unlike there, a user who - * can edit others' posts of the post type can see any user, since they may make any user - * the author, so for them the lookup does tell whether an account exists. + * The slug is the user's nicename, which the REST API users endpoint returns as `slug`. It + * must match exactly one user. A user the current user may not see is reported like a + * missing one. The current user can see themselves, any user when they can list users, and + * authors with posts in a publicly viewable post type. A user who can edit others' posts of + * the post type can also see any user, since they may make any user the author, so for them + * the lookup does tell whether an account exists. * * @since 7.2.0 * @@ -1017,7 +1017,7 @@ private function get_post_properties(): array { ), 'author_slug' => array( 'type' => 'string', - 'description' => __( "The author's user slug, as core/users-query returns it. Present when the post type supports authors. Empty when the author no longer exists." ), + 'description' => __( "The author's user slug (nicename), as the REST API users endpoint returns it in `slug`. Present when the post type supports authors. Empty when the author no longer exists." ), ), 'parent' => array( 'type' => 'integer', @@ -1145,7 +1145,7 @@ private function get_content_query_input_schema( array $post_types, array $statu 'author_slug' => array( 'type' => 'string', 'minLength' => 1, - 'description' => __( "Filter by the author's user slug, as core/users-query returns it. Only supported for post types that support authors." ), + 'description' => __( "Filter by the author's user slug (nicename), as the REST API users endpoint returns it in `slug`. Only supported for post types that support authors." ), ), 'parent' => array( 'type' => 'integer', diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index f4ad71def1443..5e8f0c78f918a 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -3136,9 +3136,9 @@ public function test_execute_callback_rejects_include_with_no_valid_ids(): void } /** - * An author_slug filter names only users the current user may see, as in core/users-query: - * a user without published posts is reported like a missing one to a subscriber, but not - * to an editor, who can edit their posts. + * An author_slug filter names only users the current user may see: a user without + * published posts is reported like a missing one to a subscriber, but not to an editor, + * who can edit their posts. * * @ticket 64606 * @since 7.2.0 From fc1b0798999d167d7f2ebc2ea3764988504026e0 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 16:44:42 +0100 Subject: [PATCH 42/69] Abilities API: Hide users of other sites from the author slug filter on multisite `get_author_by_slug()` searches every user of the network (`'blog_id' => 0`), and a user who can list users or edit others' posts could name any of them. On multisite, an editor of one site could therefore tell whether a nicename exists anywhere on the network: an existing user returned an empty list, while a missing one returned an error. The REST API users endpoint only shows users of the current site. Those two capabilities now only reveal members of the current site. The lookup still searches the whole network, so posts by authors who are not members, such as super admins, can be filtered when they have posts in a publicly viewable post type. --- .../abilities/class-wp-content-abilities.php | 23 ++++-- .../wpRegisterCoreContentAbility.php | 70 +++++++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 2d832703159ee..03b65c04d217c 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -746,10 +746,15 @@ private function get_content_by_id( array $input ): ?WP_Post { * * The slug is the user's nicename, which the REST API users endpoint returns as `slug`. It * must match exactly one user. A user the current user may not see is reported like a - * missing one. The current user can see themselves, any user when they can list users, and - * authors with posts in a publicly viewable post type. A user who can edit others' posts of - * the post type can also see any user, since they may make any user the author, so for them - * the lookup does tell whether an account exists. + * missing one. The current user can see themselves, any user of the site when they can list + * users, and authors with posts in a publicly viewable post type. A user who can edit others' + * posts of the post type can also see any user of the site, since they may make any of them + * the author, so for them the lookup does tell whether such an account exists. + * + * On multisite the lookup searches the whole network, so posts by authors who are not + * members of the site, such as super admins, can still be filtered. Those users are only + * visible as the current user or as authors with posts in a publicly viewable post type, so + * the lookup does not tell whether an account exists elsewhere on the network. * * @since 7.2.0 * @@ -783,9 +788,13 @@ static function ( $user ) use ( $slug ): bool { } $user = $users[0]; - if ( get_current_user_id() === $user->ID - || current_user_can( 'list_users' ) - || current_user_can( $post_type_object->cap->edit_others_posts ) + if ( get_current_user_id() === $user->ID ) { + return $user; + } + + // The capabilities only reveal users of the site, not of the whole network. + if ( ( ! is_multisite() || is_user_member_of_blog( $user->ID ) ) + && ( current_user_can( 'list_users' ) || current_user_can( $post_type_object->cap->edit_others_posts ) ) ) { return $user; } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 5e8f0c78f918a..d7732dcb764ec 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -3177,4 +3177,74 @@ public function test_author_slug_filter_only_names_users_the_current_user_may_se $this->assertIsArray( $drafts, 'An editor should filter by an author without published posts.' ); $this->assertSame( array( $draft_id ), wp_list_pluck( $drafts['posts'], 'id' ), 'The filter should return that author\'s drafts.' ); } + + /** + * On multisite, an author_slug filter does not tell an editor of one site whether a user + * of another site exists. + * + * @ticket 64606 + * @group ms-required + * @since 7.2.0 + */ + public function test_author_slug_filter_hides_users_of_other_sites(): void { + $other_user_id = self::factory()->user->create( array( 'user_nicename' => 'other-site-user' ) ); + $site_id = self::factory()->blog->create(); + + switch_to_blog( $site_id ); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + + $query = static function ( string $author_slug ) { + return ( new WP_Content_Abilities() )->execute_content_query( + array( + 'post_type' => 'post', + 'author_slug' => $author_slug, + ) + ); + }; + + $is_member = is_user_member_of_blog( $other_user_id, $site_id ); + $other = $query( 'other-site-user' ); + $missing = $query( 'no-such-user' ); + + restore_current_blog(); + + $this->assertFalse( $is_member, 'Precondition: the other user should not be a member of the site.' ); + $this->assertWPError( $missing, 'Precondition: a slug that names no user should be rejected.' ); + $this->assertWPError( $other, 'An editor should not learn that a user of another site exists.' ); + $this->assertSame( $missing->get_error_code(), $other->get_error_code(), 'A user of another site should be reported like a missing one.' ); + } + + /** + * On multisite, an author_slug filter still names an author who is not a member of the + * site, such as a super admin, when they have published posts there. + * + * @ticket 64606 + * @group ms-required + * @since 7.2.0 + */ + public function test_author_slug_filter_names_authors_who_are_not_site_members(): void { + $super_admin_id = self::factory()->user->create( array( 'user_nicename' => 'network-author' ) ); + grant_super_admin( $super_admin_id ); + $site_id = self::factory()->blog->create(); + + switch_to_blog( $site_id ); + $post_id = self::factory()->post->create( array( 'post_author' => $super_admin_id ) ); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + + $is_member = is_user_member_of_blog( $super_admin_id, $site_id ); + $result = ( new WP_Content_Abilities() )->execute_content_query( + array( + 'post_type' => 'post', + 'author_slug' => 'network-author', + 'fields' => array( 'id' ), + ) + ); + + restore_current_blog(); + revoke_super_admin( $super_admin_id ); + + $this->assertFalse( $is_member, 'Precondition: the super admin should not be a member of the site.' ); + $this->assertIsArray( $result, 'An author with published posts on the site should be filterable without being a member.' ); + $this->assertSame( array( $post_id ), wp_list_pluck( $result['posts'], 'id' ), 'The filter should return the author\'s posts on the site.' ); + } } From 3f1c8faa94aaf6b148c092ab56f3d8e677169056 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 16:44:54 +0100 Subject: [PATCH 43/69] Abilities API: Stop naming a future `core/manage-content` ability The class docblock and the `register()` comment named the future write ability `core/manage-content`, and the comment sketched a `register_manage_content()` call. The AI plugin is adding `core/content-create`, `core/content-update`, and `core/content-delete` instead (https://github.com/WordPress/ai/pull/1025), so both now refer to future write-oriented content abilities in general. --- .../abilities/class-wp-content-abilities.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 03b65c04d217c..4022db744ebab 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -19,8 +19,8 @@ * posts the current user can edit. * * The class is intentionally structured around shared building blocks (exposed post type - * discovery, schema generation, per-post formatting and permission checks) so a future - * write-oriented `core/manage-content` ability can reuse them. + * discovery, schema generation, per-post formatting and permission checks) so future + * write-oriented content abilities can reuse them. * * This class is part of WordPress' internal implementation of the core abilities and is * not part of the public API. It may be changed or removed at any time without notice. @@ -141,10 +141,8 @@ public function register(): void { $this->register_content_query(); /* - * A future write-oriented ability can be registered here, reusing the shared - * helpers below (get_exposed_post_type(), format_post(), check_permission()): - * - * $this->register_manage_content(); + * Future write-oriented content abilities can be registered here, reusing the + * shared helpers below (get_exposed_post_type(), format_post(), check_permission()). */ } From f0198d89e5e8fc62c9bf10661bf05d92690aa15f Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 16:45:24 +0100 Subject: [PATCH 44/69] Abilities API: Document duplicate slugs in hierarchical post types The slug lookup already documented that slugs repeat across statuses. In a hierarchical post type, posts under different parents can share a slug too, and a lookup by post type and slug cannot tell them apart. The `get_post_by_slug()` docblock and the `slug` input description now say so, and point callers that need a specific post to its ID. --- src/wp-includes/abilities/class-wp-content-abilities.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 4022db744ebab..5592db0012ef3 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -813,6 +813,10 @@ static function ( $user ) use ( $slug ): bool { * requested fields; edit-field requests are gated afterwards on the resolved * post by {@see self::check_permission()}. * + * In hierarchical post types, posts under different parents can also share a + * slug. The lookup cannot tell them apart and resolves to one of them by the + * same rules, so callers that need a specific post should look it up by ID. + * * @since 7.2.0 * * @param string $post_type The post type. @@ -1124,7 +1128,7 @@ private function get_content_query_input_schema( array $post_types, array $statu 'slug' => array( 'type' => 'string', 'minLength' => 1, - 'description' => __( 'Retrieve a single readable post by slug. Resolves to the newest readable match, preferring published posts.' ), + 'description' => __( 'Retrieve a single readable post by slug. Resolves to the newest readable match, preferring published posts. In hierarchical post types, posts under different parents can share a slug; use `id` to get a specific one.' ), ), 'fields' => $fields, ), From a2bf9834b254c3389bbdabd3387676f87df06d27 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 17:41:22 +0100 Subject: [PATCH 45/69] Abilities API: Use early exits in the content query loop global helpers The AI plugin's coding standards ask for early exits in loops, so its copy of `set_up_post_context()` skips unset globals with `continue`. Core now does the same, so the two classes stay identical (https://github.com/WordPress/ai/pull/1025). The test helper that reads the loop globals follows the same shape. --- src/wp-includes/abilities/class-wp-content-abilities.php | 6 ++++-- .../tests/abilities-api/wpRegisterCoreContentAbility.php | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 5592db0012ef3..efb518da2792a 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -1546,9 +1546,11 @@ private function get_rendered_content( WP_Post $post ): string { private function set_up_post_context( WP_Post $post ): array { $previous_context = array(); foreach ( $this->loop_globals as $name ) { - if ( array_key_exists( $name, $GLOBALS ) ) { - $previous_context[ $name ] = $GLOBALS[ $name ]; + if ( ! array_key_exists( $name, $GLOBALS ) ) { + continue; } + + $previous_context[ $name ] = $GLOBALS[ $name ]; } // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily mirrors REST post context for rendering. diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index d7732dcb764ec..2499e8470c563 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -2160,9 +2160,11 @@ public function test_rendered_fields_restore_surrounding_loop_globals( string $f private function get_loop_globals(): array { $globals = array(); foreach ( self::LOOP_GLOBALS as $name ) { - if ( array_key_exists( $name, $GLOBALS ) ) { - $globals[ $name ] = $GLOBALS[ $name ]; + if ( ! array_key_exists( $name, $GLOBALS ) ) { + continue; } + + $globals[ $name ] = $GLOBALS[ $name ]; } return $globals; From 77d14e9bc4bf9623f7b49e922f37d54446376288 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 19:51:33 +0100 Subject: [PATCH 46/69] Abilities API: Prime parent caches in the content query ability Requesting `link` for a hierarchical post type loaded the parent of each returned page with its own query, because page permalinks walk the ancestors through `get_page_uri()`. Inherited read permissions read the parent too. The REST posts controller primes both the parent and the author caches, but query mode, whose comment says it mirrors that controller, only primed the authors. Query mode now calls `update_post_parent_caches()` as well, which runs no query when no returned post has a parent. A new test requests `link` for three child pages and expects three queries against the posts table. Before, each parent took its own query, for five in total. --- .../abilities/class-wp-content-abilities.php | 6 ++- .../wpRegisterCoreContentAbility.php | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index efb518da2792a..240dff26850ab 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -603,10 +603,12 @@ public function execute_content_query( $input = array() ) { } /* - * Prime the author caches with a single query instead of one user lookup - * per post, mirroring the REST posts controller. Besides `author_slug`, + * Prime the parent and author caches with a single query each instead of one + * lookup per post, mirroring the REST posts controller. Hierarchical permalinks + * and inherited read permissions read the parent. Besides `author_slug`, * permalinks read the author when the permalink structure contains `%author%`. */ + update_post_parent_caches( $query->posts ); $author_fields = array_intersect( array( 'author_slug', 'link' ), $fields ); if ( array() !== $author_fields && post_type_supports( $post_type, 'author' ) ) { $query_posts = array_filter( diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 2499e8470c563..32eeac4d95d61 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -2648,6 +2648,46 @@ static function () use ( $ids ) { $this->assertSame( 1, $queries, 'Permalinks should read primed caches, not query once per returned post.' ); } + /** + * Requesting `link` primes the parents that page permalinks read for the whole page. + * + * @ticket 64606 + * @since 7.2.0 + */ + public function test_query_link_primes_the_parents_page_permalinks_read(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + $this->set_permalink_structure( '/%postname%/' ); + + $ids = array(); + for ( $i = 0; $i < 3; $i++ ) { + $ids[] = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_parent' => self::factory()->post->create( array( 'post_type' => 'page' ) ), + ) + ); + } + + $queries = $this->count_queries( + 'posts', + static function () use ( $ids ) { + return wp_get_ability( 'core/content-query' )->execute( + array( + 'post_type' => 'page', + 'include' => $ids, + 'fields' => array( 'id', 'link' ), + ) + ); + }, + $result + ); + + $this->assertCount( 3, $result['posts'], 'Precondition: the query should return the seeded pages.' ); + // One query finds the matching IDs, one loads those pages, and one loads their parents. + $this->assertSame( 3, $queries, 'Page permalinks should read primed parents, not query once per returned page.' ); + } + /** * Counts the queries against a table issued while running the given callback. * From 7d895023f67129456fd8392923e4b54694adc319 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 19:51:33 +0100 Subject: [PATCH 47/69] Tests: Cover the editor password bypass for generated content query excerpts `format_post()` unlocks a password-protected post for a user who can edit it. The excerpt test set an explicit `post_excerpt`, which `wp_trim_excerpt()` returns as is, so it passed even without the bypass. The post now has no excerpt, so it is generated from the content, which `get_the_content()` replaces with the password form unless the post is unlocked. --- .../abilities-api/wpRegisterCoreContentAbility.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 32eeac4d95d61..72d5b166e26ed 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -1895,6 +1895,9 @@ public function test_password_protected_rendered_content_is_empty_for_roles_with /** * Password-protected excerpts render for users who can edit the post. * + * The excerpt is generated from the content, which get_the_content() replaces with the + * password form unless the ability unlocks the post for its editor. + * * @ticket 64606 * @since 7.2.0 */ @@ -1906,7 +1909,8 @@ public function test_password_protected_excerpt_visible_to_editor(): void { array( 'post_status' => 'publish', 'post_password' => 'secret', - 'post_excerpt' => 'Top secret excerpt.', + 'post_content' => 'Top secret body.', + 'post_excerpt' => '', ) ); @@ -1918,9 +1922,9 @@ public function test_password_protected_excerpt_visible_to_editor(): void { ); $this->assertSame( - "

Top secret excerpt.

\n", + "

Top secret body.

\n", $result['excerpt_rendered'], - 'Editors should receive the real rendered excerpt for password-protected posts.' + 'Editors should receive the real excerpt generated from a password-protected post.' ); $this->assertTrue( $result['excerpt_protected'], 'The protected flag should reveal the excerpt is password-protected.' ); } From 6a8d5c327d8aea4fdae74dc78a5732060fd7a5c5 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 19:51:33 +0100 Subject: [PATCH 48/69] Tests: Follow Core conventions in the content query tests - Use the `set_up()` and `tear_down()` fixtures instead of PHPUnit's `setUp()` and `tearDown()`. - Drop teardown steps the test case already takes care of: its `set_up()` registers the built-in post types again, and its `tear_down()` logs the user out. - Drop the `wpai` prefix, which came from the AI plugin, from the post types, statuses, slugs, and nicenames the tests create. - Drop the leading backslashes from class names, since the tests are not namespaced. - Align the `@param` tags of `replace_cached_post_date_columns()`. - Use the `restapi` group, like the other REST API tests. --- .../wpRegisterCoreContentAbility.php | 91 +++++++++---------- .../wpRestAbilitiesContentController.php | 2 +- 2 files changed, 42 insertions(+), 51 deletions(-) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 72d5b166e26ed..5286b6c6a51e7 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -55,7 +55,7 @@ class Tests_Abilities_API_WpRegisterCoreContentAbility extends WP_UnitTestCase { * * @since 7.2.0 * - * @param \WP_UnitTest_Factory $factory The unit test factory. + * @param WP_UnitTest_Factory $factory The unit test factory. */ public static function wpSetUpBeforeClass( $factory ): void { self::$user_ids = array( @@ -128,8 +128,8 @@ public static function wpSetUpBeforeClass( $factory ): void { * * @since 7.2.0 */ - public function setUp(): void { - parent::setUp(); + public function set_up(): void { + parent::set_up(); if ( wp_has_ability( 'core/content-query' ) ) { wp_unregister_ability( 'core/content-query' ); @@ -139,11 +139,11 @@ public function setUp(): void { } /** - * Restores ability, ability category, and post type state after each test. + * Removes the ability and its category after each test. * * @since 7.2.0 */ - public function tearDown(): void { + public function tear_down(): void { if ( wp_has_ability( 'core/content-query' ) ) { wp_unregister_ability( 'core/content-query' ); } @@ -152,16 +152,7 @@ public function tearDown(): void { wp_unregister_ability_category( 'content' ); } - foreach ( array( 'post', 'page' ) as $post_type ) { - $object = get_post_type_object( $post_type ); - if ( $object ) { - $object->show_in_abilities = true; - } - } - - wp_set_current_user( 0 ); - - parent::tearDown(); + parent::tear_down(); } /** @@ -429,7 +420,7 @@ public function test_output_schema_describes_single_post_and_query_responses(): */ public function test_exposes_a_post_type_registered_by_another_plugin(): void { register_post_type( - 'wpai_content_cpt', + 'content_cpt', array( 'public' => true, 'show_in_abilities' => true, @@ -442,16 +433,16 @@ public function test_exposes_a_post_type_registered_by_another_plugin(): void { // Query mode is the third `oneOf` branch; its `post_type` enum lists exposed types. $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; - $this->assertContains( 'wpai_content_cpt', $enum, 'Custom post types marked show_in_abilities should appear in the query enum.' ); + $this->assertContains( 'content_cpt', $enum, 'Custom post types marked show_in_abilities should appear in the query enum.' ); $post_id = self::factory()->post->create( array( - 'post_type' => 'wpai_content_cpt', + 'post_type' => 'content_cpt', 'post_status' => 'publish', ) ); - $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'wpai_content_cpt' ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'content_cpt' ) ); $ids = wp_list_pluck( $result['posts'], 'id' ); $this->assertContains( $post_id, $ids, 'The custom post type should be queryable through the content ability.' ); @@ -472,7 +463,7 @@ public function test_schema_filter_exposes_late_registered_post_type(): void { } foreach ( $args['input_schema']['oneOf'] as $index => $mode ) { - $args['input_schema']['oneOf'][ $index ]['properties']['post_type']['enum'][] = 'wpai_late_cpt'; + $args['input_schema']['oneOf'][ $index ]['properties']['post_type']['enum'][] = 'late_cpt'; } return $args; @@ -486,10 +477,10 @@ public function test_schema_filter_exposes_late_registered_post_type(): void { } $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; - $this->assertContains( 'wpai_late_cpt', $enum, 'The ability args filter should amend the frozen schema enum.' ); + $this->assertContains( 'late_cpt', $enum, 'The ability args filter should amend the frozen schema enum.' ); register_post_type( - 'wpai_late_cpt', + 'late_cpt', array( 'public' => true, 'show_in_abilities' => true, @@ -499,14 +490,14 @@ public function test_schema_filter_exposes_late_registered_post_type(): void { $post_id = self::factory()->post->create( array( - 'post_type' => 'wpai_late_cpt', + 'post_type' => 'late_cpt', 'post_status' => 'publish', ) ); $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'wpai_late_cpt', + 'post_type' => 'late_cpt', 'fields' => array( 'id' ), ) ); @@ -667,7 +658,7 @@ public function test_get_by_id_with_mismatched_post_type_is_denied(): void { */ public function test_get_by_id_for_unexposed_post_type_is_denied(): void { register_post_type( - 'wpai_hidden_cpt', + 'hidden_cpt', array( 'public' => true, 'show_in_rest' => false, @@ -679,7 +670,7 @@ public function test_get_by_id_for_unexposed_post_type_is_denied(): void { $post_id = self::factory()->post->create( array( - 'post_type' => 'wpai_hidden_cpt', + 'post_type' => 'hidden_cpt', 'post_status' => 'publish', ) ); @@ -701,7 +692,7 @@ public function test_get_by_id_for_unexposed_post_type_is_denied(): void { */ public function test_public_non_viewable_status_is_denied_for_read_only_users(): void { register_post_status( - 'wpai_public_hidden', + 'public_hidden', array( 'label' => 'Public hidden', 'public' => true, @@ -711,7 +702,7 @@ public function test_public_non_viewable_status_is_denied_for_read_only_users(): $post_id = self::factory()->post->create( array( - 'post_status' => 'wpai_public_hidden', + 'post_status' => 'public_hidden', ) ); @@ -732,7 +723,7 @@ public function test_public_non_viewable_status_is_denied_for_read_only_users(): */ public function test_public_non_viewable_status_is_readable_with_edit_access(): void { register_post_status( - 'wpai_public_hidden', + 'public_hidden', array( 'label' => 'Public hidden', 'public' => true, @@ -743,7 +734,7 @@ public function test_public_non_viewable_status_is_readable_with_edit_access(): $post_id = self::factory()->post->create( array( 'post_title' => 'Hidden public status', - 'post_status' => 'wpai_public_hidden', + 'post_status' => 'public_hidden', ) ); @@ -765,7 +756,7 @@ public function test_public_non_viewable_status_is_readable_with_edit_access(): */ public function test_inherited_post_is_readable_when_parent_is_readable(): void { register_post_type( - 'wpai_inherit_cpt', + 'inherit_cpt', array( 'public' => true, 'show_in_abilities' => true, @@ -776,14 +767,14 @@ public function test_inherited_post_is_readable_when_parent_is_readable(): void $parent_id = self::factory()->post->create( array( 'post_author' => self::$user_ids['administrator'], - 'post_type' => 'wpai_inherit_cpt', + 'post_type' => 'inherit_cpt', 'post_status' => 'publish', ) ); $child_id = self::factory()->post->create( array( 'post_author' => self::$user_ids['administrator'], - 'post_type' => 'wpai_inherit_cpt', + 'post_type' => 'inherit_cpt', 'post_parent' => $parent_id, 'post_status' => 'inherit', 'post_title' => 'Inherited child', @@ -808,7 +799,7 @@ public function test_inherited_post_is_readable_when_parent_is_readable(): void */ public function test_inherited_post_without_parent_is_denied_for_read_only_users(): void { register_post_type( - 'wpai_inherit_cpt', + 'inherit_cpt', array( 'public' => true, 'show_in_abilities' => true, @@ -819,7 +810,7 @@ public function test_inherited_post_without_parent_is_denied_for_read_only_users $post_id = self::factory()->post->create( array( 'post_author' => self::$user_ids['administrator'], - 'post_type' => 'wpai_inherit_cpt', + 'post_type' => 'inherit_cpt', 'post_status' => 'inherit', ) ); @@ -1140,7 +1131,7 @@ public function test_slug_lookup_is_not_bounded_by_a_page_size(): void { $published = self::factory()->post->create( array( 'post_status' => 'publish', - 'post_name' => 'wpai-slug-not-bounded', + 'post_name' => 'slug-not-bounded', 'post_date' => '2026-01-01 10:00:00', ) ); @@ -1153,13 +1144,13 @@ public function test_slug_lookup_is_not_bounded_by_a_page_size(): void { self::factory()->post->create( array( 'post_status' => 'draft', - 'post_name' => 'wpai-slug-not-bounded', + 'post_name' => 'slug-not-bounded', 'post_date' => '2026-03-01 10:00:00', ) ); } - $sharing = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_name = %s", 'wpai-slug-not-bounded' ) ); // phpcs:ignore WordPress.DB + $sharing = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_name = %s", 'slug-not-bounded' ) ); // phpcs:ignore WordPress.DB $this->assertGreaterThan( 100, $sharing, 'Precondition: more posts share the slug than a single page holds.' ); $this->login_as( 'subscriber' ); @@ -1168,7 +1159,7 @@ public function test_slug_lookup_is_not_bounded_by_a_page_size(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', - 'slug' => 'wpai-slug-not-bounded', + 'slug' => 'slug-not-bounded', 'fields' => array( 'id' ), ) ); @@ -1748,7 +1739,7 @@ public function test_query_mode_filters_pages_by_parent(): void { */ public function test_query_mode_rejects_author_filter_for_post_type_without_author_support(): void { register_post_type( - 'wpai_no_author_cpt', + 'no_author_cpt', array( 'public' => true, 'show_in_abilities' => true, @@ -1761,7 +1752,7 @@ public function test_query_mode_rejects_author_filter_for_post_type_without_auth $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'wpai_no_author_cpt', + 'post_type' => 'no_author_cpt', 'author_slug' => get_userdata( self::$user_ids['author'] )->user_nicename, ) ); @@ -2023,7 +2014,7 @@ public function test_excerpt_rendered_uses_and_restores_requested_post_context() $surrounding = get_post( self::$post_ids['published'] ); $previous_post = $GLOBALS['post'] ?? null; - $this->assertInstanceOf( \WP_Post::class, $surrounding, 'The surrounding post fixture should exist.' ); + $this->assertInstanceOf( WP_Post::class, $surrounding, 'The surrounding post fixture should exist.' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Establishes a distinct context to verify the ability restores it. $GLOBALS['post'] = $surrounding; @@ -2045,7 +2036,7 @@ public function test_excerpt_rendered_uses_and_restores_requested_post_context() } finally { remove_filter( 'the_excerpt', $append_context_id, 20 ); - if ( $previous_post instanceof \WP_Post ) { + if ( $previous_post instanceof WP_Post ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the context that preceded the test. $GLOBALS['post'] = $previous_post; setup_postdata( $previous_post ); @@ -2901,14 +2892,14 @@ public function test_gmt_date_is_derived_from_local_date_for_drafts(): void { * * @since 7.2.0 * - * @param int $post_id The post ID. + * @param int $post_id The post ID. * @param array $columns Post column values keyed by column name. */ private function replace_cached_post_date_columns( int $post_id, array $columns ): void { get_post( $post_id ); $cached = wp_cache_get( $post_id, 'posts' ); - $this->assertInstanceOf( \stdClass::class, $cached, 'Precondition: the raw post row should be cached.' ); + $this->assertInstanceOf( stdClass::class, $cached, 'Precondition: the raw post row should be cached.' ); $this->assertSame( 'raw', $cached->filter, 'Precondition: the cached row should be unsanitized.' ); foreach ( $columns as $column => $value ) { @@ -3084,8 +3075,8 @@ public function test_execute_callback_returns_not_found_for_structural_lookup_fa public function data_author_slugs_that_name_no_single_user(): array { return array( 'unknown slug' => array( 'no-such-user' ), - 'shared slug' => array( 'wpai-shared-slug' ), - 'slug in capitals' => array( 'WPAI-AUTHOR-SLUG' ), + 'shared slug' => array( 'shared-slug' ), + 'slug in capitals' => array( 'AUTHOR-SLUG' ), 'not a string' => array( 5 ), ); } @@ -3104,9 +3095,9 @@ public function test_execute_callback_rejects_an_author_slug_that_names_no_singl // Core keeps nicenames unique, so give two users the same one through the database. $nicenames = array( - self::$user_ids['author'] => 'wpai-author-slug', - self::$user_ids['contributor'] => 'wpai-shared-slug', - self::$user_ids['author_secondary'] => 'wpai-shared-slug', + self::$user_ids['author'] => 'author-slug', + self::$user_ids['contributor'] => 'shared-slug', + self::$user_ids['author_secondary'] => 'shared-slug', ); foreach ( $nicenames as $user_id => $nicename ) { $wpdb->update( $wpdb->users, array( 'user_nicename' => $nicename ), array( 'ID' => $user_id ) ); diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php index 6d17f5ec60a46..cf54bd3da9b70 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -8,7 +8,7 @@ * @covers WP_Content_Abilities * * @group abilities-api - * @group rest-api + * @group restapi */ class Tests_REST_API_WpRestAbilitiesContentController extends WP_UnitTestCase { From 37e5e4154e1bf9618268156677b49071e8a1def3 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 19:51:33 +0100 Subject: [PATCH 49/69] Abilities API: Tidy the content query ability class - Drop the leading backslashes from class names, since the class is not namespaced. - Make the field lists and the loop globals class constants, like `CATEGORY` and `MAX_PER_PAGE`, since they never change. - Label the ability "Query Content", so that it starts with a verb, like "Get Site Information". --- .../abilities/class-wp-content-abilities.php | 122 +++++++----------- 1 file changed, 48 insertions(+), 74 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 240dff26850ab..cdc498adfcee6 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -64,11 +64,7 @@ final class WP_Content_Abilities { * @since 7.2.0 * @var list */ - private array $edit_fields = array( - 'title_raw', - 'excerpt_raw', - 'content_raw', - ); + private const EDIT_FIELDS = array( 'title_raw', 'excerpt_raw', 'content_raw' ); /** * Fields whose output may read post meta or terms. @@ -81,54 +77,32 @@ final class WP_Content_Abilities { * @since 7.2.0 * @var list */ - private array $cache_priming_fields = array( - 'link', - 'excerpt_rendered', - 'content_rendered', - ); + private const CACHE_PRIMING_FIELDS = array( 'link', 'excerpt_rendered', 'content_rendered' ); /** - * Cached post field definitions, keyed by field name in output order. + * Default fields returned when the caller does not request a field subset. * * @since 7.2.0 - * @var array|null + * @var list */ - private ?array $post_properties = null; + private const DEFAULT_FIELDS = array( 'id', 'post_type', 'status', 'date', 'slug', 'title_rendered' ); /** - * Default fields returned when the caller does not request a field subset. + * Globals that rendering a post changes: the global post and the globals that + * setup_postdata() populates. * * @since 7.2.0 * @var list */ - private array $default_fields = array( - 'id', - 'post_type', - 'status', - 'date', - 'slug', - 'title_rendered', - ); + private const LOOP_GLOBALS = array( 'post', 'id', 'authordata', 'currentday', 'currentmonth', 'page', 'pages', 'multipage', 'more', 'numpages' ); /** - * Globals that rendering a post changes: the global post and the globals that - * setup_postdata() populates. + * Cached post field definitions, keyed by field name in output order. * * @since 7.2.0 - * @var list + * @var array|null */ - private array $loop_globals = array( - 'post', - 'id', - 'authordata', - 'currentday', - 'currentmonth', - 'page', - 'pages', - 'multipage', - 'more', - 'numpages', - ); + private ?array $post_properties = null; /** * Registers all content abilities. @@ -171,7 +145,7 @@ private function register_content_query(): void { wp_register_ability( 'core/content-query', array( - 'label' => __( 'Content Query' ), + 'label' => __( 'Query Content' ), 'description' => __( 'Reads content from post types exposed to abilities. Single-post lookups by ID or by post type and slug return the post object directly. Query mode returns readable posts filtered by post type, status, author, parent, or included IDs. Requires an authenticated user. Lookups and filters are exact-match only; the ability does not perform full-text search.' ), 'category' => self::CATEGORY, 'input_schema' => $this->get_content_query_input_schema( $post_types, $statuses ), @@ -324,7 +298,7 @@ private function parse_list_input( array $input, string $key ): array { * @return bool True if edit-context fields were explicitly requested. */ private function has_explicit_edit_fields( array $input ): bool { - return array() !== array_intersect( $this->edit_fields, $this->parse_list_input( $input, 'fields' ) ); + return array() !== array_intersect( self::EDIT_FIELDS, $this->parse_list_input( $input, 'fields' ) ); } /** @@ -336,11 +310,11 @@ private function has_explicit_edit_fields( array $input ): bool { * * @since 7.2.0 * - * @param array $input The ability input. - * @param \WP_Post_Type $post_type_object The post type object. + * @param array $input The ability input. + * @param WP_Post_Type $post_type_object The post type object. * @return bool True if the requested statuses may be queried. */ - private function can_query_statuses( array $input, \WP_Post_Type $post_type_object ): bool { + private function can_query_statuses( array $input, WP_Post_Type $post_type_object ): bool { foreach ( $this->normalize_statuses( $input ) as $status ) { if ( 'publish' === $status ) { continue; @@ -368,7 +342,7 @@ private function can_query_statuses( array $input, \WP_Post_Type $post_type_obje * * @since 7.2.0 * - * @param \WP_Post $post Post object. + * @param WP_Post $post Post object. * @param array $checked_post_ids Post IDs already checked while walking inherited parents. * @return bool Whether the post can be read. */ @@ -402,7 +376,7 @@ private function check_read_permission( WP_Post $post, array $checked_post_ids = } $post_status_object = get_post_status_object( $post_status ); - if ( ! $post_status_object instanceof \stdClass ) { + if ( ! $post_status_object instanceof stdClass ) { return false; } @@ -455,7 +429,7 @@ private function check_read_permission( WP_Post $post, array $checked_post_ids = * @since 7.2.0 * * @param mixed $input Optional. The ability input. Default empty array. - * @return array|\stdClass|\WP_Error A single post, a `posts` list with totals in query mode, or a WP_Error. + * @return array|stdClass|WP_Error A single post, a `posts` list with totals in query mode, or a WP_Error. */ public function execute_content_query( $input = array() ) { $input = rest_sanitize_object( $input ); @@ -681,7 +655,7 @@ private function normalize_per_page( array $input, array $include_ids = array() * * @since 7.2.0 * - * @param \WP_Query $query The executed query. + * @param WP_Query $query The executed query. * @param array $query_args The arguments used for the executed query. * @param int $page The requested page. * @return int Total matching rows across all pages. @@ -714,7 +688,7 @@ private function get_query_total( WP_Query $query, array $query_args, int $page * @return bool True when post meta and term caches should be primed. */ private function should_prime_post_caches( array $fields ): bool { - return array() !== array_intersect( $this->cache_priming_fields, $fields ); + return array() !== array_intersect( self::CACHE_PRIMING_FIELDS, $fields ); } /** @@ -728,7 +702,7 @@ private function should_prime_post_caches( array $fields ): bool { * @since 7.2.0 * * @param array $input The ability input. - * @return \WP_Post|null The post, or null when it cannot be resolved. + * @return WP_Post|null The post, or null when it cannot be resolved. */ private function get_content_by_id( array $input ): ?WP_Post { $post_id = isset( $input['id'] ) ? $this->parse_filter_int( $input['id'], 1 ) : null; @@ -758,11 +732,11 @@ private function get_content_by_id( array $input ): ?WP_Post { * * @since 7.2.0 * - * @param mixed $slug The author slug. - * @param \WP_Post_Type $post_type_object The post type the author is looked up for. - * @return \WP_User|null The user, or null when the slug does not name exactly one visible user. + * @param mixed $slug The author slug. + * @param WP_Post_Type $post_type_object The post type the author is looked up for. + * @return WP_User|null The user, or null when the slug does not name exactly one visible user. */ - private function get_author_by_slug( $slug, \WP_Post_Type $post_type_object ): ?\WP_User { + private function get_author_by_slug( $slug, WP_Post_Type $post_type_object ): ?WP_User { if ( ! is_string( $slug ) || '' === $slug ) { return null; } @@ -778,7 +752,7 @@ private function get_author_by_slug( $slug, \WP_Post_Type $post_type_object ): ? ) ), static function ( $user ) use ( $slug ): bool { - return $user instanceof \WP_User && $user->user_nicename === $slug; + return $user instanceof WP_User && $user->user_nicename === $slug; } ) ); @@ -823,7 +797,7 @@ static function ( $user ) use ( $slug ): bool { * * @param string $post_type The post type. * @param string $slug The post slug. - * @return \WP_Post|null The matching readable post, or null when none exists. + * @return WP_Post|null The matching readable post, or null when none exists. */ private function get_post_by_slug( string $post_type, string $slug ): ?WP_Post { $name = sanitize_title( $slug ); @@ -880,9 +854,9 @@ private function get_post_by_slug( string $post_type, string $slug ): ?WP_Post { * @since 7.2.0 * * @param mixed $post_type The post type name. - * @return \WP_Post_Type|null The post type object, or null when the post type is not exposed. + * @return WP_Post_Type|null The post type object, or null when the post type is not exposed. */ - private function get_exposed_post_type( $post_type ): ?\WP_Post_Type { + private function get_exposed_post_type( $post_type ): ?WP_Post_Type { $post_type_object = is_string( $post_type ) ? get_post_type_object( $post_type ) : null; return empty( $post_type_object->show_in_abilities ) ? null : $post_type_object; @@ -939,7 +913,7 @@ private function normalize_include( array $input ): array { private function normalize_fields( array $input ): array { $fields = $this->parse_list_input( $input, 'fields' ); - return array() === $fields ? $this->default_fields : $fields; + return array() === $fields ? self::DEFAULT_FIELDS : $fields; } /** @@ -948,7 +922,7 @@ private function normalize_fields( array $input ): array { * This is the single source of truth for the ability's post fields: the output * schema uses the definitions directly, while the input schema fields enum uses * the keys. Read-context fields are returned for readable posts; the edit-context - * fields listed in {@see self::$edit_fields} additionally require edit access. + * fields listed in {@see self::EDIT_FIELDS} additionally require edit access. * * @since 7.2.0 * @@ -1260,7 +1234,7 @@ private function get_content_query_output_schema(): array { * @since 7.2.0 * * @param array $formatted The formatted post data. - * @return array|\stdClass The post data, or an empty object when the projection is empty. + * @return array|stdClass The post data, or an empty object when the projection is empty. */ private function to_output_post( array $formatted ) { return array() === $formatted ? (object) array() : $formatted; @@ -1276,7 +1250,7 @@ private function to_output_post( array $formatted ) { * * @since 7.2.0 * - * @param \WP_Post $post The post object. + * @param WP_Post $post The post object. * @param list $fields The requested field names. * @return array The formatted post data. */ @@ -1317,7 +1291,7 @@ private function format_post( WP_Post $post, array $fields ): array { * * @since 7.2.0 * - * @param \WP_Post $post The post object. + * @param WP_Post $post The post object. * @param list $fields The requested field names. * @param bool $can_edit Whether the current user can edit the post. * @param bool $is_protected Whether rendered fields must be withheld as password-protected. @@ -1326,9 +1300,9 @@ private function format_post( WP_Post $post, array $fields ): array { private function build_post_fields( WP_Post $post, array $fields, bool $can_edit, bool $is_protected ): array { $post_type = $post->post_type; - // Edit-context fields require edit access; drop them so $edit_fields is the single gate. + // Edit-context fields require edit access; drop them so EDIT_FIELDS is the single gate. if ( ! $can_edit ) { - $fields = array_diff( $fields, $this->edit_fields ); + $fields = array_diff( $fields, self::EDIT_FIELDS ); } $requested = array_flip( $fields ); @@ -1433,7 +1407,7 @@ public function allow_password_content( $required, $post ): bool { * * @since 7.2.0 * - * @param \WP_Post $post The post object. + * @param WP_Post $post The post object. * @return string The post title. */ private function get_title( WP_Post $post ): string { @@ -1480,7 +1454,7 @@ public function return_raw_title_format(): string { * * @since 7.2.0 * - * @param \WP_Post $post The post object. + * @param WP_Post $post The post object. * @return string Rendered post excerpt. */ private function get_rendered_excerpt( WP_Post $post ): string { @@ -1512,7 +1486,7 @@ private function get_rendered_excerpt( WP_Post $post ): string { * * @since 7.2.0 * - * @param \WP_Post $post The post object. + * @param WP_Post $post The post object. * @return string Rendered post content. */ private function get_rendered_content( WP_Post $post ): string { @@ -1542,12 +1516,12 @@ private function get_rendered_content( WP_Post $post ): string { * * @since 7.2.0 * - * @param \WP_Post $post The post to render. + * @param WP_Post $post The post to render. * @return array The previous loop globals, keyed by name, leaving out those that were not set. */ private function set_up_post_context( WP_Post $post ): array { $previous_context = array(); - foreach ( $this->loop_globals as $name ) { + foreach ( self::LOOP_GLOBALS as $name ) { if ( ! array_key_exists( $name, $GLOBALS ) ) { continue; } @@ -1583,7 +1557,7 @@ private function restore_post_context( array $previous_context ): void { setup_postdata( $previous_post ); } - foreach ( $this->loop_globals as $name ) { + foreach ( self::LOOP_GLOBALS as $name ) { if ( array_key_exists( $name, $previous_context ) ) { $GLOBALS[ $name ] = $previous_context[ $name ]; } else { @@ -1597,8 +1571,8 @@ private function restore_post_context( array $previous_context ): void { * * @since 7.2.0 * - * @param \WP_Post $post The post object. - * @param string $field Either 'date' or 'modified'. Default 'date'. + * @param WP_Post $post The post object. + * @param string $field Either 'date' or 'modified'. Default 'date'. * @return string The ISO 8601 date, or an empty string if unavailable. */ private function format_local_date( WP_Post $post, string $field = 'date' ): string { @@ -1618,8 +1592,8 @@ private function format_local_date( WP_Post $post, string $field = 'date' ): str * * @since 7.2.0 * - * @param \WP_Post $post The post object. - * @param string $field Either 'date' or 'modified'. Default 'date'. + * @param WP_Post $post The post object. + * @param string $field Either 'date' or 'modified'. Default 'date'. * @return string The ISO 8601 date, or an empty string if unavailable. */ private function format_gmt_date( WP_Post $post, string $field = 'date' ): string { @@ -1671,7 +1645,7 @@ private function is_usable_date( $date ): bool { * * @since 7.2.0 * - * @return \WP_Error The not-found error. + * @return WP_Error The not-found error. */ private function not_found_error(): WP_Error { return new WP_Error( From dcfb11f407ca1719e8b15c90bfbc10a162e2a285 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 19:51:33 +0100 Subject: [PATCH 50/69] Abilities API: Simplify the content query ability - Resolve ID and slug requests with one `get_requested_post()` helper, which replaces `get_content_by_id()`. A request with an `id` or a `slug` now always takes the single-post path, so one that does not resolve fails closed, instead of falling through to query mode, when the callbacks run on input that skipped schema validation. - Parse `page` and `per_page` with `parse_filter_int()`, like `id` and `parent`, and drop `input_int()`. Values that skipped schema validation and are not positive integers now fall back to the defaults. - Read GMT dates with `get_post_datetime()` and convert the result back to UTC, which replaces `is_usable_date()` and the `$field` normalization. - Pick the post a slug resolves to in one loop. - Look up author slugs with `get_user_by()`. Core keeps nicenames unique, so the check for users sharing one, and its test case, are gone. - Copy the loop globals with `array_intersect_key()`. - Pass the query posts to `update_post_author_caches()` directly, like the REST posts controller does. - Drop the self-parent check from the inherited read permission, since the cycle guard already rejects a post that is its own parent. --- .../abilities/class-wp-content-abilities.php | 234 +++++------------- .../wpRegisterCoreContentAbility.php | 31 +-- 2 files changed, 76 insertions(+), 189 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index cdc498adfcee6..93ab2a18ed454 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -188,9 +188,9 @@ public function check_permission( $input = array() ): bool { $requires_edit = $this->has_explicit_edit_fields( $input ); - // Single-post mode (by ID). - if ( ! empty( $input['id'] ) ) { - $post = $this->get_content_by_id( $input ); + // Single-post modes, by ID or by post type and slug. + if ( isset( $input['id'] ) || isset( $input['slug'] ) ) { + $post = $this->get_requested_post( $input ); if ( ! $post ) { return false; } @@ -198,22 +198,12 @@ public function check_permission( $input = array() ): bool { return $requires_edit ? current_user_can( 'edit_post', $post->ID ) : $this->check_read_permission( $post ); } - // Single-post mode (by slug) and query mode require an exposed post type. + // Query mode requires an exposed post type. $post_type_object = $this->get_exposed_post_type( $input['post_type'] ?? null ); if ( ! $post_type_object ) { return false; } - $post_type = $post_type_object->name; - if ( isset( $input['slug'] ) && is_string( $input['slug'] ) && '' !== $input['slug'] ) { - $post = $this->get_post_by_slug( $post_type, $input['slug'] ); - if ( ! $post ) { - return false; - } - - return $requires_edit ? current_user_can( 'edit_post', $post->ID ) : $this->check_read_permission( $post ); - } - if ( $requires_edit ) { return current_user_can( $post_type_object->cap->edit_posts ); } @@ -221,27 +211,15 @@ public function check_permission( $input = array() ): bool { return $this->can_query_statuses( $input, $post_type_object ); } - /** - * Casts a raw input value to a non-negative integer. - * - * @since 7.2.0 - * - * @param mixed $value The raw input value. - * @return int The value as a non-negative integer, or 0 when not scalar. - */ - private function input_int( $value ): int { - return is_scalar( $value ) ? absint( $value ) : 0; - } - /** * Parses a raw input value into an integer of at least a minimum, or null when invalid. * - * Unlike {@see self::input_int()}, which coerces any non-integer to 0, this rejects - * values that are not integers, so an ID or a parent that cannot be honored fails - * loudly instead of being read as 0: a `parent` filter of 0 asks for top-level posts. - * Accepts native integers and unsigned integer strings. Schema validation accepts an - * integer string, and only the REST run controller converts input to the schema types, - * so callers that bypass it, such as a direct WP_Ability::execute() call, can pass one. + * Values that are not integers are rejected rather than coerced, so an ID or a parent + * that cannot be honored fails loudly instead of being read as 0: a `parent` filter of + * 0 asks for top-level posts. Accepts native integers and unsigned integer strings. + * Schema validation accepts an integer string, and only the REST run controller + * converts input to the schema types, so callers that bypass it, such as a direct + * WP_Ability::execute() call, can pass one. * * @since 7.2.0 * @@ -401,11 +379,7 @@ private function check_read_permission( WP_Post $post, array $checked_post_ids = * Mirror the REST posts controller's inherited-parent behavior, but keep the * ability fail-closed for missing parents or parent loops. */ - if ( - 'inherit' === $post->post_status && - $post->post_parent > 0 && - (int) $post->post_parent !== (int) $post->ID - ) { + if ( 'inherit' === $post->post_status && $post->post_parent > 0 ) { $parent = get_post( $post->post_parent ); if ( $parent instanceof WP_Post ) { return $this->check_read_permission( $parent, $checked_post_ids ); @@ -436,32 +410,19 @@ public function execute_content_query( $input = array() ) { $fields = $this->normalize_fields( $input ); $requires_edit = $this->has_explicit_edit_fields( $input ); - // Single-post mode (by ID). - if ( ! empty( $input['id'] ) ) { - $post = $this->get_content_by_id( $input ); - if ( ! $post ) { - return $this->not_found_error(); - } + // Single-post modes, by ID or by post type and slug. + if ( isset( $input['id'] ) || isset( $input['slug'] ) ) { + $post = $this->get_requested_post( $input ); - return $this->to_output_post( $this->format_post( $post, $fields ) ); + return $post ? $this->to_output_post( $this->format_post( $post, $fields ) ) : $this->not_found_error(); } - // Single-post mode (by slug) and query mode. $post_type_object = $this->get_exposed_post_type( $input['post_type'] ?? null ); if ( ! $post_type_object ) { return $this->not_found_error(); } $post_type = $post_type_object->name; - if ( isset( $input['slug'] ) && is_string( $input['slug'] ) && '' !== $input['slug'] ) { - $post = $this->get_post_by_slug( $post_type, $input['slug'] ); - - if ( ! $post ) { - return $this->not_found_error(); - } - - return $this->to_output_post( $this->format_post( $post, $fields ) ); - } /* * REST only registers the equivalent collection filters for post types that @@ -528,7 +489,7 @@ public function execute_content_query( $input = array() ) { } $per_page = $this->normalize_per_page( $input, $include ); - $page = isset( $input['page'] ) ? max( 1, $this->input_int( $input['page'] ) ) : 1; + $page = $this->parse_filter_int( $input['page'] ?? 1, 1 ) ?? 1; $prime_post_caches = $this->should_prime_post_caches( $fields ); @@ -583,15 +544,8 @@ public function execute_content_query( $input = array() ) { * permalinks read the author when the permalink structure contains `%author%`. */ update_post_parent_caches( $query->posts ); - $author_fields = array_intersect( array( 'author_slug', 'link' ), $fields ); - if ( array() !== $author_fields && post_type_supports( $post_type, 'author' ) ) { - $query_posts = array_filter( - $query->posts, - static function ( $queried_post ): bool { - return $queried_post instanceof WP_Post; - } - ); - update_post_author_caches( $query_posts ); + if ( array() !== array_intersect( array( 'author_slug', 'link' ), $fields ) && post_type_supports( $post_type, 'author' ) ) { + update_post_author_caches( $query->posts ); } $posts = array(); @@ -634,16 +588,13 @@ static function ( $queried_post ): bool { * @param list $include_ids Normalized included post IDs; empty when not requested. * @return int The clamped per-page value. */ - private function normalize_per_page( array $input, array $include_ids = array() ): int { - if ( isset( $input['per_page'] ) ) { - return max( 1, min( self::MAX_PER_PAGE, $this->input_int( $input['per_page'] ) ) ); - } - - if ( array() !== $include_ids ) { - return max( 1, min( self::MAX_PER_PAGE, count( $include_ids ) ) ); + private function normalize_per_page( array $input, array $include_ids ): int { + $per_page = $this->parse_filter_int( $input['per_page'] ?? null, 1 ); + if ( null === $per_page ) { + $per_page = array() === $include_ids ? self::DEFAULT_PER_PAGE : count( $include_ids ); } - return self::DEFAULT_PER_PAGE; + return min( self::MAX_PER_PAGE, $per_page ); } /** @@ -692,34 +643,42 @@ private function should_prime_post_caches( array $fields ): bool { } /** - * Looks up the single post an ID request resolves to. + * Looks up the single post an ID or slug request resolves to. * - * The post must exist, belong to a post type exposed to abilities, and match the + * By ID, the post must exist, belong to a post type exposed to abilities, and match the * `post_type` guard when one is given. As with the integer filters, only an integer or * an unsigned integer string is accepted, so a malformed ID or one beyond the integer - * range cannot be coerced onto another post. + * range cannot be coerced onto another post. By slug, the post type must be exposed to + * abilities, and {@see self::get_post_by_slug()} resolves the post. * * @since 7.2.0 * * @param array $input The ability input. * @return WP_Post|null The post, or null when it cannot be resolved. */ - private function get_content_by_id( array $input ): ?WP_Post { - $post_id = isset( $input['id'] ) ? $this->parse_filter_int( $input['id'], 1 ) : null; - $post = null === $post_id ? null : get_post( $post_id ); + private function get_requested_post( array $input ): ?WP_Post { + if ( isset( $input['id'] ) ) { + $post_id = $this->parse_filter_int( $input['id'], 1 ); + $post = null === $post_id ? null : get_post( $post_id ); - if ( ! $post instanceof WP_Post || ! $this->get_exposed_post_type( $post->post_type ) ) { - return null; + if ( ! $post instanceof WP_Post || ! $this->get_exposed_post_type( $post->post_type ) ) { + return null; + } + + return empty( $input['post_type'] ) || $post->post_type === $input['post_type'] ? $post : null; } - return empty( $input['post_type'] ) || $post->post_type === $input['post_type'] ? $post : null; + $slug = $input['slug'] ?? null; + $post_type_object = $this->get_exposed_post_type( $input['post_type'] ?? null ); + + return is_string( $slug ) && $post_type_object ? $this->get_post_by_slug( $post_type_object->name, $slug ) : null; } /** * Looks up the user an author slug names. * - * The slug is the user's nicename, which the REST API users endpoint returns as `slug`. It - * must match exactly one user. A user the current user may not see is reported like a + * The slug is the user's nicename, which the REST API users endpoint returns as `slug`, and + * must match it exactly. A user the current user may not see is reported like a * missing one. The current user can see themselves, any user of the site when they can list * users, and authors with posts in a publicly viewable post type. A user who can edit others' * posts of the post type can also see any user of the site, since they may make any of them @@ -734,34 +693,16 @@ private function get_content_by_id( array $input ): ?WP_Post { * * @param mixed $slug The author slug. * @param WP_Post_Type $post_type_object The post type the author is looked up for. - * @return WP_User|null The user, or null when the slug does not name exactly one visible user. + * @return WP_User|null The user, or null when the slug does not name a visible user. */ private function get_author_by_slug( $slug, WP_Post_Type $post_type_object ): ?WP_User { - if ( ! is_string( $slug ) || '' === $slug ) { - return null; - } - - // The database compares nicenames without regard to case, so keep the exact matches only. - $users = array_values( - array_filter( - get_users( - array( - 'blog_id' => 0, - 'nicename' => $slug, - 'count_total' => false, - ) - ), - static function ( $user ) use ( $slug ): bool { - return $user instanceof WP_User && $user->user_nicename === $slug; - } - ) - ); + $user = is_string( $slug ) ? get_user_by( 'slug', $slug ) : false; - if ( 1 !== count( $users ) ) { + // The database compares nicenames without regard to case, so keep an exact match only. + if ( ! $user || $user->user_nicename !== $slug ) { return null; } - $user = $users[0]; if ( get_current_user_id() === $user->ID ) { return $user; } @@ -817,31 +758,19 @@ private function get_post_by_slug( string $post_type, string $slug ): ?WP_Post { ) ); - $viewable = array(); - $hidden = array(); + // Candidates come newest first; a publicly viewable post is always readable here. + $readable = null; foreach ( $query->posts as $candidate ) { - if ( ! $candidate instanceof WP_Post ) { - continue; - } - if ( is_post_publicly_viewable( $candidate ) ) { - $viewable[] = $candidate; - continue; + return $candidate; } - $hidden[] = $candidate; - } - - // Both groups keep the query's newest-first ordering. - foreach ( array_merge( $viewable, $hidden ) as $candidate ) { - if ( ! $this->check_read_permission( $candidate ) ) { - continue; + if ( null === $readable && $this->check_read_permission( $candidate ) ) { + $readable = $candidate; } - - return $candidate; } - return null; + return $readable; } /** @@ -1520,14 +1449,7 @@ private function get_rendered_content( WP_Post $post ): string { * @return array The previous loop globals, keyed by name, leaving out those that were not set. */ private function set_up_post_context( WP_Post $post ): array { - $previous_context = array(); - foreach ( self::LOOP_GLOBALS as $name ) { - if ( ! array_key_exists( $name, $GLOBALS ) ) { - continue; - } - - $previous_context[ $name ] = $GLOBALS[ $name ]; - } + $previous_context = array_intersect_key( $GLOBALS, array_flip( self::LOOP_GLOBALS ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily mirrors REST post context for rendering. $GLOBALS['post'] = $post; @@ -1572,12 +1494,11 @@ private function restore_post_context( array $previous_context ): void { * @since 7.2.0 * * @param WP_Post $post The post object. - * @param string $field Either 'date' or 'modified'. Default 'date'. + * @param string $field Either 'date' or 'modified'. * @return string The ISO 8601 date, or an empty string if unavailable. */ - private function format_local_date( WP_Post $post, string $field = 'date' ): string { - $field = 'modified' === $field ? 'modified' : 'date'; - $datetime = get_post_datetime( $post, $field, 'local' ); + private function format_local_date( WP_Post $post, string $field ): string { + $datetime = get_post_datetime( $post, $field ); return $datetime ? $datetime->format( 'c' ) : ''; } @@ -1585,49 +1506,22 @@ private function format_local_date( WP_Post $post, string $field = 'date' ): str /** * Formats a post date field as an ISO 8601 string in GMT. * - * Reads the stored GMT date directly, deriving it from the local date when missing - * (e.g. drafts), mirroring the REST posts controller. get_post_datetime() is avoided - * here because it reprojects even GMT-sourced dates into the site timezone, which - * would label the returned instant with the site offset instead of UTC. + * Reads the stored GMT date, deriving it from the local date when it is missing + * (e.g. drafts), mirroring the REST posts controller. * * @since 7.2.0 * * @param WP_Post $post The post object. - * @param string $field Either 'date' or 'modified'. Default 'date'. + * @param string $field Either 'date' or 'modified'. * @return string The ISO 8601 date, or an empty string if unavailable. */ - private function format_gmt_date( WP_Post $post, string $field = 'date' ): string { - $field = 'modified' === $field ? 'modified' : 'date'; - $gmt = 'modified' === $field ? $post->post_modified_gmt : $post->post_date_gmt; - - if ( ! $this->is_usable_date( $gmt ) ) { - $local = 'modified' === $field ? $post->post_modified : $post->post_date; - $gmt = $this->is_usable_date( $local ) ? get_gmt_from_date( $local ) : ''; + private function format_gmt_date( WP_Post $post, string $field ): string { + $datetime = get_post_datetime( $post, $field, 'gmt' ); + if ( ! $datetime ) { + $datetime = get_post_datetime( $post, $field ); } - /* - * Guard the empty string before `strtotime()`: `strtotime( ' UTC' )` resolves to the - * current time, which would report a fabricated date instead of the documented - * empty-string sentinel. - */ - $timestamp = '' === $gmt ? false : strtotime( $gmt . ' UTC' ); - - return false === $timestamp ? '' : gmdate( 'c', $timestamp ); - } - - /** - * Checks whether a raw post date column holds a usable date. - * - * The columns are `NOT NULL` in core's schema, but a post object can reach this class - * from a filter or an in-memory row where a date is null or a zero date. - * - * @since 7.2.0 - * - * @param mixed $date The raw date column value. - * @return bool True when the value is a non-empty, non-zero date string. - */ - private function is_usable_date( $date ): bool { - return is_string( $date ) && '' !== $date && '0000-00-00 00:00:00' !== $date; + return $datetime ? $datetime->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'c' ) : ''; } /** diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 5286b6c6a51e7..f15f5a7e99aed 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -3068,41 +3068,34 @@ public function test_execute_callback_returns_not_found_for_structural_lookup_fa } /** - * Returns author slugs that do not name exactly one user. + * Returns author slugs that do not name a user. * * @return array The author slug filter value. */ - public function data_author_slugs_that_name_no_single_user(): array { + public function data_author_slugs_that_name_no_user(): array { return array( 'unknown slug' => array( 'no-such-user' ), - 'shared slug' => array( 'shared-slug' ), 'slug in capitals' => array( 'AUTHOR-SLUG' ), 'not a string' => array( 5 ), ); } /** - * An author_slug filter that does not name exactly one user is rejected rather than - * silently dropped, which would widen the query to every author's posts. + * An author_slug filter that does not name a user is rejected rather than silently + * dropped, which would widen the query to every author's posts. * * @ticket 64606 - * @dataProvider data_author_slugs_that_name_no_single_user + * @dataProvider data_author_slugs_that_name_no_user * * @param mixed $author_slug The author slug filter value. */ - public function test_execute_callback_rejects_an_author_slug_that_names_no_single_user( $author_slug ): void { - global $wpdb; - - // Core keeps nicenames unique, so give two users the same one through the database. - $nicenames = array( - self::$user_ids['author'] => 'author-slug', - self::$user_ids['contributor'] => 'shared-slug', - self::$user_ids['author_secondary'] => 'shared-slug', + public function test_execute_callback_rejects_an_author_slug_that_names_no_user( $author_slug ): void { + wp_update_user( + array( + 'ID' => self::$user_ids['author'], + 'user_nicename' => 'author-slug', + ) ); - foreach ( $nicenames as $user_id => $nicename ) { - $wpdb->update( $wpdb->users, array( 'user_nicename' => $nicename ), array( 'ID' => $user_id ) ); - clean_user_cache( $user_id ); - } $this->login_as( 'administrator' ); $content = new WP_Content_Abilities(); @@ -3114,7 +3107,7 @@ public function test_execute_callback_rejects_an_author_slug_that_names_no_singl ) ); - $this->assertWPError( $result, 'An author_slug that names no single user must not silently widen the query to all authors.' ); + $this->assertWPError( $result, 'An author_slug that names no user must not silently widen the query to all authors.' ); $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An unhonorable author_slug filter should fail closed as an invalid filter.' ); } From 917361e73e29738a53f6ea7c4c03896ae9117e56 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 21:33:14 +0100 Subject: [PATCH 51/69] Abilities API: Set up each content query post once while formatting it format_post() now sets the post up as the global post while it builds all of the post's fields, as the REST posts controller does, and restores the previous context in the same finally block that removes the password filter. Title filters now see the requested post too, and requesting both rendered fields no longer sets the context up twice. Every post now goes through setup_postdata(), which reads the author, so the author caches are primed for every page, as the REST posts controller does. Before, rendered fields ran one user query per author. --- .../abilities/class-wp-content-abilities.php | 92 ++++++++----------- .../wpRegisterCoreContentAbility.php | 21 +++-- 2 files changed, 50 insertions(+), 63 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 93ab2a18ed454..c6635234e243a 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -539,14 +539,12 @@ public function execute_content_query( $input = array() ) { /* * Prime the parent and author caches with a single query each instead of one - * lookup per post, mirroring the REST posts controller. Hierarchical permalinks - * and inherited read permissions read the parent. Besides `author_slug`, - * permalinks read the author when the permalink structure contains `%author%`. + * lookup per post, as the REST posts controller does. Hierarchical permalinks and + * inherited read permissions read the parent, and format_post() sets up every post + * with setup_postdata(), which reads the author. */ update_post_parent_caches( $query->posts ); - if ( array() !== array_intersect( array( 'author_slug', 'link' ), $fields ) && post_type_supports( $post_type, 'author' ) ) { - update_post_author_caches( $query->posts ); - } + update_post_author_caches( $query->posts ); $posts = array(); foreach ( $query->posts as $post ) { @@ -1172,8 +1170,10 @@ private function to_output_post( array $formatted ) { /** * Formats a post into the ability output shape. * - * For an editor of a password-protected post, the cookie-based password gate is suspended - * while the fields are built so rendered fields resolve to real values instead of + * As the REST posts controller does, the post is set up as the global post while its + * fields are built, so filters that rely on loop globals, including title filters, see + * the requested post. For an editor of a password-protected post, the cookie-based + * password gate is also suspended, so rendered fields resolve to real values instead of * protected-post placeholders. The field projection itself is delegated to * {@see self::build_post_fields()}. * @@ -1186,28 +1186,32 @@ private function to_output_post( array $formatted ) { private function format_post( WP_Post $post, array $fields ): array { $can_edit = current_user_can( 'edit_post', $post->ID ); $password_required = post_password_required( $post ); - $protected = $password_required && ! $can_edit; + $unlock_password = $password_required && $can_edit; + $previous_context = $this->set_up_post_context( $post ); /* - * Suspend the cookie-based password gate for an editor of this protected post, so - * helpers with their own gate (e.g. get_the_excerpt()) resolve the real values. The - * filter unlocks only posts the current user can edit, mirroring the REST posts + * The filter unlocks only posts the current user can edit, mirroring the REST posts * controller's check_password_required(): an unconditional bypass (e.g. __return_false) * would also expose other protected posts that the content filter may render, such as - * posts pulled in by a Query Loop block. The filter is removed in a finally block so a - * throw mid-render cannot leave the gate globally disabled for the rest of the request. + * posts pulled in by a Query Loop block. */ - if ( $password_required && $can_edit ) { + if ( $unlock_password ) { add_filter( 'post_password_required', array( $this, 'allow_password_content' ), 10, 2 ); + } - try { - return $this->build_post_fields( $post, $fields, $can_edit, $protected ); - } finally { + /* + * Undo both in a finally block, so a throw mid-render cannot leave the password gate + * disabled or the global post pointing at this post for the rest of the request. + */ + try { + return $this->build_post_fields( $post, $fields, $can_edit, $password_required && ! $can_edit ); + } finally { + if ( $unlock_password ) { remove_filter( 'post_password_required', array( $this, 'allow_password_content' ), 10 ); } - } - return $this->build_post_fields( $post, $fields, $can_edit, $protected ); + $this->restore_post_context( $previous_context ); + } } /** @@ -1376,10 +1380,8 @@ public function return_raw_title_format(): string { /** * Returns the post excerpt transformed for display. * - * Mirrors the REST posts controller by preparing post globals before applying - * the `get_the_excerpt` and `the_excerpt` filter chains, then restoring the - * previous global post context. This ensures filters that rely on loop globals - * render against the requested post. + * Applies the `get_the_excerpt` and `the_excerpt` filter chains, as the REST posts + * controller does. {@see self::format_post()} has set the post up as the global post. * * @since 7.2.0 * @@ -1387,31 +1389,20 @@ public function return_raw_title_format(): string { * @return string Rendered post excerpt. */ private function get_rendered_excerpt( WP_Post $post ): string { - $previous_context = $this->set_up_post_context( $post ); + /** This filter is documented in wp-includes/post-template.php */ + $excerpt = apply_filters( 'get_the_excerpt', $post->post_excerpt, $post ); - /* - * The global post context is restored in a finally block so a throw from an - * excerpt filter cannot leave it pointing at the rendered post for the rest - * of the request. - */ - try { - /** This filter is documented in wp-includes/post-template.php */ - $excerpt = apply_filters( 'get_the_excerpt', $post->post_excerpt, $post ); + /** This filter is documented in wp-includes/post-template.php */ + $excerpt = apply_filters( 'the_excerpt', $excerpt ); - /** This filter is documented in wp-includes/post-template.php */ - $excerpt = apply_filters( 'the_excerpt', $excerpt ); - - return is_string( $excerpt ) ? $excerpt : ''; - } finally { - $this->restore_post_context( $previous_context ); - } + return is_string( $excerpt ) ? $excerpt : ''; } /** * Returns post content transformed for display. * - * Mirrors the REST posts controller by preparing post globals before applying - * `the_content`, then restoring the previous global post context. + * Applies `the_content`, as the REST posts controller does. {@see self::format_post()} + * has set the post up as the global post. * * @since 7.2.0 * @@ -1419,21 +1410,10 @@ private function get_rendered_excerpt( WP_Post $post ): string { * @return string Rendered post content. */ private function get_rendered_content( WP_Post $post ): string { - $previous_context = $this->set_up_post_context( $post ); + /** This filter is documented in wp-includes/post-template.php */ + $content = apply_filters( 'the_content', $post->post_content ); - /* - * The global post context is restored in a finally block so a throw from a - * content filter cannot leave it pointing at the rendered post for the rest - * of the request. - */ - try { - /** This filter is documented in wp-includes/post-template.php */ - $content = apply_filters( 'the_content', $post->post_content ); - - return is_string( $content ) ? $content : ''; - } finally { - $this->restore_post_context( $previous_context ); - } + return is_string( $content ) ? $content : ''; } /** diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index f15f5a7e99aed..adb5a21e9ac95 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -2000,13 +2000,13 @@ public function test_rendered_title_is_empty_when_a_title_filter_returns_a_non_s } /** - * Rendered excerpt filters run with the requested post as the global context and restore - * the context that was active before the ability executed. + * Rendered title and excerpt filters run with the requested post as the global context, + * and the context that was active before the ability executed is restored. * * @ticket 64606 * @since 7.2.0 */ - public function test_excerpt_rendered_uses_and_restores_requested_post_context(): void { + public function test_rendered_fields_use_and_restore_requested_post_context(): void { $this->login_as( 'subscriber' ); $this->register_ability(); @@ -2020,20 +2020,22 @@ public function test_excerpt_rendered_uses_and_restores_requested_post_context() $GLOBALS['post'] = $surrounding; setup_postdata( $surrounding ); - $append_context_id = static function ( $excerpt ): string { - return (string) $excerpt . ''; + $append_context_id = static function ( $text ): string { + return (string) $text . ''; }; + add_filter( 'the_title', $append_context_id, 20 ); add_filter( 'the_excerpt', $append_context_id, 20 ); try { $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $target_id, - 'fields' => array( 'id', 'excerpt_rendered' ), + 'fields' => array( 'id', 'title_rendered', 'excerpt_rendered' ), ) ); $restored_context_id = get_the_ID(); } finally { + remove_filter( 'the_title', $append_context_id, 20 ); remove_filter( 'the_excerpt', $append_context_id, 20 ); if ( $previous_post instanceof WP_Post ) { @@ -2047,7 +2049,12 @@ public function test_excerpt_rendered_uses_and_restores_requested_post_context() } $this->assertStringContainsString( - '', + '', + $result['title_rendered'], + 'Title filters should see the requested post as the current post.' + ); + $this->assertStringContainsString( + '', $result['excerpt_rendered'], 'Excerpt filters should see the requested post as the current post.' ); From e093f8285235cea1a62fdf31dab90772a8114d40 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 21:33:14 +0100 Subject: [PATCH 52/69] Abilities API: Accept a single ID in the content query include filter Schema validation accepts a single integer for `include`, because wp_parse_list() handles integers, but normalize_include() only took arrays and strings, so the query failed with `content_invalid_filter`. --- .../abilities/class-wp-content-abilities.php | 8 +++---- .../wpRegisterCoreContentAbility.php | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index c6635234e243a..5f34bba7fe4c4 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -813,14 +813,14 @@ private function normalize_statuses( array $input ): array { */ private function normalize_include( array $input ): array { $include = $input['include'] ?? null; - if ( ! is_array( $include ) && ! is_string( $include ) ) { + if ( ! is_array( $include ) && ! is_string( $include ) && ! is_int( $include ) ) { return array(); } /* - * Schema validation also accepts a comma-separated string, which callers that - * bypass the REST run controller can pass; wp_parse_id_list() accepts both forms - * and yields unique positive IDs. + * Schema validation also accepts a single ID or a comma-separated string, which + * callers that bypass the REST run controller can pass; wp_parse_id_list() accepts + * every form and yields unique positive IDs. */ return array_values( array_filter( wp_parse_id_list( $include ) ) ); } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index adb5a21e9ac95..b5b8db64bc4bc 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -953,6 +953,29 @@ public function test_query_include_respects_requested_post_type(): void { $this->assertSame( array( $post_id ), wp_list_pluck( $result['posts'], 'id' ), 'Include should not leak posts from other post types.' ); } + /** + * Query include accepts a single post ID, as the input schema does. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_include_accepts_a_single_post_id(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::$post_ids['published']; + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'post_type' => 'post', + 'include' => $post_id, + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $post_id ), wp_list_pluck( $result['posts'], 'id' ), 'A single included post ID should limit the query to that post.' ); + } + /** * Query include still respects row-level permissions. * From 582c1d6c1f5500e04b1806baa7573898b06c4ff0 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 21:33:14 +0100 Subject: [PATCH 53/69] Abilities API: Simplify the content query errors and page count - Build the `content_invalid_filter` errors with one helper. - Keep the `author_slug` parameter name out of the translatable strings. - Drop a check from the total page count that `ceil()` already covers, since it returns 0 for an empty result. --- .../abilities/class-wp-content-abilities.php | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 5f34bba7fe4c4..a022493f6eb6d 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -436,39 +436,29 @@ public function execute_content_query( $input = array() ) { $parent = null; if ( isset( $input['parent'] ) ) { if ( ! is_post_type_hierarchical( $post_type ) ) { - return new WP_Error( - 'content_invalid_filter', - __( 'The parent filter is only supported for hierarchical post types.' ), - array( 'status' => 400 ) - ); + return $this->invalid_filter_error( __( 'The parent filter is only supported for hierarchical post types.' ) ); } $parent = $this->parse_filter_int( $input['parent'], 0 ); if ( null === $parent ) { - return new WP_Error( - 'content_invalid_filter', - __( 'The parent filter must be a non-negative integer.' ), - array( 'status' => 400 ) - ); + return $this->invalid_filter_error( __( 'The parent filter must be a non-negative integer.' ) ); } } $author = null; if ( isset( $input['author_slug'] ) ) { if ( ! post_type_supports( $post_type, 'author' ) ) { - return new WP_Error( - 'content_invalid_filter', - __( 'The author_slug filter is only supported for post types that support authors.' ), - array( 'status' => 400 ) + return $this->invalid_filter_error( + /* translators: %s: Parameter. */ + sprintf( __( 'The %s filter is only supported for post types that support authors.' ), 'author_slug' ) ); } $author = $this->get_author_by_slug( $input['author_slug'], $post_type_object ); if ( ! $author ) { - return new WP_Error( - 'content_invalid_filter', - __( 'The author_slug filter must be the slug of an existing user.' ), - array( 'status' => 400 ) + return $this->invalid_filter_error( + /* translators: %s: Parameter. */ + sprintf( __( 'The %s filter must be the slug of an existing user.' ), 'author_slug' ) ); } } @@ -481,11 +471,7 @@ public function execute_content_query( $input = array() ) { * would return every post of the type — the opposite of the caller's intent. */ if ( isset( $input['include'] ) && array() === $include ) { - return new WP_Error( - 'content_invalid_filter', - __( 'The include filter must list one or more valid post IDs.' ), - array( 'status' => 400 ) - ); + return $this->invalid_filter_error( __( 'The include filter must list one or more valid post IDs.' ) ); } $per_page = $this->normalize_per_page( $input, $include ); @@ -522,7 +508,7 @@ public function execute_content_query( $input = array() ) { $query = new WP_Query( $query_args ); $total = $this->get_query_total( $query, $query_args, $page ); - $total_pages = $total > 0 ? (int) ceil( $total / $per_page ) : 0; + $total_pages = (int) ceil( $total / $per_page ); /* * Paging past the last page is a caller error rather than an empty collection, so @@ -1528,4 +1514,16 @@ private function not_found_error(): WP_Error { array( 'status' => 404 ) ); } + + /** + * Builds the error for a query filter that cannot be honored. + * + * @since 7.2.0 + * + * @param string $message The error message. + * @return WP_Error The invalid filter error. + */ + private function invalid_filter_error( string $message ): WP_Error { + return new WP_Error( 'content_invalid_filter', $message, array( 'status' => 400 ) ); + } } From 529a264049f5c16c81ff4b290a5a68d8a6ffd212 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 21:33:15 +0100 Subject: [PATCH 54/69] Abilities API: Add since notes for the content query ability Notes the `content` category in wp_register_core_ability_categories() and the `core/content-query` ability in wp_register_core_abilities(). --- src/wp-includes/abilities.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index cc1fa65875917..4224c3c43f858 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -16,7 +16,7 @@ * Registers the core ability categories. * * @since 6.9.0 - * @since 7.2.0 Added the `uncategorized` category. + * @since 7.2.0 Added the `content` and `uncategorized` categories. */ function wp_register_core_ability_categories(): void { wp_register_ability_category( @@ -56,7 +56,7 @@ function wp_register_core_ability_categories(): void { * Registers the default core abilities. * * @since 6.9.0 - * @since 7.2.0 Added the `core/settings-get` ability. + * @since 7.2.0 Added the `core/content-query` and `core/settings-get` abilities. * * @global wpdb $wpdb WordPress database abstraction object. */ From dcf7d67eb669e7bb4fe270f7680e75d7306be219 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 21:33:15 +0100 Subject: [PATCH 55/69] Tests: Reset the post types after each content query test A test turns off `show_in_abilities` on `post` and `page`. When it is the last test of its class to run, the next class's set_up_before_class() runs before the next set_up() resets the post types, so a class that registers the core abilities there, such as Tests_REST_API_WpRestAbilitiesContentController, registers no content ability, and all of its tests fail. An earlier cleanup dropped this reset, so a comment now explains why it stays. --- .../abilities-api/wpRegisterCoreContentAbility.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index b5b8db64bc4bc..6d8ca50a0cd33 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -139,7 +139,7 @@ public function set_up(): void { } /** - * Removes the ability and its category after each test. + * Removes the ability and its category, and resets the post types, after each test. * * @since 7.2.0 */ @@ -152,6 +152,15 @@ public function tear_down(): void { wp_unregister_ability_category( 'content' ); } + /* + * Keep this reset even though the parent set_up() also resets the post types. A test + * may turn off `show_in_abilities` on `post` and `page`, and the next class's + * set_up_before_class() runs before the next set_up(). A class that registers the core + * abilities there, such as Tests_REST_API_WpRestAbilitiesContentController, would then + * register no content ability, and all of its tests would fail. + */ + $this->reset_post_types(); + parent::tear_down(); } From b1c2cfc6e210ac18e904f21b58fae2fda3b7672f Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 21:33:15 +0100 Subject: [PATCH 56/69] Tests: Tidy the content query tests - Assert that the post type objects exist. get_post_type_object() returns null, not false, so assertNotFalse() always passed. - Merge the post fixtures that only differed in their text, and drop three tests that other tests already cover. - Use REST_TESTS_IMPOSSIBLY_HIGH_NUMBER for posts that do not exist. --- .../wpRegisterCoreContentAbility.php | 152 +++--------------- 1 file changed, 19 insertions(+), 133 deletions(-) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 6d8ca50a0cd33..6d1d4f91895bf 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -68,29 +68,15 @@ public static function wpSetUpBeforeClass( $factory ): void { ); self::$post_ids = array( - 'published' => $factory->post->create( array( 'post_status' => 'publish' ) ), - 'published_content' => $factory->post->create( + 'published' => $factory->post->create( array( 'post_status' => 'publish' ) ), + 'published_content' => $factory->post->create( array( 'post_title' => 'Hello Content', 'post_content' => 'Body here.', 'post_status' => 'publish', ) ), - 'subscriber_content' => $factory->post->create( - array( - 'post_title' => 'Visible to subscribers', - 'post_content' => 'Rendered body for subscribers.', - 'post_status' => 'publish', - ) - ), - 'readable_single' => $factory->post->create( - array( - 'post_title' => 'Readable single', - 'post_content' => 'Readable single body.', - 'post_status' => 'publish', - ) - ), - 'limited_role_content' => $factory->post->create( + 'limited_role_content' => $factory->post->create( array( 'post_author' => self::$user_ids['administrator'], 'post_title' => 'Readable title', @@ -99,25 +85,12 @@ public static function wpSetUpBeforeClass( $factory ): void { 'post_status' => 'publish', ) ), - 'raw_content' => $factory->post->create( - array( - 'post_status' => 'publish', - 'post_content' => 'Public body with raw block markup.', - ) - ), - 'password_protected_editor' => $factory->post->create( - array( - 'post_status' => 'publish', - 'post_password' => 'secret', - 'post_content' => 'Top secret body.', - ) - ), - 'password_protected_limited' => $factory->post->create( + 'password_protected' => $factory->post->create( array( 'post_author' => self::$user_ids['administrator'], 'post_status' => 'publish', 'post_password' => 'secret', - 'post_content' => 'Hidden rendered body.', + 'post_content' => 'Top secret body.', ) ), ); @@ -261,7 +234,7 @@ public function test_registers_core_content_query_ability(): void { public function test_does_not_register_core_content_query_ability_without_exposed_post_types(): void { foreach ( array( 'post', 'page' ) as $post_type ) { $object = get_post_type_object( $post_type ); - $this->assertNotFalse( $object, "Precondition: the {$post_type} post type should exist." ); + $this->assertInstanceOf( WP_Post_Type::class, $object, "Precondition: the {$post_type} post type should exist." ); $object->show_in_abilities = false; } @@ -605,7 +578,7 @@ public function test_get_by_missing_id_is_denied(): void { $this->login_as( 'administrator' ); $this->register_ability(); - $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => 999999 ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ) ); $this->assertWPError( $result, 'Missing posts should be denied before execution probes object details.' ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Missing posts should fail closed as a permission error.' ); @@ -853,37 +826,6 @@ public function test_query_returns_only_published_by_default(): void { $this->assertNotContains( $draft, $ids, 'Draft posts should not be returned by default.' ); } - /** - * Query mode can limit results to included IDs. - * - * @ticket 64606 - * @since 7.2.0 - */ - public function test_query_include_limits_results(): void { - $this->login_as( 'administrator' ); - $this->register_ability(); - - $first = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $second = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $third = self::factory()->post->create( array( 'post_status' => 'publish' ) ); - - $result = wp_get_ability( 'core/content-query' )->execute( - array( - 'post_type' => 'post', - 'include' => array( $third, $first ), - 'fields' => array( 'id' ), - ) - ); - $ids = wp_list_pluck( $result['posts'], 'id' ); - - sort( $ids ); - $expected = array( $first, $third ); - sort( $expected ); - - $this->assertSame( $expected, $ids, 'Included post IDs should limit results without requiring caller order.' ); - $this->assertNotContains( $second, $ids, 'Posts outside include should not be returned.' ); - } - /** * Query results are ordered by post date, newest first, whatever order `include` uses. * @@ -1445,7 +1387,7 @@ public function test_logged_out_user_is_denied(): void { * @since 7.2.0 */ public function test_subscriber_can_request_published_content(): void { - $post_id = self::$post_ids['subscriber_content']; + $post_id = self::$post_ids['published_content']; $this->login_as( 'subscriber' ); $this->register_ability(); @@ -1462,8 +1404,8 @@ public function test_subscriber_can_request_published_content(): void { $post_index = array_search( $post_id, $ids, true ); $this->assertIsInt( $post_index, 'The published post should be present in the subscriber query response.' ); $post = $result['posts'][ $post_index ]; - $this->assertSame( 'Visible to subscribers', $post['title_rendered'], 'Subscribers should receive rendered titles.' ); - $this->assertStringContainsString( 'Rendered body for subscribers.', $post['content_rendered'], 'Subscribers should receive rendered content.' ); + $this->assertSame( 'Hello Content', $post['title_rendered'], 'Subscribers should receive rendered titles.' ); + $this->assertStringContainsString( 'Body here.', $post['content_rendered'], 'Subscribers should receive rendered content.' ); $this->assertArrayNotHasKey( 'content_raw', $post, 'Subscribers should not receive raw content without edit access.' ); } @@ -1474,7 +1416,7 @@ public function test_subscriber_can_request_published_content(): void { * @since 7.2.0 */ public function test_subscriber_can_get_single_published_post_by_id(): void { - $post_id = self::$post_ids['readable_single']; + $post_id = self::$post_ids['published_content']; $this->login_as( 'subscriber' ); $this->register_ability(); @@ -1482,7 +1424,7 @@ public function test_subscriber_can_get_single_published_post_by_id(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); $this->assertIsArray( $result, 'Subscribers should be able to fetch a readable published post by ID.' ); - $this->assertSame( 'Readable single', $result['title_rendered'], 'Subscribers should receive the rendered title.' ); + $this->assertSame( 'Hello Content', $result['title_rendered'], 'Subscribers should receive the rendered title.' ); $this->assertArrayNotHasKey( 'title_raw', $result, 'Subscribers should not receive raw titles without edit access.' ); $this->assertArrayNotHasKey( 'content_raw', $result, 'Subscribers should not receive raw content without edit access.' ); $this->assertArrayNotHasKey( 'content_rendered', $result, 'Rendered content should require an explicit field request.' ); @@ -1840,7 +1782,7 @@ public function test_query_mode_filters_posts_by_author(): void { * @since 7.2.0 */ public function test_raw_content_visible_to_editor(): void { - $post_id = self::$post_ids['raw_content']; + $post_id = self::$post_ids['published_content']; $this->login_as( 'editor' ); $this->register_ability(); @@ -1853,7 +1795,7 @@ public function test_raw_content_visible_to_editor(): void { ); $this->assertSame( - 'Public body with raw block markup.', + 'Body here.', $result['content_raw'], 'Editors should receive explicitly requested raw content.' ); @@ -1866,7 +1808,7 @@ public function test_raw_content_visible_to_editor(): void { * @since 7.2.0 */ public function test_password_protected_content_visible_to_editor(): void { - $post_id = self::$post_ids['password_protected_editor']; + $post_id = self::$post_ids['password_protected']; $this->login_as( 'editor' ); $this->register_ability(); @@ -1899,7 +1841,7 @@ public function test_password_protected_content_visible_to_editor(): void { * @param string $role The role to test. */ public function test_password_protected_rendered_content_is_empty_for_roles_without_edit_access_to_other_users_posts( string $role ): void { - $post_id = self::$post_ids['password_protected_limited']; + $post_id = self::$post_ids['password_protected']; $this->login_as( $role ); $this->register_ability(); @@ -2218,7 +2160,7 @@ public function test_allow_password_content_only_unlocks_editable_posts(): void 'post_password' => 'secret', ) ); - $other_id = self::$post_ids['password_protected_limited']; + $other_id = self::$post_ids['password_protected']; $this->login_as( 'author' ); @@ -2300,42 +2242,6 @@ static function ( $atts ): string { ); } - /** - * Query mode paginates with `page`/`per_page` and reports totals. - * - * @ticket 64606 - * @since 7.2.0 - */ - public function test_query_paginates_and_reports_totals(): void { - $this->login_as( 'administrator' ); - $this->register_ability(); - - self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); - - $page1 = wp_get_ability( 'core/content-query' )->execute( - array( - 'post_type' => 'post', - 'per_page' => 2, - 'page' => 1, - ) - ); - - $this->assertCount( 2, $page1['posts'], 'The first page should honor the requested per_page value.' ); - $this->assertGreaterThanOrEqual( 3, $page1['total'], 'The query should report the total matching post count.' ); - $this->assertSame( (int) ceil( $page1['total'] / 2 ), $page1['total_pages'], 'The query should report the computed total page count.' ); - - $page2 = wp_get_ability( 'core/content-query' )->execute( - array( - 'post_type' => 'post', - 'per_page' => 2, - 'page' => 2, - ) - ); - - $this->assertNotEmpty( $page2['posts'], 'The second page should return remaining posts.' ); - $this->assertSame( $page1['total'], $page2['total'], 'Pagination should keep total counts stable across pages.' ); - } - /** * Query mode reports a total that matches the returned posts for an uncapped query. * @@ -2435,7 +2341,7 @@ public function test_query_empty_result_beyond_first_page_reports_zero_totals(): $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post', - 'include' => array( 999999 ), + 'include' => array( REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ), 'page' => 2, 'fields' => array( 'id' ), ) @@ -2818,26 +2724,6 @@ public function test_single_post_returns_empty_object_for_empty_field_projection $this->assertEquals( (object) array(), $result, 'An empty single-post field projection should be returned as an empty object so it serializes as `{}`.' ); } - /** - * A single post fetched by ID is returned directly without query totals. - * - * @ticket 64606 - * @since 7.2.0 - */ - public function test_single_post_returns_direct_post_object(): void { - $this->login_as( 'administrator' ); - $this->register_ability(); - - $post_id = self::$post_ids['published']; - - $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); - - $this->assertSame( $post_id, $result['id'], 'Single-post responses should include the requested post ID.' ); - $this->assertArrayNotHasKey( 'posts', $result, 'Single-post responses should not include the query posts wrapper.' ); - $this->assertArrayNotHasKey( 'total', $result, 'Single-post responses should not include query totals.' ); - $this->assertArrayNotHasKey( 'total_pages', $result, 'Single-post responses should not include query page totals.' ); - } - /** * Local and GMT date fields report the correct instant and offset on non-UTC sites. * @@ -3083,7 +2969,7 @@ public function test_execute_callback_returns_not_found_for_structural_lookup_fa $content = new WP_Content_Abilities(); - $missing = $content->execute_content_query( array( 'id' => 999999 ) ); + $missing = $content->execute_content_query( array( 'id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ) ); $this->assertWPError( $missing, 'A nonexistent post ID should fail the lookup.' ); $this->assertSame( 'content_not_found', $missing->get_error_code(), 'Missing posts should map to the uniform not-found error.' ); From 4d0afbe132069718e0319988d6de15c99734b028 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 21:33:15 +0100 Subject: [PATCH 57/69] Tests: Use Trac ticket 66268 in the content query tests The content query ability has its own ticket, #66268. The tests used #64606, the earlier ticket for post management abilities. --- .../wpRegisterCoreContentAbility.php | 174 +++++++++--------- .../wpRestAbilitiesContentController.php | 22 +-- 2 files changed, 98 insertions(+), 98 deletions(-) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 6d1d4f91895bf..4816640b1ecb2 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -205,7 +205,7 @@ public function data_roles_without_edit_access_to_other_users_posts(): array { /** * The ability is registered in the `content` category and flagged read-only. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_registers_core_content_query_ability(): void { @@ -228,7 +228,7 @@ public function test_registers_core_content_query_ability(): void { /** * The content ability is not registered when no post types are exposed to it. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_does_not_register_core_content_query_ability_without_exposed_post_types(): void { @@ -248,7 +248,7 @@ public function test_does_not_register_core_content_query_ability_without_expose * The input schema models mutually exclusive ID, slug, and query modes, each * rejecting the other modes' properties and exposing only marked types. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_input_schema_models_mutually_exclusive_modes(): void { @@ -299,7 +299,7 @@ public function test_input_schema_models_mutually_exclusive_modes(): void { * Branch-local defaults are omitted so the schema can compile in the client-side * Abilities API validator. Runtime defaults are still applied by the ability. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_input_schema_omits_oneof_branch_defaults(): void { @@ -317,7 +317,7 @@ public function test_input_schema_omits_oneof_branch_defaults(): void { * Query-mode filters cannot be combined with a by-ID lookup: passing `per_page` alongside * `id` is rejected outright rather than silently ignored. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_id_mode_rejects_query_only_params(): void { @@ -338,7 +338,7 @@ public function test_id_mode_rejects_query_only_params(): void { /** * `post_type` is accepted alongside `id` as a guard: the by-ID mode still resolves the post. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_id_mode_accepts_post_type_guard(): void { @@ -362,7 +362,7 @@ public function test_id_mode_accepts_post_type_guard(): void { /** * The output schema describes single-post and query response shapes. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_output_schema_describes_single_post_and_query_responses(): void { @@ -397,7 +397,7 @@ public function test_output_schema_describes_single_post_and_query_responses(): * A post type registered by another active plugin and flagged `show_in_abilities` * is exposed by the ability, both in the input enum and in query results. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_exposes_a_post_type_registered_by_another_plugin(): void { @@ -433,7 +433,7 @@ public function test_exposes_a_post_type_registered_by_another_plugin(): void { /** * A schema filter can expose a post type that is registered after the ability. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_schema_filter_exposes_late_registered_post_type(): void { @@ -490,7 +490,7 @@ public function test_schema_filter_exposes_late_registered_post_type(): void { /** * A published post can be fetched by ID. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_get_single_published_post_by_id(): void { @@ -518,7 +518,7 @@ public function test_get_single_published_post_by_id(): void { * WP_Ability validates `stdClass` as object input but does not coerce the value before * passing it to the permission and execute callbacks, so both must preserve its fields. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_get_single_published_post_by_id_accepts_object_input(): void { @@ -545,7 +545,7 @@ public function test_get_single_published_post_by_id_accepts_object_input(): voi /** * A single post fetched by ID can return explicitly requested rendered and raw content. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_get_single_published_post_by_id_can_return_content_fields(): void { @@ -571,7 +571,7 @@ public function test_get_single_published_post_by_id_can_return_content_fields() /** * A missing post ID is denied before execution can probe the requested object. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_get_by_missing_id_is_denied(): void { @@ -587,7 +587,7 @@ public function test_get_by_missing_id_is_denied(): void { /** * An ID beyond the integer range is denied instead of wrapping around onto another post. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_get_by_id_beyond_the_integer_range_is_denied(): void { @@ -612,7 +612,7 @@ public function test_get_by_id_beyond_the_integer_range_is_denied(): void { /** * A post type guard mismatch is denied before execution can probe the requested object. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_get_by_id_with_mismatched_post_type_is_denied(): void { @@ -635,7 +635,7 @@ public function test_get_by_id_with_mismatched_post_type_is_denied(): void { /** * A post from a post type not exposed to abilities is denied. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_get_by_id_for_unexposed_post_type_is_denied(): void { @@ -669,7 +669,7 @@ public function test_get_by_id_for_unexposed_post_type_is_denied(): void { /** * A status that is public but not viewable is not exposed to read-only users. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_public_non_viewable_status_is_denied_for_read_only_users(): void { @@ -700,7 +700,7 @@ public function test_public_non_viewable_status_is_denied_for_read_only_users(): /** * A status that is public but not viewable remains available to users who can edit it. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_public_non_viewable_status_is_readable_with_edit_access(): void { @@ -733,7 +733,7 @@ public function test_public_non_viewable_status_is_readable_with_edit_access(): /** * A post that inherits its status from a readable parent is readable. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_inherited_post_is_readable_when_parent_is_readable(): void { @@ -776,7 +776,7 @@ public function test_inherited_post_is_readable_when_parent_is_readable(): void /** * A post with an inherited status but no readable parent is denied. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_inherited_post_without_parent_is_denied_for_read_only_users(): void { @@ -809,7 +809,7 @@ public function test_inherited_post_without_parent_is_denied_for_read_only_users /** * Query mode returns only published posts by default. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_returns_only_published_by_default(): void { @@ -833,7 +833,7 @@ public function test_query_returns_only_published_by_default(): void { * WP_Query default, matching the REST posts controller, and `include` only filters the * query, so a caller that passes IDs in a chosen order must not expect them back in it. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_orders_posts_newest_first_regardless_of_include_order(): void { @@ -878,7 +878,7 @@ public function test_query_orders_posts_newest_first_regardless_of_include_order /** * Query include still respects the requested post type. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_include_respects_requested_post_type(): void { @@ -930,7 +930,7 @@ public function test_query_include_accepts_a_single_post_id(): void { /** * Query include still respects row-level permissions. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_include_respects_row_level_permissions(): void { @@ -968,7 +968,7 @@ public function test_query_include_respects_row_level_permissions(): void { /** * Query mode can return included drafts with explicitly requested rendered and raw content. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_draft_include_can_return_content_fields(): void { @@ -1002,7 +1002,7 @@ public function test_query_draft_include_can_return_content_fields(): void { /** * Querying by slug without a post type is rejected by the input schema. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_slug_mode_requires_post_type(): void { @@ -1018,7 +1018,7 @@ public function test_slug_mode_requires_post_type(): void { /** * Slug mode returns a single post directly when paired with a post type. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_get_single_published_post_by_slug(): void { @@ -1050,7 +1050,7 @@ public function test_get_single_published_post_by_slug(): void { /** * A single post fetched by slug can return explicitly requested rendered and raw content. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_get_single_published_post_by_slug_can_return_content_fields(): void { @@ -1089,7 +1089,7 @@ public function test_get_single_published_post_by_slug_can_return_content_fields * size applies. Bounding it with `post_name__in` and a page size would page straight past * an older published post, because the query is ordered newest first. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_slug_lookup_is_not_bounded_by_a_page_size(): void { @@ -1145,7 +1145,7 @@ public function test_slug_lookup_is_not_bounded_by_a_page_size(): void { /** * A post whose slug is the literal string "0" is fetched in single-post slug mode. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_get_single_post_by_slug_zero(): void { @@ -1181,7 +1181,7 @@ public function test_get_single_post_by_slug_zero(): void { /** * Query-only filters cannot be combined with slug mode. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_slug_mode_rejects_query_only_params(): void { @@ -1203,7 +1203,7 @@ public function test_slug_mode_rejects_query_only_params(): void { /** * A newer draft sharing a published post's slug does not shadow the published post. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_slug_lookup_prefers_published_post_over_newer_draft(): void { @@ -1245,7 +1245,7 @@ public function test_slug_lookup_prefers_published_post_over_newer_draft(): void /** * A slug held only by a draft resolves for its author and stays denied for readers. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_slug_lookup_resolves_draft_only_slug_by_readability(): void { @@ -1287,7 +1287,7 @@ public function test_slug_lookup_resolves_draft_only_slug_by_readability(): void /** * Include is a query-only option and cannot be combined with single-post modes. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_include_cannot_be_combined_with_single_post_modes(): void { @@ -1317,7 +1317,7 @@ public function test_include_cannot_be_combined_with_single_post_modes(): void { /** * The `fields` filter limits the returned keys. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_fields_filter_limits_returned_keys(): void { @@ -1346,7 +1346,7 @@ public function test_fields_filter_limits_returned_keys(): void { * Unlike fields a post type does not support, which are omitted per post, a field * name that is not part of the supported set is rejected before the ability executes. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_unknown_requested_field_fails_schema_validation(): void { @@ -1367,7 +1367,7 @@ public function test_unknown_requested_field_fails_schema_validation(): void { /** * Logged-out users cannot run the ability. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_logged_out_user_is_denied(): void { @@ -1383,7 +1383,7 @@ public function test_logged_out_user_is_denied(): void { /** * Subscribers can request rendered published content. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_subscriber_can_request_published_content(): void { @@ -1412,7 +1412,7 @@ public function test_subscriber_can_request_published_content(): void { /** * Subscribers can fetch a published post by ID. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_subscriber_can_get_single_published_post_by_id(): void { @@ -1433,7 +1433,7 @@ public function test_subscriber_can_get_single_published_post_by_id(): void { /** * Subscribers cannot request edit-context raw fields in query mode. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_subscriber_cannot_request_raw_fields_in_query_mode(): void { @@ -1454,7 +1454,7 @@ public function test_subscriber_cannot_request_raw_fields_in_query_mode(): void /** * Subscribers cannot request edit-context raw fields for a single post. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_subscriber_cannot_request_raw_fields_for_single_post(): void { @@ -1477,7 +1477,7 @@ public function test_subscriber_cannot_request_raw_fields_for_single_post(): voi /** * Users who cannot edit another user's post do not receive raw fields by default. * - * @ticket 64606 + * @ticket 66268 * @dataProvider data_roles_without_edit_access_to_other_users_posts * * @param string $role The role to test. @@ -1501,7 +1501,7 @@ public function test_default_fields_omit_raw_fields_for_roles_without_edit_acces /** * Users who cannot edit another user's post cannot explicitly request raw fields. * - * @ticket 64606 + * @ticket 66268 * @dataProvider data_roles_without_edit_access_to_other_users_posts * * @param string $role The role to test. @@ -1526,7 +1526,7 @@ public function test_raw_field_requests_are_denied_for_roles_without_edit_access /** * Subscribers cannot request draft posts. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_subscriber_cannot_request_draft_status(): void { @@ -1547,7 +1547,7 @@ public function test_subscriber_cannot_request_draft_status(): void { /** * Subscribers cannot request private posts. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_subscriber_cannot_request_private_status(): void { @@ -1568,7 +1568,7 @@ public function test_subscriber_cannot_request_private_status(): void { /** * An author can pass the draft gate but only sees their own drafts. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_author_cannot_see_other_authors_drafts(): void { @@ -1609,7 +1609,7 @@ public function test_author_cannot_see_other_authors_drafts(): void { * This matches the REST posts controller: `posts` only contains rows the current * user can read, while `total` and `total_pages` describe the underlying query. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_totals_may_include_rows_withheld_by_row_level_permissions(): void { @@ -1652,7 +1652,7 @@ public function test_query_totals_may_include_rows_withheld_by_row_level_permiss /** * The parent filter is rejected for non-hierarchical post types, mirroring REST. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_mode_rejects_parent_filter_for_non_hierarchical_post_type(): void { @@ -1673,7 +1673,7 @@ public function test_query_mode_rejects_parent_filter_for_non_hierarchical_post_ /** * The parent filter narrows hierarchical queries to children of the given post. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_mode_filters_pages_by_parent(): void { @@ -1708,7 +1708,7 @@ public function test_query_mode_filters_pages_by_parent(): void { /** * The author_slug filter is rejected for post types without author support, mirroring REST. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_mode_rejects_author_filter_for_post_type_without_author_support(): void { @@ -1739,7 +1739,7 @@ public function test_query_mode_rejects_author_filter_for_post_type_without_auth * The author_slug filter narrows queries to posts by the given author, and each post * returns its author's slug. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_mode_filters_posts_by_author(): void { @@ -1778,7 +1778,7 @@ public function test_query_mode_filters_posts_by_author(): void { /** * Raw content is available to users who can edit the post. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_raw_content_visible_to_editor(): void { @@ -1804,7 +1804,7 @@ public function test_raw_content_visible_to_editor(): void { /** * Password-protected content is visible to users who can edit the post. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_password_protected_content_visible_to_editor(): void { @@ -1835,7 +1835,7 @@ public function test_password_protected_content_visible_to_editor(): void { /** * Password-protected rendered content is withheld from users who cannot edit the post. * - * @ticket 64606 + * @ticket 66268 * @dataProvider data_roles_without_edit_access_to_other_users_posts * * @param string $role The role to test. @@ -1863,7 +1863,7 @@ public function test_password_protected_rendered_content_is_empty_for_roles_with * The excerpt is generated from the content, which get_the_content() replaces with the * password form unless the ability unlocks the post for its editor. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_password_protected_excerpt_visible_to_editor(): void { @@ -1897,7 +1897,7 @@ public function test_password_protected_excerpt_visible_to_editor(): void { /** * Password-protected rendered excerpts are withheld from users who cannot edit the post. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_password_protected_rendered_excerpt_is_empty_for_subscriber(): void { @@ -1927,7 +1927,7 @@ public function test_password_protected_rendered_excerpt_is_empty_for_subscriber /** * Rendered excerpts carry the REST API's `the_excerpt` markup (paragraph wrapping). * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_excerpt_rendered_applies_the_excerpt_filters(): void { @@ -1952,7 +1952,7 @@ public function test_excerpt_rendered_applies_the_excerpt_filters(): void { * A title filter that returns a non-string empties the rendered title instead of failing * the query. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_rendered_title_is_empty_when_a_title_filter_returns_a_non_string(): void { @@ -1977,7 +1977,7 @@ public function test_rendered_title_is_empty_when_a_title_filter_returns_a_non_s * Rendered title and excerpt filters run with the requested post as the global context, * and the context that was active before the ability executed is restored. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_rendered_fields_use_and_restore_requested_post_context(): void { @@ -2062,7 +2062,7 @@ public function data_rendered_fields(): array { * Without a surrounding post, as in a REST request, wp_reset_postdata() cannot restore * the globals that setup_postdata() populates, because the main query has no post. * - * @ticket 64606 + * @ticket 66268 * @dataProvider data_rendered_fields * @since 7.2.0 * @@ -2091,7 +2091,7 @@ public function test_rendered_fields_unset_loop_globals_that_were_not_set( strin * Rendering a field restores the surrounding loop globals as they were, rather than as * setup_postdata() computes them for the surrounding post. * - * @ticket 64606 + * @ticket 66268 * @dataProvider data_rendered_fields * @since 7.2.0 * @@ -2149,7 +2149,7 @@ private function get_loop_globals(): array { /** * The password gate is suspended only for posts the current user can edit. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_allow_password_content_only_unlocks_editable_posts(): void { @@ -2184,7 +2184,7 @@ public function test_allow_password_content_only_unlocks_editable_posts(): void * Rendering an editable protected post must not unlock other protected posts embedded in * its content (e.g. through a shortcode or Query Loop block). * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_password_filter_does_not_leak_other_protected_posts(): void { @@ -2245,7 +2245,7 @@ static function ( $atts ): string { /** * Query mode reports a total that matches the returned posts for an uncapped query. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_total_matches_returned_posts_when_uncapped(): void { @@ -2270,7 +2270,7 @@ public function test_query_total_matches_returned_posts_when_uncapped(): void { * Guards the boundary next to the out-of-range page error: the final page must not be * mistaken for an overshoot. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_last_page_reports_totals(): void { @@ -2302,7 +2302,7 @@ public function test_query_last_page_reports_totals(): void { * which is indistinguishable from an empty collection. Match the REST posts controller * by reporting this as a caller error instead. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_out_of_range_page_is_rejected_rather_than_reported_as_empty(): void { @@ -2331,7 +2331,7 @@ public function test_query_out_of_range_page_is_rejected_rather_than_reported_as * * The out-of-range guard only fires when the underlying query actually matched rows. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_empty_result_beyond_first_page_reports_zero_totals(): void { @@ -2359,7 +2359,7 @@ public function test_query_empty_result_beyond_first_page_reports_zero_totals(): * Without this the default page size silently truncates a batch load: a caller asking * for a known set of IDs would receive only the first `per_page` of them. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_include_returns_every_requested_post_without_per_page(): void { @@ -2389,7 +2389,7 @@ public function test_query_include_returns_every_requested_post_without_per_page /** * An explicit `per_page` still paginates an include request. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_include_honors_an_explicit_per_page(): void { @@ -2415,7 +2415,7 @@ public function test_query_include_honors_an_explicit_per_page(): void { /** * The include list is capped at the maximum page size. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_include_is_capped_at_the_maximum_page_size(): void { @@ -2444,7 +2444,7 @@ public function test_query_include_is_capped_at_the_maximum_page_size(): void { * The rendered filter chains may read post meta, so priming avoids one lazy meta * query per returned row. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_rendered_fields_prime_the_post_meta_cache(): void { @@ -2486,7 +2486,7 @@ static function () use ( $ids ) { * the request belongs to whoever runs the query, and it may prime more for its own * reasons, so counting queries here would describe that layer rather than this one. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_lean_projection_does_not_request_post_meta_priming(): void { @@ -2547,7 +2547,7 @@ public function data_permalink_structures_that_read_related_data(): array { * Permalinks read the post's terms for `%category%` and its author for `%author%`, * so without priming, each returned row runs its own query. * - * @ticket 64606 + * @ticket 66268 * @dataProvider data_permalink_structures_that_read_related_data * * @param string $permalink_structure The permalink structure. @@ -2591,7 +2591,7 @@ static function () use ( $ids ) { /** * Requesting `link` primes the parents that page permalinks read for the whole page. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_link_primes_the_parents_page_permalinks_read(): void { @@ -2676,7 +2676,7 @@ private function count_queries( string $table, callable $callback, &$result ): i /** * Query rows are kept, not dropped, when the requested fields project to nothing. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_query_keeps_posts_with_empty_field_projection(): void { @@ -2706,7 +2706,7 @@ public function test_query_keeps_posts_with_empty_field_projection(): void { /** * A single post whose requested fields project to nothing is returned as an empty object. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_single_post_returns_empty_object_for_empty_field_projection(): void { @@ -2727,7 +2727,7 @@ public function test_single_post_returns_empty_object_for_empty_field_projection /** * Local and GMT date fields report the correct instant and offset on non-UTC sites. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_gmt_dates_are_utc_on_non_utc_sites(): void { @@ -2772,7 +2772,7 @@ public function test_gmt_dates_are_utc_on_non_utc_sites(): void { /** * Drafts without a stored GMT date derive it from the local date and the site timezone. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_gmt_date_is_derived_from_local_date_for_drafts(): void { @@ -2870,7 +2870,7 @@ public function data_gmt_date_fields(): array { * `strtotime( ' UTC' )` resolves to the current time, so an unguarded null would * report a fabricated "now" as the publication date. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 * * @dataProvider data_gmt_date_fields @@ -2919,7 +2919,7 @@ public function test_gmt_date_recovers_from_a_null_stored_gmt_date( string $fiel /** * A post with no usable date at all reports the documented empty-string sentinel. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 * * @dataProvider data_gmt_date_fields @@ -2961,7 +2961,7 @@ public function test_gmt_date_is_empty_when_no_usable_date_exists( string $field * denies the same lookups first. The registered callback still fails closed on * structural lookup errors when invoked directly. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_execute_callback_returns_not_found_for_structural_lookup_failures(): void { @@ -3009,7 +3009,7 @@ public function data_author_slugs_that_name_no_user(): array { * An author_slug filter that does not name a user is rejected rather than silently * dropped, which would widen the query to every author's posts. * - * @ticket 64606 + * @ticket 66268 * @dataProvider data_author_slugs_that_name_no_user * * @param mixed $author_slug The author slug filter value. @@ -3044,7 +3044,7 @@ public function test_execute_callback_rejects_an_author_slug_that_names_no_user( * cannot be detected by a numeric bound; it must be rejected on the raw value so * garbage does not silently become a top-level query. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_execute_callback_rejects_non_integer_parent_filter(): void { @@ -3070,7 +3070,7 @@ public function test_execute_callback_rejects_non_integer_parent_filter(): void * would otherwise return every post of the type — the opposite of the caller's * intent. The filter must fail closed instead. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_execute_callback_rejects_include_with_no_valid_ids(): void { @@ -3095,7 +3095,7 @@ public function test_execute_callback_rejects_include_with_no_valid_ids(): void * published posts is reported like a missing one to a subscriber, but not to an editor, * who can edit their posts. * - * @ticket 64606 + * @ticket 66268 * @since 7.2.0 */ public function test_author_slug_filter_only_names_users_the_current_user_may_see(): void { @@ -3137,7 +3137,7 @@ public function test_author_slug_filter_only_names_users_the_current_user_may_se * On multisite, an author_slug filter does not tell an editor of one site whether a user * of another site exists. * - * @ticket 64606 + * @ticket 66268 * @group ms-required * @since 7.2.0 */ @@ -3173,7 +3173,7 @@ public function test_author_slug_filter_hides_users_of_other_sites(): void { * On multisite, an author_slug filter still names an author who is not a member of the * site, such as a super admin, when they have published posts there. * - * @ticket 64606 + * @ticket 66268 * @group ms-required * @since 7.2.0 */ diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php index cf54bd3da9b70..26fd28e998964 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -124,7 +124,7 @@ private function run_request( array $input ): WP_REST_Request { } /** - * @ticket 64606 + * @ticket 66268 */ public function test_logged_out_user_receives_401(): void { wp_set_current_user( 0 ); @@ -135,7 +135,7 @@ public function test_logged_out_user_receives_401(): void { } /** - * @ticket 64606 + * @ticket 66268 */ public function test_subscriber_requesting_drafts_receives_403(): void { wp_set_current_user( self::$subscriber_id ); @@ -153,7 +153,7 @@ public function test_subscriber_requesting_drafts_receives_403(): void { } /** - * @ticket 64606 + * @ticket 66268 */ public function test_subscriber_requesting_published_posts_receives_readable_fields(): void { $post_id = self::factory()->post->create( @@ -189,7 +189,7 @@ public function test_subscriber_requesting_published_posts_receives_readable_fie } /** - * @ticket 64606 + * @ticket 66268 */ public function test_subscriber_requesting_raw_fields_receives_403(): void { wp_set_current_user( self::$subscriber_id ); @@ -207,7 +207,7 @@ public function test_subscriber_requesting_raw_fields_receives_403(): void { } /** - * @ticket 64606 + * @ticket 66268 */ public function test_admin_query_returns_published_posts(): void { $post_id = self::factory()->post->create( @@ -226,7 +226,7 @@ public function test_admin_query_returns_published_posts(): void { } /** - * @ticket 64606 + * @ticket 66268 */ public function test_admin_query_include_limits_results(): void { $first = self::factory()->post->create( @@ -266,7 +266,7 @@ public function test_admin_query_include_limits_results(): void { } /** - * @ticket 64606 + * @ticket 66268 */ public function test_get_single_post_by_id(): void { $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); @@ -281,7 +281,7 @@ public function test_get_single_post_by_id(): void { } /** - * @ticket 64606 + * @ticket 66268 */ public function test_get_single_post_by_slug(): void { $post_id = self::factory()->post->create( @@ -308,7 +308,7 @@ public function test_get_single_post_by_slug(): void { } /** - * @ticket 64606 + * @ticket 66268 */ public function test_wrong_http_method_returns_405(): void { $request = new WP_REST_Request( 'POST', self::RUN_ROUTE ); @@ -322,7 +322,7 @@ public function test_wrong_http_method_returns_405(): void { } /** - * @ticket 64606 + * @ticket 66268 */ public function test_pagination_returns_totals_in_body(): void { self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); @@ -345,7 +345,7 @@ public function test_pagination_returns_totals_in_body(): void { } /** - * @ticket 64606 + * @ticket 66268 */ public function test_out_of_range_page_returns_400(): void { self::factory()->post->create( array( 'post_status' => 'publish' ) ); From eeb4f7d747eb422de9bf43d34c4c191c07137f54 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 21:39:23 +0100 Subject: [PATCH 58/69] Abilities API: Save the content query loop globals by value set_up_post_context() saved the loop globals with array_intersect_key( $GLOBALS, ... ), which keeps a global as a reference when a calling function binds it with `global`, as load_template() and WP_Block::render() do. Setting up the requested post then changed the saved copy as well, so the restore left the requested post in `$post` and `$id` while the other loop globals went back to the surrounding post. In a classic theme, the rest of the template, including the comment form, then used the wrong post. --- .../abilities/class-wp-content-abilities.php | 15 +++++- .../wpRegisterCoreContentAbility.php | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index a022493f6eb6d..21fa7444f15c7 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -1415,7 +1415,20 @@ private function get_rendered_content( WP_Post $post ): string { * @return array The previous loop globals, keyed by name, leaving out those that were not set. */ private function set_up_post_context( WP_Post $post ): array { - $previous_context = array_intersect_key( $GLOBALS, array_flip( self::LOOP_GLOBALS ) ); + /* + * Copy each global by value. A calling function that binds a global with `global`, + * as load_template() and WP_Block::render() do, makes it a reference, which + * array_intersect_key( $GLOBALS, ... ) would keep. The saved copy would then follow + * the global to this post, and the restore would put this post back. + */ + $previous_context = array(); + foreach ( self::LOOP_GLOBALS as $name ) { + if ( ! array_key_exists( $name, $GLOBALS ) ) { + continue; + } + + $previous_context[ $name ] = $GLOBALS[ $name ]; + } // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily mirrors REST post context for rendering. $GLOBALS['post'] = $post; diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 4816640b1ecb2..8aae78c08ff6d 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -2126,6 +2126,52 @@ public function test_rendered_fields_restore_surrounding_loop_globals( string $f $this->assertSame( $surrounding_globals, $this->get_loop_globals(), 'The surrounding loop globals should be restored as they were.' ); } + /** + * Rendering a field restores the surrounding loop globals when a calling function binds + * them with `global`, as load_template() and WP_Block::render() do. + * + * Such a binding makes the global a reference, so a saved copy that kept the reference + * would follow the global to the rendered post, and the rendered post would be restored. + * + * @ticket 66268 + * @dataProvider data_rendered_fields + * @since 7.2.0 + * + * @param string $field The rendered field to request. + */ + public function test_rendered_fields_restore_loop_globals_bound_by_the_caller( string $field ): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $surrounding = get_post( self::$post_ids['published'] ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Establishes a surrounding context to verify the ability restores it. + $GLOBALS['post'] = $surrounding; + setup_postdata( $surrounding ); + + $surrounding_globals = $this->get_loop_globals(); + + $render_in_template = static function ( string $field ): array { + global $post, $id; + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'id' => self::$post_ids['limited_role_content'], + 'fields' => array( $field ), + ) + ); + + return array( $result, $post->ID, $id ); + }; + + list( $result, $bound_post_id, $bound_id ) = $render_in_template( $field ); + + $this->assertArrayHasKey( $field, $result, 'Precondition: the rendered field should be returned.' ); + $this->assertSame( $surrounding->ID, $bound_post_id, 'The caller should see the surrounding post again after rendering.' ); + $this->assertSame( $surrounding->ID, $bound_id, 'The caller should see the surrounding post ID again after rendering.' ); + $this->assertSame( $surrounding_globals, $this->get_loop_globals(), 'The surrounding loop globals should be restored as they were.' ); + } + /** * Returns the loop globals that are set, keyed by name. * From 2d94ef4436e5398679639d858185694428975896 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 21:39:27 +0100 Subject: [PATCH 59/69] Tests: Cover the content query post context and author priming format_post() sets each post up as the global post, and query mode primes the authors that setup_postdata() reads. Test that title and permalink filters see the requested post when no post was set up before, as in a REST request, and that rendered fields read primed authors instead of querying once per author. --- .../wpRegisterCoreContentAbility.php | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 8aae78c08ff6d..fda1f0c6858f3 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -2172,6 +2172,48 @@ public function test_rendered_fields_restore_loop_globals_bound_by_the_caller( s $this->assertSame( $surrounding_globals, $this->get_loop_globals(), 'The surrounding loop globals should be restored as they were.' ); } + /** + * Title and permalink filters run with the requested post as the global post, also when + * no post was set up before, as in a REST request. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_title_and_link_filters_see_the_requested_post(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + foreach ( self::LOOP_GLOBALS as $name ) { + unset( $GLOBALS[ $name ] ); + } + + $target_id = self::$post_ids['limited_role_content']; + + add_filter( + 'the_title', + static function ( $title ): string { + return (string) $title . ''; + }, + 20 + ); + add_filter( + 'post_link', + static function ( $link ): string { + return add_query_arg( 'context', get_the_ID(), (string) $link ); + } + ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'id' => $target_id, + 'fields' => array( 'title_rendered', 'link' ), + ) + ); + + $this->assertStringContainsString( '', $result['title_rendered'], 'Title filters should see the requested post as the current post.' ); + $this->assertStringContainsString( 'context=' . $target_id, $result['link'], 'Permalink filters should see the requested post as the current post.' ); + } + /** * Returns the loop globals that are set, keyed by name. * @@ -2522,6 +2564,50 @@ static function () use ( $ids ) { $this->assertSame( 1, $postmeta_queries, 'Rendered field requests should prime post meta with a single batched query, not one per returned post.' ); } + /** + * Requesting rendered fields primes the authors of the whole page. + * + * Rendering sets each post up with setup_postdata(), which reads the post's author, so + * without priming, each author on the page runs its own query. + * + * @ticket 66268 + * @dataProvider data_rendered_fields + * @since 7.2.0 + * + * @param string $field The rendered field to request. + */ + public function test_query_rendered_fields_prime_the_authors_of_the_page( string $field ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = array(); + foreach ( array( 'author', 'author_secondary', 'editor' ) as $role ) { + $ids[] = self::factory()->post->create( + array( + 'post_author' => self::$user_ids[ $role ], + 'post_status' => 'publish', + ) + ); + } + + $users_queries = $this->count_queries( + 'users', + static function () use ( $ids, $field ) { + return wp_get_ability( 'core/content-query' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'fields' => array( 'id', $field ), + ) + ); + }, + $result + ); + + $this->assertCount( 3, $result['posts'], 'Precondition: the query should return the seeded posts.' ); + $this->assertSame( 1, $users_queries, 'Rendered fields should read primed authors, not query once per author.' ); + } + /** * A lean projection does not ask for post meta priming. * From 9a035ea5588948ccd1ac4947ecdee31f541a3e7a Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 22:50:58 +0100 Subject: [PATCH 60/69] Abilities API: Document `show_in_abilities` for all content abilities --- src/wp-includes/class-wp-post-type.php | 4 ++-- src/wp-includes/post.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index 1781191dc517a..ac9682c426c51 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -376,8 +376,8 @@ final class WP_Post_Type { /** * Whether this post type should be exposed through the Abilities API. * - * Default false. If true, the post type's readable posts can be retrieved through - * the read-only `core/content-query` ability, subject to per-post capability checks. + * Default false. If true, the core content abilities can access the post type's + * posts, subject to the current user's capabilities. * * @since 7.2.0 * @var bool $show_in_abilities diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 4a26a9f2bc9df..041e71e31a97e 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -1739,9 +1739,9 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * of $show_in_menu. * @type bool $show_in_rest Whether to include the post type in the REST API. Set this to true * for the post type to be available in the block editor. - * @type bool $show_in_abilities Whether to expose this post type through the Abilities API, so its - * readable posts can be retrieved via the read-only `core/content-query` - * ability (subject to per-post capability checks). Default false. + * @type bool $show_in_abilities Whether to expose this post type through the Abilities API, so the + * core content abilities can access its posts, subject to the current + * user's capabilities. Default false. * @type string $rest_base To change the base URL of REST API route. Default is $post_type. * @type string $rest_namespace To change the namespace URL of REST API route. Default is wp/v2. * @type string $rest_controller_class REST API controller class name. Default is 'WP_REST_Posts_Controller'. From d02c02eaa322017a959613218d4f9df9a830c6ed Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 22:50:58 +0100 Subject: [PATCH 61/69] Abilities API: Explain the content query string input in one place --- .../abilities/class-wp-content-abilities.php | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 21fa7444f15c7..7a6c0480b6bac 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -214,12 +214,11 @@ public function check_permission( $input = array() ): bool { /** * Parses a raw input value into an integer of at least a minimum, or null when invalid. * - * Values that are not integers are rejected rather than coerced, so an ID or a parent - * that cannot be honored fails loudly instead of being read as 0: a `parent` filter of - * 0 asks for top-level posts. Accepts native integers and unsigned integer strings. - * Schema validation accepts an integer string, and only the REST run controller - * converts input to the schema types, so callers that bypass it, such as a direct - * WP_Ability::execute() call, can pass one. + * Accepts native integers and unsigned integer strings. Only the REST run controller + * converts input to the schema types, so other callers, such as a direct + * WP_Ability::execute() call, can pass an integer string, which schema validation + * accepts. Anything else is rejected rather than read as 0, which as a `parent` + * filter would ask for top-level posts. * * @since 7.2.0 * @@ -244,10 +243,8 @@ private function parse_filter_int( $value, int $min ): ?int { /** * Parses a raw list input into a list of strings. * - * Schema validation accepts a list given as a scalar or comma-separated string, and - * only the REST run controller converts input to the schema types, so callers that - * bypass it, such as a direct WP_Ability::execute() call, can pass one. This parses - * it the same way validation did, with wp_parse_list(). + * Like schema validation, it also accepts a scalar or a comma-separated string, and + * parses it the same way, with wp_parse_list(). * * @since 7.2.0 * @@ -804,9 +801,8 @@ private function normalize_include( array $input ): array { } /* - * Schema validation also accepts a single ID or a comma-separated string, which - * callers that bypass the REST run controller can pass; wp_parse_id_list() accepts - * every form and yields unique positive IDs. + * wp_parse_id_list() also parses a single ID or a comma-separated string, as + * schema validation does. */ return array_values( array_filter( wp_parse_id_list( $include ) ) ); } From 19fae9948afba70e18d9eec75f738cfd28eee45d Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 23:41:51 +0100 Subject: [PATCH 62/69] Abilities API: Honor whole-number `page` and `per_page` in the content query The integer schema also accepts whole floats such as 2.0 and strings such as "2.0" or "+2", and callers other than the REST run controller, such as the MCP adapter, pass them on unconverted. The content query read `page` and `per_page` with parse_filter_int(), which rejects them, so such a request silently returned page 1 at the default page size: a client paging with 2.0, 3.0, and so on never got past page 1 or reached the error for a page past the last one. Read both with absint() again, as the REST posts controller does. --- .../abilities/class-wp-content-abilities.php | 18 +++++-- .../wpRegisterCoreContentAbility.php | 54 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index 7a6c0480b6bac..beba28d15700a 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -471,8 +471,19 @@ public function execute_content_query( $input = array() ) { return $this->invalid_filter_error( __( 'The include filter must list one or more valid post IDs.' ) ); } + /* + * Read `page` and `per_page` with absint(), as the REST posts controller does, not with + * parse_filter_int(). The integer schema also accepts whole floats such as 2.0, which + * JSON encoders and ceil() produce, and strings such as "2.0" or "+2", and callers other + * than the REST run controller pass them on unconverted, such as the MCP adapter or a + * direct WP_Ability::execute() call. parse_filter_int() rejects them, so the query + * would silently fall back to page 1 and the default page size: a client paging with + * 2.0, 3.0, and so on would get page 1 every time and never reach the error for a page + * past the last one. The schema's minimum of 1 keeps out the negative values that + * absint() would turn positive. + */ $per_page = $this->normalize_per_page( $input, $include ); - $page = $this->parse_filter_int( $input['page'] ?? 1, 1 ) ?? 1; + $page = isset( $input['page'] ) ? max( 1, absint( $input['page'] ) ) : 1; $prime_post_caches = $this->should_prime_post_caches( $fields ); @@ -570,8 +581,9 @@ public function execute_content_query( $input = array() ) { * @return int The clamped per-page value. */ private function normalize_per_page( array $input, array $include_ids ): int { - $per_page = $this->parse_filter_int( $input['per_page'] ?? null, 1 ); - if ( null === $per_page ) { + // absint(), not parse_filter_int(): see where execute_content_query() reads `page`. + $per_page = isset( $input['per_page'] ) ? absint( $input['per_page'] ) : 0; + if ( $per_page < 1 ) { $per_page = array() === $include_ids ? self::DEFAULT_PER_PAGE : count( $include_ids ); } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index fda1f0c6858f3..5d5dd097ef92f 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -2414,6 +2414,60 @@ public function test_query_out_of_range_page_is_rejected_rather_than_reported_as $this->assertSame( 400, $result->get_error_data()['status'], 'An out-of-range page is a caller error.' ); } + /** + * Returns whole numbers that the integer schema accepts but that are not PHP integers, + * as a page, a page size, and a page past the last one. + * + * @return array The values to send. + */ + public function data_whole_numbers_that_are_not_integers(): array { + return array( + 'floats' => array( 2.0, 2.0, 99.0 ), + 'decimal strings' => array( '2.0', '2.0', '99.0' ), + 'signed strings' => array( '+2', '+2', '+99' ), + 'strings with a space' => array( ' 2', ' 2', ' 99' ), + ); + } + + /** + * A `page` and `per_page` that the integer schema accepts are honored even when they are + * not PHP integers, as when the MCP adapter passes the 2.0 a JSON encoder produced. + * + * Read with parse_filter_int(), they fell back to page 1 and the default page size, so a + * client paging with 2.0, 3.0, and so on got page 1 every time and never reached the error + * for a page past the last one. + * + * @ticket 66268 + * @dataProvider data_whole_numbers_that_are_not_integers + * @since 7.2.0 + * + * @param float|string $page The page to request. + * @param float|string $per_page The page size to request. + * @param float|string $past_page A page past the last one. + */ + public function test_query_honors_a_page_and_per_page_that_are_not_integers( $page, $per_page, $past_page ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $query = array( + 'post_type' => 'post', + 'include' => self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ), + 'per_page' => $per_page, + 'fields' => array( 'id' ), + ); + + $result = wp_get_ability( 'core/content-query' )->execute( array( 'page' => $page ) + $query ); + + $this->assertIsArray( $result, 'The page should be returned.' ); + $this->assertCount( 1, $result['posts'], 'The second page of two posts each should hold the third post.' ); + $this->assertSame( 2, $result['total_pages'], 'The page count should follow the requested page size.' ); + + $result = wp_get_ability( 'core/content-query' )->execute( array( 'page' => $past_page ) + $query ); + + $this->assertWPError( $result, 'A page past the last one should still fail.' ); + $this->assertSame( 'content_invalid_page_number', $result->get_error_code(), 'A page past the last one should report the out-of-range error.' ); + } + /** * A genuinely empty result set beyond the first page reports zero totals, not an error. * From 870ec33dfcc9e768aa26452c8a6d51a6c453b524 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 7 Oct 2026 23:41:51 +0100 Subject: [PATCH 63/69] Abilities API: Keep the setup of a global post that was not set up restore_post_context() unset the loop globals that were not set before, even after setup_postdata() had set up the previous global post again. A global post that was assigned but never set up, such as the main post before the loop starts, then had no `$pages` after `the_post` had fired, so get_the_content() without a post, as the Post Content block and the_content() outside the loop call it, threw a TypeError. Such a post now keeps the loop globals that setup_postdata() gives it. --- .../abilities/class-wp-content-abilities.php | 15 ++++++- .../wpRegisterCoreContentAbility.php | 39 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php index beba28d15700a..e77577b266b05 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -1452,7 +1452,8 @@ private function set_up_post_context( WP_Post $post ): array { * wp_reset_postdata() alone is not enough: it does nothing when the main query has no * post, which would leave the rendered post's data in the globals. When a post was set * up before, setup_postdata() first runs for it again, as wp_reset_postdata() would, so - * callbacks on the `the_post` action can restore their own globals too. + * callbacks on the `the_post` action can restore their own globals too. A global post + * that was never set up keeps the loop globals that setup_postdata() gives it. * * @since 7.2.0 * @@ -1464,6 +1465,18 @@ private function restore_post_context( array $previous_context ): void { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the previous global post context. $GLOBALS['post'] = $previous_post; setup_postdata( $previous_post ); + + /* + * A global post that was never set up, such as the main post before the loop + * starts, keeps what setup_postdata() just gave it. Do not put back the values + * saved before: `the_post` has fired now, so get_the_content() called without a + * post, as the Post Content block and the_content() outside the loop do, reads the + * loop globals instead of the post, and an unset `$pages` makes it throw a + * TypeError that breaks the page being rendered. + */ + if ( ! is_array( $previous_context['pages'] ?? null ) ) { + return; + } } foreach ( self::LOOP_GLOBALS as $name ) { diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 5d5dd097ef92f..f6e1253d464e1 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -2172,6 +2172,45 @@ public function test_rendered_fields_restore_loop_globals_bound_by_the_caller( s $this->assertSame( $surrounding_globals, $this->get_loop_globals(), 'The surrounding loop globals should be restored as they were.' ); } + /** + * Rendering a field sets up a global post that was not set up before, such as the main + * post before the loop starts, so get_the_content() without a post still works. + * + * Rendering fires `the_post`, after which get_the_content() without a post reads the loop + * globals. Unsetting `$pages` again, as for a post that was never set up, made it throw a + * TypeError. + * + * @ticket 66268 + * @dataProvider data_rendered_fields + * @since 7.2.0 + * + * @param string $field The rendered field to request. + */ + public function test_rendered_fields_set_up_a_global_post_that_was_not_set_up( string $field ): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + foreach ( self::LOOP_GLOBALS as $name ) { + unset( $GLOBALS[ $name ] ); + } + + // WP::register_globals() sets the main post this way, before the loop sets it up. + $main_post = get_post( self::$post_ids['published_content'] ); + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Establishes a global post that was not set up. + $GLOBALS['post'] = $main_post; + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'id' => self::$post_ids['limited_role_content'], + 'fields' => array( $field ), + ) + ); + + $this->assertArrayHasKey( $field, $result, 'Precondition: the rendered field should be returned.' ); + $this->assertSame( $main_post->ID, get_the_ID(), 'The main post should be the global post again.' ); + $this->assertSame( 'Body here.', get_the_content(), 'get_the_content() without a post should return the main post content.' ); + } + /** * Title and permalink filters run with the requested post as the global post, also when * no post was set up before, as in a REST request. From b334505da51738a581b1aa80351dd3612ea61f3b Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 8 Oct 2026 15:51:33 +0100 Subject: [PATCH 64/69] Abilities API: Rename the content class to `WP_Abilities_Content` Rename `WP_Content_Abilities` to `WP_Abilities_Content`, matching the `WP_Abilities_` prefix of the users abilities class. Also: - Only expose a post type whose `show_in_abilities` is `true`, so other values, such as arrays, can be given a meaning later. - Count the pages with the page size the query ran with, as the REST posts controller does, since `pre_get_posts` callbacks can change it. - Skip posts of other post types that query filters add, in query mode and in the slug lookup. - Fold to_output_post() into format_post() and the local and GMT date helpers into format_date(), inline the raw title format callback, and stop caching the post field definitions. --- src/wp-includes/abilities.php | 4 +- ...ies.php => class-wp-abilities-content.php} | 149 ++++----- src/wp-includes/class-wp-post-type.php | 3 + .../wpRegisterCoreContentAbility.php | 306 +++++++++++++++++- .../wpRestAbilitiesContentController.php | 2 +- 5 files changed, 366 insertions(+), 98 deletions(-) rename src/wp-includes/abilities/{class-wp-content-abilities.php => class-wp-abilities-content.php} (94%) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 4224c3c43f858..20f287d49bcfe 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -9,7 +9,7 @@ declare( strict_types = 1 ); -require_once __DIR__ . '/abilities/class-wp-content-abilities.php'; +require_once __DIR__ . '/abilities/class-wp-abilities-content.php'; require_once __DIR__ . '/abilities/class-wp-abilities-settings.php'; /** @@ -377,5 +377,5 @@ function wp_register_core_abilities(): void { ( new WP_Abilities_Settings() )->register(); // Register the content abilities (currently the read-only `core/content-query`). - ( new WP_Content_Abilities() )->register(); + ( new WP_Abilities_Content() )->register(); } diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-abilities-content.php similarity index 94% rename from src/wp-includes/abilities/class-wp-content-abilities.php rename to src/wp-includes/abilities/class-wp-abilities-content.php index e77577b266b05..f0952d2a4f4e3 100644 --- a/src/wp-includes/abilities/class-wp-content-abilities.php +++ b/src/wp-includes/abilities/class-wp-abilities-content.php @@ -1,6 +1,6 @@ |null - */ - private ?array $post_properties = null; - /** * Registers all content abilities. * @@ -395,7 +387,7 @@ private function check_read_permission( WP_Post $post, array $checked_post_ids = * because the gate cannot resolve rows before the query runs. * * A post is returned as an empty object when its field projection is empty, so callers - * must not assume array access on a post. See {@see self::to_output_post()}. + * must not assume array access on a post. See {@see self::format_post()}. * * @since 7.2.0 * @@ -411,7 +403,7 @@ public function execute_content_query( $input = array() ) { if ( isset( $input['id'] ) || isset( $input['slug'] ) ) { $post = $this->get_requested_post( $input ); - return $post ? $this->to_output_post( $this->format_post( $post, $fields ) ) : $this->not_found_error(); + return $post ? $this->format_post( $post, $fields ) : $this->not_found_error(); } $post_type_object = $this->get_exposed_post_type( $input['post_type'] ?? null ); @@ -514,9 +506,20 @@ public function execute_content_query( $input = array() ) { $query_args['post_parent'] = $parent; } - $query = new WP_Query( $query_args ); - $total = $this->get_query_total( $query, $query_args, $page ); - $total_pages = (int) ceil( $total / $per_page ); + $query = new WP_Query( $query_args ); + $total = $this->get_query_total( $query, $query_args, $page ); + + /* + * Count the pages with the page size the query ran with, as the REST posts controller + * does, since query filters such as `pre_get_posts` callbacks can change it. Without + * paging, the query returns every post on one page. + */ + $query_per_page = (int) $query->get( 'posts_per_page' ); + if ( $query->get( 'nopaging' ) || $query_per_page < 1 ) { + $total_pages = $total > 0 ? 1 : 0; + } else { + $total_pages = (int) ceil( $total / $query_per_page ); + } /* * Paging past the last page is a caller error rather than an empty collection, so @@ -542,7 +545,12 @@ public function execute_content_query( $input = array() ) { $posts = array(); foreach ( $query->posts as $post ) { - if ( ! $post instanceof WP_Post ) { + /* + * Skip posts of other post types, which query filters can add, such as a + * `pre_get_posts` callback that adds post types to the blog home without an + * is_main_query() check. + */ + if ( ! $post instanceof WP_Post || $post_type !== $post->post_type ) { continue; } if ( $requires_edit && ! current_user_can( 'edit_post', $post->ID ) ) { @@ -552,12 +560,12 @@ public function execute_content_query( $input = array() ) { continue; } // Keep rows whose field projection is empty so a caller can still count them. - $posts[] = $this->to_output_post( $this->format_post( $post, $fields ) ); + $posts[] = $this->format_post( $post, $fields ); } /* * Mirror the REST posts controller: totals come from the underlying WP_Query, - * while row-level permission checks above may withhold individual returned rows. + * while the row-level checks above may withhold individual returned rows. */ return array( 'posts' => $posts, @@ -754,6 +762,15 @@ private function get_post_by_slug( string $post_type, string $slug ): ?WP_Post { // Candidates come newest first; a publicly viewable post is always readable here. $readable = null; foreach ( $query->posts as $candidate ) { + /* + * Skip posts of other post types, which query filters can add, such as a + * `pre_get_posts` callback that adds post types to single views without an + * is_main_query() check, since a `name` query is a single view. + */ + if ( ! $candidate instanceof WP_Post || $post_type !== $candidate->post_type ) { + continue; + } + if ( is_post_publicly_viewable( $candidate ) ) { return $candidate; } @@ -850,11 +867,7 @@ private function normalize_fields( array $input ): array { * @return array Post field definitions. */ private function get_post_properties(): array { - if ( null !== $this->post_properties ) { - return $this->post_properties; - } - - $this->post_properties = array( + return array( 'id' => array( 'type' => 'integer', 'description' => __( 'The post ID.' ), @@ -932,8 +945,6 @@ private function get_post_properties(): array { 'description' => __( 'The parent post ID. Present for hierarchical post types.' ), ), ); - - return $this->post_properties; } /** @@ -1141,26 +1152,6 @@ private function get_content_query_output_schema(): array { ); } - /** - * Prepares a formatted post for output. - * - * A field projection can legitimately be empty, for example when the only requested - * field is one the post type does not support. An empty PHP array encodes as `[]`, - * which would break the `object` output schema, so return an empty object instead. - * - * This deliberately improves on the REST posts controller, which encodes the same - * case as `[]` even though it types the response as an object - * (`GET /wp/v2/posts/?_fields=parent` on a non-hierarchical post type). - * - * @since 7.2.0 - * - * @param array $formatted The formatted post data. - * @return array|stdClass The post data, or an empty object when the projection is empty. - */ - private function to_output_post( array $formatted ) { - return array() === $formatted ? (object) array() : $formatted; - } - /** * Formats a post into the ability output shape. * @@ -1171,13 +1162,18 @@ private function to_output_post( array $formatted ) { * protected-post placeholders. The field projection itself is delegated to * {@see self::build_post_fields()}. * + * A field projection can legitimately be empty, for example when the only requested + * field is one the post type does not support. An empty PHP array encodes as `[]`, + * which would break the `object` output schema, so an empty object is returned instead. + * The REST posts controller returns `[]` in that case. + * * @since 7.2.0 * * @param WP_Post $post The post object. * @param list $fields The requested field names. - * @return array The formatted post data. + * @return array|stdClass The formatted post data, or an empty object when the projection is empty. */ - private function format_post( WP_Post $post, array $fields ): array { + private function format_post( WP_Post $post, array $fields ) { $can_edit = current_user_can( 'edit_post', $post->ID ); $password_required = post_password_required( $post ); $unlock_password = $password_required && $can_edit; @@ -1198,7 +1194,7 @@ private function format_post( WP_Post $post, array $fields ): array { * disabled or the global post pointing at this post for the rest of the request. */ try { - return $this->build_post_fields( $post, $fields, $can_edit, $password_required && ! $can_edit ); + $data = $this->build_post_fields( $post, $fields, $can_edit, $password_required && ! $can_edit ); } finally { if ( $unlock_password ) { remove_filter( 'post_password_required', array( $this, 'allow_password_content' ), 10 ); @@ -1206,6 +1202,8 @@ private function format_post( WP_Post $post, array $fields ): array { $this->restore_post_context( $previous_context ); } + + return array() === $data ? (object) array() : $data; } /** @@ -1245,16 +1243,16 @@ private function build_post_fields( WP_Post $post, array $fields, bool $can_edit $data['status'] = $post->post_status; } if ( isset( $requested['date'] ) ) { - $data['date'] = $this->format_local_date( $post, 'date' ); + $data['date'] = $this->format_date( $post, 'date', false ); } if ( isset( $requested['date_gmt'] ) ) { - $data['date_gmt'] = $this->format_gmt_date( $post, 'date' ); + $data['date_gmt'] = $this->format_date( $post, 'date', true ); } if ( isset( $requested['modified'] ) ) { - $data['modified'] = $this->format_local_date( $post, 'modified' ); + $data['modified'] = $this->format_date( $post, 'modified', false ); } if ( isset( $requested['modified_gmt'] ) ) { - $data['modified_gmt'] = $this->format_gmt_date( $post, 'modified' ); + $data['modified_gmt'] = $this->format_date( $post, 'modified', true ); } if ( isset( $requested['slug'] ) ) { $data['slug'] = $post->post_name; @@ -1338,7 +1336,9 @@ public function allow_password_content( $required, $post ): bool { * @return string The post title. */ private function get_title( WP_Post $post ): string { - $strip = array( $this, 'return_raw_title_format' ); + $strip = static function (): string { + return '%s'; + }; add_filter( 'protected_title_format', $strip ); add_filter( 'private_title_format', $strip ); @@ -1360,17 +1360,6 @@ private function get_title( WP_Post $post ): string { } } - /** - * Returns the raw title format, used to strip protected/private title prefixes. - * - * @since 7.2.0 - * - * @return string The unprefixed title format. - */ - public function return_raw_title_format(): string { - return '%s'; - } - /** * Returns the post excerpt transformed for display. * @@ -1489,39 +1478,29 @@ private function restore_post_context( array $previous_context ): void { } /** - * Formats a post date field as an ISO 8601 string in the site's timezone. + * Formats a post date field as an ISO 8601 string, in the site's timezone or in GMT. * - * @since 7.2.0 - * - * @param WP_Post $post The post object. - * @param string $field Either 'date' or 'modified'. - * @return string The ISO 8601 date, or an empty string if unavailable. - */ - private function format_local_date( WP_Post $post, string $field ): string { - $datetime = get_post_datetime( $post, $field ); - - return $datetime ? $datetime->format( 'c' ) : ''; - } - - /** - * Formats a post date field as an ISO 8601 string in GMT. - * - * Reads the stored GMT date, deriving it from the local date when it is missing - * (e.g. drafts), mirroring the REST posts controller. + * In GMT, it reads the stored GMT date, deriving it from the local date when it is + * missing (e.g. drafts), mirroring the REST posts controller. * * @since 7.2.0 * * @param WP_Post $post The post object. * @param string $field Either 'date' or 'modified'. + * @param bool $gmt Whether to format the date in GMT instead of the site's timezone. * @return string The ISO 8601 date, or an empty string if unavailable. */ - private function format_gmt_date( WP_Post $post, string $field ): string { - $datetime = get_post_datetime( $post, $field, 'gmt' ); + private function format_date( WP_Post $post, string $field, bool $gmt ): string { + $datetime = $gmt ? get_post_datetime( $post, $field, 'gmt' ) : false; if ( ! $datetime ) { $datetime = get_post_datetime( $post, $field ); } - return $datetime ? $datetime->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'c' ) : ''; + if ( ! $datetime ) { + return ''; + } + + return ( $gmt ? $datetime->setTimezone( new DateTimeZone( 'UTC' ) ) : $datetime )->format( 'c' ); } /** diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index ac9682c426c51..6307cbcd4c6f7 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -595,6 +595,9 @@ public function set_props( $args ) { $args['rest_namespace'] = 'wp/v2'; } + // Only `true` exposes the post type, so other values, such as arrays, can be given a meaning later. + $args['show_in_abilities'] = true === $args['show_in_abilities']; + // If not set, default to the setting for 'show_ui'. if ( null === $args['show_in_menu'] || ! $args['show_ui'] ) { $args['show_in_menu'] = $args['show_ui']; diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index f6e1253d464e1..60ff2e62ed91b 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -5,7 +5,7 @@ /** * Tests for the core/content-query ability shipped with the Abilities API. * - * @covers WP_Content_Abilities + * @covers WP_Abilities_Content * * @group abilities-api */ @@ -168,7 +168,7 @@ private function ensure_ability_category( string $slug ): void { private function register_ability(): void { global $wp_current_filter; $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. - ( new WP_Content_Abilities() )->register(); + ( new WP_Abilities_Content() )->register(); } /** @@ -430,6 +430,65 @@ public function test_exposes_a_post_type_registered_by_another_plugin(): void { $this->assertContains( $post_id, $ids, 'The custom post type should be queryable through the content ability.' ); } + /** + * Returns `show_in_abilities` values other than `true`. + * + * @return array Non-boolean `show_in_abilities` test cases. + */ + public function data_show_in_abilities_values_other_than_true(): array { + return array( + 'array of operations' => array( + 'value' => array( 'create' ), + ), + 'string "false"' => array( + 'value' => 'false', + ), + 'integer 1' => array( + 'value' => 1, + ), + ); + } + + /** + * Only a `show_in_abilities` value of `true` exposes a post type, so other values, such as + * arrays, can be given a meaning later. + * + * @ticket 66268 + * @dataProvider data_show_in_abilities_values_other_than_true + * @since 7.2.0 + * + * @param mixed $value The `show_in_abilities` value to register the post type with. + */ + public function test_does_not_expose_a_post_type_with_a_show_in_abilities_value_other_than_true( $value ): void { + register_post_type( + 'content_cpt', + array( + 'public' => true, + 'show_in_abilities' => $value, + ) + ); + + $this->assertFalse( get_post_type_object( 'content_cpt' )->show_in_abilities, 'The post type object should hold false.' ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'content_cpt', + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + // Query mode is the third `oneOf` branch; its `post_type` enum lists exposed types. + $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; + $this->assertNotContains( 'content_cpt', $enum, 'The post type should not appear in the query enum.' ); + + $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); + $this->assertWPError( $result, 'A post of the post type should not be readable by ID.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'The by-ID lookup should be denied.' ); + } + /** * A schema filter can expose a post type that is registered after the ability. * @@ -1284,6 +1343,99 @@ public function test_slug_lookup_resolves_draft_only_slug_by_readability(): void $this->assertSame( 'ability_invalid_permissions', $denied->get_error_code(), 'Unreadable slug lookups should fail closed as a permission error.' ); } + /** + * Returns field sets that a request is checked against with read or with edit access. + * + * @return array}> Field set test cases. + */ + public function data_read_and_edit_fields(): array { + return array( + 'read fields' => array( + 'fields' => array( 'id' ), + ), + 'edit fields' => array( + 'fields' => array( 'id', 'content_raw' ), + ), + ); + } + + /** + * A slug lookup resolves only posts of the requested post type, also when a query filter + * adds post types to single views. + * + * WP_Query treats the lookup's `name` query as a single view, so a `pre_get_posts` + * callback without an is_main_query() check also runs for it. The newest post sharing + * the slug would otherwise win, even from a post type that is not exposed. + * + * @ticket 66268 + * @dataProvider data_read_and_edit_fields + * @since 7.2.0 + * + * @param list $fields The fields to request. + */ + public function test_slug_lookup_skips_posts_of_other_post_types( array $fields ): void { + register_post_type( + 'hidden_cpt', + array( + 'public' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + $post_id = self::factory()->post->create( + array( + 'post_name' => 'shared-slug', + 'post_status' => 'publish', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + $other_ids = array( + self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_name' => 'shared-slug', + 'post_status' => 'publish', + 'post_date' => '2026-03-01 10:00:00', + ) + ), + self::factory()->post->create( + array( + 'post_type' => 'hidden_cpt', + 'post_name' => 'shared-slug', + 'post_status' => 'publish', + 'post_date' => '2026-06-01 10:00:00', + ) + ), + ); + + foreach ( $other_ids as $other_id ) { + $this->assertSame( 'shared-slug', get_post( $other_id )->post_name, 'Precondition: newer posts of other types should share the slug.' ); + } + + add_action( + 'pre_get_posts', + static function ( WP_Query $query ): void { + if ( $query->is_single() ) { + $query->set( 'post_type', array( 'post', 'page', 'hidden_cpt' ) ); + } + } + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'post_type' => 'post', + 'slug' => 'shared-slug', + 'fields' => $fields, + ) + ); + + $this->assertIsArray( $result, 'The slug lookup should succeed.' ); + $this->assertSame( $post_id, $result['id'], 'The slug lookup should resolve to the post of the requested type.' ); + } + /** * Include is a query-only option and cannot be combined with single-post modes. * @@ -1775,6 +1927,65 @@ public function test_query_mode_filters_posts_by_author(): void { $this->assertSame( array( $author_slug ), array_unique( wp_list_pluck( $result['posts'], 'author_slug' ) ), 'Each post should return its author\'s slug.' ); } + /** + * Query mode returns only posts of the requested post type, also when a query filter adds + * post types to the blog home. + * + * WP_Query treats the ability's query as the blog home, so a `pre_get_posts` callback + * without an is_main_query() check also runs for it. The read check would let through + * posts of other exposed post types, and the edit check posts of any post type. + * + * @ticket 66268 + * @dataProvider data_read_and_edit_fields + * @since 7.2.0 + * + * @param list $fields The fields to request. + */ + public function test_query_mode_skips_posts_of_other_post_types( array $fields ): void { + register_post_type( + 'hidden_cpt', + array( + 'public' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + // With several post types, an editable query keeps only the current user's posts. + $ids = array(); + foreach ( array( 'post', 'page', 'hidden_cpt' ) as $post_type ) { + $ids[] = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_type' => $post_type, + 'post_status' => 'publish', + ) + ); + } + + add_action( + 'pre_get_posts', + static function ( WP_Query $query ): void { + if ( $query->is_home() ) { + $query->set( 'post_type', array( 'post', 'page', 'hidden_cpt' ) ); + } + } + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'fields' => $fields, + ) + ); + + $this->assertSame( 3, $result['total'], 'Precondition: the query filter should add the other post types.' ); + $this->assertSame( array( $ids[0] ), wp_list_pluck( $result['posts'], 'id' ), 'Query mode should return only posts of the requested type.' ); + } + /** * Raw content is available to users who can edit the post. * @@ -2291,7 +2502,7 @@ public function test_allow_password_content_only_unlocks_editable_posts(): void $this->login_as( 'author' ); - $ability = new WP_Content_Abilities(); + $ability = new WP_Abilities_Content(); $this->assertFalse( $ability->allow_password_content( true, get_post( $owned_id ) ), @@ -2534,6 +2745,81 @@ public function test_query_empty_result_beyond_first_page_reports_zero_totals(): $this->assertSame( 0, $result['total_pages'], 'An empty result set reports zero pages.' ); } + /** + * Returns page sizes that a query filter can set, with the pages they make of five posts. + * + * @return array Page size test cases. + */ + public function data_page_sizes_set_by_a_query_filter(): array { + return array( + 'smaller page size' => array( + 'posts_per_page' => 2, + 'total_pages' => 3, + 'last_page_count' => 1, + ), + 'no paging' => array( + 'posts_per_page' => -1, + 'total_pages' => 1, + 'last_page_count' => 5, + ), + ); + } + + /** + * Query mode counts the pages with the page size the query ran with. + * + * WP_Query treats the ability's query as the blog home, so a `pre_get_posts` callback + * without an is_main_query() check can change its page size. Counting the pages with the + * requested page size would then report the wrong number of pages, and reject pages that + * hold posts or serve the same posts again. + * + * @ticket 66268 + * @dataProvider data_page_sizes_set_by_a_query_filter + * @since 7.2.0 + * + * @param int $posts_per_page The page size the query filter sets. + * @param int $total_pages The expected number of pages. + * @param int $last_page_count The expected number of posts on the last page. + */ + public function test_query_counts_pages_with_the_page_size_the_query_ran_with( int $posts_per_page, int $total_pages, int $last_page_count ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 5, array( 'post_status' => 'publish' ) ); + + add_action( + 'pre_get_posts', + static function ( WP_Query $query ) use ( $posts_per_page ): void { + if ( $query->is_home() ) { + $query->set( 'posts_per_page', $posts_per_page ); + } + } + ); + + $ability = wp_get_ability( 'core/content-query' ); + $input = array( + 'post_type' => 'post', + 'include' => $ids, + 'per_page' => 4, + 'fields' => array( 'id' ), + ); + + $first_page = $ability->execute( $input ); + + $this->assertSame( 5, $first_page['total'], 'The total should count every matching post.' ); + $this->assertSame( $total_pages, $first_page['total_pages'], 'The page count should follow the page size the query ran with.' ); + + $last_page = $ability->execute( array_merge( $input, array( 'page' => $total_pages ) ) ); + + $this->assertIsArray( $last_page, 'The last page should be served.' ); + $this->assertCount( $last_page_count, $last_page['posts'], 'The last page should return the remaining posts.' ); + + $past_last_page = $ability->execute( array_merge( $input, array( 'page' => $total_pages + 1 ) ) ); + + $this->assertWPError( $past_last_page, 'The page after the last one should be rejected.' ); + $this->assertSame( 'content_invalid_page_number', $past_last_page->get_error_code(), 'Paging past the last page should report the page number error.' ); + } + /** * Include returns every requested post when `per_page` is omitted. * @@ -3192,7 +3478,7 @@ public function test_gmt_date_is_empty_when_no_usable_date_exists( string $field public function test_execute_callback_returns_not_found_for_structural_lookup_failures(): void { $this->login_as( 'administrator' ); - $content = new WP_Content_Abilities(); + $content = new WP_Abilities_Content(); $missing = $content->execute_content_query( array( 'id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ) ); $this->assertWPError( $missing, 'A nonexistent post ID should fail the lookup.' ); @@ -3248,7 +3534,7 @@ public function test_execute_callback_rejects_an_author_slug_that_names_no_user( ); $this->login_as( 'administrator' ); - $content = new WP_Content_Abilities(); + $content = new WP_Abilities_Content(); $result = $content->execute_content_query( array( @@ -3274,7 +3560,7 @@ public function test_execute_callback_rejects_an_author_slug_that_names_no_user( */ public function test_execute_callback_rejects_non_integer_parent_filter(): void { $this->login_as( 'administrator' ); - $content = new WP_Content_Abilities(); + $content = new WP_Abilities_Content(); $result = $content->execute_content_query( array( @@ -3302,7 +3588,7 @@ public function test_execute_callback_rejects_include_with_no_valid_ids(): void $this->login_as( 'administrator' ); self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $content = new WP_Content_Abilities(); + $content = new WP_Abilities_Content(); $result = $content->execute_content_query( array( @@ -3333,7 +3619,7 @@ public function test_author_slug_filter_only_names_users_the_current_user_may_se ); $query = static function ( array $statuses, int $user_id ) { - return ( new WP_Content_Abilities() )->execute_content_query( + return ( new WP_Abilities_Content() )->execute_content_query( array( 'post_type' => 'post', 'status' => $statuses, @@ -3374,7 +3660,7 @@ public function test_author_slug_filter_hides_users_of_other_sites(): void { wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); $query = static function ( string $author_slug ) { - return ( new WP_Content_Abilities() )->execute_content_query( + return ( new WP_Abilities_Content() )->execute_content_query( array( 'post_type' => 'post', 'author_slug' => $author_slug, @@ -3412,7 +3698,7 @@ public function test_author_slug_filter_names_authors_who_are_not_site_members() wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); $is_member = is_user_member_of_blog( $super_admin_id, $site_id ); - $result = ( new WP_Content_Abilities() )->execute_content_query( + $result = ( new WP_Abilities_Content() )->execute_content_query( array( 'post_type' => 'post', 'author_slug' => 'network-author', diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php index 26fd28e998964..c299ae1d04211 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -5,7 +5,7 @@ /** * Tests dispatching the core/content-query ability through the Abilities REST run endpoint. * - * @covers WP_Content_Abilities + * @covers WP_Abilities_Content * * @group abilities-api * @group restapi From 5175329e7122ff60bf160824512c09dfa53f313e Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 8 Oct 2026 15:51:33 +0100 Subject: [PATCH 65/69] Abilities API: Address review feedback on the content query - Rename the `post_type` input and output field to `type`, the only field with a `post_` prefix. Input and output share the name, so a returned post's `type` and `slug` can still be sent back as they are. - Accept any whole number the integer schema accepts for IDs and `parent`, such as 2.0, "2.0", or "+2", up to 2 ** 53, and read `page` and `per_page` the same way instead of with absint(). Fractions and larger values are rejected, since casting them could resolve another post. - Report a page past the last one as not found (404). - Always include the `id`, so every returned post can be identified and a post is never an empty object. - Look up an author slug as given, like the `slug` filter of the REST API users endpoint, so the database collation decides whether a variant in another case matches. - Use `! empty()` for the field checks. - Tests: use a data provider for the slug lookup roles, drop try/finally blocks that tear-down makes unnecessary, use assertCount(), and set up the REST test fixtures in wpSetUpBeforeClass(). --- .../abilities/class-wp-abilities-content.php | 153 ++-- .../wpRegisterCoreContentAbility.php | 714 ++++++++++++------ .../wpRestAbilitiesContentController.php | 58 +- 3 files changed, 570 insertions(+), 355 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-content.php b/src/wp-includes/abilities/class-wp-abilities-content.php index f0952d2a4f4e3..e3d4a2e9f906d 100644 --- a/src/wp-includes/abilities/class-wp-abilities-content.php +++ b/src/wp-includes/abilities/class-wp-abilities-content.php @@ -85,7 +85,7 @@ final class WP_Abilities_Content { * @since 7.2.0 * @var list */ - private const DEFAULT_FIELDS = array( 'id', 'post_type', 'status', 'date', 'slug', 'title_rendered' ); + private const DEFAULT_FIELDS = array( 'id', 'type', 'status', 'date', 'slug', 'title_rendered' ); /** * Globals that rendering a post changes: the global post and the globals that @@ -191,7 +191,7 @@ public function check_permission( $input = array() ): bool { } // Query mode requires an exposed post type. - $post_type_object = $this->get_exposed_post_type( $input['post_type'] ?? null ); + $post_type_object = $this->get_exposed_post_type( $input['type'] ?? null ); if ( ! $post_type_object ) { return false; } @@ -206,11 +206,13 @@ public function check_permission( $input = array() ): bool { /** * Parses a raw input value into an integer of at least a minimum, or null when invalid. * - * Accepts native integers and unsigned integer strings. Only the REST run controller - * converts input to the schema types, so other callers, such as a direct - * WP_Ability::execute() call, can pass an integer string, which schema validation - * accepts. Anything else is rejected rather than read as 0, which as a `parent` - * filter would ask for top-level posts. + * Accepts the whole numbers that the integer schema accepts: native integers, floats such + * as the 2.0 that JSON encoders produce, and integer strings such as "2", "2.0", or "+2". + * Only the REST run controller converts input to the schema types, so other callers, such + * as the MCP adapter or a direct WP_Ability::execute() call, pass them on unconverted. + * Anything else, such as a fraction, is rejected rather than coerced: a malformed ID would + * otherwise resolve to another post, and a malformed `parent` filter would be read as 0, + * which asks for top-level posts. * * @since 7.2.0 * @@ -229,7 +231,18 @@ private function parse_filter_int( $value, int $min ): ?int { return $int >= $min ? $int : null; } - return null; + if ( ! rest_is_integer( $value ) ) { + return null; + } + + /* + * Other whole numbers are read as floats, which hold every integer only up to 2 ** 53. + * Past that, the value may not be the one that was sent, and casting a float beyond the + * integer range wraps it to an arbitrary integer, such as the ID of another post. + */ + $number = (float) $value; + + return $number >= $min && $number <= 2 ** 53 ? (int) $number : null; } /** @@ -265,7 +278,7 @@ private function parse_list_input( array $input, string $key ): array { * @return bool True if edit-context fields were explicitly requested. */ private function has_explicit_edit_fields( array $input ): bool { - return array() !== array_intersect( self::EDIT_FIELDS, $this->parse_list_input( $input, 'fields' ) ); + return ! empty( array_intersect( self::EDIT_FIELDS, $this->parse_list_input( $input, 'fields' ) ) ); } /** @@ -386,13 +399,10 @@ private function check_read_permission( WP_Post $post, array $checked_post_ids = * matching post type. Query mode still filters every row by read or edit permission, * because the gate cannot resolve rows before the query runs. * - * A post is returned as an empty object when its field projection is empty, so callers - * must not assume array access on a post. See {@see self::format_post()}. - * * @since 7.2.0 * * @param mixed $input Optional. The ability input. Default empty array. - * @return array|stdClass|WP_Error A single post, a `posts` list with totals in query mode, or a WP_Error. + * @return array|WP_Error A single post, a `posts` list with totals in query mode, or a WP_Error. */ public function execute_content_query( $input = array() ) { $input = rest_sanitize_object( $input ); @@ -406,7 +416,7 @@ public function execute_content_query( $input = array() ) { return $post ? $this->format_post( $post, $fields ) : $this->not_found_error(); } - $post_type_object = $this->get_exposed_post_type( $input['post_type'] ?? null ); + $post_type_object = $this->get_exposed_post_type( $input['type'] ?? null ); if ( ! $post_type_object ) { return $this->not_found_error(); } @@ -463,19 +473,8 @@ public function execute_content_query( $input = array() ) { return $this->invalid_filter_error( __( 'The include filter must list one or more valid post IDs.' ) ); } - /* - * Read `page` and `per_page` with absint(), as the REST posts controller does, not with - * parse_filter_int(). The integer schema also accepts whole floats such as 2.0, which - * JSON encoders and ceil() produce, and strings such as "2.0" or "+2", and callers other - * than the REST run controller pass them on unconverted, such as the MCP adapter or a - * direct WP_Ability::execute() call. parse_filter_int() rejects them, so the query - * would silently fall back to page 1 and the default page size: a client paging with - * 2.0, 3.0, and so on would get page 1 every time and never reach the error for a page - * past the last one. The schema's minimum of 1 keeps out the negative values that - * absint() would turn positive. - */ $per_page = $this->normalize_per_page( $input, $include ); - $page = isset( $input['page'] ) ? max( 1, absint( $input['page'] ) ) : 1; + $page = $this->parse_filter_int( $input['page'] ?? 1, 1 ) ?? 1; $prime_post_caches = $this->should_prime_post_caches( $fields ); @@ -522,15 +521,15 @@ public function execute_content_query( $input = array() ) { } /* - * Paging past the last page is a caller error rather than an empty collection, so - * report it instead of returning a bare empty list. A genuinely empty result set - * still returns zero totals and no error. + * A page past the last one does not exist, so report it as not found instead of + * returning a bare empty list. A genuinely empty result set still returns zero + * totals and no error. */ if ( $total > 0 && $page > $total_pages ) { return new WP_Error( 'content_invalid_page_number', __( 'The page number requested is larger than the number of pages available.' ), - array( 'status' => 400 ) + array( 'status' => 404 ) ); } @@ -559,7 +558,6 @@ public function execute_content_query( $input = array() ) { if ( ! $requires_edit && ! $this->check_read_permission( $post ) ) { continue; } - // Keep rows whose field projection is empty so a caller can still count them. $posts[] = $this->format_post( $post, $fields ); } @@ -589,9 +587,8 @@ public function execute_content_query( $input = array() ) { * @return int The clamped per-page value. */ private function normalize_per_page( array $input, array $include_ids ): int { - // absint(), not parse_filter_int(): see where execute_content_query() reads `page`. - $per_page = isset( $input['per_page'] ) ? absint( $input['per_page'] ) : 0; - if ( $per_page < 1 ) { + $per_page = $this->parse_filter_int( $input['per_page'] ?? null, 1 ); + if ( null === $per_page ) { $per_page = array() === $include_ids ? self::DEFAULT_PER_PAGE : count( $include_ids ); } @@ -640,16 +637,16 @@ private function get_query_total( WP_Query $query, array $query_args, int $page * @return bool True when post meta and term caches should be primed. */ private function should_prime_post_caches( array $fields ): bool { - return array() !== array_intersect( self::CACHE_PRIMING_FIELDS, $fields ); + return ! empty( array_intersect( self::CACHE_PRIMING_FIELDS, $fields ) ); } /** * Looks up the single post an ID or slug request resolves to. * * By ID, the post must exist, belong to a post type exposed to abilities, and match the - * `post_type` guard when one is given. As with the integer filters, only an integer or - * an unsigned integer string is accepted, so a malformed ID or one beyond the integer - * range cannot be coerced onto another post. By slug, the post type must be exposed to + * `type` guard when one is given. As with the integer filters, the ID is read with + * {@see self::parse_filter_int()}, so a malformed ID or one beyond the integer range + * cannot be coerced onto another post. By slug, the post type must be exposed to * abilities, and {@see self::get_post_by_slug()} resolves the post. * * @since 7.2.0 @@ -666,11 +663,11 @@ private function get_requested_post( array $input ): ?WP_Post { return null; } - return empty( $input['post_type'] ) || $post->post_type === $input['post_type'] ? $post : null; + return empty( $input['type'] ) || $post->post_type === $input['type'] ? $post : null; } $slug = $input['slug'] ?? null; - $post_type_object = $this->get_exposed_post_type( $input['post_type'] ?? null ); + $post_type_object = $this->get_exposed_post_type( $input['type'] ?? null ); return is_string( $slug ) && $post_type_object ? $this->get_post_by_slug( $post_type_object->name, $slug ) : null; } @@ -678,12 +675,13 @@ private function get_requested_post( array $input ): ?WP_Post { /** * Looks up the user an author slug names. * - * The slug is the user's nicename, which the REST API users endpoint returns as `slug`, and - * must match it exactly. A user the current user may not see is reported like a - * missing one. The current user can see themselves, any user of the site when they can list - * users, and authors with posts in a publicly viewable post type. A user who can edit others' - * posts of the post type can also see any user of the site, since they may make any of them - * the author, so for them the lookup does tell whether such an account exists. + * The slug is the user's nicename, which the REST API users endpoint returns as `slug`. It is + * looked up as given, like that endpoint's `slug` filter, so the database collation decides + * whether a variant in another case matches. A user the current user may not see is reported + * like a missing one. The current user can see themselves, any user of the site when they can + * list users, and authors with posts in a publicly viewable post type. A user who can edit + * others' posts of the post type can also see any user of the site, since they may make any of + * them the author, so for them the lookup does tell whether such an account exists. * * On multisite the lookup searches the whole network, so posts by authors who are not * members of the site, such as super admins, can still be filtered. Those users are only @@ -698,9 +696,7 @@ private function get_requested_post( array $input ): ?WP_Post { */ private function get_author_by_slug( $slug, WP_Post_Type $post_type_object ): ?WP_User { $user = is_string( $slug ) ? get_user_by( 'slug', $slug ) : false; - - // The database compares nicenames without regard to case, so keep an exact match only. - if ( ! $user || $user->user_nicename !== $slug ) { + if ( ! $user ) { return null; } @@ -840,9 +836,12 @@ private function normalize_include( array $input ): array { * Returns the requested fields, or a lean default set when none are given. * * An empty or absent `fields` value selects a lean set of common read fields. - * Otherwise the requested fields are returned as-is. The input schema has already + * Otherwise the requested fields are returned. The input schema has already * validated them against the supported set before the ability executes. * + * The `id` field is always included, so every returned post can be identified. This + * also means a post is never empty, so it always encodes as a JSON object. + * * @since 7.2.0 * * @param array $input The ability input. @@ -850,8 +849,15 @@ private function normalize_include( array $input ): array { */ private function normalize_fields( array $input ): array { $fields = $this->parse_list_input( $input, 'fields' ); + if ( array() === $fields ) { + return self::DEFAULT_FIELDS; + } + + if ( ! in_array( 'id', $fields, true ) ) { + array_unshift( $fields, 'id' ); + } - return array() === $fields ? self::DEFAULT_FIELDS : $fields; + return $fields; } /** @@ -872,7 +878,7 @@ private function get_post_properties(): array { 'type' => 'integer', 'description' => __( 'The post ID.' ), ), - 'post_type' => array( + 'type' => array( 'type' => 'string', 'description' => __( 'The post type.' ), ), @@ -962,7 +968,7 @@ private function get_fields_input_schema(): array { 'type' => 'string', 'enum' => array_keys( $this->get_post_properties() ), ), - 'description' => __( 'Limit each returned post to these fields. If omitted, a lean set of common read fields is returned. Explicit raw field requests require edit access.' ), + 'description' => __( 'Limit each returned post to these fields. The `id` is always included. If omitted, a lean set of common read fields is returned. Explicit raw field requests require edit access.' ), ); } @@ -972,9 +978,9 @@ private function get_fields_input_schema(): array { * The ability has three mutually exclusive modes, modeled as a `oneOf` so invalid * combinations are rejected rather than silently ignored: * - * - Get a single post by `id` (optionally guarded by `post_type`). - * - Get a single post by `post_type` and `slug`. - * - Query a set of posts by `post_type` plus filters (`status`, `author_slug`, `parent`, + * - Get a single post by `id` (optionally guarded by `type`). + * - Get a single post by `type` and `slug`. + * - Query a set of posts by `type` plus filters (`status`, `author_slug`, `parent`, * `include`, `page`, `per_page`). * * Each mode sets `additionalProperties: false`, so e.g. passing `per_page` alongside `id` @@ -1009,45 +1015,45 @@ private function get_content_query_input_schema( array $post_types, array $statu 'required' => array( 'id' ), 'additionalProperties' => false, 'properties' => array( - 'id' => array( + 'id' => array( 'type' => 'integer', 'minimum' => 1, 'description' => __( 'Retrieve a single readable post by ID.' ), ), - 'post_type' => array( + 'type' => array( 'type' => 'string', 'enum' => $post_types, 'description' => __( 'Optional. Restrict the lookup to this post type; the post is returned only if it matches and the current user can read it.' ), ), - 'fields' => $fields, + 'fields' => $fields, ), ), // Mode 2: retrieve a single readable post by post type and slug. array( 'title' => __( 'Get a single readable post by slug' ), - 'required' => array( 'post_type', 'slug' ), + 'required' => array( 'type', 'slug' ), 'additionalProperties' => false, 'properties' => array( - 'post_type' => array( + 'type' => array( 'type' => 'string', 'enum' => $post_types, 'description' => __( 'Post type containing the slug. Slugs are not unique across post types.' ), ), - 'slug' => array( + 'slug' => array( 'type' => 'string', 'minLength' => 1, 'description' => __( 'Retrieve a single readable post by slug. Resolves to the newest readable match, preferring published posts. In hierarchical post types, posts under different parents can share a slug; use `id` to get a specific one.' ), ), - 'fields' => $fields, + 'fields' => $fields, ), ), // Mode 3: query a set of readable posts by post type and filters. array( 'title' => __( 'Query readable posts by post type and filters' ), - 'required' => array( 'post_type' ), + 'required' => array( 'type' ), 'additionalProperties' => false, 'properties' => array( - 'post_type' => array( + 'type' => array( 'type' => 'string', 'enum' => $post_types, 'description' => __( 'Post type to query for readable posts.' ), @@ -1162,18 +1168,13 @@ private function get_content_query_output_schema(): array { * protected-post placeholders. The field projection itself is delegated to * {@see self::build_post_fields()}. * - * A field projection can legitimately be empty, for example when the only requested - * field is one the post type does not support. An empty PHP array encodes as `[]`, - * which would break the `object` output schema, so an empty object is returned instead. - * The REST posts controller returns `[]` in that case. - * * @since 7.2.0 * * @param WP_Post $post The post object. * @param list $fields The requested field names. - * @return array|stdClass The formatted post data, or an empty object when the projection is empty. + * @return array The formatted post data. */ - private function format_post( WP_Post $post, array $fields ) { + private function format_post( WP_Post $post, array $fields ): array { $can_edit = current_user_can( 'edit_post', $post->ID ); $password_required = post_password_required( $post ); $unlock_password = $password_required && $can_edit; @@ -1194,7 +1195,7 @@ private function format_post( WP_Post $post, array $fields ) { * disabled or the global post pointing at this post for the rest of the request. */ try { - $data = $this->build_post_fields( $post, $fields, $can_edit, $password_required && ! $can_edit ); + return $this->build_post_fields( $post, $fields, $can_edit, $password_required && ! $can_edit ); } finally { if ( $unlock_password ) { remove_filter( 'post_password_required', array( $this, 'allow_password_content' ), 10 ); @@ -1202,8 +1203,6 @@ private function format_post( WP_Post $post, array $fields ) { $this->restore_post_context( $previous_context ); } - - return array() === $data ? (object) array() : $data; } /** @@ -1236,8 +1235,8 @@ private function build_post_fields( WP_Post $post, array $fields, bool $can_edit if ( isset( $requested['id'] ) ) { $data['id'] = (int) $post->ID; } - if ( isset( $requested['post_type'] ) ) { - $data['post_type'] = $post_type; + if ( isset( $requested['type'] ) ) { + $data['type'] = $post_type; } if ( isset( $requested['status'] ) ) { $data['status'] = $post->post_status; diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 60ff2e62ed91b..5c7b56145351a 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -32,6 +32,16 @@ class Tests_Abilities_API_WpRegisterCoreContentAbility extends WP_UnitTestCase { 'numpages', ); + /** + * A post ID that tests create a post with through `import_id`, so data providers can + * write the ID in other forms. + * + * @since 7.2.0 + * + * @var int + */ + private const IMPORTED_POST_ID = 1234567; + /** * Shared user IDs keyed by role or fixture name. * @@ -128,7 +138,7 @@ public function tear_down(): void { /* * Keep this reset even though the parent set_up() also resets the post types. A test * may turn off `show_in_abilities` on `post` and `page`, and the next class's - * set_up_before_class() runs before the next set_up(). A class that registers the core + * wpSetUpBeforeClass() runs before the next set_up(). A class that registers the core * abilities there, such as Tests_REST_API_WpRestAbilitiesContentController, would then * register no content ability, and all of its tests would fail. */ @@ -263,8 +273,8 @@ public function test_input_schema_models_mutually_exclusive_modes(): void { // All modes reject properties from the other modes. $this->assertSame( array( 'id' ), $by_id['required'], 'The by-ID mode should require an ID.' ); - $this->assertSame( array( 'post_type', 'slug' ), $by_slug['required'], 'The slug mode should require post type and slug.' ); - $this->assertSame( array( 'post_type' ), $query['required'], 'The query mode should require a post type.' ); + $this->assertSame( array( 'type', 'slug' ), $by_slug['required'], 'The slug mode should require post type and slug.' ); + $this->assertSame( array( 'type' ), $query['required'], 'The query mode should require a post type.' ); $this->assertFalse( $by_id['additionalProperties'], 'The by-ID mode should reject unrelated properties.' ); $this->assertFalse( $by_slug['additionalProperties'], 'The slug mode should reject unrelated properties.' ); $this->assertFalse( $query['additionalProperties'], 'The query mode should reject unrelated properties.' ); @@ -276,10 +286,10 @@ public function test_input_schema_models_mutually_exclusive_modes(): void { $this->assertArrayNotHasKey( 'include', $by_slug['properties'], 'The slug mode should not accept query-only included IDs.' ); $this->assertArrayNotHasKey( 'slug', $query['properties'], 'The query mode should not accept slug; slug is a single-post mode.' ); - // Exposed post types appear in all modes that accept `post_type`. - $this->assertContains( 'post', $query['properties']['post_type']['enum'], 'The query mode should include exposed posts.' ); - $this->assertContains( 'page', $by_id['properties']['post_type']['enum'], 'The by-ID guard should include exposed pages.' ); - $this->assertContains( 'page', $by_slug['properties']['post_type']['enum'], 'The slug mode should include exposed pages.' ); + // Exposed post types appear in all modes that accept `type`. + $this->assertContains( 'post', $query['properties']['type']['enum'], 'The query mode should include exposed posts.' ); + $this->assertContains( 'page', $by_id['properties']['type']['enum'], 'The by-ID guard should include exposed pages.' ); + $this->assertContains( 'page', $by_slug['properties']['type']['enum'], 'The slug mode should include exposed pages.' ); $this->assertSame( 1, $query['properties']['include']['minItems'], 'The include option should require at least one post ID.' ); $this->assertTrue( $query['properties']['include']['uniqueItems'], 'The include option should reject duplicate post IDs.' ); @@ -287,8 +297,8 @@ public function test_input_schema_models_mutually_exclusive_modes(): void { $this->assertSame( 1, $query['properties']['include']['items']['minimum'], 'The include option should contain positive post IDs.' ); $fields_enum = $query['properties']['fields']['items']['enum']; - $this->assertContains( 'post_type', $fields_enum, 'The fields enum should expose the post type as post_type.' ); - $this->assertNotContains( 'type', $fields_enum, 'The fields enum should not expose the post type as type.' ); + $this->assertContains( 'type', $fields_enum, 'The fields enum should expose the post type as type.' ); + $this->assertNotContains( 'post_type', $fields_enum, 'The fields enum should not expose the post type as post_type.' ); $this->assertContains( 'content_raw', $fields_enum, 'The fields enum should include raw content.' ); $this->assertContains( 'content_rendered', $fields_enum, 'The fields enum should include rendered content.' ); $this->assertContains( 'title_raw', $fields_enum, 'The fields enum should include raw titles.' ); @@ -336,7 +346,7 @@ public function test_id_mode_rejects_query_only_params(): void { } /** - * `post_type` is accepted alongside `id` as a guard: the by-ID mode still resolves the post. + * `type` is accepted alongside `id` as a guard: the by-ID mode still resolves the post. * * @ticket 66268 * @since 7.2.0 @@ -349,8 +359,8 @@ public function test_id_mode_accepts_post_type_guard(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'id' => $post_id, - 'post_type' => 'post', + 'id' => $post_id, + 'type' => 'post', ) ); @@ -379,8 +389,8 @@ public function test_output_schema_describes_single_post_and_query_responses(): $this->assertSame( 'object', $post_schema['type'], 'The single-post response should be described as an object.' ); $this->assertArrayNotHasKey( 'required', $post_schema, 'Individual post fields should remain optional.' ); $this->assertFalse( $post_schema['additionalProperties'], 'Returned posts should not allow unknown properties.' ); - $this->assertArrayHasKey( 'post_type', $post_schema['properties'], 'The post schema should describe the post type as post_type.' ); - $this->assertArrayNotHasKey( 'type', $post_schema['properties'], 'The post schema should not expose the post type as type.' ); + $this->assertArrayHasKey( 'type', $post_schema['properties'], 'The post schema should describe the post type as type.' ); + $this->assertArrayNotHasKey( 'post_type', $post_schema['properties'], 'The post schema should not expose the post type as post_type.' ); $this->assertSame( $input_schema['oneOf'][2]['properties']['fields']['items']['enum'], array_keys( $post_schema['properties'] ), @@ -413,8 +423,8 @@ public function test_exposes_a_post_type_registered_by_another_plugin(): void { $this->login_as( 'administrator' ); $this->register_ability(); - // Query mode is the third `oneOf` branch; its `post_type` enum lists exposed types. - $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; + // Query mode is the third `oneOf` branch; its `type` enum lists exposed types. + $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['oneOf'][2]['properties']['type']['enum']; $this->assertContains( 'content_cpt', $enum, 'Custom post types marked show_in_abilities should appear in the query enum.' ); $post_id = self::factory()->post->create( @@ -424,7 +434,7 @@ public function test_exposes_a_post_type_registered_by_another_plugin(): void { ) ); - $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'content_cpt' ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'type' => 'content_cpt' ) ); $ids = wp_list_pluck( $result['posts'], 'id' ); $this->assertContains( $post_id, $ids, 'The custom post type should be queryable through the content ability.' ); @@ -480,8 +490,8 @@ public function test_does_not_expose_a_post_type_with_a_show_in_abilities_value_ $this->login_as( 'administrator' ); $this->register_ability(); - // Query mode is the third `oneOf` branch; its `post_type` enum lists exposed types. - $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; + // Query mode is the third `oneOf` branch; its `type` enum lists exposed types. + $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['oneOf'][2]['properties']['type']['enum']; $this->assertNotContains( 'content_cpt', $enum, 'The post type should not appear in the query enum.' ); $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id ) ); @@ -504,20 +514,15 @@ public function test_schema_filter_exposes_late_registered_post_type(): void { } foreach ( $args['input_schema']['oneOf'] as $index => $mode ) { - $args['input_schema']['oneOf'][ $index ]['properties']['post_type']['enum'][] = 'late_cpt'; + $args['input_schema']['oneOf'][ $index ]['properties']['type']['enum'][] = 'late_cpt'; } return $args; }; add_filter( 'wp_register_ability_args', $amend_schema, 10, 2 ); + $this->register_ability(); - try { - $this->register_ability(); - } finally { - remove_filter( 'wp_register_ability_args', $amend_schema, 10 ); - } - - $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; + $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['oneOf'][2]['properties']['type']['enum']; $this->assertContains( 'late_cpt', $enum, 'The ability args filter should amend the frozen schema enum.' ); register_post_type( @@ -538,8 +543,8 @@ public function test_schema_filter_exposes_late_registered_post_type(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'late_cpt', - 'fields' => array( 'id' ), + 'type' => 'late_cpt', + 'fields' => array( 'id' ), ) ); @@ -564,7 +569,7 @@ public function test_get_single_published_post_by_id(): void { $this->assertSame( $post_id, $result['id'], 'The by-ID lookup should return the requested post directly.' ); $this->assertSame( 'Hello Content', $result['title_rendered'], 'Rendered titles should be returned by default.' ); $this->assertSame( - array( 'id', 'post_type', 'status', 'date', 'slug', 'title_rendered' ), + array( 'id', 'type', 'status', 'date', 'slug', 'title_rendered' ), array_keys( $result ), 'Omitted fields should return the lean default field set.' ); @@ -616,12 +621,12 @@ public function test_get_single_published_post_by_id_can_return_content_fields() $result = wp_get_ability( 'core/content-query' )->execute( array( 'id' => $post_id, - 'fields' => array( 'id', 'post_type', 'content_rendered', 'content_raw' ), + 'fields' => array( 'id', 'type', 'content_rendered', 'content_raw' ), ) ); $this->assertSame( $post_id, $result['id'], 'The by-ID lookup should return the requested post.' ); - $this->assertSame( 'post', $result['post_type'], 'The by-ID lookup should return the post type as post_type.' ); + $this->assertSame( 'post', $result['type'], 'The by-ID lookup should return the post type as type.' ); $this->assertStringContainsString( 'Body here.', $result['content_rendered'], 'Explicit content fields should include rendered content.' ); $this->assertSame( 'Body here.', $result['content_raw'], 'Explicit content fields should include raw content.' ); $this->assertArrayNotHasKey( 'posts', $result, 'The by-ID lookup should not return the query wrapper.' ); @@ -682,8 +687,8 @@ public function test_get_by_id_with_mismatched_post_type_is_denied(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'id' => $post_id, - 'post_type' => 'page', + 'id' => $post_id, + 'type' => 'page', ) ); @@ -878,7 +883,7 @@ public function test_query_returns_only_published_by_default(): void { $published = self::factory()->post->create( array( 'post_status' => 'publish' ) ); $draft = self::factory()->post->create( array( 'post_status' => 'draft' ) ); - $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post' ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'type' => 'post' ) ); $ids = wp_list_pluck( $result['posts'], 'id' ); $this->assertContains( $published, $ids, 'Published posts should be returned by default.' ); @@ -920,10 +925,10 @@ public function test_query_orders_posts_newest_first_regardless_of_include_order $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', + 'type' => 'post', // Deliberately neither date order nor ID order. - 'include' => array( $middle, $newest, $oldest ), - 'fields' => array( 'id' ), + 'include' => array( $middle, $newest, $oldest ), + 'fields' => array( 'id' ), ) ); @@ -954,9 +959,9 @@ public function test_query_include_respects_requested_post_type(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'include' => array( $page_id, $post_id ), - 'fields' => array( 'id' ), + 'type' => 'post', + 'include' => array( $page_id, $post_id ), + 'fields' => array( 'id' ), ) ); @@ -977,9 +982,9 @@ public function test_query_include_accepts_a_single_post_id(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'include' => $post_id, - 'fields' => array( 'id' ), + 'type' => 'post', + 'include' => $post_id, + 'fields' => array( 'id' ), ) ); @@ -1014,10 +1019,10 @@ public function test_query_include_respects_row_level_permissions(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'status' => array( 'draft' ), - 'include' => array( $draft_a, $draft_b ), - 'fields' => array( 'id' ), + 'type' => 'post', + 'status' => array( 'draft' ), + 'include' => array( $draft_a, $draft_b ), + 'fields' => array( 'id' ), ) ); @@ -1044,15 +1049,15 @@ public function test_query_draft_include_can_return_content_fields(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'status' => array( 'draft' ), - 'include' => array( $draft ), - 'fields' => array( 'id', 'post_type', 'status', 'content_rendered', 'content_raw' ), + 'type' => 'post', + 'status' => array( 'draft' ), + 'include' => array( $draft ), + 'fields' => array( 'id', 'type', 'status', 'content_rendered', 'content_raw' ), ) ); $this->assertSame( array( $draft ), wp_list_pluck( $result['posts'], 'id' ), 'The draft query should return only the included draft.' ); - $this->assertSame( 'post', $result['posts'][0]['post_type'], 'Query responses should return the post type as post_type.' ); + $this->assertSame( 'post', $result['posts'][0]['type'], 'Query responses should return the post type as type.' ); $this->assertSame( 'draft', $result['posts'][0]['status'], 'The draft query should expose the requested draft status.' ); $this->assertStringContainsString( 'Draft body for content fields.', $result['posts'][0]['content_rendered'], 'Draft query results should include rendered content when requested.' ); $this->assertSame( 'Draft body for content fields.', $result['posts'][0]['content_raw'], 'Draft query results should include raw content when requested.' ); @@ -1094,8 +1099,8 @@ public function test_get_single_published_post_by_slug(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'slug' => 'content-slug-mode', + 'type' => 'post', + 'slug' => 'content-slug-mode', ) ); @@ -1127,14 +1132,14 @@ public function test_get_single_published_post_by_slug_can_return_content_fields $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'slug' => 'content-slug-fields', - 'fields' => array( 'id', 'post_type', 'slug', 'content_rendered', 'content_raw' ), + 'type' => 'post', + 'slug' => 'content-slug-fields', + 'fields' => array( 'id', 'type', 'slug', 'content_rendered', 'content_raw' ), ) ); $this->assertSame( $post_id, $result['id'], 'The slug lookup should return the requested post.' ); - $this->assertSame( 'post', $result['post_type'], 'The slug lookup should return the post type as post_type.' ); + $this->assertSame( 'post', $result['type'], 'The slug lookup should return the post type as type.' ); $this->assertSame( 'content-slug-fields', $result['slug'], 'The slug lookup should return the matching slug.' ); $this->assertStringContainsString( 'Slug body for content fields.', $result['content_rendered'], 'Slug lookups should include rendered content when requested.' ); $this->assertSame( 'Slug body for content fields.', $result['content_raw'], 'Slug lookups should include raw content when requested.' ); @@ -1191,9 +1196,9 @@ public function test_slug_lookup_is_not_bounded_by_a_page_size(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'slug' => 'slug-not-bounded', - 'fields' => array( 'id' ), + 'type' => 'post', + 'slug' => 'slug-not-bounded', + 'fields' => array( 'id' ), ) ); @@ -1227,8 +1232,8 @@ public function test_get_single_post_by_slug_zero(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'slug' => '0', + 'type' => 'post', + 'slug' => '0', ) ); @@ -1249,9 +1254,9 @@ public function test_slug_mode_rejects_query_only_params(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'slug' => 'content-slug-mode', - 'per_page' => 10, + 'type' => 'post', + 'slug' => 'content-slug-mode', + 'per_page' => 10, ) ); @@ -1259,13 +1264,32 @@ public function test_slug_mode_rejects_query_only_params(): void { $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'Invalid slug mode combinations should return an input error.' ); } + /** + * Returns a role that cannot read another user's draft and a role that can. + * + * @return array Role test cases. + */ + public function data_roles_with_and_without_access_to_others_drafts(): array { + return array( + 'subscriber' => array( + 'role' => 'subscriber', + ), + 'editor' => array( + 'role' => 'editor', + ), + ); + } + /** * A newer draft sharing a published post's slug does not shadow the published post. * * @ticket 66268 + * @dataProvider data_roles_with_and_without_access_to_others_drafts * @since 7.2.0 + * + * @param string $role The role to look up the slug as. */ - public function test_slug_lookup_prefers_published_post_over_newer_draft(): void { + public function test_slug_lookup_prefers_published_post_over_newer_draft( string $role ): void { $published_id = self::factory()->post->create( array( 'post_name' => 'shadowed-slug', @@ -1284,21 +1308,19 @@ public function test_slug_lookup_prefers_published_post_over_newer_draft(): void ); $this->assertSame( 'shadowed-slug', get_post( $draft_id )->post_name, 'Precondition: the draft should share the published slug.' ); - $this->register_ability(); - foreach ( array( 'subscriber', 'editor' ) as $role ) { - $this->login_as( $role ); + $this->register_ability(); + $this->login_as( $role ); - $result = wp_get_ability( 'core/content-query' )->execute( - array( - 'post_type' => 'post', - 'slug' => 'shadowed-slug', - ) - ); + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'type' => 'post', + 'slug' => 'shadowed-slug', + ) + ); - $this->assertIsArray( $result, "The slug lookup should succeed for a {$role}." ); - $this->assertSame( $published_id, $result['id'], "The slug lookup should resolve to the published post for a {$role}." ); - } + $this->assertIsArray( $result, 'The slug lookup should succeed.' ); + $this->assertSame( $published_id, $result['id'], 'The slug lookup should resolve to the published post.' ); } /** @@ -1322,8 +1344,8 @@ public function test_slug_lookup_resolves_draft_only_slug_by_readability(): void $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'slug' => 'draft-only-slug', + 'type' => 'post', + 'slug' => 'draft-only-slug', ) ); @@ -1334,8 +1356,8 @@ public function test_slug_lookup_resolves_draft_only_slug_by_readability(): void $denied = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'slug' => 'draft-only-slug', + 'type' => 'post', + 'slug' => 'draft-only-slug', ) ); @@ -1426,9 +1448,9 @@ static function ( WP_Query $query ): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'slug' => 'shared-slug', - 'fields' => $fields, + 'type' => 'post', + 'slug' => 'shared-slug', + 'fields' => $fields, ) ); @@ -1454,9 +1476,9 @@ public function test_include_cannot_be_combined_with_single_post_modes(): void { ); $by_slug = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'slug' => 'whatever', - 'include' => array( self::$post_ids['published'] ), + 'type' => 'post', + 'slug' => 'whatever', + 'include' => array( self::$post_ids['published'] ), ) ); @@ -1492,6 +1514,29 @@ public function test_fields_filter_limits_returned_keys(): void { ); } + /** + * The `id` is returned even when the requested fields leave it out. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_fields_filter_always_includes_the_id(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::$post_ids['published_content']; + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'title_rendered' ), + ) + ); + + $this->assertSame( array( 'id', 'title_rendered' ), array_keys( $result ), 'The ID should be returned along with the requested fields.' ); + $this->assertSame( $post_id, $result['id'], 'The returned ID should be the requested post.' ); + } + /** * An unknown requested field name fails schema validation. * @@ -1526,7 +1571,7 @@ public function test_logged_out_user_is_denied(): void { wp_set_current_user( 0 ); $this->register_ability(); - $result = wp_get_ability( 'core/content-query' )->execute( array( 'post_type' => 'post' ) ); + $result = wp_get_ability( 'core/content-query' )->execute( array( 'type' => 'post' ) ); $this->assertWPError( $result, 'Logged-out users should not be allowed to run the content ability.' ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Logged-out users should receive a permission error.' ); @@ -1546,8 +1591,8 @@ public function test_subscriber_can_request_published_content(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'fields' => array( 'id', 'title_rendered', 'content_rendered' ), + 'type' => 'post', + 'fields' => array( 'id', 'title_rendered', 'content_rendered' ), ) ); $ids = wp_list_pluck( $result['posts'], 'id' ); @@ -1594,8 +1639,8 @@ public function test_subscriber_cannot_request_raw_fields_in_query_mode(): void $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'fields' => array( 'content_raw' ), + 'type' => 'post', + 'fields' => array( 'content_raw' ), ) ); @@ -1687,8 +1732,8 @@ public function test_subscriber_cannot_request_draft_status(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'status' => array( 'draft' ), + 'type' => 'post', + 'status' => array( 'draft' ), ) ); @@ -1708,8 +1753,8 @@ public function test_subscriber_cannot_request_private_status(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'status' => array( 'private' ), + 'type' => 'post', + 'status' => array( 'private' ), ) ); @@ -1745,8 +1790,8 @@ public function test_author_cannot_see_other_authors_drafts(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'status' => array( 'draft' ), + 'type' => 'post', + 'status' => array( 'draft' ), ) ); $ids = wp_list_pluck( $result['posts'], 'id' ); @@ -1788,10 +1833,10 @@ public function test_query_totals_may_include_rows_withheld_by_row_level_permiss $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'status' => array( 'draft' ), - 'per_page' => 1, - 'fields' => array( 'id' ), + 'type' => 'post', + 'status' => array( 'draft' ), + 'per_page' => 1, + 'fields' => array( 'id' ), ) ); @@ -1813,8 +1858,8 @@ public function test_query_mode_rejects_parent_filter_for_non_hierarchical_post_ $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'parent' => 0, + 'type' => 'post', + 'parent' => 0, ) ); @@ -1848,8 +1893,8 @@ public function test_query_mode_filters_pages_by_parent(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'page', - 'parent' => $parent_id, + 'type' => 'page', + 'parent' => $parent_id, ) ); $ids = wp_list_pluck( $result['posts'], 'id' ); @@ -1878,7 +1923,7 @@ public function test_query_mode_rejects_author_filter_for_post_type_without_auth $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'no_author_cpt', + 'type' => 'no_author_cpt', 'author_slug' => get_userdata( self::$user_ids['author'] )->user_nicename, ) ); @@ -1914,7 +1959,7 @@ public function test_query_mode_filters_posts_by_author(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', + 'type' => 'post', 'author_slug' => $author_slug, 'per_page' => 100, 'fields' => array( 'id', 'author_slug' ), @@ -1976,9 +2021,9 @@ static function ( WP_Query $query ): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'include' => $ids, - 'fields' => $fields, + 'type' => 'post', + 'include' => $ids, + 'fields' => $fields, ) ); @@ -2195,9 +2240,8 @@ public function test_rendered_fields_use_and_restore_requested_post_context(): v $this->login_as( 'subscriber' ); $this->register_ability(); - $target_id = self::$post_ids['limited_role_content']; - $surrounding = get_post( self::$post_ids['published'] ); - $previous_post = $GLOBALS['post'] ?? null; + $target_id = self::$post_ids['limited_role_content']; + $surrounding = get_post( self::$post_ids['published'] ); $this->assertInstanceOf( WP_Post::class, $surrounding, 'The surrounding post fixture should exist.' ); @@ -2211,27 +2255,13 @@ public function test_rendered_fields_use_and_restore_requested_post_context(): v add_filter( 'the_title', $append_context_id, 20 ); add_filter( 'the_excerpt', $append_context_id, 20 ); - try { - $result = wp_get_ability( 'core/content-query' )->execute( - array( - 'id' => $target_id, - 'fields' => array( 'id', 'title_rendered', 'excerpt_rendered' ), - ) - ); - $restored_context_id = get_the_ID(); - } finally { - remove_filter( 'the_title', $append_context_id, 20 ); - remove_filter( 'the_excerpt', $append_context_id, 20 ); - - if ( $previous_post instanceof WP_Post ) { - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the context that preceded the test. - $GLOBALS['post'] = $previous_post; - setup_postdata( $previous_post ); - } else { - unset( $GLOBALS['post'] ); - wp_reset_postdata(); - } - } + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'id' => $target_id, + 'fields' => array( 'id', 'title_rendered', 'excerpt_rendered' ), + ) + ); + $restored_context_id = get_the_ID(); $this->assertStringContainsString( '', @@ -2557,16 +2587,15 @@ static function ( $atts ): string { $this->register_ability(); - try { - $result = wp_get_ability( 'core/content-query' )->execute( - array( - 'id' => $owned_id, - 'fields' => array( 'id', 'content_rendered' ), - ) - ); - } finally { - remove_shortcode( 'read_content_nested' ); - } + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'id' => $owned_id, + 'fields' => array( 'id', 'content_rendered' ), + ) + ); + + // Tear-down restores hooks, but not shortcodes. + remove_shortcode( 'read_content_nested' ); $this->assertStringNotContainsString( 'NESTED_SECRET_MARKER', @@ -2592,13 +2621,13 @@ public function test_query_total_matches_returned_posts_when_uncapped(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'per_page' => 100, + 'type' => 'post', + 'per_page' => 100, ) ); $this->assertNotEmpty( $result['posts'], 'An uncapped query should return the readable posts.' ); - $this->assertSame( count( $result['posts'] ), $result['total'], 'An uncapped query should report a total equal to the number of returned posts.' ); + $this->assertCount( $result['total'], $result['posts'], 'An uncapped query should report a total equal to the number of returned posts.' ); $this->assertSame( 1, $result['total_pages'], 'An uncapped query should fit on a single page.' ); } @@ -2619,11 +2648,11 @@ public function test_query_last_page_reports_totals(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'include' => $ids, - 'per_page' => 2, - 'page' => 2, - 'fields' => array( 'id' ), + 'type' => 'post', + 'include' => $ids, + 'per_page' => 2, + 'page' => 2, + 'fields' => array( 'id' ), ) ); @@ -2637,8 +2666,8 @@ public function test_query_last_page_reports_totals(): void { * * `WP_Query::set_found_posts()` skips the count when a page yields no rows, so without * recovering the total an out-of-range page would report `total: 0, total_pages: 0`, - * which is indistinguishable from an empty collection. Match the REST posts controller - * by reporting this as a caller error instead. + * which is indistinguishable from an empty collection. Report it as a page that does not + * exist instead. * * @ticket 66268 * @since 7.2.0 @@ -2651,17 +2680,17 @@ public function test_query_out_of_range_page_is_rejected_rather_than_reported_as $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'include' => $ids, - 'per_page' => 2, - 'page' => 99, - 'fields' => array( 'id' ), + 'type' => 'post', + 'include' => $ids, + 'per_page' => 2, + 'page' => 99, + 'fields' => array( 'id' ), ) ); $this->assertWPError( $result, 'A page beyond the last one should fail rather than return an empty list.' ); $this->assertSame( 'content_invalid_page_number', $result->get_error_code(), 'Out-of-range pages should report a dedicated error code.' ); - $this->assertSame( 400, $result->get_error_data()['status'], 'An out-of-range page is a caller error.' ); + $this->assertSame( 404, $result->get_error_data()['status'], 'An out-of-range page should be reported as not found.' ); } /** @@ -2683,9 +2712,9 @@ public function data_whole_numbers_that_are_not_integers(): array { * A `page` and `per_page` that the integer schema accepts are honored even when they are * not PHP integers, as when the MCP adapter passes the 2.0 a JSON encoder produced. * - * Read with parse_filter_int(), they fell back to page 1 and the default page size, so a - * client paging with 2.0, 3.0, and so on got page 1 every time and never reached the error - * for a page past the last one. + * Read as invalid, they would fall back to page 1 and the default page size, so a client + * paging with 2.0, 3.0, and so on would get page 1 every time and never reach the error for + * a page past the last one. * * @ticket 66268 * @dataProvider data_whole_numbers_that_are_not_integers @@ -2700,10 +2729,10 @@ public function test_query_honors_a_page_and_per_page_that_are_not_integers( $pa $this->register_ability(); $query = array( - 'post_type' => 'post', - 'include' => self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ), - 'per_page' => $per_page, - 'fields' => array( 'id' ), + 'type' => 'post', + 'include' => self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ), + 'per_page' => $per_page, + 'fields' => array( 'id' ), ); $result = wp_get_ability( 'core/content-query' )->execute( array( 'page' => $page ) + $query ); @@ -2718,6 +2747,157 @@ public function test_query_honors_a_page_and_per_page_that_are_not_integers( $pa $this->assertSame( 'content_invalid_page_number', $result->get_error_code(), 'A page past the last one should report the out-of-range error.' ); } + /** + * Returns {@see self::IMPORTED_POST_ID} in forms that the integer schema accepts but that + * are not PHP integers. + * + * @return array The values to send for the post ID. + */ + public function data_post_ids_that_are_not_integers(): array { + return array( + 'float' => array( + 'id' => (float) self::IMPORTED_POST_ID, + ), + 'decimal string' => array( + 'id' => self::IMPORTED_POST_ID . '.0', + ), + 'signed string' => array( + 'id' => '+' . self::IMPORTED_POST_ID, + ), + 'string with a space' => array( + 'id' => ' ' . self::IMPORTED_POST_ID, + ), + ); + } + + /** + * A single-post lookup honors an `id` that the integer schema accepts even when it is not + * a PHP integer, as when the MCP adapter passes a float that a JSON encoder produced. + * + * Read as invalid, the ID would deny a readable post as a permission error. + * + * @ticket 66268 + * @dataProvider data_post_ids_that_are_not_integers + * @since 7.2.0 + * + * @param float|string $id The value to send for the imported post ID. + */ + public function test_get_by_id_honors_an_id_that_is_not_an_integer( $id ): void { + $post_id = self::factory()->post->create( + array( + 'import_id' => self::IMPORTED_POST_ID, + 'post_status' => 'publish', + ) + ); + $this->assertSame( self::IMPORTED_POST_ID, $post_id, 'Precondition: the post should have the requested ID.' ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'id' => $id, + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'The post should be returned.' ); + $this->assertSame( $post_id, $result['id'], 'The lookup should resolve to the requested post.' ); + } + + /** + * Query mode honors a `parent` filter that the integer schema accepts even when it is not + * a PHP integer. + * + * Read as invalid, the filter would be rejected as an invalid filter. + * + * @ticket 66268 + * @dataProvider data_post_ids_that_are_not_integers + * @since 7.2.0 + * + * @param float|string $id The value to send for the imported parent post ID. + */ + public function test_query_honors_a_parent_that_is_not_an_integer( $id ): void { + $parent_id = self::factory()->post->create( + array( + 'import_id' => self::IMPORTED_POST_ID, + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + $this->assertSame( self::IMPORTED_POST_ID, $parent_id, 'Precondition: the parent page should have the requested ID.' ); + + $child_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_parent' => $parent_id, + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'type' => 'page', + 'parent' => $id, + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'The query should succeed.' ); + $this->assertSame( array( $child_id ), wp_list_pluck( $result['posts'], 'id' ), 'The query should return the children of the requested parent.' ); + } + + /** + * Returns fractions just above {@see self::IMPORTED_POST_ID}, which the integer schema rejects. + * + * @return array The values to send for the post ID. + */ + public function data_fractional_post_ids(): array { + return array( + 'float' => array( + 'id' => self::IMPORTED_POST_ID + 0.5, + ), + 'string' => array( + 'id' => self::IMPORTED_POST_ID . '.5', + ), + ); + } + + /** + * A fractional ID is rejected rather than truncated onto the post with the whole ID. + * + * Schema validation rejects it before either callback runs through WP_Ability::execute(), + * but each callback still fails closed on its own. + * + * @ticket 66268 + * @dataProvider data_fractional_post_ids + * @since 7.2.0 + * + * @param float|string $id The value to send, a fraction just above the imported post ID. + */ + public function test_callbacks_reject_a_fractional_id( $id ): void { + $post_id = self::factory()->post->create( + array( + 'import_id' => self::IMPORTED_POST_ID, + 'post_status' => 'publish', + ) + ); + $this->assertSame( self::IMPORTED_POST_ID, $post_id, 'Precondition: the post should have the ID the fraction truncates to.' ); + + $this->login_as( 'administrator' ); + $content = new WP_Abilities_Content(); + + $this->assertFalse( $content->check_permission( array( 'id' => $id ) ), 'The permission callback should deny a fractional ID.' ); + + $result = $content->execute_content_query( array( 'id' => $id ) ); + + $this->assertWPError( $result, 'The execute callback should not resolve a fractional ID.' ); + $this->assertSame( 'content_not_found', $result->get_error_code(), 'A fractional ID should fail the lookup.' ); + } + /** * A genuinely empty result set beyond the first page reports zero totals, not an error. * @@ -2732,10 +2912,10 @@ public function test_query_empty_result_beyond_first_page_reports_zero_totals(): $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'include' => array( REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ), - 'page' => 2, - 'fields' => array( 'id' ), + 'type' => 'post', + 'include' => array( REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ), + 'page' => 2, + 'fields' => array( 'id' ), ) ); @@ -2798,10 +2978,10 @@ static function ( WP_Query $query ) use ( $posts_per_page ): void { $ability = wp_get_ability( 'core/content-query' ); $input = array( - 'post_type' => 'post', - 'include' => $ids, - 'per_page' => 4, - 'fields' => array( 'id' ), + 'type' => 'post', + 'include' => $ids, + 'per_page' => 4, + 'fields' => array( 'id' ), ); $first_page = $ability->execute( $input ); @@ -2838,9 +3018,9 @@ public function test_query_include_returns_every_requested_post_without_per_page $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'include' => $ids, - 'fields' => array( 'id' ), + 'type' => 'post', + 'include' => $ids, + 'fields' => array( 'id' ), ) ); @@ -2867,10 +3047,10 @@ public function test_query_include_honors_an_explicit_per_page(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'include' => $ids, - 'per_page' => 2, - 'fields' => array( 'id' ), + 'type' => 'post', + 'include' => $ids, + 'per_page' => 2, + 'fields' => array( 'id' ), ) ); @@ -2896,8 +3076,8 @@ public function test_query_include_is_capped_at_the_maximum_page_size(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'include' => range( 1, $query['properties']['include']['maxItems'] + 1 ), + 'type' => 'post', + 'include' => range( 1, $query['properties']['include']['maxItems'] + 1 ), ) ); @@ -2925,9 +3105,9 @@ public function test_query_rendered_fields_prime_the_post_meta_cache(): void { static function () use ( $ids ) { return wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'include' => $ids, - 'fields' => array( 'id', 'content_rendered' ), + 'type' => 'post', + 'include' => $ids, + 'fields' => array( 'id', 'content_rendered' ), ) ); }, @@ -2974,9 +3154,9 @@ public function test_query_rendered_fields_prime_the_authors_of_the_page( string static function () use ( $ids, $field ) { return wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'include' => $ids, - 'fields' => array( 'id', $field ), + 'type' => 'post', + 'include' => $ids, + 'fields' => array( 'id', $field ), ) ); }, @@ -3017,17 +3197,14 @@ public function test_query_lean_projection_does_not_request_post_meta_priming(): }; add_action( 'pre_get_posts', $spy ); - try { - $result = wp_get_ability( 'core/content-query' )->execute( - array( - 'post_type' => 'post', - 'include' => $ids, - 'fields' => array( 'id' ), - ) - ); - } finally { - remove_action( 'pre_get_posts', $spy ); - } + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'type' => 'post', + 'include' => $ids, + 'fields' => array( 'id' ), + ) + ); $this->assertCount( 3, $result['posts'], 'Precondition: the query should return the seeded posts.' ); $this->assertNotEmpty( $priming, 'Precondition: the ability should query for the included posts.' ); @@ -3086,9 +3263,9 @@ public function test_query_link_primes_the_caches_permalinks_read( string $perma static function () use ( $ids ) { return wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'include' => $ids, - 'fields' => array( 'id', 'link' ), + 'type' => 'post', + 'include' => $ids, + 'fields' => array( 'id', 'link' ), ) ); }, @@ -3125,9 +3302,9 @@ public function test_query_link_primes_the_parents_page_permalinks_read(): void static function () use ( $ids ) { return wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'page', - 'include' => $ids, - 'fields' => array( 'id', 'link' ), + 'type' => 'page', + 'include' => $ids, + 'fields' => array( 'id', 'link' ), ) ); }, @@ -3175,64 +3352,60 @@ private function count_queries( string $table, callable $callback, &$result ): i get_userdata( get_current_user_id() ); add_filter( 'query', $spy ); - try { - $result = $callback(); - } finally { - remove_filter( 'query', $spy ); - } + $result = $callback(); + remove_filter( 'query', $spy ); return $queries; } /** - * Query rows are kept, not dropped, when the requested fields project to nothing. + * Query rows are kept, with their ID, when none of the requested fields apply to them. * * @ticket 66268 * @since 7.2.0 */ - public function test_query_keeps_posts_with_empty_field_projection(): void { + public function test_query_keeps_posts_whose_requested_fields_do_not_apply(): void { $this->login_as( 'administrator' ); $this->register_ability(); - /* - * `parent` never applies to the non-hierarchical `post` type, so every row - * projects to an empty object. - */ + // `parent` never applies to the non-hierarchical `post` type. $result = wp_get_ability( 'core/content-query' )->execute( array( - 'post_type' => 'post', - 'per_page' => 100, - 'fields' => array( 'parent' ), + 'type' => 'post', + 'per_page' => 100, + 'fields' => array( 'parent' ), ) ); - $this->assertNotEmpty( $result['posts'], 'Posts with an empty field projection should still be returned.' ); - $this->assertSame( count( $result['posts'] ), $result['total'], 'The reported total should match the returned posts when projections are empty.' ); + $this->assertNotEmpty( $result['posts'], 'Posts whose requested fields do not apply should still be returned.' ); + $this->assertCount( $result['total'], $result['posts'], 'The reported total should match the returned posts.' ); foreach ( $result['posts'] as $post_entry ) { - $this->assertEquals( (object) array(), $post_entry, 'A post whose requested fields do not apply should be returned as an empty object.' ); + $this->assertSame( array( 'id' ), array_keys( $post_entry ), 'A post whose requested fields do not apply should return only its ID.' ); } } /** - * A single post whose requested fields project to nothing is returned as an empty object. + * A single post is returned with its ID when none of the requested fields apply to it. * * @ticket 66268 * @since 7.2.0 */ - public function test_single_post_returns_empty_object_for_empty_field_projection(): void { + public function test_single_post_returns_its_id_when_requested_fields_do_not_apply(): void { $this->login_as( 'administrator' ); $this->register_ability(); - // `parent` never applies to the non-hierarchical `post` type, so the projection is empty. + $post_id = self::$post_ids['published']; + + // `parent` never applies to the non-hierarchical `post` type. $result = wp_get_ability( 'core/content-query' )->execute( array( - 'id' => self::$post_ids['published'], + 'id' => $post_id, 'fields' => array( 'parent' ), ) ); - $this->assertEquals( (object) array(), $result, 'An empty single-post field projection should be returned as an empty object so it serializes as `{}`.' ); + $this->assertSame( array( 'id' => $post_id ), $result, 'A post whose requested fields do not apply should return only its ID.' ); } /** @@ -3486,8 +3659,8 @@ public function test_execute_callback_returns_not_found_for_structural_lookup_fa $mismatched = $content->execute_content_query( array( - 'id' => self::$post_ids['published'], - 'post_type' => 'page', + 'id' => self::$post_ids['published'], + 'type' => 'page', ) ); $this->assertWPError( $mismatched, 'A post type mismatch should fail the lookup.' ); @@ -3495,8 +3668,8 @@ public function test_execute_callback_returns_not_found_for_structural_lookup_fa $missing_slug = $content->execute_content_query( array( - 'post_type' => 'post', - 'slug' => 'no-such-slug', + 'type' => 'post', + 'slug' => 'no-such-slug', ) ); $this->assertWPError( $missing_slug, 'An unmatched slug should fail the lookup.' ); @@ -3510,9 +3683,8 @@ public function test_execute_callback_returns_not_found_for_structural_lookup_fa */ public function data_author_slugs_that_name_no_user(): array { return array( - 'unknown slug' => array( 'no-such-user' ), - 'slug in capitals' => array( 'AUTHOR-SLUG' ), - 'not a string' => array( 5 ), + 'unknown slug' => array( 'no-such-user' ), + 'not a string' => array( 5 ), ); } @@ -3538,7 +3710,7 @@ public function test_execute_callback_rejects_an_author_slug_that_names_no_user( $result = $content->execute_content_query( array( - 'post_type' => 'post', + 'type' => 'post', 'author_slug' => $author_slug, ) ); @@ -3547,6 +3719,52 @@ public function test_execute_callback_rejects_an_author_slug_that_names_no_user( $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An unhonorable author_slug filter should fail closed as an invalid filter.' ); } + /** + * An author_slug filter is looked up as given, like the `slug` filter of the REST API users + * endpoint, so with the database's case-insensitive collation a slug in capitals names the + * author too. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_author_slug_filter_matches_a_slug_in_capitals(): void { + wp_update_user( + array( + 'ID' => self::$user_ids['author'], + 'user_nicename' => 'author-slug', + ) + ); + + $author_post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'publish', + ) + ); + $other_post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['editor'], + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'type' => 'post', + 'author_slug' => 'AUTHOR-SLUG', + 'include' => array( $author_post_id, $other_post_id ), + 'fields' => array( 'id', 'author_slug' ), + ) + ); + + $this->assertIsArray( $result, 'The query should succeed.' ); + $this->assertSame( array( $author_post_id ), wp_list_pluck( $result['posts'], 'id' ), 'The filter should return only the author\'s posts.' ); + $this->assertSame( 'author-slug', $result['posts'][0]['author_slug'], 'Each post should return the stored slug.' ); + } + /** * A parent filter that is not a non-negative integer is rejected rather than * coerced to 0 (top-level). @@ -3564,8 +3782,8 @@ public function test_execute_callback_rejects_non_integer_parent_filter(): void $result = $content->execute_content_query( array( - 'post_type' => 'page', - 'parent' => 'not-a-number', + 'type' => 'page', + 'parent' => 'not-a-number', ) ); @@ -3592,8 +3810,8 @@ public function test_execute_callback_rejects_include_with_no_valid_ids(): void $result = $content->execute_content_query( array( - 'post_type' => 'post', - 'include' => array( 0 ), + 'type' => 'post', + 'include' => array( 0 ), ) ); @@ -3621,7 +3839,7 @@ public function test_author_slug_filter_only_names_users_the_current_user_may_se $query = static function ( array $statuses, int $user_id ) { return ( new WP_Abilities_Content() )->execute_content_query( array( - 'post_type' => 'post', + 'type' => 'post', 'status' => $statuses, 'author_slug' => get_userdata( $user_id )->user_nicename, 'fields' => array( 'id' ), @@ -3662,7 +3880,7 @@ public function test_author_slug_filter_hides_users_of_other_sites(): void { $query = static function ( string $author_slug ) { return ( new WP_Abilities_Content() )->execute_content_query( array( - 'post_type' => 'post', + 'type' => 'post', 'author_slug' => $author_slug, ) ); @@ -3700,7 +3918,7 @@ public function test_author_slug_filter_names_authors_who_are_not_site_members() $is_member = is_user_member_of_blog( $super_admin_id, $site_id ); $result = ( new WP_Abilities_Content() )->execute_content_query( array( - 'post_type' => 'post', + 'type' => 'post', 'author_slug' => 'network-author', 'fields' => array( 'id' ), ) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php index c299ae1d04211..ad27df86c29aa 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -44,12 +44,12 @@ class Tests_REST_API_WpRestAbilitiesContentController extends WP_UnitTestCase { * Sets up users and registers the core abilities. * * @since 7.2.0 + * + * @param WP_UnitTest_Factory $factory The unit test factory. */ - public static function set_up_before_class(): void { - parent::set_up_before_class(); - - self::$admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); - self::$subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + public static function wpSetUpBeforeClass( $factory ): void { + self::$admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); @@ -82,15 +82,13 @@ public static function set_up_before_class(): void { * * @since 7.2.0 */ - public static function tear_down_after_class(): void { + public static function wpTearDownAfterClass(): void { foreach ( wp_get_abilities() as $ability ) { wp_unregister_ability( $ability->get_name() ); } foreach ( wp_get_ability_categories() as $ability_category ) { wp_unregister_ability_category( $ability_category->get_slug() ); } - - parent::tear_down_after_class(); } public function set_up(): void { @@ -129,7 +127,7 @@ private function run_request( array $input ): WP_REST_Request { public function test_logged_out_user_receives_401(): void { wp_set_current_user( 0 ); - $response = $this->server->dispatch( $this->run_request( array( 'post_type' => 'post' ) ) ); + $response = $this->server->dispatch( $this->run_request( array( 'type' => 'post' ) ) ); $this->assertSame( 401, $response->get_status() ); } @@ -143,8 +141,8 @@ public function test_subscriber_requesting_drafts_receives_403(): void { $response = $this->server->dispatch( $this->run_request( array( - 'post_type' => 'post', - 'status' => array( 'draft' ), + 'type' => 'post', + 'status' => array( 'draft' ), ) ) ); @@ -169,8 +167,8 @@ public function test_subscriber_requesting_published_posts_receives_readable_fie $response = $this->server->dispatch( $this->run_request( array( - 'post_type' => 'post', - 'fields' => array( 'id', 'title_rendered', 'content_rendered' ), + 'type' => 'post', + 'fields' => array( 'id', 'title_rendered', 'content_rendered' ), ) ) ); @@ -197,8 +195,8 @@ public function test_subscriber_requesting_raw_fields_receives_403(): void { $response = $this->server->dispatch( $this->run_request( array( - 'post_type' => 'post', - 'fields' => array( 'content_raw' ), + 'type' => 'post', + 'fields' => array( 'content_raw' ), ) ) ); @@ -217,7 +215,7 @@ public function test_admin_query_returns_published_posts(): void { ) ); - $response = $this->server->dispatch( $this->run_request( array( 'post_type' => 'post' ) ) ); + $response = $this->server->dispatch( $this->run_request( array( 'type' => 'post' ) ) ); $data = $response->get_data(); $this->assertSame( 200, $response->get_status(), 'An administrator should be able to query published posts.' ); @@ -251,10 +249,10 @@ public function test_admin_query_include_limits_results(): void { $response = $this->server->dispatch( $this->run_request( array( - 'post_type' => 'post', + 'type' => 'post', // Deliberately pass IDs in the opposite of the expected date order. - 'include' => array( $first, $third ), - 'fields' => array( 'id' ), + 'include' => array( $first, $third ), + 'fields' => array( 'id' ), ) ) ); @@ -294,8 +292,8 @@ public function test_get_single_post_by_slug(): void { $response = $this->server->dispatch( $this->run_request( array( - 'post_type' => 'post', - 'slug' => 'rest-content-slug', + 'type' => 'post', + 'slug' => 'rest-content-slug', ) ) ); @@ -313,7 +311,7 @@ public function test_get_single_post_by_slug(): void { public function test_wrong_http_method_returns_405(): void { $request = new WP_REST_Request( 'POST', self::RUN_ROUTE ); $request->set_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( array( 'input' => array( 'post_type' => 'post' ) ) ) ); + $request->set_body( wp_json_encode( array( 'input' => array( 'type' => 'post' ) ) ) ); $response = $this->server->dispatch( $request ); @@ -330,9 +328,9 @@ public function test_pagination_returns_totals_in_body(): void { $response = $this->server->dispatch( $this->run_request( array( - 'post_type' => 'post', - 'per_page' => 2, - 'page' => 1, + 'type' => 'post', + 'per_page' => 2, + 'page' => 1, ) ) ); @@ -347,20 +345,20 @@ public function test_pagination_returns_totals_in_body(): void { /** * @ticket 66268 */ - public function test_out_of_range_page_returns_400(): void { + public function test_out_of_range_page_returns_404(): void { self::factory()->post->create( array( 'post_status' => 'publish' ) ); $response = $this->server->dispatch( $this->run_request( array( - 'post_type' => 'post', - 'per_page' => 1, - 'page' => 999, + 'type' => 'post', + 'per_page' => 1, + 'page' => 999, ) ) ); - $this->assertSame( 400, $response->get_status(), 'Requesting a page past the last one should return a 400 error.' ); + $this->assertSame( 404, $response->get_status(), 'Requesting a page past the last one should return a 404 error.' ); $this->assertSame( 'content_invalid_page_number', $response->get_data()['code'], 'The error should identify the invalid page number.' ); } } From 673f4a46ba555c8d377087da7acf3507b0e440a3 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 8 Oct 2026 16:52:14 +0100 Subject: [PATCH 66/69] Abilities API: Align the content query docs with the sibling abilities - Explain in the class docblock why the content ability lives in a dedicated class instead of a closure in wp_register_core_abilities(), as the users and settings classes do. - Say that an empty `fields` list, like an omitted one, returns the lean set of common read fields. --- .../abilities/class-wp-abilities-content.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-content.php b/src/wp-includes/abilities/class-wp-abilities-content.php index e3d4a2e9f906d..ad58a0166bb03 100644 --- a/src/wp-includes/abilities/class-wp-abilities-content.php +++ b/src/wp-includes/abilities/class-wp-abilities-content.php @@ -18,9 +18,12 @@ * by post type, status, author, parent, or included IDs. Raw fields are only returned for * posts the current user can edit. * - * The class is intentionally structured around shared building blocks (exposed post type - * discovery, schema generation, per-post formatting and permission checks) so future - * write-oriented content abilities can reuse them. + * Unlike the other core abilities, which are self-contained closures registered directly + * in wp_register_core_abilities(), the content ability lives in a dedicated class because + * its callbacks and schemas share helpers: the permission and execute callbacks resolve and + * authorize the requested post through the same code, and the input schema, output schema, + * and field projection are built from the same field definitions. Future write-oriented + * content abilities can reuse them as well. * * This class is part of WordPress' internal implementation of the core abilities and is * not part of the public API. It may be changed or removed at any time without notice. @@ -968,7 +971,7 @@ private function get_fields_input_schema(): array { 'type' => 'string', 'enum' => array_keys( $this->get_post_properties() ), ), - 'description' => __( 'Limit each returned post to these fields. The `id` is always included. If omitted, a lean set of common read fields is returned. Explicit raw field requests require edit access.' ), + 'description' => __( 'Limit each returned post to these fields. The `id` is always included. If omitted or empty, a lean set of common read fields is returned. Explicit raw field requests require edit access.' ), ); } From f38cace17c119c8e6f1ef714214bc20ab13d3374 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 9 Oct 2026 12:36:24 +0100 Subject: [PATCH 67/69] Abilities API: Address further review feedback on the content query - Return null instead of an empty string for a date that cannot be resolved, and declare the `date-time` format on the date fields, so callers can tell a usable date from a missing one through the schema. A malformed stored date that the format rejects is reported as null too, instead of failing output validation for the whole call. - Map the filter to the message under `params` in the `content_invalid_filter` error data, as the REST API's `rest_invalid_param` errors do, so callers can tell which filter failed without parsing the translated message. - Mark `id`, which is always returned, as required in the post output schema. --- .../abilities/class-wp-abilities-content.php | 72 +++++++++---- .../wpRegisterCoreContentAbility.php | 102 +++++++++++++++++- 2 files changed, 148 insertions(+), 26 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-content.php b/src/wp-includes/abilities/class-wp-abilities-content.php index ad58a0166bb03..58d8b57168cc5 100644 --- a/src/wp-includes/abilities/class-wp-abilities-content.php +++ b/src/wp-includes/abilities/class-wp-abilities-content.php @@ -438,12 +438,12 @@ public function execute_content_query( $input = array() ) { $parent = null; if ( isset( $input['parent'] ) ) { if ( ! is_post_type_hierarchical( $post_type ) ) { - return $this->invalid_filter_error( __( 'The parent filter is only supported for hierarchical post types.' ) ); + return $this->invalid_filter_error( 'parent', __( 'The parent filter is only supported for hierarchical post types.' ) ); } $parent = $this->parse_filter_int( $input['parent'], 0 ); if ( null === $parent ) { - return $this->invalid_filter_error( __( 'The parent filter must be a non-negative integer.' ) ); + return $this->invalid_filter_error( 'parent', __( 'The parent filter must be a non-negative integer.' ) ); } } @@ -451,6 +451,7 @@ public function execute_content_query( $input = array() ) { if ( isset( $input['author_slug'] ) ) { if ( ! post_type_supports( $post_type, 'author' ) ) { return $this->invalid_filter_error( + 'author_slug', /* translators: %s: Parameter. */ sprintf( __( 'The %s filter is only supported for post types that support authors.' ), 'author_slug' ) ); @@ -459,6 +460,7 @@ public function execute_content_query( $input = array() ) { $author = $this->get_author_by_slug( $input['author_slug'], $post_type_object ); if ( ! $author ) { return $this->invalid_filter_error( + 'author_slug', /* translators: %s: Parameter. */ sprintf( __( 'The %s filter must be the slug of an existing user.' ), 'author_slug' ) ); @@ -473,7 +475,7 @@ public function execute_content_query( $input = array() ) { * would return every post of the type — the opposite of the caller's intent. */ if ( isset( $input['include'] ) && array() === $include ) { - return $this->invalid_filter_error( __( 'The include filter must list one or more valid post IDs.' ) ); + return $this->invalid_filter_error( 'include', __( 'The include filter must list one or more valid post IDs.' ) ); } $per_page = $this->normalize_per_page( $input, $include ); @@ -890,20 +892,24 @@ private function get_post_properties(): array { 'description' => __( 'The post status.' ), ), 'date' => array( - 'type' => 'string', - 'description' => __( "The publication date, in ISO 8601 format using the site's timezone. Empty string when the date cannot be resolved." ), + 'type' => array( 'string', 'null' ), + 'format' => 'date-time', + 'description' => __( "The publication date, in ISO 8601 format using the site's timezone. Null when the date cannot be resolved." ), ), 'date_gmt' => array( - 'type' => 'string', - 'description' => __( 'The publication date, in ISO 8601 format as GMT. Empty string when the date cannot be resolved.' ), + 'type' => array( 'string', 'null' ), + 'format' => 'date-time', + 'description' => __( 'The publication date, in ISO 8601 format as GMT. Null when the date cannot be resolved.' ), ), 'modified' => array( - 'type' => 'string', - 'description' => __( "The last modified date, in ISO 8601 format using the site's timezone. Empty string when the date cannot be resolved." ), + 'type' => array( 'string', 'null' ), + 'format' => 'date-time', + 'description' => __( "The last modified date, in ISO 8601 format using the site's timezone. Null when the date cannot be resolved." ), ), 'modified_gmt' => array( - 'type' => 'string', - 'description' => __( 'The last modified date, in ISO 8601 format as GMT. Empty string when the date cannot be resolved.' ), + 'type' => array( 'string', 'null' ), + 'format' => 'date-time', + 'description' => __( 'The last modified date, in ISO 8601 format as GMT. Null when the date cannot be resolved.' ), ), 'slug' => array( 'type' => 'string', @@ -1102,8 +1108,9 @@ private function get_content_query_input_schema( array $post_types, array $statu /** * Builds the output schema of a single post, shared by all content abilities. * - * No field is marked required because the `fields` input lets the caller request any - * subset, and a field is only present when its post type supports it. + * Only `id` is required, because it is always returned. The other fields are optional + * because the `fields` input lets the caller request any subset, and a field is only + * present when its post type supports it. * * @since 7.2.0 * @@ -1113,6 +1120,7 @@ private function get_content_output_schema(): array { return array( 'type' => 'object', 'additionalProperties' => false, + 'required' => array( 'id' ), 'properties' => $this->get_post_properties(), ); } @@ -1120,9 +1128,10 @@ private function get_content_output_schema(): array { /** * Builds the output schema for the `core/content-query` ability. * - * No field is marked required because the `fields` input lets the caller request any - * subset, and a field is only present when its post type supports it. Single-post - * mode returns the post object directly, while query mode returns a paginated wrapper. + * Only `id` is required in a post, because it is always returned. The other fields are + * optional because the `fields` input lets the caller request any subset, and a field + * is only present when its post type supports it. Single-post mode returns the post + * object directly, while query mode returns a paginated wrapper. * * @since 7.2.0 * @@ -1490,19 +1499,26 @@ private function restore_post_context( array $previous_context ): void { * @param WP_Post $post The post object. * @param string $field Either 'date' or 'modified'. * @param bool $gmt Whether to format the date in GMT instead of the site's timezone. - * @return string The ISO 8601 date, or an empty string if unavailable. + * @return string|null The ISO 8601 date, or null if unavailable. */ - private function format_date( WP_Post $post, string $field, bool $gmt ): string { + private function format_date( WP_Post $post, string $field, bool $gmt ): ?string { $datetime = $gmt ? get_post_datetime( $post, $field, 'gmt' ) : false; if ( ! $datetime ) { $datetime = get_post_datetime( $post, $field ); } if ( ! $datetime ) { - return ''; + return null; } - return ( $gmt ? $datetime->setTimezone( new DateTimeZone( 'UTC' ) ) : $datetime )->format( 'c' ); + /* + * A malformed stored date can still parse: a zero month formats with a negative + * year. The `date-time` format rejects it, which would fail output validation for + * the whole call, so it is reported as null too. + */ + $date = ( $gmt ? $datetime->setTimezone( new DateTimeZone( 'UTC' ) ) : $datetime )->format( 'c' ); + + return rest_parse_date( $date ) ? $date : null; } /** @@ -1533,12 +1549,24 @@ private function not_found_error(): WP_Error { /** * Builds the error for a query filter that cannot be honored. * + * As in the REST API's `rest_invalid_param` errors, the error data maps the filter to + * the message under `params`, so callers can tell which filter failed without parsing + * the translated message. + * * @since 7.2.0 * + * @param string $filter The filter's input name. * @param string $message The error message. * @return WP_Error The invalid filter error. */ - private function invalid_filter_error( string $message ): WP_Error { - return new WP_Error( 'content_invalid_filter', $message, array( 'status' => 400 ) ); + private function invalid_filter_error( string $filter, string $message ): WP_Error { + return new WP_Error( + 'content_invalid_filter', + $message, + array( + 'status' => 400, + 'params' => array( $filter => $message ), + ) + ); } } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 5c7b56145351a..d852d742f246c 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -387,7 +387,7 @@ public function test_output_schema_describes_single_post_and_query_responses(): $this->assertSame( 'object', $schema['type'], 'The output schema should describe object responses.' ); $this->assertCount( 2, $schema['oneOf'], 'The output schema should describe single-post and query responses.' ); $this->assertSame( 'object', $post_schema['type'], 'The single-post response should be described as an object.' ); - $this->assertArrayNotHasKey( 'required', $post_schema, 'Individual post fields should remain optional.' ); + $this->assertSame( array( 'id' ), $post_schema['required'], 'Only the always-returned id should be required in a post.' ); $this->assertFalse( $post_schema['additionalProperties'], 'Returned posts should not allow unknown properties.' ); $this->assertArrayHasKey( 'type', $post_schema['properties'], 'The post schema should describe the post type as type.' ); $this->assertArrayNotHasKey( 'post_type', $post_schema['properties'], 'The post schema should not expose the post type as post_type.' ); @@ -1865,6 +1865,11 @@ public function test_query_mode_rejects_parent_filter_for_non_hierarchical_post_ $this->assertWPError( $result, 'The parent filter should be rejected for non-hierarchical post types.' ); $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'Unsupported parent filters should return a filter error.' ); + $this->assertSame( + array( 'parent' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the unsupported parent filter to the error message.' + ); } /** @@ -1930,6 +1935,11 @@ public function test_query_mode_rejects_author_filter_for_post_type_without_auth $this->assertWPError( $result, 'The author_slug filter should be rejected for post types without author support.' ); $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'Unsupported author filters should return a filter error.' ); + $this->assertSame( + array( 'author_slug' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the unsupported author_slug filter to the error message.' + ); } /** @@ -3601,7 +3611,7 @@ public function test_gmt_date_recovers_from_a_null_stored_gmt_date( string $fiel } /** - * A post with no usable date at all reports the documented empty-string sentinel. + * A post with no usable date at all reports null. * * @ticket 66268 * @since 7.2.0 @@ -3614,7 +3624,7 @@ public function test_gmt_date_recovers_from_a_null_stored_gmt_date( string $fiel * @param string $local_date Unused. Present to match the shared data provider shape. * @param string $expected Unused. Present to match the shared data provider shape. */ - public function test_gmt_date_is_empty_when_no_usable_date_exists( string $field, string $gmt_column, string $local_column, string $local_date, string $expected ): void { + public function test_gmt_date_is_null_when_no_usable_date_exists( string $field, string $gmt_column, string $local_column, string $local_date, string $expected ): void { $this->login_as( 'administrator' ); $this->register_ability(); @@ -3635,7 +3645,71 @@ public function test_gmt_date_is_empty_when_no_usable_date_exists( string $field ) ); - $this->assertSame( '', $result[ $field ], 'An unresolvable GMT date should be the empty-string sentinel.' ); + $this->assertIsArray( $result, 'An unresolvable GMT date should pass output validation.' ); + $this->assertArrayHasKey( $field, $result, 'An unresolvable GMT date should still be returned.' ); + $this->assertNull( $result[ $field ], 'An unresolvable GMT date should be null.' ); + } + + /** + * Data provider for date fields. + * + * @since 7.2.0 + * + * @return array + */ + public function data_date_fields(): array { + return array( + 'date' => array( + 'field' => 'date', + 'column' => 'post_date', + ), + 'date_gmt' => array( + 'field' => 'date_gmt', + 'column' => 'post_date_gmt', + ), + 'modified' => array( + 'field' => 'modified', + 'column' => 'post_modified', + ), + 'modified_gmt' => array( + 'field' => 'modified_gmt', + 'column' => 'post_modified_gmt', + ), + ); + } + + /** + * A malformed stored date reports null instead of failing output validation. + * + * A zero month still parses, but formats with a negative year, which the `date-time` + * format of the output schema rejects. + * + * @ticket 66268 + * @since 7.2.0 + * + * @dataProvider data_date_fields + * + * @param string $field The ability output field to request. + * @param string $column The cached post column to corrupt. + */ + public function test_malformed_date_is_null( string $field, string $column ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $this->replace_cached_post_date_columns( $post_id, array( $column => '0000-00-01 00:00:00' ) ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', $field ), + ) + ); + + $this->assertIsArray( $result, 'A malformed date should pass output validation.' ); + $this->assertArrayHasKey( $field, $result, 'A malformed date should still be returned.' ); + $this->assertNull( $result[ $field ], 'A malformed date should be null.' ); } /** @@ -3717,6 +3791,11 @@ public function test_execute_callback_rejects_an_author_slug_that_names_no_user( $this->assertWPError( $result, 'An author_slug that names no user must not silently widen the query to all authors.' ); $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An unhonorable author_slug filter should fail closed as an invalid filter.' ); + $this->assertSame( + array( 'author_slug' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the unmatched author_slug filter to the error message.' + ); } /** @@ -3789,6 +3868,11 @@ public function test_execute_callback_rejects_non_integer_parent_filter(): void $this->assertWPError( $result, 'A non-integer parent filter must not silently coerce to a top-level (0) query.' ); $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An unhonorable parent filter should fail closed as an invalid filter.' ); + $this->assertSame( + array( 'parent' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the non-integer parent filter to the error message.' + ); } /** @@ -3817,6 +3901,11 @@ public function test_execute_callback_rejects_include_with_no_valid_ids(): void $this->assertWPError( $result, 'An include filter with no valid IDs must not fall through to an unrestricted query.' ); $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An empty-after-parsing include should fail closed as an invalid filter.' ); + $this->assertSame( + array( 'include' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the include filter to the error message.' + ); } /** @@ -3855,6 +3944,11 @@ public function test_author_slug_filter_only_names_users_the_current_user_may_se $hidden = $query( array( 'publish' ), self::$user_ids['author_secondary'] ); $this->assertWPError( $hidden, 'A subscriber should not learn that an author without published posts exists.' ); $this->assertSame( 'content_invalid_filter', $hidden->get_error_code(), 'A hidden author should be reported like a missing one.' ); + $this->assertSame( + array( 'author_slug' => $hidden->get_error_message() ), + $hidden->get_error_data()['params'], + 'The error data should map the hidden author_slug filter to the error message.' + ); $this->login_as( 'editor' ); $drafts = $query( array( 'draft' ), self::$user_ids['author_secondary'] ); From 162e688dc17067632b0b878e227d74599801555b Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 9 Oct 2026 12:48:08 +0100 Subject: [PATCH 68/69] Abilities API: Keep the `core/content-query` callbacks private. Only `register()` stays public. The permission and execute callbacks, and the `post_password_required` filter that `format_post()` adds, are now private methods, reached through closures defined in the class, so callers run the ability through the Abilities API, such as `wp_get_ability( 'core/content-query' )->execute()`, which validates the input and checks permissions first. This replaces the class docblock's note that the class is not part of the public API. Public methods are covered by Core's backward compatibility commitment whatever the docblock says. Tests that call the callbacks directly now capture them from the registration arguments, through the `wp_register_ability_args` filter. The password filter test now reads `post_password_required()` while an editable protected post renders, instead of calling the filter callback. --- .../abilities/class-wp-abilities-content.php | 31 +++-- .../wpRegisterCoreContentAbility.php | 110 +++++++++++++----- 2 files changed, 100 insertions(+), 41 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-content.php b/src/wp-includes/abilities/class-wp-abilities-content.php index 58d8b57168cc5..89889ba0c8ef2 100644 --- a/src/wp-includes/abilities/class-wp-abilities-content.php +++ b/src/wp-includes/abilities/class-wp-abilities-content.php @@ -25,9 +25,10 @@ * and field projection are built from the same field definitions. Future write-oriented * content abilities can reuse them as well. * - * This class is part of WordPress' internal implementation of the core abilities and is - * not part of the public API. It may be changed or removed at any time without notice. - * Do not use it directly or rely on its existence. + * Only register() is public. The ability callbacks are closures that call private + * methods, so callers go through the Abilities API, such as + * `wp_get_ability( 'core/content-query' )->execute()`, which validates the input and + * checks permissions before running them. * * @since 7.2.0 * @@ -145,8 +146,12 @@ private function register_content_query(): void { 'category' => self::CATEGORY, 'input_schema' => $this->get_content_query_input_schema( $post_types, $statuses ), 'output_schema' => $this->get_content_query_output_schema(), - 'execute_callback' => array( $this, 'execute_content_query' ), - 'permission_callback' => array( $this, 'check_permission' ), + 'execute_callback' => function ( $input = array() ) { + return $this->execute_content_query( $input ); + }, + 'permission_callback' => function ( $input = array() ): bool { + return $this->check_permission( $input ); + }, 'meta' => array( 'annotations' => array( 'readonly' => true, @@ -174,7 +179,7 @@ private function register_content_query(): void { * @param mixed $input Optional. The ability input. Default empty array. * @return bool True if the request may proceed, false otherwise. */ - public function check_permission( $input = array() ): bool { + private function check_permission( $input = array() ): bool { $input = rest_sanitize_object( $input ); if ( ! is_user_logged_in() ) { @@ -407,7 +412,7 @@ private function check_read_permission( WP_Post $post, array $checked_post_ids = * @param mixed $input Optional. The ability input. Default empty array. * @return array|WP_Error A single post, a `posts` list with totals in query mode, or a WP_Error. */ - public function execute_content_query( $input = array() ) { + private function execute_content_query( $input = array() ) { $input = rest_sanitize_object( $input ); $fields = $this->normalize_fields( $input ); $requires_edit = $this->has_explicit_edit_fields( $input ); @@ -1196,10 +1201,14 @@ private function format_post( WP_Post $post, array $fields ): array { * The filter unlocks only posts the current user can edit, mirroring the REST posts * controller's check_password_required(): an unconditional bypass (e.g. __return_false) * would also expose other protected posts that the content filter may render, such as - * posts pulled in by a Query Loop block. + * posts pulled in by a Query Loop block. The closure is kept in a variable, so the same + * instance can be removed again. */ + $allow_password_content = function ( $required, $checked_post ): bool { + return $this->allow_password_content( $required, $checked_post ); + }; if ( $unlock_password ) { - add_filter( 'post_password_required', array( $this, 'allow_password_content' ), 10, 2 ); + add_filter( 'post_password_required', $allow_password_content, 10, 2 ); } /* @@ -1210,7 +1219,7 @@ private function format_post( WP_Post $post, array $fields ): array { return $this->build_post_fields( $post, $fields, $can_edit, $password_required && ! $can_edit ); } finally { if ( $unlock_password ) { - remove_filter( 'post_password_required', array( $this, 'allow_password_content' ), 10 ); + remove_filter( 'post_password_required', $allow_password_content, 10 ); } $this->restore_post_context( $previous_context ); @@ -1330,7 +1339,7 @@ private function build_post_fields( WP_Post $post, array $fields, bool $can_edit * @param mixed $post The post being checked; a WP_Post when invoked by the core filter. * @return bool Whether the post still requires a password. */ - public function allow_password_content( $required, $post ): bool { + private function allow_password_content( $required, $post ): bool { if ( ! $required || ! $post instanceof WP_Post ) { return (bool) $required; } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index d852d742f246c..b5a77397dbfb5 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -181,6 +181,40 @@ private function register_ability(): void { ( new WP_Abilities_Content() )->register(); } + /** + * Registers the core/content-query ability and returns the callbacks it was registered with. + * + * The callbacks call private methods, so tests that skip input validation or the + * permission check capture them from the registration arguments. + * + * @since 7.2.0 + * + * @return array The permission and execute callbacks, keyed by argument name. + */ + private function get_ability_callbacks(): array { + $callbacks = array(); + + add_filter( + 'wp_register_ability_args', + static function ( array $args, string $name ) use ( &$callbacks ): array { + if ( 'core/content-query' === $name ) { + $callbacks = array( + 'permission_callback' => $args['permission_callback'], + 'execute_callback' => $args['execute_callback'], + ); + } + + return $args; + }, + 10, + 2 + ); + + $this->register_ability(); + + return $callbacks; + } + /** * Logs in as a user with the given role and returns the user ID. * @@ -2538,24 +2572,37 @@ public function test_allow_password_content_only_unlocks_editable_posts(): void 'post_password' => 'secret', ) ); - $other_id = self::$post_ids['password_protected']; $this->login_as( 'author' ); - $ability = new WP_Abilities_Content(); + // Read the password gate of each post while the editable protected post renders. + $gates = array(); + add_filter( + 'the_content', + static function ( $content ) use ( &$gates, $owned_id ) { + $gates = array( + 'owned' => post_password_required( $owned_id ), + 'other' => post_password_required( self::$post_ids['password_protected'] ), + 'public' => post_password_required( self::$post_ids['published'] ), + ); - $this->assertFalse( - $ability->allow_password_content( true, get_post( $owned_id ) ), - 'The filter should unlock a protected post the current user can edit.' - ); - $this->assertTrue( - $ability->allow_password_content( true, get_post( $other_id ) ), - 'The filter should keep the gate on a protected post the current user cannot edit.' + return $content; + } ); - $this->assertFalse( - $ability->allow_password_content( false, get_post( $other_id ) ), - 'The filter should leave posts that do not require a password ungated.' + + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'id' => $owned_id, + 'fields' => array( 'id', 'content_rendered' ), + ) ); + + $this->assertIsArray( $result, 'Precondition: the author should read their own protected post.' ); + $this->assertFalse( $gates['owned'], 'The filter should unlock a protected post the current user can edit.' ); + $this->assertTrue( $gates['other'], 'The filter should keep the gate on a protected post the current user cannot edit.' ); + $this->assertFalse( $gates['public'], 'The filter should leave posts that do not require a password ungated.' ); } /** @@ -2898,11 +2945,11 @@ public function test_callbacks_reject_a_fractional_id( $id ): void { $this->assertSame( self::IMPORTED_POST_ID, $post_id, 'Precondition: the post should have the ID the fraction truncates to.' ); $this->login_as( 'administrator' ); - $content = new WP_Abilities_Content(); + $callbacks = $this->get_ability_callbacks(); - $this->assertFalse( $content->check_permission( array( 'id' => $id ) ), 'The permission callback should deny a fractional ID.' ); + $this->assertFalse( $callbacks['permission_callback']( array( 'id' => $id ) ), 'The permission callback should deny a fractional ID.' ); - $result = $content->execute_content_query( array( 'id' => $id ) ); + $result = $callbacks['execute_callback']( array( 'id' => $id ) ); $this->assertWPError( $result, 'The execute callback should not resolve a fractional ID.' ); $this->assertSame( 'content_not_found', $result->get_error_code(), 'A fractional ID should fail the lookup.' ); @@ -3725,13 +3772,13 @@ public function test_malformed_date_is_null( string $field, string $column ): vo public function test_execute_callback_returns_not_found_for_structural_lookup_failures(): void { $this->login_as( 'administrator' ); - $content = new WP_Abilities_Content(); + $execute = $this->get_ability_callbacks()['execute_callback']; - $missing = $content->execute_content_query( array( 'id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ) ); + $missing = $execute( array( 'id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ) ); $this->assertWPError( $missing, 'A nonexistent post ID should fail the lookup.' ); $this->assertSame( 'content_not_found', $missing->get_error_code(), 'Missing posts should map to the uniform not-found error.' ); - $mismatched = $content->execute_content_query( + $mismatched = $execute( array( 'id' => self::$post_ids['published'], 'type' => 'page', @@ -3740,7 +3787,7 @@ public function test_execute_callback_returns_not_found_for_structural_lookup_fa $this->assertWPError( $mismatched, 'A post type mismatch should fail the lookup.' ); $this->assertSame( 'content_not_found', $mismatched->get_error_code(), 'Mismatched post types should map to the uniform not-found error.' ); - $missing_slug = $content->execute_content_query( + $missing_slug = $execute( array( 'type' => 'post', 'slug' => 'no-such-slug', @@ -3780,9 +3827,9 @@ public function test_execute_callback_rejects_an_author_slug_that_names_no_user( ); $this->login_as( 'administrator' ); - $content = new WP_Abilities_Content(); + $execute = $this->get_ability_callbacks()['execute_callback']; - $result = $content->execute_content_query( + $result = $execute( array( 'type' => 'post', 'author_slug' => $author_slug, @@ -3857,9 +3904,9 @@ public function test_author_slug_filter_matches_a_slug_in_capitals(): void { */ public function test_execute_callback_rejects_non_integer_parent_filter(): void { $this->login_as( 'administrator' ); - $content = new WP_Abilities_Content(); + $execute = $this->get_ability_callbacks()['execute_callback']; - $result = $content->execute_content_query( + $result = $execute( array( 'type' => 'page', 'parent' => 'not-a-number', @@ -3890,9 +3937,9 @@ public function test_execute_callback_rejects_include_with_no_valid_ids(): void $this->login_as( 'administrator' ); self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $content = new WP_Abilities_Content(); + $execute = $this->get_ability_callbacks()['execute_callback']; - $result = $content->execute_content_query( + $result = $execute( array( 'type' => 'post', 'include' => array( 0 ), @@ -3925,8 +3972,9 @@ public function test_author_slug_filter_only_names_users_the_current_user_may_se ) ); - $query = static function ( array $statuses, int $user_id ) { - return ( new WP_Abilities_Content() )->execute_content_query( + $execute = $this->get_ability_callbacks()['execute_callback']; + $query = static function ( array $statuses, int $user_id ) use ( $execute ) { + return $execute( array( 'type' => 'post', 'status' => $statuses, @@ -3971,8 +4019,9 @@ public function test_author_slug_filter_hides_users_of_other_sites(): void { switch_to_blog( $site_id ); wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); - $query = static function ( string $author_slug ) { - return ( new WP_Abilities_Content() )->execute_content_query( + $execute = $this->get_ability_callbacks()['execute_callback']; + $query = static function ( string $author_slug ) use ( $execute ) { + return $execute( array( 'type' => 'post', 'author_slug' => $author_slug, @@ -4004,13 +4053,14 @@ public function test_author_slug_filter_names_authors_who_are_not_site_members() $super_admin_id = self::factory()->user->create( array( 'user_nicename' => 'network-author' ) ); grant_super_admin( $super_admin_id ); $site_id = self::factory()->blog->create(); + $execute = $this->get_ability_callbacks()['execute_callback']; switch_to_blog( $site_id ); $post_id = self::factory()->post->create( array( 'post_author' => $super_admin_id ) ); wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); $is_member = is_user_member_of_blog( $super_admin_id, $site_id ); - $result = ( new WP_Abilities_Content() )->execute_content_query( + $result = $execute( array( 'type' => 'post', 'author_slug' => 'network-author', From 39c563558d29571d09257e14fb316e7ebfe63080 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 12 Oct 2026 15:47:32 +0100 Subject: [PATCH 69/69] Abilities API: Stop describing every other core ability as a closure. The `WP_Abilities_Content` and `WP_Abilities_Settings` docblocks said the other core abilities are self-contained closures registered directly in `wp_register_core_abilities()`. With both classes in Core, that is no longer true for either, so both now contrast the class with the core abilities that are registered there as closures. --- src/wp-includes/abilities/class-wp-abilities-content.php | 4 ++-- src/wp-includes/abilities/class-wp-abilities-settings.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-content.php b/src/wp-includes/abilities/class-wp-abilities-content.php index 89889ba0c8ef2..48f1c52eb319b 100644 --- a/src/wp-includes/abilities/class-wp-abilities-content.php +++ b/src/wp-includes/abilities/class-wp-abilities-content.php @@ -18,8 +18,8 @@ * by post type, status, author, parent, or included IDs. Raw fields are only returned for * posts the current user can edit. * - * Unlike the other core abilities, which are self-contained closures registered directly - * in wp_register_core_abilities(), the content ability lives in a dedicated class because + * Unlike the core abilities registered directly in wp_register_core_abilities() as + * self-contained closures, the content ability lives in a dedicated class because * its callbacks and schemas share helpers: the permission and execute callbacks resolve and * authorize the requested post through the same code, and the input schema, output schema, * and field projection are built from the same field definitions. Future write-oriented diff --git a/src/wp-includes/abilities/class-wp-abilities-settings.php b/src/wp-includes/abilities/class-wp-abilities-settings.php index f9afdcd195c9c..953eb92ef8ab4 100644 --- a/src/wp-includes/abilities/class-wp-abilities-settings.php +++ b/src/wp-includes/abilities/class-wp-abilities-settings.php @@ -16,8 +16,8 @@ * (exposed-settings discovery and schema generation) that are intended to also back a * future write-oriented `core/settings-update` ability. * - * Unlike the other core abilities, which are self-contained closures registered directly - * in wp_register_core_abilities(), the settings abilities live in a dedicated class + * Unlike the core abilities registered directly in wp_register_core_abilities() as + * self-contained closures, the settings abilities live in a dedicated class * because they share state: the set of exposed settings is computed once at registration * and reused by the input schema, the output schema, and the execute callback, and the * same helpers are meant to be shared with the future write ability.