diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 0eb87a4581589..ce9170f9d2cbe 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -9,6 +9,8 @@ declare( strict_types = 1 ); +require_once __DIR__ . '/abilities/class-wp-content-abilities.php'; + /** * Registers the core ability categories. * @@ -30,6 +32,14 @@ function wp_register_core_ability_categories(): void { 'description' => __( 'Abilities that retrieve or modify user information and settings.' ), ) ); + + wp_register_ability_category( + 'content', + array( + 'label' => __( 'Content' ), + 'description' => __( 'Abilities that retrieve or manage posts and other content.' ), + ) + ); } /** @@ -351,4 +361,7 @@ function wp_register_core_abilities(): void { ), ) ); + + // Register the content abilities (currently the read-only `core/read-content`). + ( new WP_Content_Abilities() )->register(); } diff --git a/src/wp-includes/abilities/class-wp-content-abilities.php b/src/wp-includes/abilities/class-wp-content-abilities.php new file mode 100644 index 0000000000000..d80d357c57225 --- /dev/null +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -0,0 +1,1546 @@ + + */ + private array $edit_fields = array( + 'title_raw', + 'excerpt_raw', + 'content_raw', + ); + + /** + * Fields whose rendering may read post meta or terms. + * + * Requests that include any of these prime the post meta and term caches for the + * page. Other rendered fields, such as the title, do not need that cache priming. + * + * @since 7.1.0 + * @var list + */ + private array $cache_priming_fields = array( + 'excerpt_rendered', + 'content_rendered', + ); + + /** + * Cached post field definitions, keyed by field name in output order. + * + * @since 7.1.0 + * @var array|null + */ + private ?array $post_properties = null; + + /** + * Default fields returned when the caller does not request a field subset. + * + * @since 7.1.0 + * @var list + */ + private array $default_fields = array( + 'id', + 'post_type', + 'status', + 'date', + 'slug', + 'title_rendered', + ); + + /** + * Registers all content abilities. + * + * Must run on the `wp_abilities_api_init` hook. + * + * @since 7.1.0 + */ + public function register(): void { + $this->register_read_content(); + + /* + * A future write-oriented ability can be registered here, reusing the shared + * helpers below (get_exposed_post_types(), format_post(), check_permission()): + * + * $this->register_manage_content(); + */ + } + + /** + * Registers the read-only `core/read-content` ability. + * + * @since 7.1.0 + */ + private function register_read_content(): void { + /* + * Post types must be registered with `show_in_abilities` before the ability is + * registered so they are included in its input schema. + */ + $post_types = array_keys( $this->get_exposed_post_types() ); + if ( empty( $post_types ) ) { + return; + } + + /* + * Internal statuses (e.g. `inherit`) are excluded, so post types that rely on + * them (attachments) are only reachable by ID. Revisit if such a post type is + * ever exposed via `show_in_abilities`. + */ + $statuses = array_values( get_post_stati( array( 'internal' => false ) ) ); + + wp_register_ability( + 'core/read-content', + array( + 'label' => __( 'Read Content' ), + 'description' => __( 'Reads content from post types exposed to abilities. Single-post lookups by ID or by post type and slug return the post object directly. Query mode returns readable posts filtered by post type, status, author, parent, or included IDs. Requires an authenticated user. Lookups and filters are exact-match only; the ability does not perform full-text search.' ), + 'category' => self::CATEGORY, + 'input_schema' => $this->get_read_content_input_schema( $post_types, $statuses ), + 'output_schema' => $this->get_read_content_output_schema(), + 'execute_callback' => array( $this, 'execute_read_content' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + // MCP clients assume open-world (may reach external systems) when the + // hint is absent; this ability only reads the local database. + 'open_world' => false, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Permission callback for the `core/read-content` ability. + * + * This gate is the authoritative permission decision for single-post modes: it + * resolves the requested post and denies missing, mismatched, or unreadable posts + * before execution. Query mode is only gated coarsely here (collection status + * capabilities); {@see self::execute_read_content()} enforces row-level read/edit + * permissions, since individual rows are unknown until the query runs. Requests + * that explicitly ask for edit-context fields require edit access before execution. + * + * @since 7.1.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return bool True if the request may proceed, false otherwise. + */ + public function check_permission( $input = array() ): bool { + $input = rest_sanitize_object( $input ); + $exposed = $this->get_exposed_post_types(); + + if ( ! is_user_logged_in() ) { + return false; + } + + $requires_edit = $this->has_explicit_edit_fields( $input ); + + // Single-post mode (by ID). + if ( ! empty( $input['id'] ) ) { + $post = get_post( $this->input_int( $input['id'] ) ); + + if ( ! $post + || ! isset( $exposed[ $post->post_type ] ) + || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) + ) { + return false; + } + + return $requires_edit ? current_user_can( 'edit_post', $post->ID ) : $this->check_read_permission( $post ); + } + + // Single-post mode (by slug) and query mode require an exposed post type. + $post_type = isset( $input['post_type'] ) && is_string( $input['post_type'] ) ? $input['post_type'] : ''; + if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { + return false; + } + + if ( isset( $input['slug'] ) && is_string( $input['slug'] ) && '' !== $input['slug'] ) { + $post = $this->get_post_by_slug( $post_type, $input['slug'] ); + if ( ! $post ) { + return false; + } + + return $requires_edit ? current_user_can( 'edit_post', $post->ID ) : $this->check_read_permission( $post ); + } + + $post_type_object = $exposed[ $post_type ]; + if ( $requires_edit ) { + return current_user_can( $this->post_type_cap( $post_type_object, 'edit_posts' ) ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. + } + + return $this->can_query_statuses( $input, $post_type_object ); + } + + /** + * Casts a raw input value to a non-negative integer. + * + * @since 7.1.0 + * + * @param mixed $value The raw input value. + * @return int The value as a non-negative integer, or 0 when not scalar. + */ + private function input_int( $value ): int { + return is_scalar( $value ) ? absint( $value ) : 0; + } + + /** + * Parses a raw filter value into an integer of at least a minimum, or null when invalid. + * + * Unlike {@see self::input_int()}, which coerces any non-integer to 0, this rejects + * values that are not integers so a filter whose value cannot be honored can fail + * loudly instead of silently widening the query: `author => 0` drops the author + * filter (matching every author) and `post_parent => 0` becomes a top-level query. + * Accepts native integers and unsigned integer strings, mirroring how the JSON + * Schema `integer` type and the query-string transport respectively deliver them. + * + * @since 7.1.0 + * + * @param mixed $value The raw input value. + * @param int $min The smallest acceptable value. + * @return int|null The parsed integer, or null when the value is not an integer >= $min. + */ + private function parse_filter_int( $value, int $min ): ?int { + if ( is_int( $value ) ) { + return $value >= $min ? $value : null; + } + + if ( is_string( $value ) && '' !== $value && ctype_digit( $value ) ) { + $int = (int) $value; + + return $int >= $min ? $int : null; + } + + return null; + } + + /** + * Resolves a capability name from a post type's capability map. + * + * The capability map is a plain object with untyped properties, so guard the + * lookup and fail closed with `do_not_allow` when the name cannot be resolved. + * + * @since 7.1.0 + * + * @param \WP_Post_Type $post_type_object The post type object. + * @param string $capability The capability key, e.g. 'edit_posts'. + * @return string The resolved capability name, or 'do_not_allow' when unresolved. + */ + private function post_type_cap( \WP_Post_Type $post_type_object, string $capability ): string { + $cap = $post_type_object->cap->$capability ?? null; + + return is_string( $cap ) && '' !== $cap ? $cap : 'do_not_allow'; + } + + /** + * Parses a raw list input into a list of strings. + * + * A GET request delivers list inputs as scalar/CSV strings; this parses them the + * same way schema validation did (wp_parse_list) so they are honored regardless of + * transport, until core sanitizes ability input itself. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @param string $key The input key holding the list. + * @return list The parsed string values; empty when absent or unparseable. + */ + private function parse_list_input( array $input, string $key ): array { + $value = $input[ $key ] ?? null; + if ( ! is_array( $value ) && ! is_string( $value ) ) { + return array(); + } + + return array_values( array_filter( wp_parse_list( $value ), 'is_string' ) ); + } + + /** + * Checks whether the input explicitly requests edit-context fields. + * + * Omitted fields are not treated as edit-intent: default responses include the + * fields visible for each individual post. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return bool True if edit-context fields were explicitly requested. + */ + private function has_explicit_edit_fields( array $input ): bool { + return array() !== array_intersect( $this->edit_fields, $this->parse_list_input( $input, 'fields' ) ); + } + + /** + * Checks whether the current user may query the requested statuses. + * + * This mirrors the REST posts controller's conservative collection-status gate: + * requesting non-default statuses requires edit access, except `private`, which + * may be queried by users who can read private posts. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @param \WP_Post_Type $post_type_object The post type object. + * @return bool True if the requested statuses may be queried. + */ + private function can_query_statuses( array $input, \WP_Post_Type $post_type_object ): bool { + foreach ( $this->normalize_statuses( $input ) as $status ) { + if ( 'publish' === $status ) { + continue; + } + + // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. + if ( 'private' === $status && current_user_can( $this->post_type_cap( $post_type_object, 'read_private_posts' ) ) ) { + continue; + } + + // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. + if ( current_user_can( $this->post_type_cap( $post_type_object, 'edit_posts' ) ) ) { + continue; + } + + return false; + } + + return true; + } + + /** + * Checks if a post can be read by the current user. + * + * Mirrors the REST posts controller's read permission, while keeping this ability + * authenticated-only via {@see self::check_permission()}. + * + * @since 7.1.0 + * + * @param \WP_Post $post Post object. + * @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; + + $post_type = get_post_type_object( $post->post_type ); + if ( ! $post_type instanceof \WP_Post_Type || empty( $post_type->show_in_abilities ) ) { + 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. + */ + if ( + 'inherit' === $post->post_status && + $post->post_parent > 0 && + (int) $post->post_parent !== (int) $post->ID + ) { + $parent = get_post( $post->post_parent ); + if ( $parent instanceof WP_Post ) { + return $this->check_read_permission( $parent, $checked_post_ids ); + } + } + + return false; + } + + /** + * Executes the `core/read-content` ability. + * + * {@see WP_Ability::execute()} always runs {@see self::check_permission()} first, so the + * single-post modes only re-validate the lookup itself: existence, exposure, and a + * matching post type. Query mode still filters every row by read or edit permission, + * because the gate cannot resolve rows before the query runs. + * + * A post is returned as an empty object when its field projection is empty, so callers + * must not assume array access on a post. See {@see self::to_output_post()}. + * + * @since 7.1.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return array|\stdClass|\WP_Error A single post, a `posts` list with totals in query mode, or a WP_Error. + */ + public function execute_read_content( $input = array() ) { + $input = rest_sanitize_object( $input ); + $exposed = $this->get_exposed_post_types(); + $fields = $this->normalize_fields( $input ); + $requires_edit = $this->has_explicit_edit_fields( $input ); + + // Single-post mode (by ID). + if ( ! empty( $input['id'] ) ) { + $post = get_post( $this->input_int( $input['id'] ) ); + + if ( ! $post + || ! isset( $exposed[ $post->post_type ] ) + || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) + ) { + return $this->not_found_error(); + } + + return $this->to_output_post( $this->format_post( $post, $fields ) ); + } + + // Single-post mode (by slug) and query mode. + $post_type = isset( $input['post_type'] ) && is_string( $input['post_type'] ) ? $input['post_type'] : ''; + if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { + return $this->not_found_error(); + } + + if ( isset( $input['slug'] ) && is_string( $input['slug'] ) && '' !== $input['slug'] ) { + $post = $this->get_post_by_slug( $post_type, $input['slug'] ); + + if ( ! $post ) { + return $this->not_found_error(); + } + + return $this->to_output_post( $this->format_post( $post, $fields ) ); + } + + /* + * REST only registers the equivalent collection filters for post types that + * support them; a shared input schema cannot express that per post type. On + * transports that skip schema validation a malformed value would otherwise + * coerce to a benign default and silently *widen* the query (`author => 0` + * drops the author filter, an empty `post__in` is ignored, `post_parent => 0` + * becomes a top-level query). Reject unsupported filters and invalid filter + * values loudly so a filter that cannot be honored fails closed instead. + */ + $parent = null; + if ( isset( $input['parent'] ) ) { + if ( ! is_post_type_hierarchical( $post_type ) ) { + return new WP_Error( + 'content_invalid_filter', + __( 'The parent filter is only supported for hierarchical post types.' ), + array( 'status' => 400 ) + ); + } + + $parent = $this->parse_filter_int( $input['parent'], 0 ); + if ( null === $parent ) { + return new WP_Error( + 'content_invalid_filter', + __( 'The parent filter must be a non-negative integer.' ), + array( 'status' => 400 ) + ); + } + } + + $author = null; + if ( isset( $input['author'] ) ) { + if ( ! post_type_supports( $post_type, 'author' ) ) { + return new WP_Error( + 'content_invalid_filter', + __( 'The author filter is only supported for post types that support authors.' ), + array( 'status' => 400 ) + ); + } + + $author = $this->parse_filter_int( $input['author'], 1 ); + if ( null === $author ) { + return new WP_Error( + 'content_invalid_filter', + __( 'The author filter must be a positive integer.' ), + array( 'status' => 400 ) + ); + } + } + + $include = $this->normalize_include( $input ); + + /* + * An include filter that was supplied but parsed to no valid IDs must not fall + * through to an unrestricted query: WP_Query ignores an empty `post__in`, which + * would return every post of the type — the opposite of the caller's intent. + */ + if ( isset( $input['include'] ) && array() === $include ) { + return new WP_Error( + 'content_invalid_filter', + __( 'The include filter must list one or more valid post IDs.' ), + array( 'status' => 400 ) + ); + } + + $per_page = $this->normalize_per_page( $input, $include ); + $page = isset( $input['page'] ) ? max( 1, $this->input_int( $input['page'] ) ) : 1; + + $prime_post_caches = $this->should_prime_post_caches( $fields ); + + // `orderby` is left unset, which orders by `post_date` descending, matching the + // default of the REST posts controller. + $query_args = array( + 'post_type' => $post_type, + 'post_status' => $this->normalize_statuses( $input ), + 'posts_per_page' => $per_page, + 'paged' => $page, + 'perm' => $requires_edit ? 'editable' : 'readable', + 'ignore_sticky_posts' => true, + 'update_post_meta_cache' => $prime_post_caches, + 'update_post_term_cache' => $prime_post_caches, + ); + + if ( array() !== $include ) { + $query_args['post__in'] = $include; + } + + if ( null !== $author ) { + $query_args['author'] = $author; + } + + if ( null !== $parent ) { + $query_args['post_parent'] = $parent; + } + + $query = new WP_Query( $query_args ); + $total = $this->get_query_total( $query, $query_args, $page ); + $total_pages = $total > 0 ? (int) ceil( $total / $per_page ) : 0; + + /* + * Paging past the last page is a caller error rather than an empty collection, so + * report it instead of returning a bare empty list. A genuinely empty result set + * still returns zero totals and no error. + */ + if ( $total > 0 && $page > $total_pages ) { + return new WP_Error( + 'content_invalid_page_number', + __( 'The page number requested is larger than the number of pages available.' ), + array( 'status' => 400 ) + ); + } + + /* + * Prime the author caches with a single query instead of one user lookup + * per post, mirroring the REST posts controller. + */ + if ( in_array( 'author', $fields, true ) && post_type_supports( $post_type, 'author' ) ) { + $query_posts = array_filter( + $query->posts, + static function ( $queried_post ): bool { + return $queried_post instanceof WP_Post; + } + ); + update_post_author_caches( $query_posts ); + } + + $posts = array(); + foreach ( $query->posts as $post ) { + if ( ! $post instanceof WP_Post ) { + continue; + } + if ( $requires_edit && ! current_user_can( 'edit_post', $post->ID ) ) { + continue; + } + if ( ! $requires_edit && ! $this->check_read_permission( $post ) ) { + continue; + } + // Keep rows whose field projection is empty so a caller can still count them. + $posts[] = $this->to_output_post( $this->format_post( $post, $fields ) ); + } + + /* + * Mirror the REST posts controller: totals come from the underlying WP_Query, + * while row-level permission checks above may withhold individual returned rows. + */ + return array( + 'posts' => $posts, + 'total' => $total, + 'total_pages' => $total_pages, + ); + } + + /** + * Normalizes the requested per-page value to the supported bounds. + * + * An explicit `per_page` always wins. Otherwise an `include` request pages to the + * number of requested IDs, so a caller loading a known set of posts receives all of + * them in one call rather than silently losing the ones past the default page size. + * The input schema caps `include` at {@see self::MAX_PER_PAGE} so it always fits. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @param list $include_ids Normalized included post IDs; empty when not requested. + * @return int The clamped per-page value. + */ + private function normalize_per_page( array $input, array $include_ids = array() ): int { + if ( isset( $input['per_page'] ) ) { + return max( 1, min( self::MAX_PER_PAGE, $this->input_int( $input['per_page'] ) ) ); + } + + if ( array() !== $include_ids ) { + return max( 1, min( self::MAX_PER_PAGE, count( $include_ids ) ) ); + } + + return self::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.1.0 + * + * @param \WP_Query $query The executed query. + * @param array $query_args The arguments used for the executed query. + * @param int $page The requested page. + * @return int Total matching rows across all pages. + */ + private function get_query_total( WP_Query $query, array $query_args, int $page ): int { + $total = (int) $query->found_posts; + + if ( $total > 0 || $page <= 1 ) { + return $total; + } + + $count_args = $query_args; + $count_args['fields'] = 'ids'; + $count_args['posts_per_page'] = 1; + $count_args['update_post_meta_cache'] = false; + $count_args['update_post_term_cache'] = false; + unset( $count_args['paged'] ); + + $count_query = new WP_Query( $count_args ); + + return (int) $count_query->found_posts; + } + + /** + * Checks whether requested fields benefit from page-level cache priming. + * + * @since 7.1.0 + * + * @param list $fields The requested field names. + * @return bool True when post meta and term caches should be primed. + */ + private function should_prime_post_caches( array $fields ): bool { + return array() !== array_intersect( $this->cache_priming_fields, $fields ); + } + + /** + * Looks up the single post a slug request resolves to. + * + * Slugs are not unique across statuses (drafts skip slug uniqueness), so the + * lookup returns the newest match the current user can read, preferring + * publicly viewable posts — a newer draft sharing the slug cannot shadow a + * published post. This mirrors the REST API, where slug queries default to + * the `publish` status. Which post a slug resolves to is independent of the + * requested fields; edit-field requests are gated afterwards on the resolved + * post by {@see self::check_permission()}. + * + * @since 7.1.0 + * + * @param string $post_type The post type. + * @param string $slug The post slug. + * @return \WP_Post|null The matching readable post, or null when none exists. + */ + private function get_post_by_slug( string $post_type, string $slug ): ?WP_Post { + $name = sanitize_title( $slug ); + if ( '' === $name ) { + return null; + } + + $query = new WP_Query( + array( + 'post_type' => $post_type, + 'name' => $name, + 'post_status' => array_values( get_post_stati( array( 'internal' => false ) ) ), + 'no_found_rows' => true, + 'ignore_sticky_posts' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + + $viewable = array(); + $hidden = array(); + foreach ( $query->posts as $candidate ) { + if ( ! $candidate instanceof WP_Post ) { + continue; + } + + if ( is_post_publicly_viewable( $candidate ) ) { + $viewable[] = $candidate; + continue; + } + + $hidden[] = $candidate; + } + + // Both groups keep the query's newest-first ordering. + foreach ( array_merge( $viewable, $hidden ) as $candidate ) { + if ( ! $this->check_read_permission( $candidate ) ) { + continue; + } + + return $candidate; + } + + return null; + } + + /** + * Returns the post types exposed through the Abilities API, keyed by name. + * + * Deliberately resolved on every call rather than cached: post types can be + * unregistered or re-registered with different arguments between the ability + * being registered and the ability being used. + * + * @since 7.1.0 + * + * @return array Exposed post type objects keyed by name. + */ + private function get_exposed_post_types(): array { + $exposed_post_types = array(); + + foreach ( get_post_types( array( 'show_in_abilities' => true ), 'objects' ) as $post_type_object ) { + $exposed_post_types[ $post_type_object->name ] = $post_type_object; + } + + return $exposed_post_types; + } + + /** + * Normalizes the requested statuses to a non-empty, sanitized list defaulting to publish. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return list Normalized list of post status slugs. + */ + private function normalize_statuses( array $input ): array { + $statuses = $this->parse_list_input( $input, 'status' ); + + return array() === $statuses ? array( 'publish' ) : array_map( 'sanitize_key', $statuses ); + } + + /** + * Normalizes query-mode included post IDs. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return list Unique positive post IDs. + */ + private function normalize_include( array $input ): array { + $include = $input['include'] ?? null; + if ( ! is_array( $include ) && ! is_string( $include ) ) { + return array(); + } + + // A GET request delivers list inputs as scalar/CSV strings; wp_parse_id_list() + // accepts both and yields unique positive IDs, matching schema validation. + return array_values( array_filter( wp_parse_id_list( $include ) ) ); + } + + /** + * Returns the requested fields, or a lean default set when none are given. + * + * An empty or absent `fields` value selects a lean set of common read fields. + * Otherwise the requested fields are returned as-is. The input schema has already + * validated them against the supported set before the ability executes. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return list List of requested field names. + */ + private function normalize_fields( array $input ): array { + $fields = $this->parse_list_input( $input, 'fields' ); + + return array() === $fields ? $this->default_fields : $fields; + } + + /** + * Returns the post field definitions, keyed by field name in output order. + * + * This is the single source of truth for the ability's post fields: the output + * schema uses the definitions directly, while the input schema fields enum uses + * the keys. Read-context fields are returned for readable posts; the edit-context + * fields listed in {@see self::$edit_fields} additionally require edit access. + * + * @since 7.1.0 + * + * @return array Post field definitions. + */ + private function get_post_properties(): array { + if ( null !== $this->post_properties ) { + return $this->post_properties; + } + + $this->post_properties = array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The post ID.' ), + ), + 'post_type' => array( + 'type' => 'string', + 'description' => __( 'The post type.' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'The post status.' ), + ), + 'date' => array( + 'type' => 'string', + 'description' => __( "The publication date, in ISO 8601 format using the site's timezone. Empty string when the date cannot be resolved." ), + ), + 'date_gmt' => array( + 'type' => 'string', + 'description' => __( 'The publication date, in ISO 8601 format as GMT. Empty string when the date cannot be resolved.' ), + ), + 'modified' => array( + 'type' => 'string', + 'description' => __( "The last modified date, in ISO 8601 format using the site's timezone. Empty string when the date cannot be resolved." ), + ), + 'modified_gmt' => array( + 'type' => 'string', + 'description' => __( 'The last modified date, in ISO 8601 format as GMT. Empty string when the date cannot be resolved.' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'The post slug.' ), + ), + 'link' => array( + 'type' => 'string', + 'description' => __( 'The permalink URL.' ), + ), + 'title_raw' => array( + 'type' => 'string', + 'description' => __( 'The raw post title. Present when the post type supports titles and the current user can edit the post.' ), + ), + 'title_rendered' => array( + 'type' => 'string', + 'description' => __( 'The rendered post title. Present when the post type supports titles.' ), + ), + 'excerpt_raw' => array( + 'type' => 'string', + 'description' => __( 'The raw post excerpt. Present when the post type supports excerpts and the current user can edit the post.' ), + ), + 'excerpt_rendered' => array( + 'type' => 'string', + 'description' => __( 'The rendered post excerpt (HTML). Present when the post type supports excerpts. Empty when withheld for a password-protected post.' ), + ), + 'excerpt_protected' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the excerpt is protected with a password. Present when the post type supports excerpts.' ), + ), + 'content_raw' => array( + 'type' => 'string', + 'description' => __( 'The raw, unfiltered post content (block markup). Present when the post type supports the editor and the current user can edit the post.' ), + ), + 'content_rendered' => array( + 'type' => 'string', + 'description' => __( 'The rendered post content. Present when the post type supports the editor. Empty when withheld for a password-protected post.' ), + ), + 'content_protected' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the content is protected with a password. Present when the post type supports the editor.' ), + ), + 'author' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The author user ID.' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'The author display name.' ), + ), + ), + 'description' => __( 'The post author. Present when the post type supports authors.' ), + ), + 'parent' => array( + 'type' => 'integer', + 'description' => __( 'The parent post ID. Present for hierarchical post types.' ), + ), + ); + + return $this->post_properties; + } + + /** + * Builds the input schema for the `core/read-content` ability. + * + * The ability has three mutually exclusive modes, modeled as a `oneOf` so invalid + * combinations are rejected rather than silently ignored: + * + * - Get a single post by `id` (optionally guarded by `post_type`). + * - Get a single post by `post_type` and `slug`. + * - Query a set of posts by `post_type` plus filters (`status`, `author`, `parent`, + * `include`, `page`, `per_page`). + * + * Each mode sets `additionalProperties: false`, so e.g. passing `per_page` alongside `id` + * fails validation instead of being dropped. `fields` is accepted in every mode. + * + * @since 7.1.0 + * + * @param list $post_types Exposed post type names. + * @param list $statuses Requestable post status slugs. + * @return array The input JSON Schema. + */ + private function get_read_content_input_schema( array $post_types, array $statuses ): array { + $fields = array( + 'type' => 'array', + 'uniqueItems' => true, + 'items' => array( + 'type' => 'string', + 'enum' => array_keys( $this->get_post_properties() ), + ), + 'description' => __( 'Limit each returned post to these fields. If omitted, a lean set of common read fields is returned. Explicit raw field requests require edit access.' ), + ); + $include = array( + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => self::MAX_PER_PAGE, + 'uniqueItems' => true, + 'items' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'description' => __( 'Limit the query to these post IDs. The order of the IDs does not affect the order of the results. If `per_page` is omitted, the page size defaults to the number of included IDs, capped at the maximum.' ), + ); + + return array( + 'type' => 'object', + 'oneOf' => array( + // Mode 1: retrieve a single readable post by ID. + array( + 'title' => __( 'Get a single readable post by ID' ), + 'required' => array( 'id' ), + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Retrieve a single readable post by ID.' ), + ), + 'post_type' => array( + 'type' => 'string', + 'enum' => $post_types, + 'description' => __( 'Optional. Restrict the lookup to this post type; the post is returned only if it matches and the current user can read it.' ), + ), + 'fields' => $fields, + ), + ), + // Mode 2: retrieve a single readable post by post type and slug. + array( + 'title' => __( 'Get a single readable post by slug' ), + 'required' => array( 'post_type', 'slug' ), + 'additionalProperties' => false, + 'properties' => array( + 'post_type' => array( + 'type' => 'string', + 'enum' => $post_types, + 'description' => __( 'Post type containing the slug. Slugs are not unique across post types.' ), + ), + 'slug' => array( + 'type' => 'string', + 'minLength' => 1, + 'description' => __( 'Retrieve a single readable post by slug. Resolves to the newest readable match, preferring published posts.' ), + ), + 'fields' => $fields, + ), + ), + // Mode 3: query a set of readable posts by post type and filters. + array( + 'title' => __( 'Query readable posts by post type and filters' ), + 'required' => array( 'post_type' ), + 'additionalProperties' => false, + 'properties' => array( + 'post_type' => array( + 'type' => 'string', + 'enum' => $post_types, + 'description' => __( 'Post type to query for readable posts.' ), + ), + 'status' => array( + 'type' => 'array', + 'uniqueItems' => true, + 'items' => array( + 'type' => 'string', + 'enum' => $statuses, + ), + 'description' => __( 'Filter readable posts by one or more post statuses. Defaults to publish. Non-published statuses require the appropriate capabilities.' ), + ), + 'author' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Filter by author user ID. Only supported for post types that support authors.' ), + ), + 'parent' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Filter by parent post ID. Only supported for hierarchical post types. Use 0 for top-level posts.' ), + ), + 'include' => $include, + 'fields' => $fields, + 'page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Page of results to return. Requesting a page beyond the last one is an error. Check `total_pages` before requesting later pages.' ), + ), + 'per_page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => self::MAX_PER_PAGE, + 'description' => __( 'Maximum number of posts to return per page.' ), + ), + ), + ), + ), + ); + } + + /** + * Builds the output schema for the `core/read-content` ability. + * + * No field is marked required because the `fields` input lets the caller request any + * subset, and a field is only present when its post type supports it. Single-post + * mode returns the post object directly, while query mode returns a paginated wrapper. + * + * @since 7.1.0 + * + * @return array The output JSON Schema. + */ + private function get_read_content_output_schema(): array { + $post_schema = array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => $this->get_post_properties(), + ); + + $query_schema = array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'posts', 'total', 'total_pages' ), + 'properties' => array( + 'posts' => array( + 'type' => 'array', + 'description' => __( 'The readable posts matching the query, ordered by post date, newest first.' ), + 'items' => $post_schema, + ), + '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.' ), + ), + ), + ); + + return array( + 'type' => 'object', + 'oneOf' => array( + $post_schema, + $query_schema, + ), + ); + } + + /** + * Prepares a formatted post for output. + * + * A field projection can legitimately be empty, for example when the only requested + * field is one the post type does not support. An empty PHP array encodes as `[]`, + * which would break the `object` output schema, so return an empty object instead. + * + * This deliberately improves on the REST posts controller, which encodes the same + * case as `[]` even though it types the response as an object + * (`GET /wp/v2/posts/?_fields=parent` on a non-hierarchical post type). + * + * @since 7.1.0 + * + * @param array $formatted The formatted post data. + * @return array|\stdClass The post data, or an empty object when the projection is empty. + */ + private function to_output_post( array $formatted ) { + return array() === $formatted ? (object) array() : $formatted; + } + + /** + * Formats a post into the ability output shape. + * + * For an editor of a password-protected post, the cookie-based password gate is suspended + * while the fields are built so rendered fields resolve to real values instead of + * protected-post placeholders. The field projection itself is delegated to + * {@see self::build_post_fields()}. + * + * @since 7.1.0 + * + * @param \WP_Post $post The post object. + * @param list $fields The requested field names. + * @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 ); + $protected = $password_required && ! $can_edit; + + /* + * Suspend the cookie-based password gate for an editor of this protected post, so + * helpers with their own gate (e.g. get_the_excerpt()) resolve the real values. The + * filter unlocks only posts the current user can edit, mirroring the REST posts + * controller's check_password_required(): an unconditional bypass (e.g. __return_false) + * would also expose other protected posts that the content filter may render, such as + * posts pulled in by a Query Loop block. The filter is removed in a finally block so a + * throw mid-render cannot leave the gate globally disabled for the rest of the request. + */ + if ( $password_required && $can_edit ) { + add_filter( 'post_password_required', array( $this, 'allow_password_content' ), 10, 2 ); + + try { + return $this->build_post_fields( $post, $fields, $can_edit, $protected ); + } finally { + remove_filter( 'post_password_required', array( $this, 'allow_password_content' ), 10 ); + } + } + + return $this->build_post_fields( $post, $fields, $can_edit, $protected ); + } + + /** + * Builds the requested field projection for a post. + * + * Only the requested fields that the post type supports and the current user can see are + * included. Raw fields are edit-context fields; rendered fields are read-context fields and + * are withheld for password-protected posts unless the current user can edit the post, + * mirroring the REST API behavior. + * + * @since 7.1.0 + * + * @param \WP_Post $post The post object. + * @param list $fields The requested field names. + * @param bool $can_edit Whether the current user can edit the post. + * @param bool $is_protected Whether rendered fields must be withheld as password-protected. + * @return array The formatted post data. + */ + private function build_post_fields( WP_Post $post, array $fields, bool $can_edit, bool $is_protected ): array { + $post_type = $post->post_type; + + // Edit-context fields require edit access; drop them so $edit_fields is the single gate. + if ( ! $can_edit ) { + $fields = array_diff( $fields, $this->edit_fields ); + } + + $requested = array_flip( $fields ); + $data = array(); + + if ( isset( $requested['id'] ) ) { + $data['id'] = (int) $post->ID; + } + if ( isset( $requested['post_type'] ) ) { + $data['post_type'] = $post_type; + } + if ( isset( $requested['status'] ) ) { + $data['status'] = $post->post_status; + } + if ( isset( $requested['date'] ) ) { + $data['date'] = $this->format_local_date( $post, 'date' ); + } + if ( isset( $requested['date_gmt'] ) ) { + $data['date_gmt'] = $this->format_gmt_date( $post, 'date' ); + } + if ( isset( $requested['modified'] ) ) { + $data['modified'] = $this->format_local_date( $post, 'modified' ); + } + if ( isset( $requested['modified_gmt'] ) ) { + $data['modified_gmt'] = $this->format_gmt_date( $post, 'modified' ); + } + if ( isset( $requested['slug'] ) ) { + $data['slug'] = $post->post_name; + } + if ( isset( $requested['link'] ) ) { + $data['link'] = (string) get_permalink( $post ); + } + + if ( isset( $requested['title_raw'] ) && post_type_supports( $post_type, 'title' ) ) { + $data['title_raw'] = $post->post_title; + } + + if ( isset( $requested['title_rendered'] ) && post_type_supports( $post_type, 'title' ) ) { + $data['title_rendered'] = $this->get_title( $post ); + } + + if ( isset( $requested['excerpt_raw'] ) && post_type_supports( $post_type, 'excerpt' ) ) { + $data['excerpt_raw'] = $post->post_excerpt; + } + + if ( isset( $requested['excerpt_rendered'] ) && post_type_supports( $post_type, 'excerpt' ) ) { + $data['excerpt_rendered'] = $is_protected ? '' : $this->get_rendered_excerpt( $post ); + } + + if ( isset( $requested['excerpt_protected'] ) && post_type_supports( $post_type, 'excerpt' ) ) { + $data['excerpt_protected'] = (bool) $post->post_password; + } + + if ( isset( $requested['content_raw'] ) && post_type_supports( $post_type, 'editor' ) ) { + $data['content_raw'] = $post->post_content; + } + + if ( isset( $requested['content_rendered'] ) && post_type_supports( $post_type, 'editor' ) ) { + $data['content_rendered'] = $is_protected ? '' : $this->get_rendered_content( $post ); + } + + if ( isset( $requested['content_protected'] ) && post_type_supports( $post_type, 'editor' ) ) { + $data['content_protected'] = (bool) $post->post_password; + } + + if ( isset( $requested['author'] ) && post_type_supports( $post_type, 'author' ) ) { + $author = get_userdata( (int) $post->post_author ); + $data['author'] = array( + 'id' => (int) $post->post_author, + 'name' => $author ? $author->display_name : '', + ); + } + + if ( isset( $requested['parent'] ) && 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.1.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. + */ + public function allow_password_content( $required, $post ): bool { + if ( ! $required || ! $post instanceof WP_Post ) { + return (bool) $required; + } + + return ! current_user_can( 'edit_post', $post->ID ); + } + + /** + * Returns the post title with the protected/private prefixes stripped. + * + * @since 7.1.0 + * + * @param \WP_Post $post The post object. + * @return string The post title. + */ + private function get_title( WP_Post $post ): string { + $strip = array( $this, 'return_raw_title_format' ); + add_filter( 'protected_title_format', $strip ); + add_filter( 'private_title_format', $strip ); + + /* + * The format filters are removed in a finally block so a throw from a title + * filter cannot leave them attached for the rest of the request. + */ + try { + return get_the_title( $post ); + } finally { + remove_filter( 'protected_title_format', $strip ); + remove_filter( 'private_title_format', $strip ); + } + } + + /** + * Returns the raw title format, used to strip protected/private title prefixes. + * + * @since 7.1.0 + * + * @return string The unprefixed title format. + */ + public function return_raw_title_format(): string { + return '%s'; + } + + /** + * Returns the post excerpt transformed for display. + * + * Mirrors the REST posts controller by preparing post globals before applying + * the `get_the_excerpt` and `the_excerpt` filter chains, then restoring the + * previous global post context. This ensures filters that rely on loop globals + * render against the requested post. + * + * @since 7.1.0 + * + * @param \WP_Post $post The post object. + * @return string Rendered post excerpt. + */ + private function get_rendered_excerpt( WP_Post $post ): string { + $previous_post = $GLOBALS['post'] ?? null; + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily mirrors REST post context for excerpt rendering. + $GLOBALS['post'] = $post; + setup_postdata( $post ); + + /* + * The global post context is restored in a finally block so a throw from an + * excerpt filter cannot leave it pointing at the rendered post for the rest + * of the request. + */ + try { + /** This filter is documented in wp-includes/post-template.php. */ + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core excerpt filter to mirror REST rendering. + $excerpt = apply_filters( 'get_the_excerpt', $post->post_excerpt, $post ); + + /** This filter is documented in wp-includes/post-template.php. */ + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core excerpt filter to mirror REST rendering. + $excerpt = apply_filters( 'the_excerpt', $excerpt ); + + return is_string( $excerpt ) ? $excerpt : ''; + } finally { + if ( $previous_post instanceof WP_Post ) { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the previous global post context. + $GLOBALS['post'] = $previous_post; + setup_postdata( $previous_post ); + } else { + unset( $GLOBALS['post'] ); + wp_reset_postdata(); + } + } + } + + /** + * Returns post content transformed for display. + * + * Mirrors the REST posts controller by preparing post globals before applying + * `the_content`, then restoring the previous global post context. + * + * @since 7.1.0 + * + * @param \WP_Post $post The post object. + * @return string Rendered post content. + */ + private function get_rendered_content( WP_Post $post ): string { + $previous_post = $GLOBALS['post'] ?? null; + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily mirrors REST post context for content rendering. + $GLOBALS['post'] = $post; + setup_postdata( $post ); + + /* + * The global post context is restored in a finally block so a throw from a + * content filter cannot leave it pointing at the rendered post for the rest + * of the request. + */ + try { + /** This filter is documented in wp-includes/post-template.php. */ + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core content filter to mirror REST rendering. + $content = apply_filters( 'the_content', $post->post_content ); + + return is_string( $content ) ? $content : ''; + } finally { + if ( $previous_post instanceof WP_Post ) { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the previous global post context. + $GLOBALS['post'] = $previous_post; + setup_postdata( $previous_post ); + } else { + unset( $GLOBALS['post'] ); + wp_reset_postdata(); + } + } + } + + /** + * Formats a post date field as an ISO 8601 string in the site's timezone. + * + * @since 7.1.0 + * + * @param \WP_Post $post The post object. + * @param string $field Either 'date' or 'modified'. Default 'date'. + * @return string The ISO 8601 date, or an empty string if unavailable. + */ + private function format_local_date( WP_Post $post, string $field = 'date' ): string { + $field = 'modified' === $field ? 'modified' : 'date'; + $datetime = get_post_datetime( $post, $field, 'local' ); + + return $datetime ? $datetime->format( 'c' ) : ''; + } + + /** + * Formats a post date field as an ISO 8601 string in GMT. + * + * Reads the stored GMT date directly, deriving it from the local date when missing + * (e.g. drafts), mirroring the REST posts controller. get_post_datetime() is avoided + * here because it reprojects even GMT-sourced dates into the site timezone, which + * would label the returned instant with the site offset instead of UTC. + * + * @since 7.1.0 + * + * @param \WP_Post $post The post object. + * @param string $field Either 'date' or 'modified'. Default 'date'. + * @return string The ISO 8601 date, or an empty string if unavailable. + */ + private function format_gmt_date( WP_Post $post, string $field = 'date' ): string { + $field = 'modified' === $field ? 'modified' : 'date'; + $gmt = 'modified' === $field ? $post->post_modified_gmt : $post->post_date_gmt; + + if ( ! $this->is_usable_date( $gmt ) ) { + $local = 'modified' === $field ? $post->post_modified : $post->post_date; + $gmt = $this->is_usable_date( $local ) ? get_gmt_from_date( $local ) : ''; + } + + /* + * Guard the empty string before `strtotime()`: `strtotime( ' UTC' )` resolves to the + * current time, which would report a fabricated date instead of the documented + * empty-string sentinel. + */ + $timestamp = '' === $gmt ? false : strtotime( $gmt . ' UTC' ); + + return false === $timestamp ? '' : gmdate( 'c', $timestamp ); + } + + /** + * Checks whether a raw post date column holds a usable date. + * + * The columns are `NOT NULL` in core's schema, but a post object can reach this class + * from a filter or an in-memory row where a date is null or a zero date. + * + * @since 7.1.0 + * + * @param mixed $date The raw date column value. + * @return bool True when the value is a non-empty, non-zero date string. + */ + private function is_usable_date( $date ): bool { + return is_string( $date ) && '' !== $date && '0000-00-00 00:00:00' !== $date; + } + + /** + * Builds the uniform not-found error. + * + * Unreachable through gated transports, which run {@see self::check_permission()} + * first and deny the same lookups. It is kept so that a direct call to the execute + * callback still fails closed on a structural lookup failure: a missing post, a post + * type that is not exposed, or a post type that does not match the requested one. + * + * This is not a permission check. The execute callback deliberately does not repeat + * the read/edit checks that {@see self::check_permission()} already performed, so a + * direct call bypasses them. Only invoke the callback through + * {@see WP_Ability::execute()}, which always runs the permission callback first. + * + * @since 7.1.0 + * + * @return \WP_Error The not-found error. + */ + private function not_found_error(): WP_Error { + return new WP_Error( + 'content_not_found', + __( 'The requested content was not found.' ), + array( 'status' => 404 ) + ); + } +} diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index b53a244d7de84..c77c60ac27e79 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -371,6 +371,18 @@ final class WP_Post_Type { */ public $show_in_rest; + /** + * Whether this post type should be exposed through the Abilities API. + * + * Default false. When truthy, the post type's readable posts can be retrieved + * through the read-only `core/read-content` ability, subject to per-post capability + * checks. May be an array to enable specific operations in the future. + * + * @since 7.1.0 + * @var bool|array $show_in_abilities + */ + public $show_in_abilities; + /** * The base path for this post type's REST API endpoints. * @@ -551,6 +563,7 @@ public function set_props( $args ) { 'can_export' => true, 'delete_with_user' => null, 'show_in_rest' => false, + 'show_in_abilities' => false, 'rest_base' => false, 'rest_namespace' => false, 'rest_controller_class' => false, diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index a1d887b45381f..0325f587fd3fe 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -50,6 +50,7 @@ function create_initial_post_types() { 'post-formats', ), 'show_in_rest' => true, + 'show_in_abilities' => true, 'rest_base' => 'posts', 'rest_controller_class' => 'WP_REST_Posts_Controller', ) @@ -84,6 +85,7 @@ function create_initial_post_types() { 'revisions', ), 'show_in_rest' => true, + 'show_in_abilities' => true, 'rest_base' => 'pages', 'rest_controller_class' => 'WP_REST_Posts_Controller', ) @@ -1709,6 +1711,7 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * @since 5.0.0 The `template` and `template_lock` arguments were added. * @since 5.3.0 The `supports` argument will now accept an array of arguments for a feature. * @since 5.9.0 The `rest_namespace` argument was added. + * @since 7.1.0 The `show_in_abilities` argument was added. * * @global array $wp_post_types List of post types. * @@ -1754,6 +1757,10 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * of $show_in_menu. * @type bool $show_in_rest Whether to include the post type in the REST API. Set this to true * for the post type to be available in the block editor. + * @type bool|array $show_in_abilities Whether to expose this post type through the Abilities API, so its + * readable posts can be retrieved via the read-only `core/read-content` + * ability (subject to per-post capability checks). Accepts a boolean + * or an array reserved for enabling specific operations. Default false. * @type string $rest_base To change the base URL of REST API route. Default is $post_type. * @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..d63e1b2de984a --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -0,0 +1,2833 @@ + + */ + private static $user_ids = array(); + + /** + * Shared post IDs keyed by fixture name. + * + * @since 7.1.0 + * + * @var array + */ + private static $post_ids = array(); + + /** + * Creates shared users and posts for the content ability tests. + * + * @since 7.1.0 + * + * @param \WP_UnitTest_Factory $factory The unit test factory. + */ + public static function 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', + ) + ), + 'subscriber_content' => $factory->post->create( + array( + 'post_title' => 'Visible to subscribers', + 'post_content' => 'Rendered body for subscribers.', + 'post_status' => 'publish', + ) + ), + 'readable_single' => $factory->post->create( + array( + 'post_title' => 'Readable single', + 'post_content' => 'Readable single body.', + 'post_status' => 'publish', + ) + ), + 'limited_role_content' => $factory->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_title' => 'Readable title', + 'post_content' => 'Readable body for limited role.', + 'post_excerpt' => 'Readable excerpt.', + 'post_status' => 'publish', + ) + ), + 'raw_content' => $factory->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'Public body with raw block markup.', + ) + ), + 'password_protected_editor' => $factory->post->create( + array( + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'Top secret body.', + ) + ), + 'password_protected_limited' => $factory->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'Hidden rendered body.', + ) + ), + ); + } + + + /** + * Sets up the content ability category for each test. + * + * @since 7.1.0 + */ + public function setUp(): void { + parent::setUp(); + + if ( wp_has_ability( 'core/read-content' ) ) { + wp_unregister_ability( 'core/read-content' ); + } + + $this->ensure_ability_category( 'content' ); + } + + /** + * Restores ability and post type state after each test. + * + * @since 7.1.0 + */ + public function tearDown(): void { + if ( wp_has_ability( 'core/read-content' ) ) { + wp_unregister_ability( 'core/read-content' ); + } + + foreach ( array( 'post', 'page' ) as $post_type ) { + $object = get_post_type_object( $post_type ); + if ( $object ) { + $object->show_in_abilities = true; + } + } + + wp_set_current_user( 0 ); + + parent::tearDown(); + } + + /** + * Ensures an ability category exists for an ability to attach to. + * + * @since 7.1.0 + * + * @param string $slug The ability category slug. + */ + private function ensure_ability_category( string $slug ): void { + if ( wp_has_ability_category( $slug ) ) { + return; + } + + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_categories_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + wp_register_ability_category( + $slug, + array( + 'label' => ucfirst( $slug ), + 'description' => ucfirst( $slug ) . '.', + ) + ); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Registers the core/read-content ability inside a faked init action. + * + * @since 7.1.0 + */ + private function register_ability(): void { + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + ( new WP_Content_Abilities() )->register(); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Logs in as a user with the given role and returns the user ID. + * + * @param string $role The role to log in as. + * @return int The user ID. + */ + private function login_as( string $role ): int { + $user_id = self::$user_ids[ $role ] ?? self::factory()->user->create( array( 'role' => $role ) ); + wp_set_current_user( $user_id ); + return $user_id; + } + + /** + * Returns roles that can read public posts but cannot edit another user's post. + * + * @return array Role test cases. + */ + public function data_roles_without_edit_access_to_other_users_posts(): array { + return array( + 'subscriber' => array( + 'role' => 'subscriber', + ), + 'contributor' => array( + 'role' => 'contributor', + ), + 'author' => array( + 'role' => 'author', + ), + ); + } + + /** + * The ability is registered in the `content` category and flagged read-only. + * + * @since 7.1.0 + */ + public function test_registers_core_read_content_ability(): void { + $this->register_ability(); + + $ability = wp_get_ability( 'core/read-content' ); + + $this->assertNotNull( $ability, 'The core/read-content ability should be registered.' ); + $this->assertSame( 'core/read-content', $ability->get_name(), 'The registered ability should use the expected name.' ); + $this->assertSame( 'content', $ability->get_category(), 'The registered ability should use the content category.' ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ), 'The ability should be exposed in REST.' ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertTrue( $annotations['readonly'], 'The ability should be marked read-only.' ); + $this->assertFalse( $annotations['destructive'], 'The ability should be marked non-destructive.' ); + $this->assertTrue( $annotations['idempotent'], 'The ability should be marked idempotent.' ); + $this->assertFalse( $annotations['open_world'], 'The ability should be marked closed-world; it only reads the local database.' ); + } + + /** + * The content ability is not registered when no post types are exposed to it. + * + * @since 7.1.0 + */ + public function test_does_not_register_core_read_content_ability_without_exposed_post_types(): void { + foreach ( array( 'post', 'page' ) as $post_type ) { + $object = get_post_type_object( $post_type ); + $this->assertNotFalse( $object, "Precondition: the {$post_type} post type should exist." ); + + $object->show_in_abilities = false; + } + + $this->register_ability(); + + $this->assertFalse( wp_has_ability( 'core/read-content' ), 'The content ability should not register without any exposed post types.' ); + } + + /** + * The input schema models mutually exclusive ID, slug, and query modes, each + * rejecting the other modes' properties and exposing only marked types. + * + * @since 7.1.0 + */ + public function test_input_schema_models_mutually_exclusive_modes(): void { + $this->register_ability(); + + $schema = wp_get_ability( 'core/read-content' )->get_input_schema(); + + $this->assertSame( 'object', $schema['type'], 'The input schema should describe an object.' ); + $this->assertCount( 3, $schema['oneOf'], 'The input schema should expose exactly three modes.' ); + + [ $by_id, $by_slug, $query ] = $schema['oneOf']; + + // All modes reject properties from the other modes. + $this->assertSame( array( 'id' ), $by_id['required'], 'The by-ID mode should require an ID.' ); + $this->assertSame( array( 'post_type', 'slug' ), $by_slug['required'], 'The slug mode should require post type and slug.' ); + $this->assertSame( array( 'post_type' ), $query['required'], 'The query mode should require a post type.' ); + $this->assertFalse( $by_id['additionalProperties'], 'The by-ID mode should reject unrelated properties.' ); + $this->assertFalse( $by_slug['additionalProperties'], 'The slug mode should reject unrelated properties.' ); + $this->assertFalse( $query['additionalProperties'], 'The query mode should reject unrelated properties.' ); + + // Query-only filters live only in the query mode, not the single-post modes. + $this->assertArrayHasKey( 'include', $query['properties'], 'The query mode should support included post IDs.' ); + $this->assertArrayHasKey( 'per_page', $query['properties'], 'The query mode should support pagination.' ); + $this->assertArrayNotHasKey( 'per_page', $by_id['properties'], 'The by-ID mode should not accept query-only pagination.' ); + $this->assertArrayNotHasKey( 'include', $by_slug['properties'], 'The slug mode should not accept query-only included IDs.' ); + $this->assertArrayNotHasKey( 'slug', $query['properties'], 'The query mode should not accept slug; slug is a single-post mode.' ); + + // Exposed post types appear in all modes that accept `post_type`. + $this->assertContains( 'post', $query['properties']['post_type']['enum'], 'The query mode should include exposed posts.' ); + $this->assertContains( 'page', $by_id['properties']['post_type']['enum'], 'The by-ID guard should include exposed pages.' ); + $this->assertContains( 'page', $by_slug['properties']['post_type']['enum'], 'The slug mode should include exposed pages.' ); + + $this->assertSame( 1, $query['properties']['include']['minItems'], 'The include option should require at least one post ID.' ); + $this->assertTrue( $query['properties']['include']['uniqueItems'], 'The include option should reject duplicate post IDs.' ); + $this->assertSame( 'integer', $query['properties']['include']['items']['type'], 'The include option should contain post IDs.' ); + $this->assertSame( 1, $query['properties']['include']['items']['minimum'], 'The include option should contain positive post IDs.' ); + + $fields_enum = $query['properties']['fields']['items']['enum']; + $this->assertContains( 'post_type', $fields_enum, 'The fields enum should expose the post type as post_type.' ); + $this->assertNotContains( 'type', $fields_enum, 'The fields enum should not expose the post type as type.' ); + $this->assertContains( 'content_raw', $fields_enum, 'The fields enum should include raw content.' ); + $this->assertContains( 'content_rendered', $fields_enum, 'The fields enum should include rendered content.' ); + $this->assertContains( 'title_raw', $fields_enum, 'The fields enum should include raw titles.' ); + $this->assertContains( 'title_rendered', $fields_enum, 'The fields enum should include rendered titles.' ); + } + + /** + * Branch-local defaults are omitted so the schema can compile in the client-side + * Abilities API validator. Runtime defaults are still applied by the ability. + * + * @since 7.1.0 + */ + public function test_input_schema_omits_oneof_branch_defaults(): void { + $this->register_ability(); + + $schema = wp_get_ability( 'core/read-content' )->get_input_schema(); + $query = $schema['oneOf'][2]; + + $this->assertArrayNotHasKey( 'default', $query['properties']['status'], 'Status should rely on runtime defaults, not schema defaults.' ); + $this->assertArrayNotHasKey( 'default', $query['properties']['page'], 'Page should rely on runtime defaults, not schema defaults.' ); + $this->assertArrayNotHasKey( 'default', $query['properties']['per_page'], 'Per-page should rely on runtime defaults, not schema defaults.' ); + } + + /** + * Query-mode filters cannot be combined with a by-ID lookup: passing `per_page` alongside + * `id` is rejected outright rather than silently ignored. + * + * @since 7.1.0 + */ + public function test_id_mode_rejects_query_only_params(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => 1, + 'per_page' => 10, + ) + ); + + $this->assertWPError( $result, 'Combining by-ID mode with query-only params should fail validation.' ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'Invalid mode combinations should return an input error.' ); + } + + /** + * `post_type` is accepted alongside `id` as a guard: the by-ID mode still resolves the post. + * + * @since 7.1.0 + */ + public function test_id_mode_accepts_post_type_guard(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::$post_ids['published']; + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'post_type' => 'post', + ) + ); + + $this->assertIsArray( $result, 'A matching post type guard should allow the by-ID lookup.' ); + $this->assertSame( $post_id, $result['id'], 'The guarded by-ID lookup should return the requested post directly.' ); + $this->assertArrayNotHasKey( 'posts', $result, 'The guarded by-ID lookup should not return the query wrapper.' ); + } + + /** + * The output schema describes single-post and query response shapes. + * + * @since 7.1.0 + */ + public function test_output_schema_describes_single_post_and_query_responses(): void { + $this->register_ability(); + + $ability = wp_get_ability( 'core/read-content' ); + $input_schema = $ability->get_input_schema(); + $schema = $ability->get_output_schema(); + $post_schema = $schema['oneOf'][0]; + $query_schema = $schema['oneOf'][1]; + + $this->assertSame( 'object', $schema['type'], 'The output schema should describe object responses.' ); + $this->assertCount( 2, $schema['oneOf'], 'The output schema should describe single-post and query responses.' ); + $this->assertSame( 'object', $post_schema['type'], 'The single-post response should be described as an object.' ); + $this->assertArrayNotHasKey( 'required', $post_schema, 'Individual post fields should remain optional.' ); + $this->assertFalse( $post_schema['additionalProperties'], 'Returned posts should not allow unknown properties.' ); + $this->assertArrayHasKey( 'post_type', $post_schema['properties'], 'The post schema should describe the post type as post_type.' ); + $this->assertArrayNotHasKey( 'type', $post_schema['properties'], 'The post schema should not expose the post type as type.' ); + $this->assertSame( + $input_schema['oneOf'][2]['properties']['fields']['items']['enum'], + array_keys( $post_schema['properties'] ), + 'The fields enum should match the post output schema properties.' + ); + $this->assertArrayHasKey( 'content_raw', $post_schema['properties'], 'The post schema should describe raw content.' ); + $this->assertArrayHasKey( 'content_rendered', $post_schema['properties'], 'The post schema should describe rendered content.' ); + $this->assertSame( array( 'posts', 'total', 'total_pages' ), $query_schema['required'], 'The query wrapper should require all top-level properties.' ); + $this->assertArrayHasKey( 'total', $query_schema['properties'], 'The query schema should describe the total count.' ); + $this->assertArrayHasKey( 'total_pages', $query_schema['properties'], 'The query schema should describe page count.' ); + } + + /** + * A post type registered by another active plugin and flagged `show_in_abilities` + * is exposed by the ability, both in the input enum and in query results. + * + * @since 7.1.0 + */ + public function test_exposes_a_post_type_registered_by_another_plugin(): void { + register_post_type( + 'wpai_content_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + try { + $this->login_as( 'administrator' ); + $this->register_ability(); + + // Query mode is the third `oneOf` branch; its `post_type` enum lists exposed types. + $enum = wp_get_ability( 'core/read-content' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; + $this->assertContains( 'wpai_content_cpt', $enum, 'Custom post types marked show_in_abilities should appear in the query enum.' ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wpai_content_cpt', + 'post_status' => 'publish', + ) + ); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'post_type' => 'wpai_content_cpt' ) ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $post_id, $ids, 'The custom post type should be queryable through the content ability.' ); + } finally { + unregister_post_type( 'wpai_content_cpt' ); + } + } + + /** + * A schema filter can expose a post type that is registered after the ability. + * + * @since 7.1.0 + */ + public function test_schema_filter_exposes_late_registered_post_type(): void { + $this->login_as( 'administrator' ); + + $amend_schema = static function ( array $args, string $name ): array { + if ( 'core/read-content' !== $name ) { + return $args; + } + + foreach ( $args['input_schema']['oneOf'] as $index => $mode ) { + $args['input_schema']['oneOf'][ $index ]['properties']['post_type']['enum'][] = 'wpai_late_cpt'; + } + + return $args; + }; + add_filter( 'wp_register_ability_args', $amend_schema, 10, 2 ); + + try { + $this->register_ability(); + } finally { + remove_filter( 'wp_register_ability_args', $amend_schema, 10 ); + } + + $enum = wp_get_ability( 'core/read-content' )->get_input_schema()['oneOf'][2]['properties']['post_type']['enum']; + $this->assertContains( 'wpai_late_cpt', $enum, 'The ability args filter should amend the frozen schema enum.' ); + + register_post_type( + 'wpai_late_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + try { + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wpai_late_cpt', + 'post_status' => 'publish', + ) + ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'wpai_late_cpt', + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $post_id ), wp_list_pluck( $result['posts'], 'id' ), 'The late post type should be queryable after it becomes exposed.' ); + } finally { + unregister_post_type( 'wpai_late_cpt' ); + } + } + + /** + * A published post can be fetched by ID. + * + * @since 7.1.0 + */ + public function test_get_single_published_post_by_id(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::$post_ids['published_content']; + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result, 'The by-ID lookup should return a post array.' ); + $this->assertSame( $post_id, $result['id'], 'The by-ID lookup should return the requested post directly.' ); + $this->assertSame( 'Hello Content', $result['title_rendered'], 'Rendered titles should be returned by default.' ); + $this->assertSame( + array( 'id', 'post_type', 'status', 'date', 'slug', 'title_rendered' ), + array_keys( $result ), + 'Omitted fields should return the lean default field set.' + ); + $this->assertArrayNotHasKey( 'posts', $result, 'The by-ID lookup should not return the query wrapper.' ); + } + + /** + * Schema-valid object input behaves like its array form. + * + * WP_Ability validates `stdClass` as object input but does not coerce the value before + * passing it to the permission and execute callbacks, so both must preserve its fields. + * + * @since 7.1.0 + */ + public function test_get_single_published_post_by_id_accepts_object_input(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::$post_ids['published_content']; + $ability = wp_get_ability( 'core/read-content' ); + $input = (object) array( + 'id' => $post_id, + 'fields' => array( 'id', 'title_rendered' ), + ); + + $this->assertTrue( $ability->validate_input( $input ), 'Object input should pass the registered schema.' ); + + $result = $ability->execute( $input ); + + $this->assertIsArray( $result, 'Object input should execute the by-ID lookup.' ); + $this->assertSame( $post_id, $result['id'], 'Object input should preserve the requested post ID.' ); + $this->assertSame( 'Hello Content', $result['title_rendered'], 'Object input should preserve the requested fields.' ); + $this->assertSame( array( 'id', 'title_rendered' ), array_keys( $result ), 'Object input should use the requested field projection.' ); + } + + /** + * A single post fetched by ID can return explicitly requested rendered and raw content. + * + * @since 7.1.0 + */ + public function test_get_single_published_post_by_id_can_return_content_fields(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::$post_ids['published_content']; + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'post_type', 'content_rendered', 'content_raw' ), + ) + ); + + $this->assertSame( $post_id, $result['id'], 'The by-ID lookup should return the requested post.' ); + $this->assertSame( 'post', $result['post_type'], 'The by-ID lookup should return the post type as post_type.' ); + $this->assertStringContainsString( 'Body here.', $result['content_rendered'], 'Explicit content fields should include rendered content.' ); + $this->assertSame( 'Body here.', $result['content_raw'], 'Explicit content fields should include raw content.' ); + $this->assertArrayNotHasKey( 'posts', $result, 'The by-ID lookup should not return the query wrapper.' ); + } + + /** + * A missing post ID is denied before execution can probe the requested object. + * + * @since 7.1.0 + */ + public function test_get_by_missing_id_is_denied(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => 999999 ) ); + + $this->assertWPError( $result, 'Missing posts should be denied before execution probes object details.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Missing posts should fail closed as a permission error.' ); + } + + /** + * A post type guard mismatch is denied before execution can probe the requested object. + * + * @since 7.1.0 + */ + public function test_get_by_id_with_mismatched_post_type_is_denied(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::$post_ids['published']; + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'post_type' => 'page', + ) + ); + + $this->assertWPError( $result, 'Mismatched post type guards should deny the lookup.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Mismatched post type guards should fail closed as a permission error.' ); + } + + /** + * A post from a post type not exposed to abilities is denied. + * + * @since 7.1.0 + */ + public function test_get_by_id_for_unexposed_post_type_is_denied(): void { + register_post_type( + 'wpai_hidden_cpt', + array( + 'public' => true, + 'show_in_rest' => false, + 'supports' => array( 'title', 'editor' ), + ) + ); + + try { + $this->login_as( 'administrator' ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wpai_hidden_cpt', + 'post_status' => 'publish', + ) + ); + $this->assertGreaterThan( 0, $post_id, 'The hidden custom post should be created for the denial check.' ); + + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + + $this->assertWPError( $result, 'Posts from unexposed post types should be denied.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Unexposed post types should fail closed as a permission error.' ); + } finally { + unregister_post_type( 'wpai_hidden_cpt' ); + } + } + + /** + * A status that is public but not viewable is not exposed to read-only users. + * + * @since 7.1.0 + */ + public function test_public_non_viewable_status_is_denied_for_read_only_users(): void { + register_post_status( + 'wpai_public_hidden', + array( + 'label' => 'Public hidden', + 'public' => true, + 'publicly_queryable' => false, + ) + ); + + try { + $post_id = self::factory()->post->create( + array( + 'post_status' => 'wpai_public_hidden', + ) + ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + + $this->assertWPError( $result, 'Read-only users should not receive public statuses that are not viewable.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Non-viewable public statuses should fail closed for read-only users.' ); + } finally { + unset( $GLOBALS['wp_post_statuses']['wpai_public_hidden'] ); + } + } + + /** + * A status that is public but not viewable remains available to users who can edit it. + * + * @since 7.1.0 + */ + public function test_public_non_viewable_status_is_readable_with_edit_access(): void { + register_post_status( + 'wpai_public_hidden', + array( + 'label' => 'Public hidden', + 'public' => true, + 'publicly_queryable' => false, + ) + ); + + try { + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Hidden public status', + 'post_status' => 'wpai_public_hidden', + ) + ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result, 'Editors should be able to access posts they can edit even when the status is not publicly viewable.' ); + $this->assertSame( $post_id, $result['id'], 'The editable post should be returned.' ); + $this->assertSame( 'Hidden public status', $result['title_rendered'], 'The editable post should include normal default fields.' ); + } finally { + unset( $GLOBALS['wp_post_statuses']['wpai_public_hidden'] ); + } + } + + /** + * A post that inherits its status from a readable parent is readable. + * + * @since 7.1.0 + */ + public function test_inherited_post_is_readable_when_parent_is_readable(): void { + register_post_type( + 'wpai_inherit_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + try { + $parent_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_type' => 'wpai_inherit_cpt', + 'post_status' => 'publish', + ) + ); + $child_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_type' => 'wpai_inherit_cpt', + 'post_parent' => $parent_id, + 'post_status' => 'inherit', + 'post_title' => 'Inherited child', + ) + ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $child_id ) ); + + $this->assertIsArray( $result, 'Inherited posts should be readable when their parent is readable.' ); + $this->assertSame( $child_id, $result['id'], 'The inherited child should be returned.' ); + $this->assertSame( 'Inherited child', $result['title_rendered'], 'The inherited child should include normal default fields.' ); + } finally { + unregister_post_type( 'wpai_inherit_cpt' ); + } + } + + /** + * A post with an inherited status but no readable parent is denied. + * + * @since 7.1.0 + */ + public function test_inherited_post_without_parent_is_denied_for_read_only_users(): void { + register_post_type( + 'wpai_inherit_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + try { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_type' => 'wpai_inherit_cpt', + 'post_status' => 'inherit', + ) + ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + + $this->assertWPError( $result, 'Inherited posts without a readable parent should be denied.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Orphaned inherited posts should fail closed.' ); + } finally { + unregister_post_type( 'wpai_inherit_cpt' ); + } + } + + /** + * Query mode returns only published posts by default. + * + * @since 7.1.0 + */ + public function test_query_returns_only_published_by_default(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $published = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $draft = self::factory()->post->create( array( 'post_status' => 'draft' ) ); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'post_type' => 'post' ) ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $published, $ids, 'Published posts should be returned by default.' ); + $this->assertNotContains( $draft, $ids, 'Draft posts should not be returned by default.' ); + } + + /** + * Query mode can limit results to included IDs. + * + * @since 7.1.0 + */ + public function test_query_include_limits_results(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $first = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $second = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $third = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => array( $third, $first ), + 'fields' => array( 'id' ), + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + sort( $ids ); + $expected = array( $first, $third ); + sort( $expected ); + + $this->assertSame( $expected, $ids, 'Included post IDs should limit results without requiring caller order.' ); + $this->assertNotContains( $second, $ids, 'Posts outside include should not be returned.' ); + } + + /** + * Query results are ordered by post date, newest first, whatever order `include` uses. + * + * Pins both halves of what the schema advertises. The ability leaves `orderby` at the + * WP_Query default, matching the REST posts controller, and `include` only filters the + * query, so a caller that passes IDs in a chosen order must not expect them back in it. + * + * @since 7.1.0 + */ + public function test_query_orders_posts_newest_first_regardless_of_include_order(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $oldest = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + $middle = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-02-01 10:00:00', + ) + ); + $newest = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-03-01 10:00:00', + ) + ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + // Deliberately neither date order nor ID order. + 'include' => array( $middle, $newest, $oldest ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( + array( $newest, $middle, $oldest ), + wp_list_pluck( $result['posts'], 'id' ), + 'Results should be ordered by post date, newest first, not by the order of the include list.' + ); + } + + /** + * Query include still respects the requested post type. + * + * @since 7.1.0 + */ + public function test_query_include_respects_requested_post_type(): void { + $this->login_as( 'administrator' ); + $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/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => array( $page_id, $post_id ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $post_id ), wp_list_pluck( $result['posts'], 'id' ), 'Include should not leak posts from other post types.' ); + } + + /** + * Query include still respects row-level permissions. + * + * @since 7.1.0 + */ + public function test_query_include_respects_row_level_permissions(): void { + $author_a = self::$user_ids['author']; + $author_b = self::$user_ids['author_secondary']; + + $draft_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'draft', + ) + ); + $draft_b = self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'draft', + ) + ); + + wp_set_current_user( $author_b ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + 'include' => array( $draft_a, $draft_b ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $draft_b ), wp_list_pluck( $result['posts'], 'id' ), 'Include should not bypass row-level draft permissions.' ); + } + + /** + * Query mode can return included drafts with explicitly requested rendered and raw content. + * + * @since 7.1.0 + */ + public function test_query_draft_include_can_return_content_fields(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $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/read-content' )->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + 'include' => array( $draft ), + 'fields' => array( 'id', 'post_type', 'status', 'content_rendered', 'content_raw' ), + ) + ); + + $this->assertSame( array( $draft ), wp_list_pluck( $result['posts'], 'id' ), 'The draft query should return only the included draft.' ); + $this->assertSame( 'post', $result['posts'][0]['post_type'], 'Query responses should return the post type as post_type.' ); + $this->assertSame( 'draft', $result['posts'][0]['status'], 'The draft query should expose the requested draft status.' ); + $this->assertStringContainsString( 'Draft body for content fields.', $result['posts'][0]['content_rendered'], 'Draft query results should include rendered content when requested.' ); + $this->assertSame( 'Draft body for content fields.', $result['posts'][0]['content_raw'], 'Draft query results should include raw content when requested.' ); + } + + /** + * Querying by slug without a post type is rejected by the input schema. + * + * @since 7.1.0 + */ + public function test_slug_mode_requires_post_type(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'slug' => 'whatever' ) ); + + $this->assertWPError( $result, 'Slug queries without a post type should fail validation.' ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'Invalid slug queries should return an input error.' ); + } + + /** + * Slug mode returns a single post directly when paired with a post type. + * + * @since 7.1.0 + */ + public function test_get_single_published_post_by_slug(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_name' => 'content-slug-mode', + 'post_title' => 'Content Slug Mode', + 'post_status' => 'publish', + ) + ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'slug' => 'content-slug-mode', + ) + ); + + $this->assertIsArray( $result, 'The slug lookup should return a post array.' ); + $this->assertSame( $post_id, $result['id'], 'The slug lookup should return the requested post directly.' ); + $this->assertSame( 'content-slug-mode', $result['slug'], 'The slug lookup should return the matching slug.' ); + $this->assertArrayNotHasKey( 'posts', $result, 'The slug lookup should not return the query wrapper.' ); + $this->assertArrayNotHasKey( 'total', $result, 'The slug lookup should not return query totals.' ); + } + + /** + * A single post fetched by slug can return explicitly requested rendered and raw content. + * + * @since 7.1.0 + */ + public function test_get_single_published_post_by_slug_can_return_content_fields(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_name' => 'content-slug-fields', + 'post_title' => 'Content Slug Fields', + 'post_content' => 'Slug body for content fields.', + 'post_status' => 'publish', + ) + ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'slug' => 'content-slug-fields', + 'fields' => array( 'id', 'post_type', 'slug', 'content_rendered', 'content_raw' ), + ) + ); + + $this->assertSame( $post_id, $result['id'], 'The slug lookup should return the requested post.' ); + $this->assertSame( 'post', $result['post_type'], 'The slug lookup should return the post type as post_type.' ); + $this->assertSame( 'content-slug-fields', $result['slug'], 'The slug lookup should return the matching slug.' ); + $this->assertStringContainsString( 'Slug body for content fields.', $result['content_rendered'], 'Slug lookups should include rendered content when requested.' ); + $this->assertSame( 'Slug body for content fields.', $result['content_raw'], 'Slug lookups should include raw content when requested.' ); + $this->assertArrayNotHasKey( 'posts', $result, 'The slug lookup should not return the query wrapper.' ); + } + + /** + * A published post is not hidden behind more same-slug drafts than a page holds. + * + * The slug lookup is a singular WP_Query, so it returns every matching row and no page + * size applies. Bounding it with `post_name__in` and a page size would page straight past + * an older published post, because the query is ordered newest first. + * + * @since 7.1.0 + */ + public function test_slug_lookup_is_not_bounded_by_a_page_size(): void { + global $wpdb; + + // Author every post as the administrator, so the subscriber who reads them below can + // only see the published one. + $this->login_as( 'administrator' ); + + // The published post owns the slug and is the oldest of the group. + $published = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_name' => 'wpai-slug-not-bounded', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + + // Drafts skip slug uniqueness, so they can all share the slug. Create more of them + // than the largest page the ability will ever return. + for ( $i = 0; $i < 110; $i++ ) { + self::factory()->post->create( + array( + 'post_status' => 'draft', + 'post_name' => 'wpai-slug-not-bounded', + 'post_date' => '2026-03-01 10:00:00', + ) + ); + } + + $sharing = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_name = %s", 'wpai-slug-not-bounded' ) ); // phpcs:ignore WordPress.DB + $this->assertGreaterThan( 100, $sharing, 'Precondition: more posts share the slug than a single page holds.' ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'slug' => 'wpai-slug-not-bounded', + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'The published post should resolve even though the drafts fill more than a page.' ); + $this->assertSame( $published, $result['id'], 'The readable published post should still resolve.' ); + } + + /** + * A post whose slug is the literal string "0" is fetched in single-post slug mode. + * + * @since 7.1.0 + */ + public function test_get_single_post_by_slug_zero(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + // Core regenerates an "empty" post_name from the title, so a post titled "0" + // ends up with the literal slug "0". + $post_id = self::factory()->post->create( + array( + 'post_title' => '0', + 'post_name' => '0', + 'post_status' => 'publish', + ) + ); + + $this->assertSame( '0', get_post( $post_id )->post_name, 'Precondition: the post slug should be the literal string "0".' ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'slug' => '0', + ) + ); + + $this->assertIsArray( $result, 'The slug lookup should return a post array.' ); + $this->assertSame( $post_id, $result['id'], 'Slug mode should resolve the literal "0" slug to the post.' ); + $this->assertArrayNotHasKey( 'posts', $result, 'A "0" slug should not fall through to the query wrapper.' ); + } + + /** + * Query-only filters cannot be combined with slug mode. + * + * @since 7.1.0 + */ + public function test_slug_mode_rejects_query_only_params(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'slug' => 'content-slug-mode', + 'per_page' => 10, + ) + ); + + $this->assertWPError( $result, 'Combining slug mode with query-only params should fail validation.' ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'Invalid slug mode combinations should return an input error.' ); + } + + /** + * A newer draft sharing a published post's slug does not shadow the published post. + * + * @since 7.1.0 + */ + public function test_slug_lookup_prefers_published_post_over_newer_draft(): void { + $published_id = self::factory()->post->create( + array( + 'post_name' => 'shadowed-slug', + 'post_status' => 'publish', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + // Drafts skip slug uniqueness, so a newer draft can share the published slug. + $draft_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_name' => 'shadowed-slug', + 'post_status' => 'draft', + 'post_date' => '2026-06-01 10:00:00', + ) + ); + + $this->assertSame( 'shadowed-slug', get_post( $draft_id )->post_name, 'Precondition: the draft should share the published slug.' ); + $this->register_ability(); + + foreach ( array( 'subscriber', 'editor' ) as $role ) { + $this->login_as( $role ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'slug' => 'shadowed-slug', + ) + ); + + $this->assertIsArray( $result, "The slug lookup should succeed for a {$role}." ); + $this->assertSame( $published_id, $result['id'], "The slug lookup should resolve to the published post for a {$role}." ); + } + } + + /** + * A slug held only by a draft resolves for its author and stays denied for readers. + * + * @since 7.1.0 + */ + public function test_slug_lookup_resolves_draft_only_slug_by_readability(): void { + $author_id = self::$user_ids['author']; + $draft_id = self::factory()->post->create( + array( + 'post_author' => $author_id, + 'post_name' => 'draft-only-slug', + 'post_status' => 'draft', + ) + ); + + wp_set_current_user( $author_id ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'slug' => 'draft-only-slug', + ) + ); + + $this->assertIsArray( $result, 'The draft author should resolve their own draft by slug.' ); + $this->assertSame( $draft_id, $result['id'], 'The draft author should receive their own draft.' ); + + $this->login_as( 'subscriber' ); + + $denied = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'slug' => 'draft-only-slug', + ) + ); + + $this->assertWPError( $denied, 'Readers should not resolve a slug held only by an unreadable draft.' ); + $this->assertSame( 'ability_invalid_permissions', $denied->get_error_code(), 'Unreadable slug lookups should fail closed as a permission error.' ); + } + + /** + * Include is a query-only option and cannot be combined with single-post modes. + * + * @since 7.1.0 + */ + public function test_include_cannot_be_combined_with_single_post_modes(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $by_id = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => self::$post_ids['published'], + 'include' => array( self::$post_ids['published'] ), + ) + ); + $by_slug = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'slug' => 'whatever', + 'include' => array( self::$post_ids['published'] ), + ) + ); + + $this->assertWPError( $by_id, 'Include should fail validation in ID mode.' ); + $this->assertSame( 'ability_invalid_input', $by_id->get_error_code(), 'ID plus include should return an input error.' ); + $this->assertWPError( $by_slug, 'Include should fail validation in slug mode.' ); + $this->assertSame( 'ability_invalid_input', $by_slug->get_error_code(), 'Slug plus include should return an input error.' ); + } + + /** + * The `fields` filter limits the returned keys. + * + * @since 7.1.0 + */ + public function test_fields_filter_limits_returned_keys(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::$post_ids['published_content']; + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'title_rendered' ), + ) + ); + + $this->assertSame( + array( 'id', 'title_rendered' ), + array_keys( $result ), + 'The fields filter should limit the response to exactly the requested keys.' + ); + } + + /** + * An unknown requested field name fails schema validation. + * + * Unlike fields a post type does not support, which are omitted per post, a field + * name that is not part of the supported set is rejected before the ability executes. + * + * @since 7.1.0 + */ + public function test_unknown_requested_field_fails_schema_validation(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => self::$post_ids['published_content'], + 'fields' => array( 'id', 'bogus_field' ), + ) + ); + + $this->assertWPError( $result, 'An unknown requested field should fail the request.' ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'Unknown fields should use the invalid input error.' ); + } + + /** + * Logged-out users cannot run the ability. + * + * @since 7.1.0 + */ + public function test_logged_out_user_is_denied(): void { + wp_set_current_user( 0 ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'post_type' => 'post' ) ); + + $this->assertWPError( $result, 'Logged-out users should not be allowed to run the content ability.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Logged-out users should receive a permission error.' ); + } + + /** + * Subscribers can request rendered published content. + * + * @since 7.1.0 + */ + public function test_subscriber_can_request_published_content(): void { + $post_id = self::$post_ids['subscriber_content']; + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'fields' => array( 'id', 'title_rendered', 'content_rendered' ), + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $post_id, $ids, 'Subscribers should be able to query readable published posts.' ); + $post_index = array_search( $post_id, $ids, true ); + $this->assertIsInt( $post_index, 'The published post should be present in the subscriber query response.' ); + $post = $result['posts'][ $post_index ]; + $this->assertSame( 'Visible to subscribers', $post['title_rendered'], 'Subscribers should receive rendered titles.' ); + $this->assertStringContainsString( 'Rendered body for subscribers.', $post['content_rendered'], 'Subscribers should receive rendered content.' ); + $this->assertArrayNotHasKey( 'content_raw', $post, 'Subscribers should not receive raw content without edit access.' ); + } + + /** + * Subscribers can fetch a published post by ID. + * + * @since 7.1.0 + */ + public function test_subscriber_can_get_single_published_post_by_id(): void { + $post_id = self::$post_ids['readable_single']; + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result, 'Subscribers should be able to fetch a readable published post by ID.' ); + $this->assertSame( 'Readable single', $result['title_rendered'], 'Subscribers should receive the rendered title.' ); + $this->assertArrayNotHasKey( 'title_raw', $result, 'Subscribers should not receive raw titles without edit access.' ); + $this->assertArrayNotHasKey( 'content_raw', $result, 'Subscribers should not receive raw content without edit access.' ); + $this->assertArrayNotHasKey( 'content_rendered', $result, 'Rendered content should require an explicit field request.' ); + } + + /** + * Subscribers cannot request edit-context raw fields in query mode. + * + * @since 7.1.0 + */ + public function test_subscriber_cannot_request_raw_fields_in_query_mode(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'fields' => array( 'content_raw' ), + ) + ); + + $this->assertWPError( $result, 'Subscribers should not be able to request raw fields in query mode.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Subscriber raw-field query requests should return a permission error.' ); + } + + /** + * Subscribers cannot request edit-context raw fields for a single post. + * + * @since 7.1.0 + */ + public function test_subscriber_cannot_request_raw_fields_for_single_post(): void { + $post_id = self::$post_ids['published']; + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'content_raw' ), + ) + ); + + $this->assertWPError( $result, 'Subscribers should not be able to request raw fields by ID.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Subscriber raw-field by-ID requests should return a permission error.' ); + } + + /** + * Users who cannot edit another user's post do not receive raw fields by default. + * + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * + * @param string $role The role to test. + */ + public function test_default_fields_omit_raw_fields_for_roles_without_edit_access_to_other_users_posts( string $role ): void { + $post_id = self::$post_ids['limited_role_content']; + + $this->login_as( $role ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result, 'The readable published post should be returned.' ); + $this->assertSame( 'Readable title', $result['title_rendered'], 'Rendered title should remain visible.' ); + $this->assertArrayNotHasKey( 'title_raw', $result, 'Raw title should be omitted.' ); + $this->assertArrayNotHasKey( 'excerpt_raw', $result, 'Raw excerpt should be omitted.' ); + $this->assertArrayNotHasKey( 'content_raw', $result, 'Raw content should be omitted.' ); + $this->assertArrayNotHasKey( 'content_rendered', $result, 'Rendered content should be omitted from the lean default field set.' ); + } + + /** + * Users who cannot edit another user's post cannot explicitly request raw fields. + * + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * + * @param string $role The role to test. + */ + public function test_raw_field_requests_are_denied_for_roles_without_edit_access_to_other_users_posts( string $role ): void { + $post_id = self::$post_ids['limited_role_content']; + + $this->login_as( $role ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'content_raw' ), + ) + ); + + $this->assertWPError( $result, 'Raw field requests should fail for users without edit access.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Raw field requests should require edit access to the post.' ); + } + + /** + * Subscribers cannot request draft posts. + * + * @since 7.1.0 + */ + public function test_subscriber_cannot_request_draft_status(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ); + + $this->assertWPError( $result, 'Subscribers should not be allowed to query draft posts.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Subscriber draft queries should return a permission error.' ); + } + + /** + * Subscribers cannot request private posts. + * + * @since 7.1.0 + */ + public function test_subscriber_cannot_request_private_status(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'status' => array( 'private' ), + ) + ); + + $this->assertWPError( $result, 'Subscribers should not be allowed to query private posts.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Subscriber private queries should return a permission error.' ); + } + + /** + * An author can pass the draft gate but only sees their own drafts. + * + * @since 7.1.0 + */ + public function test_author_cannot_see_other_authors_drafts(): void { + $author_a = self::$user_ids['author']; + $author_b = self::$user_ids['author_secondary']; + + $draft_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'draft', + ) + ); + $draft_b = self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'draft', + ) + ); + + wp_set_current_user( $author_b ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $draft_b, $ids, 'Authors should see their own drafts.' ); + $this->assertNotContains( $draft_a, $ids, 'Authors should not see another author\'s drafts.' ); + } + + /** + * Query totals mirror WP_Query even when row-level permissions withhold rows. + * + * This matches the REST posts controller: `posts` only contains rows the current + * user can read, while `total` and `total_pages` describe the underlying query. + * + * @since 7.1.0 + */ + public function test_query_totals_may_include_rows_withheld_by_row_level_permissions(): void { + $author_a = self::$user_ids['author']; + $author_b = self::$user_ids['author_secondary']; + + $draft_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'draft', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + $draft_b = self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'draft', + 'post_date' => '2026-01-02 10:00:00', + ) + ); + + wp_set_current_user( $author_b ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + 'per_page' => 1, + 'fields' => array( 'id' ), + ) + ); + + $this->assertSame( array( $draft_b ), wp_list_pluck( $result['posts'], 'id' ), 'Authors should receive only drafts they can read.' ); + $this->assertNotContains( $draft_a, wp_list_pluck( $result['posts'], 'id' ), 'Rows withheld by row-level permissions should not be returned.' ); + $this->assertGreaterThan( count( $result['posts'] ), $result['total'], 'Totals may include rows withheld by row-level permission checks.' ); + $this->assertSame( 2, $result['total_pages'], 'Page counts should be based on the underlying query total, matching REST behavior.' ); + } + + /** + * The parent filter is rejected for non-hierarchical post types, mirroring REST. + * + * @since 7.1.0 + */ + public function test_query_mode_rejects_parent_filter_for_non_hierarchical_post_type(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'parent' => 0, + ) + ); + + $this->assertWPError( $result, 'The parent filter should be rejected for non-hierarchical post types.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'Unsupported parent filters should return a filter error.' ); + } + + /** + * The parent filter narrows hierarchical queries to children of the given post. + * + * @since 7.1.0 + */ + public function test_query_mode_filters_pages_by_parent(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $parent_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + $child_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_parent' => $parent_id, + 'post_status' => 'publish', + ) + ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'page', + 'parent' => $parent_id, + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertSame( array( $child_id ), $ids, 'The parent filter should return only the children of the given page.' ); + } + + /** + * The author filter is rejected for post types without author support, mirroring REST. + * + * @since 7.1.0 + */ + public function test_query_mode_rejects_author_filter_for_post_type_without_author_support(): void { + register_post_type( + 'wpai_no_author_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title' ), + ) + ); + + try { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'wpai_no_author_cpt', + 'author' => self::$user_ids['author'], + ) + ); + + $this->assertWPError( $result, 'The author filter should be rejected for post types without author support.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'Unsupported author filters should return a filter error.' ); + } finally { + unregister_post_type( 'wpai_no_author_cpt' ); + } + } + + /** + * The author filter narrows queries to posts by the given author. + * + * @since 7.1.0 + */ + public function test_query_mode_filters_posts_by_author(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $mine_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'publish', + ) + ); + $other_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author_secondary'], + 'post_status' => 'publish', + ) + ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'author' => self::$user_ids['author'], + 'per_page' => 100, + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $mine_id, $ids, 'The author filter should include the author\'s posts.' ); + $this->assertNotContains( $other_id, $ids, 'The author filter should exclude other authors\' posts.' ); + } + + /** + * Raw content is available to users who can edit the post. + * + * @since 7.1.0 + */ + public function test_raw_content_visible_to_editor(): void { + $post_id = self::$post_ids['raw_content']; + + $this->login_as( 'editor' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'content_raw' ), + ) + ); + + $this->assertSame( + 'Public body with raw block markup.', + $result['content_raw'], + 'Editors should receive explicitly requested raw content.' + ); + } + + /** + * Password-protected content is visible to users who can edit the post. + * + * @since 7.1.0 + */ + public function test_password_protected_content_visible_to_editor(): void { + $post_id = self::$post_ids['password_protected_editor']; + + $this->login_as( 'editor' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'content_raw', 'content_rendered' ), + ) + ); + + $this->assertSame( + 'Top secret body.', + $result['content_raw'], + 'Editors should receive raw password-protected content.' + ); + $this->assertStringContainsString( + 'Top secret body.', + $result['content_rendered'], + 'Editors should receive rendered password-protected content.' + ); + } + + /** + * Password-protected rendered content is withheld from users who cannot edit the post. + * + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * + * @param string $role The role to test. + */ + public function test_password_protected_rendered_content_is_empty_for_roles_without_edit_access_to_other_users_posts( string $role ): void { + $post_id = self::$post_ids['password_protected_limited']; + + $this->login_as( $role ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'content_rendered', 'content_protected' ), + ) + ); + + $this->assertSame( '', $result['content_rendered'], 'Password-protected rendered content should be withheld.' ); + $this->assertTrue( $result['content_protected'], 'The protected flag should reveal the field is password-protected.' ); + } + + /** + * Password-protected excerpts render for users who can edit the post. + * + * @since 7.1.0 + */ + public function test_password_protected_excerpt_visible_to_editor(): void { + $this->login_as( 'editor' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_excerpt' => 'Top secret excerpt.', + ) + ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'excerpt_rendered', 'excerpt_protected' ), + ) + ); + + $this->assertSame( + "

Top secret excerpt.

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

Readable excerpt.

\n", + $result['excerpt_rendered'], + 'Rendered excerpts should match the REST API excerpt filter output.' + ); + } + + /** + * Rendered excerpt filters run with the requested post as the global context and restore + * the context that was active before the ability executed. + * + * @since 7.1.0 + */ + public function test_excerpt_rendered_uses_and_restores_requested_post_context(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $target_id = self::$post_ids['limited_role_content']; + $surrounding = get_post( self::$post_ids['published'] ); + $previous_post = $GLOBALS['post'] ?? null; + + $this->assertInstanceOf( \WP_Post::class, $surrounding, 'The surrounding post fixture should exist.' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Establishes a distinct context to verify the ability restores it. + $GLOBALS['post'] = $surrounding; + setup_postdata( $surrounding ); + + $append_context_id = static function ( $excerpt ): string { + return (string) $excerpt . ''; + }; + add_filter( 'the_excerpt', $append_context_id, 20 ); + + try { + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $target_id, + 'fields' => array( 'id', 'excerpt_rendered' ), + ) + ); + $restored_context_id = get_the_ID(); + } finally { + remove_filter( 'the_excerpt', $append_context_id, 20 ); + + if ( $previous_post instanceof \WP_Post ) { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the context that preceded the test. + $GLOBALS['post'] = $previous_post; + setup_postdata( $previous_post ); + } else { + unset( $GLOBALS['post'] ); + wp_reset_postdata(); + } + } + + $this->assertStringContainsString( + '', + $result['excerpt_rendered'], + 'Excerpt filters should see the requested post as the current post.' + ); + $this->assertSame( + $surrounding->ID, + $restored_context_id, + 'The surrounding post context should be restored after rendering.' + ); + } + + /** + * The password gate is suspended only for posts the current user can edit. + * + * @since 7.1.0 + */ + public function test_allow_password_content_only_unlocks_editable_posts(): void { + $owned_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'publish', + 'post_password' => 'secret', + ) + ); + $other_id = self::$post_ids['password_protected_limited']; + + $this->login_as( 'author' ); + + $ability = new WP_Content_Abilities(); + + $this->assertFalse( + $ability->allow_password_content( true, get_post( $owned_id ) ), + 'The filter should unlock a protected post the current user can edit.' + ); + $this->assertTrue( + $ability->allow_password_content( true, get_post( $other_id ) ), + 'The filter should keep the gate on a protected post the current user cannot edit.' + ); + $this->assertFalse( + $ability->allow_password_content( false, get_post( $other_id ) ), + 'The filter should leave posts that do not require a password ungated.' + ); + } + + /** + * Rendering an editable protected post must not unlock other protected posts embedded in + * its content (e.g. through a shortcode or Query Loop block). + * + * @since 7.1.0 + */ + public function test_password_filter_does_not_leak_other_protected_posts(): void { + $hidden_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'NESTED_SECRET_MARKER', + ) + ); + + $author_id = $this->login_as( 'author' ); + + $owned_id = self::factory()->post->create( + array( + 'post_author' => $author_id, + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => '[read_content_nested id="' . $hidden_id . '"]', + ) + ); + + add_shortcode( + 'read_content_nested', + static function ( $atts ): string { + $id = is_array( $atts ) && isset( $atts['id'] ) ? (int) $atts['id'] : 0; + + return post_password_required( $id ) ? 'GATED' : (string) get_post_field( 'post_content', $id ); + } + ); + + $this->register_ability(); + + try { + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $owned_id, + 'fields' => array( 'id', 'content_rendered' ), + ) + ); + } finally { + remove_shortcode( 'read_content_nested' ); + } + + $this->assertStringNotContainsString( + 'NESTED_SECRET_MARKER', + $result['content_rendered'], + 'Rendering an editable protected post must not unlock another protected post it embeds.' + ); + $this->assertStringContainsString( + 'GATED', + $result['content_rendered'], + 'The embedded protected post should still report as password-gated.' + ); + } + + /** + * Query mode paginates with `page`/`per_page` and reports totals. + * + * @since 7.1.0 + */ + public function test_query_paginates_and_reports_totals(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $page1 = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 1, + ) + ); + + $this->assertCount( 2, $page1['posts'], 'The first page should honor the requested per_page value.' ); + $this->assertGreaterThanOrEqual( 3, $page1['total'], 'The query should report the total matching post count.' ); + $this->assertSame( (int) ceil( $page1['total'] / 2 ), $page1['total_pages'], 'The query should report the computed total page count.' ); + + $page2 = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 2, + ) + ); + + $this->assertNotEmpty( $page2['posts'], 'The second page should return remaining posts.' ); + $this->assertSame( $page1['total'], $page2['total'], 'Pagination should keep total counts stable across pages.' ); + } + + /** + * Query mode reports a total that matches the returned posts for an uncapped query. + * + * @since 7.1.0 + */ + public function test_query_total_matches_returned_posts_when_uncapped(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'per_page' => 100, + ) + ); + + $this->assertNotEmpty( $result['posts'], 'An uncapped query should return the readable posts.' ); + $this->assertSame( count( $result['posts'] ), $result['total'], 'An uncapped query should report a total equal to the number of returned posts.' ); + $this->assertSame( 1, $result['total_pages'], 'An uncapped query should fit on a single page.' ); + } + + /** + * The last page still reports the totals of the underlying query. + * + * Guards the boundary next to the out-of-range page error: the final page must not be + * mistaken for an overshoot. + * + * @since 7.1.0 + */ + public function test_query_last_page_reports_totals(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'per_page' => 2, + 'page' => 2, + 'fields' => array( 'id' ), + ) + ); + + $this->assertCount( 1, $result['posts'], 'The last page should return the remaining post.' ); + $this->assertSame( 3, $result['total'], 'The last page should report the full total.' ); + $this->assertSame( 2, $result['total_pages'], 'The last page should report the full page count.' ); + } + + /** + * Paging past the last page reports an error rather than an empty collection. + * + * `WP_Query::set_found_posts()` skips the count when a page yields no rows, so without + * recovering the total an out-of-range page would report `total: 0, total_pages: 0`, + * which is indistinguishable from an empty collection. Match the REST posts controller + * by reporting this as a caller error instead. + * + * @since 7.1.0 + */ + public function test_query_out_of_range_page_is_rejected_rather_than_reported_as_empty(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'per_page' => 2, + 'page' => 99, + 'fields' => array( 'id' ), + ) + ); + + $this->assertWPError( $result, 'A page beyond the last one should fail rather than return an empty list.' ); + $this->assertSame( 'content_invalid_page_number', $result->get_error_code(), 'Out-of-range pages should report a dedicated error code.' ); + $this->assertSame( 400, $result->get_error_data()['status'], 'An out-of-range page is a caller error.' ); + } + + /** + * A genuinely empty result set beyond the first page reports zero totals, not an error. + * + * The out-of-range guard only fires when the underlying query actually matched rows. + * + * @since 7.1.0 + */ + public function test_query_empty_result_beyond_first_page_reports_zero_totals(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => array( 999999 ), + 'page' => 2, + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'An empty result set should not be treated as an out-of-range page.' ); + $this->assertSame( array(), $result['posts'], 'No posts match the query.' ); + $this->assertSame( 0, $result['total'], 'An empty result set reports a zero total.' ); + $this->assertSame( 0, $result['total_pages'], 'An empty result set reports zero pages.' ); + } + + /** + * Include returns every requested post when `per_page` is omitted. + * + * Without this the default page size silently truncates a batch load: a caller asking + * for a known set of IDs would receive only the first `per_page` of them. + * + * @since 7.1.0 + */ + public function test_query_include_returns_every_requested_post_without_per_page(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + // More than DEFAULT_PER_PAGE (10) so truncation would be visible. + $ids = self::factory()->post->create_many( 15, array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'fields' => array( 'id' ), + ) + ); + + $returned = wp_list_pluck( $result['posts'], 'id' ); + sort( $returned ); + sort( $ids ); + + $this->assertSame( $ids, $returned, 'Every requested post ID should be returned on a single page.' ); + $this->assertSame( 15, $result['total'], 'The total should cover every requested post.' ); + $this->assertSame( 1, $result['total_pages'], 'Included posts should fit on a single page by default.' ); + } + + /** + * An explicit `per_page` still paginates an include request. + * + * @since 7.1.0 + */ + public function test_query_include_honors_an_explicit_per_page(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 5, array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'per_page' => 2, + 'fields' => array( 'id' ), + ) + ); + + $this->assertCount( 2, $result['posts'], 'An explicit per_page should paginate included posts.' ); + $this->assertSame( 5, $result['total'], 'The total should still cover every requested post.' ); + $this->assertSame( 3, $result['total_pages'], 'Page counts should follow the explicit per_page.' ); + } + + /** + * The include list is capped at the maximum page size. + * + * @since 7.1.0 + */ + public function test_query_include_is_capped_at_the_maximum_page_size(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $schema = wp_get_ability( 'core/read-content' )->get_input_schema(); + $query = $schema['oneOf'][2]; + + $this->assertSame( $query['properties']['per_page']['maximum'], $query['properties']['include']['maxItems'], 'The include list should be capped at the maximum page size.' ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => range( 1, $query['properties']['include']['maxItems'] + 1 ), + ) + ); + + $this->assertWPError( $result, 'An include list beyond the cap should be rejected as invalid input.' ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'The cap should be enforced by schema validation.' ); + } + + /** + * Requesting rendered fields primes the post meta cache for the whole page. + * + * The rendered filter chains may read post meta, so priming avoids one lazy meta + * query per returned row. + * + * @since 7.1.0 + */ + public function test_query_rendered_fields_prime_the_post_meta_cache(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $postmeta_queries = $this->count_post_meta_queries( + static function () use ( $ids ) { + return wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'fields' => array( 'id', 'content_rendered' ), + ) + ); + }, + $result + ); + + $this->assertCount( 3, $result['posts'], 'Precondition: the query should return the seeded posts.' ); + + /* + * The rendered filter chains can read post meta per post. Without priming, each + * row lazily primes its own meta, which is one query per returned post. + */ + $this->assertSame( 1, $postmeta_queries, 'Rendered field requests should prime post meta with a single batched query, not one per returned post.' ); + } + + /** + * A lean projection keeps skipping the post meta cache priming. + * + * Nothing in the default field set renders a post, so the extra lookup stays skipped. + * + * @since 7.1.0 + */ + public function test_query_lean_projection_does_not_prime_the_post_meta_cache(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $ids = self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $postmeta_queries = $this->count_post_meta_queries( + static function () use ( $ids ) { + return wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'include' => $ids, + 'fields' => array( 'id' ), + ) + ); + }, + $result + ); + + $this->assertCount( 3, $result['posts'], 'Precondition: the query should return the seeded posts.' ); + $this->assertSame( 0, $postmeta_queries, 'A lean projection should not read post meta at all.' ); + + foreach ( $ids as $id ) { + $this->assertFalse( wp_cache_get( $id, 'post_meta' ), 'A lean projection should not prime the post meta cache.' ); + } + } + + /** + * Counts the post meta queries issued while running the given callback. + * + * Counts during the call rather than checking the cache afterwards: the rendered + * filter chains prime meta lazily, so an after-the-fact cache check passes either way. + * + * @since 7.1.0 + * + * @param callable $callback Callback to run. + * @param mixed $result Set to the callback's return value. + * @return int Number of post meta queries issued. + */ + private function count_post_meta_queries( callable $callback, &$result ): int { + global $wpdb; + + $postmeta_queries = 0; + $spy = static function ( $query ) use ( &$postmeta_queries, $wpdb ) { + if ( is_string( $query ) && preg_match( '/FROM\s+`?' . preg_quote( $wpdb->postmeta, '/' ) . '`?/i', $query ) ) { + ++$postmeta_queries; + } + + return $query; + }; + + wp_cache_flush(); + add_filter( 'query', $spy ); + try { + $result = $callback(); + } finally { + remove_filter( 'query', $spy ); + } + + return $postmeta_queries; + } + + /** + * Query rows are kept, not dropped, when the requested fields project to nothing. + * + * @since 7.1.0 + */ + public function test_query_keeps_posts_with_empty_field_projection(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + // `parent` never applies to the non-hierarchical `post` type, so every row + // projects to an empty object. + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'post_type' => 'post', + 'per_page' => 100, + 'fields' => array( 'parent' ), + ) + ); + + $this->assertNotEmpty( $result['posts'], 'Posts with an empty field projection should still be returned.' ); + $this->assertSame( count( $result['posts'] ), $result['total'], 'The reported total should match the returned posts when projections are empty.' ); + + foreach ( $result['posts'] as $post_entry ) { + $this->assertEquals( (object) array(), $post_entry, 'A post whose requested fields do not apply should be returned as an empty object.' ); + } + } + + /** + * A single post whose requested fields project to nothing is returned as an empty object. + * + * @since 7.1.0 + */ + public function test_single_post_returns_empty_object_for_empty_field_projection(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + // `parent` never applies to the non-hierarchical `post` type, so the projection is empty. + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => self::$post_ids['published'], + 'fields' => array( 'parent' ), + ) + ); + + $this->assertEquals( (object) array(), $result, 'An empty single-post field projection should be returned as an empty object so it serializes as `{}`.' ); + } + + /** + * A single post fetched by ID is returned directly without query totals. + * + * @since 7.1.0 + */ + public function test_single_post_returns_direct_post_object(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::$post_ids['published']; + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'id' => $post_id ) ); + + $this->assertSame( $post_id, $result['id'], 'Single-post responses should include the requested post ID.' ); + $this->assertArrayNotHasKey( 'posts', $result, 'Single-post responses should not include the query posts wrapper.' ); + $this->assertArrayNotHasKey( 'total', $result, 'Single-post responses should not include query totals.' ); + $this->assertArrayNotHasKey( 'total_pages', $result, 'Single-post responses should not include query page totals.' ); + } + + /** + * Local and GMT date fields report the correct instant and offset on non-UTC sites. + * + * @since 7.1.0 + */ + public function test_gmt_dates_are_utc_on_non_utc_sites(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + update_option( 'timezone_string', 'America/New_York' ); + + $post_id = self::factory()->post->create( + array( + 'post_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/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'date', 'date_gmt', 'modified', 'modified_gmt' ), + ) + ); + + $this->assertSame( '2026-01-15T10:00:00-05:00', $result['date'], 'The local date should carry the site timezone offset.' ); + $this->assertSame( '2026-01-15T15:00:00+00:00', $result['date_gmt'], 'The GMT date should be the UTC instant with a UTC offset.' ); + $this->assertSame( '2026-01-16T11:00:00-05:00', $result['modified'], 'The local modified date should carry the site timezone offset.' ); + $this->assertSame( '2026-01-16T16:00:00+00:00', $result['modified_gmt'], 'The GMT modified date should be the UTC instant with a UTC offset.' ); + } + + /** + * Drafts without a stored GMT date derive it from the local date and the site timezone. + * + * @since 7.1.0 + */ + public function test_gmt_date_is_derived_from_local_date_for_drafts(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + 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/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'date_gmt' ), + ) + ); + + $this->assertSame( + '2026-01-15T15:00:00+00:00', + $result['date_gmt'], + 'The GMT date should be derived from the local date using the site timezone, not read the local wall-clock as UTC.' + ); + } + + /** + * 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.1.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 fields. + * + * @since 7.1.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. + * + * @since 7.1.0 + * + * @dataProvider data_gmt_date_fields + * + * @param string $field The ability output field to request. + * @param string $gmt_column The cached GMT post column to null out. + * @param string $local_column The cached local post column to derive the GMT date from. + * @param string $local_date The local date column value. + * @param string $expected The expected GMT output. + */ + public function test_gmt_date_recovers_from_a_null_stored_gmt_date( string $field, string $gmt_column, string $local_column, string $local_date, string $expected ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + update_option( 'timezone_string', 'America/New_York' ); + + $post_id = self::factory()->post->create( + array( + 'post_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, + ) + ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', $field ), + ) + ); + + $this->assertSame( + $expected, + $result[ $field ], + '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 the documented empty-string sentinel. + * + * @since 7.1.0 + * + * @dataProvider data_gmt_date_fields + * + * @param string $field The ability output field to request. + * @param string $gmt_column The cached GMT post column to null out. + * @param string $local_column The cached local post column to null out. + * @param string $local_date Unused. Present to match the shared data provider shape. + * @param string $expected Unused. Present to match the shared data provider shape. + */ + public function test_gmt_date_is_empty_when_no_usable_date_exists( string $field, string $gmt_column, string $local_column, string $local_date, string $expected ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $this->replace_cached_post_date_columns( + $post_id, + array( + $gmt_column => null, + $local_column => null, + ) + ); + + $result = wp_get_ability( 'core/read-content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', $field ), + ) + ); + + $this->assertSame( '', $result[ $field ], 'An unresolvable GMT date should be the empty-string sentinel.' ); + } + + /** + * The execute callback re-validates the lookup structurally when invoked directly. + * + * Gated transports never reach these branches: check_permission() resolves and + * denies the same lookups first. The registered callback still fails closed on + * structural lookup errors when invoked directly. + * + * @since 7.1.0 + */ + public function test_execute_callback_returns_not_found_for_structural_lookup_failures(): void { + $this->login_as( 'administrator' ); + + $content = new WP_Content_Abilities(); + + $missing = $content->execute_read_content( array( 'id' => 999999 ) ); + $this->assertWPError( $missing, 'A nonexistent post ID should fail the lookup.' ); + $this->assertSame( 'content_not_found', $missing->get_error_code(), 'Missing posts should map to the uniform not-found error.' ); + + $mismatched = $content->execute_read_content( + array( + 'id' => self::$post_ids['published'], + 'post_type' => 'page', + ) + ); + $this->assertWPError( $mismatched, 'A post type mismatch should fail the lookup.' ); + $this->assertSame( 'content_not_found', $mismatched->get_error_code(), 'Mismatched post types should map to the uniform not-found error.' ); + + $missing_slug = $content->execute_read_content( + array( + 'post_type' => 'post', + 'slug' => 'no-such-slug', + ) + ); + $this->assertWPError( $missing_slug, 'An unmatched slug should fail the lookup.' ); + $this->assertSame( 'content_not_found', $missing_slug->get_error_code(), 'Unmatched slugs should map to the uniform not-found error.' ); + } + + /** + * An author filter that does not resolve to a positive integer is rejected + * rather than silently dropped. + * + * On transports that skip schema validation a non-integer `author` would coerce + * to 0, which WP_Query treats as "no author filter" — returning every author's + * posts. The filter must fail closed instead of widening the result set. + * + * @since 7.1.0 + */ + public function test_execute_callback_rejects_non_integer_author_filter(): void { + $this->login_as( 'administrator' ); + $content = new WP_Content_Abilities(); + + $result = $content->execute_read_content( + array( + 'post_type' => 'post', + 'author' => 'not-a-number', + ) + ); + + $this->assertWPError( $result, 'A non-integer author filter must not silently widen the query to all authors.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An unhonorable author filter should fail closed as an invalid filter.' ); + } + + /** + * A parent filter that is not a non-negative integer is rejected rather than + * coerced to 0 (top-level). + * + * Because 0 is a legitimate parent value (top-level posts), a non-integer value + * cannot be detected by a numeric bound; it must be rejected on the raw value so + * garbage does not silently become a top-level query. + * + * @since 7.1.0 + */ + public function test_execute_callback_rejects_non_integer_parent_filter(): void { + $this->login_as( 'administrator' ); + $content = new WP_Content_Abilities(); + + $result = $content->execute_read_content( + array( + 'post_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.' ); + } + + /** + * An include filter that parses to no valid IDs is rejected rather than + * returning an unrestricted result set. + * + * WP_Query ignores an empty `post__in`, so an include list with no valid IDs + * would otherwise return every post of the type — the opposite of the caller's + * intent. The filter must fail closed instead. + * + * @since 7.1.0 + */ + public function test_execute_callback_rejects_include_with_no_valid_ids(): void { + $this->login_as( 'administrator' ); + + self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $content = new WP_Content_Abilities(); + + $result = $content->execute_read_content( + array( + 'post_type' => 'post', + 'include' => array( 0 ), + ) + ); + + $this->assertWPError( $result, 'An include filter with no valid IDs must not fall through to an unrestricted query.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An empty-after-parsing include should fail closed as an invalid filter.' ); + } + + /** + * Valid filter values delivered as strings are still honored. + * + * The schema-less query-string transport delivers integers as strings; the + * stricter filter parsing must accept those so it only rejects genuinely + * unhonorable values, not well-formed ones. + * + * @since 7.1.0 + */ + public function test_execute_callback_honors_string_author_filter(): void { + $author_a = self::$user_ids['author']; + $author_b = self::$user_ids['author_secondary']; + + $post_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'publish', + ) + ); + self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + $content = new WP_Content_Abilities(); + + $result = $content->execute_read_content( + array( + 'post_type' => 'post', + 'author' => (string) $author_a, + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'A valid numeric-string author filter should be honored, not rejected.' ); + $this->assertSame( array( $post_a ), wp_list_pluck( $result['posts'], 'id' ), 'The author filter should restrict results to the requested author.' ); + } +} diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php new file mode 100644 index 0000000000000..a16f69ac6dd97 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -0,0 +1,326 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + 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' ); + } + + /** + * Cleans up registered abilities and categories. + * + * @since 7.1.0 + */ + public static function tear_down_after_class(): void { + add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + parent::tear_down_after_class(); + } + + public function set_up(): void { + parent::set_up(); + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + do_action( 'rest_api_init' ); + + wp_set_current_user( self::$admin_id ); + } + + public function tear_down(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Builds a GET run request with the given ability input. + * + * @param array $input The ability input. + * @return WP_REST_Request The request. + */ + private function run_request( array $input ): WP_REST_Request { + $request = new WP_REST_Request( 'GET', self::RUN_ROUTE ); + $request->set_query_params( array( 'input' => $input ) ); + return $request; + } + + public function test_logged_out_user_receives_401(): void { + wp_set_current_user( 0 ); + + $response = $this->server->dispatch( $this->run_request( array( 'post_type' => 'post' ) ) ); + + $this->assertSame( 401, $response->get_status() ); + } + + public function test_subscriber_requesting_drafts_receives_403(): void { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ) + ); + + $this->assertSame( 403, $response->get_status() ); + } + + public function test_subscriber_requesting_published_posts_receives_readable_fields(): void { + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Published for subscriber via REST', + 'post_content' => 'Subscriber REST body.', + 'post_status' => 'publish', + ) + ); + + wp_set_current_user( self::$subscriber_id ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'fields' => array( 'id', 'title_rendered', 'content_rendered' ), + ) + ) + ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertContains( $post_id, wp_list_pluck( $data['posts'], 'id' ) ); + + $post_index = array_search( $post_id, wp_list_pluck( $data['posts'], 'id' ), true ); + $this->assertIsInt( $post_index ); + + $post = $data['posts'][ $post_index ]; + $this->assertSame( 'Published for subscriber via REST', $post['title_rendered'] ); + $this->assertStringContainsString( 'Subscriber REST body.', $post['content_rendered'] ); + $this->assertArrayNotHasKey( 'content_raw', $post ); + } + + public function test_subscriber_requesting_raw_fields_receives_403(): void { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'fields' => array( 'content_raw' ), + ) + ) + ); + + $this->assertSame( 403, $response->get_status() ); + } + + public function test_admin_query_returns_published_posts(): void { + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Published via REST', + 'post_status' => 'publish', + ) + ); + + $response = $this->server->dispatch( $this->run_request( array( 'post_type' => 'post' ) ) ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'posts', $data ); + $this->assertContains( $post_id, wp_list_pluck( $data['posts'], 'id' ) ); + } + + public function test_admin_query_include_limits_results(): void { + $first = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-01-01 10:00:00', + ) + ); + $second = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-02-01 10:00:00', + ) + ); + $third = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2026-03-01 10:00:00', + ) + ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + // Deliberately pass IDs in the opposite of the expected date order. + 'include' => array( $first, $third ), + 'fields' => array( 'id' ), + ) + ) + ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array( $third, $first ), wp_list_pluck( $data['posts'], 'id' ) ); + $this->assertNotContains( $second, wp_list_pluck( $data['posts'], 'id' ) ); + } + + public function test_get_single_post_by_id(): void { + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $response = $this->server->dispatch( $this->run_request( array( 'id' => $post_id ) ) ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $post_id, $data['id'] ); + $this->assertArrayNotHasKey( 'posts', $data ); + $this->assertArrayNotHasKey( 'total', $data ); + } + + public function test_get_single_post_by_slug(): void { + $post_id = self::factory()->post->create( + array( + 'post_name' => 'rest-content-slug', + 'post_status' => 'publish', + ) + ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'slug' => 'rest-content-slug', + ) + ) + ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $post_id, $data['id'] ); + $this->assertSame( 'rest-content-slug', $data['slug'] ); + $this->assertArrayNotHasKey( 'posts', $data ); + } + + public function test_wrong_http_method_returns_405(): void { + $request = new WP_REST_Request( 'POST', self::RUN_ROUTE ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => array( 'post_type' => 'post' ) ) ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 405, $response->get_status() ); + $this->assertSame( 'rest_ability_invalid_method', $response->get_data()['code'] ); + } + + public function test_pagination_returns_totals_in_body(): void { + self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 1, + ) + ) + ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 2, $data['posts'] ); + $this->assertGreaterThanOrEqual( 3, $data['total'] ); + $this->assertSame( (int) ceil( $data['total'] / 2 ), $data['total_pages'] ); + } + + public function test_out_of_range_page_returns_400(): void { + self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'per_page' => 1, + 'page' => 999, + ) + ) + ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'content_invalid_page_number', $response->get_data()['code'] ); + } +}