From 4201a7836f59f268d353086e034c03795bdda7f8 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 17 Jun 2026 00:17:40 +0100 Subject: [PATCH 1/9] 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 005ccadd62e34..5f3a702a6474b 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', ) @@ -1675,6 +1677,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. * @@ -1720,6 +1723,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 3cf49573f93bba467707eb8a19cfefcbb0a145ba Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 17 Jun 2026 15:49:11 +0100 Subject: [PATCH 2/9] 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 9db2536cb26bb0f6cd753e3a3bba0791ba9498d0 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 16:33:12 +0100 Subject: [PATCH 3/9] 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 f6fcf6a6948373e93e6262a4c7a180a17164e429 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 16:51:52 +0100 Subject: [PATCH 4/9] 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 aaa6a2e78ba2e46062d4ce52e737d8f6ccb84511 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 19:44:10 +0100 Subject: [PATCH 5/9] 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 5f3a702a6474b..90621086ae25b 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -1724,7 +1724,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 b8a9d2ad8ed4ff517c02296b282926ec325961a2 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 19:53:21 +0100 Subject: [PATCH 6/9] 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 0cf6c360b39f47060caa51461e5c8029583440fa Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 24 Jun 2026 09:38:02 +0100 Subject: [PATCH 7/9] 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 bc6eebb0808c0fc4a51dac963a0c5096fc089fcf Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 24 Jun 2026 15:52:23 +0100 Subject: [PATCH 8/9] 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 82274addfbf4251441e4625dddb2f82f52cff254 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 24 Jun 2026 16:43:05 +0100 Subject: [PATCH 9/9] 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.' ); } /*