diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 405323c2eff2a..a6a904181777f 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -9,6 +9,7 @@ declare( strict_types = 1 ); +require_once __DIR__ . '/abilities/class-wp-abilities-content.php'; require_once __DIR__ . '/abilities/class-wp-abilities-settings.php'; require_once __DIR__ . '/abilities/class-wp-abilities-users.php'; @@ -16,7 +17,7 @@ * Registers the core ability categories. * * @since 6.9.0 - * @since 7.2.0 Added the `uncategorized` category. + * @since 7.2.0 Added the `content` and `uncategorized` categories. */ function wp_register_core_ability_categories(): void { wp_register_ability_category( @@ -35,6 +36,14 @@ function wp_register_core_ability_categories(): void { ) ); + wp_register_ability_category( + 'content', + array( + 'label' => __( 'Content' ), + 'description' => __( 'Abilities that retrieve or manage posts and other content.' ), + ) + ); + wp_register_ability_category( 'uncategorized', array( @@ -48,7 +57,7 @@ function wp_register_core_ability_categories(): void { * Registers the default core abilities. * * @since 6.9.0 - * @since 7.2.0 Added the `core/settings-get` and `core/users-query` abilities. + * @since 7.2.0 Added the `core/content-query`, `core/settings-get`, and `core/users-query` abilities. * * @global wpdb $wpdb WordPress database abstraction object. */ @@ -370,4 +379,7 @@ function wp_register_core_abilities(): void { // Register the user abilities (currently the read-only `core/users-query`). ( new WP_Abilities_Users() )->register(); + + // Register the content abilities (currently the read-only `core/content-query`). + ( new WP_Abilities_Content() )->register(); } diff --git a/src/wp-includes/abilities/class-wp-abilities-content.php b/src/wp-includes/abilities/class-wp-abilities-content.php new file mode 100644 index 0000000000000..725301fd6a0f3 --- /dev/null +++ b/src/wp-includes/abilities/class-wp-abilities-content.php @@ -0,0 +1,1762 @@ +execute()`, which validates the input and + * checks permissions before running them. + * + * @since 7.2.0 + * + * @access private + */ +final class WP_Abilities_Content { + + /** + * The ability category used for content abilities. + * + * @since 7.2.0 + * @var string + */ + private const CATEGORY = 'content'; + + /** + * Default number of posts returned per page, unless `where` lists posts by ID or slug. + * + * @since 7.2.0 + * @var int + */ + private const DEFAULT_PER_PAGE = 10; + + /** + * Maximum number of posts returned per page. + * + * The `ids` and `slugs` lists in `where` each hold at most this many values, so the posts + * that `ids` lists fit on one page. + * + * @since 7.2.0 + * @var int + */ + private const MAX_PER_PAGE = 100; + + /** + * Field paths whose output may read post meta or terms. + * + * Requests that include any of these, directly or through their whole object, prime + * the post meta and term caches for the page. Rendered excerpts and content may read + * either, and permalinks read terms when the permalink structure contains `%category%`. + * Other fields, such as the rendered title, do not need that cache priming. + * + * @since 7.2.0 + * @var list + */ + private const CACHE_PRIMING_FIELDS = array( 'link', 'excerpt.rendered', 'content.rendered' ); + + /** + * Default field paths returned when the caller does not request a field subset. + * + * @since 7.2.0 + * @var list + */ + private const DEFAULT_FIELDS = array( 'id', 'type', 'status', 'date', 'slug', 'title.rendered' ); + + /** + * Globals that rendering a post changes: the global post and the globals that + * setup_postdata() populates. + * + * @since 7.2.0 + * @var list + */ + private const LOOP_GLOBALS = array( 'post', 'id', 'authordata', 'currentday', 'currentmonth', 'page', 'pages', 'multipage', 'more', 'numpages' ); + + /** + * Registers all content abilities. + * + * Must run on the `wp_abilities_api_init` hook. + * + * @since 7.2.0 + */ + public function register(): void { + $this->register_content_query(); + + /* + * Future write-oriented content abilities can be registered here, reusing the + * shared helpers below (get_exposed_post_type(), format_post(), check_permission()). + */ + } + + /** + * Registers the read-only `core/content-query` ability. + * + * @since 7.2.0 + */ + private function register_content_query(): void { + /* + * Post types must be registered with `show_in_abilities` before the ability is + * registered so they are included in its input schema. + */ + $post_types = array_values( get_post_types( array( 'show_in_abilities' => true ) ) ); + if ( empty( $post_types ) ) { + return; + } + + /* + * Internal statuses (e.g. `inherit`) are excluded here and from the statuses a query + * covers by default, so the posts of post types that rely on them (attachments) + * cannot be queried, not even by ID. Revisit if such a post type is ever exposed via + * `show_in_abilities`. + */ + $statuses = array_values( get_post_stati( array( 'internal' => false ) ) ); + + wp_register_ability( + 'core/content-query', + array( + 'label' => __( 'Query Content' ), + 'description' => __( 'Retrieves a paginated list of the readable posts of a post type exposed to abilities, filtered by any of post IDs, slugs, status, author, and parent. Requires an authenticated user. Filters are exact-match only; the ability does not perform full-text search.' ), + 'category' => self::CATEGORY, + 'input_schema' => $this->get_content_query_input_schema( $post_types, $statuses ), + 'output_schema' => $this->get_content_query_output_schema(), + 'execute_callback' => function ( $input = array() ) { + return $this->execute_content_query( $input ); + }, + 'permission_callback' => function ( $input = array() ): bool { + return $this->check_permission( $input ); + }, + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'public' => true, + ), + ) + ); + } + + /** + * Permission callback for the `core/content-query` ability. + * + * The request is only gated as a whole here: it needs a logged-in user, a post type + * exposed to abilities, and the capabilities to query the explicitly requested statuses. + * It reads no post. {@see self::execute_content_query()} enforces row-level read + * permissions, since individual rows are unknown until the query runs. + * + * @since 7.2.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return bool True if the request may proceed, false otherwise. + */ + private function check_permission( $input = array() ): bool { + $input = rest_sanitize_object( $input ); + + if ( ! is_user_logged_in() ) { + return false; + } + + /* + * A `where` that is not an object is let through, so that the execute callback can + * report it as an invalid filter. + */ + $where = $this->get_input_object( $input, 'where' ); + if ( null === $where ) { + return true; + } + + // A query requires an exposed post type. + $post_type_object = $this->get_exposed_post_type( $where['type'] ?? null ); + if ( ! $post_type_object ) { + return false; + } + + return $this->can_query_statuses( $where, $post_type_object ); + } + + /** + * Parses a raw input value into an integer of at least a minimum, or null when invalid. + * + * Accepts the whole numbers that the integer schema accepts: native integers, floats such + * as the 2.0 that JSON encoders produce, and integer strings such as "2", "2.0", or "+2". + * Only the REST run controller converts input to the schema types, so other callers, such + * as the MCP adapter or a direct WP_Ability::execute() call, pass them on unconverted. + * Anything else, such as a fraction, is rejected rather than coerced: a malformed ID would + * otherwise resolve to another post, and a malformed `parent` filter would be read as 0, + * which asks for top-level posts. + * + * @since 7.2.0 + * + * @param mixed $value The raw input value. + * @param int $min The smallest acceptable value. + * @return int|null The parsed integer, or null when the value is not an integer >= $min. + */ + private function parse_filter_int( $value, int $min ): ?int { + if ( is_int( $value ) ) { + return $value >= $min ? $value : null; + } + + if ( is_string( $value ) && '' !== $value && ctype_digit( $value ) ) { + $int = (int) $value; + + return $int >= $min ? $int : null; + } + + if ( ! rest_is_integer( $value ) ) { + return null; + } + + /* + * Other whole numbers are read as floats, which hold every integer only up to 2 ** 53. + * Past that, the value may not be the one that was sent, and casting a float beyond the + * integer range wraps it to an arbitrary integer, such as the ID of another post. + */ + $number = (float) $value; + + return $number >= $min && $number <= 2 ** 53 ? (int) $number : null; + } + + /** + * Parses a raw list input into a list of strings. + * + * Like schema validation, it also accepts a scalar or a comma-separated string, and + * parses it the same way, with wp_parse_list(). A number, such as a slug sent as 2024, + * is read as its string form. + * + * @since 7.2.0 + * + * @param array $input The ability input, or one of its objects, such as `where`. + * @param string $key The input key holding the list. + * @return list The parsed string values; empty when absent or unparseable. + */ + private function parse_list_input( array $input, string $key ): array { + $value = $input[ $key ] ?? null; + if ( is_scalar( $value ) ) { + $value = (string) $value; + } elseif ( ! is_array( $value ) ) { + return array(); + } + + return array_values( array_filter( wp_parse_list( $value ), 'is_string' ) ); + } + + /** + * Checks whether the current user may query the explicitly requested statuses. + * + * The statuses a query covers by default, see {@see self::normalize_statuses()}, only + * hold statuses the current user may query, so they need no check. + * + * @since 7.2.0 + * + * @param array $where The `where` filters. + * @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 $where, WP_Post_Type $post_type_object ): bool { + foreach ( $this->get_requested_statuses( $where ) as $status ) { + if ( ! $this->can_query_status( $status, $post_type_object ) ) { + return false; + } + } + + return true; + } + + /** + * Checks whether the current user may query posts in a status. + * + * Like the REST posts controller, any user may query `publish`, while other statuses, such + * as `draft`, require edit access. Any user may also query the other publicly viewable + * statuses, and `private`, since the query only covers the private posts the user can + * read, see {@see self::execute_content_query()}. + * + * @since 7.2.0 + * + * @param string $status The post status slug. + * @param WP_Post_Type $post_type_object The post type object. + * @return bool True if the status may be queried. + */ + private function can_query_status( string $status, WP_Post_Type $post_type_object ): bool { + return in_array( $status, array( 'publish', 'private' ), true ) || is_post_status_viewable( $status ) || current_user_can( $post_type_object->cap->edit_posts ); + } + + /** + * 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.2.0 + * + * @param WP_Post $post Post object. + * @param array $checked_post_ids Post IDs already checked while walking inherited parents. + * @return bool Whether the post can be read. + */ + private function check_read_permission( WP_Post $post, array $checked_post_ids = array() ): bool { + if ( isset( $checked_post_ids[ $post->ID ] ) ) { + return false; + } + + $checked_post_ids[ $post->ID ] = true; + + if ( ! $this->get_exposed_post_type( $post->post_type ) ) { + return false; + } + + /* + * Treat publicly viewable posts as readable. This checks both the post type + * and post status using Core's viewability helpers, which is stricter than + * checking the status object's `public` flag alone. + */ + if ( is_post_publicly_viewable( $post ) ) { + return true; + } + + /* + * Use the normalized status for the status object lookup. For attachments, + * get_post_status() resolves `inherit` through the parent before returning. + */ + $post_status = get_post_status( $post ); + if ( ! is_string( $post_status ) ) { + return false; + } + + $post_status_object = get_post_status_object( $post_status ); + if ( ! $post_status_object instanceof stdClass ) { + return false; + } + + /* + * Core maps `read_post` for public statuses to the post type's plain `read` + * capability. Publicly viewable posts already returned above, so a remaining + * public status is public but not viewable and should require edit access. + */ + if ( $post_status_object->public ) { + return current_user_can( 'edit_post', $post->ID ); + } + + /* + * For non-public statuses, defer to Core's meta-capability mapping. This + * handles own drafts, private posts, and statuses that require edit access. + */ + if ( current_user_can( 'read_post', $post->ID ) ) { + return true; + } + + /* + * Mirror the REST posts controller's inherited-parent behavior, but keep the + * ability fail-closed for missing parents or parent loops. No input can request + * `inherit`, since internal statuses are left out of the input schema and of the + * statuses a query covers by default, but a query filter such as a `pre_get_posts` + * callback can still add it, so such posts keep their parent's read check. + */ + if ( 'inherit' === $post->post_status && $post->post_parent > 0 ) { + $parent = get_post( $post->post_parent ); + if ( $parent instanceof WP_Post ) { + return $this->check_read_permission( $parent, $checked_post_ids ); + } + } + + return false; + } + + /** + * Executes the `core/content-query` ability. + * + * {@see WP_Ability::execute()} always runs {@see self::check_permission()} first, which + * gates the request as a whole. The query still filters every row by read permission, + * because the gate cannot know the rows before the query runs. + * + * @since 7.2.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return array|WP_Error A page of posts, or a WP_Error on failure. + */ + private function execute_content_query( $input = array() ) { + $input = rest_sanitize_object( $input ); + $fields = $this->normalize_fields( $input ); + + /* + * A `where` that is not an object cannot be honored. Reject it rather than reading it + * as no filter at all. + */ + $where = $this->get_input_object( $input, 'where' ); + if ( null === $where ) { + return $this->invalid_filter_error( 'where', __( 'The where filter must be an object.' ) ); + } + + // Pagination that is not an object falls back to the defaults, like page values that cannot be parsed. + $pagination = $this->get_input_object( $input, 'pagination' ) ?? array(); + $page = $this->parse_filter_int( $pagination['page'] ?? 1, 1 ) ?? 1; + + /* + * Unreachable through gated transports, which run check_permission() first and deny a + * request without a post type exposed to abilities. A direct call to the execute + * callback still fails closed here, but it bypasses the checks of the request as a + * whole that the permission callback performed, such as the status capabilities, so + * only invoke the callback through WP_Ability::execute(). + */ + $post_type_object = $this->get_exposed_post_type( $where['type'] ?? null ); + if ( ! $post_type_object ) { + return new WP_Error( + 'content_not_found', + __( 'The requested content was not found.' ), + array( 'status' => 404 ) + ); + } + + $post_type = $post_type_object->name; + + /* + * REST only registers the equivalent collection filters for post types that + * support them; a shared input schema cannot express that per post type. On + * transports that skip schema validation a malformed value would otherwise + * coerce to a benign default and silently *widen* the query (`author => 0` + * drops the author filter, an empty `post__in` or `post_name__in` is ignored, + * `post_parent => 0` becomes a top-level query). Reject unsupported filters and + * invalid filter values loudly so a filter that cannot be honored fails closed + * instead. + */ + $parent = null; + if ( isset( $where['parent'] ) ) { + if ( ! is_post_type_hierarchical( $post_type ) ) { + return $this->invalid_filter_error( 'where[parent]', __( 'The parent filter is only supported for hierarchical post types.' ) ); + } + + $parent = $this->parse_filter_int( $where['parent'], 0 ); + if ( null === $parent ) { + return $this->invalid_filter_error( 'where[parent]', __( 'The parent filter must be a non-negative integer.' ) ); + } + } + + $author = null; + if ( isset( $where['author_slug'] ) ) { + if ( ! post_type_supports( $post_type, 'author' ) ) { + return $this->invalid_filter_error( + 'where[author_slug]', + /* translators: %s: Parameter. */ + sprintf( __( 'The %s filter is only supported for post types that support authors.' ), 'author_slug' ) + ); + } + + $author = $this->get_author_by_slug( $where['author_slug'], $post_type_object ); + if ( ! $author ) { + return $this->invalid_filter_error( + 'where[author_slug]', + /* translators: %s: Parameter. */ + sprintf( __( 'The %s filter must be the slug of an existing user.' ), 'author_slug' ) + ); + } + } + + /* + * An `ids` or `slugs` filter that was given but kept no valid entry must not fall + * through to an unrestricted query: WP_Query ignores an empty `post__in` or + * `post_name__in`, which would return every post of the type, the opposite of the + * caller's intent. + */ + $ids = null; + if ( isset( $where['ids'] ) ) { + $ids = $this->normalize_ids( $where ); + if ( empty( $ids ) ) { + return $this->invalid_filter_error( + 'where[ids]', + /* translators: %s: Parameter. */ + sprintf( __( 'The %s filter must list one or more valid post IDs.' ), 'ids' ) + ); + } + } + + $slugs = null; + if ( isset( $where['slugs'] ) ) { + $slugs = $this->normalize_slugs( $where ); + if ( empty( $slugs ) ) { + return $this->invalid_filter_error( + 'where[slugs]', + /* translators: %s: Parameter. */ + sprintf( __( 'The %s filter must list one or more valid post slugs.' ), 'slugs' ) + ); + } + } + + /* + * A `status` filter that was given but kept no status must not fall through to the + * default statuses, which with `ids` or `slugs` cover every status the current user may + * query, wider than the caller asked for. + */ + $statuses = $this->get_requested_statuses( $where ); + if ( isset( $where['status'] ) && empty( $statuses ) ) { + return $this->invalid_filter_error( + 'where[status]', + /* translators: %s: Parameter. */ + sprintf( __( 'The %s filter must list one or more post statuses.' ), 'status' ) + ); + } + + /* + * The input schema lists the statuses registered when the ability was, so check them + * again: WP_Query ignores a status that is not registered, and drops the status clause + * when none is left, which would cover every status, including internal ones such as + * `trash`. + */ + if ( ! empty( array_diff( $statuses, get_post_stati( array( 'internal' => false ) ) ) ) ) { + return $this->invalid_filter_error( + 'where[status]', + /* translators: %s: Parameter. */ + sprintf( __( 'The %s filter must list valid post statuses.' ), 'status' ) + ); + } + + $lists_posts = null !== $ids || null !== $slugs; + $per_page = $this->normalize_per_page( $pagination, $lists_posts ); + + $prime_post_caches = $this->should_prime_post_caches( $fields ); + + /* + * `orderby` is left unset, which orders by `post_date` descending, matching the + * default of the REST posts controller. + * + * `perm` leaves out the other users' private posts for a user who cannot read them, + * so the totals do not count them. It is not set for a user who can: when a query + * filter adds post types, WP_Query checks `read_private_multiple_post_types` + * instead, which no role has, and would leave the posts out too. + */ + $query_args = array( + 'post_type' => $post_type, + 'post_status' => $this->normalize_statuses( $statuses, $post_type_object, $lists_posts ), + 'posts_per_page' => $per_page, + 'paged' => $page, + 'perm' => current_user_can( $post_type_object->cap->read_private_posts ) ? '' : 'readable', + 'ignore_sticky_posts' => true, + 'update_post_meta_cache' => $prime_post_caches, + 'update_post_term_cache' => $prime_post_caches, + ); + + if ( null !== $ids ) { + $query_args['post__in'] = $ids; + } + + if ( null !== $slugs ) { + $query_args['post_name__in'] = $slugs; + } + + if ( null !== $author ) { + $query_args['author'] = $author->ID; + } + + if ( null !== $parent ) { + $query_args['post_parent'] = $parent; + } + + $query = new WP_Query( $query_args ); + $total = $this->get_query_total( $query, $query_args, $page ); + + /* + * Count the pages with the page size the query ran with, as the REST posts controller + * does, since query filters such as `pre_get_posts` callbacks can change it. Without + * paging, the query returns every post on one page. + */ + $query_per_page = (int) $query->get( 'posts_per_page' ); + if ( $query->get( 'nopaging' ) || $query_per_page < 1 ) { + $total_pages = $total > 0 ? 1 : 0; + } else { + $total_pages = (int) ceil( $total / $query_per_page ); + } + + /* + * A page past the last one does not exist, so report it as not found instead of + * returning a bare empty list. A genuinely empty result set still returns zero + * totals and no error. + */ + if ( $total > 0 && $page > $total_pages ) { + return $this->invalid_page_number_error(); + } + + /* + * Prime the parent and author caches with a single query each instead of one + * lookup per post, as the REST posts controller does. Hierarchical permalinks and + * inherited read permissions read the parent, and format_post() sets up every post + * with setup_postdata(), which reads the author. + */ + update_post_parent_caches( $query->posts ); + update_post_author_caches( $query->posts ); + + $posts = array(); + foreach ( $query->posts as $post ) { + /* + * Skip posts of other post types, which query filters can add, such as a + * `pre_get_posts` callback that adds post types to the blog home without an + * is_main_query() check. + */ + if ( ! $post instanceof WP_Post || $post_type !== $post->post_type ) { + continue; + } + if ( ! $this->check_read_permission( $post ) ) { + continue; + } + $posts[] = $this->format_post( $post, $fields ); + } + + /* + * Mirror the REST posts controller: totals come from the underlying WP_Query, + * while the row-level checks above may withhold individual returned rows. The + * totals can therefore count posts the current user cannot read, such as another + * author's draft listed in `ids`. This is deliberate: it only reveals that such a + * post exists, as a REST collection request with `include` and `status` does. + */ + return $this->prepare_output( $posts, $total, $total_pages ); + } + + /** + * Reads an object member of the input, such as `where` or `pagination`. + * + * Accepts what schema validation reads as an object: an array, a `stdClass`, or an + * empty string. Only the REST run controller converts input to the schema types, so + * callers that bypass it, such as a direct WP_Ability::execute() call, can pass any of them. + * + * @since 7.2.0 + * + * @param array $input The ability input. + * @param string $key The member name. + * @return array|null The member as an array, an empty array when it is omitted, + * or null when it is not an object. + */ + private function get_input_object( array $input, string $key ): ?array { + if ( ! array_key_exists( $key, $input ) ) { + return array(); + } + + return rest_is_object( $input[ $key ] ) ? rest_sanitize_object( $input[ $key ] ) : null; + } + + /** + * Normalizes the requested per-page value to the supported bounds. + * + * An explicit `per_page` always wins. Otherwise a request that lists posts by ID or slug + * pages to the maximum, as `core/users-query` does for its lists, so a caller loading a + * known set of posts receives them in one call rather than silently losing the ones past + * the default page size. The input schema caps each list at {@see self::MAX_PER_PAGE}, + * so every post that `ids` lists fits on the first page. A slug can match several posts, + * though, so the matches of `slugs` only continue past the first page when more than + * that many posts match. + * + * @since 7.2.0 + * + * @param array $pagination The `pagination` input. + * @param bool $lists_posts Whether the query lists `ids` or `slugs`. + * @return int The clamped per-page value. + */ + private function normalize_per_page( array $pagination, bool $lists_posts ): int { + $default_per_page = $lists_posts ? self::MAX_PER_PAGE : self::DEFAULT_PER_PAGE; + + return min( self::MAX_PER_PAGE, $this->parse_filter_int( $pagination['per_page'] ?? null, 1 ) ?? $default_per_page ); + } + + /** + * Returns the query total, recovering it when WP_Query skipped the count. + * + * WP_Query leaves `found_posts` at 0 when a requested page has no rows. Re-run a + * minimal unpaged query so the caller can distinguish an out-of-range page from + * an empty result set, matching the REST posts controller behavior. + * + * @since 7.2.0 + * + * @param WP_Query $query The executed query. + * @param array $query_args The arguments used for the executed query. + * @param int $page The requested page. + * @return int Total matching rows across all pages. + */ + private function get_query_total( WP_Query $query, array $query_args, int $page ): int { + $total = (int) $query->found_posts; + + if ( $total > 0 || $page <= 1 ) { + return $total; + } + + $count_args = $query_args; + $count_args['fields'] = 'ids'; + $count_args['posts_per_page'] = 1; + $count_args['update_post_meta_cache'] = false; + $count_args['update_post_term_cache'] = false; + unset( $count_args['paged'] ); + + $count_query = new WP_Query( $count_args ); + + return (int) $count_query->found_posts; + } + + /** + * Checks whether requested fields benefit from page-level cache priming. + * + * A whole object counts as a request for its sub-fields, so `content` primes the + * caches as `content.rendered` does. + * + * @since 7.2.0 + * + * @param list $fields The requested field paths. + * @return bool True when post meta and term caches should be primed. + */ + private function should_prime_post_caches( array $fields ): bool { + foreach ( self::CACHE_PRIMING_FIELDS as $field ) { + if ( $this->is_field_requested( $field, $fields ) ) { + return true; + } + } + + return false; + } + + /** + * Checks whether a field path is requested. + * + * Matches paths as the REST API's `_fields` parameter does, with rest_is_field_included(): + * a field such as `title` is requested when it or one of its paths, such as + * `title.rendered`, is requested, and a path such as `title.rendered` is requested when it + * or its whole field is. + * + * @since 7.2.0 + * + * @param string $path The field path, such as `title` or `title.rendered`. + * @param list $fields The requested field paths. + * @return bool Whether the field path is requested. + */ + private function is_field_requested( string $path, array $fields ): bool { + return rest_is_field_included( $path, $fields ); + } + + /** + * Looks up the user an author slug names. + * + * The slug is the user's nicename, which the REST API users endpoint returns as `slug`. It is + * looked up as given, like that endpoint's `slug` filter, so the database collation decides + * whether a variant in another case matches. A user the current user may not see is reported + * like a missing one. The current user can see themselves, any user of the site when they can + * list users, and authors with posts in a publicly viewable post type. A user who can edit + * others' posts of the post type can also see any user of the site, since they may make any of + * them the author, so for them the lookup does tell whether such an account exists. + * + * On multisite the lookup searches the whole network, so posts by authors who are not + * members of the site, such as super admins, can still be filtered. Those users are only + * visible as the current user or as authors with posts in a publicly viewable post type, so + * the lookup does not tell whether an account exists elsewhere on the network. + * + * @since 7.2.0 + * + * @param mixed $slug The author slug. + * @param WP_Post_Type $post_type_object The post type the author is looked up for. + * @return WP_User|null The user, or null when the slug does not name a visible user. + */ + private function get_author_by_slug( $slug, WP_Post_Type $post_type_object ): ?WP_User { + $user = is_string( $slug ) ? get_user_by( 'slug', $slug ) : false; + if ( ! $user ) { + return null; + } + + if ( get_current_user_id() === $user->ID ) { + return $user; + } + + // The capabilities only reveal users of the site, not of the whole network. + if ( is_user_member_of_blog( $user->ID ) + && ( current_user_can( 'list_users' ) || current_user_can( $post_type_object->cap->edit_others_posts ) ) + ) { + return $user; + } + + $public_post_types = array_values( array_filter( get_post_types(), 'is_post_type_viewable' ) ); + + return ! empty( $public_post_types ) && count_user_posts( $user->ID, $public_post_types ) > 0 ? $user : null; + } + + /** + * Returns a post type exposed through the Abilities API. + * + * Deliberately resolved on every call rather than cached: post types can be + * unregistered or re-registered with different arguments between the ability + * being registered and the ability being used. + * + * @since 7.2.0 + * + * @param mixed $post_type The post type name. + * @return WP_Post_Type|null The post type object, or null when the post type is not exposed. + */ + private function get_exposed_post_type( $post_type ): ?WP_Post_Type { + $post_type_object = is_string( $post_type ) ? get_post_type_object( $post_type ) : null; + + return empty( $post_type_object->show_in_abilities ) ? null : $post_type_object; + } + + /** + * Returns the statuses the `status` filter explicitly requests. + * + * @since 7.2.0 + * + * @param array $where The `where` filters. + * @return list Sanitized post status slugs; empty when none is requested. + */ + private function get_requested_statuses( array $where ): array { + return array_map( 'sanitize_key', $this->parse_list_input( $where, 'status' ) ); + } + + /** + * Normalizes the statuses a query covers. + * + * Explicitly requested statuses are queried as given, once the permission callback has + * allowed them. Otherwise a query covers `publish`, as in the REST posts controller, + * unless it lists `ids` or `slugs`. Such a query names the posts it wants, which may be + * drafts or private posts, so it covers every non-internal status that + * {@see self::can_query_status()} allows instead. The row-level read checks still + * withhold the posts the current user cannot read, such as other users' drafts. + * + * @since 7.2.0 + * + * @param list $statuses The explicitly requested statuses; empty when none is requested. + * @param WP_Post_Type $post_type_object The post type object. + * @param bool $lists_posts Whether the query lists `ids` or `slugs`. + * @return list Post status slugs. + */ + private function normalize_statuses( array $statuses, WP_Post_Type $post_type_object, bool $lists_posts ): array { + if ( ! empty( $statuses ) ) { + return $statuses; + } + + if ( ! $lists_posts ) { + return array( 'publish' ); + } + + foreach ( get_post_stati( array( 'internal' => false ) ) as $status ) { + if ( $this->can_query_status( $status, $post_type_object ) ) { + $statuses[] = $status; + } + } + + return $statuses; + } + + /** + * Normalizes the post IDs the `ids` filter lists. + * + * Like schema validation, it also accepts a single ID or a comma-separated string. Each + * ID is read with {@see self::parse_filter_int()}, and one that is not a whole number of + * at least 1, such as a fraction, a negative number, or a float too large to hold its + * integer exactly, is dropped rather than coerced, so it can never select another post. + * A digit string beyond the integer range saturates to PHP_INT_MAX, as REST sanitization + * does, which selects no post either. + * + * @since 7.2.0 + * + * @param array $where The `where` filters. + * @return list Unique post IDs; empty when none is valid. + */ + private function normalize_ids( array $where ): array { + $ids = $where['ids'] ?? null; + if ( is_string( $ids ) ) { + $ids = wp_parse_list( $ids ); + } elseif ( is_int( $ids ) || is_float( $ids ) ) { + $ids = array( $ids ); + } elseif ( ! is_array( $ids ) ) { + return array(); + } + + $post_ids = array(); + foreach ( $ids as $id ) { + $post_id = $this->parse_filter_int( $id, 1 ); + if ( null !== $post_id ) { + $post_ids[] = $post_id; + } + } + + return array_values( array_unique( $post_ids ) ); + } + + /** + * Normalizes the post slugs the `slugs` filter lists. + * + * Like schema validation, it also accepts a single slug, even one sent as a number, or a + * comma-separated string. Each slug is sanitized with sanitize_title(), as WordPress + * sanitizes a post slug when it saves the post, so `Hello-World` matches `hello-world`. + * A slug that sanitizes to an empty string, such as `!!!`, is dropped: it names no post, + * and querying it would match the posts that have no slug yet, such as drafts. + * + * @since 7.2.0 + * + * @param array $where The `where` filters. + * @return list Unique post slugs; empty when none is valid. + */ + private function normalize_slugs( array $where ): array { + $slugs = array(); + foreach ( $this->parse_list_input( $where, 'slugs' ) as $slug ) { + $slug = sanitize_title( $slug ); + if ( '' !== $slug ) { + $slugs[] = $slug; + } + } + + return array_values( array_unique( $slugs ) ); + } + + /** + * Returns the requested field paths, or a lean default set when none are given. + * + * An empty or absent `fields` value selects a lean set of common read fields. + * Otherwise the requested paths are returned. The input schema has already + * validated them against the supported set before the ability executes, and + * {@see self::build_post_fields()} ignores any other path. + * + * @since 7.2.0 + * + * @param array $input The ability input. + * @return list List of requested field paths. + */ + private function normalize_fields( array $input ): array { + $fields = $this->parse_list_input( $input, 'fields' ); + + return empty( $fields ) ? self::DEFAULT_FIELDS : $fields; + } + + /** + * Returns the post field definitions, keyed by field name in output order. + * + * This is the single source of truth for the ability's post fields: the output + * schema uses the definitions directly, while the input schema `fields` enum lists + * each field, followed by the path of each of its sub-fields, such as `title.rendered`. + * Read-context fields are returned for readable posts; the `raw` sub-fields only for + * posts the current user can edit. + * + * @since 7.2.0 + * + * @return array Post field definitions. + */ + private function get_post_properties(): array { + return 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' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'local' => array( + 'type' => array( 'string', 'null' ), + 'format' => 'date-time', + 'description' => __( "The publication date, in ISO 8601 format using the site's timezone. Null when the date cannot be resolved." ), + ), + 'gmt' => array( + 'type' => array( 'string', 'null' ), + 'format' => 'date-time', + 'description' => __( 'The publication date, in ISO 8601 format as GMT. Null when the date cannot be resolved.' ), + ), + ), + 'description' => __( 'The publication date.' ), + ), + 'modified' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'local' => array( + 'type' => array( 'string', 'null' ), + 'format' => 'date-time', + 'description' => __( "The last modified date, in ISO 8601 format using the site's timezone. Null when the date cannot be resolved." ), + ), + 'gmt' => array( + 'type' => array( 'string', 'null' ), + 'format' => 'date-time', + 'description' => __( 'The last modified date, in ISO 8601 format as GMT. Null when the date cannot be resolved.' ), + ), + ), + 'description' => __( 'The last modified date.' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'The post slug.' ), + ), + 'link' => array( + 'type' => 'string', + 'description' => __( 'The permalink URL.' ), + ), + 'title' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'raw' => array( + 'type' => 'string', + 'description' => __( 'The raw post title. Present when the current user can edit the post.' ), + ), + 'rendered' => array( + 'type' => 'string', + 'description' => __( 'The rendered post title.' ), + ), + ), + 'description' => __( 'The post title. Present when the post type supports titles.' ), + ), + 'excerpt' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'raw' => array( + 'type' => 'string', + 'description' => __( 'The raw post excerpt. Present when the current user can edit the post.' ), + ), + 'rendered' => array( + 'type' => 'string', + 'description' => __( 'The rendered post excerpt (HTML). Empty when withheld for a password-protected post.' ), + ), + 'protected' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the excerpt is protected with a password.' ), + ), + ), + 'description' => __( 'The post excerpt. Present when the post type supports excerpts.' ), + ), + 'content' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'raw' => array( + 'type' => 'string', + 'description' => __( 'The raw, unfiltered post content (block markup). Present when the current user can edit the post.' ), + ), + 'rendered' => array( + 'type' => 'string', + 'description' => __( 'The rendered post content. Empty when withheld for a password-protected post.' ), + ), + 'protected' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the content is protected with a password.' ), + ), + ), + 'description' => __( 'The post content. Present when the post type supports the editor.' ), + ), + 'author' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The user ID of the author.' ), + ), + 'name' => array( + 'type' => array( 'string', 'null' ), + 'description' => __( "The author's display name, as the REST API users endpoint returns it in `name`. Null when the author no longer exists." ), + ), + 'slug' => array( + 'type' => array( 'string', 'null' ), + 'description' => __( "The author's user slug (nicename), as the REST API users endpoint returns it in `slug`, and as the `author_slug` filter takes it. Null when the author no longer exists." ), + ), + ), + '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.' ), + ), + ); + } + + /** + * Returns the field paths the `fields` input accepts, in output order. + * + * Each post field is followed by the path of each of its sub-fields, such as `title`, + * `title.raw` and `title.rendered`. Requesting a field returns all of its sub-fields + * that apply, while a sub-field path returns only that sub-field, as the REST API's + * `_fields` parameter does. + * + * @since 7.2.0 + * + * @return list The field paths. + */ + private function get_field_paths(): array { + $paths = array(); + foreach ( $this->get_post_properties() as $name => $schema ) { + $paths[] = $name; + + foreach ( array_keys( $schema['properties'] ?? array() ) as $sub_field ) { + $paths[] = "{$name}.{$sub_field}"; + } + } + + return $paths; + } + + /** + * Builds the schema of the `fields` input, shared by all content abilities. + * + * @since 7.2.0 + * + * @return array The `fields` JSON Schema. + */ + private function get_fields_input_schema(): array { + return array( + 'type' => 'array', + 'uniqueItems' => true, + 'items' => array( + 'type' => 'string', + 'enum' => $this->get_field_paths(), + ), + 'description' => sprintf( + /* translators: %s: Comma-separated list of field paths. */ + __( 'Limit each returned post to these fields. A field such as `title` returns all of its sub-fields the current user can see, while a path such as `title.rendered` returns only that sub-field. The `id` is always included. If omitted or empty, the default fields are returned: %s. Requested fields that do not apply to a post are left out of it: raw sub-fields, such as `content.raw`, when the current user cannot edit the post, and fields its post type does not support, such as `parent` for a post type that is not hierarchical. A returned post may therefore hold only its `id`. Password-protected posts differ: when the current user cannot edit one, its `excerpt.rendered` and `content.rendered` are returned as empty strings rather than left out, with `protected` set to true.' ), + '`' . implode( '`, `', self::DEFAULT_FIELDS ) . '`' + ), + ); + } + + /** + * Builds the input schema for the `core/content-query` ability. + * + * The input has three members: `where` selects the posts, `fields` selects the fields + * returned for each post, and `pagination` selects the page of results. `where` holds + * the post type to query in `type`, and any of the filters `ids`, `slugs`, `status`, + * `author_slug`, and `parent`, which all combine. Every object sets + * `additionalProperties: false`, so an unknown key, such as a misspelled filter, fails + * validation instead of being silently ignored. + * + * @since 7.2.0 + * + * @param list $post_types Exposed post type names. + * @param list $statuses Requestable post status slugs. + * @return array The input JSON Schema. + */ + private function get_content_query_input_schema( array $post_types, array $statuses ): array { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'where' ), + 'properties' => array( + 'where' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'type' ), + 'properties' => array( + 'type' => array( + 'type' => 'string', + 'enum' => $post_types, + 'description' => __( 'Post type to query for readable posts.' ), + ), + 'ids' => array( + 'type' => 'array', + 'uniqueItems' => true, + 'minItems' => 1, + 'maxItems' => self::MAX_PER_PAGE, + 'items' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'description' => __( 'Limit the query to these post IDs. The order of the IDs does not affect the order of the results. If `pagination.per_page` is omitted, every matching post is returned on the first page.' ), + ), + 'slugs' => array( + 'type' => 'array', + 'uniqueItems' => true, + 'minItems' => 1, + 'maxItems' => self::MAX_PER_PAGE, + 'items' => array( + 'type' => 'string', + 'minLength' => 1, + ), + 'description' => __( 'Limit the query to posts with these slugs. Each slug is sanitized as a post slug is, so `Hello-World` matches `hello-world`. A slug can match several posts, such as pages under different parents, and every match is returned. The order of the slugs does not affect the order of the results. If `pagination.per_page` is omitted, every matching post is returned on the first page, up to the `per_page` maximum.' ), + ), + 'status' => array( + 'type' => 'array', + 'uniqueItems' => true, + 'minItems' => 1, + 'items' => array( + 'type' => 'string', + 'enum' => $statuses, + ), + 'description' => __( 'Filter readable posts by one or more post statuses. Defaults to publish, or, when `ids` or `slugs` is given, to every status the current user may query. Statuses that are not publicly viewable, other than private, require edit access.' ), + ), + 'author_slug' => array( + 'type' => 'string', + 'minLength' => 1, + 'description' => __( "Filter by the author's user slug (nicename), as the REST API users endpoint returns it in `slug`. Only supported for post types that support authors." ), + ), + 'parent' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Filter by parent post ID. Only supported for hierarchical post types. Use 0 for top-level posts.' ), + ), + ), + 'description' => __( 'Selects the posts to return: the readable posts of a `type`, filtered by any of `ids`, `slugs`, `status`, `author_slug`, and `parent`. A post must match every given filter.' ), + ), + 'fields' => $this->get_fields_input_schema(), + 'pagination' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Page of results to return. Defaults to 1. Requesting a page beyond the last one is an error, unless no post matches the query: then any page returns empty `items` and zero totals. Check `pagination.total_pages` in the result before requesting later pages.' ), + ), + 'per_page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => self::MAX_PER_PAGE, + 'description' => sprintf( + /* translators: 1: Default number of posts per page, 2: Maximum number of posts per page. */ + __( 'Maximum number of posts to return per page. Defaults to %1$d, or to %2$d when `where.ids` or `where.slugs` is given.' ), + self::DEFAULT_PER_PAGE, + self::MAX_PER_PAGE + ), + ), + ), + 'description' => __( 'Selects the page of results to return.' ), + ), + ), + ); + } + + /** + * Builds the output schema of a single post, shared by all content abilities. + * + * Only `id` is required, because it is always returned. The other fields are optional + * because the `fields` input lets the caller request any subset, and a field is only + * present when its post type supports it. + * + * @since 7.2.0 + * + * @return array The post JSON Schema. + */ + private function get_content_output_schema(): array { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'id' ), + 'properties' => $this->get_post_properties(), + ); + } + + /** + * Builds the output schema for the `core/content-query` ability. + * + * Every request returns the same shape: a page of posts in `items`, and the totals + * across all pages in `pagination`. + * + * @since 7.2.0 + * + * @return array The output JSON Schema. + */ + private function get_content_query_output_schema(): array { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'items', 'pagination' ), + 'properties' => array( + 'items' => array( + 'type' => 'array', + 'description' => __( 'The readable posts on the requested page, ordered by post date, newest first. Can be empty even when `pagination.total` is not zero, because the totals can count matching posts the current user cannot read.' ), + 'items' => $this->get_content_output_schema(), + ), + 'pagination' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'total', 'total_pages' ), + 'properties' => array( + 'total' => array( + 'type' => 'integer', + 'description' => __( 'Total number of posts matching the underlying query, across all pages. May exceed the number of returned posts when row-level permission checks withhold some of them.' ), + ), + 'total_pages' => array( + 'type' => 'integer', + 'description' => __( 'Total number of query result pages available for the underlying query. May include pages whose rows are withheld by row-level permission checks.' ), + ), + ), + 'description' => __( 'The totals of the request, across all pages.' ), + ), + ), + ); + } + + /** + * Formats a post into the ability output shape. + * + * As the REST posts controller does, the post is set up as the global post while its + * fields are built, so filters that rely on loop globals, including title filters, see + * the requested post. For an editor of a password-protected post, the cookie-based + * password gate is also suspended, so rendered fields resolve to real values instead of + * protected-post placeholders. The field projection itself is delegated to + * {@see self::build_post_fields()}. + * + * @since 7.2.0 + * + * @param WP_Post $post The post object. + * @param list $fields The requested field paths. + * @return array The formatted post data. + */ + private function format_post( WP_Post $post, array $fields ): array { + $can_edit = current_user_can( 'edit_post', $post->ID ); + $password_required = post_password_required( $post ); + $unlock_password = $password_required && $can_edit; + $previous_context = $this->get_post_context(); + $the_post_fired = did_action( 'the_post' ) > 0; + + /* + * The filter unlocks only posts the current user can edit, mirroring the REST posts + * controller's check_password_required(): an unconditional bypass (e.g. __return_false) + * would also expose other protected posts that the content filter may render, such as + * posts pulled in by a Query Loop block. The closure is kept in a variable, so the same + * instance can be removed again. + */ + $allow_password_content = function ( $required, $checked_post ): bool { + return $this->allow_password_content( $required, $checked_post ); + }; + + /* + * Set up both inside the try block and undo them in the finally block, so a throw from + * a `the_post` callback or mid-render cannot leave the password gate disabled or the + * global post pointing at this post for the rest of the request. + */ + try { + $this->set_up_post_context( $post ); + if ( $unlock_password ) { + add_filter( 'post_password_required', $allow_password_content, 10, 2 ); + } + + return $this->build_post_fields( $post, $fields, $can_edit, $password_required && ! $can_edit ); + } finally { + if ( $unlock_password ) { + remove_filter( 'post_password_required', $allow_password_content, 10 ); + } + + $this->restore_post_context( $previous_context, $the_post_fired ); + } + } + + /** + * Builds the requested field projection for a post. + * + * Fields are selected as the REST API's `_fields` parameter selects them, with + * {@see self::is_field_requested()}. The `id` is always included, whether or not it + * was requested, so every returned post can be identified. This also means a post is + * never empty, so it always encodes as a JSON object. + * + * Only the fields that the post type supports and the current user can see are included, + * and a field none of whose requested sub-fields remain is left out, so it never encodes + * as an empty JSON array. Raw sub-fields are edit-context fields, included only when the + * current user can edit the post, whether they were requested on their own or through + * their field. Rendered sub-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.2.0 + * + * @param WP_Post $post The post object. + * @param list $fields The requested field paths. + * @param bool $can_edit Whether the current user can edit the post. + * @param bool $is_protected Whether rendered fields must be withheld as password-protected. + * @return array The formatted post data. + */ + private function build_post_fields( WP_Post $post, array $fields, bool $can_edit, bool $is_protected ): array { + $post_type = $post->post_type; + $data = array( 'id' => (int) $post->ID ); + + if ( $this->is_field_requested( 'type', $fields ) ) { + $data['type'] = $post_type; + } + if ( $this->is_field_requested( 'status', $fields ) ) { + $data['status'] = $post->post_status; + } + + foreach ( array( 'date', 'modified' ) as $field ) { + $dates = array(); + if ( $this->is_field_requested( "{$field}.local", $fields ) ) { + $dates['local'] = $this->format_date( $post, $field, false ); + } + if ( $this->is_field_requested( "{$field}.gmt", $fields ) ) { + $dates['gmt'] = $this->format_date( $post, $field, true ); + } + if ( ! empty( $dates ) ) { + $data[ $field ] = $dates; + } + } + + if ( $this->is_field_requested( 'slug', $fields ) ) { + $data['slug'] = $post->post_name; + } + if ( $this->is_field_requested( 'link', $fields ) ) { + $data['link'] = (string) get_permalink( $post ); + } + + /* + * The rendered fields share a shape: `raw` for a user who can edit the post, + * `rendered`, and `protected` for the excerpt and content, whose rendered values are + * withheld for a password-protected post. The title is never withheld, so it has no + * `protected` sub-field. Each field lists the feature the post type must support, the + * post property holding the raw value, the method rendering it, and whether it can be + * protected. + */ + $rendered_fields = array( + 'title' => array( 'title', 'post_title', 'get_title', false ), + 'excerpt' => array( 'excerpt', 'post_excerpt', 'get_rendered_excerpt', true ), + 'content' => array( 'editor', 'post_content', 'get_rendered_content', true ), + ); + foreach ( $rendered_fields as $field => $spec ) { + list( $feature, $property, $render, $protectable ) = $spec; + if ( ! $this->is_field_requested( $field, $fields ) || ! post_type_supports( $post_type, $feature ) ) { + continue; + } + + $value = array(); + if ( $can_edit && $this->is_field_requested( "{$field}.raw", $fields ) ) { + $value['raw'] = $post->$property; + } + if ( $this->is_field_requested( "{$field}.rendered", $fields ) ) { + $value['rendered'] = ( $protectable && $is_protected ) ? '' : $this->$render( $post ); + } + if ( $protectable && $this->is_field_requested( "{$field}.protected", $fields ) ) { + $value['protected'] = (bool) $post->post_password; + } + if ( ! empty( $value ) ) { + $data[ $field ] = $value; + } + } + + if ( $this->is_field_requested( 'author', $fields ) && post_type_supports( $post_type, 'author' ) ) { + $user = get_userdata( (int) $post->post_author ); + $author = array(); + if ( $this->is_field_requested( 'author.id', $fields ) ) { + $author['id'] = (int) $post->post_author; + } + if ( $this->is_field_requested( 'author.name', $fields ) ) { + $author['name'] = $user ? $user->display_name : null; + } + if ( $this->is_field_requested( 'author.slug', $fields ) ) { + $author['slug'] = $user ? $user->user_nicename : null; + } + if ( ! empty( $author ) ) { + $data['author'] = $author; + } + } + + if ( $this->is_field_requested( 'parent', $fields ) && is_post_type_hierarchical( $post_type ) ) { + $data['parent'] = (int) $post->post_parent; + } + + return $data; + } + + /** + * Filters {@see post_password_required()} to unlock only posts the current user can edit. + * + * Added by {@see self::format_post()} while formatting a password-protected post the + * current user can edit, so rendered fields resolve to real values without also unlocking + * other protected posts that the content filter may render. Mirrors the REST posts + * controller's check_password_required(). + * + * @since 7.2.0 + * + * @param mixed $required Whether the post currently requires a password. + * @param mixed $post The post being checked; a WP_Post when invoked by the core filter. + * @return bool Whether the post still requires a password. + */ + private function allow_password_content( $required, $post ): bool { + if ( ! $required || ! $post instanceof WP_Post ) { + return (bool) $required; + } + + return ! current_user_can( 'edit_post', $post->ID ); + } + + /** + * Returns the post title with the protected/private prefixes stripped. + * + * @since 7.2.0 + * + * @param WP_Post $post The post object. + * @return string The post title. + */ + private function get_title( WP_Post $post ): string { + $strip = static function (): string { + return '%s'; + }; + add_filter( 'protected_title_format', $strip ); + add_filter( 'private_title_format', $strip ); + + /* + * The format filters are removed in a finally block so a throw from a title + * filter cannot leave them attached for the rest of the request. + */ + try { + $title = get_the_title( $post ); + + /* + * A title filter that returns a non-string would fail the return type, so guard + * it as the excerpt and content are. + */ + return is_string( $title ) ? $title : ''; + } finally { + remove_filter( 'protected_title_format', $strip ); + remove_filter( 'private_title_format', $strip ); + } + } + + /** + * Returns the post excerpt transformed for display. + * + * Applies the `get_the_excerpt` and `the_excerpt` filter chains, as the REST posts + * controller does. {@see self::format_post()} has set the post up as the global post. + * + * @since 7.2.0 + * + * @param WP_Post $post The post object. + * @return string Rendered post excerpt. + */ + private function get_rendered_excerpt( WP_Post $post ): string { + /** This filter is documented in wp-includes/post-template.php */ + $excerpt = apply_filters( 'get_the_excerpt', $post->post_excerpt, $post ); + + /** This filter is documented in wp-includes/post-template.php */ + $excerpt = apply_filters( 'the_excerpt', $excerpt ); + + return is_string( $excerpt ) ? $excerpt : ''; + } + + /** + * Returns post content transformed for display. + * + * Applies `the_content`, as the REST posts controller does. {@see self::format_post()} + * has set the post up as the global post. + * + * @since 7.2.0 + * + * @param WP_Post $post The post object. + * @return string Rendered post content. + */ + private function get_rendered_content( WP_Post $post ): string { + /** This filter is documented in wp-includes/post-template.php */ + $content = apply_filters( 'the_content', $post->post_content ); + + return is_string( $content ) ? $content : ''; + } + + /** + * Returns the global post context, so it can be restored after a post is rendered. + * + * @since 7.2.0 + * + * @return array The loop globals, keyed by name, leaving out those that are not set. + */ + private function get_post_context(): array { + /* + * Copy each global by value. A calling function that binds a global with `global`, + * as load_template() and WP_Block::render() do, makes it a reference, which + * array_intersect_key( $GLOBALS, ... ) would keep. The saved copy would then follow + * the global to the rendered post, and the restore would put that post back. + */ + $context = array(); + foreach ( self::LOOP_GLOBALS as $name ) { + if ( ! array_key_exists( $name, $GLOBALS ) ) { + continue; + } + + $context[ $name ] = $GLOBALS[ $name ]; + } + + return $context; + } + + /** + * Sets up the global post context for rendering a post. + * + * Sets the global post and calls setup_postdata(), as the REST posts controller does + * before it renders a post, so filters that rely on loop globals render against the + * requested post. + * + * @since 7.2.0 + * + * @param WP_Post $post The post to render. + */ + private function set_up_post_context( WP_Post $post ): void { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily mirrors REST post context for rendering. + $GLOBALS['post'] = $post; + setup_postdata( $post ); + } + + /** + * Restores the global post context saved by {@see self::get_post_context()}. + * + * Each loop global gets its previous value back, or is unset again when it was not set. + * wp_reset_postdata() alone is not enough: it does nothing when the main query has no + * post, which would leave the rendered post's data in the globals. When a post was set + * up before, setup_postdata() first runs for it again, as wp_reset_postdata() would, so + * callbacks on the `the_post` action can restore their own globals too. The saved values + * are put back even when such a callback throws. A global post that was never set up + * keeps the loop globals that setup_postdata() gives it. The `the_post` count is reset + * only when the ability was the first thing to fire that action and no global post was + * set, so rendering a post before the main query does not count as starting the loop. + * + * @since 7.2.0 + * + * @param array $previous_context The loop globals that get_post_context() returned. + * @param bool $the_post_fired Whether the `the_post` action had fired before the ability set up the post. + */ + private function restore_post_context( array $previous_context, bool $the_post_fired ): void { + $previous_post = $previous_context['post'] ?? null; + + /* + * A global post that was never set up, such as the main post before the loop + * starts, keeps what setup_postdata() gives it below. Do not put back the values + * saved before: `the_post` has fired then, so get_the_content() called without a + * post, as the Post Content block and the_content() outside the loop do, reads the + * loop globals instead of the post, and an unset `$pages` makes it throw a + * TypeError that breaks the page being rendered. + */ + $restore_saved_values = ! $previous_post instanceof WP_Post || is_array( $previous_context['pages'] ?? null ); + + try { + if ( $previous_post instanceof WP_Post ) { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the previous global post context. + $GLOBALS['post'] = $previous_post; + setup_postdata( $previous_post ); + } + } finally { + if ( $restore_saved_values ) { + foreach ( self::LOOP_GLOBALS as $name ) { + if ( array_key_exists( $name, $previous_context ) ) { + $GLOBALS[ $name ] = $previous_context[ $name ]; + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + /* + * Only reset the `the_post` count when the ability was the first thing to fire it + * and no global post was set. Setting up the rendered post counted the action, and + * once it has fired, get_the_content() called without a post reads the loop globals + * unset above instead of the post. A global post set later without being set up, + * as WP::register_globals() sets the main post after the ability runs on `init`, + * would then make it throw a TypeError. When a loop had already fired the action, + * or a post was set, the count is left alone. + */ + if ( ! $previous_post instanceof WP_Post && ! $the_post_fired ) { + unset( $GLOBALS['wp_actions']['the_post'] ); + } + } + } + + /** + * Formats a post date field as an ISO 8601 string, in the site's timezone or in GMT. + * + * In GMT, it reads the stored GMT date, deriving it from the local date when it is + * missing (e.g. drafts), mirroring the REST posts controller. + * + * @since 7.2.0 + * + * @param WP_Post $post The post object. + * @param string $field Either 'date' or 'modified'. + * @param bool $gmt Whether to format the date in GMT instead of the site's timezone. + * @return string|null The ISO 8601 date, or null if unavailable. + */ + private function format_date( WP_Post $post, string $field, bool $gmt ): ?string { + $datetime = $gmt ? get_post_datetime( $post, $field, 'gmt' ) : false; + if ( ! $datetime ) { + $datetime = get_post_datetime( $post, $field ); + } + + if ( ! $datetime ) { + return null; + } + + /* + * A malformed stored date can still parse: a zero month formats with a negative + * year. The `date-time` format rejects it, which would fail output validation for + * the whole call, so it is reported as null too. + */ + $date = ( $gmt ? $datetime->setTimezone( new DateTimeZone( 'UTC' ) ) : $datetime )->format( 'c' ); + + return rest_parse_date( $date ) ? $date : null; + } + + /** + * Builds the ability output for a page of formatted posts. + * + * @since 7.2.0 + * + * @param array> $items The formatted posts on the page. + * @param int $total Total number of posts across all pages. + * @param int $total_pages Total number of pages. + * @return array The ability output. + */ + private function prepare_output( array $items, int $total, int $total_pages ): array { + return array( + 'items' => $items, + 'pagination' => array( + 'total' => $total, + 'total_pages' => $total_pages, + ), + ); + } + + /** + * Builds the error for a page past the last page of results. + * + * A page that does not exist is reported as not found rather than as an empty list, + * so callers can tell it apart from a request that matches no posts. + * + * @since 7.2.0 + * + * @return WP_Error The invalid page number error. + */ + private function invalid_page_number_error(): WP_Error { + return new WP_Error( + 'content_invalid_page_number', + __( 'The page number requested is larger than the number of pages available.' ), + array( 'status' => 404 ) + ); + } + + /** + * Builds the error for a filter that cannot be honored. + * + * As in the REST API's `rest_invalid_param` errors, the error data maps the filter's + * input path to the message under `params`, so callers can tell which filter failed + * without parsing the translated message. + * + * @since 7.2.0 + * + * @param string $param The filter's input path, such as `where[ids]`. + * @param string $message The error message. + * @return WP_Error The invalid filter error. + */ + private function invalid_filter_error( string $param, string $message ): WP_Error { + return new WP_Error( + 'content_invalid_filter', + $message, + array( + 'status' => 400, + 'params' => array( $param => $message ), + ) + ); + } +} diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index f1919062686d0..6307cbcd4c6f7 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -373,6 +373,17 @@ final class WP_Post_Type { */ public $show_in_rest; + /** + * Whether this post type should be exposed through the Abilities API. + * + * Default false. If true, the core content abilities can access the post type's + * posts, subject to the current user's capabilities. + * + * @since 7.2.0 + * @var bool $show_in_abilities + */ + public $show_in_abilities; + /** * The base path for this post type's REST API endpoints. * @@ -553,6 +564,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, @@ -583,6 +595,9 @@ public function set_props( $args ) { $args['rest_namespace'] = 'wp/v2'; } + // Only `true` exposes the post type, so other values, such as arrays, can be given a meaning later. + $args['show_in_abilities'] = true === $args['show_in_abilities']; + // If not set, default to the setting for 'show_ui'. if ( null === $args['show_in_menu'] || ! $args['show_ui'] ) { $args['show_in_menu'] = $args['show_ui']; diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 8d9b58c037501..041e71e31a97e 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -50,6 +50,7 @@ function create_initial_post_types() { 'post-formats', ), 'show_in_rest' => true, + 'show_in_abilities' => true, 'rest_base' => 'posts', 'rest_controller_class' => 'WP_REST_Posts_Controller', ) @@ -84,6 +85,7 @@ function create_initial_post_types() { 'revisions', ), 'show_in_rest' => true, + 'show_in_abilities' => true, 'rest_base' => 'pages', 'rest_controller_class' => 'WP_REST_Posts_Controller', ) @@ -1691,6 +1693,7 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * @since 5.0.0 The `template` and `template_lock` arguments were added. * @since 5.3.0 The `supports` argument will now accept an array of arguments for a feature. * @since 5.9.0 The `rest_namespace` argument was added. + * @since 7.2.0 The `show_in_abilities` argument was added. * * @global array $wp_post_types List of post types. * @@ -1736,6 +1739,9 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * of $show_in_menu. * @type bool $show_in_rest Whether to include the post type in the REST API. Set this to true * for the post type to be available in the block editor. + * @type bool $show_in_abilities Whether to expose this post type through the Abilities API, so the + * core content abilities can access its posts, subject to the current + * user's capabilities. Default false. * @type string $rest_base To change the base URL of REST API route. Default is $post_type. * @type string $rest_namespace To change the namespace URL of REST API route. Default is wp/v2. * @type string $rest_controller_class REST API controller class name. Default is 'WP_REST_Posts_Controller'. diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php new file mode 100644 index 0000000000000..08c6df936aecb --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -0,0 +1,5834 @@ + + */ + private const LOOP_GLOBALS = array( + 'post', + 'id', + 'authordata', + 'currentday', + 'currentmonth', + 'page', + 'pages', + 'multipage', + 'more', + 'numpages', + ); + + /** + * A post ID that tests create a post with through `import_id`, so data providers can + * write the ID in other forms. + * + * @since 7.2.0 + * + * @var int + */ + private const IMPORTED_POST_ID = 1234567; + + /** + * A post ID that tests create a post with through `import_id`, so that an ID beyond the + * integer range can alias it. + * + * Floats near 2^64 are 4096 apart, so 2^64 + N is exact for a multiple of 4096 and casts + * to N. + * + * @since 7.2.0 + * + * @var int + */ + private const ALIASED_POST_ID = 4096 * 1024; + + /** + * The result of a query that matches no post, on any page. + * + * @since 7.2.0 + * + * @var array> + */ + private const EMPTY_RESULT = array( + 'items' => array(), + 'pagination' => array( + 'total' => 0, + 'total_pages' => 0, + ), + ); + + /** + * Shared user IDs keyed by role or fixture name. + * + * @since 7.2.0 + * + * @var array + */ + private static $user_ids = array(); + + /** + * Shared post IDs keyed by fixture name. + * + * @since 7.2.0 + * + * @var array + */ + private static $post_ids = array(); + + /** + * Creates shared users and posts for the content ability tests. + * + * @since 7.2.0 + * + * @param WP_UnitTest_Factory $factory The unit test factory. + */ + public static function wpSetUpBeforeClass( $factory ): void { + self::$user_ids = array( + 'administrator' => $factory->user->create( array( 'role' => 'administrator' ) ), + 'editor' => $factory->user->create( array( 'role' => 'editor' ) ), + 'subscriber' => $factory->user->create( array( 'role' => 'subscriber' ) ), + 'contributor' => $factory->user->create( array( 'role' => 'contributor' ) ), + 'author' => $factory->user->create( array( 'role' => 'author' ) ), + 'author_secondary' => $factory->user->create( array( 'role' => 'author' ) ), + ); + + self::$post_ids = array( + 'published' => $factory->post->create( array( 'post_status' => 'publish' ) ), + 'published_content' => $factory->post->create( + array( + 'post_title' => 'Hello Content', + 'post_content' => 'Body here.', + 'post_status' => 'publish', + ) + ), + 'limited_role_content' => $factory->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_title' => 'Readable title', + 'post_content' => 'Readable body for limited role.', + 'post_excerpt' => 'Readable excerpt.', + 'post_status' => 'publish', + ) + ), + 'password_protected' => $factory->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'Top secret body.', + ) + ), + ); + } + + /** + * Sets up the content ability category for each test. + * + * @since 7.2.0 + */ + public function set_up(): void { + parent::set_up(); + + if ( wp_has_ability( 'core/content-query' ) ) { + wp_unregister_ability( 'core/content-query' ); + } + + if ( ! wp_has_ability_category( 'content' ) ) { + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_categories_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + wp_register_ability_category( + 'content', + array( + 'label' => 'Content', + 'description' => 'Content.', + ) + ); + } + } + + /** + * Removes the ability and its category, and resets the post types, after each test. + * + * @since 7.2.0 + */ + public function tear_down(): void { + if ( wp_has_ability( 'core/content-query' ) ) { + wp_unregister_ability( 'core/content-query' ); + } + + if ( wp_has_ability_category( 'content' ) ) { + wp_unregister_ability_category( 'content' ); + } + + /* + * Keep this reset even though the parent set_up() also resets the post types. A test + * may turn off `show_in_abilities` on `post` and `page`, and the next class's + * wpSetUpBeforeClass() runs before the next set_up(). A class that registers the core + * abilities there, such as Tests_REST_API_WpRestAbilitiesContentController, would then + * register no content ability, and all of its tests would fail. + */ + $this->reset_post_types(); + + parent::tear_down(); + } + + /** + * Registers the core/content-query ability inside a faked init action. + * + * @since 7.2.0 + */ + private function register_ability(): void { + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + ( new WP_Abilities_Content() )->register(); + } + + /** + * Registers the core/content-query ability and returns the callbacks it was registered with. + * + * The callbacks call private methods, so tests that skip input validation or the + * permission check capture them from the registration arguments. + * + * @since 7.2.0 + * + * @return array The permission and execute callbacks, keyed by argument name. + */ + private function get_ability_callbacks(): array { + $callbacks = array(); + + add_filter( + 'wp_register_ability_args', + static function ( array $args, string $name ) use ( &$callbacks ): array { + if ( 'core/content-query' === $name ) { + $callbacks = array( + 'permission_callback' => $args['permission_callback'], + 'execute_callback' => $args['execute_callback'], + ); + } + + return $args; + }, + 10, + 2 + ); + + $this->register_ability(); + + return $callbacks; + } + + /** + * Logs in as the shared user with the given role. + * + * @since 7.2.0 + * + * @param string $role The role to log in as. + */ + private function login_as( string $role ): void { + wp_set_current_user( self::$user_ids[ $role ] ); + } + + /** + * 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', + ), + ); + } + + /** + * The ability is registered in the `content` category and flagged read-only. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_registers_core_content_query_ability(): void { + $this->register_ability(); + + $ability = wp_get_ability( 'core/content-query' ); + + $this->assertNotNull( $ability, 'The core/content-query ability should be registered.' ); + $this->assertSame( 'content', $ability->get_category(), 'The registered ability should use the content category.' ); + $this->assertTrue( $ability->get_meta_item( 'public', false ), 'The ability should be marked public.' ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ), 'The ability should be exposed in REST.' ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertTrue( $annotations['readonly'], 'The ability should be marked read-only.' ); + $this->assertFalse( $annotations['destructive'], 'The ability should be marked non-destructive.' ); + $this->assertTrue( $annotations['idempotent'], 'The ability should be marked idempotent.' ); + } + + /** + * The content ability is not registered when no post types are exposed to it. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_does_not_register_core_content_query_ability_without_exposed_post_types(): void { + foreach ( array( 'post', 'page' ) as $post_type ) { + $object = get_post_type_object( $post_type ); + $this->assertInstanceOf( WP_Post_Type::class, $object, "Precondition: the {$post_type} post type should exist." ); + + $object->show_in_abilities = false; + } + + $this->register_ability(); + + $this->assertFalse( wp_has_ability( 'core/content-query' ), 'The content ability should not register without any exposed post types.' ); + } + + /** + * The input schema models `where` as one object that requires the post type and takes the + * filters next to it, exposing only marked types. + * + * The root takes only `where`, `fields` and `pagination`. No part of the input schema uses + * a combinator, and `where` has no title. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_input_schema_models_a_flat_where(): void { + $this->register_ability(); + + $schema = wp_get_ability( 'core/content-query' )->get_input_schema(); + + $this->assertSame( 'object', $schema['type'], 'The input schema should describe an object.' ); + $this->assertFalse( $schema['additionalProperties'], 'The input schema should reject unknown root keys.' ); + $this->assertSame( array( 'where' ), $schema['required'], 'The input schema should require where.' ); + $this->assertSame( array(), $this->find_schema_keywords( $schema, array( 'oneOf', 'anyOf', 'allOf' ) ), 'The input schema should not use a combinator at any depth.' ); + $this->assertSame( array( 'where', 'fields', 'pagination' ), array_keys( $schema['properties'] ), 'The input schema should take where, fields and pagination, in that order.' ); + + $where = $schema['properties']['where']; + + $this->assertSame( 'object', $where['type'], 'The where option should describe an object.' ); + $this->assertArrayNotHasKey( 'title', $where, 'The where option should not have a title.' ); + $this->assertSame( array( 'type' ), $where['required'], 'The where option should require a post type.' ); + $this->assertFalse( $where['additionalProperties'], 'The where option should reject unknown keys.' ); + $this->assertSame( array( 'type', 'ids', 'slugs', 'status', 'author_slug', 'parent' ), array_keys( $where['properties'] ), 'The where option should take a post type and the filters, in that order.' ); + $this->assertSame( 'string', $where['properties']['type']['type'], 'The where option should take the post type as a string.' ); + $this->assertSameSets( array( 'post', 'page' ), $where['properties']['type']['enum'], 'Only the post types marked show_in_abilities should be exposed.' ); + + $ids = $where['properties']['ids']; + + $this->assertSame( 'array', $ids['type'], 'The ids filter should take a list.' ); + $this->assertSame( 1, $ids['minItems'], 'The ids filter should require at least one post ID.' ); + $this->assertSame( 100, $ids['maxItems'], 'The ids filter should accept at most 100 post IDs.' ); + $this->assertTrue( $ids['uniqueItems'], 'The ids filter should reject duplicate post IDs.' ); + $this->assertSame( 'integer', $ids['items']['type'], 'The ids filter should contain post IDs.' ); + $this->assertSame( 1, $ids['items']['minimum'], 'The ids filter should contain positive post IDs.' ); + + $slugs = $where['properties']['slugs']; + + $this->assertSame( 'array', $slugs['type'], 'The slugs filter should take a list.' ); + $this->assertSame( 1, $slugs['minItems'], 'The slugs filter should require at least one slug.' ); + $this->assertSame( 100, $slugs['maxItems'], 'The slugs filter should accept at most 100 slugs.' ); + $this->assertTrue( $slugs['uniqueItems'], 'The slugs filter should reject duplicate slugs.' ); + $this->assertSame( 'string', $slugs['items']['type'], 'The slugs filter should contain slugs as strings.' ); + $this->assertSame( 1, $slugs['items']['minLength'], 'The slugs filter should not contain an empty slug.' ); + + $status = $where['properties']['status']; + + $this->assertSame( 'array', $status['type'], 'The status filter should take a list.' ); + $this->assertTrue( $status['uniqueItems'], 'The status filter should reject duplicate statuses.' ); + $this->assertSame( 1, $status['minItems'], 'The status filter should require at least one status.' ); + $this->assertSame( 'string', $status['items']['type'], 'The status filter should contain status names.' ); + $this->assertSameSets( array_values( get_post_stati( array( 'internal' => false ) ) ), $status['items']['enum'], 'The status filter should expose the statuses that are not internal.' ); + + $this->assertSame( 'string', $where['properties']['author_slug']['type'], 'The author_slug filter should be a string.' ); + $this->assertSame( 1, $where['properties']['author_slug']['minLength'], 'The author_slug filter should not be empty.' ); + $this->assertSame( 'integer', $where['properties']['parent']['type'], 'The parent filter should be an integer.' ); + $this->assertSame( 0, $where['properties']['parent']['minimum'], 'The parent filter should accept 0 for top-level posts.' ); + + $fields = $schema['properties']['fields']; + + $this->assertSame( 'array', $fields['type'], 'The fields option should take a list.' ); + $this->assertTrue( $fields['uniqueItems'], 'The fields option should reject duplicate field paths.' ); + $this->assertArrayNotHasKey( 'minItems', $fields, 'The fields option should accept an empty list, which selects the default fields.' ); + $this->assertSame( 'string', $fields['items']['type'], 'The fields option should contain field paths.' ); + $this->assertSame( + array( + 'id', + 'type', + 'status', + 'date', + 'date.local', + 'date.gmt', + 'modified', + 'modified.local', + 'modified.gmt', + 'slug', + 'link', + 'title', + 'title.raw', + 'title.rendered', + 'excerpt', + 'excerpt.raw', + 'excerpt.rendered', + 'excerpt.protected', + 'content', + 'content.raw', + 'content.rendered', + 'content.protected', + 'author', + 'author.id', + 'author.name', + 'author.slug', + 'parent', + ), + $fields['items']['enum'], + 'The fields enum should list every field, each followed by the paths of its sub-fields.' + ); + + $pagination = $schema['properties']['pagination']; + + $this->assertSame( 'object', $pagination['type'], 'The pagination option should describe an object.' ); + $this->assertFalse( $pagination['additionalProperties'], 'The pagination option should reject unknown keys.' ); + $this->assertArrayNotHasKey( 'required', $pagination, 'The pagination option should not require a page or a page size.' ); + $this->assertSame( array( 'page', 'per_page' ), array_keys( $pagination['properties'] ), 'The pagination option should take a page and a page size, in that order.' ); + $this->assertSame( 'integer', $pagination['properties']['page']['type'], 'The page should be an integer.' ); + $this->assertSame( 1, $pagination['properties']['page']['minimum'], 'The page should start at 1.' ); + $this->assertSame( 'integer', $pagination['properties']['per_page']['type'], 'The page size should be an integer.' ); + $this->assertSame( 1, $pagination['properties']['per_page']['minimum'], 'The page size should be at least 1.' ); + $this->assertSame( 100, $pagination['properties']['per_page']['maximum'], 'The page size should be capped at 100.' ); + + $input_properties = array( + 'root' => $schema['properties'], + 'where' => $where['properties'], + 'pagination' => $pagination['properties'], + ); + foreach ( $input_properties as $location => $properties ) { + foreach ( $properties as $name => $property ) { + $this->assertArrayHasKey( 'description', $property, sprintf( 'The %s property %s should have a description.', $location, $name ) ); + } + } + } + + /** + * Defaults are omitted from the whole input schema, so the schema can compile in the + * client-side Abilities API validator. Runtime defaults are still applied by the ability. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_input_schema_omits_defaults(): void { + $this->register_ability(); + + $schema = wp_get_ability( 'core/content-query' )->get_input_schema(); + + $this->assertSame( array(), $this->find_schema_keywords( $schema, array( 'default' ) ), 'The input schema should not declare a default at any depth.' ); + } + + /** + * `where.ids` combines with a `status` filter: a post must have one of the IDs and one of the + * statuses. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_ids_combine_with_a_status_filter(): void { + $draft_id = self::factory()->post->create( array( 'post_status' => 'draft' ) ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::$post_ids['published'], $draft_id ), + 'status' => array( 'publish' ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'The ids filter next to a status filter should pass validation.' ); + $this->assertSame( array( self::$post_ids['published'] ), wp_list_pluck( $result['items'], 'id' ), 'The ids filter and the status filter should both apply.' ); + } + + /** + * Returns inputs whose `where` is missing, empty, or has no exposed post type. + * + * An empty string counts as an empty object, as in schema validation. + * + * @return array}> Input test cases. + */ + public function data_inputs_without_a_post_type(): array { + return array( + 'missing where' => array( + 'input' => array( 'fields' => array( 'id' ) ), + ), + 'empty where' => array( + 'input' => array( 'where' => array() ), + ), + 'empty string where' => array( + 'input' => array( 'where' => '' ), + ), + 'IDs without a post type' => array( + 'input' => array( 'where' => array( 'ids' => array( self::IMPORTED_POST_ID ) ) ), + ), + 'slugs without a post type' => array( + 'input' => array( 'where' => array( 'slugs' => array( 'x' ) ) ), + ), + 'status without a post type' => array( + 'input' => array( 'where' => array( 'status' => array( 'draft' ) ) ), + ), + 'post type outside the enum' => array( + 'input' => array( 'where' => array( 'type' => 'attachment' ) ), + ), + ); + } + + /** + * Returns no input, and inputs that hold a key their object does not take or a value its + * schema rejects. + * + * @return array Input test cases. + */ + public function data_invalid_inputs(): array { + return array( + 'no input' => array( + 'input' => null, + ), + 'unknown key' => array( + 'input' => array( + 'where' => array( + 'type' => 'post', + 'bogus' => 1, + ), + ), + ), + 'unknown pagination key' => array( + 'input' => array( + 'where' => array( 'type' => 'post' ), + 'pagination' => array( 'bogus' => 1 ), + ), + ), + 'empty IDs' => array( + 'input' => array( + 'where' => array( + 'type' => 'post', + 'ids' => array(), + ), + ), + ), + 'ID of 0' => array( + 'input' => array( + 'where' => array( + 'type' => 'post', + 'ids' => array( 0 ), + ), + ), + ), + 'duplicate IDs' => array( + 'input' => array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::IMPORTED_POST_ID, self::IMPORTED_POST_ID ), + ), + ), + ), + 'empty slugs' => array( + 'input' => array( + 'where' => array( + 'type' => 'post', + 'slugs' => array(), + ), + ), + ), + 'empty slug' => array( + 'input' => array( + 'where' => array( + 'type' => 'post', + 'slugs' => array( '' ), + ), + ), + ), + 'duplicate slugs' => array( + 'input' => array( + 'where' => array( + 'type' => 'post', + 'slugs' => array( 'x', 'x' ), + ), + ), + ), + ); + } + + /** + * Input without a post type, with a key that its object does not take, or with a value its + * schema rejects, fails schema validation. + * + * @ticket 66268 + * @dataProvider data_inputs_without_a_post_type + * @dataProvider data_invalid_inputs + * @since 7.2.0 + * + * @param mixed $input The input to send. + */ + public function test_invalid_input_fails_schema_validation( $input ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( $input ); + + $this->assertWPError( $result, 'Invalid input should not return a result.' ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'Invalid input should fail schema validation.' ); + } + + /** + * An empty `fields` list and an empty `pagination` object are valid, and select the + * default fields on the first page of the default page size. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_empty_fields_and_pagination_select_the_defaults(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + // More posts than the default page size of 10, each with its own date. + for ( $day = 1; $day <= 11; $day++ ) { + self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => sprintf( '2026-01-%02d 10:00:00', $day ), + ) + ); + } + + $ability = wp_get_ability( 'core/content-query' ); + + $result = $ability->execute( + array( + 'where' => array( 'type' => 'post' ), + 'fields' => array(), + 'pagination' => array(), + ) + ); + + $this->assertIsArray( $result, 'An empty fields list and an empty pagination object should be valid.' ); + $this->assertCount( 10, $result['items'], 'An empty pagination object should use the default page size.' ); + $this->assertSame( (int) ceil( $result['pagination']['total'] / 10 ), $result['pagination']['total_pages'], 'The page count should follow the default page size.' ); + $this->assertSameSets( array( 'id', 'type', 'status', 'date', 'slug', 'title' ), array_keys( $result['items'][0] ), 'An empty fields list should select the default fields.' ); + $this->assertSame( array( 'rendered' ), array_keys( $result['items'][0]['title'] ), 'The default fields should hold only the rendered title.' ); + $this->assertSameSets( array( 'local', 'gmt' ), array_keys( $result['items'][0]['date'] ), 'The default fields should hold both dates.' ); + + $explicit = $ability->execute( + array( + 'where' => array( 'type' => 'post' ), + 'fields' => array( 'id', 'type', 'status', 'date', 'slug', 'title.rendered' ), + 'pagination' => array( + 'page' => 1, + 'per_page' => 10, + ), + ) + ); + + $this->assertSame( $explicit, $result, 'An empty fields list and an empty pagination object should return the default fields of the first page of 10 posts.' ); + } + + /** + * The output schema describes the one response shape that every query shares: the posts as + * `items`, and their totals as `pagination`, without a combinator at any depth. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_output_schema_describes_query_responses(): void { + $this->register_ability(); + + $schema = wp_get_ability( 'core/content-query' )->get_output_schema(); + $post_schema = $schema['properties']['items']['items']; + $pagination = $schema['properties']['pagination']; + + $this->assertSame( 'object', $schema['type'], 'The output schema should describe object responses.' ); + $this->assertFalse( $schema['additionalProperties'], 'The output schema should not allow keys besides items and pagination.' ); + $this->assertSame( array(), $this->find_schema_keywords( $schema, array( 'oneOf', 'anyOf', 'allOf' ) ), 'The output schema should describe query responses without a combinator at any depth.' ); + $this->assertSame( array( 'items', 'pagination' ), $schema['required'], 'Every response should require the items and the pagination.' ); + $this->assertSame( array( 'items', 'pagination' ), array_keys( $schema['properties'] ), 'The output schema should describe the items and the pagination, in that order.' ); + $this->assertSame( 'array', $schema['properties']['items']['type'], 'The items should be a list.' ); + + $this->assertSame( 'object', $post_schema['type'], 'Each post should be described as an object.' ); + $this->assertSame( array( 'id' ), $post_schema['required'], 'Only the always-returned id should be required in a post.' ); + $this->assertFalse( $post_schema['additionalProperties'], 'Returned posts should not allow unknown properties.' ); + $this->assertSame( + array( + 'id' => 'integer', + 'type' => 'string', + 'status' => 'string', + 'date' => 'object', + 'modified' => 'object', + 'slug' => 'string', + 'link' => 'string', + 'title' => 'object', + 'excerpt' => 'object', + 'content' => 'object', + 'author' => 'object', + 'parent' => 'integer', + ), + wp_list_pluck( $post_schema['properties'], 'type' ), + 'The post schema should describe each post property with its type, in order.' + ); + + $nullable_string = array( 'string', 'null' ); + $object_types = array( + 'date' => array( + 'local' => $nullable_string, + 'gmt' => $nullable_string, + ), + 'modified' => array( + 'local' => $nullable_string, + 'gmt' => $nullable_string, + ), + 'title' => array( + 'raw' => 'string', + 'rendered' => 'string', + ), + 'excerpt' => array( + 'raw' => 'string', + 'rendered' => 'string', + 'protected' => 'boolean', + ), + 'content' => array( + 'raw' => 'string', + 'rendered' => 'string', + 'protected' => 'boolean', + ), + 'author' => array( + 'id' => 'integer', + 'name' => $nullable_string, + 'slug' => $nullable_string, + ), + ); + foreach ( $object_types as $name => $types ) { + $object = $post_schema['properties'][ $name ]; + + $this->assertFalse( $object['additionalProperties'], sprintf( 'The %s object should not allow unknown keys.', $name ) ); + $this->assertArrayNotHasKey( 'required', $object, sprintf( 'The %s object should not require any key.', $name ) ); + $this->assertSame( $types, wp_list_pluck( $object['properties'], 'type' ), sprintf( 'The %s object should describe each key with its type, in order.', $name ) ); + } + foreach ( array( 'date', 'modified' ) as $name ) { + foreach ( array( 'local', 'gmt' ) as $key ) { + $this->assertSame( 'date-time', $post_schema['properties'][ $name ]['properties'][ $key ]['format'], sprintf( 'The %s.%s value should use the date-time format.', $name, $key ) ); + } + } + + $this->assertSame( 'object', $pagination['type'], 'The pagination should describe an object.' ); + $this->assertFalse( $pagination['additionalProperties'], 'The pagination should not allow keys besides the totals.' ); + $this->assertSame( array( 'total', 'total_pages' ), $pagination['required'], 'The pagination should require both totals.' ); + $this->assertSame( + array( + 'total' => 'integer', + 'total_pages' => 'integer', + ), + wp_list_pluck( $pagination['properties'], 'type' ), + 'The pagination should describe the total and the page count as integers, in that order.' + ); + } + + /** + * Returns the paths of the given keywords anywhere in a schema. + * + * @since 7.2.0 + * + * @param array $schema The schema to search. + * @param list $keywords The keywords to look for. + * @param string $path Optional. The path of the schema in its root schema. Default empty string. + * @return list The paths of the keywords, such as `properties.where.properties.ids.minItems`. + */ + private function find_schema_keywords( array $schema, array $keywords, string $path = '' ): array { + $found = array(); + + foreach ( $schema as $key => $value ) { + $key_path = '' === $path ? (string) $key : $path . '.' . $key; + + if ( in_array( $key, $keywords, true ) ) { + $found[] = $key_path; + } + + if ( is_array( $value ) ) { + $found = array_merge( $found, $this->find_schema_keywords( $value, $keywords, $key_path ) ); + } + } + + return $found; + } + + /** + * A post type registered by another active plugin and flagged `show_in_abilities` + * is exposed by the ability, both in the input enum and in query results. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_exposes_a_post_type_registered_by_another_plugin(): void { + register_post_type( + 'content_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + // The `type` enum of `where` lists the exposed post types. + $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['properties']['where']['properties']['type']['enum']; + $this->assertContains( 'content_cpt', $enum, 'Custom post types marked show_in_abilities should appear in the post type enum.' ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'content_cpt', + 'post_status' => 'publish', + ) + ); + + $result = wp_get_ability( 'core/content-query' )->execute( array( 'where' => array( 'type' => 'content_cpt' ) ) ); + $ids = wp_list_pluck( $result['items'], 'id' ); + + $this->assertContains( $post_id, $ids, 'The custom post type should be queryable through the content ability.' ); + } + + /** + * Returns `show_in_abilities` values other than `true`. + * + * @return array Non-boolean `show_in_abilities` test cases. + */ + public function data_show_in_abilities_values_other_than_true(): array { + return array( + 'false' => array( + 'value' => false, + ), + 'array of operations' => array( + 'value' => array( 'create' ), + ), + 'string "false"' => array( + 'value' => 'false', + ), + 'integer 1' => array( + 'value' => 1, + ), + ); + } + + /** + * Only a `show_in_abilities` value of `true` exposes a post type, so other values, such as + * arrays, can be given a meaning later. + * + * @ticket 66268 + * @dataProvider data_show_in_abilities_values_other_than_true + * @since 7.2.0 + * + * @param mixed $value The `show_in_abilities` value to register the post type with. + */ + public function test_does_not_expose_a_post_type_with_a_show_in_abilities_value_other_than_true( $value ): void { + register_post_type( + 'content_cpt', + array( + 'public' => true, + 'show_in_abilities' => $value, + ) + ); + + $this->assertFalse( get_post_type_object( 'content_cpt' )->show_in_abilities, 'The post type object should hold false.' ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'content_cpt', + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + // The `type` enum of `where` lists the exposed post types. + $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['properties']['where']['properties']['type']['enum']; + $this->assertNotContains( 'content_cpt', $enum, 'The post type should not appear in the post type enum.' ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'content_cpt', + 'ids' => array( $post_id ), + ), + ) + ); + $this->assertWPError( $result, 'A post of the post type should not be readable by ID.' ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'A query of the post type should fail validation.' ); + } + + /** + * A schema filter can expose a post type that is registered after the ability, by adding it + * to the `type` enum of `where`. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_schema_filter_exposes_late_registered_post_type(): void { + $this->login_as( 'administrator' ); + + $amend_schema = static function ( array $args, string $name ): array { + if ( 'core/content-query' !== $name ) { + return $args; + } + + $args['input_schema']['properties']['where']['properties']['type']['enum'][] = 'late_cpt'; + + return $args; + }; + add_filter( 'wp_register_ability_args', $amend_schema, 10, 2 ); + $this->register_ability(); + + $enum = wp_get_ability( 'core/content-query' )->get_input_schema()['properties']['where']['properties']['type']['enum']; + $this->assertContains( 'late_cpt', $enum, 'The ability args filter should amend the frozen schema enum.' ); + + register_post_type( + 'late_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'late_cpt', + 'post_status' => 'publish', + ) + ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( 'type' => 'late_cpt' ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $post_id ), wp_list_pluck( $result['items'], 'id' ), 'The late post type should be queryable after it becomes exposed.' ); + } + + /** + * A published post can be fetched by ID. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_by_id_returns_the_published_post(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::$post_ids['published_content']; + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + ) + ); + + $this->assertIsArray( $result, 'The query by ID should return a post array.' ); + $this->assertSame( array( $post_id ), wp_list_pluck( $result['items'], 'id' ), 'The query by ID should return only the requested post.' ); + $this->assertSame( 'Hello Content', $result['items'][0]['title']['rendered'], 'Rendered titles should be returned by default.' ); + $this->assertSame( + array( + 'total' => 1, + 'total_pages' => 1, + ), + $result['pagination'], + 'The query by ID should report one post on one page.' + ); + } + + /** + * Schema-valid object input behaves like its array form. + * + * WP_Ability validates `stdClass` as object input but does not coerce the value before + * passing it to the permission and execute callbacks, so both must preserve its fields. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_accepts_object_input(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::$post_ids['published_content']; + $ability = wp_get_ability( 'core/content-query' ); + $input = (object) array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'id', 'title.rendered' ), + ); + + $result = $ability->execute( $input ); + + $this->assertIsArray( $result, 'Object input should execute the query by ID.' ); + $this->assertSame( $post_id, $result['items'][0]['id'], 'Object input should preserve the requested post ID.' ); + $this->assertSame( 'Hello Content', $result['items'][0]['title']['rendered'], 'Object input should preserve the requested fields.' ); + $this->assertSameSets( array( 'id', 'title' ), array_keys( $result['items'][0] ), 'Object input should use the requested field projection.' ); + $this->assertSame( array( 'rendered' ), array_keys( $result['items'][0]['title'] ), 'Object input should project the title to the requested path.' ); + } + + /** + * Object `where` and `pagination` values behave like their array forms. + * + * Schema validation accepts a `stdClass` for every object in the input, so neither value + * may be discarded, whether the input itself is an array or an object. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_object_where_and_pagination_behave_like_arrays(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ability = wp_get_ability( 'core/content-query' ); + + $older_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + $newer_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-02-01 10:00:00', + ) + ); + + // Posts are ordered newest first, so the second page of one post holds the older post. + $query = array( + 'where' => (object) array( + 'type' => 'post', + 'ids' => array( $newer_id, $older_id ), + ), + 'fields' => array( 'id' ), + 'pagination' => (object) array( + 'page' => 2, + 'per_page' => 1, + ), + ); + + $expected_query = array( + 'items' => array( + array( 'id' => $older_id ), + ), + 'pagination' => array( + 'total' => 2, + 'total_pages' => 2, + ), + ); + + $this->assertSame( $expected_query, $ability->execute( $query ), 'Object where and pagination values in array input should filter and page the query.' ); + $this->assertSame( $expected_query, $ability->execute( (object) $query ), 'Object where and pagination values in object input should filter and page the query.' ); + } + + /** + * A post queried by ID can return explicitly requested rendered and raw content. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_by_id_can_return_content_fields(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::$post_ids['published_content']; + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'id', 'type', 'content.rendered', 'content.raw' ), + ) + ); + + $this->assertSame( array( $post_id ), wp_list_pluck( $result['items'], 'id' ), 'The query by ID should return only the requested post.' ); + $this->assertSameSets( array( 'id', 'type', 'content' ), array_keys( $result['items'][0] ), 'The query by ID should return only the requested fields.' ); + $this->assertSameSets( array( 'raw', 'rendered' ), array_keys( $result['items'][0]['content'] ), 'The content should hold only the requested sub-fields.' ); + $this->assertSame( 'post', $result['items'][0]['type'], 'The query by ID should return the post type as type.' ); + $this->assertStringContainsString( 'Body here.', $result['items'][0]['content']['rendered'], 'Explicit content fields should include rendered content.' ); + $this->assertSame( 'Body here.', $result['items'][0]['content']['raw'], 'Explicit content fields should include raw content.' ); + $this->assertSame( + array( + 'total' => 1, + 'total_pages' => 1, + ), + $result['pagination'], + 'The query by ID should report one post on one page.' + ); + } + + /** + * Returns `where` values whose IDs or slugs match no post of the post type: a missing ID + * and a slug that no post has. + * + * @return array}> The `where` values. + */ + public function data_where_values_that_match_no_post(): array { + return array( + 'missing ID' => array( + 'where' => array( + 'type' => 'post', + 'ids' => array( REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ), + ), + ), + 'slug that no post has' => array( + 'where' => array( + 'type' => 'post', + 'slugs' => array( 'no-such-slug' ), + ), + ), + ); + } + + /** + * A query whose IDs or slugs match no post of the post type returns the empty result, + * rather than an error. + * + * @ticket 66268 + * @dataProvider data_where_values_that_match_no_post + * @since 7.2.0 + * + * @param array $where The `where` value to send. + */ + public function test_ids_or_slugs_that_match_no_post_return_the_empty_result( array $where ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( array( 'where' => $where ) ); + + $this->assertSame( self::EMPTY_RESULT, $result, 'A query whose IDs or slugs match no post should return the empty result.' ); + } + + /** + * A post in a status that is public but not viewable is returned only to users who can edit + * it. + * + * The default statuses of a subscriber leave the status out of the query. Those of a + * contributor and an author cover it, and the post is withheld from them because they + * cannot edit it. + * + * @ticket 66268 + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * @dataProvider data_roles_with_edit_access_to_other_users_posts + * @since 7.2.0 + * + * @param string $role The role to test. + */ + public function test_public_non_viewable_status_is_returned_only_with_edit_access( string $role ): void { + register_post_status( + 'public_hidden', + array( + 'label' => 'Public hidden', + 'public' => true, + 'publicly_queryable' => false, + ) + ); + + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_status' => 'public_hidden', + ) + ); + + $this->login_as( $role ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( + current_user_can( 'edit_post', $post_id ) ? array( $post_id ) : array(), + wp_list_pluck( $result['items'], 'id' ), + 'A post in a public status that is not viewable should be returned only to users who can edit it.' + ); + } + + /** + * Returns `where` filters that name a post in the publicly viewable `public_archive` status. + * + * @return array}> Filter test cases. + */ + public function data_filters_naming_a_post_in_a_viewable_custom_status(): array { + return array( + 'ids' => array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::IMPORTED_POST_ID ), + ), + ), + 'slugs' => array( + 'where' => array( + 'type' => 'post', + 'slugs' => array( 'public-archive-post' ), + ), + ), + 'status' => array( + 'where' => array( + 'type' => 'post', + 'status' => array( 'public_archive' ), + ), + ), + ); + } + + /** + * A post in a publicly viewable custom status is returned to a subscriber, who cannot edit + * it, whether the query names it by ID, by slug or by status. + * + * @ticket 66268 + * @dataProvider data_filters_naming_a_post_in_a_viewable_custom_status + * @since 7.2.0 + * + * @param array $where The `where` filters. + */ + public function test_viewable_custom_status_is_readable_by_subscribers( array $where ): void { + register_post_status( + 'public_archive', + array( + 'label' => 'Public archive', + 'public' => true, + ) + ); + + $post_id = self::factory()->post->create( + array( + 'import_id' => self::IMPORTED_POST_ID, + 'post_author' => self::$user_ids['administrator'], + 'post_name' => 'public-archive-post', + 'post_status' => 'public_archive', + ) + ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => $where, + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( + array( + 'items' => array( + array( 'id' => $post_id ), + ), + 'pagination' => array( + 'total' => 1, + 'total_pages' => 1, + ), + ), + $result, + 'A subscriber should get the post in a publicly viewable custom status.' + ); + } + + /** + * Registers the exposed `inherit_cpt` post type, and adds the `inherit` status to the + * ability's queries for it. + * + * `inherit` is an internal status, so neither `where.status` nor the default statuses cover + * it, and only a query filter can add posts in it. The ability's query runs + * `pre_get_posts`, so a callback without an is_main_query() check can. + * + * @since 7.2.0 + */ + private function register_inherit_post_type(): void { + register_post_type( + 'inherit_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + add_action( + 'pre_get_posts', + static function ( WP_Query $query ): void { + if ( in_array( 'inherit_cpt', (array) $query->get( 'post_type' ), true ) ) { + $query->set( 'post_status', array( 'publish', 'inherit' ) ); + } + } + ); + } + + /** + * A post that inherits its status from a readable parent is readable. + * + * A query filter adds the `inherit` status, which no input can request, so the post reaches + * the read check. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_inherited_post_is_readable_when_parent_is_readable(): void { + $this->register_inherit_post_type(); + + $parent_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_type' => 'inherit_cpt', + 'post_status' => 'publish', + ) + ); + $child_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_type' => 'inherit_cpt', + 'post_parent' => $parent_id, + 'post_status' => 'inherit', + 'post_title' => 'Inherited child', + ) + ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'inherit_cpt', + 'ids' => array( $child_id ), + ), + ) + ); + + $this->assertIsArray( $result, 'Inherited posts should be readable when their parent is readable.' ); + $this->assertSame( $child_id, $result['items'][0]['id'], 'The inherited child should be returned.' ); + $this->assertSame( 'Inherited child', $result['items'][0]['title']['rendered'], 'The inherited child should include normal default fields.' ); + } + + /** + * A post with an inherited status but no readable parent is denied. + * + * A query filter adds the `inherit` status, which no input can request, so the post reaches + * the read check. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_inherited_post_without_parent_is_denied_for_read_only_users(): void { + $this->register_inherit_post_type(); + + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_type' => 'inherit_cpt', + 'post_status' => 'inherit', + ) + ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'inherit_cpt', + 'ids' => array( $post_id ), + ), + ) + ); + + $this->assertIsArray( $result, 'An inherited post without a readable parent should not fail the query.' ); + $this->assertSame( 1, $result['pagination']['total'], 'Precondition: the query filter should add the inherited post.' ); + $this->assertSame( array(), $result['items'], 'Inherited posts without a readable parent should be denied.' ); + } + + /** + * A query without IDs or slugs returns only published posts by default. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_returns_only_published_by_default(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $published = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $draft = self::factory()->post->create( array( 'post_status' => 'draft' ) ); + + $result = wp_get_ability( 'core/content-query' )->execute( array( 'where' => array( 'type' => 'post' ) ) ); + $ids = wp_list_pluck( $result['items'], 'id' ); + + $this->assertContains( $published, $ids, 'Published posts should be returned by default.' ); + $this->assertNotContains( $draft, $ids, 'Draft posts should not be returned by default.' ); + } + + /** + * Query results are ordered by post date, newest first, whatever order `where.ids` uses. + * + * Pins both halves of what the schema advertises. The ability leaves `orderby` at the + * WP_Query default, matching the REST posts controller, and `ids` only filters the query, + * so a caller that passes IDs in a chosen order must not expect them back in it. The posts + * are created out of date order, so that ordering by ID would fail. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_orders_posts_newest_first_regardless_of_ids_order(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $newest = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-03-01 10:00:00', + ) + ); + $oldest = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + $middle = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-02-01 10:00:00', + ) + ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + // Deliberately neither date order nor ID order. + 'ids' => array( $oldest, $newest, $middle ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( + array( $newest, $middle, $oldest ), + wp_list_pluck( $result['items'], 'id' ), + 'Results should be ordered by post date, newest first, not by the order of the ids list.' + ); + } + + /** + * Query IDs still respect the requested post type. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_ids_respect_requested_post_type(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $page_id, $post_id ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $post_id ), wp_list_pluck( $result['items'], 'id' ), 'IDs should not leak posts from other post types.' ); + } + + /** + * Query IDs accept a single post ID, as the input schema does. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_ids_accept_a_single_post_id(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::$post_ids['published']; + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => $post_id, + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $post_id ), wp_list_pluck( $result['items'], 'id' ), 'A single post ID should limit the query to that post.' ); + } + + /** + * Query IDs still respect row-level permissions. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_ids_respect_row_level_permissions(): void { + $author_a = self::$user_ids['author']; + $author_b = self::$user_ids['author_secondary']; + + $draft_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'draft', + ) + ); + $draft_b = self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'draft', + ) + ); + + wp_set_current_user( $author_b ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'status' => array( 'draft' ), + 'ids' => array( $draft_a, $draft_b ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $draft_b ), wp_list_pluck( $result['items'], 'id' ), 'IDs should not bypass row-level draft permissions.' ); + } + + /** + * A query can return drafts named by ID with explicitly requested rendered and raw content. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_draft_ids_can_return_content_fields(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $draft = self::factory()->post->create( + array( + 'post_title' => 'Draft content fields', + 'post_content' => 'Draft body for content fields.', + 'post_status' => 'draft', + ) + ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'status' => array( 'draft' ), + 'ids' => array( $draft ), + ), + 'fields' => array( 'id', 'type', 'status', 'content.rendered', 'content.raw' ), + ) + ); + + $this->assertSame( array( $draft ), wp_list_pluck( $result['items'], 'id' ), 'The draft query should return only the requested draft.' ); + $this->assertSameSets( array( 'id', 'type', 'status', 'content' ), array_keys( $result['items'][0] ), 'The draft query should return only the requested fields.' ); + $this->assertSameSets( array( 'raw', 'rendered' ), array_keys( $result['items'][0]['content'] ), 'The draft content should hold only the requested sub-fields.' ); + $this->assertSame( 'post', $result['items'][0]['type'], 'Query responses should return the post type as type.' ); + $this->assertSame( 'draft', $result['items'][0]['status'], 'The draft query should expose the requested draft status.' ); + $this->assertStringContainsString( 'Draft body for content fields.', $result['items'][0]['content']['rendered'], 'Draft query results should include rendered content when requested.' ); + $this->assertSame( 'Draft body for content fields.', $result['items'][0]['content']['raw'], 'Draft query results should include raw content when requested.' ); + } + + /** + * A query by several slugs returns every listed post, newest first, on one page. + * + * Each slug is normalized like a post slug, so a slug in capitals matches its lowercase + * slug, and the slug "0" matches too. With slugs, the page size defaults to the maximum of + * 100 instead of 10, so the posts of more than 10 slugs still fit on the first page. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_slugs_return_every_listed_post(): void { + /* + * Core regenerates an "empty" post_name from the title, so a post titled "0" + * ends up with the literal slug "0". It is the newest post, and is created first + * so that ID order is not date order. + */ + $zero_id = self::factory()->post->create( + array( + 'post_title' => '0', + 'post_name' => '0', + 'post_status' => 'publish', + 'post_date' => '2026-01-12 10:00:00', + ) + ); + + $this->assertSame( '0', get_post( $zero_id )->post_name, 'Precondition: the post slug should be the literal string "0".' ); + + $slugs = array(); + $post_ids = array(); + for ( $day = 1; $day <= 11; $day++ ) { + $slugs[] = "listed-slug-{$day}"; + $post_ids[] = self::factory()->post->create( + array( + 'post_name' => "listed-slug-{$day}", + 'post_status' => 'publish', + 'post_date' => sprintf( '2026-01-%02d 10:00:00', $day ), + ) + ); + } + + $slugs[] = '0'; + $post_ids[] = $zero_id; + + // Request one slug in capitals, and the slugs in neither date order nor slug order. + $slugs[2] = 'LISTED-SLUG-3'; + $requested = array_merge( array_slice( $slugs, 6 ), array_reverse( array_slice( $slugs, 0, 6 ) ) ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'slugs' => $requested, + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'The query by several slugs should succeed.' ); + + $ids = wp_list_pluck( $result['items'], 'id' ); + + $this->assertContains( $post_ids[2], $ids, 'A slug in capitals should match its lowercase slug.' ); + $this->assertContains( $zero_id, $ids, 'The slug "0" should match its post.' ); + $this->assertSame( array_reverse( $post_ids ), $ids, 'Every listed post should be returned, newest first.' ); + $this->assertSame( + array( + 'total' => 12, + 'total_pages' => 1, + ), + $result['pagination'], + 'With slugs, the page size should default to 100, so every listed post should fit on one page.' + ); + } + + /** + * A slug that several posts share returns every one of them, newest first, and the + * `parent` filter tells them apart, with a parent of 0 for the top-level page. + * + * Pages under the same parent cannot share a slug, but a top-level page and a child page + * can. With slugs, the page size defaults to the maximum of 100, so without a page size + * both pages come back on the first page, while an explicit page size of one splits them + * over two pages. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_slugs_return_pages_with_the_same_slug_under_different_parents(): void { + $parent_id = self::factory()->post->create( array( 'post_type' => 'page' ) ); + $page_ids = array(); + foreach ( array( 0, $parent_id ) as $index => $page_parent ) { + $page_ids[] = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_parent' => $page_parent, + 'post_name' => 'shared-slug', + 'post_status' => 'publish', + 'post_date' => sprintf( '2026-%02d-01 10:00:00', $index + 1 ), + ) + ); + } + + $this->assertSame( array( 'shared-slug', 'shared-slug' ), wp_list_pluck( array_map( 'get_post', $page_ids ), 'post_name' ), 'Precondition: a top-level page and a child page should share the slug.' ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $ability = wp_get_ability( 'core/content-query' ); + $where = array( + 'type' => 'page', + 'slugs' => array( 'shared-slug' ), + ); + + $both = $ability->execute( + array( + 'where' => $where, + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $both, 'The query by a shared slug should succeed.' ); + $this->assertSame( array_reverse( $page_ids ), wp_list_pluck( $both['items'], 'id' ), 'Both pages sharing the slug should be returned, newest first.' ); + $this->assertSame( + array( + 'total' => 2, + 'total_pages' => 1, + ), + $both['pagination'], + 'Without a page size, both pages sharing the slug should fit on the first page.' + ); + + $first_page = $ability->execute( + array( + 'where' => $where, + 'fields' => array( 'id' ), + 'pagination' => array( 'per_page' => 1 ), + ) + ); + + $this->assertSame( + array( + 'items' => array( + array( 'id' => $page_ids[1] ), + ), + 'pagination' => array( + 'total' => 2, + 'total_pages' => 2, + ), + ), + $first_page, + 'An explicit page size of one should return the newest page first, and the totals should count both pages.' + ); + + $by_parent = $ability->execute( + array( + 'where' => $where + array( 'parent' => $parent_id ), + 'fields' => array( 'id' ), + ) + ); + $top_level = $ability->execute( + array( + 'where' => $where + array( 'parent' => 0 ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $page_ids[1] ), wp_list_pluck( $by_parent['items'], 'id' ), 'The parent filter should return only the child page.' ); + $this->assertSame( array( $page_ids[0] ), wp_list_pluck( $top_level['items'], 'id' ), 'A parent of 0 should return only the top-level page.' ); + } + + /** + * A slug that normalizes to an empty string cannot match a post, so it is dropped, while + * the other slugs still apply. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_slugs_that_normalize_to_nothing_are_dropped(): void { + $post_id = self::factory()->post->create( + array( + 'post_name' => 'real-slug', + 'post_status' => 'publish', + ) + ); + + // A draft has no slug yet, so an empty slug would match it. + self::factory()->post->create( array( 'post_status' => 'draft' ) ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'slugs' => array( '!!!', 'real-slug' ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'A slug that normalizes to nothing should not fail the query while another slug is left.' ); + $this->assertSame( array( $post_id ), wp_list_pluck( $result['items'], 'id' ), 'The post with the real slug should be returned.' ); + } + + /** + * `where.ids` and `where.slugs` combine like every other filter: a post must be in both + * lists. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_ids_and_slugs_together_return_only_posts_in_both_lists(): void { + $post_ids = array(); + foreach ( array( 'a', 'b', 'c' ) as $name ) { + $post_ids[ $name ] = self::factory()->post->create( + array( + 'post_name' => "both-lists-{$name}", + 'post_status' => 'publish', + ) + ); + } + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_ids['a'], $post_ids['b'] ), + 'slugs' => array( 'both-lists-b', 'both-lists-c' ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( + array( + 'items' => array( + array( 'id' => $post_ids['b'] ), + ), + 'pagination' => array( + 'total' => 1, + 'total_pages' => 1, + ), + ), + $result, + 'Only the post in both lists should be returned.' + ); + } + + /** + * Returns a role that cannot read another user's draft and a role that can, with the posts + * that share a slug that each role gets, newest first. + * + * @return array}> Role test cases. + */ + public function data_roles_with_and_without_access_to_others_drafts(): array { + return array( + 'subscriber' => array( + 'role' => 'subscriber', + 'expected' => array( 'published' ), + ), + 'editor' => array( + 'role' => 'editor', + 'expected' => array( 'draft', 'published' ), + ), + ); + } + + /** + * A newer draft that shares a published post's slug comes back next to the published post + * for a role that can read the draft, and is withheld from a role that cannot. + * + * Every post that matches a slug is returned. A subscriber's default statuses do not cover + * drafts, while an editor's do. + * + * @ticket 66268 + * @dataProvider data_roles_with_and_without_access_to_others_drafts + * @since 7.2.0 + * + * @param string $role The role to query the slug as. + * @param list $expected The posts the role should get, newest first. + */ + public function test_slugs_return_a_newer_draft_sharing_the_slug_only_to_roles_that_can_read_it( string $role, array $expected ): void { + $published_id = self::factory()->post->create( + array( + 'post_name' => 'shadowed-slug', + 'post_status' => 'publish', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + // Drafts skip slug uniqueness, so a newer draft can share the published slug. + $draft_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_name' => 'shadowed-slug', + 'post_status' => 'draft', + 'post_date' => '2026-06-01 10:00:00', + ) + ); + + $this->assertSame( 'shadowed-slug', get_post( $draft_id )->post_name, 'Precondition: the draft should share the published slug.' ); + + $this->register_ability(); + $this->login_as( $role ); + + $posts = array( + 'published' => $published_id, + 'draft' => $draft_id, + ); + $expected_ids = array_map( + static function ( string $name ) use ( $posts ): int { + return $posts[ $name ]; + }, + $expected + ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'slugs' => array( 'shadowed-slug' ), + ), + ) + ); + + $this->assertIsArray( $result, 'The query by slug should succeed.' ); + $this->assertSame( $expected_ids, wp_list_pluck( $result['items'], 'id' ), 'The query by slug should return every post with the slug that the role may read, newest first.' ); + $this->assertCount( $result['pagination']['total'], $expected, 'The total should count every post with the slug in the statuses the role may query.' ); + } + + /** + * An unknown requested field path fails schema validation. + * + * Unlike fields a post type does not support, which are omitted per post, a field path + * that is not part of the supported set is rejected before the ability executes. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_unknown_requested_field_fails_schema_validation(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::$post_ids['published_content'] ), + ), + 'fields' => array( 'id', 'bogus_field' ), + ) + ); + + $this->assertWPError( $result, 'An unknown requested field should fail the request.' ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'Unknown fields should use the invalid input error.' ); + } + + /** + * Returns field paths that each name one sub-field. + * + * @return array Field path test cases. + */ + public function data_sub_field_paths(): array { + return array( + 'title.rendered' => array( + 'field' => 'title.rendered', + ), + 'content.raw' => array( + 'field' => 'content.raw', + ), + 'content.rendered' => array( + 'field' => 'content.rendered', + ), + 'date.gmt' => array( + 'field' => 'date.gmt', + ), + 'modified.local' => array( + 'field' => 'modified.local', + ), + 'author.name' => array( + 'field' => 'author.name', + ), + 'content.protected' => array( + 'field' => 'content.protected', + ), + ); + } + + /** + * A sub-field path returns only that sub-field, inside its object. + * + * @ticket 66268 + * @dataProvider data_sub_field_paths + * @since 7.2.0 + * + * @param string $field The field path to request, such as `title.rendered`. + */ + public function test_sub_field_path_returns_only_that_sub_field( string $field ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + list( $object, $key ) = explode( '.', $field ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::$post_ids['limited_role_content'] ), + ), + 'fields' => array( $field ), + ) + ); + + $this->assertIsArray( $result, 'The query should succeed.' ); + $this->assertSameSets( array( 'id', $object ), array_keys( $result['items'][0] ), 'Only the ID and the object that holds the sub-field should be returned.' ); + $this->assertSame( array( $key ), array_keys( $result['items'][0][ $object ] ), 'The object should hold only the requested sub-field.' ); + } + + /** + * Returns the fields that are objects, with the keys each one holds. + * + * @return array}> Object field test cases. + */ + public function data_object_fields(): array { + return array( + 'title' => array( + 'field' => 'title', + 'keys' => array( 'raw', 'rendered' ), + ), + 'excerpt' => array( + 'field' => 'excerpt', + 'keys' => array( 'raw', 'rendered', 'protected' ), + ), + 'content' => array( + 'field' => 'content', + 'keys' => array( 'raw', 'rendered', 'protected' ), + ), + 'date' => array( + 'field' => 'date', + 'keys' => array( 'local', 'gmt' ), + ), + 'modified' => array( + 'field' => 'modified', + 'keys' => array( 'local', 'gmt' ), + ), + 'author' => array( + 'field' => 'author', + 'keys' => array( 'id', 'name', 'slug' ), + ), + ); + } + + /** + * A whole object returns every key it holds to a user who can edit the post. + * + * @ticket 66268 + * @dataProvider data_object_fields + * @since 7.2.0 + * + * @param string $field The object field to request. + * @param list $keys The keys the object should hold, in order. + */ + public function test_whole_object_returns_every_key_to_a_user_who_can_edit_the_post( string $field, array $keys ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::$post_ids['limited_role_content'] ), + ), + 'fields' => array( $field ), + ) + ); + + $this->assertIsArray( $result, 'The query should succeed.' ); + $this->assertSame( array( 'id', $field ), array_keys( $result['items'][0] ), 'Only the ID and the requested object should be returned.' ); + $this->assertSame( $keys, array_keys( $result['items'][0][ $field ] ), 'The object should hold every key, in order.' ); + } + + /** + * Whole title, excerpt and content objects leave out the raw values for users who cannot + * edit the post, rather than failing the request. + * + * @ticket 66268 + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * @since 7.2.0 + * + * @param string $role The role to test. + */ + public function test_whole_objects_omit_raw_values_for_roles_without_edit_access_to_other_users_posts( string $role ): void { + $this->login_as( $role ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::$post_ids['limited_role_content'] ), + ), + 'fields' => array( 'title', 'excerpt', 'content' ), + ) + ); + + $this->assertIsArray( $result, 'Whole objects should not require edit access.' ); + $this->assertSameSets( array( 'id', 'title', 'excerpt', 'content' ), array_keys( $result['items'][0] ), 'Only the ID and the requested objects should be returned.' ); + $this->assertSame( array( 'rendered' ), array_keys( $result['items'][0]['title'] ), 'The title should hold only the rendered title.' ); + $this->assertSameSets( array( 'rendered', 'protected' ), array_keys( $result['items'][0]['excerpt'] ), 'The excerpt should hold the rendered excerpt and the protected flag.' ); + $this->assertSameSets( array( 'rendered', 'protected' ), array_keys( $result['items'][0]['content'] ), 'The content should hold the rendered content and the protected flag.' ); + } + + /** + * A query for the whole content is not narrowed to the posts the current user can edit, + * and holds the raw content only on those posts. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_for_whole_content_is_not_narrowed_to_editable_posts(): void { + $own_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_content' => 'Own body.', + 'post_status' => 'publish', + ) + ); + $other_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author_secondary'], + 'post_content' => 'Other body.', + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'author' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $own_id, $other_id ), + ), + 'fields' => array( 'content' ), + ) + ); + + $this->assertIsArray( $result, 'An author should query the whole content.' ); + + $posts = array_column( $result['items'], null, 'id' ); + + $this->assertSameSets( array( $own_id, $other_id ), array_keys( $posts ), 'The query should return the posts of other users as well as the author\'s own.' ); + $this->assertSameSets( array( 'raw', 'rendered', 'protected' ), array_keys( $posts[ $own_id ]['content'] ), 'The author\'s own post should hold every content key.' ); + $this->assertSame( 'Own body.', $posts[ $own_id ]['content']['raw'], 'The author\'s own post should hold the raw content.' ); + $this->assertSameSets( array( 'rendered', 'protected' ), array_keys( $posts[ $other_id ]['content'] ), 'Another user\'s post should not hold the raw content.' ); + $this->assertStringContainsString( 'Other body.', $posts[ $other_id ]['content']['rendered'], 'Another user\'s post should hold the rendered content.' ); + } + + /** + * A query for an explicit raw path is not narrowed to the posts the current user can edit + * either, and leaves the raw content out of the other posts. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_for_an_explicit_raw_path_is_not_narrowed_to_editable_posts(): void { + $own_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_content' => 'Own body.', + 'post_status' => 'publish', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + $other_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author_secondary'], + 'post_content' => 'Other body.', + 'post_status' => 'publish', + 'post_date' => '2026-02-01 10:00:00', + ) + ); + + $this->login_as( 'author' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $own_id, $other_id ), + ), + 'fields' => array( 'content.raw' ), + ) + ); + + $this->assertSame( + array( + 'items' => array( + array( 'id' => $other_id ), + array( + 'id' => $own_id, + 'content' => array( 'raw' => 'Own body.' ), + ), + ), + 'pagination' => array( + 'total' => 2, + 'total_pages' => 1, + ), + ), + $result, + 'The query should return another user\'s post without the raw content, next to the author\'s own post with it.' + ); + } + + /** + * A path next to its whole object returns the whole object, without the sub-fields the + * current user cannot see. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_whole_object_with_one_of_its_paths_follows_the_path_rules(): void { + $this->register_ability(); + + $ability = wp_get_ability( 'core/content-query' ); + $post_id = self::$post_ids['limited_role_content']; + + $this->login_as( 'subscriber' ); + $without_raw = $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'title', 'title.raw' ), + ) + ); + + $this->assertSame( + array( + 'items' => array( + array( + 'id' => $post_id, + 'title' => array( 'rendered' => 'Readable title' ), + ), + ), + 'pagination' => array( + 'total' => 1, + 'total_pages' => 1, + ), + ), + $without_raw, + 'A raw title path next to the whole title should return the whole title without the raw title to a subscriber.' + ); + + $this->login_as( 'editor' ); + $result = $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'title', 'title.rendered' ), + ) + ); + + $this->assertIsArray( $result, 'An editor should read the whole title.' ); + $this->assertSameSets( array( 'id', 'title' ), array_keys( $result['items'][0] ), 'Only the ID and the title should be returned.' ); + $this->assertSame( + array( + 'raw' => 'Readable title', + 'rendered' => 'Readable title', + ), + $result['items'][0]['title'], + 'A rendered title path next to the whole title should return the whole title.' + ); + } + + /** + * The author object holds the author's ID, display name and slug, and keeps the stored ID + * with a null name and slug when the user no longer exists. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_author_object_describes_the_author(): void { + $author_id = self::factory()->user->create( + array( + 'role' => 'author', + 'display_name' => 'Content Author', + 'user_nicename' => 'content-author', + ) + ); + $post_id = self::factory()->post->create( + array( + 'post_author' => $author_id, + 'post_status' => 'publish', + ) + ); + + // No user has this ID, as when the author was deleted after writing the post. + $orphan_id = self::factory()->post->create( + array( + 'post_author' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER, + 'post_status' => 'publish', + ) + ); + + $this->assertFalse( get_userdata( REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ), 'Precondition: no user should have the stored author ID.' ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ability = wp_get_ability( 'core/content-query' ); + + $result = $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'author' ), + ) + ); + $orphan = $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $orphan_id ), + ), + 'fields' => array( 'author' ), + ) + ); + + $this->assertSameSets( array( 'id', 'author' ), array_keys( $result['items'][0] ), 'Only the ID and the author should be returned.' ); + $this->assertSame( + array( + 'id' => $author_id, + 'name' => 'Content Author', + 'slug' => 'content-author', + ), + $result['items'][0]['author'], + 'The author object should hold the author\'s ID, display name and slug.' + ); + $this->assertSame( + array( + 'id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER, + 'name' => null, + 'slug' => null, + ), + $orphan['items'][0]['author'], + 'The author object should keep the stored ID, with a null name and slug, when the user no longer exists.' + ); + } + + /** + * Requested objects that do not apply to the post type are left out, and the post keeps its + * ID. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_objects_that_do_not_apply_are_left_out(): void { + register_post_type( + 'untitled_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'editor' ), + ) + ); + + $this->assertFalse( post_type_supports( 'untitled_cpt', 'title' ), 'Precondition: the post type should not support titles.' ); + $this->assertFalse( post_type_supports( 'untitled_cpt', 'author' ), 'Precondition: the post type should not support authors.' ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'untitled_cpt', + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'untitled_cpt', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'title', 'author' ), + ) + ); + + $this->assertIsArray( $result, 'The query should succeed.' ); + $this->assertSame( array( 'id' => $post_id ), $result['items'][0], 'Objects that do not apply should be left out, and the post should keep its ID.' ); + } + + /** + * Returned keys follow the order of the fields enum, whatever order the fields are + * requested in, both at the top level and inside each object. + * + * The second query requests every top-level field in reverse order, on a hierarchical + * post type that supports every object, so that every top-level field applies. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_returned_keys_follow_the_fields_order(): void { + register_post_type( + 'every_field_cpt', + array( + 'public' => true, + 'hierarchical' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor', 'author', 'excerpt' ), + ) + ); + + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_type' => 'every_field_cpt', + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::$post_ids['limited_role_content'] ), + ), + 'fields' => array( 'title', 'id', 'date.gmt', 'content.protected', 'content.raw' ), + ) + ); + + $this->assertIsArray( $result, 'The query should succeed.' ); + $this->assertSame( array( 'id', 'date', 'title', 'content' ), array_keys( $result['items'][0] ), 'Top-level keys should follow the fields order.' ); + $this->assertSame( array( 'raw', 'protected' ), array_keys( $result['items'][0]['content'] ), 'Keys inside an object should follow the fields order.' ); + + // Every top-level field of the fields enum, in its order. + $top_level_fields = array( 'id', 'type', 'status', 'date', 'modified', 'slug', 'link', 'title', 'excerpt', 'content', 'author', 'parent' ); + + $every_field = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'every_field_cpt', + 'ids' => array( $post_id ), + ), + 'fields' => array_reverse( $top_level_fields ), + ) + ); + + $this->assertIsArray( $every_field, 'The query for every top-level field should succeed.' ); + $this->assertSame( $top_level_fields, array_keys( $every_field['items'][0] ), 'Every top-level key should be returned in the fields order, whatever order the fields are requested in.' ); + } + + /** + * Logged-out users cannot run the ability. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_logged_out_user_is_denied(): void { + wp_set_current_user( 0 ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( array( 'where' => array( 'type' => 'post' ) ) ); + + $this->assertWPError( $result, 'Logged-out users should not be allowed to run the content ability.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Logged-out users should receive a permission error.' ); + } + + /** + * Subscribers can request rendered published content. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_subscriber_can_request_published_content(): void { + $post_id = self::$post_ids['published_content']; + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( 'type' => 'post' ), + 'fields' => array( 'id', 'title.rendered', 'content.rendered' ), + ) + ); + $ids = wp_list_pluck( $result['items'], 'id' ); + + $this->assertContains( $post_id, $ids, 'Subscribers should be able to query readable published posts.' ); + $post = $result['items'][ array_search( $post_id, $ids, true ) ]; + $this->assertSame( 'Hello Content', $post['title']['rendered'], 'Subscribers should receive rendered titles.' ); + $this->assertStringContainsString( 'Body here.', $post['content']['rendered'], 'Subscribers should receive rendered content.' ); + $this->assertSame( array( 'rendered' ), array_keys( $post['content'] ), 'Subscribers should receive only the requested rendered content.' ); + } + + /** + * Users who cannot edit another user's post get it without the raw fields they explicitly + * request, rather than an error or no post. + * + * @ticket 66268 + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * @since 7.2.0 + * + * @param string $role The role to test. + */ + public function test_raw_field_requests_omit_raw_values_for_roles_without_edit_access_to_other_users_posts( string $role ): void { + $post_id = self::$post_ids['limited_role_content']; + + $this->login_as( $role ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'title.raw', 'excerpt.raw', 'content.raw' ), + ) + ); + + $this->assertSame( + array( + 'items' => array( + array( 'id' => $post_id ), + ), + 'pagination' => array( + 'total' => 1, + 'total_pages' => 1, + ), + ), + $result, + 'Raw field requests should return the readable post without the raw values.' + ); + } + + /** + * Returns statuses that are neither publicly viewable nor `private`: a draft, a public status + * that is not viewable, and a custom status flagged private, which WP_Query does not narrow + * to the current user's own posts as it does `private`. + * + * @return array Status test cases. + */ + public function data_statuses_that_require_edit_access(): array { + return array( + 'draft' => array( + 'status' => 'draft', + ), + 'public status, not viewable' => array( + 'status' => 'public_hidden', + ), + 'custom status flagged private' => array( + 'status' => 'members_only', + ), + ); + } + + /** + * Subscribers cannot request posts in a status that requires edit access. + * + * @ticket 66268 + * @dataProvider data_statuses_that_require_edit_access + * @since 7.2.0 + * + * @param string $status The status to request. + */ + public function test_subscriber_cannot_request_statuses_that_require_edit_access( string $status ): void { + register_post_status( + 'public_hidden', + array( + 'label' => 'Public hidden', + 'public' => true, + 'publicly_queryable' => false, + ) + ); + register_post_status( + 'members_only', + array( + 'label' => 'Members only', + 'private' => true, + ) + ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'status' => array( $status ), + ), + ) + ); + + $this->assertWPError( $result, 'Subscribers should not be allowed to query posts in a status that requires edit access.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'A subscriber query for a status that requires edit access should return a permission error.' ); + } + + /** + * Subscribers can query private posts, and get only their own. + * + * Another user's private post is neither returned nor counted, because a subscriber lacks + * `read_private_posts`. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_subscriber_gets_only_their_own_private_posts(): void { + $own_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['subscriber'], + 'post_status' => 'private', + ) + ); + $other_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'private', + ) + ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $ability = wp_get_ability( 'core/content-query' ); + $expected = array( + 'items' => array( + array( 'id' => $own_id ), + ), + 'pagination' => array( + 'total' => 1, + 'total_pages' => 1, + ), + ); + + $by_status = $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'status' => array( 'private' ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( $expected, $by_status, 'A query for private posts should return only the subscriber\'s own private post.' ); + + $by_ids = $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $own_id, $other_id ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( $expected, $by_ids, 'A query by ID without a status should return only the subscriber\'s own private post.' ); + } + + /** + * Registers a post type with its own capabilities, such as `edit_books`, instead of those + * of posts. + * + * No role has these capabilities, so tests grant them with + * create_subscriber_with_capabilities(). + * + * @since 7.2.0 + */ + private function register_post_type_with_own_capabilities(): void { + register_post_type( + 'book', + array( + 'public' => true, + 'show_in_abilities' => true, + 'capability_type' => array( 'book', 'books' ), + 'map_meta_cap' => true, + 'supports' => array( 'title', 'editor', 'author' ), + ) + ); + } + + /** + * Creates a subscriber who also has the given capabilities. + * + * @since 7.2.0 + * + * @param list $capabilities The capabilities to grant. + * @return int The user ID. + */ + private function create_subscriber_with_capabilities( array $capabilities ): int { + $user = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + foreach ( $capabilities as $capability ) { + $user->add_cap( $capability ); + } + + return $user->ID; + } + + /** + * Querying the drafts of a post type with its own capabilities requires the edit capability + * of that post type, not that of posts. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_drafts_of_a_post_type_with_own_capabilities_require_its_edit_capability(): void { + $this->register_post_type_with_own_capabilities(); + + $draft_id = self::factory()->post->create( + array( + 'post_type' => 'book', + 'post_author' => self::$user_ids['author'], + 'post_status' => 'draft', + ) + ); + + $this->register_ability(); + + $ability = wp_get_ability( 'core/content-query' ); + $input = array( + 'where' => array( + 'type' => 'book', + 'status' => array( 'draft' ), + ), + 'fields' => array( 'id' ), + ); + + $this->login_as( 'editor' ); + $denied = $ability->execute( $input ); + + $this->assertWPError( $denied, 'An editor, who can edit posts but not books, should not query draft books.' ); + $this->assertSame( 'ability_invalid_permissions', $denied->get_error_code(), 'A draft query without the edit capability of the post type should return a permission error.' ); + + wp_set_current_user( $this->create_subscriber_with_capabilities( array( 'edit_books', 'edit_others_books' ) ) ); + + $this->assertSame( + array( + 'items' => array( + array( 'id' => $draft_id ), + ), + 'pagination' => array( + 'total' => 1, + 'total_pages' => 1, + ), + ), + $ability->execute( $input ), + 'A user who can edit others\' books, but not posts, should get another user\'s draft book.' + ); + } + + /** + * Another user's private post of a post type with its own capabilities is only returned and + * counted with the `read_private` capability of that post type, not that of posts. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_private_posts_of_a_post_type_with_own_capabilities_require_its_read_private_capability(): void { + $this->register_post_type_with_own_capabilities(); + + $private_id = self::factory()->post->create( + array( + 'post_type' => 'book', + 'post_author' => self::$user_ids['author'], + 'post_status' => 'private', + ) + ); + + $this->register_ability(); + + $ability = wp_get_ability( 'core/content-query' ); + $input = array( + 'where' => array( + 'type' => 'book', + 'status' => array( 'private' ), + ), + 'fields' => array( 'id' ), + ); + + $this->login_as( 'editor' ); + + $this->assertSame( self::EMPTY_RESULT, $ability->execute( $input ), 'An editor, who can read private posts but not private books, should neither get nor count another user\'s private book.' ); + + wp_set_current_user( $this->create_subscriber_with_capabilities( array( 'read_private_books' ) ) ); + + $this->assertSame( + array( + 'items' => array( + array( 'id' => $private_id ), + ), + 'pagination' => array( + 'total' => 1, + 'total_pages' => 1, + ), + ), + $ability->execute( $input ), + 'A user who can read private books, but not private posts, should get another user\'s private book.' + ); + } + + /** + * A user who can read private posts but cannot edit posts gets another user's private + * posts, but cannot query drafts, so a query by ID without a status covers the private post + * but not the draft. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_user_who_can_read_private_posts_but_not_edit_posts_gets_private_posts_but_not_drafts(): void { + $private_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'private', + ) + ); + $draft_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'draft', + ) + ); + + wp_set_current_user( $this->create_subscriber_with_capabilities( array( 'read_private_posts' ) ) ); + $this->register_ability(); + + $ability = wp_get_ability( 'core/content-query' ); + $expected = array( + 'items' => array( + array( 'id' => $private_id ), + ), + 'pagination' => array( + 'total' => 1, + 'total_pages' => 1, + ), + ); + + $by_status = $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'status' => array( 'private' ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( $expected, $by_status, 'A query for private posts should return another user\'s private post.' ); + + $drafts = $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'status' => array( 'draft' ), + ), + ) + ); + + $this->assertWPError( $drafts, 'A user who cannot edit posts should not query drafts.' ); + $this->assertSame( 'ability_invalid_permissions', $drafts->get_error_code(), 'A draft query without edit access should return a permission error.' ); + + $by_ids = $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $private_id, $draft_id ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( $expected, $by_ids, 'A query by ID without a status should cover the private post but not the draft.' ); + } + + /** + * Query totals mirror WP_Query even when row-level permissions withhold rows. + * + * This matches the REST posts controller: `items` only contains rows the current user can + * read, while `pagination.total` and `pagination.total_pages` describe the underlying query. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_totals_may_include_rows_withheld_by_row_level_permissions(): void { + $author_a = self::$user_ids['author']; + $author_b = self::$user_ids['author_secondary']; + + self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'draft', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + $draft_b = self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'draft', + 'post_date' => '2026-01-02 10:00:00', + ) + ); + + wp_set_current_user( $author_b ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'status' => array( 'draft' ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $draft_b ), wp_list_pluck( $result['items'], 'id' ), 'Authors should receive only drafts they can read.' ); + $this->assertSame( 2, $result['pagination']['total'], 'Totals should include rows withheld by row-level permission checks.' ); + $this->assertSame( 1, $result['pagination']['total_pages'], 'Page counts should be based on the underlying query total, matching REST behavior.' ); + } + + /** + * Returns roles with the posts that a query by ID without a status returns to them, and the + * total they get. + * + * The posts are named by status and by owner: the `own_` posts belong to the `author` role, + * and the published post and the `other_` posts to another author. + * + * The total counts the posts that the query covers, including withheld ones. Another user's + * private post is only covered with `read_private_posts`, so the subscriber's and the + * author's totals leave it out while the editor's counts it. + * + * @return array, total: int}> Role test cases. + */ + public function data_default_statuses_with_ids_by_role(): array { + return array( + 'subscriber' => array( + 'role' => 'subscriber', + 'expected' => array( 'publish' ), + 'total' => 1, + ), + 'author' => array( + 'role' => 'author', + 'expected' => array( 'publish', 'own_draft', 'own_pending', 'own_future', 'own_private' ), + 'total' => 8, + ), + 'editor' => array( + 'role' => 'editor', + 'expected' => array( 'publish', 'own_draft', 'own_pending', 'own_future', 'own_private', 'other_draft', 'other_pending', 'other_future', 'other_private' ), + 'total' => 9, + ), + ); + } + + /** + * A query by ID without a status covers every status that the current user may query, and + * returns the posts in them that the user may read. + * + * A subscriber may query published and private posts. An author may query every status and + * reads their own posts in any of them, while another user's drafts, pending and scheduled + * posts are withheld, though counted. Another user's private post is neither returned nor + * counted, because the subscriber and the author lack `read_private_posts`. An editor reads + * posts in every status. + * + * @ticket 66268 + * @dataProvider data_default_statuses_with_ids_by_role + * @since 7.2.0 + * + * @param string $role The role to query as. + * @param list $expected The posts the role should get. + * @param int $total The total the role should get. + */ + public function test_ids_without_a_status_cover_the_statuses_each_role_may_query( string $role, array $expected, int $total ): void { + $owners = array( + 'own' => self::$user_ids['author'], + 'other' => self::$user_ids['author_secondary'], + ); + $posts = array( + 'publish' => self::factory()->post->create( + array( + 'post_author' => $owners['other'], + 'post_status' => 'publish', + ) + ), + ); + foreach ( $owners as $owner => $author_id ) { + foreach ( array( 'draft', 'pending', 'future', 'private' ) as $status ) { + $args = array( + 'post_author' => $author_id, + 'post_status' => $status, + ); + if ( 'future' === $status ) { + // A scheduled post needs a date in the future. + $args['post_date'] = '2099-01-01 10:00:00'; + } + + $posts[ "{$owner}_{$status}" ] = self::factory()->post->create( $args ); + } + } + + $this->assertSame( 'future', get_post_status( $posts['own_future'] ), 'Precondition: the scheduled post should keep its status.' ); + + $this->login_as( $role ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array_values( $posts ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'The query by ID should succeed.' ); + $this->assertSameSets( + array_values( array_intersect_key( $posts, array_flip( $expected ) ) ), + wp_list_pluck( $result['items'], 'id' ), + 'The query should return the posts the role may read, in the statuses the role may query.' + ); + $this->assertSame( $total, $result['pagination']['total'], 'The total should count every post the query covers, including withheld ones.' ); + } + + /** + * An author gets their own draft from a query by ID or by slug that gives no status, but + * not from a query without IDs or slugs, whose default status stays `publish`, nor from a + * query whose explicit status leaves drafts out. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_author_gets_their_own_draft_only_by_ids_or_slugs_without_a_status(): void { + $draft_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_name' => 'own-draft', + 'post_status' => 'draft', + ) + ); + + $this->login_as( 'author' ); + $this->register_ability(); + + $ability = wp_get_ability( 'core/content-query' ); + + $by_id = $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $draft_id ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $draft_id ), wp_list_pluck( $by_id['items'], 'id' ), 'A query by ID without a status should return the author\'s own draft.' ); + + $by_slug = $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'slugs' => array( 'own-draft' ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $draft_id ), wp_list_pluck( $by_slug['items'], 'id' ), 'A query by slug without a status should return the author\'s own draft.' ); + + $without_ids = $ability->execute( + array( + 'where' => array( 'type' => 'post' ), + 'fields' => array( 'id' ), + 'pagination' => array( 'per_page' => 100 ), + ) + ); + + $this->assertNotContains( $draft_id, wp_list_pluck( $without_ids['items'], 'id' ), 'A query without IDs or slugs should keep the default status of publish.' ); + + $published_only = $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $draft_id ), + 'status' => array( 'publish' ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( self::EMPTY_RESULT, $published_only, 'An explicit status should apply next to the IDs, so the draft should not be returned.' ); + } + + /** + * Returns roles that can edit and read another user's post in any status. + * + * @return array Role test cases. + */ + public function data_roles_with_edit_access_to_other_users_posts(): array { + return array( + 'administrator' => array( + 'role' => 'administrator', + ), + 'editor' => array( + 'role' => 'editor', + ), + ); + } + + /** + * A query by ID without a status does not cover the internal `trash` status, even for a + * role that can edit the trashed post. + * + * The default statuses with IDs are the statuses that are not internal, so the query + * neither returns nor counts the trashed post. The post is named by ID, since trashing a + * post renames its slug. + * + * @ticket 66268 + * @dataProvider data_roles_with_edit_access_to_other_users_posts + * @since 7.2.0 + * + * @param string $role The role to query as. + */ + public function test_ids_without_a_status_do_not_cover_trashed_posts( string $role ): void { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'publish', + ) + ); + wp_trash_post( $post_id ); + + $this->assertSame( 'trash', get_post_status( $post_id ), 'Precondition: the post should be in the trash.' ); + + $this->login_as( $role ); + $this->register_ability(); + + $this->assertTrue( current_user_can( 'edit_post', $post_id ), 'Precondition: the role should be able to edit the trashed post.' ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( self::EMPTY_RESULT, $result, 'A query by ID without a status should neither return nor count a trashed post.' ); + } + + /** + * The parent filter is rejected for non-hierarchical post types, mirroring REST. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_rejects_parent_filter_for_non_hierarchical_post_type(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'parent' => 0, + ), + ) + ); + + $this->assertWPError( $result, 'The parent filter should be rejected for non-hierarchical post types.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'Unsupported parent filters should return a filter error.' ); + $this->assertSame( + array( 'where[parent]' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the unsupported parent filter to the error message.' + ); + } + + /** + * The parent filter narrows hierarchical queries to children of the given post. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_filters_pages_by_parent(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $parent_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + $child_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_parent' => $parent_id, + 'post_status' => 'publish', + ) + ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'page', + 'parent' => $parent_id, + ), + ) + ); + $ids = wp_list_pluck( $result['items'], 'id' ); + + $this->assertSame( array( $child_id ), $ids, 'The parent filter should return only the children of the given page.' ); + } + + /** + * The author_slug filter is rejected for post types without author support, mirroring REST. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_rejects_author_filter_for_post_type_without_author_support(): void { + register_post_type( + 'no_author_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title' ), + ) + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'no_author_cpt', + 'author_slug' => get_userdata( self::$user_ids['author'] )->user_nicename, + ), + ) + ); + + $this->assertWPError( $result, 'The author_slug filter should be rejected for post types without author support.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'Unsupported author filters should return a filter error.' ); + $this->assertSame( + array( 'where[author_slug]' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the unsupported author_slug filter to the error message.' + ); + } + + /** + * The author_slug filter narrows queries to posts by the given author, and each post + * returns its author's slug as `author.slug`. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_filters_posts_by_author(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $author_slug = get_userdata( self::$user_ids['author'] )->user_nicename; + $mine_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'publish', + ) + ); + $other_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author_secondary'], + 'post_status' => 'publish', + ) + ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'author_slug' => $author_slug, + ), + 'fields' => array( 'id', 'author.slug' ), + 'pagination' => array( 'per_page' => 100 ), + ) + ); + $ids = wp_list_pluck( $result['items'], 'id' ); + + $this->assertContains( $mine_id, $ids, 'The author_slug filter should include the author\'s posts.' ); + $this->assertNotContains( $other_id, $ids, 'The author_slug filter should exclude other authors\' posts.' ); + $this->assertSame( array( $author_slug ), array_unique( wp_list_pluck( wp_list_pluck( $result['items'], 'author' ), 'slug' ) ), 'Each post should return its author\'s slug.' ); + } + + /** + * A query returns only posts of the requested post type, also when a query filter adds + * post types to the blog home. + * + * WP_Query treats the ability's query as the blog home, so a `pre_get_posts` callback + * without an is_main_query() check also runs for it. The read check would let through + * posts of other exposed post types. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_skips_posts_of_other_post_types(): void { + register_post_type( + 'hidden_cpt', + array( + 'public' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + $ids = array(); + foreach ( array( 'post', 'page', 'hidden_cpt' ) as $post_type ) { + $ids[] = self::factory()->post->create( + array( + 'post_type' => $post_type, + 'post_status' => 'publish', + ) + ); + } + + add_action( + 'pre_get_posts', + static function ( WP_Query $query ): void { + if ( $query->is_home() ) { + $query->set( 'post_type', array( 'post', 'page', 'hidden_cpt' ) ); + } + } + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => $ids, + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( + array( + 'items' => array( + array( 'id' => $ids[0] ), + ), + 'pagination' => array( + 'total' => 3, + 'total_pages' => 1, + ), + ), + $result, + 'The query by ID should return only the post of the requested type, and count the posts that the query filter adds.' + ); + } + + /** + * A query by ID or by status returns another user's private post to a role that can read + * it, also when a query filter adds post types to the blog home. + * + * With several post types, WP_Query checks private posts against + * `read_private_multiple_post_types`, which no role has, so a query for the readable posts + * only would leave the post out. + * + * @ticket 66268 + * @dataProvider data_roles_with_edit_access_to_other_users_posts + * @since 7.2.0 + * + * @param string $role The role to query as. + */ + public function test_another_users_private_post_is_returned_when_a_query_filter_adds_post_types( string $role ): void { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'private', + ) + ); + + add_action( + 'pre_get_posts', + static function ( WP_Query $query ): void { + if ( $query->is_home() ) { + $query->set( 'post_type', array( 'post', 'page' ) ); + } + } + ); + + $this->login_as( $role ); + $this->register_ability(); + + $ability = wp_get_ability( 'core/content-query' ); + $expected = array( + 'items' => array( + array( 'id' => $post_id ), + ), + 'pagination' => array( + 'total' => 1, + 'total_pages' => 1, + ), + ); + + $by_ids = $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( $expected, $by_ids, 'A query by ID should return another user\'s private post to a role that can read it.' ); + + $by_status = $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'status' => array( 'private' ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( $expected, $by_status, 'A query for private posts should return another user\'s private post to a role that can read it.' ); + } + + /** + * Password-protected content is visible to users who can edit the post. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_password_protected_content_visible_to_editor(): void { + $post_id = self::$post_ids['password_protected']; + + $this->login_as( 'editor' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'id', 'content.raw', 'content.rendered' ), + ) + ); + + $this->assertSame( + 'Top secret body.', + $result['items'][0]['content']['raw'], + 'Editors should receive raw password-protected content.' + ); + $this->assertStringContainsString( + 'Top secret body.', + $result['items'][0]['content']['rendered'], + 'Editors should receive rendered password-protected content.' + ); + } + + /** + * Password-protected rendered content and excerpts are withheld from users who cannot edit + * the post. + * + * @ticket 66268 + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * @since 7.2.0 + * + * @param string $role The role to test. + */ + public function test_password_protected_rendered_content_is_empty_for_roles_without_edit_access_to_other_users_posts( string $role ): void { + $post_id = self::$post_ids['password_protected']; + + $this->login_as( $role ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'id', 'content.rendered', 'content.protected', 'excerpt.rendered', 'excerpt.protected' ), + ) + ); + + $this->assertSame( '', $result['items'][0]['content']['rendered'], 'Password-protected rendered content should be withheld.' ); + $this->assertTrue( $result['items'][0]['content']['protected'], 'The protected flag should reveal the content is password-protected.' ); + $this->assertSame( '', $result['items'][0]['excerpt']['rendered'], 'Password-protected rendered excerpts should be withheld.' ); + $this->assertTrue( $result['items'][0]['excerpt']['protected'], 'The protected flag should reveal the excerpt is password-protected.' ); + } + + /** + * Password-protected excerpts render for users who can edit the post. + * + * The excerpt is generated from the content, which get_the_content() replaces with the + * password form unless the ability unlocks the post for its editor. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_password_protected_excerpt_visible_to_editor(): void { + $this->login_as( 'editor' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'Top secret body.', + 'post_excerpt' => '', + ) + ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'id', 'excerpt.rendered', 'excerpt.protected' ), + ) + ); + + $this->assertSame( + "

Top secret body.

\n", + $result['items'][0]['excerpt']['rendered'], + 'Editors should receive the real excerpt generated from a password-protected post.' + ); + $this->assertTrue( $result['items'][0]['excerpt']['protected'], 'The protected flag should reveal the excerpt is password-protected.' ); + } + + /** + * Rendered excerpts carry the REST API's `the_excerpt` markup (paragraph wrapping). + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_excerpt_rendered_applies_the_excerpt_filters(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::$post_ids['limited_role_content'] ), + ), + 'fields' => array( 'id', 'excerpt.rendered' ), + ) + ); + + $this->assertSame( + "

Readable excerpt.

\n", + $result['items'][0]['excerpt']['rendered'], + 'Rendered excerpts should match the REST API excerpt filter output.' + ); + } + + /** + * A title filter that returns a non-string empties the rendered title instead of failing + * the query. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_rendered_title_is_empty_when_a_title_filter_returns_a_non_string(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + // Runs after core's own title filters, which expect a string. + add_filter( 'the_title', '__return_null', 20 ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::$post_ids['published'] ), + ), + 'fields' => array( 'id', 'title.rendered' ), + ) + ); + + $this->assertIsArray( $result, 'A non-string rendered title should not fail the query.' ); + $this->assertSame( '', $result['items'][0]['title']['rendered'], 'A non-string rendered title should be returned as an empty string.' ); + } + + /** + * Title, excerpt and permalink filters run with the requested post as the global context, + * and the context that was active before the ability executed is restored. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_rendered_fields_use_and_restore_requested_post_context(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $target_id = self::$post_ids['limited_role_content']; + $surrounding = get_post( self::$post_ids['published'] ); + + $this->assertInstanceOf( WP_Post::class, $surrounding, 'The surrounding post fixture should exist.' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Establishes a distinct context to verify the ability restores it. + $GLOBALS['post'] = $surrounding; + setup_postdata( $surrounding ); + + $append_context_id = static function ( $text ): string { + return (string) $text . ''; + }; + add_filter( 'the_title', $append_context_id, 20 ); + add_filter( 'the_excerpt', $append_context_id, 20 ); + add_filter( + 'post_link', + static function ( $link ): string { + return add_query_arg( 'context', get_the_ID(), (string) $link ); + } + ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $target_id ), + ), + 'fields' => array( 'id', 'link', 'title.rendered', 'excerpt.rendered' ), + ) + ); + $restored_context_id = get_the_ID(); + + $this->assertStringContainsString( + '', + $result['items'][0]['title']['rendered'], + 'Title filters should see the requested post as the current post.' + ); + $this->assertStringContainsString( + '', + $result['items'][0]['excerpt']['rendered'], + 'Excerpt filters should see the requested post as the current post.' + ); + $this->assertStringContainsString( 'context=' . $target_id, $result['items'][0]['link'], 'Permalink filters should see the requested post as the current post.' ); + $this->assertSame( + $surrounding->ID, + $restored_context_id, + 'The surrounding post context should be restored after rendering.' + ); + } + + /** + * Rendering a field unsets the loop globals that were not set before. + * + * Without a surrounding post, as in a REST request, wp_reset_postdata() cannot restore + * the globals that setup_postdata() populates, because the main query has no post. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_rendered_fields_unset_loop_globals_that_were_not_set(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + foreach ( self::LOOP_GLOBALS as $name ) { + unset( $GLOBALS[ $name ] ); + } + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::$post_ids['limited_role_content'] ), + ), + 'fields' => array( 'content.rendered' ), + ) + ); + + $this->assertArrayHasKey( 'rendered', $result['items'][0]['content'], 'Precondition: the rendered content should be returned.' ); + $this->assertSame( array(), $this->get_loop_globals(), 'Loop globals that were not set should be unset again after rendering.' ); + } + + /** + * Rendering a field restores the surrounding loop globals as they were, rather than as + * setup_postdata() computes them for the surrounding post. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_rendered_fields_restore_surrounding_loop_globals(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $surrounding = get_post( self::$post_ids['published'] ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Establishes a surrounding context to verify the ability restores it. + $GLOBALS['post'] = $surrounding; + setup_postdata( $surrounding ); + + /* + * A template can show the full content outside a single post view by setting `$more`, + * which running setup_postdata() for the surrounding post again would undo. + */ + $GLOBALS['more'] = 1; + + $surrounding_globals = $this->get_loop_globals(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::$post_ids['limited_role_content'] ), + ), + 'fields' => array( 'content.rendered' ), + ) + ); + + $this->assertArrayHasKey( 'rendered', $result['items'][0]['content'], 'Precondition: the rendered content should be returned.' ); + $this->assertSame( $surrounding_globals, $this->get_loop_globals(), 'The surrounding loop globals should be restored as they were.' ); + } + + /** + * Rendering a field restores the surrounding loop globals when a calling function binds + * them with `global`, as load_template() and WP_Block::render() do. + * + * Such a binding makes the global a reference, so a saved copy that kept the reference + * would follow the global to the rendered post, and the rendered post would be restored. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_rendered_fields_restore_loop_globals_bound_by_the_caller(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $surrounding = get_post( self::$post_ids['published'] ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Establishes a surrounding context to verify the ability restores it. + $GLOBALS['post'] = $surrounding; + setup_postdata( $surrounding ); + + $surrounding_globals = $this->get_loop_globals(); + + $render_in_template = static function (): array { + global $post, $id; + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::$post_ids['limited_role_content'] ), + ), + 'fields' => array( 'content.rendered' ), + ) + ); + + return array( $result, $post->ID, $id ); + }; + + list( $result, $bound_post_id, $bound_id ) = $render_in_template(); + + $this->assertArrayHasKey( 'rendered', $result['items'][0]['content'], 'Precondition: the rendered content should be returned.' ); + $this->assertSame( $surrounding->ID, $bound_post_id, 'The caller should see the surrounding post again after rendering.' ); + $this->assertSame( $surrounding->ID, $bound_id, 'The caller should see the surrounding post ID again after rendering.' ); + $this->assertSame( $surrounding_globals, $this->get_loop_globals(), 'The surrounding loop globals should be restored as they were.' ); + } + + /** + * Rendering a field sets up a global post that was not set up before, such as the main + * post before the loop starts, so get_the_content() without a post still works. + * + * Rendering fires `the_post`, after which get_the_content() without a post reads the loop + * globals. Unsetting `$pages` again, as for a post that was never set up, made it throw a + * TypeError. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_rendered_fields_set_up_a_global_post_that_was_not_set_up(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + foreach ( self::LOOP_GLOBALS as $name ) { + unset( $GLOBALS[ $name ] ); + } + + // WP::register_globals() sets the main post this way, before the loop sets it up. + $main_post = get_post( self::$post_ids['published_content'] ); + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Establishes a global post that was not set up. + $GLOBALS['post'] = $main_post; + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::$post_ids['limited_role_content'] ), + ), + 'fields' => array( 'content.rendered' ), + ) + ); + + $this->assertArrayHasKey( 'rendered', $result['items'][0]['content'], 'Precondition: the rendered content should be returned.' ); + $this->assertSame( $main_post->ID, get_the_ID(), 'The main post should be the global post again.' ); + $this->assertSame( 'Body here.', get_the_content(), 'get_the_content() without a post should return the main post content.' ); + } + + /** + * Rendering a field with no global post, such as on `init`, does not start the loop, so + * get_the_content() without a post still works for a global post set afterwards. + * + * Rendering fires `the_post`, after which get_the_content() without a post reads the loop + * globals. They are unset again when there was no global post, so the main post that + * WP::register_globals() sets afterwards made it throw a TypeError. The ability resets + * the count when it was the first thing to fire the action and no global post was set. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_rendered_fields_without_a_global_post_do_not_start_the_loop(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + foreach ( self::LOOP_GLOBALS as $name ) { + unset( $GLOBALS[ $name ] ); + } + + // No loop has started yet, as on `init`. + unset( $GLOBALS['wp_actions']['the_post'] ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::$post_ids['limited_role_content'] ), + ), + 'fields' => array( 'content.rendered' ), + ) + ); + + $this->assertArrayHasKey( 'rendered', $result['items'][0]['content'], 'Precondition: the rendered content should be returned.' ); + $this->assertSame( 0, did_action( 'the_post' ), 'Rendering a field with no global post should not count as starting the loop.' ); + + // WP::register_globals() sets the main post this way, before the loop sets it up. + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Establishes a global post that was not set up. + $GLOBALS['post'] = get_post( self::$post_ids['published_content'] ); + + $this->assertSame( 'Body here.', get_the_content(), 'get_the_content() without a post should return the content of a global post set afterwards.' ); + } + + /** + * Rendering a field with no global post keeps the `the_post` count of a loop that had + * already started, since the ability resets the count only when it was the first thing + * to fire the action. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_rendered_fields_without_a_global_post_keep_an_earlier_the_post_count(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + // Another loop has already set up a post, and the global post was cleared afterwards. + setup_postdata( get_post( self::$post_ids['published'] ) ); + foreach ( self::LOOP_GLOBALS as $name ) { + unset( $GLOBALS[ $name ] ); + } + + $the_post_count = did_action( 'the_post' ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::$post_ids['limited_role_content'] ), + ), + 'fields' => array( 'content.rendered' ), + ) + ); + + $this->assertArrayHasKey( 'rendered', $result['items'][0]['content'], 'Precondition: the rendered content should be returned.' ); + $this->assertSame( $the_post_count + 1, did_action( 'the_post' ), 'The ability should keep the `the_post` count of a loop that had already started.' ); + } + + /** + * Returns the posts whose `the_post` callbacks throw: the requested post, as it is set + * up, and the surrounding post, as it is set up again afterwards. + * + * @return array Throwing post test cases. + */ + public function data_posts_whose_the_post_callbacks_throw(): array { + return array( + 'requested post' => array( + 'throwing_post' => 'limited_role_content', + ), + 'surrounding post' => array( + 'throwing_post' => 'published', + ), + ); + } + + /** + * A `the_post` callback that throws fails the query without leaving the requested post + * in the loop globals. + * + * @ticket 66268 + * @dataProvider data_posts_whose_the_post_callbacks_throw + * @since 7.2.0 + * + * @param string $throwing_post The fixture name of the post whose `the_post` callback throws. + */ + public function test_post_context_is_restored_when_a_the_post_callback_throws( string $throwing_post ): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $surrounding = get_post( self::$post_ids['published'] ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Establishes a surrounding context to verify the ability restores it. + $GLOBALS['post'] = $surrounding; + setup_postdata( $surrounding ); + + /* + * Setting up the surrounding post again undoes a `$more` that a template set, so only + * putting back the saved values restores it. + */ + $GLOBALS['more'] = 1; + + $surrounding_globals = $this->get_loop_globals(); + + $throwing_post_id = self::$post_ids[ $throwing_post ]; + add_action( + 'the_post', + static function ( WP_Post $post ) use ( $throwing_post_id ): void { + if ( $throwing_post_id === $post->ID ) { + throw new RuntimeException( 'A the_post callback failed.' ); + } + } + ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::$post_ids['limited_role_content'] ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertWPError( $result, 'A throwing the_post callback should fail the query.' ); + $this->assertSame( 'ability_callback_exception', $result->get_error_code(), 'A throwing the_post callback should return the callback exception error.' ); + $this->assertSame( $surrounding_globals, $this->get_loop_globals(), 'The surrounding loop globals should be restored as they were.' ); + } + + /** + * Returns the loop globals that are set, keyed by name. + * + * @since 7.2.0 + * + * @return array The loop globals that are set. + */ + private function get_loop_globals(): array { + $globals = array(); + foreach ( self::LOOP_GLOBALS as $name ) { + if ( ! array_key_exists( $name, $GLOBALS ) ) { + continue; + } + + $globals[ $name ] = $GLOBALS[ $name ]; + } + + return $globals; + } + + /** + * The password gate is suspended only for posts the current user can edit. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_allow_password_content_only_unlocks_editable_posts(): void { + $owned_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'publish', + 'post_password' => 'secret', + ) + ); + + $this->login_as( 'author' ); + + // Read the password gate of each post while the editable protected post renders. + $gates = array(); + add_filter( + 'the_content', + static function ( $content ) use ( &$gates, $owned_id ) { + $gates = array( + 'owned' => post_password_required( $owned_id ), + 'other' => post_password_required( self::$post_ids['password_protected'] ), + 'public' => post_password_required( self::$post_ids['published'] ), + ); + + return $content; + } + ); + + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $owned_id ), + ), + 'fields' => array( 'id', 'content.rendered' ), + ) + ); + + $this->assertSame( array( $owned_id ), wp_list_pluck( $result['items'], 'id' ), 'Precondition: the author should read their own protected post.' ); + $this->assertFalse( $gates['owned'], 'The filter should unlock a protected post the current user can edit.' ); + $this->assertTrue( $gates['other'], 'The filter should keep the gate on a protected post the current user cannot edit.' ); + $this->assertFalse( $gates['public'], 'The filter should leave posts that do not require a password ungated.' ); + } + + /** + * The last page still reports the totals of the underlying query. + * + * Guards the boundary next to the out-of-range page error: the final page must not be + * mistaken for an overshoot. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_last_page_reports_totals(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => $ids, + ), + 'fields' => array( 'id' ), + 'pagination' => array( + 'page' => 2, + 'per_page' => 2, + ), + ) + ); + + $this->assertCount( 1, $result['items'], 'The last page should return the remaining post.' ); + $this->assertSame( 3, $result['pagination']['total'], 'The last page should report the full total.' ); + $this->assertSame( 2, $result['pagination']['total_pages'], 'The last page should report the full page count.' ); + } + + /** + * Paging past the last page reports an error rather than an empty collection. + * + * `WP_Query::set_found_posts()` skips the count when a page yields no rows, so without + * recovering the total an out-of-range page would report a `pagination` of + * `total: 0, total_pages: 0`, which is indistinguishable from an empty collection. Report + * it as a page that does not exist instead. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_out_of_range_page_is_rejected_rather_than_reported_as_empty(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => $ids, + ), + 'fields' => array( 'id' ), + 'pagination' => array( + 'page' => 99, + 'per_page' => 2, + ), + ) + ); + + $this->assertWPError( $result, 'A page beyond the last one should fail rather than return an empty list.' ); + $this->assertSame( 'content_invalid_page_number', $result->get_error_code(), 'Out-of-range pages should report a dedicated error code.' ); + $this->assertSame( 404, $result->get_error_data()['status'], 'An out-of-range page should be reported as not found.' ); + } + + /** + * Returns whole numbers that the integer schema accepts but that are not PHP integers, + * as a page, a page size, and a page past the last one. + * + * @return array The values to send. + */ + public function data_whole_numbers_that_are_not_integers(): array { + return array( + 'floats' => array( 2.0, 2.0, 99.0 ), + 'decimal strings' => array( '2.0', '2.0', '99.0' ), + 'signed strings' => array( '+2', '+2', '+99' ), + 'strings with a space' => array( ' 2', ' 2', ' 99' ), + ); + } + + /** + * A `pagination.page` and `pagination.per_page` that the integer schema accepts are honored + * even when they are not PHP integers, as when the MCP adapter passes the 2.0 a JSON encoder + * produced. + * + * Read as invalid, they would fall back to page 1 and the default page size, so a client + * paging with 2.0, 3.0, and so on would get page 1 every time and never reach the error for + * a page past the last one. + * + * @ticket 66268 + * @dataProvider data_whole_numbers_that_are_not_integers + * @since 7.2.0 + * + * @param float|string $page The page to request. + * @param float|string $per_page The page size to request. + * @param float|string $past_page A page past the last one. + */ + public function test_query_honors_a_page_and_per_page_that_are_not_integers( $page, $per_page, $past_page ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $query = array( + 'where' => array( + 'type' => 'post', + 'ids' => self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ), + ), + 'fields' => array( 'id' ), + 'pagination' => array( 'per_page' => $per_page ), + ); + + $result = wp_get_ability( 'core/content-query' )->execute( array_replace_recursive( $query, array( 'pagination' => array( 'page' => $page ) ) ) ); + + $this->assertIsArray( $result, 'The page should be returned.' ); + $this->assertCount( 1, $result['items'], 'The second page of two posts each should hold the third post.' ); + $this->assertSame( 2, $result['pagination']['total_pages'], 'The page count should follow the requested page size.' ); + + $result = wp_get_ability( 'core/content-query' )->execute( array_replace_recursive( $query, array( 'pagination' => array( 'page' => $past_page ) ) ) ); + + $this->assertWPError( $result, 'A page past the last one should still fail.' ); + $this->assertSame( 'content_invalid_page_number', $result->get_error_code(), 'A page past the last one should report the out-of-range error.' ); + } + + /** + * Returns {@see self::IMPORTED_POST_ID} in forms that the integer schema accepts but that + * are not PHP integers. + * + * @return array The values to send for the post ID. + */ + public function data_post_ids_that_are_not_integers(): array { + return array( + 'float' => array( + 'id' => (float) self::IMPORTED_POST_ID, + ), + 'decimal string' => array( + 'id' => self::IMPORTED_POST_ID . '.0', + ), + 'signed string' => array( + 'id' => '+' . self::IMPORTED_POST_ID, + ), + 'string with a space' => array( + 'id' => ' ' . self::IMPORTED_POST_ID, + ), + ); + } + + /** + * A query honors an ID in `where.ids` that the integer schema accepts even when it is not a + * PHP integer, as when the MCP adapter passes a float that a JSON encoder produced. + * + * Read as invalid, the ID would be dropped, and the query would fail as an invalid filter. + * + * @ticket 66268 + * @dataProvider data_post_ids_that_are_not_integers + * @since 7.2.0 + * + * @param float|string $id The value to send for the imported post ID. + */ + public function test_query_honors_an_id_that_is_not_an_integer( $id ): void { + $post_id = self::factory()->post->create( + array( + 'import_id' => self::IMPORTED_POST_ID, + 'post_status' => 'publish', + ) + ); + $this->assertSame( self::IMPORTED_POST_ID, $post_id, 'Precondition: the post should have the requested ID.' ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $id ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'The post should be returned.' ); + $this->assertSame( array( $post_id ), wp_list_pluck( $result['items'], 'id' ), 'The query should resolve to the requested post.' ); + } + + /** + * A query honors a `where.parent` filter that the integer schema accepts even when it is + * not a PHP integer. + * + * Read as invalid, the filter would be rejected as an invalid filter. + * + * @ticket 66268 + * @dataProvider data_post_ids_that_are_not_integers + * @since 7.2.0 + * + * @param float|string $id The value to send for the imported parent post ID. + */ + public function test_query_honors_a_parent_that_is_not_an_integer( $id ): void { + $parent_id = self::factory()->post->create( + array( + 'import_id' => self::IMPORTED_POST_ID, + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + $this->assertSame( self::IMPORTED_POST_ID, $parent_id, 'Precondition: the parent page should have the requested ID.' ); + + $child_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_parent' => $parent_id, + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'page', + 'parent' => $id, + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'The query should succeed.' ); + $this->assertSame( array( $child_id ), wp_list_pluck( $result['items'], 'id' ), 'The query should return the children of the requested parent.' ); + } + + /** + * A genuinely empty result set beyond the first page reports zero totals, not an error. + * + * The out-of-range guard only fires when the underlying query actually matched rows. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_empty_result_beyond_first_page_reports_zero_totals(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ), + ), + 'fields' => array( 'id' ), + 'pagination' => array( 'page' => 2 ), + ) + ); + + $this->assertIsArray( $result, 'An empty result set should not be treated as an out-of-range page.' ); + $this->assertSame( array(), $result['items'], 'No posts match the query.' ); + $this->assertSame( 0, $result['pagination']['total'], 'An empty result set reports a zero total.' ); + $this->assertSame( 0, $result['pagination']['total_pages'], 'An empty result set reports zero pages.' ); + } + + /** + * Returns page sizes that a query filter can set, with the pages they make of five posts. + * + * @return array Page size test cases. + */ + public function data_page_sizes_set_by_a_query_filter(): array { + return array( + 'smaller page size' => array( + 'posts_per_page' => 2, + 'total_pages' => 3, + 'last_page_count' => 1, + ), + 'no paging' => array( + 'posts_per_page' => -1, + 'total_pages' => 1, + 'last_page_count' => 5, + ), + ); + } + + /** + * A query counts the pages with the page size it ran with. + * + * WP_Query treats the ability's query as the blog home, so a `pre_get_posts` callback + * without an is_main_query() check can change its page size. Counting the pages with the + * requested page size would then report the wrong number of pages, and reject pages that + * hold posts or serve the same posts again. + * + * @ticket 66268 + * @dataProvider data_page_sizes_set_by_a_query_filter + * @since 7.2.0 + * + * @param int $posts_per_page The page size the query filter sets. + * @param int $total_pages The expected number of pages. + * @param int $last_page_count The expected number of posts on the last page. + */ + public function test_query_counts_pages_with_the_page_size_the_query_ran_with( int $posts_per_page, int $total_pages, int $last_page_count ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 5, array( 'post_status' => 'publish' ) ); + + add_action( + 'pre_get_posts', + static function ( WP_Query $query ) use ( $posts_per_page ): void { + if ( $query->is_home() ) { + $query->set( 'posts_per_page', $posts_per_page ); + } + } + ); + + $ability = wp_get_ability( 'core/content-query' ); + $input = array( + 'where' => array( + 'type' => 'post', + 'ids' => $ids, + ), + 'fields' => array( 'id' ), + 'pagination' => array( 'per_page' => 4 ), + ); + + $first_page = $ability->execute( $input ); + + $this->assertSame( 5, $first_page['pagination']['total'], 'The total should count every matching post.' ); + $this->assertSame( $total_pages, $first_page['pagination']['total_pages'], 'The page count should follow the page size the query ran with.' ); + + $last_page = $ability->execute( array_replace_recursive( $input, array( 'pagination' => array( 'page' => $total_pages ) ) ) ); + + $this->assertIsArray( $last_page, 'The last page should be served.' ); + $this->assertCount( $last_page_count, $last_page['items'], 'The last page should return the remaining posts.' ); + + $past_last_page = $ability->execute( array_replace_recursive( $input, array( 'pagination' => array( 'page' => $total_pages + 1 ) ) ) ); + + $this->assertWPError( $past_last_page, 'The page after the last one should be rejected.' ); + $this->assertSame( 'content_invalid_page_number', $past_last_page->get_error_code(), 'Paging past the last page should report the page number error.' ); + } + + /** + * IDs return every requested post when `pagination.per_page` is omitted. + * + * Without this the default page size silently truncates a batch load: a caller asking + * for a known set of IDs would receive only the first page of them. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_ids_return_every_requested_post_without_per_page(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + // More than DEFAULT_PER_PAGE (10) so truncation would be visible. + $ids = self::factory()->post->create_many( 15, array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => $ids, + ), + 'fields' => array( 'id' ), + ) + ); + + $returned = wp_list_pluck( $result['items'], 'id' ); + sort( $returned ); + sort( $ids ); + + $this->assertSame( $ids, $returned, 'Every requested post ID should be returned on a single page.' ); + $this->assertSame( 15, $result['pagination']['total'], 'The total should cover every requested post.' ); + $this->assertSame( 1, $result['pagination']['total_pages'], 'The requested posts should fit on a single page by default.' ); + } + + /** + * Returns the field paths that request a rendered excerpt or content, either directly or + * through the whole object. + * + * @return array Post meta priming test cases. + */ + public function data_fields_that_prime_post_meta(): array { + return array( + 'content.rendered' => array( + 'field' => 'content.rendered', + ), + 'excerpt.rendered' => array( + 'field' => 'excerpt.rendered', + ), + 'whole content' => array( + 'field' => 'content', + ), + 'whole excerpt' => array( + 'field' => 'excerpt', + ), + ); + } + + /** + * Requesting rendered fields primes the post meta cache for the whole page. + * + * The rendered filter chains may read post meta, so priming avoids one lazy meta + * query per returned row. Requesting the whole excerpt or content counts as requesting + * its rendered value. + * + * @ticket 66268 + * @dataProvider data_fields_that_prime_post_meta + * @since 7.2.0 + * + * @param string $field The field path to request. + */ + public function test_query_rendered_fields_prime_the_post_meta_cache( string $field ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $postmeta_queries = $this->count_queries( + 'postmeta', + static function () use ( $ids, $field ) { + return wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => $ids, + ), + 'fields' => array( 'id', $field ), + ) + ); + }, + $result + ); + + $this->assertCount( 3, $result['items'], 'Precondition: the query should return the seeded posts.' ); + + /* + * The rendered filter chains can read post meta per post. Without priming, each + * row lazily primes its own meta, which is one query per returned post. + */ + $this->assertSame( 1, $postmeta_queries, 'Rendered field requests should prime post meta with a single batched query, not one per returned post.' ); + } + + /** + * A query primes the authors of the whole page. + * + * Every returned post is set up with setup_postdata(), which reads the post's author, so + * without priming, each author on the page runs its own query, whatever fields are + * requested. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_primes_the_authors_of_the_page(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = array(); + foreach ( array( 'author', 'author_secondary', 'editor' ) as $role ) { + $ids[] = self::factory()->post->create( + array( + 'post_author' => self::$user_ids[ $role ], + 'post_status' => 'publish', + ) + ); + } + + $users_queries = $this->count_queries( + 'users', + static function () use ( $ids ) { + return wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => $ids, + ), + 'fields' => array( 'id' ), + ) + ); + }, + $result + ); + + $this->assertCount( 3, $result['items'], 'Precondition: the query should return the seeded posts.' ); + $this->assertSame( 1, $users_queries, 'Setting up the returned posts should read primed authors, not query once per author.' ); + } + + /** + * A lean projection does not ask for post meta priming. + * + * Nothing in the default field set renders a post, so the ability leaves priming off + * when it builds the query. + * + * The check is on what the ability asks for, not on the queries that follow. Honoring + * the request belongs to whoever runs the query, and it may prime more for its own + * reasons, so counting queries here would describe that layer rather than this one. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_lean_projection_does_not_request_post_meta_priming(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + // A non-empty `post__in` identifies the query the ability built for this request. + $priming = array(); + $spy = static function ( $query ) use ( &$priming ): void { + if ( empty( $query->get( 'post__in' ) ) ) { + return; + } + + $priming[] = $query->get( 'update_post_meta_cache' ); + }; + + add_action( 'pre_get_posts', $spy ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => $ids, + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertCount( 3, $result['items'], 'Precondition: the query should return the seeded posts.' ); + $this->assertNotEmpty( $priming, 'Precondition: the ability should query for the requested posts.' ); + $this->assertSame( array(), array_filter( $priming ), 'A lean projection should leave post meta priming off.' ); + } + + /** + * Requesting `link` primes the terms that `%category%` permalinks read for the whole page. + * + * Without priming, each returned row runs its own query. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_link_primes_the_terms_category_permalinks_read(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + $this->set_permalink_structure( '/%category%/%postname%/' ); + + $ids = self::factory()->post->create_many( + 3, + array( + 'post_category' => array( self::factory()->category->create() ), + 'post_status' => 'publish', + ) + ); + + $queries = $this->count_queries( + 'term_relationships', + static function () use ( $ids ) { + return wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => $ids, + ), + 'fields' => array( 'id', 'link' ), + ) + ); + }, + $result + ); + + $this->assertCount( 3, $result['items'], 'Precondition: the query should return the seeded posts.' ); + $this->assertSame( 1, $queries, 'Permalinks should read primed caches, not query once per returned post.' ); + } + + /** + * Requesting `link` primes the parents that page permalinks read for the whole page. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_link_primes_the_parents_page_permalinks_read(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + $this->set_permalink_structure( '/%postname%/' ); + + $ids = array(); + for ( $i = 0; $i < 3; $i++ ) { + $ids[] = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_parent' => self::factory()->post->create( array( 'post_type' => 'page' ) ), + ) + ); + } + + $queries = $this->count_queries( + 'posts', + static function () use ( $ids ) { + return wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'page', + 'ids' => $ids, + ), + 'fields' => array( 'id', 'link' ), + ) + ); + }, + $result + ); + + $this->assertCount( 3, $result['items'], 'Precondition: the query should return the seeded pages.' ); + // One query finds the matching IDs, one loads those pages, and one loads their parents. + $this->assertSame( 3, $queries, 'Page permalinks should read primed parents, not query once per returned page.' ); + } + + /** + * Counts the queries against a table issued while running the given callback. + * + * Counts during the call rather than checking the cache afterwards: the rendered + * filter chains prime meta lazily, so an after-the-fact cache check passes either way. + * + * @since 7.2.0 + * + * @param string $table The wpdb property naming the table, such as `postmeta`. + * @param callable $callback Callback to run. + * @param mixed $result Set to the callback's return value. + * @return int Number of queries issued against the table. + */ + private function count_queries( string $table, callable $callback, &$result ): int { + global $wpdb; + + $queries = 0; + $pattern = '/(?:FROM|JOIN)\s+`?' . preg_quote( $wpdb->$table, '/' ) . '\b/i'; + $spy = static function ( $query ) use ( &$queries, $pattern ) { + if ( is_string( $query ) && preg_match( $pattern, $query ) ) { + ++$queries; + } + + return $query; + }; + + wp_cache_flush(); + + /* + * On multisite, capability checks load the current user to check whether they are a + * super admin. Load the user again after the flush so only the callback's own queries + * are counted. + */ + get_userdata( get_current_user_id() ); + + add_filter( 'query', $spy ); + $result = $callback(); + remove_filter( 'query', $spy ); + + return $queries; + } + + /** + * Query rows are kept, with their ID, when none of the requested fields apply to them. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_keeps_posts_whose_requested_fields_do_not_apply(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + // `parent` never applies to the non-hierarchical `post` type. + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( 'type' => 'post' ), + 'fields' => array( 'parent' ), + 'pagination' => array( 'per_page' => 100 ), + ) + ); + + $this->assertNotEmpty( $result['items'], 'Posts whose requested fields do not apply should still be returned.' ); + $this->assertCount( $result['pagination']['total'], $result['items'], 'The reported total should match the returned posts.' ); + $this->assertSame( 1, $result['pagination']['total_pages'], 'The returned posts should fit on a single page.' ); + + foreach ( $result['items'] as $post_entry ) { + $this->assertSame( array( 'id' ), array_keys( $post_entry ), 'A post whose requested fields do not apply should return only its ID.' ); + } + } + + /** + * Local and GMT date fields report the correct instant and offset on non-UTC sites. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_gmt_dates_are_utc_on_non_utc_sites(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + update_option( 'timezone_string', 'America/New_York' ); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-01-15 10:00:00', + ) + ); + + /* + * On insert, `wp_insert_post()` copies the post date onto the modified columns. Give + * the modified columns their own instant, so a field that read the post date where it + * meant the modified date cannot pass. + */ + $this->replace_cached_post_date_columns( + $post_id, + array( + 'post_modified' => '2026-01-16 11:00:00', + 'post_modified_gmt' => '2026-01-16 16:00:00', + ) + ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'id', 'date.local', 'date.gmt', 'modified.local', 'modified.gmt' ), + ) + ); + + $this->assertSame( '2026-01-15T10:00:00-05:00', $result['items'][0]['date']['local'], 'The local date should carry the site timezone offset.' ); + $this->assertSame( '2026-01-15T15:00:00+00:00', $result['items'][0]['date']['gmt'], 'The GMT date should be the UTC instant with a UTC offset.' ); + $this->assertSame( '2026-01-16T11:00:00-05:00', $result['items'][0]['modified']['local'], 'The local modified date should carry the site timezone offset.' ); + $this->assertSame( '2026-01-16T16:00:00+00:00', $result['items'][0]['modified']['gmt'], 'The GMT modified date should be the UTC instant with a UTC offset.' ); + } + + /** + * Drafts without a stored GMT date derive it from the local date and the site timezone. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_gmt_date_is_derived_from_local_date_for_drafts(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + update_option( 'timezone_string', 'America/New_York' ); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'draft', + 'post_date' => '2026-01-15 10:00:00', + ) + ); + + $this->assertSame( + '0000-00-00 00:00:00', + get_post( $post_id )->post_date_gmt, + 'Precondition: drafts should have no stored GMT date.' + ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'id', 'date.gmt' ), + ) + ); + + $this->assertSame( + '2026-01-15T15:00:00+00:00', + $result['items'][0]['date']['gmt'], + 'The GMT date should be derived from the local date using the site timezone, not read the local wall-clock as UTC.' + ); + } + + /** + * Replaces cached post columns so the ability sees a post object with custom dates. + * + * Core's schema keeps the date columns `NOT NULL`, but a post object can still reach + * the ability from a filter or an in-memory row where a date is null or otherwise + * differs from the database row. + * + * @since 7.2.0 + * + * @param int $post_id The post ID. + * @param array $columns Post column values keyed by column name. + */ + private function replace_cached_post_date_columns( int $post_id, array $columns ): void { + get_post( $post_id ); + + $cached = wp_cache_get( $post_id, 'posts' ); + $this->assertInstanceOf( stdClass::class, $cached, 'Precondition: the raw post row should be cached.' ); + $this->assertSame( 'raw', $cached->filter, 'Precondition: the cached row should be unsanitized.' ); + + foreach ( $columns as $column => $value ) { + $cached->$column = $value; + } + + wp_cache_set( $post_id, $cached, 'posts' ); + + foreach ( $columns as $column => $value ) { + $this->assertSame( $value, get_post( $post_id )->$column, "Precondition: {$column} should have the test value." ); + } + } + + /** + * Data provider for GMT date field paths. + * + * @since 7.2.0 + * + * @return array + */ + public function data_gmt_date_fields(): array { + return array( + 'date.gmt' => array( + 'field' => 'date.gmt', + 'gmt_column' => 'post_date_gmt', + 'local_column' => 'post_date', + 'local_date' => '2026-01-15 10:00:00', + 'expected' => '2026-01-15T15:00:00+00:00', + ), + 'modified.gmt' => array( + 'field' => 'modified.gmt', + 'gmt_column' => 'post_modified_gmt', + 'local_column' => 'post_modified', + 'local_date' => '2026-01-16 11:00:00', + 'expected' => '2026-01-16T16:00:00+00:00', + ), + ); + } + + /** + * A null stored GMT date falls back to the local date instead of the current time. + * + * `strtotime( ' UTC' )` resolves to the current time, so an unguarded null would + * report a fabricated "now" as the publication date. + * + * @ticket 66268 + * @since 7.2.0 + * + * @dataProvider data_gmt_date_fields + * + * @param string $field The field path to request, such as `date.gmt`. + * @param string $gmt_column The cached GMT post column to null out. + * @param string $local_column The cached local post column to derive the GMT date from. + * @param string $local_date The local date column value. + * @param string $expected The expected GMT output. + */ + public function test_gmt_date_recovers_from_a_null_stored_gmt_date( string $field, string $gmt_column, string $local_column, string $local_date, string $expected ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + update_option( 'timezone_string', 'America/New_York' ); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-01-15 10:00:00', + ) + ); + + $this->replace_cached_post_date_columns( + $post_id, + array( + $gmt_column => null, + $local_column => $local_date, + ) + ); + + list( $object, $key ) = explode( '.', $field ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'id', $field ), + ) + ); + + $this->assertSame( + $expected, + $result['items'][0][ $object ][ $key ], + 'A null stored GMT date should be derived from the local date, not resolved to the current time.' + ); + } + + /** + * A post with no usable date at all reports null. + * + * @ticket 66268 + * @since 7.2.0 + * + * @dataProvider data_gmt_date_fields + * + * @param string $field The field path to request, such as `date.gmt`. + * @param string $gmt_column The cached GMT post column to null out. + * @param string $local_column The cached local post column to null out. + * @param string $local_date Unused. Present to match the shared data provider shape. + * @param string $expected Unused. Present to match the shared data provider shape. + */ + public function test_gmt_date_is_null_when_no_usable_date_exists( string $field, string $gmt_column, string $local_column, string $local_date, string $expected ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $this->replace_cached_post_date_columns( + $post_id, + array( + $gmt_column => null, + $local_column => null, + ) + ); + + list( $object, $key ) = explode( '.', $field ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'id', $field ), + ) + ); + + $this->assertIsArray( $result, 'An unresolvable GMT date should pass output validation.' ); + $this->assertArrayHasKey( $key, $result['items'][0][ $object ], 'An unresolvable GMT date should still be returned.' ); + $this->assertNull( $result['items'][0][ $object ][ $key ], 'An unresolvable GMT date should be null.' ); + } + + /** + * Data provider for date field paths. + * + * @since 7.2.0 + * + * @return array + */ + public function data_date_fields(): array { + return array( + 'date.local' => array( + 'field' => 'date.local', + 'column' => 'post_date', + ), + 'date.gmt' => array( + 'field' => 'date.gmt', + 'column' => 'post_date_gmt', + ), + 'modified.local' => array( + 'field' => 'modified.local', + 'column' => 'post_modified', + ), + 'modified.gmt' => array( + 'field' => 'modified.gmt', + 'column' => 'post_modified_gmt', + ), + ); + } + + /** + * A malformed stored date reports null instead of failing output validation. + * + * A zero month still parses, but formats with a negative year, which the `date-time` + * format of the output schema rejects. + * + * @ticket 66268 + * @since 7.2.0 + * + * @dataProvider data_date_fields + * + * @param string $field The field path to request, such as `date.local`. + * @param string $column The cached post column to corrupt. + */ + public function test_malformed_date_is_null( string $field, string $column ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $this->replace_cached_post_date_columns( $post_id, array( $column => '0000-00-01 00:00:00' ) ); + + list( $object, $key ) = explode( '.', $field ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( $post_id ), + ), + 'fields' => array( 'id', $field ), + ) + ); + + $this->assertIsArray( $result, 'A malformed date should pass output validation.' ); + $this->assertArrayHasKey( $key, $result['items'][0][ $object ], 'A malformed date should still be returned.' ); + $this->assertNull( $result['items'][0][ $object ][ $key ], 'A malformed date should be null.' ); + } + + /** + * Returns author slugs that do not name a user. + * + * @return array The author slug filter value. + */ + public function data_author_slugs_that_name_no_user(): array { + return array( + 'unknown slug' => array( 'no-such-user' ), + 'not a string' => array( 5 ), + ); + } + + /** + * An author_slug filter that does not name a user is rejected rather than silently + * dropped, which would widen the query to every author's posts. + * + * @ticket 66268 + * @dataProvider data_author_slugs_that_name_no_user + * + * @param mixed $author_slug The author slug filter value. + */ + public function test_execute_callback_rejects_an_author_slug_that_names_no_user( $author_slug ): void { + wp_update_user( + array( + 'ID' => self::$user_ids['author'], + 'user_nicename' => 'author-slug', + ) + ); + + $this->login_as( 'administrator' ); + $execute = $this->get_ability_callbacks()['execute_callback']; + + $result = $execute( + array( + 'where' => array( + 'type' => 'post', + 'author_slug' => $author_slug, + ), + ) + ); + + $this->assertWPError( $result, 'An author_slug that names no user must not silently widen the query to all authors.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An unhonorable author_slug filter should fail closed as an invalid filter.' ); + $this->assertSame( + array( 'where[author_slug]' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the unmatched author_slug filter to the error message.' + ); + } + + /** + * An author_slug filter is looked up as given, like the `slug` filter of the REST API users + * endpoint, so with the database's case-insensitive collation a slug in capitals names the + * author too. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_author_slug_filter_matches_a_slug_in_capitals(): void { + wp_update_user( + array( + 'ID' => self::$user_ids['author'], + 'user_nicename' => 'author-slug', + ) + ); + + $author_post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'publish', + ) + ); + $other_post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['editor'], + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'author_slug' => 'AUTHOR-SLUG', + 'ids' => array( $author_post_id, $other_post_id ), + ), + 'fields' => array( 'id', 'author.slug' ), + ) + ); + + $this->assertIsArray( $result, 'The query should succeed.' ); + $this->assertSame( array( $author_post_id ), wp_list_pluck( $result['items'], 'id' ), 'The filter should return only the author\'s posts.' ); + $this->assertSame( 'author-slug', $result['items'][0]['author']['slug'], 'Each post should return the stored slug.' ); + } + + /** + * A parent filter that is not a non-negative integer is rejected rather than + * coerced to 0 (top-level). + * + * Because 0 is a legitimate parent value (top-level posts), a non-integer value + * cannot be detected by a numeric bound; it must be rejected on the raw value so + * garbage does not silently become a top-level query. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_execute_callback_rejects_non_integer_parent_filter(): void { + $this->login_as( 'administrator' ); + $execute = $this->get_ability_callbacks()['execute_callback']; + + $result = $execute( + array( + 'where' => array( + 'type' => 'page', + 'parent' => 'not-a-number', + ), + ) + ); + + $this->assertWPError( $result, 'A non-integer parent filter must not silently coerce to a top-level (0) query.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An unhonorable parent filter should fail closed as an invalid filter.' ); + $this->assertSame( + array( 'where[parent]' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the non-integer parent filter to the error message.' + ); + } + + /** + * Returns `where.ids` values that hold no valid ID: no item is a whole number of at least + * 1 within the integer range. + * + * @return array The `where.ids` values. + */ + public function data_ids_with_no_valid_ids(): array { + return array( + 'zero' => array( + 'ids' => array( 0 ), + ), + 'fraction' => array( + 'ids' => array( 2.5 ), + ), + 'negative number' => array( + 'ids' => array( -5 ), + ), + 'non-numeric string' => array( + 'ids' => 'abc', + ), + 'beyond the integer range' => array( + 'ids' => array( 2 ** 64 + self::ALIASED_POST_ID ), + ), + ); + } + + /** + * An ids filter that parses to no valid IDs is rejected rather than returning an + * unrestricted result set. + * + * WP_Query ignores an empty `post__in`, so an ids list with no valid IDs would otherwise + * return every post of the type — the opposite of the caller's intent. The filter must + * fail closed instead, and an ID beyond the integer range must not wrap around onto the + * published post whose ID it aliases. + * + * @ticket 66268 + * @dataProvider data_ids_with_no_valid_ids + * @since 7.2.0 + * + * @param mixed $ids The `where.ids` value to send. + */ + public function test_execute_callback_rejects_ids_with_no_valid_ids( $ids ): void { + $this->login_as( 'administrator' ); + + self::factory()->post->create( + array( + 'import_id' => self::ALIASED_POST_ID, + 'post_status' => 'publish', + ) + ); + $execute = $this->get_ability_callbacks()['execute_callback']; + + $result = $execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => $ids, + ), + ) + ); + + $this->assertWPError( $result, 'An ids filter with no valid IDs must not fall through to an unrestricted query.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An empty-after-parsing ids filter should fail closed as an invalid filter.' ); + $this->assertSame( + array( 'where[ids]' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the ids filter to the error message.' + ); + } + + /** + * Called directly, an item of `where.ids` that is not a valid ID is dropped, while the + * other IDs still apply. + * + * The fraction is just above a real post's ID, so an ID truncated onto that post would + * return it as well. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_execute_callback_drops_an_invalid_id_next_to_a_valid_one(): void { + $truncated_id = self::factory()->post->create( + array( + 'import_id' => self::IMPORTED_POST_ID, + 'post_status' => 'publish', + ) + ); + $this->assertSame( self::IMPORTED_POST_ID, $truncated_id, 'Precondition: the post should have the ID the fraction truncates to.' ); + + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $this->login_as( 'administrator' ); + $execute = $this->get_ability_callbacks()['execute_callback']; + + $result = $execute( + array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::IMPORTED_POST_ID + 0.5, $post_id ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( + array( + 'items' => array( + array( 'id' => $post_id ), + ), + 'pagination' => array( + 'total' => 1, + 'total_pages' => 1, + ), + ), + $result, + 'Only the post with the valid ID should be returned.' + ); + } + + /** + * Returns `where.slugs` values in which no slug is left after normalization. + * + * Schema validation rejects the empty ones, so the test calls the execute callback + * directly. + * + * @return array The `where.slugs` values. + */ + public function data_slugs_with_no_slug_left(): array { + return array( + 'empty slug' => array( + 'slugs' => array( '' ), + ), + 'empty string' => array( + 'slugs' => '', + ), + 'punctuation string' => array( + 'slugs' => '!!!', + ), + ); + } + + /** + * Called directly, a slugs filter with no slug left after normalization is rejected rather + * than widened to every post. + * + * @ticket 66268 + * @dataProvider data_slugs_with_no_slug_left + * @since 7.2.0 + * + * @param mixed $slugs The `where.slugs` value to send. + */ + public function test_execute_callback_rejects_slugs_with_no_slug_left( $slugs ): void { + $this->login_as( 'administrator' ); + + self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $execute = $this->get_ability_callbacks()['execute_callback']; + + $result = $execute( + array( + 'where' => array( + 'type' => 'post', + 'slugs' => $slugs, + ), + ) + ); + + $this->assertWPError( $result, 'A slugs filter with no slug left must not fall through to an unrestricted query.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'A slugs filter with no slug left should fail closed as an invalid filter.' ); + $this->assertSame( + array( 'where[slugs]' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the slugs filter to the error message.' + ); + } + + /** + * Returns `where` filters with a status that is not registered, or is internal, which reach + * the execute callback when it is called directly, or when the input schema lists a status + * that is not registered when the query runs. + * + * @return array}> The `where` filters. + */ + public function data_where_filters_with_statuses_that_cannot_be_queried(): array { + return array( + 'empty status list' => array( + 'where' => array( + 'type' => 'post', + 'status' => array(), + ), + ), + 'status list without a status name' => array( + 'where' => array( + 'type' => 'post', + 'status' => array( 123 ), + ), + ), + 'unregistered status' => array( + 'where' => array( + 'type' => 'post', + 'status' => array( 'unregistered_status' ), + ), + ), + 'unregistered status next to publish' => array( + 'where' => array( + 'type' => 'post', + 'status' => array( 'publish', 'unregistered_status' ), + ), + ), + 'unregistered status next to ids' => array( + 'where' => array( + 'type' => 'post', + 'ids' => array( self::IMPORTED_POST_ID ), + 'status' => array( 'unregistered_status' ), + ), + ), + 'trash' => array( + 'where' => array( + 'type' => 'post', + 'status' => array( 'trash' ), + ), + ), + 'inherit' => array( + 'where' => array( + 'type' => 'post', + 'status' => array( 'inherit' ), + ), + ), + ); + } + + /** + * Called directly, a status filter with a status that is not registered, or is internal, or + * with no status left in it, is rejected rather than queried. + * + * WP_Query ignores a status that is not registered, and with no status left it covers every + * status, including `trash`. A list that is given but empty would fall through to the default + * statuses instead, which with `ids` or `slugs` cover every status the user may query. + * + * @ticket 66268 + * @dataProvider data_where_filters_with_statuses_that_cannot_be_queried + * @since 7.2.0 + * + * @param array $where The `where` filters to send. + */ + public function test_execute_callback_rejects_statuses_that_cannot_be_queried( array $where ): void { + $this->login_as( 'administrator' ); + + $execute = $this->get_ability_callbacks()['execute_callback']; + + $result = $execute( array( 'where' => $where ) ); + + $this->assertWPError( $result, 'A status filter with a status that cannot be queried must not run the query.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'A status that cannot be queried should fail as an invalid filter.' ); + $this->assertSame( + array( 'where[status]' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the status filter to the error message.' + ); + } + + /** + * A status that the input schema still lists, but that is no longer registered when the + * query runs, is rejected rather than queried. + * + * The input schema lists the statuses registered when the ability is registered, so such a + * status passes input validation. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_status_no_longer_registered_is_rejected(): void { + register_post_status( + 'temporary', + array( + 'label' => 'Temporary', + 'public' => true, + ) + ); + + $this->login_as( 'editor' ); + $this->register_ability(); + + _unregister_post_status( 'temporary' ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'status' => array( 'temporary' ), + ), + ) + ); + + $this->assertWPError( $result, 'A status that is no longer registered must not run the query.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'A status that is no longer registered should fail as an invalid filter.' ); + $this->assertSame( + array( 'where[status]' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the status that is no longer registered to the error message.' + ); + } + + /** + * `where.ids` and `where.slugs` also take a comma-separated string, and a single ID or + * slug, even a slug sent as a number, as schema validation does. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_ids_and_slugs_accept_comma_separated_strings_and_single_values(): void { + $older_id = self::factory()->post->create( + array( + 'post_name' => 'string-list-older', + 'post_status' => 'publish', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + $newer_id = self::factory()->post->create( + array( + 'post_name' => 'string-list-newer', + 'post_status' => 'publish', + 'post_date' => '2026-02-01 10:00:00', + ) + ); + $year_id = self::factory()->post->create( + array( + 'post_name' => '2024', + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ability = wp_get_ability( 'core/content-query' ); + $query = static function ( array $filter ) use ( $ability ) { + return $ability->execute( + array( + 'where' => array( 'type' => 'post' ) + $filter, + 'fields' => array( 'id' ), + ) + ); + }; + $both = array( + 'items' => array( + array( 'id' => $newer_id ), + array( 'id' => $older_id ), + ), + 'pagination' => array( + 'total' => 2, + 'total_pages' => 1, + ), + ); + $older = array( + 'items' => array( + array( 'id' => $older_id ), + ), + 'pagination' => array( + 'total' => 1, + 'total_pages' => 1, + ), + ); + $year = array( + 'items' => array( + array( 'id' => $year_id ), + ), + 'pagination' => array( + 'total' => 1, + 'total_pages' => 1, + ), + ); + + $this->assertSame( $both, $query( array( 'ids' => $older_id . ',' . $newer_id ) ), 'A comma-separated ids string should return every listed post.' ); + $this->assertSame( $both, $query( array( 'slugs' => 'string-list-older,string-list-newer' ) ), 'A comma-separated slugs string should return every listed post.' ); + $this->assertSame( $older, $query( array( 'ids' => $older_id ) ), 'A single ID should return its post.' ); + $this->assertSame( $older, $query( array( 'slugs' => 'string-list-older' ) ), 'A single slug string should return its post.' ); + $this->assertSame( $year, $query( array( 'slugs' => 2024 ) ), 'A single slug sent as a number should return its post.' ); + } + + /** + * Returns values that are not objects, which schema validation rejects for `where` and + * `pagination`. + * + * @return array The values to send. + */ + public function data_values_that_are_not_objects(): array { + return array( + 'query string' => array( + 'value' => 'id=5&page=2', + ), + 'integer' => array( + 'value' => 5, + ), + 'boolean' => array( + 'value' => true, + ), + 'null' => array( + 'value' => null, + ), + ); + } + + /** + * The execute callback rejects a `where` that is not an object, instead of running a query + * without filters. + * + * Gated transports validate the input first, so such a value never reaches the callback + * through them. The permission callback lets it through so that the execute callback can + * report it. + * + * @ticket 66268 + * @dataProvider data_values_that_are_not_objects + * @since 7.2.0 + * + * @param mixed $value The value to send as `where`. + */ + public function test_execute_callback_rejects_a_where_that_is_not_an_object( $value ): void { + $this->login_as( 'administrator' ); + + $callbacks = $this->get_ability_callbacks(); + $input = array( 'where' => $value ); + + $this->assertTrue( $callbacks['permission_callback']( $input ), 'The permission callback should let the where through.' ); + + $result = $callbacks['execute_callback']( $input ); + + $this->assertWPError( $result, 'The where must not fall through to a query without filters.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'The where should fail closed as an invalid filter.' ); + $this->assertSame( + array( 'where' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the where to the error message.' + ); + $this->assertSame( 400, $result->get_error_data()['status'], 'The where should be reported as a bad request.' ); + } + + /** + * Called directly, a `where` that is missing, empty, or has no exposed post type reads as + * a query without a post type, which both callbacks refuse. + * + * IDs and slugs do not stand in for the post type. + * + * @ticket 66268 + * @dataProvider data_inputs_without_a_post_type + * @since 7.2.0 + * + * @param array $input The input to send. + */ + public function test_callbacks_refuse_a_query_without_a_post_type( array $input ): void { + $this->login_as( 'administrator' ); + + $callbacks = $this->get_ability_callbacks(); + + $this->assertFalse( $callbacks['permission_callback']( $input ), 'The permission callback should refuse a query without a post type.' ); + + $result = $callbacks['execute_callback']( $input ); + + $this->assertWPError( $result, 'The execute callback should not run a query without a post type.' ); + $this->assertSame( 'content_not_found', $result->get_error_code(), 'A query without a post type should be reported as not found.' ); + } + + /** + * Called directly, a `pagination` that is not an object falls back to the first page of + * the default page size. + * + * @ticket 66268 + * @dataProvider data_values_that_are_not_objects + * @since 7.2.0 + * + * @param mixed $value The value to send as `pagination`. + */ + public function test_execute_callback_defaults_a_pagination_that_is_not_an_object( $value ): void { + $this->login_as( 'administrator' ); + + // More posts than the default page size of 10, each with its own date. + for ( $day = 1; $day <= 11; $day++ ) { + self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => sprintf( '2026-01-%02d 10:00:00', $day ), + ) + ); + } + + $execute = $this->get_ability_callbacks()['execute_callback']; + $query = array( + 'where' => array( 'type' => 'post' ), + 'fields' => array( 'id' ), + ); + + $expected = $execute( $query ); + + $this->assertIsArray( $expected, 'Precondition: the query without pagination should succeed.' ); + $this->assertCount( 10, $expected['items'], 'Precondition: the query without pagination should hold a page of the default size.' ); + $this->assertSame( $expected, $execute( $query + array( 'pagination' => $value ) ), 'The pagination should fall back to the first page of the default page size.' ); + } + + /** + * An author_slug filter names only users the current user may see: a user without + * published posts is reported like a missing one to a subscriber, but not to an editor, + * who can edit their posts. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_author_slug_filter_only_names_users_the_current_user_may_see(): void { + $published_id = self::factory()->post->create( array( 'post_author' => self::$user_ids['author'] ) ); + $draft_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author_secondary'], + 'post_status' => 'draft', + ) + ); + + $execute = $this->get_ability_callbacks()['execute_callback']; + $query = static function ( array $statuses, int $user_id ) use ( $execute ) { + return $execute( + array( + 'where' => array( + 'type' => 'post', + 'status' => $statuses, + 'author_slug' => get_userdata( $user_id )->user_nicename, + ), + 'fields' => array( 'id' ), + ) + ); + }; + + $this->login_as( 'subscriber' ); + $public = $query( array( 'publish' ), self::$user_ids['author'] ); + $this->assertIsArray( $public, 'A subscriber should filter by an author with published posts.' ); + $this->assertSame( array( $published_id ), wp_list_pluck( $public['items'], 'id' ), 'The filter should return that author\'s posts.' ); + + $hidden = $query( array( 'publish' ), self::$user_ids['author_secondary'] ); + $this->assertWPError( $hidden, 'A subscriber should not learn that an author without published posts exists.' ); + $this->assertSame( 'content_invalid_filter', $hidden->get_error_code(), 'A hidden author should be reported like a missing one.' ); + $this->assertSame( + array( 'where[author_slug]' => $hidden->get_error_message() ), + $hidden->get_error_data()['params'], + 'The error data should map the hidden author_slug filter to the error message.' + ); + + $this->login_as( 'editor' ); + $drafts = $query( array( 'draft' ), self::$user_ids['author_secondary'] ); + $this->assertIsArray( $drafts, 'An editor should filter by an author without published posts.' ); + $this->assertSame( array( $draft_id ), wp_list_pluck( $drafts['items'], 'id' ), 'The filter should return that author\'s drafts.' ); + } + + /** + * For a post type with its own capabilities, an author_slug filter names a user without + * published posts to the users who can edit others' posts of that post type, not to those + * who can edit others' posts. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_author_slug_filter_follows_the_capabilities_of_the_queried_post_type(): void { + $this->register_post_type_with_own_capabilities(); + + $draft_id = self::factory()->post->create( + array( + 'post_type' => 'book', + 'post_author' => self::$user_ids['author_secondary'], + 'post_status' => 'draft', + ) + ); + + $execute = $this->get_ability_callbacks()['execute_callback']; + $input = array( + 'where' => array( + 'type' => 'book', + 'status' => array( 'draft' ), + 'author_slug' => get_userdata( self::$user_ids['author_secondary'] )->user_nicename, + ), + 'fields' => array( 'id' ), + ); + + $this->login_as( 'editor' ); + $hidden = $execute( $input ); + + $this->assertWPError( $hidden, 'An editor, who can edit others\' posts but not others\' books, should not learn that an author without published posts exists.' ); + $this->assertSame( 'content_invalid_filter', $hidden->get_error_code(), 'An author hidden from the editor should be reported like a missing one.' ); + + wp_set_current_user( $this->create_subscriber_with_capabilities( array( 'edit_books', 'edit_others_books' ) ) ); + $drafts = $execute( $input ); + + $this->assertIsArray( $drafts, 'A user who can edit others\' books should filter by an author without published posts.' ); + $this->assertSame( array( $draft_id ), wp_list_pluck( $drafts['items'], 'id' ), 'The filter should return that author\'s draft books.' ); + } + + /** + * On multisite, an author_slug filter does not tell an editor of one site whether a user + * of another site exists. + * + * @ticket 66268 + * @group ms-required + * @since 7.2.0 + */ + public function test_author_slug_filter_hides_users_of_other_sites(): void { + $other_user_id = self::factory()->user->create( array( 'user_nicename' => 'other-site-user' ) ); + $site_id = self::factory()->blog->create(); + + switch_to_blog( $site_id ); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + + $execute = $this->get_ability_callbacks()['execute_callback']; + $query = static function ( string $author_slug ) use ( $execute ) { + return $execute( + array( + 'where' => array( + 'type' => 'post', + 'author_slug' => $author_slug, + ), + ) + ); + }; + + $is_member = is_user_member_of_blog( $other_user_id, $site_id ); + $other = $query( 'other-site-user' ); + $missing = $query( 'no-such-user' ); + + restore_current_blog(); + + $this->assertFalse( $is_member, 'Precondition: the other user should not be a member of the site.' ); + $this->assertWPError( $missing, 'Precondition: a slug that names no user should be rejected.' ); + $this->assertWPError( $other, 'An editor should not learn that a user of another site exists.' ); + $this->assertSame( $missing->get_error_code(), $other->get_error_code(), 'A user of another site should be reported like a missing one.' ); + } + + /** + * On multisite, an author_slug filter still names an author who is not a member of the + * site, such as a super admin, when they have published posts there. + * + * @ticket 66268 + * @group ms-required + * @since 7.2.0 + */ + public function test_author_slug_filter_names_authors_who_are_not_site_members(): void { + $super_admin_id = self::factory()->user->create( array( 'user_nicename' => 'network-author' ) ); + grant_super_admin( $super_admin_id ); + $site_id = self::factory()->blog->create(); + $execute = $this->get_ability_callbacks()['execute_callback']; + + switch_to_blog( $site_id ); + $post_id = self::factory()->post->create( array( 'post_author' => $super_admin_id ) ); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + + $is_member = is_user_member_of_blog( $super_admin_id, $site_id ); + $result = $execute( + array( + 'where' => array( + 'type' => 'post', + 'author_slug' => 'network-author', + ), + 'fields' => array( 'id' ), + ) + ); + + restore_current_blog(); + revoke_super_admin( $super_admin_id ); + + $this->assertFalse( $is_member, 'Precondition: the super admin should not be a member of the site.' ); + $this->assertIsArray( $result, 'An author with published posts on the site should be filterable without being a member.' ); + $this->assertSame( array( $post_id ), wp_list_pluck( $result['items'], 'id' ), 'The filter should return the author\'s posts on the site.' ); + } +} diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php new file mode 100644 index 0000000000000..0374d38eb37f4 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -0,0 +1,411 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + + remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); + add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + + /* + * Restore the hooks right away instead of after the class. The first test of a run + * snapshots the hooks and every test resets them to that snapshot, so changes left + * here would leak into every later test whenever this class runs first. + */ + remove_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); + remove_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); + add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + } + + /** + * Cleans up registered abilities and categories. + * + * @since 7.2.0 + */ + public static function wpTearDownAfterClass(): void { + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + } + + 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; + } + + /** + * @ticket 66268 + */ + public function test_logged_out_user_receives_401(): void { + wp_set_current_user( 0 ); + + $response = $this->server->dispatch( $this->run_request( array( 'where' => array( 'type' => 'post' ) ) ) ); + + $this->assertSame( 401, $response->get_status(), 'A logged-out user should be refused as unauthenticated.' ); + } + + /** + * @ticket 66268 + */ + public function test_subscriber_requesting_drafts_receives_403(): void { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'where' => array( + 'type' => 'post', + 'status' => array( 'draft' ), + ), + ) + ) + ); + + $this->assertSame( 403, $response->get_status(), 'A subscriber should be forbidden from querying drafts.' ); + } + + /** + * @ticket 66268 + */ + public function test_subscriber_requesting_raw_fields_receives_the_posts_without_them(): void { + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + wp_set_current_user( self::$subscriber_id ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'where' => array( 'type' => 'post' ), + 'fields' => array( 'content.raw' ), + ) + ) + ); + + $this->assertSame( 200, $response->get_status(), 'A subscriber requesting raw fields should not be forbidden.' ); + $this->assertSame( + array( + 'items' => array( + array( 'id' => $post_id ), + ), + 'pagination' => array( + 'total' => 1, + 'total_pages' => 1, + ), + ), + $response->get_data(), + 'The published post should be returned without the raw content.' + ); + } + + /** + * @ticket 66268 + */ + public function test_query_by_slug_returns_the_post(): void { + $post_id = self::factory()->post->create( + array( + 'post_name' => 'rest-content-slug', + 'post_status' => 'publish', + ) + ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'where' => array( + 'type' => 'post', + 'slugs' => array( 'rest-content-slug' ), + ), + ) + ) + ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The query by slug should succeed.' ); + $this->assertSame( array( $post_id ), wp_list_pluck( $data['items'], 'id' ), 'A query by slug should return only the matching post.' ); + $this->assertSame( 'rest-content-slug', $data['items'][0]['slug'], 'The query by slug should return the post slug.' ); + $this->assertSame( + array( + 'total' => 1, + 'total_pages' => 1, + ), + $data['pagination'], + 'A query by slug should report one post on one page.' + ); + } + + /** + * @ticket 66268 + */ + 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( 'where' => array( 'type' => 'post' ) ) ) ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 405, $response->get_status(), 'A POST request to the read-only ability should be rejected.' ); + $this->assertSame( 'rest_ability_invalid_method', $response->get_data()['code'], 'The error should identify the invalid HTTP method.' ); + } + + /** + * @ticket 66268 + */ + public function test_out_of_range_page_returns_404(): void { + self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'where' => array( 'type' => 'post' ), + 'pagination' => array( + 'page' => 999, + 'per_page' => 1, + ), + ) + ) + ); + + $this->assertSame( 404, $response->get_status(), 'Requesting a page past the last one should return a 404 error.' ); + $this->assertSame( 'content_invalid_page_number', $response->get_data()['code'], 'The error should identify the invalid page number.' ); + } + + /** + * A GET request delivers every value as a string, and a list as a comma-separated string. + * + * @ticket 66268 + */ + public function test_query_string_values_are_accepted(): void { + $older = self::factory()->post->create( + array( + 'post_title' => 'Older post', + 'post_status' => 'publish', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + $newer = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-02-01 10:00:00', + ) + ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'where' => array( + 'type' => 'post', + 'ids' => $newer . ',' . $older, + ), + 'fields' => 'id,title.rendered', + 'pagination' => array( + 'page' => '2', + 'per_page' => '1', + ), + ) + ) + ); + + $this->assertSame( 200, $response->get_status(), 'String values for the second page of a query by two IDs should be accepted.' ); + $this->assertSame( + array( + 'items' => array( + array( + 'id' => $older, + 'title' => array( 'rendered' => 'Older post' ), + ), + ), + 'pagination' => array( + 'total' => 2, + 'total_pages' => 2, + ), + ), + $response->get_data(), + 'The second page, of one post each, should hold the older post with the fields from the comma-separated list.' + ); + } + + /** + * A GET request delivers `slugs` as a comma-separated string, alone or next to `ids`. + * + * @ticket 66268 + */ + public function test_query_string_lists_of_slugs_are_accepted(): void { + $first = self::factory()->post->create( + array( + 'post_name' => 'rest-list-a', + 'post_status' => 'publish', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + $second = self::factory()->post->create( + array( + 'post_name' => 'rest-list-b', + 'post_status' => 'publish', + 'post_date' => '2026-02-01 10:00:00', + ) + ); + $third = self::factory()->post->create( + array( + 'post_name' => 'rest-list-c', + 'post_status' => 'publish', + 'post_date' => '2026-03-01 10:00:00', + ) + ); + + $by_slugs = $this->server->dispatch( + $this->run_request( + array( + 'where' => array( + 'type' => 'post', + 'slugs' => 'rest-list-a,rest-list-c', + ), + 'fields' => 'id', + ) + ) + ); + + $this->assertSame( 200, $by_slugs->get_status(), 'A comma-separated slugs string from a query string should be accepted.' ); + $this->assertSame( + array( + 'items' => array( + array( 'id' => $third ), + array( 'id' => $first ), + ), + 'pagination' => array( + 'total' => 2, + 'total_pages' => 1, + ), + ), + $by_slugs->get_data(), + 'A comma-separated slugs string should return every listed post, newest first, on one page.' + ); + + $by_both = $this->server->dispatch( + $this->run_request( + array( + 'where' => array( + 'type' => 'post', + 'ids' => $first . ',' . $second, + 'slugs' => 'rest-list-b,rest-list-c', + ), + 'fields' => 'id', + ) + ) + ); + + $this->assertSame( 200, $by_both->get_status(), 'Comma-separated ids and slugs strings together should be accepted.' ); + $this->assertSame( + array( + 'items' => array( + array( 'id' => $second ), + ), + 'pagination' => array( + 'total' => 1, + 'total_pages' => 1, + ), + ), + $by_both->get_data(), + 'Comma-separated ids and slugs strings together should return only the post in both lists.' + ); + } + + /** + * A GET request without `where` is rejected, also when it gives the post type at the root. + * + * @ticket 66268 + */ + public function test_input_without_where_returns_400(): void { + $response = $this->server->dispatch( $this->run_request( array( 'type' => 'post' ) ) ); + + $this->assertSame( 400, $response->get_status(), 'Input without where should be rejected.' ); + $this->assertSame( 'ability_invalid_input', $response->get_data()['code'], 'The error should come from input validation.' ); + } +}