diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 0eb87a4581589..078169ade2ca3 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -9,6 +9,8 @@ declare( strict_types = 1 ); +require_once __DIR__ . '/abilities/class-wp-content-abilities.php'; + /** * Registers the core ability categories. * @@ -30,6 +32,14 @@ function wp_register_core_ability_categories(): void { 'description' => __( 'Abilities that retrieve or modify user information and settings.' ), ) ); + + wp_register_ability_category( + 'content', + array( + 'label' => __( 'Content' ), + 'description' => __( 'Abilities that retrieve or manage posts and other content.' ), + ) + ); } /** @@ -351,4 +361,7 @@ function wp_register_core_abilities(): void { ), ) ); + + // Register the content abilities (currently the read-only `core/content`). + ( 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..623a66738efb9 --- /dev/null +++ b/src/wp-includes/abilities/class-wp-content-abilities.php @@ -0,0 +1,956 @@ +|null + */ + private ?array $exposed_post_types = null; + + /** + * Registers all content abilities. + * + * Must run on the `wp_abilities_api_init` hook. + * + * @since 7.1.0 + */ + public function register(): void { + $this->register_get_content(); + + /* + * A future write-oriented ability can be registered here, reusing the shared + * helpers below (get_exposed_post_types(), format_post(), check_permission()): + * + * $this->register_manage_content(); + */ + } + + /** + * Registers the read-only `core/content` ability. + * + * @since 7.1.0 + */ + private function register_get_content(): void { + // Compute once; check_permission()/execute_get_content() reuse this set. + $this->exposed_post_types = $this->get_exposed_post_types(); + + $post_types = array_keys( $this->exposed_post_types ); + $statuses = array_values( get_post_stati( array( 'internal' => false ) ) ); + + wp_register_ability( + 'core/content', + array( + 'label' => __( 'Get Content' ), + 'description' => __( 'Retrieves one or more readable posts of a post type exposed to abilities. Fetch a single readable post by ID or by slug, or query multiple readable posts filtered by post type, status, author, or parent. Returns a basic, support-aware set of fields per post, with raw fields limited to users who can edit the post.' ), + 'category' => self::CATEGORY, + 'input_schema' => $this->get_content_input_schema( $post_types, $statuses ), + 'output_schema' => $this->get_content_output_schema(), + 'execute_callback' => array( $this, 'execute_get_content' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + // Opt into REST-level pagination: query mode accepts `page`/`per_page` + // and returns `total`/`total_pages`, which the run controller turns into + // the standard X-WP-Total / X-WP-TotalPages response headers. + 'pagination' => true, + ), + ) + ); + } + + /** + * Permission callback for the `core/content` ability. + * + * Implements defense in depth: this gate decides whether the request may proceed at + * all, while the per-post read/edit checks in {@see self::execute_get_content()} + * are the authoritative, row-level enforcement. Requests that explicitly ask for + * edit-context fields require edit access before execution. + * + * @since 7.1.0 + * + * @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 = is_array( $input ) ? $input : array(); + $exposed = $this->exposed_post_types ?? $this->get_exposed_post_types(); + + if ( ! is_user_logged_in() ) { + return false; + } + + $requires_edit = $this->has_explicit_edit_fields( $input ); + + // Single-post mode (by ID). + if ( ! empty( $input['id'] ) ) { + $post = get_post( $this->input_int( $input['id'] ) ); + + 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 ); + } + + // Query / slug mode requires 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; + } + + $post_type_object = $exposed[ $post_type ]; + if ( $requires_edit ) { + return current_user_can( $post_type_object->cap->edit_posts ); + } + + 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; + } + + /** + * Checks whether the input explicitly requests edit-context fields. + * + * Omitted fields are not treated as edit-intent: default responses include the + * fields visible for each individual post. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return bool True if edit-context fields were explicitly requested. + */ + private function has_explicit_edit_fields( array $input ): bool { + if ( empty( $input['fields'] ) || ! is_array( $input['fields'] ) ) { + return false; + } + + $requested_fields = array_filter( $input['fields'], 'is_string' ); + + return array() !== array_intersect( self::EDIT_FIELDS, $requested_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; + } + + if ( 'private' === $status && current_user_can( $post_type_object->cap->read_private_posts ) ) { + continue; + } + + if ( current_user_can( $post_type_object->cap->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. + * @return bool Whether the post can be read. + */ + private function check_read_permission( WP_Post $post ): bool { + $post_type = get_post_type_object( $post->post_type ); + if ( ! $post_type instanceof WP_Post_Type || empty( $post_type->show_in_abilities ) ) { + return false; + } + + if ( 'publish' === $post->post_status || current_user_can( 'read_post', $post->ID ) ) { + return true; + } + + $post_status_object = get_post_status_object( $post->post_status ); + if ( $post_status_object && $post_status_object->public ) { + return true; + } + + if ( 'inherit' === $post->post_status && $post->post_parent > 0 ) { + $parent = get_post( $post->post_parent ); + if ( $parent instanceof WP_Post ) { + return $this->check_read_permission( $parent ); + } + } + + return 'inherit' === $post->post_status; + } + + /** + * Executes the `core/content` ability. + * + * @since 7.1.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return array|WP_Error A map with a `posts` list, or a WP_Error on failure. + */ + public function execute_get_content( $input = array() ) { + $input = is_array( $input ) ? $input : array(); + $exposed = $this->exposed_post_types ?? $this->get_exposed_post_types(); + $fields = $this->normalize_fields( $input ); + $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'] ) + || ( $requires_edit && ! current_user_can( 'edit_post', $post->ID ) ) + || ( ! $requires_edit && ! $this->check_read_permission( $post ) ) + ) { + return $this->not_found_error(); + } + + return array( + 'posts' => array( $this->format_post( $post, $fields ) ), + 'total' => 1, + 'total_pages' => 1, + ); + } + + // Query / slug mode. + $post_type = isset( $input['post_type'] ) && is_string( $input['post_type'] ) ? $input['post_type'] : ''; + if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { + return $this->not_found_error(); + } + + $per_page = $this->normalize_per_page( $input ); + $page = isset( $input['page'] ) ? max( 1, $this->input_int( $input['page'] ) ) : 1; + + $query_args = array( + 'post_type' => $post_type, + 'post_status' => $this->normalize_statuses( $input ), + 'posts_per_page' => $per_page, + 'paged' => $page, + 'perm' => $requires_edit ? 'editable' : 'readable', + 'ignore_sticky_posts' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ); + + if ( ! empty( $input['slug'] ) && is_string( $input['slug'] ) ) { + $query_args['name'] = sanitize_title( $input['slug'] ); + } + + if ( ! empty( $input['author'] ) ) { + $query_args['author'] = $this->input_int( $input['author'] ); + } + + if ( isset( $input['parent'] ) ) { + $query_args['post_parent'] = $this->input_int( $input['parent'] ); + } + + $query = new WP_Query( $query_args ); + + $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; + } + $formatted = $this->format_post( $post, $fields ); + if ( array() === $formatted ) { + continue; + } + $posts[] = $formatted; + } + + return array( + 'posts' => $posts, + 'total' => (int) $query->found_posts, + 'total_pages' => (int) $query->max_num_pages, + ); + } + + /** + * Normalizes the requested per-page value to the supported bounds. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return int The clamped per-page value. + */ + private function normalize_per_page( array $input ): int { + $per_page = isset( $input['per_page'] ) ? $this->input_int( $input['per_page'] ) : self::DEFAULT_PER_PAGE; + + return max( 1, min( self::MAX_PER_PAGE, $per_page ) ); + } + + /** + * Returns the post types exposed through the Abilities API, keyed by name. + * + * Only post types whose `show_in_abilities` argument is truthy are exposed. + * + * @since 7.1.0 + * + * @return array Exposed post type objects keyed by name. + */ + 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 string[] Normalized list of post status slugs. + */ + private function normalize_statuses( array $input ): array { + $statuses = $input['status'] ?? array( 'publish' ); + if ( ! is_array( $statuses ) ) { + return array( 'publish' ); + } + + $statuses = array_values( array_filter( $statuses, 'is_string' ) ); + + return array() === $statuses ? array( 'publish' ) : array_map( 'sanitize_key', $statuses ); + } + + /** + * Normalizes the requested fields to the supported set, defaulting to all fields. + * + * An empty or absent `fields` value selects every field. Edit-context fields are + * still omitted per post when the current user cannot edit that post. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return string[] List of requested field names. + */ + private function normalize_fields( array $input ): array { + if ( empty( $input['fields'] ) || ! is_array( $input['fields'] ) ) { + return $this->fields; + } + + $requested_fields = array_filter( $input['fields'], 'is_string' ); + $fields = array_intersect( $this->fields, $requested_fields ); + + return array() === $fields ? $this->fields : array_values( $fields ); + } + + /** + * Builds the input schema for the `core/content` ability. + * + * The ability has two mutually exclusive modes, modeled as a `oneOf` so invalid + * combinations are rejected rather than silently ignored: + * + * - Get a single readable post by `id` (optionally guarded by `post_type`). + * - Query a set of readable posts by `post_type` plus filters (`slug`, `status`, + * `author`, `parent`, `page`, `per_page`). + * + * Each mode sets `additionalProperties: false`, so e.g. passing `per_page` alongside `id` + * fails validation instead of being dropped. `fields` is accepted in both modes. + * + * @since 7.1.0 + * + * @param string[] $post_types Exposed post type names. + * @param string[] $statuses Requestable post status slugs. + * @return array The input JSON Schema. + */ + private function get_content_input_schema( array $post_types, array $statuses ): array { + $fields = array( + 'type' => 'array', + 'uniqueItems' => true, + 'items' => array( + 'type' => 'string', + 'enum' => $this->fields, + ), + 'description' => __( 'Limit each returned post to these fields. If omitted, all fields visible to the current user are returned. Explicit raw field requests require edit access.' ), + ); + + 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: query a set of readable posts by post type and filters. + array( + 'title' => __( 'Query readable posts by type and filters' ), + 'required' => array( 'post_type' ), + 'additionalProperties' => false, + 'properties' => array( + 'post_type' => array( + 'type' => 'string', + 'enum' => $post_types, + 'description' => __( 'Post type to query for readable posts.' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'Filter by slug. Combined with `post_type`, as slugs are not unique across post types.' ), + ), + 'status' => array( + 'type' => 'array', + 'uniqueItems' => true, + '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.' ), + ), + 'parent' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Filter by parent post ID, for hierarchical post types. Use 0 for top-level posts.' ), + ), + 'fields' => $fields, + 'page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Page of results to return.' ), + ), + '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/content` ability. + * + * No field is marked required because the `fields` input lets the caller request any + * subset, and a field is only present when its post type supports it. + * + * @since 7.1.0 + * + * @return array The output JSON Schema. + */ + private function get_content_output_schema(): array { + $post_schema = array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The post ID.' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'The post type.' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'The post status.' ), + ), + 'date' => array( + 'type' => 'string', + 'description' => __( "The publication date, in ISO 8601 format using the site's timezone." ), + ), + 'date_gmt' => array( + 'type' => 'string', + 'description' => __( 'The publication date, in ISO 8601 format as GMT.' ), + ), + 'modified' => array( + 'type' => 'string', + 'description' => __( "The last modified date, in ISO 8601 format using the site's timezone." ), + ), + 'modified_gmt' => array( + 'type' => 'string', + 'description' => __( 'The last modified date, in ISO 8601 format as GMT.' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'The post slug.' ), + ), + 'link' => array( + 'type' => 'string', + 'description' => __( 'The permalink URL.' ), + ), + 'title_raw' => array( + 'type' => 'string', + 'description' => __( 'The raw post title. Present when the post type supports titles and the current user can edit the post.' ), + ), + 'title_rendered' => array( + 'type' => 'string', + 'description' => __( 'The rendered post title. Present when the post type supports titles.' ), + ), + 'excerpt_raw' => array( + 'type' => 'string', + 'description' => __( 'The raw post excerpt. Present when the post type supports excerpts and the current user can edit the post.' ), + ), + 'excerpt_rendered' => array( + 'type' => 'string', + 'description' => __( 'The rendered post excerpt. Present when the post type supports excerpts. Empty when withheld for a password-protected post.' ), + ), + 'excerpt_protected' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the excerpt is protected with a password. Present when the post type supports excerpts.' ), + ), + 'content_raw' => array( + 'type' => 'string', + 'description' => __( 'The raw, unfiltered post content (block markup). Present when the post type supports the editor and the current user can edit the post.' ), + ), + 'content_rendered' => array( + 'type' => 'string', + 'description' => __( 'The rendered post content. Present when the post type supports the editor. Empty when withheld for a password-protected post.' ), + ), + 'content_protected' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the content is protected with a password. Present when the post type supports the editor.' ), + ), + 'author' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The author user ID.' ), + ), + 'display_name' => array( + 'type' => 'string', + 'description' => __( 'The author display name.' ), + ), + ), + 'description' => __( 'The post author. Present when the post type supports authors.' ), + ), + 'parent' => array( + 'type' => 'integer', + 'description' => __( 'The parent post ID. Present for hierarchical post types.' ), + ), + ), + ); + + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'posts', 'total', 'total_pages' ), + 'properties' => array( + 'posts' => array( + 'type' => 'array', + 'description' => __( 'The readable posts matching the request. A single-element list when requested by ID.' ), + 'items' => $post_schema, + ), + 'total' => array( + 'type' => 'integer', + 'description' => __( 'Total number of posts matching the query, across all pages, after applying the permission filter to the query. Surfaced over REST as the X-WP-Total header.' ), + ), + 'total_pages' => array( + 'type' => 'integer', + 'description' => __( 'Total number of query result pages available after applying the permission filter to the query. Surfaced over REST as the X-WP-TotalPages header.' ), + ), + ), + ); + } + + /** + * Formats a post into the ability output shape. + * + * Only the requested fields that the post type supports 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 string[] $fields The requested field names. + * @return array The formatted post data. + */ + private function format_post( WP_Post $post, array $fields ): array { + $post_type = $post->post_type; + $fields_requested = static function ( string $field ) use ( $fields ): bool { + return in_array( $field, $fields, true ); + }; + $can_edit = current_user_can( 'edit_post', $post->ID ); + $protected = post_password_required( $post ) && ! $can_edit; + + $data = array(); + + if ( $fields_requested( 'id' ) ) { + $data['id'] = (int) $post->ID; + } + if ( $fields_requested( 'type' ) ) { + $data['type'] = $post_type; + } + if ( $fields_requested( 'status' ) ) { + $data['status'] = $post->post_status; + } + if ( $fields_requested( 'date' ) ) { + $data['date'] = $this->format_local_date( $post, 'date' ); + } + if ( $fields_requested( 'date_gmt' ) ) { + $data['date_gmt'] = $this->format_gmt_date( $post, 'date' ); + } + if ( $fields_requested( 'modified' ) ) { + $data['modified'] = $this->format_local_date( $post, 'modified' ); + } + if ( $fields_requested( 'modified_gmt' ) ) { + $data['modified_gmt'] = $this->format_gmt_date( $post, 'modified' ); + } + if ( $fields_requested( 'slug' ) ) { + $data['slug'] = $post->post_name; + } + if ( $fields_requested( 'link' ) ) { + $data['link'] = (string) get_permalink( $post ); + } + + if ( $fields_requested( 'title_raw' ) && post_type_supports( $post_type, 'title' ) && $can_edit ) { + $data['title_raw'] = $post->post_title; + } + + if ( $fields_requested( 'title_rendered' ) && post_type_supports( $post_type, 'title' ) ) { + $data['title_rendered'] = $this->get_title( $post ); + } + + if ( $fields_requested( 'excerpt_raw' ) && post_type_supports( $post_type, 'excerpt' ) && $can_edit ) { + $data['excerpt_raw'] = $post->post_excerpt; + } + + if ( $fields_requested( 'excerpt_rendered' ) && post_type_supports( $post_type, 'excerpt' ) ) { + $data['excerpt_rendered'] = $protected ? '' : (string) get_the_excerpt( $post ); + } + + if ( $fields_requested( 'excerpt_protected' ) && post_type_supports( $post_type, 'excerpt' ) ) { + $data['excerpt_protected'] = (bool) $post->post_password; + } + + if ( $fields_requested( 'content_raw' ) && post_type_supports( $post_type, 'editor' ) && $can_edit ) { + $data['content_raw'] = $post->post_content; + } + + if ( $fields_requested( 'content_rendered' ) && post_type_supports( $post_type, 'editor' ) ) { + $data['content_rendered'] = $protected ? '' : $this->get_rendered_content( $post ); + } + + if ( $fields_requested( 'content_protected' ) && post_type_supports( $post_type, 'editor' ) ) { + $data['content_protected'] = (bool) $post->post_password; + } + + if ( $fields_requested( 'author' ) && post_type_supports( $post_type, 'author' ) ) { + $author = get_userdata( (int) $post->post_author ); + $data['author'] = array( + 'id' => (int) $post->post_author, + 'display_name' => $author ? $author->display_name : '', + ); + } + + if ( $fields_requested( 'parent' ) && is_post_type_hierarchical( $post_type ) ) { + $data['parent'] = (int) $post->post_parent; + } + + return $data; + } + + /** + * Returns the post title with the protected/private prefixes stripped. + * + * Mirrors the REST API, which removes the "Protected: " / "Private: " prefixes for + * machine consumers while still applying the_title filters. + * + * @since 7.1.0 + * + * @param WP_Post $post The post object. + * @return string The post title. + */ + private function get_title( WP_Post $post ): string { + $strip = array( $this, 'return_raw_title_format' ); + add_filter( 'protected_title_format', $strip ); + add_filter( 'private_title_format', $strip ); + $title = get_the_title( $post ); + remove_filter( 'protected_title_format', $strip ); + remove_filter( 'private_title_format', $strip ); + + return $title; + } + + /** + * 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 post content transformed for display. + * + * Mirrors the REST posts controller by preparing post globals before applying + * `the_content`, then restoring the previous global post context. + * + * @since 7.1.0 + * + * @param WP_Post $post The post object. + * @return string Rendered post content. + */ + private function get_rendered_content( WP_Post $post ): string { + $previous_post = $GLOBALS['post'] ?? null; + + $GLOBALS['post'] = $post; + setup_postdata( $post ); + + /** This filter is documented in wp-includes/post-template.php. */ + $content = apply_filters( 'the_content', $post->post_content ); + + if ( $previous_post instanceof WP_Post ) { + $GLOBALS['post'] = $previous_post; + setup_postdata( $previous_post ); + } else { + unset( $GLOBALS['post'] ); + wp_reset_postdata(); + } + + return (string) $content; + } + + /** + * Formats a post date field as an ISO 8601 string in the site's timezone. + * + * @since 7.1.0 + * + * @param WP_Post $post The post object. + * @param string $field Either 'date' or 'modified'. + * @return string The ISO 8601 date, or an empty string if unavailable. + */ + private function format_local_date( WP_Post $post, string $field ): string { + $field = 'modified' === $field ? 'modified' : 'date'; + $datetime = get_post_datetime( $post, $field, 'local' ); + if ( $datetime ) { + return $datetime->format( 'c' ); + } + + $local = 'modified' === $field ? $post->post_modified : $post->post_date; + $timestamp = mysql2date( 'U', $local, false ); + + return $timestamp ? wp_date( 'c', (int) $timestamp ) : ''; + } + + /** + * Formats a post date field as an ISO 8601 string in GMT. + * + * Uses get_post_datetime() so that posts without a GMT timestamp (e.g. some drafts) + * still resolve to a valid date. + * + * @since 7.1.0 + * + * @param WP_Post $post The post object. + * @param string $field Either 'date' or 'modified'. + * @return string The ISO 8601 date, or an empty string if unavailable. + */ + private function format_gmt_date( WP_Post $post, string $field ): string { + $field = 'modified' === $field ? 'modified' : 'date'; + $datetime = get_post_datetime( $post, $field, 'gmt' ); + if ( $datetime ) { + return $datetime->format( 'c' ); + } + + // Fallback for posts without a resolvable timestamp. + $local = 'modified' === $field ? $post->post_modified : $post->post_date; + $timestamp = mysql2date( 'U', $local, false ); + + return $timestamp ? gmdate( 'c', (int) $timestamp ) : ''; + } + + /** + * Builds the uniform not-found error. + * + * Used by execution when content cannot be resolved or edited after permission + * checks. The permission callback fails closed for uncertain by-ID lookups before + * execution runs. + * + * @since 7.1.0 + * + * @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..4d53974ba49ca 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 editable posts can be retrieved + * through the read-only `core/content` ability, subject to per-post capability + * checks. May be an array to enable specific operations in the future. + * + * @since 7.1.0 + * @var bool|array $show_in_abilities + */ + public $show_in_abilities; + /** * The base path for this post type's REST API endpoints. * @@ -551,6 +563,7 @@ public function set_props( $args ) { 'can_export' => true, 'delete_with_user' => null, 'show_in_rest' => false, + 'show_in_abilities' => false, 'rest_base' => false, 'rest_namespace' => false, 'rest_controller_class' => false, diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 005ccadd62e34..90621086ae25b 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -50,6 +50,7 @@ function create_initial_post_types() { 'post-formats', ), 'show_in_rest' => true, + 'show_in_abilities' => true, 'rest_base' => 'posts', 'rest_controller_class' => 'WP_REST_Posts_Controller', ) @@ -84,6 +85,7 @@ function create_initial_post_types() { 'revisions', ), 'show_in_rest' => true, + 'show_in_abilities' => true, 'rest_base' => 'pages', 'rest_controller_class' => 'WP_REST_Posts_Controller', ) @@ -1675,6 +1677,7 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * @since 5.0.0 The `template` and `template_lock` arguments were added. * @since 5.3.0 The `supports` argument will now accept an array of arguments for a feature. * @since 5.9.0 The `rest_namespace` argument was added. + * @since 7.1.0 The `show_in_abilities` argument was added. * * @global array $wp_post_types List of post types. * @@ -1720,6 +1723,10 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * of $show_in_menu. * @type bool $show_in_rest Whether to include the post type in the REST API. Set this to true * for the post type to be available in the block editor. + * @type bool|array $show_in_abilities Whether to expose this post type through the Abilities API, so its + * editable posts can be retrieved via the read-only `core/content` + * ability (subject to per-post capability checks). Accepts a boolean + * or an array reserved for enabling specific operations. Default false. * @type string $rest_base To change the base URL of REST API route. Default is $post_type. * @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..7102fcdbc0711 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -0,0 +1,821 @@ + true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor', 'excerpt', 'author' ), + ) + ); + + register_post_type( + self::HIDDEN_CPT, + array( + 'public' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + // Temporarily remove the unhook functions so we can register core abilities. + remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); + add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + /** + * Cleans up registered abilities, categories, and post types. + * + * @since 7.1.0 + */ + public static function tear_down_after_class(): void { + add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + unregister_post_type( self::EXPOSED_CPT ); + unregister_post_type( self::HIDDEN_CPT ); + + parent::tear_down_after_class(); + } + + /** + * Logs in as a user with the given role and returns the user ID. + * + * @param string $role The role to create the user with. + * @return int The new user ID. + */ + private function login_as( string $role ): int { + $user_id = self::factory()->user->create( array( 'role' => $role ) ); + wp_set_current_user( $user_id ); + return $user_id; + } + + /** + * Returns roles that can read public posts but cannot edit another user's post. + * + * @return array Role test cases. + */ + public function data_roles_without_edit_access_to_other_users_posts(): array { + return array( + 'subscriber' => array( + 'role' => 'subscriber', + ), + 'contributor' => array( + 'role' => 'contributor', + ), + 'author' => array( + 'role' => 'author', + ), + ); + } + + /** + * Convenience accessor for the ability. + * + * @return WP_Ability The core/content ability. + */ + private function ability(): WP_Ability { + return wp_get_ability( 'core/content' ); + } + + /* + * ------------------------------------------------------------------------- + * Registration & schema + * ------------------------------------------------------------------------- + */ + + public function test_ability_is_registered_readonly_in_content_category(): void { + $ability = $this->ability(); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertSame( 'content', $ability->get_category() ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertTrue( $annotations['readonly'] ); + $this->assertFalse( $annotations['destructive'] ); + $this->assertTrue( $annotations['idempotent'] ); + } + + public function test_input_schema_models_mutually_exclusive_modes(): void { + $schema = $this->ability()->get_input_schema(); + + $this->assertSame( 'object', $schema['type'] ); + $this->assertCount( 2, $schema['oneOf'] ); + + [ $by_id, $by_type ] = $schema['oneOf']; + + // Mode 1 requires `id`; Mode 2 requires `post_type`. Both reject extra properties. + $this->assertSame( array( 'id' ), $by_id['required'] ); + $this->assertSame( array( 'post_type' ), $by_type['required'] ); + $this->assertFalse( $by_id['additionalProperties'] ); + $this->assertFalse( $by_type['additionalProperties'] ); + + // Query-only filters live only in the query mode, not the by-ID mode. + $this->assertArrayHasKey( 'per_page', $by_type['properties'] ); + $this->assertArrayNotHasKey( 'per_page', $by_id['properties'] ); + } + + public function test_id_mode_rejects_query_only_params(): void { + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( + array( + 'id' => 1, + 'per_page' => 10, + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + public function test_id_mode_accepts_post_type_guard(): void { + $this->login_as( 'administrator' ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + ) + ); + + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'post_type' => 'post', + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( $post_id, $result['posts'][0]['id'] ); + } + + public function test_input_schema_post_type_enum_only_includes_exposed_types(): void { + $enum = $this->ability()->get_input_schema()['oneOf'][1]['properties']['post_type']['enum']; + + $this->assertContains( 'post', $enum ); + $this->assertContains( 'page', $enum ); + $this->assertContains( self::EXPOSED_CPT, $enum ); + $this->assertNotContains( self::HIDDEN_CPT, $enum ); + $this->assertNotContains( 'revision', $enum ); + } + + public function test_input_schema_status_and_fields_enums(): void { + $properties = $this->ability()->get_input_schema()['oneOf'][1]['properties']; + + $status_enum = $properties['status']['items']['enum']; + $this->assertContains( 'publish', $status_enum ); + $this->assertContains( 'draft', $status_enum ); + $this->assertContains( 'private', $status_enum ); + $this->assertNotContains( 'trash', $status_enum ); + $this->assertNotContains( 'auto-draft', $status_enum ); + + $fields_enum = $properties['fields']['items']['enum']; + $this->assertContains( 'content_raw', $fields_enum ); + $this->assertContains( 'content_rendered', $fields_enum ); + $this->assertContains( 'title_raw', $fields_enum ); + $this->assertContains( 'title_rendered', $fields_enum ); + $this->assertContains( 'author', $fields_enum ); + } + + public function test_input_schema_omits_oneof_branch_defaults(): void { + $properties = $this->ability()->get_input_schema()['oneOf'][1]['properties']; + + $this->assertArrayNotHasKey( 'default', $properties['status'] ); + $this->assertArrayNotHasKey( 'default', $properties['page'] ); + $this->assertArrayNotHasKey( 'default', $properties['per_page'] ); + } + + public function test_output_schema_has_no_required_fields(): void { + $schema = $this->ability()->get_output_schema(); + $post_item = $schema['properties']['posts']['items']; + + $this->assertSame( array( 'posts', 'total', 'total_pages' ), $schema['required'] ); + $this->assertArrayNotHasKey( 'required', $post_item ); + $this->assertFalse( $post_item['additionalProperties'] ); + $this->assertArrayHasKey( 'content_raw', $post_item['properties'] ); + $this->assertArrayHasKey( 'content_rendered', $post_item['properties'] ); + } + + /* + * ------------------------------------------------------------------------- + * Single-post retrieval + * ------------------------------------------------------------------------- + */ + + public function test_get_single_published_post_by_id(): void { + $this->login_as( 'administrator' ); + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Hello Content', + 'post_content' => 'Body here.', + 'post_status' => 'publish', + ) + ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result ); + $this->assertCount( 1, $result['posts'] ); + $this->assertSame( $post_id, $result['posts'][0]['id'] ); + $this->assertSame( 'Hello Content', $result['posts'][0]['title_raw'] ); + $this->assertSame( 'Hello Content', $result['posts'][0]['title_rendered'] ); + $this->assertSame( 'Body here.', $result['posts'][0]['content_raw'] ); + $this->assertStringContainsString( 'Body here.', $result['posts'][0]['content_rendered'] ); + $this->assertSame( 'post', $result['posts'][0]['type'] ); + } + + public function test_get_by_id_with_mismatched_post_type_is_denied(): void { + $this->login_as( 'administrator' ); + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'post_type' => 'page', + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + public function test_get_by_missing_id_is_denied(): void { + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( array( 'id' => 999999 ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + public function test_get_by_id_for_unexposed_post_type_is_denied(): void { + $post_id = self::factory()->post->create( + array( + 'post_type' => self::HIDDEN_CPT, + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + /* + * ------------------------------------------------------------------------- + * Query mode + * ------------------------------------------------------------------------- + */ + + public function test_query_returns_only_published_by_default(): void { + $this->login_as( 'administrator' ); + $published = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $draft = self::factory()->post->create( array( 'post_status' => 'draft' ) ); + + $result = $this->ability()->execute( array( 'post_type' => 'post' ) ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $published, $ids ); + $this->assertNotContains( $draft, $ids ); + } + + public function test_query_by_slug_requires_post_type(): void { + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( array( 'slug' => 'whatever' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + public function test_query_by_slug_within_post_type(): void { + $this->login_as( 'administrator' ); + $post_id = self::factory()->post->create( + array( + 'post_name' => 'find-me', + 'post_status' => 'publish', + ) + ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'slug' => 'find-me', + ) + ); + + $this->assertCount( 1, $result['posts'] ); + $this->assertSame( $post_id, $result['posts'][0]['id'] ); + } + + public function test_query_filters_by_author(): void { + $author_a = self::factory()->user->create( array( 'role' => 'author' ) ); + $author_b = self::factory()->user->create( array( 'role' => 'author' ) ); + $post_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'publish', + ) + ); + self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'author' => $author_a, + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertSame( array( $post_a ), $ids ); + } + + public function test_query_filters_by_parent_for_hierarchical_types(): void { + $this->login_as( 'administrator' ); + $parent = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + $child = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_parent' => $parent, + 'post_status' => 'publish', + ) + ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'page', + 'parent' => $parent, + ) + ); + + $this->assertCount( 1, $result['posts'] ); + $this->assertSame( $child, $result['posts'][0]['id'] ); + $this->assertSame( $parent, $result['posts'][0]['parent'] ); + } + + /* + * ------------------------------------------------------------------------- + * fields filter + * ------------------------------------------------------------------------- + */ + + public function test_fields_filter_limits_returned_keys(): void { + $this->login_as( 'administrator' ); + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'title_rendered' ), + ) + ); + + $this->assertSame( array( 'id', 'title_rendered' ), array_keys( $result['posts'][0] ) ); + } + + public function test_unsupported_fields_are_omitted_for_post_type(): void { + $this->login_as( 'administrator' ); + // Pages do not support excerpt by default in this CPT, but `post` does; use the + // exposed CPT which does not support `comments`/`parent` to confirm omission. + $post_id = self::factory()->post->create( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + ) + ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + // `post` is not hierarchical, so `parent` must be absent even though requested implicitly. + $this->assertArrayNotHasKey( 'parent', $result['posts'][0] ); + } + + /* + * ------------------------------------------------------------------------- + * Permissions & visibility (security) + * ------------------------------------------------------------------------- + */ + + public function test_logged_out_user_is_denied(): void { + wp_set_current_user( 0 ); + + $result = $this->ability()->execute( array( 'post_type' => 'post' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + public function test_subscriber_can_request_published_content(): void { + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Visible to subscribers', + 'post_content' => 'Rendered body for subscribers.', + 'post_status' => 'publish', + ) + ); + $this->login_as( 'subscriber' ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'fields' => array( 'id', 'title_rendered', 'content_rendered' ), + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $post_id, $ids ); + $post_index = array_search( $post_id, $ids, true ); + $this->assertIsInt( $post_index ); + $post = $result['posts'][ $post_index ]; + $this->assertSame( 'Visible to subscribers', $post['title_rendered'] ); + $this->assertStringContainsString( 'Rendered body for subscribers.', $post['content_rendered'] ); + $this->assertArrayNotHasKey( 'content_raw', $post ); + } + + public function test_subscriber_can_get_single_published_post_by_id(): void { + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Readable single', + 'post_content' => 'Readable single body.', + 'post_status' => 'publish', + ) + ); + $this->login_as( 'subscriber' ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Readable single', $result['posts'][0]['title_rendered'] ); + $this->assertStringContainsString( 'Readable single body.', $result['posts'][0]['content_rendered'] ); + $this->assertArrayNotHasKey( 'title_raw', $result['posts'][0] ); + $this->assertArrayNotHasKey( 'content_raw', $result['posts'][0] ); + } + + public function test_subscriber_cannot_request_raw_fields_in_query_mode(): void { + $this->login_as( 'subscriber' ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'fields' => array( 'content_raw' ), + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + public function test_subscriber_cannot_request_raw_fields_for_single_post(): void { + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $this->login_as( 'subscriber' ); + + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'content_raw' ), + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + /** + * Users who cannot edit another user's post do not receive raw fields by default. + * + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * + * @param string $role The role to test. + */ + public function test_default_fields_omit_raw_fields_for_roles_without_edit_access_to_other_users_posts( string $role ): void { + $post_owner_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $post_id = self::factory()->post->create( + array( + 'post_author' => $post_owner_id, + 'post_title' => 'Readable title', + 'post_content' => 'Readable body for limited role.', + 'post_excerpt' => 'Readable excerpt.', + 'post_status' => 'publish', + ) + ); + + $this->login_as( $role ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result, 'The readable published post should be returned.' ); + $this->assertSame( 'Readable title', $result['posts'][0]['title_rendered'], 'Rendered title should remain visible.' ); + $this->assertStringContainsString( 'Readable body for limited role.', $result['posts'][0]['content_rendered'], 'Rendered content should remain visible.' ); + $this->assertArrayNotHasKey( 'title_raw', $result['posts'][0], 'Raw title should be omitted.' ); + $this->assertArrayNotHasKey( 'excerpt_raw', $result['posts'][0], 'Raw excerpt should be omitted.' ); + $this->assertArrayNotHasKey( 'content_raw', $result['posts'][0], 'Raw content should be omitted.' ); + } + + /** + * Users who cannot edit another user's post cannot explicitly request raw fields. + * + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * + * @param string $role The role to test. + */ + public function test_raw_field_requests_are_denied_for_roles_without_edit_access_to_other_users_posts( string $role ): void { + $post_owner_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $post_id = self::factory()->post->create( + array( + 'post_author' => $post_owner_id, + 'post_status' => 'publish', + ) + ); + + $this->login_as( $role ); + + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'content_raw' ), + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Raw field requests should require edit access to the post.' ); + } + + public function test_subscriber_cannot_request_draft_status(): void { + $this->login_as( 'subscriber' ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + public function test_subscriber_cannot_request_private_status(): void { + $this->login_as( 'subscriber' ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'status' => array( 'private' ), + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + public function test_author_cannot_see_other_authors_drafts(): void { + $author_a = self::factory()->user->create( array( 'role' => 'author' ) ); + $author_b = self::factory()->user->create( array( 'role' => 'author' ) ); + + $draft_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'draft', + ) + ); + $draft_b = self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'draft', + ) + ); + + // Author B can pass the status gate (has edit_posts) but only sees their own draft. + wp_set_current_user( $author_b ); + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $draft_b, $ids ); + $this->assertNotContains( $draft_a, $ids ); + } + + public function test_administrator_can_access_private_posts(): void { + $private = self::factory()->post->create( array( 'post_status' => 'private' ) ); + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( + array( + 'post_type' => 'post', + 'status' => array( 'private' ), + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $private, $ids ); + } + + public function test_unexposed_post_type_is_rejected_by_input_schema(): void { + $this->login_as( 'administrator' ); + + $result = $this->ability()->execute( array( 'post_type' => self::HIDDEN_CPT ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + /* + * ------------------------------------------------------------------------- + * Password-protected posts + * ------------------------------------------------------------------------- + */ + + public function test_raw_content_visible_to_editor(): void { + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'Public body with raw block markup.', + ) + ); + + $this->login_as( 'editor' ); + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'content_raw' ), + ) + ); + + $this->assertSame( 'Public body with raw block markup.', $result['posts'][0]['content_raw'] ); + } + + public function test_password_protected_content_visible_to_editor(): void { + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'Top secret body.', + ) + ); + + $this->login_as( 'editor' ); + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'content_raw', 'content_rendered' ), + ) + ); + + $this->assertSame( 'Top secret body.', $result['posts'][0]['content_raw'] ); + $this->assertStringContainsString( 'Top secret body.', $result['posts'][0]['content_rendered'] ); + } + + /** + * Password-protected rendered content is withheld from users who cannot edit the post. + * + * @dataProvider data_roles_without_edit_access_to_other_users_posts + * + * @param string $role The role to test. + */ + public function test_password_protected_rendered_content_is_empty_for_roles_without_edit_access_to_other_users_posts( string $role ): void { + $post_owner_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $post_id = self::factory()->post->create( + array( + 'post_author' => $post_owner_id, + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'Hidden rendered body.', + ) + ); + + $this->login_as( $role ); + $result = $this->ability()->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'content_rendered', 'content_protected' ), + ) + ); + + $this->assertSame( '', $result['posts'][0]['content_rendered'], 'Password-protected rendered content should be withheld.' ); + $this->assertTrue( $result['posts'][0]['content_protected'], 'The protected flag should reveal the field is password-protected.' ); + } + + /* + * ------------------------------------------------------------------------- + * Pagination + * ------------------------------------------------------------------------- + */ + + public function test_query_paginates_and_reports_totals(): void { + $this->login_as( 'administrator' ); + self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $page1 = $this->ability()->execute( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 1, + ) + ); + + $this->assertCount( 2, $page1['posts'] ); + $this->assertGreaterThanOrEqual( 3, $page1['total'] ); + $this->assertSame( (int) ceil( $page1['total'] / 2 ), $page1['total_pages'] ); + + $page2 = $this->ability()->execute( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 2, + ) + ); + + $this->assertNotEmpty( $page2['posts'] ); + $this->assertSame( $page1['total'], $page2['total'] ); + } + + public function test_per_page_is_capped(): void { + $this->login_as( 'administrator' ); + + $schema = $this->ability()->get_input_schema()['oneOf'][1]; + + $this->assertSame( 100, $schema['properties']['per_page']['maximum'] ); + } + + public function test_single_post_reports_totals(): void { + $this->login_as( 'administrator' ); + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = $this->ability()->execute( array( 'id' => $post_id ) ); + + $this->assertSame( 1, $result['total'] ); + $this->assertSame( 1, $result['total_pages'] ); + } + + public function test_ability_opts_into_pagination(): void { + $this->assertTrue( (bool) $this->ability()->get_meta_item( 'pagination', false ) ); + } +} diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php new file mode 100644 index 0000000000000..41f1937600f2c --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php @@ -0,0 +1,240 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); + add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + /** + * Cleans up registered abilities and categories. + * + * @since 7.1.0 + */ + public static function tear_down_after_class(): void { + add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + parent::tear_down_after_class(); + } + + public function set_up(): void { + parent::set_up(); + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + do_action( 'rest_api_init' ); + + wp_set_current_user( self::$admin_id ); + } + + public function tear_down(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Builds a GET run request with the given ability input. + * + * @param array $input The ability input. + * @return WP_REST_Request The request. + */ + private function run_request( array $input ): WP_REST_Request { + $request = new WP_REST_Request( 'GET', self::RUN_ROUTE ); + $request->set_query_params( array( 'input' => $input ) ); + return $request; + } + + public function test_logged_out_user_receives_401(): void { + wp_set_current_user( 0 ); + + $response = $this->server->dispatch( $this->run_request( array( 'post_type' => 'post' ) ) ); + + $this->assertSame( 401, $response->get_status() ); + } + + public function test_subscriber_requesting_drafts_receives_403(): void { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ) + ); + + $this->assertSame( 403, $response->get_status() ); + } + + public function test_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_get_single_post_by_id(): void { + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $response = $this->server->dispatch( $this->run_request( array( 'id' => $post_id ) ) ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 1, $data['posts'] ); + $this->assertSame( $post_id, $data['posts'][0]['id'] ); + } + + public function test_wrong_http_method_returns_405(): void { + $request = new WP_REST_Request( 'POST', self::RUN_ROUTE ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => array( 'post_type' => 'post' ) ) ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 405, $response->get_status() ); + $this->assertSame( 'rest_ability_invalid_method', $response->get_data()['code'] ); + } + + public function test_pagination_returns_totals_in_body(): void { + self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $response = $this->server->dispatch( + $this->run_request( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 1, + ) + ) + ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 2, $data['posts'] ); + $this->assertGreaterThanOrEqual( 3, $data['total'] ); + $this->assertSame( (int) ceil( $data['total'] / 2 ), $data['total_pages'] ); + } +}