From 939903c5938bf9368c1b802c0063a6a33558a8b8 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 17 Jun 2026 00:17:40 +0100 Subject: [PATCH 01/14] 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 | 13 + .../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, 1510 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 0eb87a4581589..5cd030d905940 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -9,6 +9,8 @@ declare( strict_types = 1 ); +require_once __DIR__ . '/abilities/class-wp-content-abilities.php'; + /** * Registers the core ability categories. * @@ -30,6 +32,14 @@ function wp_register_core_ability_categories(): void { 'description' => __( 'Abilities that retrieve or modify user information and settings.' ), ) ); + + wp_register_ability_category( + 'content', + array( + 'label' => __( 'Content' ), + 'description' => __( 'Abilities that retrieve or manage posts and other content.' ), + ) + ); } /** @@ -351,4 +361,7 @@ function wp_register_core_abilities(): void { ), ) ); + + // 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 b53a244d7de84..806c65d297a4c 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -371,6 +371,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. * @@ -551,6 +563,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 a1d887b45381f..1d551de161168 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', ) @@ -1709,6 +1711,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. * @@ -1754,6 +1757,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 a0227729180a713e27e88c480fcea7e91babd1d6 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 17 Jun 2026 15:49:11 +0100 Subject: [PATCH 02/14] 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 167d38d1b3d05b026ac96d8807f74a3523d5b974 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 16:33:12 +0100 Subject: [PATCH 03/14] 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 5cd030d905940..078169ade2ca3 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -363,5 +363,5 @@ function wp_register_core_abilities(): void { ); // 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 170d639ba5b32cd2f012d400498de3366009ffb7 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 16:51:52 +0100 Subject: [PATCH 04/14] 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 947f3444ab12f3231eb7acd0e34326dabbb0b575 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 19:44:10 +0100 Subject: [PATCH 05/14] 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 806c65d297a4c..4d53974ba49ca 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -374,7 +374,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 1d551de161168..e3f1337d300b8 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -1758,7 +1758,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 9f37ed39fdb4f3be833283f6a305011719ddd7fe Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 19:53:21 +0100 Subject: [PATCH 06/14] 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 09a751bf211f44637f36b6ccac0e31648be34e99 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 24 Jun 2026 09:38:02 +0100 Subject: [PATCH 07/14] 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 de90d550aa2079497f01f4d0021325c7983e7125 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 24 Jun 2026 15:52:23 +0100 Subject: [PATCH 08/14] 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 ef3155c9eaa05b8890c9b581731dbce7aa466133 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 24 Jun 2026 16:43:05 +0100 Subject: [PATCH 09/14] 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 19b31298a65e59f68fbaae5b83289f848f4f66b7 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 29 Jun 2026 17:27:04 +0100 Subject: [PATCH 10/14] 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 078169ade2ca3..ce9170f9d2cbe 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -362,6 +362,6 @@ function wp_register_core_abilities(): void { ) ); - // 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 4d53974ba49ca..80c22290e7fe1 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -375,7 +375,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 e3f1337d300b8..72aee52c10c55 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -1758,7 +1758,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 39aeee70f2c415128952ed4a8644bf0fc30a09ad Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 29 Jun 2026 17:31:01 +0100 Subject: [PATCH 11/14] 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 64755abd77f58e6f3698001f1e34a9a5c8a23784 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 29 Jun 2026 17:37:05 +0100 Subject: [PATCH 12/14] 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 8123116651936a1d4afcf32030afbd74483366f1 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 10 Jul 2026 17:00:36 +0100 Subject: [PATCH 13/14] 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 670cd55c5bac1cde271b5a10e36ca43967a3c46e Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 10 Jul 2026 17:17:12 +0100 Subject: [PATCH 14/14] 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 80c22290e7fe1..c77c60ac27e79 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -374,7 +374,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 72aee52c10c55..0325f587fd3fe 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -1758,7 +1758,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.