From e846a9c1b69bae99e29512f85552d0b06eb398cc Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 25 Jun 2026 14:56:48 +0100 Subject: [PATCH 01/10] Abilities: Add core users ability --- src/wp-includes/abilities.php | 4 + .../abilities/class-wp-users-abilities.php | 799 ++++++++++++++++++ 2 files changed, 803 insertions(+) create mode 100644 src/wp-includes/abilities/class-wp-users-abilities.php diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 0eb87a4581589..185098bb09498 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-users-abilities.php'; + /** * Registers the core ability categories. * @@ -43,6 +45,8 @@ function wp_register_core_abilities(): void { $category_site = 'site'; $category_user = 'user'; + WP_Users_Abilities::register(); + $site_info_properties = array( 'name' => array( 'type' => 'string', diff --git a/src/wp-includes/abilities/class-wp-users-abilities.php b/src/wp-includes/abilities/class-wp-users-abilities.php new file mode 100644 index 0000000000000..ac94b5fe80470 --- /dev/null +++ b/src/wp-includes/abilities/class-wp-users-abilities.php @@ -0,0 +1,799 @@ + __( 'Get Users' ), + 'description' => __( 'Retrieves one or more readable WordPress users. Fetch a single readable user by ID, email, username, or slug, or query a paginated collection optionally filtered by roles or published-post authorship.' ), + 'category' => self::CATEGORY, + 'input_schema' => self::get_users_input_schema(), + 'output_schema' => self::get_users_output_schema(), + 'execute_callback' => array( __CLASS__, 'execute_get_users' ), + 'permission_callback' => array( __CLASS__, 'check_permission' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + 'pagination' => true, + ), + ) + ); + } + + /** + * Permission callback for the `core/users` ability. + * + * @since 6.9.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return bool True if the request may proceed, false otherwise. + */ + public static function check_permission( $input = array() ): bool { + $input = is_array( $input ) ? $input : array(); + + if ( ! is_user_logged_in() ) { + return false; + } + + if ( ! empty( $input['roles'] ) && ! current_user_can( 'list_users' ) ) { + return false; + } + + $lookup_type = self::get_lookup_type( $input ); + if ( '' === $lookup_type ) { + return true; + } + + $user = self::find_user( $input ); + if ( ! $user instanceof WP_User || ! self::is_user_member_of_site( $user ) ) { + return false; + } + + return self::can_read_user_for_lookup( $user, $lookup_type ); + } + + /** + * Executes the `core/users` ability. + * + * @since 6.9.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return array|WP_Error A map with a `users` list, or a WP_Error on failure. + */ + public static function execute_get_users( $input = array() ) { + $input = is_array( $input ) ? $input : array(); + $fields = self::normalize_fields( $input ); + + $lookup_type = self::get_lookup_type( $input ); + if ( '' !== $lookup_type ) { + $user = self::find_user( $input ); + if ( ! $user instanceof WP_User + || ! self::is_user_member_of_site( $user ) + || ! self::can_read_user_for_lookup( $user, $lookup_type ) + ) { + return self::not_found_error(); + } + + return array( + 'users' => array( self::format_user( $user, $fields ) ), + 'total' => 1, + 'total_pages' => 1, + ); + } + + $per_page = self::normalize_per_page( $input ); + $page = isset( $input['page'] ) ? max( 1, self::input_int( $input['page'] ) ) : 1; + + $query_args = array( + 'number' => $per_page, + 'offset' => ( $page - 1 ) * $per_page, + 'count_total' => true, + ); + + if ( ! empty( $input['roles'] ) && current_user_can( 'list_users' ) ) { + $query_args['role__in'] = self::normalize_string_list( $input['roles'] ); + } + + if ( current_user_can( 'list_users' ) ) { + $has_published_posts = self::normalize_has_published_posts( $input ); + if ( null !== $has_published_posts ) { + $query_args['has_published_posts'] = $has_published_posts; + } + } else { + $query_args['has_published_posts'] = self::get_public_author_post_types(); + } + + $query = new WP_User_Query( $query_args ); + + $users = array(); + foreach ( $query->get_results() as $user ) { + if ( ! $user instanceof WP_User || ! self::is_user_member_of_site( $user ) || ! self::can_read_user( $user ) ) { + continue; + } + + $users[] = self::format_user( $user, $fields ); + } + + $total_users = (int) $query->get_total(); + + return array( + 'users' => $users, + 'total' => $total_users, + 'total_pages' => $per_page > 0 ? (int) ceil( $total_users / $per_page ) : 0, + ); + } + + /** + * Casts a raw input value to a non-negative integer. + * + * @since 6.9.0 + * + * @param mixed $value The raw input value. + * @return int The value as a non-negative integer, or 0 when not scalar. + */ + private static function input_int( $value ): int { + return is_scalar( $value ) ? absint( $value ) : 0; + } + + /** + * Determines the single-user lookup type represented by the input. + * + * @since 6.9.0 + * + * @param array $input The ability input. + * @return string The lookup type, or an empty string for collection mode. + */ + private static function get_lookup_type( array $input ): string { + foreach ( array( 'id', 'email', 'username', 'slug' ) as $key ) { + if ( array_key_exists( $key, $input ) ) { + return $key; + } + } + + return ''; + } + + /** + * Finds a user by one of the supported unique input identifiers. + * + * @since 6.9.0 + * + * @param array $input The ability input. + * @return WP_User|null User object, or null when not found. + */ + private static function find_user( array $input ): ?WP_User { + if ( isset( $input['id'] ) ) { + $user = get_userdata( self::input_int( $input['id'] ) ); + return $user instanceof WP_User ? $user : null; + } + + if ( isset( $input['email'] ) && is_string( $input['email'] ) ) { + $user = get_user_by( 'email', sanitize_email( $input['email'] ) ); + return $user instanceof WP_User ? $user : null; + } + + if ( isset( $input['username'] ) && is_string( $input['username'] ) ) { + $user = get_user_by( 'login', $input['username'] ); + return $user instanceof WP_User ? $user : null; + } + + if ( isset( $input['slug'] ) && is_string( $input['slug'] ) ) { + $user = get_user_by( 'slug', sanitize_title( $input['slug'] ) ); + return $user instanceof WP_User ? $user : null; + } + + return null; + } + + /** + * Checks whether a user belongs to the current site. + * + * @since 6.9.0 + * + * @param WP_User $user User object. + * @return bool Whether the user belongs to the current site. + */ + private static function is_user_member_of_site( WP_User $user ): bool { + return ! is_multisite() || is_user_member_of_blog( (int) $user->ID ); + } + + /** + * Checks whether a single-user lookup may return the target user. + * + * Email and username are identifier-sensitive lookup modes and do not use the + * public-author fallback. + * + * @since 6.9.0 + * + * @param WP_User $user User object. + * @param string $lookup_type Lookup type. + * @return bool Whether the user can be read for that lookup type. + */ + private static function can_read_user_for_lookup( WP_User $user, string $lookup_type ): bool { + if ( self::is_current_user( $user ) ) { + return true; + } + + if ( current_user_can( 'edit_user', $user->ID ) || current_user_can( 'list_users' ) ) { + return true; + } + + if ( 'email' === $lookup_type || 'username' === $lookup_type ) { + return false; + } + + return self::is_public_author( $user ); + } + + /** + * Checks whether a user may be included in collection results. + * + * @since 6.9.0 + * + * @param WP_User $user User object. + * @return bool Whether the user can be read. + */ + private static function can_read_user( WP_User $user ): bool { + return self::is_current_user( $user ) + || current_user_can( 'edit_user', $user->ID ) + || current_user_can( 'list_users' ) + || self::is_public_author( $user ); + } + + /** + * Checks whether the current user is the target user. + * + * @since 6.9.0 + * + * @param WP_User $user User object. + * @return bool Whether the current user is the target user. + */ + private static function is_current_user( WP_User $user ): bool { + return get_current_user_id() === (int) $user->ID; + } + + /** + * Checks whether a user has published posts in REST-visible author post types. + * + * @since 6.9.0 + * + * @param WP_User $user User object. + * @return bool Whether the user is publicly visible as an author. + */ + private static function is_public_author( WP_User $user ): bool { + $post_types = self::get_public_author_post_types(); + if ( array() === $post_types ) { + return false; + } + + return count_user_posts( (int) $user->ID, $post_types ) > 0; + } + + /** + * Returns REST-visible post types that support authors. + * + * @since 6.9.0 + * + * @return string[] REST-visible author post type names. + */ + private static function get_public_author_post_types(): array { + $post_types = array(); + + foreach ( get_post_types( array( 'show_in_rest' => true ), 'names' ) as $post_type ) { + if ( is_string( $post_type ) && post_type_supports( $post_type, 'author' ) ) { + $post_types[] = $post_type; + } + } + + return $post_types; + } + + /** + * Normalizes the requested fields to the supported set, defaulting to all fields. + * + * @since 6.9.0 + * + * @param array $input The ability input. + * @return string[] List of requested field names. + */ + private static function normalize_fields( array $input ): array { + $available_fields = self::get_fields(); + + if ( empty( $input['fields'] ) || ! is_array( $input['fields'] ) ) { + return $available_fields; + } + + $requested_fields = array_filter( $input['fields'], 'is_string' ); + $fields = array_intersect( $available_fields, $requested_fields ); + + return array() === $fields ? $available_fields : array_values( $fields ); + } + + /** + * Returns the supported field list in output order. + * + * @since 6.9.0 + * + * @return string[] Supported field names. + */ + private static function get_fields(): array { + $fields = self::$read_fields; + + if ( get_option( 'show_avatars' ) ) { + $fields[] = 'avatar_urls'; + } + + return array_merge( $fields, self::$sensitive_fields, array( 'roles' ) ); + } + + /** + * Normalizes the requested per-page value to the supported bounds. + * + * @since 6.9.0 + * + * @param array $input The ability input. + * @return int The clamped per-page value. + */ + private static function normalize_per_page( array $input ): int { + $per_page = isset( $input['per_page'] ) ? self::input_int( $input['per_page'] ) : self::DEFAULT_PER_PAGE; + + return max( 1, min( self::MAX_PER_PAGE, $per_page ) ); + } + + /** + * Normalizes a mixed value into a list of non-empty strings. + * + * @since 6.9.0 + * + * @param mixed $value Raw value. + * @return string[] Normalized strings. + */ + private static function normalize_string_list( $value ): array { + if ( ! is_array( $value ) ) { + return array(); + } + + $strings = array(); + foreach ( $value as $item ) { + if ( ! is_string( $item ) || '' === $item ) { + continue; + } + + $strings[] = $item; + } + + return array_values( array_unique( $strings ) ); + } + + /** + * Normalizes the `has_published_posts` collection input. + * + * @since 6.9.0 + * + * @param array $input The ability input. + * @return bool|string[]|null Normalized query value, or null when absent/invalid. + */ + private static function normalize_has_published_posts( array $input ) { + if ( ! array_key_exists( 'has_published_posts', $input ) ) { + return null; + } + + if ( true === $input['has_published_posts'] ) { + return true; + } + + $post_types = self::normalize_string_list( $input['has_published_posts'] ); + + return array() === $post_types ? null : $post_types; + } + + /** + * Builds the input schema for the `core/users` ability. + * + * @since 6.9.0 + * + * @return array The input JSON Schema. + */ + private static function get_users_input_schema(): array { + $fields = array( + 'type' => 'array', + 'uniqueItems' => true, + 'items' => array( + 'type' => 'string', + 'enum' => self::get_fields(), + ), + 'description' => __( 'Limit each returned user to these fields. If omitted, all fields visible to the current user are returned.' ), + ); + + return array( + 'type' => 'object', + 'oneOf' => array( + array( + 'title' => __( 'Get a single readable user by ID' ), + 'required' => array( 'id' ), + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Retrieve a single readable user by ID.' ), + ), + 'fields' => $fields, + ), + ), + array( + 'title' => __( 'Get a single readable user by email address' ), + 'required' => array( 'email' ), + 'additionalProperties' => false, + 'properties' => array( + 'email' => array( + 'type' => 'string', + 'format' => 'email', + 'description' => __( 'Retrieve a single readable user by email address. Resolving another user by email requires permission to list or edit users.' ), + ), + 'fields' => $fields, + ), + ), + array( + 'title' => __( 'Get a single readable user by username' ), + 'required' => array( 'username' ), + 'additionalProperties' => false, + 'properties' => array( + 'username' => array( + 'type' => 'string', + 'description' => __( 'Retrieve a single readable user by username. Resolving another user by username requires permission to list or edit users.' ), + ), + 'fields' => $fields, + ), + ), + array( + 'title' => __( 'Get a single readable user by slug' ), + 'required' => array( 'slug' ), + 'additionalProperties' => false, + 'properties' => array( + 'slug' => array( + 'type' => 'string', + 'description' => __( 'Retrieve a single readable user by slug.' ), + ), + 'fields' => $fields, + ), + ), + array( + 'title' => __( 'Query readable users' ), + 'additionalProperties' => false, + 'properties' => array( + 'roles' => array( + 'type' => 'array', + 'uniqueItems' => true, + 'minItems' => 1, + 'items' => array( + 'type' => 'string', + ), + 'description' => __( 'Filter users by one or more roles. Requires permission to list users.' ), + ), + 'has_published_posts' => array( + 'oneOf' => array( + array( + 'type' => 'boolean', + 'enum' => array( true ), + ), + array( + 'type' => 'array', + 'uniqueItems' => true, + 'minItems' => 1, + 'items' => array( + 'type' => 'string', + ), + ), + ), + 'description' => __( 'Limit results to users with published posts. Use true for all post types, or provide post type names.' ), + ), + '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 users to return per page.' ), + ), + ), + ), + ), + ); + } + + /** + * Builds the output schema for the `core/users` ability. + * + * @since 6.9.0 + * + * @return array The output JSON Schema. + */ + private static function get_users_output_schema(): array { + $user_properties = array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The user ID.' ), + ), + 'display_name' => array( + 'type' => 'string', + 'description' => __( 'The display name for the user.' ), + ), + 'description' => array( + 'type' => 'string', + 'description' => __( 'Description of the user.' ), + ), + 'url' => array( + 'type' => 'string', + 'description' => __( 'URL of the user.' ), + ), + 'link' => array( + 'type' => 'string', + 'description' => __( 'Author archive URL for the user.' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'An alphanumeric identifier for the user.' ), + ), + 'username' => array( + 'type' => 'string', + 'description' => __( 'Login name for the user. Present when the current user can view it.' ), + ), + 'email' => array( + 'type' => 'string', + 'format' => 'email', + 'description' => __( 'The email address for the user. Present when the current user can view it.' ), + ), + 'first_name' => array( + 'type' => 'string', + 'description' => __( 'First name for the user. Present when the current user can view it.' ), + ), + 'last_name' => array( + 'type' => 'string', + 'description' => __( 'Last name for the user. Present when the current user can view it.' ), + ), + 'nickname' => array( + 'type' => 'string', + 'description' => __( 'The nickname for the user. Present when the current user can view it.' ), + ), + 'locale' => array( + 'type' => 'string', + 'description' => __( 'Locale for the user. Present when the current user can view it.' ), + ), + 'registered_date' => array( + 'type' => 'string', + 'format' => 'date-time', + 'description' => __( 'Registration date for the user in ISO 8601 format. Present when the current user can view it.' ), + ), + 'roles' => array( + 'type' => 'array', + 'description' => __( 'Roles assigned to the user. Present when the current user can view them.' ), + 'items' => array( + 'type' => 'string', + ), + ), + ); + + if ( get_option( 'show_avatars' ) ) { + $user_properties['avatar_urls'] = array( + 'type' => 'object', + 'description' => __( 'Avatar URLs for the user at various sizes.' ), + 'additionalProperties' => array( + 'type' => 'string', + ), + ); + } + + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'users', 'total', 'total_pages' ), + 'properties' => array( + 'users' => array( + 'type' => 'array', + 'description' => __( 'The readable users matching the request. A single-element list when requested by a unique identifier.' ), + 'items' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => $user_properties, + ), + ), + 'total' => array( + 'type' => 'integer', + 'description' => __( 'Total number of users 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 user into the ability output shape. + * + * @since 6.9.0 + * + * @param WP_User $user The user object. + * @param string[] $fields The requested field names. + * @return array The formatted user data. + */ + private static function format_user( WP_User $user, array $fields ): array { + $fields_requested = static function ( string $field ) use ( $fields ): bool { + return in_array( $field, $fields, true ); + }; + + $user_id = (int) $user->ID; + $can_view_sensitive = self::is_current_user( $user ) || current_user_can( 'edit_user', $user_id ); + $can_view_roles = current_user_can( 'list_users' ) || current_user_can( 'edit_user', $user_id ); + + $data = array(); + + if ( $fields_requested( 'id' ) ) { + $data['id'] = $user_id; + } + if ( $fields_requested( 'display_name' ) ) { + $data['display_name'] = (string) $user->display_name; + } + if ( $fields_requested( 'description' ) ) { + $data['description'] = (string) $user->description; + } + if ( $fields_requested( 'url' ) ) { + $data['url'] = (string) $user->user_url; + } + if ( $fields_requested( 'link' ) ) { + $data['link'] = (string) get_author_posts_url( $user_id, $user->user_nicename ); + } + if ( $fields_requested( 'slug' ) ) { + $data['slug'] = (string) $user->user_nicename; + } + if ( $fields_requested( 'avatar_urls' ) && get_option( 'show_avatars' ) ) { + $data['avatar_urls'] = rest_get_avatar_urls( $user ); + } + + if ( $can_view_sensitive ) { + if ( $fields_requested( 'username' ) ) { + $data['username'] = (string) $user->user_login; + } + if ( $fields_requested( 'email' ) ) { + $data['email'] = (string) $user->user_email; + } + if ( $fields_requested( 'first_name' ) ) { + $data['first_name'] = (string) $user->first_name; + } + if ( $fields_requested( 'last_name' ) ) { + $data['last_name'] = (string) $user->last_name; + } + if ( $fields_requested( 'nickname' ) ) { + $data['nickname'] = (string) $user->nickname; + } + if ( $fields_requested( 'locale' ) ) { + $data['locale'] = (string) get_user_locale( $user ); + } + if ( $fields_requested( 'registered_date' ) ) { + $registered_timestamp = strtotime( (string) $user->user_registered ); + if ( false !== $registered_timestamp ) { + $data['registered_date'] = gmdate( 'c', $registered_timestamp ); + } + } + } + + if ( $fields_requested( 'roles' ) && $can_view_roles ) { + $data['roles'] = self::normalize_string_list( $user->roles ); + } + + return $data; + } + + /** + * Returns a generic not-found error for missing or inaccessible user lookups. + * + * @since 6.9.0 + * + * @return WP_Error Not found error. + */ + private static function not_found_error(): WP_Error { + return new WP_Error( + 'user_not_found', + __( 'The requested user was not found.' ), + array( 'status' => 404 ) + ); + } +} From 97122c1fed09d67277255fa2eeb821a015164fff Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 25 Jun 2026 14:59:24 +0100 Subject: [PATCH 02/10] Tests: Cover core users ability --- .../wpRegisterCoreUsersAbility.php | 419 ++++++++++++++++++ 1 file changed, 419 insertions(+) create mode 100644 tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php new file mode 100644 index 0000000000000..111804f632347 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php @@ -0,0 +1,419 @@ +get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + parent::tear_down_after_class(); + } + + /** + * Set up test case. + * + * @since 6.9.0 + */ + public function set_up(): void { + parent::set_up(); + + $this->show_avatars = get_option( 'show_avatars' ); + update_option( 'show_avatars', 1 ); + + $this->admin_id = self::factory()->user->create( + array( + 'role' => 'administrator', + 'user_login' => 'core_users_ability_admin', + 'user_email' => 'core-users-ability-admin@example.com', + 'user_nicename' => 'core-users-ability-admin', + ) + ); + + $this->subscriber_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + 'user_login' => 'core_users_ability_subscriber', + 'user_email' => 'core-users-ability-subscriber@example.com', + 'user_nicename' => 'core-users-ability-subscriber', + ) + ); + + $this->public_author_id = self::factory()->user->create( + array( + 'role' => 'author', + 'user_login' => 'core_users_ability_author', + 'user_email' => 'core-users-ability-author@example.com', + 'user_nicename' => 'core-users-ability-author', + ) + ); + + $this->public_post_id = self::factory()->post->create( + array( + 'post_author' => $this->public_author_id, + 'post_status' => 'publish', + 'post_type' => 'post', + ) + ); + + $this->register_core_users_ability(); + } + + /** + * Tear down test case. + * + * @since 6.9.0 + */ + public function tear_down(): void { + wp_delete_post( $this->public_post_id, true ); + update_option( 'show_avatars', $this->show_avatars ); + wp_set_current_user( 0 ); + + parent::tear_down(); + } + + /** + * Registers the core/users ability inside a faked init action. + * + * @since 6.9.0 + */ + private function register_core_users_ability(): void { + if ( wp_has_ability( 'core/users' ) ) { + wp_unregister_ability( 'core/users' ); + } + + 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 { + WP_Users_Abilities::register(); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * The ability is registered in the user category and flagged read-only. + * + * @ticket 64146 + */ + public function test_core_users_ability_is_registered(): void { + $ability = wp_get_ability( 'core/users' ); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertSame( 'user', $ability->get_category() ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); + $this->assertTrue( $ability->get_meta_item( 'pagination', false ) ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertTrue( $annotations['readonly'] ); + $this->assertFalse( $annotations['destructive'] ); + } + + /** + * The input schema exposes strict single-user and collection modes. + * + * @ticket 64146 + */ + public function test_core_users_input_schema_exposes_strict_modes(): void { + $schema = wp_get_ability( 'core/users' )->get_input_schema(); + + $this->assertSame( 'object', $schema['type'] ); + $this->assertArrayNotHasKey( 'default', $schema ); + $this->assertCount( 5, $schema['oneOf'] ); + + $this->assertSame( array( 'id' ), $schema['oneOf'][0]['required'] ); + $this->assertSame( array( 'email' ), $schema['oneOf'][1]['required'] ); + $this->assertSame( array( 'username' ), $schema['oneOf'][2]['required'] ); + $this->assertSame( array( 'slug' ), $schema['oneOf'][3]['required'] ); + $this->assertArrayNotHasKey( 'required', $schema['oneOf'][4] ); + + $collection_properties = $schema['oneOf'][4]['properties']; + $this->assertSameSets( + array( 'roles', 'has_published_posts', 'fields', 'page', 'per_page' ), + array_keys( $collection_properties ) + ); + foreach ( array( 'search', 'include', 'exclude', 'slug', 'order', 'orderby', 'search_columns', 'offset', 'context', 'who', 'capabilities' ) as $excluded_property ) { + $this->assertArrayNotHasKey( $excluded_property, $collection_properties ); + } + + $fields = $schema['oneOf'][4]['properties']['fields']['items']['enum']; + $this->assertContains( 'roles', $fields ); + $this->assertContains( 'avatar_urls', $fields ); + } + + /** + * Avatar fields are not requestable when avatars are disabled. + * + * @ticket 64146 + */ + public function test_avatar_urls_respects_show_avatars_option(): void { + update_option( 'show_avatars', 0 ); + $this->register_core_users_ability(); + + $ability = wp_get_ability( 'core/users' ); + $input_schema = $ability->get_input_schema(); + $output_schema = $ability->get_output_schema(); + + $this->assertNotContains( 'avatar_urls', $input_schema['oneOf'][0]['properties']['fields']['items']['enum'] ); + $this->assertArrayNotHasKey( 'avatar_urls', $output_schema['properties']['users']['items']['properties'] ); + + wp_set_current_user( $this->subscriber_id ); + $result = $ability->execute( array( 'id' => $this->subscriber_id ) ); + + $this->assertIsArray( $result ); + $this->assertArrayNotHasKey( 'avatar_urls', $result['users'][0] ); + } + + /** + * Logged-out users cannot run the ability. + * + * @ticket 64146 + */ + public function test_core_users_requires_logged_in_user(): void { + $result = wp_get_ability( 'core/users' )->execute( array() ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + /** + * The current user can read themselves by ID, email, and username. + * + * @ticket 64146 + */ + public function test_current_user_can_read_themselves_by_sensitive_identifiers(): void { + wp_set_current_user( $this->subscriber_id ); + + $ability = wp_get_ability( 'core/users' ); + + $result = $ability->execute( + array( + 'id' => $this->subscriber_id, + 'fields' => array( 'id', 'email', 'username', 'roles' ), + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( $this->subscriber_id, $result['users'][0]['id'] ); + $this->assertSame( 'core-users-ability-subscriber@example.com', $result['users'][0]['email'] ); + $this->assertSame( 'core_users_ability_subscriber', $result['users'][0]['username'] ); + $this->assertContains( 'subscriber', $result['users'][0]['roles'] ); + + $result = $ability->execute( array( 'email' => 'core-users-ability-subscriber@example.com' ) ); + $this->assertIsArray( $result ); + $this->assertSame( $this->subscriber_id, $result['users'][0]['id'] ); + + $result = $ability->execute( array( 'username' => 'core_users_ability_subscriber' ) ); + $this->assertIsArray( $result ); + $this->assertSame( $this->subscriber_id, $result['users'][0]['id'] ); + } + + /** + * Public-author users can be read by ID or slug by logged-in users. + * + * @ticket 64146 + */ + public function test_public_author_can_be_read_by_id_and_slug(): void { + wp_set_current_user( $this->subscriber_id ); + + $ability = wp_get_ability( 'core/users' ); + + $result = $ability->execute( + array( + 'id' => $this->public_author_id, + 'fields' => array( 'id', 'slug', 'email' ), + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( $this->public_author_id, $result['users'][0]['id'] ); + $this->assertSame( 'core-users-ability-author', $result['users'][0]['slug'] ); + $this->assertArrayNotHasKey( 'email', $result['users'][0] ); + + $result = $ability->execute( array( 'slug' => 'core-users-ability-author' ) ); + + $this->assertIsArray( $result ); + $this->assertSame( $this->public_author_id, $result['users'][0]['id'] ); + } + + /** + * Email and username lookups for another user require list or edit permissions. + * + * @ticket 64146 + */ + public function test_email_and_username_lookup_for_another_user_requires_permission(): void { + wp_set_current_user( $this->subscriber_id ); + + $ability = wp_get_ability( 'core/users' ); + + $result = $ability->execute( array( 'email' => 'core-users-ability-author@example.com' ) ); + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + + $result = $ability->execute( array( 'username' => 'core_users_ability_author' ) ); + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + + wp_set_current_user( $this->admin_id ); + + $result = $ability->execute( array( 'email' => 'core-users-ability-author@example.com' ) ); + $this->assertIsArray( $result ); + $this->assertSame( $this->public_author_id, $result['users'][0]['id'] ); + + $result = $ability->execute( array( 'username' => 'core_users_ability_author' ) ); + $this->assertIsArray( $result ); + $this->assertSame( $this->public_author_id, $result['users'][0]['id'] ); + } + + /** + * Empty collection mode returns only public authors for users without list_users. + * + * @ticket 64146 + */ + public function test_empty_collection_mode_restricts_users_without_list_users_to_public_authors(): void { + wp_set_current_user( $this->subscriber_id ); + + $result = wp_get_ability( 'core/users' )->execute( array() ); + + $this->assertIsArray( $result ); + $this->assertContains( $this->public_author_id, wp_list_pluck( $result['users'], 'id' ) ); + $this->assertNotContains( $this->admin_id, wp_list_pluck( $result['users'], 'id' ) ); + $this->assertNotContains( $this->subscriber_id, wp_list_pluck( $result['users'], 'id' ) ); + $this->assertIsInt( $result['total'] ); + $this->assertIsInt( $result['total_pages'] ); + } + + /** + * Administrators can query by role and receive roles. + * + * @ticket 64146 + */ + public function test_admin_can_query_by_role_and_receive_roles(): void { + wp_set_current_user( $this->admin_id ); + + $result = wp_get_ability( 'core/users' )->execute( + array( + 'roles' => array( 'author' ), + 'fields' => array( 'id', 'roles' ), + 'per_page' => 100, + ) + ); + + $this->assertIsArray( $result ); + $this->assertContains( $this->public_author_id, wp_list_pluck( $result['users'], 'id' ) ); + foreach ( $result['users'] as $user ) { + $this->assertContains( 'author', $user['roles'] ); + } + } + + /** + * Role filtering requires list_users. + * + * @ticket 64146 + */ + public function test_role_filter_requires_list_users(): void { + wp_set_current_user( $this->subscriber_id ); + + $result = wp_get_ability( 'core/users' )->execute( array( 'roles' => array( 'author' ) ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + /** + * Restricted fields are omitted per user instead of failing the whole result. + * + * @ticket 64146 + */ + public function test_restricted_requested_fields_are_omitted_per_user(): void { + wp_set_current_user( $this->subscriber_id ); + + $result = wp_get_ability( 'core/users' )->execute( + array( + 'id' => $this->public_author_id, + 'fields' => array( 'id', 'email', 'roles' ), + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( array( 'id' ), array_keys( $result['users'][0] ) ); + $this->assertSame( $this->public_author_id, $result['users'][0]['id'] ); + } +} From 3479b4302d8ac2baa26d0eb0a59474648bb75615 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 25 Jun 2026 15:01:00 +0100 Subject: [PATCH 03/10] Tests: Avoid duplicate core ability registration --- .../tests/abilities-api/wpRegisterCoreUsersAbility.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php index 111804f632347..604a37223f90a 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php @@ -59,6 +59,13 @@ class Tests_Abilities_API_WpRegisterCoreUsersAbility extends WP_UnitTestCase { public static function set_up_before_class(): void { parent::set_up_before_class(); + 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() ); + } + remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); From 40d717125d467e1e92e9b4cfd8f886d183aa2ed1 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 25 Jun 2026 15:37:37 +0100 Subject: [PATCH 04/10] Abilities: Default core users input to collection mode --- src/wp-includes/abilities/class-wp-users-abilities.php | 5 +++-- .../tests/abilities-api/wpRegisterCoreUsersAbility.php | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-users-abilities.php b/src/wp-includes/abilities/class-wp-users-abilities.php index ac94b5fe80470..d9f4a6c69386f 100644 --- a/src/wp-includes/abilities/class-wp-users-abilities.php +++ b/src/wp-includes/abilities/class-wp-users-abilities.php @@ -498,8 +498,9 @@ private static function get_users_input_schema(): array { ); return array( - 'type' => 'object', - 'oneOf' => array( + 'type' => 'object', + 'default' => (object) array(), + 'oneOf' => array( array( 'title' => __( 'Get a single readable user by ID' ), 'required' => array( 'id' ), diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php index 604a37223f90a..f1688f60167e1 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php @@ -202,7 +202,7 @@ public function test_core_users_input_schema_exposes_strict_modes(): void { $schema = wp_get_ability( 'core/users' )->get_input_schema(); $this->assertSame( 'object', $schema['type'] ); - $this->assertArrayNotHasKey( 'default', $schema ); + $this->assertEquals( (object) array(), $schema['default'] ); $this->assertCount( 5, $schema['oneOf'] ); $this->assertSame( array( 'id' ), $schema['oneOf'][0]['required'] ); From 1b823bd3d655b18242d2048d8786566ca5702306 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 25 Jun 2026 15:56:03 +0100 Subject: [PATCH 05/10] Abilities: Use WP_User field names for core users --- .../abilities/class-wp-users-abilities.php | 92 +++++++++---------- .../wpRegisterCoreUsersAbility.php | 64 ++++++++----- 2 files changed, 85 insertions(+), 71 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-users-abilities.php b/src/wp-includes/abilities/class-wp-users-abilities.php index d9f4a6c69386f..f067f031c4c06 100644 --- a/src/wp-includes/abilities/class-wp-users-abilities.php +++ b/src/wp-includes/abilities/class-wp-users-abilities.php @@ -53,9 +53,9 @@ class WP_Users_Abilities { 'id', 'display_name', 'description', - 'url', + 'user_url', 'link', - 'slug', + 'user_nicename', ); /** @@ -65,13 +65,13 @@ class WP_Users_Abilities { * @var string[] */ private static $sensitive_fields = array( - 'username', - 'email', + 'user_login', + 'user_email', 'first_name', 'last_name', 'nickname', 'locale', - 'registered_date', + 'user_registered', ); /** @@ -93,7 +93,7 @@ private static function register_get_users(): void { 'core/users', array( 'label' => __( 'Get Users' ), - 'description' => __( 'Retrieves one or more readable WordPress users. Fetch a single readable user by ID, email, username, or slug, or query a paginated collection optionally filtered by roles or published-post authorship.' ), + 'description' => __( 'Retrieves one or more readable WordPress users. Fetch a single readable user by ID, user email, user login, or user nicename, or query a paginated collection optionally filtered by roles or published-post authorship.' ), 'category' => self::CATEGORY, 'input_schema' => self::get_users_input_schema(), 'output_schema' => self::get_users_output_schema(), @@ -236,7 +236,7 @@ private static function input_int( $value ): int { * @return string The lookup type, or an empty string for collection mode. */ private static function get_lookup_type( array $input ): string { - foreach ( array( 'id', 'email', 'username', 'slug' ) as $key ) { + foreach ( array( 'id', 'user_email', 'user_login', 'user_nicename' ) as $key ) { if ( array_key_exists( $key, $input ) ) { return $key; } @@ -259,18 +259,18 @@ private static function find_user( array $input ): ?WP_User { return $user instanceof WP_User ? $user : null; } - if ( isset( $input['email'] ) && is_string( $input['email'] ) ) { - $user = get_user_by( 'email', sanitize_email( $input['email'] ) ); + if ( isset( $input['user_email'] ) && is_string( $input['user_email'] ) ) { + $user = get_user_by( 'email', sanitize_email( $input['user_email'] ) ); return $user instanceof WP_User ? $user : null; } - if ( isset( $input['username'] ) && is_string( $input['username'] ) ) { - $user = get_user_by( 'login', $input['username'] ); + if ( isset( $input['user_login'] ) && is_string( $input['user_login'] ) ) { + $user = get_user_by( 'login', $input['user_login'] ); return $user instanceof WP_User ? $user : null; } - if ( isset( $input['slug'] ) && is_string( $input['slug'] ) ) { - $user = get_user_by( 'slug', sanitize_title( $input['slug'] ) ); + if ( isset( $input['user_nicename'] ) && is_string( $input['user_nicename'] ) ) { + $user = get_user_by( 'slug', sanitize_title( $input['user_nicename'] ) ); return $user instanceof WP_User ? $user : null; } @@ -292,7 +292,7 @@ private static function is_user_member_of_site( WP_User $user ): bool { /** * Checks whether a single-user lookup may return the target user. * - * Email and username are identifier-sensitive lookup modes and do not use the + * User email and login are identifier-sensitive lookup modes and do not use the * public-author fallback. * * @since 6.9.0 @@ -310,7 +310,7 @@ private static function can_read_user_for_lookup( WP_User $user, string $lookup_ return true; } - if ( 'email' === $lookup_type || 'username' === $lookup_type ) { + if ( 'user_email' === $lookup_type || 'user_login' === $lookup_type ) { return false; } @@ -516,39 +516,39 @@ private static function get_users_input_schema(): array { ), array( 'title' => __( 'Get a single readable user by email address' ), - 'required' => array( 'email' ), + 'required' => array( 'user_email' ), 'additionalProperties' => false, 'properties' => array( - 'email' => array( + 'user_email' => array( 'type' => 'string', 'format' => 'email', 'description' => __( 'Retrieve a single readable user by email address. Resolving another user by email requires permission to list or edit users.' ), ), - 'fields' => $fields, + 'fields' => $fields, ), ), array( - 'title' => __( 'Get a single readable user by username' ), - 'required' => array( 'username' ), + 'title' => __( 'Get a single readable user by login' ), + 'required' => array( 'user_login' ), 'additionalProperties' => false, 'properties' => array( - 'username' => array( + 'user_login' => array( 'type' => 'string', - 'description' => __( 'Retrieve a single readable user by username. Resolving another user by username requires permission to list or edit users.' ), + 'description' => __( 'Retrieve a single readable user by login. Resolving another user by login requires permission to list or edit users.' ), ), - 'fields' => $fields, + 'fields' => $fields, ), ), array( - 'title' => __( 'Get a single readable user by slug' ), - 'required' => array( 'slug' ), + 'title' => __( 'Get a single readable user by nicename' ), + 'required' => array( 'user_nicename' ), 'additionalProperties' => false, 'properties' => array( - 'slug' => array( + 'user_nicename' => array( 'type' => 'string', - 'description' => __( 'Retrieve a single readable user by slug.' ), + 'description' => __( 'Retrieve a single readable user by nicename.' ), ), - 'fields' => $fields, + 'fields' => $fields, ), ), array( @@ -620,7 +620,7 @@ private static function get_users_output_schema(): array { 'type' => 'string', 'description' => __( 'Description of the user.' ), ), - 'url' => array( + 'user_url' => array( 'type' => 'string', 'description' => __( 'URL of the user.' ), ), @@ -628,15 +628,15 @@ private static function get_users_output_schema(): array { 'type' => 'string', 'description' => __( 'Author archive URL for the user.' ), ), - 'slug' => array( + 'user_nicename' => array( 'type' => 'string', 'description' => __( 'An alphanumeric identifier for the user.' ), ), - 'username' => array( + 'user_login' => array( 'type' => 'string', 'description' => __( 'Login name for the user. Present when the current user can view it.' ), ), - 'email' => array( + 'user_email' => array( 'type' => 'string', 'format' => 'email', 'description' => __( 'The email address for the user. Present when the current user can view it.' ), @@ -657,10 +657,9 @@ private static function get_users_output_schema(): array { 'type' => 'string', 'description' => __( 'Locale for the user. Present when the current user can view it.' ), ), - 'registered_date' => array( + 'user_registered' => array( 'type' => 'string', - 'format' => 'date-time', - 'description' => __( 'Registration date for the user in ISO 8601 format. Present when the current user can view it.' ), + 'description' => __( 'Registration date for the user. Present when the current user can view it.' ), ), 'roles' => array( 'type' => 'array', @@ -736,25 +735,25 @@ private static function format_user( WP_User $user, array $fields ): array { if ( $fields_requested( 'description' ) ) { $data['description'] = (string) $user->description; } - if ( $fields_requested( 'url' ) ) { - $data['url'] = (string) $user->user_url; + if ( $fields_requested( 'user_url' ) ) { + $data['user_url'] = (string) $user->user_url; } if ( $fields_requested( 'link' ) ) { $data['link'] = (string) get_author_posts_url( $user_id, $user->user_nicename ); } - if ( $fields_requested( 'slug' ) ) { - $data['slug'] = (string) $user->user_nicename; + if ( $fields_requested( 'user_nicename' ) ) { + $data['user_nicename'] = (string) $user->user_nicename; } if ( $fields_requested( 'avatar_urls' ) && get_option( 'show_avatars' ) ) { $data['avatar_urls'] = rest_get_avatar_urls( $user ); } if ( $can_view_sensitive ) { - if ( $fields_requested( 'username' ) ) { - $data['username'] = (string) $user->user_login; + if ( $fields_requested( 'user_login' ) ) { + $data['user_login'] = (string) $user->user_login; } - if ( $fields_requested( 'email' ) ) { - $data['email'] = (string) $user->user_email; + if ( $fields_requested( 'user_email' ) ) { + $data['user_email'] = (string) $user->user_email; } if ( $fields_requested( 'first_name' ) ) { $data['first_name'] = (string) $user->first_name; @@ -768,11 +767,8 @@ private static function format_user( WP_User $user, array $fields ): array { if ( $fields_requested( 'locale' ) ) { $data['locale'] = (string) get_user_locale( $user ); } - if ( $fields_requested( 'registered_date' ) ) { - $registered_timestamp = strtotime( (string) $user->user_registered ); - if ( false !== $registered_timestamp ) { - $data['registered_date'] = gmdate( 'c', $registered_timestamp ); - } + if ( $fields_requested( 'user_registered' ) ) { + $data['user_registered'] = (string) $user->user_registered; } } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php index f1688f60167e1..f40f8f8e117a5 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php @@ -206,9 +206,9 @@ public function test_core_users_input_schema_exposes_strict_modes(): void { $this->assertCount( 5, $schema['oneOf'] ); $this->assertSame( array( 'id' ), $schema['oneOf'][0]['required'] ); - $this->assertSame( array( 'email' ), $schema['oneOf'][1]['required'] ); - $this->assertSame( array( 'username' ), $schema['oneOf'][2]['required'] ); - $this->assertSame( array( 'slug' ), $schema['oneOf'][3]['required'] ); + $this->assertSame( array( 'user_email' ), $schema['oneOf'][1]['required'] ); + $this->assertSame( array( 'user_login' ), $schema['oneOf'][2]['required'] ); + $this->assertSame( array( 'user_nicename' ), $schema['oneOf'][3]['required'] ); $this->assertArrayNotHasKey( 'required', $schema['oneOf'][4] ); $collection_properties = $schema['oneOf'][4]['properties']; @@ -216,7 +216,25 @@ public function test_core_users_input_schema_exposes_strict_modes(): void { array( 'roles', 'has_published_posts', 'fields', 'page', 'per_page' ), array_keys( $collection_properties ) ); - foreach ( array( 'search', 'include', 'exclude', 'slug', 'order', 'orderby', 'search_columns', 'offset', 'context', 'who', 'capabilities' ) as $excluded_property ) { + $excluded_properties = array( + 'search', + 'include', + 'exclude', + 'email', + 'username', + 'slug', + 'user_email', + 'user_login', + 'user_nicename', + 'order', + 'orderby', + 'search_columns', + 'offset', + 'context', + 'who', + 'capabilities', + ); + foreach ( $excluded_properties as $excluded_property ) { $this->assertArrayNotHasKey( $excluded_property, $collection_properties ); } @@ -261,7 +279,7 @@ public function test_core_users_requires_logged_in_user(): void { } /** - * The current user can read themselves by ID, email, and username. + * The current user can read themselves by ID, user email, and user login. * * @ticket 64146 */ @@ -273,31 +291,31 @@ public function test_current_user_can_read_themselves_by_sensitive_identifiers() $result = $ability->execute( array( 'id' => $this->subscriber_id, - 'fields' => array( 'id', 'email', 'username', 'roles' ), + 'fields' => array( 'id', 'user_email', 'user_login', 'roles' ), ) ); $this->assertIsArray( $result ); $this->assertSame( $this->subscriber_id, $result['users'][0]['id'] ); - $this->assertSame( 'core-users-ability-subscriber@example.com', $result['users'][0]['email'] ); - $this->assertSame( 'core_users_ability_subscriber', $result['users'][0]['username'] ); + $this->assertSame( 'core-users-ability-subscriber@example.com', $result['users'][0]['user_email'] ); + $this->assertSame( 'core_users_ability_subscriber', $result['users'][0]['user_login'] ); $this->assertContains( 'subscriber', $result['users'][0]['roles'] ); - $result = $ability->execute( array( 'email' => 'core-users-ability-subscriber@example.com' ) ); + $result = $ability->execute( array( 'user_email' => 'core-users-ability-subscriber@example.com' ) ); $this->assertIsArray( $result ); $this->assertSame( $this->subscriber_id, $result['users'][0]['id'] ); - $result = $ability->execute( array( 'username' => 'core_users_ability_subscriber' ) ); + $result = $ability->execute( array( 'user_login' => 'core_users_ability_subscriber' ) ); $this->assertIsArray( $result ); $this->assertSame( $this->subscriber_id, $result['users'][0]['id'] ); } /** - * Public-author users can be read by ID or slug by logged-in users. + * Public-author users can be read by ID or user nicename by logged-in users. * * @ticket 64146 */ - public function test_public_author_can_be_read_by_id_and_slug(): void { + public function test_public_author_can_be_read_by_id_and_user_nicename(): void { wp_set_current_user( $this->subscriber_id ); $ability = wp_get_ability( 'core/users' ); @@ -305,46 +323,46 @@ public function test_public_author_can_be_read_by_id_and_slug(): void { $result = $ability->execute( array( 'id' => $this->public_author_id, - 'fields' => array( 'id', 'slug', 'email' ), + 'fields' => array( 'id', 'user_nicename', 'user_email' ), ) ); $this->assertIsArray( $result ); $this->assertSame( $this->public_author_id, $result['users'][0]['id'] ); - $this->assertSame( 'core-users-ability-author', $result['users'][0]['slug'] ); - $this->assertArrayNotHasKey( 'email', $result['users'][0] ); + $this->assertSame( 'core-users-ability-author', $result['users'][0]['user_nicename'] ); + $this->assertArrayNotHasKey( 'user_email', $result['users'][0] ); - $result = $ability->execute( array( 'slug' => 'core-users-ability-author' ) ); + $result = $ability->execute( array( 'user_nicename' => 'core-users-ability-author' ) ); $this->assertIsArray( $result ); $this->assertSame( $this->public_author_id, $result['users'][0]['id'] ); } /** - * Email and username lookups for another user require list or edit permissions. + * User email and login lookups for another user require list or edit permissions. * * @ticket 64146 */ - public function test_email_and_username_lookup_for_another_user_requires_permission(): void { + public function test_user_email_and_login_lookup_for_another_user_requires_permission(): void { wp_set_current_user( $this->subscriber_id ); $ability = wp_get_ability( 'core/users' ); - $result = $ability->execute( array( 'email' => 'core-users-ability-author@example.com' ) ); + $result = $ability->execute( array( 'user_email' => 'core-users-ability-author@example.com' ) ); $this->assertWPError( $result ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); - $result = $ability->execute( array( 'username' => 'core_users_ability_author' ) ); + $result = $ability->execute( array( 'user_login' => 'core_users_ability_author' ) ); $this->assertWPError( $result ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); wp_set_current_user( $this->admin_id ); - $result = $ability->execute( array( 'email' => 'core-users-ability-author@example.com' ) ); + $result = $ability->execute( array( 'user_email' => 'core-users-ability-author@example.com' ) ); $this->assertIsArray( $result ); $this->assertSame( $this->public_author_id, $result['users'][0]['id'] ); - $result = $ability->execute( array( 'username' => 'core_users_ability_author' ) ); + $result = $ability->execute( array( 'user_login' => 'core_users_ability_author' ) ); $this->assertIsArray( $result ); $this->assertSame( $this->public_author_id, $result['users'][0]['id'] ); } @@ -415,7 +433,7 @@ public function test_restricted_requested_fields_are_omitted_per_user(): void { $result = wp_get_ability( 'core/users' )->execute( array( 'id' => $this->public_author_id, - 'fields' => array( 'id', 'email', 'roles' ), + 'fields' => array( 'id', 'user_email', 'roles' ), ) ); From f3e5a847572ded3eb960e57ad47e5b64eb909be2 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 25 Jun 2026 16:46:31 +0100 Subject: [PATCH 06/10] Abilities API: Use public post types for users --- .../abilities/class-wp-users-abilities.php | 31 ++++++-- .../wpRegisterCoreUsersAbility.php | 78 +++++++++++++++++++ 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-users-abilities.php b/src/wp-includes/abilities/class-wp-users-abilities.php index f067f031c4c06..0d5bc51a4aa77 100644 --- a/src/wp-includes/abilities/class-wp-users-abilities.php +++ b/src/wp-includes/abilities/class-wp-users-abilities.php @@ -192,7 +192,22 @@ public static function execute_get_users( $input = array() ) { $query_args['has_published_posts'] = $has_published_posts; } } else { - $query_args['has_published_posts'] = self::get_public_author_post_types(); + $public_post_types = self::get_public_post_types(); + $has_published_posts = self::normalize_has_published_posts( $input ); + + if ( is_array( $has_published_posts ) ) { + $public_post_types = array_values( array_intersect( $public_post_types, $has_published_posts ) ); + } + + if ( array() === $public_post_types ) { + return array( + 'users' => array(), + 'total' => 0, + 'total_pages' => 0, + ); + } + + $query_args['has_published_posts'] = $public_post_types; } $query = new WP_User_Query( $query_args ); @@ -345,7 +360,7 @@ private static function is_current_user( WP_User $user ): bool { } /** - * Checks whether a user has published posts in REST-visible author post types. + * Checks whether a user has published posts in public post types. * * @since 6.9.0 * @@ -353,7 +368,7 @@ private static function is_current_user( WP_User $user ): bool { * @return bool Whether the user is publicly visible as an author. */ private static function is_public_author( WP_User $user ): bool { - $post_types = self::get_public_author_post_types(); + $post_types = self::get_public_post_types(); if ( array() === $post_types ) { return false; } @@ -362,17 +377,17 @@ private static function is_public_author( WP_User $user ): bool { } /** - * Returns REST-visible post types that support authors. + * Returns public post types. * * @since 6.9.0 * - * @return string[] REST-visible author post type names. + * @return string[] Public post type names. */ - private static function get_public_author_post_types(): array { + private static function get_public_post_types(): array { $post_types = array(); - foreach ( get_post_types( array( 'show_in_rest' => true ), 'names' ) as $post_type ) { - if ( is_string( $post_type ) && post_type_supports( $post_type, 'author' ) ) { + foreach ( get_post_types( array( 'public' => true ), 'names' ) as $post_type ) { + if ( is_string( $post_type ) ) { $post_types[] = $post_type; } } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php index f40f8f8e117a5..7216a162f9b38 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php @@ -385,6 +385,84 @@ public function test_empty_collection_mode_restricts_users_without_list_users_to $this->assertIsInt( $result['total_pages'] ); } + /** + * Collection mode for users without list_users honors public post type filters. + * + * @ticket 64146 + */ + public function test_collection_mode_for_users_without_list_users_uses_public_post_types(): void { + register_post_type( + 'wp_public_pt', + array( + 'public' => true, + 'show_in_rest' => false, + ) + ); + register_post_type( + 'wp_private_pt', + array( + 'public' => false, + ) + ); + + $public_author_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $private_author_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $public_post_id = self::factory()->post->create( + array( + 'post_author' => $public_author_id, + 'post_status' => 'publish', + 'post_type' => 'wp_public_pt', + ) + ); + $private_post_id = self::factory()->post->create( + array( + 'post_author' => $private_author_id, + 'post_status' => 'publish', + 'post_type' => 'wp_private_pt', + ) + ); + + try { + $this->assertFalse( get_post_type_object( 'wp_public_pt' )->show_in_rest ); + + wp_set_current_user( $this->subscriber_id ); + + $ability = wp_get_ability( 'core/users' ); + $result = $ability->execute( + array( + 'has_published_posts' => array( 'wp_public_pt' ), + 'fields' => array( 'id' ), + 'per_page' => 100, + ) + ); + + $this->assertIsArray( $result ); + $ids = wp_list_pluck( $result['users'], 'id' ); + $this->assertContains( $public_author_id, $ids ); + $this->assertNotContains( $this->public_author_id, $ids ); + $this->assertNotContains( $private_author_id, $ids ); + + $result = $ability->execute( + array( + 'has_published_posts' => array( 'wp_private_pt' ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( array(), $result['users'] ); + $this->assertSame( 0, $result['total'] ); + $this->assertSame( 0, $result['total_pages'] ); + } finally { + wp_delete_post( $public_post_id, true ); + wp_delete_post( $private_post_id, true ); + wp_delete_user( $public_author_id ); + wp_delete_user( $private_author_id ); + unregister_post_type( 'wp_public_pt' ); + unregister_post_type( 'wp_private_pt' ); + } + } + /** * Administrators can query by role and receive roles. * From da40e79b9c74ed24cf64fef13ae61c716fc41181 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 25 Jun 2026 16:53:38 +0100 Subject: [PATCH 07/10] Abilities API: Align users lookup handling --- .../abilities/class-wp-users-abilities.php | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-users-abilities.php b/src/wp-includes/abilities/class-wp-users-abilities.php index 0d5bc51a4aa77..17309a337fc79 100644 --- a/src/wp-includes/abilities/class-wp-users-abilities.php +++ b/src/wp-includes/abilities/class-wp-users-abilities.php @@ -269,22 +269,34 @@ private static function get_lookup_type( array $input ): string { * @return WP_User|null User object, or null when not found. */ private static function find_user( array $input ): ?WP_User { - if ( isset( $input['id'] ) ) { + if ( array_key_exists( 'id', $input ) ) { $user = get_userdata( self::input_int( $input['id'] ) ); return $user instanceof WP_User ? $user : null; } - if ( isset( $input['user_email'] ) && is_string( $input['user_email'] ) ) { + if ( array_key_exists( 'user_email', $input ) ) { + if ( ! is_string( $input['user_email'] ) ) { + return null; + } + $user = get_user_by( 'email', sanitize_email( $input['user_email'] ) ); return $user instanceof WP_User ? $user : null; } - if ( isset( $input['user_login'] ) && is_string( $input['user_login'] ) ) { + if ( array_key_exists( 'user_login', $input ) ) { + if ( ! is_string( $input['user_login'] ) ) { + return null; + } + $user = get_user_by( 'login', $input['user_login'] ); return $user instanceof WP_User ? $user : null; } - if ( isset( $input['user_nicename'] ) && is_string( $input['user_nicename'] ) ) { + if ( array_key_exists( 'user_nicename', $input ) ) { + if ( ! is_string( $input['user_nicename'] ) ) { + return null; + } + $user = get_user_by( 'slug', sanitize_title( $input['user_nicename'] ) ); return $user instanceof WP_User ? $user : null; } From aec66a2bb741a3efa57b188cb51676c8ee792653 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 25 Jun 2026 17:06:12 +0100 Subject: [PATCH 08/10] Abilities API: Match users class to settings pattern --- src/wp-includes/abilities.php | 2 +- .../abilities/class-wp-users-abilities.php | 129 +++++++++--------- .../wpRegisterCoreUsersAbility.php | 13 +- 3 files changed, 78 insertions(+), 66 deletions(-) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 185098bb09498..dd6a783bb8a32 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -45,7 +45,7 @@ function wp_register_core_abilities(): void { $category_site = 'site'; $category_user = 'user'; - WP_Users_Abilities::register(); + ( new WP_Users_Abilities() )->register(); $site_info_properties = array( 'name' => array( diff --git a/src/wp-includes/abilities/class-wp-users-abilities.php b/src/wp-includes/abilities/class-wp-users-abilities.php index 17309a337fc79..9170b3a12357e 100644 --- a/src/wp-includes/abilities/class-wp-users-abilities.php +++ b/src/wp-includes/abilities/class-wp-users-abilities.php @@ -17,7 +17,7 @@ * @since 6.9.0 * @access private */ -class WP_Users_Abilities { +final class WP_Users_Abilities { /** * The ability category used for user abilities. @@ -49,7 +49,7 @@ class WP_Users_Abilities { * @since 6.9.0 * @var string[] */ - private static $read_fields = array( + private $read_fields = array( 'id', 'display_name', 'description', @@ -64,7 +64,7 @@ class WP_Users_Abilities { * @since 6.9.0 * @var string[] */ - private static $sensitive_fields = array( + private $sensitive_fields = array( 'user_login', 'user_email', 'first_name', @@ -79,8 +79,8 @@ class WP_Users_Abilities { * * @since 6.9.0 */ - public static function register(): void { - self::register_get_users(); + public function register(): void { + $this->register_get_users(); } /** @@ -88,17 +88,17 @@ public static function register(): void { * * @since 6.9.0 */ - private static function register_get_users(): void { + private function register_get_users(): void { wp_register_ability( 'core/users', array( 'label' => __( 'Get Users' ), 'description' => __( 'Retrieves one or more readable WordPress users. Fetch a single readable user by ID, user email, user login, or user nicename, or query a paginated collection optionally filtered by roles or published-post authorship.' ), 'category' => self::CATEGORY, - 'input_schema' => self::get_users_input_schema(), - 'output_schema' => self::get_users_output_schema(), - 'execute_callback' => array( __CLASS__, 'execute_get_users' ), - 'permission_callback' => array( __CLASS__, 'check_permission' ), + 'input_schema' => $this->get_users_input_schema(), + 'output_schema' => $this->get_users_output_schema(), + 'execute_callback' => array( $this, 'execute_get_users' ), + 'permission_callback' => array( $this, 'check_permission' ), 'meta' => array( 'annotations' => array( 'readonly' => true, @@ -120,7 +120,7 @@ private static function register_get_users(): void { * @param mixed $input Optional. The ability input. Default empty array. * @return bool True if the request may proceed, false otherwise. */ - public static function check_permission( $input = array() ): bool { + public function check_permission( $input = array() ): bool { $input = is_array( $input ) ? $input : array(); if ( ! is_user_logged_in() ) { @@ -131,17 +131,17 @@ public static function check_permission( $input = array() ): bool { return false; } - $lookup_type = self::get_lookup_type( $input ); + $lookup_type = $this->get_lookup_type( $input ); if ( '' === $lookup_type ) { return true; } - $user = self::find_user( $input ); - if ( ! $user instanceof WP_User || ! self::is_user_member_of_site( $user ) ) { + $user = $this->find_user( $input ); + if ( ! $user instanceof WP_User || ! $this->is_user_member_of_site( $user ) ) { return false; } - return self::can_read_user_for_lookup( $user, $lookup_type ); + return $this->can_read_user_for_lookup( $user, $lookup_type ); } /** @@ -152,29 +152,29 @@ public static function check_permission( $input = array() ): bool { * @param mixed $input Optional. The ability input. Default empty array. * @return array|WP_Error A map with a `users` list, or a WP_Error on failure. */ - public static function execute_get_users( $input = array() ) { + public function execute_get_users( $input = array() ) { $input = is_array( $input ) ? $input : array(); - $fields = self::normalize_fields( $input ); + $fields = $this->normalize_fields( $input ); - $lookup_type = self::get_lookup_type( $input ); + $lookup_type = $this->get_lookup_type( $input ); if ( '' !== $lookup_type ) { - $user = self::find_user( $input ); + $user = $this->find_user( $input ); if ( ! $user instanceof WP_User - || ! self::is_user_member_of_site( $user ) - || ! self::can_read_user_for_lookup( $user, $lookup_type ) + || ! $this->is_user_member_of_site( $user ) + || ! $this->can_read_user_for_lookup( $user, $lookup_type ) ) { - return self::not_found_error(); + return $this->not_found_error(); } return array( - 'users' => array( self::format_user( $user, $fields ) ), + 'users' => array( $this->format_user( $user, $fields ) ), 'total' => 1, 'total_pages' => 1, ); } - $per_page = self::normalize_per_page( $input ); - $page = isset( $input['page'] ) ? max( 1, self::input_int( $input['page'] ) ) : 1; + $per_page = $this->normalize_per_page( $input ); + $page = isset( $input['page'] ) ? max( 1, $this->input_int( $input['page'] ) ) : 1; $query_args = array( 'number' => $per_page, @@ -183,17 +183,17 @@ public static function execute_get_users( $input = array() ) { ); if ( ! empty( $input['roles'] ) && current_user_can( 'list_users' ) ) { - $query_args['role__in'] = self::normalize_string_list( $input['roles'] ); + $query_args['role__in'] = $this->normalize_string_list( $input['roles'] ); } if ( current_user_can( 'list_users' ) ) { - $has_published_posts = self::normalize_has_published_posts( $input ); + $has_published_posts = $this->normalize_has_published_posts( $input ); if ( null !== $has_published_posts ) { $query_args['has_published_posts'] = $has_published_posts; } } else { - $public_post_types = self::get_public_post_types(); - $has_published_posts = self::normalize_has_published_posts( $input ); + $public_post_types = $this->get_public_post_types(); + $has_published_posts = $this->normalize_has_published_posts( $input ); if ( is_array( $has_published_posts ) ) { $public_post_types = array_values( array_intersect( $public_post_types, $has_published_posts ) ); @@ -214,11 +214,11 @@ public static function execute_get_users( $input = array() ) { $users = array(); foreach ( $query->get_results() as $user ) { - if ( ! $user instanceof WP_User || ! self::is_user_member_of_site( $user ) || ! self::can_read_user( $user ) ) { + if ( ! $user instanceof WP_User || ! $this->is_user_member_of_site( $user ) || ! $this->can_read_user( $user ) ) { continue; } - $users[] = self::format_user( $user, $fields ); + $users[] = $this->format_user( $user, $fields ); } $total_users = (int) $query->get_total(); @@ -238,7 +238,7 @@ public static function execute_get_users( $input = array() ) { * @param mixed $value The raw input value. * @return int The value as a non-negative integer, or 0 when not scalar. */ - private static function input_int( $value ): int { + private function input_int( $value ): int { return is_scalar( $value ) ? absint( $value ) : 0; } @@ -250,7 +250,7 @@ private static function input_int( $value ): int { * @param array $input The ability input. * @return string The lookup type, or an empty string for collection mode. */ - private static function get_lookup_type( array $input ): string { + private function get_lookup_type( array $input ): string { foreach ( array( 'id', 'user_email', 'user_login', 'user_nicename' ) as $key ) { if ( array_key_exists( $key, $input ) ) { return $key; @@ -268,9 +268,9 @@ private static function get_lookup_type( array $input ): string { * @param array $input The ability input. * @return WP_User|null User object, or null when not found. */ - private static function find_user( array $input ): ?WP_User { + private function find_user( array $input ): ?WP_User { if ( array_key_exists( 'id', $input ) ) { - $user = get_userdata( self::input_int( $input['id'] ) ); + $user = get_userdata( $this->input_int( $input['id'] ) ); return $user instanceof WP_User ? $user : null; } @@ -312,7 +312,7 @@ private static function find_user( array $input ): ?WP_User { * @param WP_User $user User object. * @return bool Whether the user belongs to the current site. */ - private static function is_user_member_of_site( WP_User $user ): bool { + private function is_user_member_of_site( WP_User $user ): bool { return ! is_multisite() || is_user_member_of_blog( (int) $user->ID ); } @@ -328,8 +328,8 @@ private static function is_user_member_of_site( WP_User $user ): bool { * @param string $lookup_type Lookup type. * @return bool Whether the user can be read for that lookup type. */ - private static function can_read_user_for_lookup( WP_User $user, string $lookup_type ): bool { - if ( self::is_current_user( $user ) ) { + private function can_read_user_for_lookup( WP_User $user, string $lookup_type ): bool { + if ( $this->is_current_user( $user ) ) { return true; } @@ -341,7 +341,7 @@ private static function can_read_user_for_lookup( WP_User $user, string $lookup_ return false; } - return self::is_public_author( $user ); + return $this->is_public_author( $user ); } /** @@ -352,11 +352,11 @@ private static function can_read_user_for_lookup( WP_User $user, string $lookup_ * @param WP_User $user User object. * @return bool Whether the user can be read. */ - private static function can_read_user( WP_User $user ): bool { - return self::is_current_user( $user ) + private function can_read_user( WP_User $user ): bool { + return $this->is_current_user( $user ) || current_user_can( 'edit_user', $user->ID ) || current_user_can( 'list_users' ) - || self::is_public_author( $user ); + || $this->is_public_author( $user ); } /** @@ -367,7 +367,7 @@ private static function can_read_user( WP_User $user ): bool { * @param WP_User $user User object. * @return bool Whether the current user is the target user. */ - private static function is_current_user( WP_User $user ): bool { + private function is_current_user( WP_User $user ): bool { return get_current_user_id() === (int) $user->ID; } @@ -379,8 +379,8 @@ private static function is_current_user( WP_User $user ): bool { * @param WP_User $user User object. * @return bool Whether the user is publicly visible as an author. */ - private static function is_public_author( WP_User $user ): bool { - $post_types = self::get_public_post_types(); + private function is_public_author( WP_User $user ): bool { + $post_types = $this->get_public_post_types(); if ( array() === $post_types ) { return false; } @@ -395,7 +395,7 @@ private static function is_public_author( WP_User $user ): bool { * * @return string[] Public post type names. */ - private static function get_public_post_types(): array { + private function get_public_post_types(): array { $post_types = array(); foreach ( get_post_types( array( 'public' => true ), 'names' ) as $post_type ) { @@ -415,8 +415,8 @@ private static function get_public_post_types(): array { * @param array $input The ability input. * @return string[] List of requested field names. */ - private static function normalize_fields( array $input ): array { - $available_fields = self::get_fields(); + private function normalize_fields( array $input ): array { + $available_fields = $this->get_fields(); if ( empty( $input['fields'] ) || ! is_array( $input['fields'] ) ) { return $available_fields; @@ -435,14 +435,14 @@ private static function normalize_fields( array $input ): array { * * @return string[] Supported field names. */ - private static function get_fields(): array { - $fields = self::$read_fields; + private function get_fields(): array { + $fields = $this->read_fields; if ( get_option( 'show_avatars' ) ) { $fields[] = 'avatar_urls'; } - return array_merge( $fields, self::$sensitive_fields, array( 'roles' ) ); + return array_merge( $fields, $this->sensitive_fields, array( 'roles' ) ); } /** @@ -453,8 +453,8 @@ private static function get_fields(): array { * @param array $input The ability input. * @return int The clamped per-page value. */ - private static function normalize_per_page( array $input ): int { - $per_page = isset( $input['per_page'] ) ? self::input_int( $input['per_page'] ) : self::DEFAULT_PER_PAGE; + private function normalize_per_page( array $input ): int { + $per_page = isset( $input['per_page'] ) ? $this->input_int( $input['per_page'] ) : self::DEFAULT_PER_PAGE; return max( 1, min( self::MAX_PER_PAGE, $per_page ) ); } @@ -467,7 +467,7 @@ private static function normalize_per_page( array $input ): int { * @param mixed $value Raw value. * @return string[] Normalized strings. */ - private static function normalize_string_list( $value ): array { + private function normalize_string_list( $value ): array { if ( ! is_array( $value ) ) { return array(); } @@ -492,7 +492,7 @@ private static function normalize_string_list( $value ): array { * @param array $input The ability input. * @return bool|string[]|null Normalized query value, or null when absent/invalid. */ - private static function normalize_has_published_posts( array $input ) { + private function normalize_has_published_posts( array $input ) { if ( ! array_key_exists( 'has_published_posts', $input ) ) { return null; } @@ -501,7 +501,7 @@ private static function normalize_has_published_posts( array $input ) { return true; } - $post_types = self::normalize_string_list( $input['has_published_posts'] ); + $post_types = $this->normalize_string_list( $input['has_published_posts'] ); return array() === $post_types ? null : $post_types; } @@ -513,13 +513,13 @@ private static function normalize_has_published_posts( array $input ) { * * @return array The input JSON Schema. */ - private static function get_users_input_schema(): array { + private function get_users_input_schema(): array { $fields = array( 'type' => 'array', 'uniqueItems' => true, 'items' => array( 'type' => 'string', - 'enum' => self::get_fields(), + 'enum' => $this->get_fields(), ), 'description' => __( 'Limit each returned user to these fields. If omitted, all fields visible to the current user are returned.' ), ); @@ -633,7 +633,7 @@ private static function get_users_input_schema(): array { * * @return array The output JSON Schema. */ - private static function get_users_output_schema(): array { + private function get_users_output_schema(): array { $user_properties = array( 'id' => array( 'type' => 'integer', @@ -686,6 +686,7 @@ private static function get_users_output_schema(): array { ), 'user_registered' => array( 'type' => 'string', + 'format' => 'date-time', 'description' => __( 'Registration date for the user. Present when the current user can view it.' ), ), 'roles' => array( @@ -742,13 +743,13 @@ private static function get_users_output_schema(): array { * @param string[] $fields The requested field names. * @return array The formatted user data. */ - private static function format_user( WP_User $user, array $fields ): array { + private function format_user( WP_User $user, array $fields ): array { $fields_requested = static function ( string $field ) use ( $fields ): bool { return in_array( $field, $fields, true ); }; $user_id = (int) $user->ID; - $can_view_sensitive = self::is_current_user( $user ) || current_user_can( 'edit_user', $user_id ); + $can_view_sensitive = $this->is_current_user( $user ) || current_user_can( 'edit_user', $user_id ); $can_view_roles = current_user_can( 'list_users' ) || current_user_can( 'edit_user', $user_id ); $data = array(); @@ -795,12 +796,12 @@ private static function format_user( WP_User $user, array $fields ): array { $data['locale'] = (string) get_user_locale( $user ); } if ( $fields_requested( 'user_registered' ) ) { - $data['user_registered'] = (string) $user->user_registered; + $data['user_registered'] = gmdate( 'c', strtotime( $user->user_registered ) ); } } if ( $fields_requested( 'roles' ) && $can_view_roles ) { - $data['roles'] = self::normalize_string_list( $user->roles ); + $data['roles'] = $this->normalize_string_list( $user->roles ); } return $data; @@ -813,7 +814,7 @@ private static function format_user( WP_User $user, array $fields ): array { * * @return WP_Error Not found error. */ - private static function not_found_error(): WP_Error { + private function not_found_error(): WP_Error { return new WP_Error( 'user_not_found', __( 'The requested user was not found.' ), diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php index 7216a162f9b38..417b3abe341b3 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php @@ -169,7 +169,7 @@ private function register_core_users_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 { - WP_Users_Abilities::register(); + ( new WP_Users_Abilities() )->register(); } finally { array_pop( $wp_current_filter ); } @@ -241,6 +241,10 @@ public function test_core_users_input_schema_exposes_strict_modes(): void { $fields = $schema['oneOf'][4]['properties']['fields']['items']['enum']; $this->assertContains( 'roles', $fields ); $this->assertContains( 'avatar_urls', $fields ); + + $output_schema = wp_get_ability( 'core/users' )->get_output_schema(); + $user_properties = $output_schema['properties']['users']['items']['properties']; + $this->assertSame( 'date-time', $user_properties['user_registered']['format'] ); } /** @@ -308,6 +312,13 @@ public function test_current_user_can_read_themselves_by_sensitive_identifiers() $result = $ability->execute( array( 'user_login' => 'core_users_ability_subscriber' ) ); $this->assertIsArray( $result ); $this->assertSame( $this->subscriber_id, $result['users'][0]['id'] ); + + $result = $ability->execute( array( 'id' => $this->subscriber_id, 'fields' => array( 'id', 'user_registered' ) ) ); + $this->assertIsArray( $result ); + $this->assertSame( + gmdate( 'c', strtotime( get_userdata( $this->subscriber_id )->user_registered ) ), + $result['users'][0]['user_registered'] + ); } /** From c231e9ee394717f3d9355a5dc8e6b166d67a11a8 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 25 Jun 2026 18:07:54 +0100 Subject: [PATCH 09/10] Abilities API: Sync users ability with plugin --- .../abilities/class-wp-users-abilities.php | 88 +++- .../wpRegisterCoreUsersAbility.php | 416 +++++++++++++----- 2 files changed, 378 insertions(+), 126 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-users-abilities.php b/src/wp-includes/abilities/class-wp-users-abilities.php index 9170b3a12357e..be850b6cc3052 100644 --- a/src/wp-includes/abilities/class-wp-users-abilities.php +++ b/src/wp-includes/abilities/class-wp-users-abilities.php @@ -74,6 +74,38 @@ final class WP_Users_Abilities { 'user_registered', ); + /** + * Cached public post type names. + * + * @since 6.9.0 + * @var string[]|null + */ + private $public_post_types = null; + + /** + * Cached supported field list. + * + * @since 6.9.0 + * @var string[]|null + */ + private $fields = null; + + /** + * Whether the cached supported field list includes avatars. + * + * @since 6.9.0 + * @var bool|null + */ + private $fields_include_avatars = null; + + /** + * Cached role names. + * + * @since 6.9.0 + * @var string[]|null + */ + private $role_names = null; + /** * Registers all user abilities. * @@ -396,15 +428,23 @@ private function is_public_author( WP_User $user ): bool { * @return string[] Public post type names. */ private function get_public_post_types(): array { + if ( null !== $this->public_post_types ) { + return $this->public_post_types; + } + $post_types = array(); foreach ( get_post_types( array( 'public' => true ), 'names' ) as $post_type ) { - if ( is_string( $post_type ) ) { - $post_types[] = $post_type; + if ( ! is_string( $post_type ) ) { + continue; } + + $post_types[] = $post_type; } - return $post_types; + $this->public_post_types = $post_types; + + return $this->public_post_types; } /** @@ -436,13 +476,39 @@ private function normalize_fields( array $input ): array { * @return string[] Supported field names. */ private function get_fields(): array { + $include_avatars = (bool) get_option( 'show_avatars' ); + + if ( null !== $this->fields && $include_avatars === $this->fields_include_avatars ) { + return $this->fields; + } + $fields = $this->read_fields; - if ( get_option( 'show_avatars' ) ) { + if ( $include_avatars ) { $fields[] = 'avatar_urls'; } - return array_merge( $fields, $this->sensitive_fields, array( 'roles' ) ); + $this->fields = array_merge( $fields, $this->sensitive_fields, array( 'roles' ) ); + $this->fields_include_avatars = $include_avatars; + + return $this->fields; + } + + /** + * Returns registered role names. + * + * @since 6.9.0 + * + * @return string[] Role names. + */ + private function get_role_names(): array { + if ( null !== $this->role_names ) { + return $this->role_names; + } + + $this->role_names = array_keys( wp_roles()->roles ); + + return $this->role_names; } /** @@ -514,7 +580,9 @@ private function normalize_has_published_posts( array $input ) { * @return array The input JSON Schema. */ private function get_users_input_schema(): array { - $fields = array( + $role_names = $this->get_role_names(); + $public_post_types = $this->get_public_post_types(); + $fields = array( 'type' => 'array', 'uniqueItems' => true, 'items' => array( @@ -588,6 +656,7 @@ private function get_users_input_schema(): array { 'minItems' => 1, 'items' => array( 'type' => 'string', + 'enum' => $role_names, ), 'description' => __( 'Filter users by one or more roles. Requires permission to list users.' ), ), @@ -603,6 +672,7 @@ private function get_users_input_schema(): array { 'minItems' => 1, 'items' => array( 'type' => 'string', + 'enum' => $public_post_types, ), ), ), @@ -694,6 +764,7 @@ private function get_users_output_schema(): array { 'description' => __( 'Roles assigned to the user. Present when the current user can view them.' ), 'items' => array( 'type' => 'string', + 'enum' => $this->get_role_names(), ), ), ); @@ -796,7 +867,10 @@ private function format_user( WP_User $user, array $fields ): array { $data['locale'] = (string) get_user_locale( $user ); } if ( $fields_requested( 'user_registered' ) ) { - $data['user_registered'] = gmdate( 'c', strtotime( $user->user_registered ) ); + $registered_timestamp = strtotime( $user->user_registered ); + if ( false !== $registered_timestamp ) { + $data['user_registered'] = gmdate( 'c', $registered_timestamp ); + } } } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php index 417b3abe341b3..22c44e5ae2f78 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php @@ -11,6 +11,14 @@ */ class Tests_Abilities_API_WpRegisterCoreUsersAbility extends WP_UnitTestCase { + /** + * Shared fixture IDs. + * + * @since 6.9.0 + * @var array + */ + private static $fixture_ids = array(); + /** * Administrator user ID. * @@ -73,6 +81,68 @@ public static function set_up_before_class(): void { add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); do_action( 'wp_abilities_api_categories_init' ); do_action( 'wp_abilities_api_init' ); + + self::$fixture_ids['administrator'] = self::factory()->user->create( + array( + 'role' => 'administrator', + 'user_login' => 'core_users_ability_admin', + 'user_email' => 'core-users-ability-admin@example.com', + 'user_nicename' => 'core-users-ability-admin', + ) + ); + + self::$fixture_ids['editor'] = self::factory()->user->create( + array( + 'role' => 'editor', + 'user_login' => 'core_users_ability_editor', + 'user_email' => 'core-users-ability-editor@example.com', + 'user_nicename' => 'core-users-ability-editor', + ) + ); + + self::$fixture_ids['author'] = self::factory()->user->create( + array( + 'role' => 'author', + 'user_login' => 'core_users_ability_author_current', + 'user_email' => 'core-users-ability-author-current@example.com', + 'user_nicename' => 'core-users-ability-author-current', + ) + ); + + self::$fixture_ids['contributor'] = self::factory()->user->create( + array( + 'role' => 'contributor', + 'user_login' => 'core_users_ability_contributor', + 'user_email' => 'core-users-ability-contributor@example.com', + 'user_nicename' => 'core-users-ability-contributor', + ) + ); + + self::$fixture_ids['subscriber'] = self::factory()->user->create( + array( + 'role' => 'subscriber', + 'user_login' => 'core_users_ability_subscriber', + 'user_email' => 'core-users-ability-subscriber@example.com', + 'user_nicename' => 'core-users-ability-subscriber', + ) + ); + + self::$fixture_ids['public_author'] = self::factory()->user->create( + array( + 'role' => 'author', + 'user_login' => 'core_users_ability_author', + 'user_email' => 'core-users-ability-author@example.com', + 'user_nicename' => 'core-users-ability-author', + ) + ); + + self::$fixture_ids['public_post'] = self::factory()->post->create( + array( + 'post_author' => self::$fixture_ids['public_author'], + 'post_status' => 'publish', + 'post_type' => 'post', + ) + ); } /** @@ -81,6 +151,14 @@ public static function set_up_before_class(): void { * @since 6.9.0 */ public static function tear_down_after_class(): void { + wp_delete_post( self::$fixture_ids['public_post'], true ); + + foreach ( array( 'administrator', 'editor', 'author', 'contributor', 'subscriber', 'public_author' ) as $fixture_name ) { + wp_delete_user( self::$fixture_ids[ $fixture_name ] ); + } + + self::$fixture_ids = array(); + add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); @@ -105,40 +183,10 @@ public function set_up(): void { $this->show_avatars = get_option( 'show_avatars' ); update_option( 'show_avatars', 1 ); - $this->admin_id = self::factory()->user->create( - array( - 'role' => 'administrator', - 'user_login' => 'core_users_ability_admin', - 'user_email' => 'core-users-ability-admin@example.com', - 'user_nicename' => 'core-users-ability-admin', - ) - ); - - $this->subscriber_id = self::factory()->user->create( - array( - 'role' => 'subscriber', - 'user_login' => 'core_users_ability_subscriber', - 'user_email' => 'core-users-ability-subscriber@example.com', - 'user_nicename' => 'core-users-ability-subscriber', - ) - ); - - $this->public_author_id = self::factory()->user->create( - array( - 'role' => 'author', - 'user_login' => 'core_users_ability_author', - 'user_email' => 'core-users-ability-author@example.com', - 'user_nicename' => 'core-users-ability-author', - ) - ); - - $this->public_post_id = self::factory()->post->create( - array( - 'post_author' => $this->public_author_id, - 'post_status' => 'publish', - 'post_type' => 'post', - ) - ); + $this->admin_id = self::$fixture_ids['administrator']; + $this->subscriber_id = self::$fixture_ids['subscriber']; + $this->public_author_id = self::$fixture_ids['public_author']; + $this->public_post_id = self::$fixture_ids['public_post']; $this->register_core_users_ability(); } @@ -149,7 +197,10 @@ public function set_up(): void { * @since 6.9.0 */ public function tear_down(): void { - wp_delete_post( $this->public_post_id, true ); + if ( wp_has_ability( 'core/users' ) ) { + wp_unregister_ability( 'core/users' ); + } + update_option( 'show_avatars', $this->show_avatars ); wp_set_current_user( 0 ); @@ -176,45 +227,51 @@ private function register_core_users_ability(): void { } /** - * The ability is registered in the user category and flagged read-only. + * The ability is registered in the `user` category and flagged read-only. * * @ticket 64146 */ public function test_core_users_ability_is_registered(): void { + $this->register_core_users_ability(); + $ability = wp_get_ability( 'core/users' ); - $this->assertInstanceOf( WP_Ability::class, $ability ); - $this->assertSame( 'user', $ability->get_category() ); - $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); - $this->assertTrue( $ability->get_meta_item( 'pagination', false ) ); + $this->assertInstanceOf( WP_Ability::class, $ability, 'The users ability should be registered.' ); + $this->assertSame( 'user', $ability->get_category(), 'The users ability should use the user category.' ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ), 'The users ability should be exposed over REST.' ); + $this->assertTrue( $ability->get_meta_item( 'pagination', false ), 'The users ability should advertise pagination support.' ); $annotations = $ability->get_meta_item( 'annotations', array() ); - $this->assertTrue( $annotations['readonly'] ); - $this->assertFalse( $annotations['destructive'] ); + $this->assertTrue( $annotations['readonly'], 'The users ability should be marked read-only.' ); + $this->assertFalse( $annotations['destructive'], 'The users ability should not be marked destructive.' ); } + /** * The input schema exposes strict single-user and collection modes. * * @ticket 64146 */ public function test_core_users_input_schema_exposes_strict_modes(): void { + $this->register_core_users_ability(); + $schema = wp_get_ability( 'core/users' )->get_input_schema(); - $this->assertSame( 'object', $schema['type'] ); - $this->assertEquals( (object) array(), $schema['default'] ); - $this->assertCount( 5, $schema['oneOf'] ); + $this->assertSame( 'object', $schema['type'], 'The users ability input schema should describe an object.' ); + $this->assertEquals( (object) array(), $schema['default'], 'The users ability input schema should default to empty collection mode.' ); + $this->assertCount( 5, $schema['oneOf'], 'The users ability input schema should expose four lookup modes and collection mode.' ); - $this->assertSame( array( 'id' ), $schema['oneOf'][0]['required'] ); - $this->assertSame( array( 'user_email' ), $schema['oneOf'][1]['required'] ); - $this->assertSame( array( 'user_login' ), $schema['oneOf'][2]['required'] ); - $this->assertSame( array( 'user_nicename' ), $schema['oneOf'][3]['required'] ); - $this->assertArrayNotHasKey( 'required', $schema['oneOf'][4] ); + $this->assertSame( array( 'id' ), $schema['oneOf'][0]['required'], 'The first input mode should require an ID.' ); + $this->assertSame( array( 'user_email' ), $schema['oneOf'][1]['required'], 'The second input mode should require a user email.' ); + $this->assertSame( array( 'user_login' ), $schema['oneOf'][2]['required'], 'The third input mode should require a user login.' ); + $this->assertSame( array( 'user_nicename' ), $schema['oneOf'][3]['required'], 'The fourth input mode should require a user nicename.' ); + $this->assertArrayNotHasKey( 'required', $schema['oneOf'][4], 'Collection mode should allow an empty request.' ); $collection_properties = $schema['oneOf'][4]['properties']; - $this->assertSameSets( + $this->assertEqualSets( array( 'roles', 'has_published_posts', 'fields', 'page', 'per_page' ), - array_keys( $collection_properties ) + array_keys( $collection_properties ), + 'Collection mode should expose only the supported query parameters.' ); $excluded_properties = array( 'search', @@ -235,16 +292,24 @@ public function test_core_users_input_schema_exposes_strict_modes(): void { 'capabilities', ); foreach ( $excluded_properties as $excluded_property ) { - $this->assertArrayNotHasKey( $excluded_property, $collection_properties ); + $this->assertArrayNotHasKey( $excluded_property, $collection_properties, sprintf( 'Collection mode should not expose %s.', $excluded_property ) ); } $fields = $schema['oneOf'][4]['properties']['fields']['items']['enum']; - $this->assertContains( 'roles', $fields ); - $this->assertContains( 'avatar_urls', $fields ); + $this->assertContains( 'roles', $fields, 'The fields enum should expose the roles field.' ); + $this->assertContains( 'avatar_urls', $fields, 'The fields enum should expose avatar_urls when avatars are enabled.' ); + + $role_names = $schema['oneOf'][4]['properties']['roles']['items']['enum']; + $this->assertEqualSets( array_keys( wp_roles()->roles ), $role_names, 'The roles query enum should expose registered role names.' ); + + $post_type_names = $schema['oneOf'][4]['properties']['has_published_posts']['oneOf'][1]['items']['enum']; + $this->assertContains( 'post', $post_type_names, 'The has_published_posts enum should expose public post types.' ); + $this->assertNotContains( 'revision', $post_type_names, 'The has_published_posts enum should omit non-public post types.' ); $output_schema = wp_get_ability( 'core/users' )->get_output_schema(); $user_properties = $output_schema['properties']['users']['items']['properties']; - $this->assertSame( 'date-time', $user_properties['user_registered']['format'] ); + $this->assertSame( 'date-time', $user_properties['user_registered']['format'], 'The user_registered output schema should use date-time format.' ); + $this->assertEqualSets( array_keys( wp_roles()->roles ), $user_properties['roles']['items']['enum'], 'The roles output enum should expose registered role names.' ); } /** @@ -260,14 +325,14 @@ public function test_avatar_urls_respects_show_avatars_option(): void { $input_schema = $ability->get_input_schema(); $output_schema = $ability->get_output_schema(); - $this->assertNotContains( 'avatar_urls', $input_schema['oneOf'][0]['properties']['fields']['items']['enum'] ); - $this->assertArrayNotHasKey( 'avatar_urls', $output_schema['properties']['users']['items']['properties'] ); + $this->assertNotContains( 'avatar_urls', $input_schema['oneOf'][0]['properties']['fields']['items']['enum'], 'The fields enum should omit avatar_urls when avatars are disabled.' ); + $this->assertArrayNotHasKey( 'avatar_urls', $output_schema['properties']['users']['items']['properties'], 'The output schema should omit avatar_urls when avatars are disabled.' ); wp_set_current_user( $this->subscriber_id ); $result = $ability->execute( array( 'id' => $this->subscriber_id ) ); - $this->assertIsArray( $result ); - $this->assertArrayNotHasKey( 'avatar_urls', $result['users'][0] ); + $this->assertIsArray( $result, 'The current user should still be readable when avatars are disabled.' ); + $this->assertArrayNotHasKey( 'avatar_urls', $result['users'][0], 'The ability result should omit avatar_urls when avatars are disabled.' ); } /** @@ -276,10 +341,12 @@ public function test_avatar_urls_respects_show_avatars_option(): void { * @ticket 64146 */ public function test_core_users_requires_logged_in_user(): void { + $this->register_core_users_ability(); + $result = wp_get_ability( 'core/users' )->execute( array() ); - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + $this->assertWPError( $result, 'Logged-out users should not be allowed to execute the users ability.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Logged-out users should receive an invalid permissions error.' ); } /** @@ -289,6 +356,7 @@ public function test_core_users_requires_logged_in_user(): void { */ public function test_current_user_can_read_themselves_by_sensitive_identifiers(): void { wp_set_current_user( $this->subscriber_id ); + $this->register_core_users_ability(); $ability = wp_get_ability( 'core/users' ); @@ -299,25 +367,31 @@ public function test_current_user_can_read_themselves_by_sensitive_identifiers() ) ); - $this->assertIsArray( $result ); - $this->assertSame( $this->subscriber_id, $result['users'][0]['id'] ); - $this->assertSame( 'core-users-ability-subscriber@example.com', $result['users'][0]['user_email'] ); - $this->assertSame( 'core_users_ability_subscriber', $result['users'][0]['user_login'] ); - $this->assertContains( 'subscriber', $result['users'][0]['roles'] ); + $this->assertIsArray( $result, 'A user should be able to read themselves by ID.' ); + $this->assertSame( $this->subscriber_id, $result['users'][0]['id'], 'The ID lookup should return the current user.' ); + $this->assertSame( 'core-users-ability-subscriber@example.com', $result['users'][0]['user_email'], 'The current user should receive their own email.' ); + $this->assertSame( 'core_users_ability_subscriber', $result['users'][0]['user_login'], 'The current user should receive their own login.' ); + $this->assertContains( 'subscriber', $result['users'][0]['roles'], 'The current user should receive their own roles.' ); $result = $ability->execute( array( 'user_email' => 'core-users-ability-subscriber@example.com' ) ); - $this->assertIsArray( $result ); - $this->assertSame( $this->subscriber_id, $result['users'][0]['id'] ); + $this->assertIsArray( $result, 'A user should be able to read themselves by email.' ); + $this->assertSame( $this->subscriber_id, $result['users'][0]['id'], 'The email lookup should return the current user.' ); $result = $ability->execute( array( 'user_login' => 'core_users_ability_subscriber' ) ); - $this->assertIsArray( $result ); - $this->assertSame( $this->subscriber_id, $result['users'][0]['id'] ); + $this->assertIsArray( $result, 'A user should be able to read themselves by login.' ); + $this->assertSame( $this->subscriber_id, $result['users'][0]['id'], 'The login lookup should return the current user.' ); - $result = $ability->execute( array( 'id' => $this->subscriber_id, 'fields' => array( 'id', 'user_registered' ) ) ); - $this->assertIsArray( $result ); + $result = $ability->execute( + array( + 'id' => $this->subscriber_id, + 'fields' => array( 'id', 'user_registered' ), + ) + ); + $this->assertIsArray( $result, 'A user should be able to request their registration date.' ); $this->assertSame( gmdate( 'c', strtotime( get_userdata( $this->subscriber_id )->user_registered ) ), - $result['users'][0]['user_registered'] + $result['users'][0]['user_registered'], + 'The registration date should be formatted as an ISO 8601 date-time string.' ); } @@ -328,6 +402,7 @@ public function test_current_user_can_read_themselves_by_sensitive_identifiers() */ public function test_public_author_can_be_read_by_id_and_user_nicename(): void { wp_set_current_user( $this->subscriber_id ); + $this->register_core_users_ability(); $ability = wp_get_ability( 'core/users' ); @@ -338,44 +413,68 @@ public function test_public_author_can_be_read_by_id_and_user_nicename(): void { ) ); - $this->assertIsArray( $result ); - $this->assertSame( $this->public_author_id, $result['users'][0]['id'] ); - $this->assertSame( 'core-users-ability-author', $result['users'][0]['user_nicename'] ); - $this->assertArrayNotHasKey( 'user_email', $result['users'][0] ); + $this->assertIsArray( $result, 'A logged-in user should be able to read a public author by ID.' ); + $this->assertSame( $this->public_author_id, $result['users'][0]['id'], 'The ID lookup should return the public author.' ); + $this->assertSame( 'core-users-ability-author', $result['users'][0]['user_nicename'], 'The public author nicename should be returned.' ); + $this->assertArrayNotHasKey( 'user_email', $result['users'][0], 'Public-author access should not expose another user email.' ); $result = $ability->execute( array( 'user_nicename' => 'core-users-ability-author' ) ); - $this->assertIsArray( $result ); - $this->assertSame( $this->public_author_id, $result['users'][0]['id'] ); + $this->assertIsArray( $result, 'A logged-in user should be able to read a public author by nicename.' ); + $this->assertSame( $this->public_author_id, $result['users'][0]['id'], 'The nicename lookup should return the public author.' ); } /** - * User email and login lookups for another user require list or edit permissions. + * User email and login lookups for another user require list or edit permissions across roles. * * @ticket 64146 + * + * @dataProvider data_roles_for_sensitive_identifier_lookup_permissions + * + * @param string $role Current user's role. + * @param bool $can_resolve Whether the role can resolve another user by sensitive identifiers. */ - public function test_user_email_and_login_lookup_for_another_user_requires_permission(): void { - wp_set_current_user( $this->subscriber_id ); + public function test_roles_have_expected_sensitive_identifier_lookup_permissions( string $role, bool $can_resolve ): void { + wp_set_current_user( self::$fixture_ids[ $role ] ); + $this->register_core_users_ability(); $ability = wp_get_ability( 'core/users' ); $result = $ability->execute( array( 'user_email' => 'core-users-ability-author@example.com' ) ); - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + if ( $can_resolve ) { + $this->assertIsArray( $result, sprintf( 'The %s role should be able to resolve another user by email.', $role ) ); + $this->assertSame( $this->public_author_id, $result['users'][0]['id'], sprintf( 'The email lookup should return the public author for the %s role.', $role ) ); + } else { + $this->assertWPError( $result, sprintf( 'The %s role should not be able to resolve another user by email.', $role ) ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), sprintf( 'Email lookup denial for the %s role should use the invalid permissions error.', $role ) ); + } $result = $ability->execute( array( 'user_login' => 'core_users_ability_author' ) ); - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); - - wp_set_current_user( $this->admin_id ); + if ( $can_resolve ) { + $this->assertIsArray( $result, sprintf( 'The %s role should be able to resolve another user by login.', $role ) ); + $this->assertSame( $this->public_author_id, $result['users'][0]['id'], sprintf( 'The login lookup should return the public author for the %s role.', $role ) ); + return; + } - $result = $ability->execute( array( 'user_email' => 'core-users-ability-author@example.com' ) ); - $this->assertIsArray( $result ); - $this->assertSame( $this->public_author_id, $result['users'][0]['id'] ); + $this->assertWPError( $result, sprintf( 'The %s role should not be able to resolve another user by login.', $role ) ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), sprintf( 'Login lookup denial for the %s role should use the invalid permissions error.', $role ) ); + } - $result = $ability->execute( array( 'user_login' => 'core_users_ability_author' ) ); - $this->assertIsArray( $result ); - $this->assertSame( $this->public_author_id, $result['users'][0]['id'] ); + /** + * Data provider for role-based sensitive identifier lookup checks. + * + * @ticket 64146 + * + * @return array + */ + public static function data_roles_for_sensitive_identifier_lookup_permissions(): array { + return array( + 'administrator' => array( 'administrator', true ), + 'editor' => array( 'editor', false ), + 'author' => array( 'author', false ), + 'contributor' => array( 'contributor', false ), + 'subscriber' => array( 'subscriber', false ), + ); } /** @@ -385,15 +484,16 @@ public function test_user_email_and_login_lookup_for_another_user_requires_permi */ public function test_empty_collection_mode_restricts_users_without_list_users_to_public_authors(): void { wp_set_current_user( $this->subscriber_id ); + $this->register_core_users_ability(); $result = wp_get_ability( 'core/users' )->execute( array() ); - $this->assertIsArray( $result ); - $this->assertContains( $this->public_author_id, wp_list_pluck( $result['users'], 'id' ) ); - $this->assertNotContains( $this->admin_id, wp_list_pluck( $result['users'], 'id' ) ); - $this->assertNotContains( $this->subscriber_id, wp_list_pluck( $result['users'], 'id' ) ); - $this->assertIsInt( $result['total'] ); - $this->assertIsInt( $result['total_pages'] ); + $this->assertIsArray( $result, 'Collection mode should return an array for logged-in users.' ); + $this->assertContains( $this->public_author_id, wp_list_pluck( $result['users'], 'id' ), 'Collection mode should include public authors.' ); + $this->assertNotContains( $this->admin_id, wp_list_pluck( $result['users'], 'id' ), 'Collection mode should omit non-author administrators for users without list_users.' ); + $this->assertNotContains( $this->subscriber_id, wp_list_pluck( $result['users'], 'id' ), 'Collection mode should omit subscribers for users without list_users.' ); + $this->assertIsInt( $result['total'], 'Collection mode should include an integer total.' ); + $this->assertIsInt( $result['total_pages'], 'Collection mode should include an integer total_pages value.' ); } /** @@ -434,12 +534,19 @@ public function test_collection_mode_for_users_without_list_users_uses_public_po ); try { - $this->assertFalse( get_post_type_object( 'wp_public_pt' )->show_in_rest ); + $this->assertFalse( get_post_type_object( 'wp_public_pt' )->show_in_rest, 'The public fixture post type should remain hidden from REST.' ); wp_set_current_user( $this->subscriber_id ); + $this->register_core_users_ability(); $ability = wp_get_ability( 'core/users' ); - $result = $ability->execute( + $schema = $ability->get_input_schema(); + $enum = $schema['oneOf'][4]['properties']['has_published_posts']['oneOf'][1]['items']['enum']; + + $this->assertContains( 'wp_public_pt', $enum, 'The has_published_posts enum should include public post types even when hidden from REST.' ); + $this->assertNotContains( 'wp_private_pt', $enum, 'The has_published_posts enum should omit private post types.' ); + + $result = $ability->execute( array( 'has_published_posts' => array( 'wp_public_pt' ), 'fields' => array( 'id' ), @@ -447,11 +554,11 @@ public function test_collection_mode_for_users_without_list_users_uses_public_po ) ); - $this->assertIsArray( $result ); + $this->assertIsArray( $result, 'A public post type author query should return an array.' ); $ids = wp_list_pluck( $result['users'], 'id' ); - $this->assertContains( $public_author_id, $ids ); - $this->assertNotContains( $this->public_author_id, $ids ); - $this->assertNotContains( $private_author_id, $ids ); + $this->assertContains( $public_author_id, $ids, 'The query should include authors of the requested public post type.' ); + $this->assertNotContains( $this->public_author_id, $ids, 'The query should exclude authors without posts in the requested public post type.' ); + $this->assertNotContains( $private_author_id, $ids, 'The query should exclude authors of private post types.' ); $result = $ability->execute( array( @@ -460,10 +567,8 @@ public function test_collection_mode_for_users_without_list_users_uses_public_po ) ); - $this->assertIsArray( $result ); - $this->assertSame( array(), $result['users'] ); - $this->assertSame( 0, $result['total'] ); - $this->assertSame( 0, $result['total_pages'] ); + $this->assertWPError( $result, 'Private post type filters should fail schema validation.' ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code(), 'Private post type filters should use the invalid input error.' ); } finally { wp_delete_post( $public_post_id, true ); wp_delete_post( $private_post_id, true ); @@ -481,6 +586,7 @@ public function test_collection_mode_for_users_without_list_users_uses_public_po */ public function test_admin_can_query_by_role_and_receive_roles(): void { wp_set_current_user( $this->admin_id ); + $this->register_core_users_ability(); $result = wp_get_ability( 'core/users' )->execute( array( @@ -490,13 +596,68 @@ public function test_admin_can_query_by_role_and_receive_roles(): void { ) ); - $this->assertIsArray( $result ); - $this->assertContains( $this->public_author_id, wp_list_pluck( $result['users'], 'id' ) ); + $this->assertIsArray( $result, 'An administrator role query should return an array.' ); + $this->assertContains( $this->public_author_id, wp_list_pluck( $result['users'], 'id' ), 'The author role query should include the public author.' ); foreach ( $result['users'] as $user ) { - $this->assertContains( 'author', $user['roles'] ); + $this->assertContains( 'author', $user['roles'], 'Each user returned by an author role query should include the author role.' ); } } + /** + * Field visibility for another public author matches the current user's role. + * + * @ticket 64146 + * + * @dataProvider data_roles_for_another_public_author_field_visibility + * + * @param string $role Current user's role. + * @param bool $can_view_sensitive Whether the role can view another user's sensitive fields. + * @param bool $can_view_roles Whether the role can view another user's roles. + */ + public function test_roles_have_expected_field_visibility_for_another_public_author( string $role, bool $can_view_sensitive, bool $can_view_roles ): void { + wp_set_current_user( self::$fixture_ids[ $role ] ); + $this->register_core_users_ability(); + + $result = wp_get_ability( 'core/users' )->execute( + array( + 'id' => $this->public_author_id, + 'fields' => array( 'id', 'user_email', 'roles' ), + ) + ); + + $this->assertIsArray( $result, sprintf( 'The %s role should be able to execute a public-author lookup.', $role ) ); + $this->assertSame( $this->public_author_id, $result['users'][0]['id'], sprintf( 'The %s role should receive the requested public author.', $role ) ); + $this->assertSame( $can_view_sensitive, array_key_exists( 'user_email', $result['users'][0] ), sprintf( 'The %s role email visibility should match expectations.', $role ) ); + $this->assertSame( $can_view_roles, array_key_exists( 'roles', $result['users'][0] ), sprintf( 'The %s role roles visibility should match expectations.', $role ) ); + + if ( $can_view_sensitive ) { + $this->assertSame( 'core-users-ability-author@example.com', $result['users'][0]['user_email'], sprintf( 'The %s role should receive the public author email when allowed.', $role ) ); + } + + if ( ! $can_view_roles ) { + return; + } + + $this->assertContains( 'author', $result['users'][0]['roles'], sprintf( 'The %s role should receive the public author role when allowed.', $role ) ); + } + + /** + * Data provider for role-based field visibility checks. + * + * @ticket 64146 + * + * @return array + */ + public static function data_roles_for_another_public_author_field_visibility(): array { + return array( + 'administrator' => array( 'administrator', true, true ), + 'editor' => array( 'editor', false, false ), + 'author' => array( 'author', false, false ), + 'contributor' => array( 'contributor', false, false ), + 'subscriber' => array( 'subscriber', false, false ), + ); + } + /** * Role filtering requires list_users. * @@ -504,11 +665,12 @@ public function test_admin_can_query_by_role_and_receive_roles(): void { */ public function test_role_filter_requires_list_users(): void { wp_set_current_user( $this->subscriber_id ); + $this->register_core_users_ability(); $result = wp_get_ability( 'core/users' )->execute( array( 'roles' => array( 'author' ) ) ); - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + $this->assertWPError( $result, 'A subscriber should not be able to filter users by role.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Role filter denial should use the invalid permissions error.' ); } /** @@ -518,6 +680,7 @@ public function test_role_filter_requires_list_users(): void { */ public function test_restricted_requested_fields_are_omitted_per_user(): void { wp_set_current_user( $this->subscriber_id ); + $this->register_core_users_ability(); $result = wp_get_ability( 'core/users' )->execute( array( @@ -526,8 +689,23 @@ public function test_restricted_requested_fields_are_omitted_per_user(): void { ) ); - $this->assertIsArray( $result ); - $this->assertSame( array( 'id' ), array_keys( $result['users'][0] ) ); - $this->assertSame( $this->public_author_id, $result['users'][0]['id'] ); + $this->assertIsArray( $result, 'A public-author lookup should return an array.' ); + $this->assertSame( array( 'id' ), array_keys( $result['users'][0] ), 'Restricted requested fields should be omitted instead of failing the request.' ); + $this->assertSame( $this->public_author_id, $result['users'][0]['id'], 'The public-author lookup should still return unrestricted fields.' ); + } + + /** + * Missing or inaccessible single-user lookups fail closed. + * + * @ticket 64146 + */ + public function test_missing_single_user_lookup_fails_closed(): void { + wp_set_current_user( $this->admin_id ); + $this->register_core_users_ability(); + + $result = wp_get_ability( 'core/users' )->execute( array( 'id' => 999999 ) ); + + $this->assertWPError( $result, 'Missing single-user lookups should fail closed.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Missing single-user lookups should use the invalid permissions error.' ); } } From d826d1c6e779a97c0299d8b2da71fd9cc4adfc3c Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 10 Jul 2026 18:18:00 +0100 Subject: [PATCH 10/10] Abilities: Sync read-users ability with plugin --- .../abilities/class-wp-users-abilities.php | 823 +++++++------ .../wpRegisterCoreUsersAbility.php | 1067 +++++++++++++++-- 2 files changed, 1424 insertions(+), 466 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-users-abilities.php b/src/wp-includes/abilities/class-wp-users-abilities.php index be850b6cc3052..891c695bcbea4 100644 --- a/src/wp-includes/abilities/class-wp-users-abilities.php +++ b/src/wp-includes/abilities/class-wp-users-abilities.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage Abilities_API - * @since 6.9.0 + * @since 7.1.0 * * @access private */ @@ -12,9 +12,13 @@ declare( strict_types = 1 ); /** - * Registers core user abilities. + * Registers the read-only `core/read-users` ability, which retrieves one or more + * readable WordPress users. Supports fetching a single readable user by ID, + * email, username, or slug, or querying a paginated collection optionally + * filtered by roles, published-post authorship, or included IDs. Field-level + * access is enforced per user by omitting fields the current user cannot view. * - * @since 6.9.0 + * @since 7.1.0 * @access private */ final class WP_Users_Abilities { @@ -22,7 +26,7 @@ final class WP_Users_Abilities { /** * The ability category used for user abilities. * - * @since 6.9.0 + * @since 7.1.0 * @var string */ private const CATEGORY = 'user'; @@ -30,7 +34,7 @@ final class WP_Users_Abilities { /** * Default number of users returned per page in collection mode. * - * @since 6.9.0 + * @since 7.1.0 * @var int */ private const DEFAULT_PER_PAGE = 10; @@ -38,94 +42,55 @@ final class WP_Users_Abilities { /** * Maximum number of users returned per page in collection mode. * - * @since 6.9.0 + * @since 7.1.0 * @var int */ private const MAX_PER_PAGE = 100; /** - * Public/read-context user fields. + * Lookup type returned for collection requests. * - * @since 6.9.0 - * @var string[] + * @since 7.1.0 + * @var string */ - private $read_fields = array( - 'id', - 'display_name', - 'description', - 'user_url', - 'link', - 'user_nicename', - ); + private const LOOKUP_COLLECTION = 'collection'; /** - * Fields that expose edit-context user data. + * Default fields returned when the caller does not request a field subset. * - * @since 6.9.0 + * @since 7.1.0 * @var string[] */ - private $sensitive_fields = array( - 'user_login', - 'user_email', - 'first_name', - 'last_name', - 'nickname', - 'locale', - 'user_registered', + private const DEFAULT_FIELDS = array( + 'id', + 'name', + 'link', + 'slug', + 'avatar_urls', ); - /** - * Cached public post type names. - * - * @since 6.9.0 - * @var string[]|null - */ - private $public_post_types = null; - - /** - * Cached supported field list. - * - * @since 6.9.0 - * @var string[]|null - */ - private $fields = null; - - /** - * Whether the cached supported field list includes avatars. - * - * @since 6.9.0 - * @var bool|null - */ - private $fields_include_avatars = null; - - /** - * Cached role names. - * - * @since 6.9.0 - * @var string[]|null - */ - private $role_names = null; - /** * Registers all user abilities. * - * @since 6.9.0 + * Must run on the `wp_abilities_api_init` hook. + * + * @since 7.1.0 */ public function register(): void { $this->register_get_users(); } /** - * Registers the read-only `core/users` ability. + * Registers the read-only `core/read-users` ability. * - * @since 6.9.0 + * @since 7.1.0 */ private function register_get_users(): void { wp_register_ability( - 'core/users', + 'core/read-users', array( - 'label' => __( 'Get Users' ), - 'description' => __( 'Retrieves one or more readable WordPress users. Fetch a single readable user by ID, user email, user login, or user nicename, or query a paginated collection optionally filtered by roles or published-post authorship.' ), + 'label' => __( 'Read Users' ), + 'description' => __( 'Retrieves one or more readable WordPress users. Fetch a single readable user by ID, email, username, or slug, or query a paginated collection optionally filtered by roles, published-post authorship, or included IDs.' ), 'category' => self::CATEGORY, 'input_schema' => $this->get_users_input_schema(), 'output_schema' => $this->get_users_output_schema(), @@ -138,22 +103,25 @@ private function register_get_users(): void { 'idempotent' => true, ), 'show_in_rest' => true, - 'pagination' => true, ), ) ); } /** - * Permission callback for the `core/users` ability. + * Permission callback for the `core/read-users` ability. + * + * Performs request-level checks. Single-user requests are checked against + * the target user, while collection requests rely on query arguments in + * {@see self::execute_get_users()} for row-level access. * - * @since 6.9.0 + * @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(); + $input = $this->to_input_array( $input ); if ( ! is_user_logged_in() ) { return false; @@ -164,45 +132,36 @@ public function check_permission( $input = array() ): bool { } $lookup_type = $this->get_lookup_type( $input ); - if ( '' === $lookup_type ) { + if ( self::LOOKUP_COLLECTION === $lookup_type ) { return true; } - $user = $this->find_user( $input ); - if ( ! $user instanceof WP_User || ! $this->is_user_member_of_site( $user ) ) { - return false; - } - - return $this->can_read_user_for_lookup( $user, $lookup_type ); + return $this->resolve_readable_user( $input, $lookup_type ) instanceof WP_User; } /** - * Executes the `core/users` ability. + * Executes the `core/read-users` ability. * - * @since 6.9.0 + * @since 7.1.0 * * @param mixed $input Optional. The ability input. Default empty array. - * @return array|WP_Error A map with a `users` list, or a WP_Error on failure. + * @return array|\stdClass|\WP_Error User data, paginated collection data, or a WP_Error on failure. */ public function execute_get_users( $input = array() ) { - $input = is_array( $input ) ? $input : array(); + $input = $this->to_input_array( $input ); $fields = $this->normalize_fields( $input ); $lookup_type = $this->get_lookup_type( $input ); - if ( '' !== $lookup_type ) { - $user = $this->find_user( $input ); - if ( ! $user instanceof WP_User - || ! $this->is_user_member_of_site( $user ) - || ! $this->can_read_user_for_lookup( $user, $lookup_type ) - ) { - return $this->not_found_error(); + if ( self::LOOKUP_COLLECTION !== $lookup_type ) { + $user = $this->resolve_readable_user( $input, $lookup_type ); + if ( ! $user instanceof WP_User ) { + return new WP_Error( + 'ability_invalid_permissions', + __( 'The requested user cannot be read.' ) + ); } - return array( - 'users' => array( $this->format_user( $user, $fields ) ), - 'total' => 1, - 'total_pages' => 1, - ); + return $this->format_user( $user, $fields ); } $per_page = $this->normalize_per_page( $input ); @@ -214,24 +173,40 @@ public function execute_get_users( $input = array() ) { 'count_total' => true, ); + $include = $this->normalize_include( $input ); + if ( array() !== $include ) { + // The include order is not applied as `orderby`. Keeping the default + // ordering lets WP_User_Query share cached results with other queries. + $query_args['include'] = $include; + } + if ( ! empty( $input['roles'] ) && current_user_can( 'list_users' ) ) { $query_args['role__in'] = $this->normalize_string_list( $input['roles'] ); } - if ( current_user_can( 'list_users' ) ) { - $has_published_posts = $this->normalize_has_published_posts( $input ); - if ( null !== $has_published_posts ) { - $query_args['has_published_posts'] = $has_published_posts; - } - } else { - $public_post_types = $this->get_public_post_types(); - $has_published_posts = $this->normalize_has_published_posts( $input ); + $has_published_posts = $this->normalize_has_published_posts( $input ); - if ( is_array( $has_published_posts ) ) { - $public_post_types = array_values( array_intersect( $public_post_types, $has_published_posts ) ); - } + // Callers who cannot list users only see public authors in a collection, + // matching core, so the filter is always applied for them. This intentionally + // excludes the caller's own account when they have no published posts. Self is + // read through a single-user lookup (like the REST `/users/me` endpoint) instead. + $requires_published_posts = ! current_user_can( 'list_users' ); + + if ( null !== $has_published_posts || $requires_published_posts ) { + /* + * The post types are always resolved here rather than passed as `true`. + * WP_User_Query reads `true` as `get_post_types( array( 'public' => true ) )`, + * which is not the same as the publicly viewable set the rest of the ability + * uses. Resolving here also picks up post types registered or unregistered + * after the input schema was built. + */ + $public_post_types = $this->get_public_post_types(); + + $has_published_posts = is_array( $has_published_posts ) + ? array_values( array_intersect( $public_post_types, $has_published_posts ) ) + : $public_post_types; - if ( array() === $public_post_types ) { + if ( array() === $has_published_posts ) { return array( 'users' => array(), 'total' => 0, @@ -239,33 +214,73 @@ public function execute_get_users( $input = array() ) { ); } - $query_args['has_published_posts'] = $public_post_types; + $query_args['has_published_posts'] = $has_published_posts; } $query = new WP_User_Query( $query_args ); $users = array(); foreach ( $query->get_results() as $user ) { - if ( ! $user instanceof WP_User || ! $this->is_user_member_of_site( $user ) || ! $this->can_read_user( $user ) ) { + if ( ! $user instanceof WP_User ) { continue; } $users[] = $this->format_user( $user, $fields ); } + // `users` and `total`/`total_pages` all derive from the same WP_User_Query, + // so the row count and the reported totals stay in agreement. Collections + // are not post-filtered by site membership, matching the REST users + // controller, whose collection endpoint applies no per-row membership check + // and reports `get_total()` directly. On multisite the collection is still + // scoped to the current site: WP_User_Query adds a capabilities meta clause + // restricting results to members of the queried blog whenever `blog_id` + // (defaulted to the current blog) is set, even for a bare query with no + // roles/has_published_posts. Callers who cannot list users are additionally + // narrowed by the forced `has_published_posts`, which joins the current + // blog's posts table. Single-user lookups remain site-scoped via + // {@see self::is_user_member_of_site()}, matching the controller's + // single-user membership check. $total_users = (int) $query->get_total(); return array( 'users' => $users, 'total' => $total_users, - 'total_pages' => $per_page > 0 ? (int) ceil( $total_users / $per_page ) : 0, + 'total_pages' => (int) ceil( $total_users / $per_page ), ); } + /** + * Casts raw ability input to an array. + * + * Schema validation accepts object input (`rest_is_object()` allows a + * `stdClass`, and the input schema's own `default` is one), so it must be + * treated as equivalent to its array form rather than discarded. Any other + * non-array input is replaced with an empty array. + * + * The Abilities API validates input against the schema but does not coerce + * it, and REST `GET` requests (the only method for read-only abilities) + * deliver every scalar as a string. Each normalizer below therefore accepts + * the string forms validation accepted (`'true'` for `true`, CSV strings + * for arrays, numeric strings for integers). + * + * @since 7.1.0 + * + * @param mixed $input The raw ability input. + * @return array The input as an array. + */ + private function to_input_array( $input ): array { + if ( $input instanceof stdClass ) { + $input = (array) $input; + } + + return is_array( $input ) ? $input : array(); + } + /** * Casts a raw input value to a non-negative integer. * - * @since 6.9.0 + * @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. @@ -277,28 +292,49 @@ private function input_int( $value ): int { /** * Determines the single-user lookup type represented by the input. * - * @since 6.9.0 + * @since 7.1.0 * * @param array $input The ability input. - * @return string The lookup type, or an empty string for collection mode. + * @return string The lookup type, or {@see self::LOOKUP_COLLECTION}. */ private function get_lookup_type( array $input ): string { - foreach ( array( 'id', 'user_email', 'user_login', 'user_nicename' ) as $key ) { + foreach ( array( 'id', 'email', 'username', 'slug' ) as $key ) { if ( array_key_exists( $key, $input ) ) { return $key; } } - return ''; + return self::LOOKUP_COLLECTION; + } + + /** + * Resolves the target of a single-user lookup when the current user may read it. + * + * Shared by the permission and execute callbacks so the single-user + * authorization decision has exactly one implementation. + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @param string $lookup_type The single-user lookup type. + * @return \WP_User|null The readable user, or null when not found or not readable. + */ + private function resolve_readable_user( array $input, string $lookup_type ): ?WP_User { + $user = $this->find_user( $input ); + if ( ! $user instanceof WP_User || ! $this->is_user_member_of_site( $user ) ) { + return null; + } + + return $this->can_read_user_for_lookup( $user, $lookup_type ) ? $user : null; } /** * Finds a user by one of the supported unique input identifiers. * - * @since 6.9.0 + * @since 7.1.0 * * @param array $input The ability input. - * @return WP_User|null User object, or null when not found. + * @return \WP_User|null User object, or null when not found. */ private function find_user( array $input ): ?WP_User { if ( array_key_exists( 'id', $input ) ) { @@ -306,30 +342,34 @@ private function find_user( array $input ): ?WP_User { return $user instanceof WP_User ? $user : null; } - if ( array_key_exists( 'user_email', $input ) ) { - if ( ! is_string( $input['user_email'] ) ) { + if ( array_key_exists( 'email', $input ) ) { + if ( ! is_string( $input['email'] ) ) { return null; } - $user = get_user_by( 'email', sanitize_email( $input['user_email'] ) ); + $user = get_user_by( 'email', sanitize_email( $input['email'] ) ); return $user instanceof WP_User ? $user : null; } - if ( array_key_exists( 'user_login', $input ) ) { - if ( ! is_string( $input['user_login'] ) ) { + if ( array_key_exists( 'username', $input ) ) { + if ( ! is_string( $input['username'] ) ) { return null; } - $user = get_user_by( 'login', $input['user_login'] ); + $user = get_user_by( 'login', sanitize_user( $input['username'] ) ); return $user instanceof WP_User ? $user : null; } - if ( array_key_exists( 'user_nicename', $input ) ) { - if ( ! is_string( $input['user_nicename'] ) ) { + if ( array_key_exists( 'slug', $input ) ) { + if ( ! is_string( $input['slug'] ) ) { return null; } - $user = get_user_by( 'slug', sanitize_title( $input['user_nicename'] ) ); + // Query the raw nicename, matching the REST users controller. Applying + // sanitize_title() here would miss users whose stored user_nicename is + // not a sanitize_title() fixed point (e.g. set via the pre_user_nicename + // filter or an import). + $user = get_user_by( 'slug', $input['slug'] ); return $user instanceof WP_User ? $user : null; } @@ -339,9 +379,9 @@ private function find_user( array $input ): ?WP_User { /** * Checks whether a user belongs to the current site. * - * @since 6.9.0 + * @since 7.1.0 * - * @param WP_User $user User object. + * @param \WP_User $user User object. * @return bool Whether the user belongs to the current site. */ private function is_user_member_of_site( WP_User $user ): bool { @@ -351,13 +391,13 @@ private function is_user_member_of_site( WP_User $user ): bool { /** * Checks whether a single-user lookup may return the target user. * - * User email and login are identifier-sensitive lookup modes and do not use the + * Email and username are identifier-sensitive lookup modes and do not use the * public-author fallback. * - * @since 6.9.0 + * @since 7.1.0 * - * @param WP_User $user User object. - * @param string $lookup_type Lookup type. + * @param \WP_User $user User object. + * @param string $lookup_type Lookup type. * @return bool Whether the user can be read for that lookup type. */ private function can_read_user_for_lookup( WP_User $user, string $lookup_type ): bool { @@ -369,34 +409,19 @@ private function can_read_user_for_lookup( WP_User $user, string $lookup_type ): return true; } - if ( 'user_email' === $lookup_type || 'user_login' === $lookup_type ) { + if ( 'email' === $lookup_type || 'username' === $lookup_type ) { return false; } return $this->is_public_author( $user ); } - /** - * Checks whether a user may be included in collection results. - * - * @since 6.9.0 - * - * @param WP_User $user User object. - * @return bool Whether the user can be read. - */ - private function can_read_user( WP_User $user ): bool { - return $this->is_current_user( $user ) - || current_user_can( 'edit_user', $user->ID ) - || current_user_can( 'list_users' ) - || $this->is_public_author( $user ); - } - /** * Checks whether the current user is the target user. * - * @since 6.9.0 + * @since 7.1.0 * - * @param WP_User $user User object. + * @param \WP_User $user User object. * @return bool Whether the current user is the target user. */ private function is_current_user( WP_User $user ): bool { @@ -404,12 +429,20 @@ private function is_current_user( WP_User $user ): bool { } /** - * Checks whether a user has published posts in public post types. + * Checks whether the current user can see a post by this user in a publicly + * viewable post type. * - * @since 6.9.0 + * Published posts always count. Private posts also count for a caller who holds + * `read_private_posts` for the post type, because `count_user_posts()` defaults to + * `$public_only = false`. This matches how the REST users controller resolves a + * single user. Collection mode is filtered by `WP_User_Query`'s + * `has_published_posts`, which matches published posts only, so the two modes + * disagree about an author whose posts are all private. * - * @param WP_User $user User object. - * @return bool Whether the user is publicly visible as an author. + * @since 7.1.0 + * + * @param \WP_User $user User object. + * @return bool Whether the user is visible as an author to the current user. */ private function is_public_author( WP_User $user ): bool { $post_types = $this->get_public_post_types(); @@ -421,100 +454,177 @@ private function is_public_author( WP_User $user ): bool { } /** - * Returns public post types. + * Returns publicly viewable post types. + * + * Uses {@see is_post_type_viewable()} rather than the `public` registration + * argument, since `public` alone does not guarantee a post type is viewable + * on the front end. 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 6.9.0 + * @since 7.1.0 * - * @return string[] Public post type names. + * @return string[] Publicly viewable post type names. */ private function get_public_post_types(): array { - if ( null !== $this->public_post_types ) { - return $this->public_post_types; - } - - $post_types = array(); - - foreach ( get_post_types( array( 'public' => true ), 'names' ) as $post_type ) { - if ( ! is_string( $post_type ) ) { - continue; - } - - $post_types[] = $post_type; - } - - $this->public_post_types = $post_types; - - return $this->public_post_types; + return array_values( array_filter( get_post_types(), 'is_post_type_viewable' ) ); } /** - * Normalizes the requested fields to the supported set, defaulting to all fields. + * 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; REST `GET` requests may + * deliver the list as a CSV string. The input schema has already validated + * the names against the supported set before the ability executes. + * + * The `id` field is always included, matching the REST users controller + * where `id` is present in every context. This also guarantees the result + * is never empty, so it always serializes as a JSON object. * - * @since 6.9.0 + * @since 7.1.0 * * @param array $input The ability input. * @return string[] List of requested field names. */ private function normalize_fields( array $input ): array { - $available_fields = $this->get_fields(); - - if ( empty( $input['fields'] ) || ! is_array( $input['fields'] ) ) { - return $available_fields; + $fields = isset( $input['fields'] ) ? $this->normalize_string_list( $input['fields'] ) : array(); + if ( array() === $fields ) { + $fields = $this->get_default_fields(); } - $requested_fields = array_filter( $input['fields'], 'is_string' ); - $fields = array_intersect( $available_fields, $requested_fields ); + if ( ! in_array( 'id', $fields, true ) ) { + array_unshift( $fields, 'id' ); + } - return array() === $fields ? $available_fields : array_values( $fields ); + return $fields; } /** - * Returns the supported field list in output order. + * Returns the user field definitions, keyed by field name in output order. * - * @since 6.9.0 + * This is the single source of truth for the ability's user fields: the output + * schema uses the definitions directly, while the input schema and field + * normalization use the keys. The field set is deliberately unconditional: + * the registered schemas are a registration-time snapshot, so conditional + * availability (such as `avatar_urls` honoring the `show_avatars` option) is + * enforced per call in {@see self::format_user()} instead of here, where the + * option could change between registration and use. Resolved on every call + * rather than cached: it is the single source of truth for the field set and + * inexpensive to rebuild. * - * @return string[] Supported field names. + * @since 7.1.0 + * + * @return array User field definitions. */ - private function get_fields(): array { - $include_avatars = (bool) get_option( 'show_avatars' ); - - if ( null !== $this->fields && $include_avatars === $this->fields_include_avatars ) { - return $this->fields; - } - - $fields = $this->read_fields; - - if ( $include_avatars ) { - $fields[] = 'avatar_urls'; - } - - $this->fields = array_merge( $fields, $this->sensitive_fields, array( 'roles' ) ); - $this->fields_include_avatars = $include_avatars; + private function get_user_properties(): array { + return array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The user ID.' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'The display name for the user.' ), + ), + 'description' => array( + 'type' => 'string', + 'description' => __( 'Description of the user.' ), + ), + 'url' => array( + 'type' => 'string', + 'description' => __( 'URL of the user.' ), + ), + 'link' => array( + 'type' => 'string', + 'description' => __( 'Author archive URL for the user.' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'An alphanumeric identifier for the user.' ), + ), + 'avatar_urls' => array( + 'type' => 'object', + 'description' => __( 'Avatar URLs for the user, keyed by image size in pixels. A size is null when no avatar URL can be resolved for it. Present when the show_avatars option is enabled.' ), + 'additionalProperties' => array( + 'type' => array( 'string', 'null' ), + ), + ), + 'username' => array( + 'type' => 'string', + 'description' => __( 'Login name for the user. Present when the current user can view it.' ), + ), + 'email' => array( + 'type' => array( 'string', 'null' ), + 'format' => 'email', + 'description' => __( 'The email address for the user. Null when the user has no stored address, or when the stored address is not a valid email. Present when the current user can view it.' ), + ), + 'first_name' => array( + 'type' => 'string', + 'description' => __( 'First name for the user. Present when the current user can view it.' ), + ), + 'last_name' => array( + 'type' => 'string', + 'description' => __( 'Last name for the user. Present when the current user can view it.' ), + ), + 'nickname' => array( + 'type' => 'string', + 'description' => __( 'The nickname for the user. Present when the current user can view it.' ), + ), + 'locale' => array( + 'type' => 'string', + 'description' => __( 'Locale for the user. Present when the current user can view it.' ), + ), + 'registered_date' => array( + 'type' => 'string', + 'format' => 'date-time', + 'description' => __( 'Registration date for the user. Present when the current user can view it.' ), + ), + 'roles' => array( + 'type' => 'array', + 'description' => __( 'Roles assigned to the user. Present when the current user can view them.' ), + // Output roles are not pinned to an enum. The schema is a + // registration-time snapshot, but a role can be registered after + // registration and still be held by a returned user; a snapshot enum + // would reject that legitimate value during output validation and + // fail the whole call. This also matches the REST users controller, + // whose `roles` output items are plain strings. + 'items' => array( + 'type' => 'string', + ), + ), + ); + } - return $this->fields; + /** + * Returns the default field list in output order. + * + * @since 7.1.0 + * + * @return string[] Default field names. + */ + private function get_default_fields(): array { + return array_values( array_intersect( array_keys( $this->get_user_properties() ), self::DEFAULT_FIELDS ) ); } /** * Returns registered role names. * - * @since 6.9.0 + * Deliberately resolved on every call rather than cached, since roles can be + * registered or unregistered at runtime. + * + * @since 7.1.0 * * @return string[] Role names. */ private function get_role_names(): array { - if ( null !== $this->role_names ) { - return $this->role_names; - } - - $this->role_names = array_keys( wp_roles()->roles ); - - return $this->role_names; + return array_keys( wp_roles()->roles ); } /** * Normalizes the requested per-page value to the supported bounds. * - * @since 6.9.0 + * @since 7.1.0 * * @param array $input The ability input. * @return int The clamped per-page value. @@ -528,12 +638,19 @@ private function normalize_per_page( array $input ): int { /** * Normalizes a mixed value into a list of non-empty strings. * - * @since 6.9.0 + * Accepts arrays and CSV strings, since REST `GET` requests deliver list + * input as strings that schema validation coerces only for the check. + * + * @since 7.1.0 * * @param mixed $value Raw value. * @return string[] Normalized strings. */ private function normalize_string_list( $value ): array { + if ( is_string( $value ) ) { + $value = wp_parse_list( $value ); + } + if ( ! is_array( $value ) ) { return array(); } @@ -550,46 +667,104 @@ private function normalize_string_list( $value ): array { return array_values( array_unique( $strings ) ); } + /** + * Normalizes collection-mode included user IDs. + * + * Accepts arrays and CSV strings via {@see wp_parse_id_list()}, which also + * deduplicates IDs that only differ as strings (e.g. `'1'` and `'01'`). + * + * @since 7.1.0 + * + * @param array $input The ability input. + * @return int[] User IDs. + */ + private function normalize_include( array $input ): array { + if ( empty( $input['include'] ) ) { + return array(); + } + + $include = $input['include']; + if ( is_scalar( $include ) ) { + $include = (string) $include; + } elseif ( ! is_array( $include ) ) { + return array(); + } + + return array_values( array_filter( wp_parse_id_list( $include ) ) ); + } + /** * Normalizes the `has_published_posts` collection input. * - * @since 6.9.0 + * Accepts the string and integer forms of `true` that schema validation + * accepts for REST `GET` input, alongside the native boolean. + * + * @since 7.1.0 * * @param array $input The ability input. - * @return bool|string[]|null Normalized query value, or null when absent/invalid. + * @return bool|string[]|null Normalized query value, or null when omitted. */ private function normalize_has_published_posts( array $input ) { if ( ! array_key_exists( 'has_published_posts', $input ) ) { return null; } - if ( true === $input['has_published_posts'] ) { + $value = $input['has_published_posts']; + + if ( true === $value || 1 === $value + || ( is_string( $value ) && in_array( strtolower( $value ), array( 'true', '1' ), true ) ) + ) { return true; } - $post_types = $this->normalize_string_list( $input['has_published_posts'] ); + $post_types = $this->normalize_string_list( $value ); return array() === $post_types ? null : $post_types; } /** - * Builds the input schema for the `core/users` ability. + * Builds the input schema for the `core/read-users` ability. + * + * The ability has five mutually exclusive modes, modeled as a `oneOf` so invalid + * combinations are rejected rather than silently ignored: * - * @since 6.9.0 + * - Get a single readable user by `id`. + * - Get a single readable user by `email`. + * - Get a single readable user by `username`. + * - Get a single readable user by `slug`. + * - Query a collection of readable users. + * + * @since 7.1.0 * * @return array The input JSON Schema. */ private function get_users_input_schema(): array { + /* + * Input enums intentionally reflect roles and post types available at + * ability registration time. This makes the schema a stable contract that + * developers can filter when registering the ability. + */ $role_names = $this->get_role_names(); $public_post_types = $this->get_public_post_types(); $fields = array( 'type' => 'array', 'uniqueItems' => true, + 'minItems' => 1, 'items' => array( 'type' => 'string', - 'enum' => $this->get_fields(), + 'enum' => array_keys( $this->get_user_properties() ), + ), + 'description' => __( 'Limit each returned user to these fields. If omitted, a lean set of common read fields is returned.' ), + ); + $include = array( + 'type' => 'array', + 'uniqueItems' => true, + 'minItems' => 1, + 'items' => array( + 'type' => 'integer', + 'minimum' => 1, ), - 'description' => __( 'Limit each returned user to these fields. If omitted, all fields visible to the current user are returned.' ), + 'description' => __( 'Limit the query to these user IDs. Collection results are limited to users the caller can read, which for callers without permission to list users means only public authors. To read your own account, use a single-user lookup by ID.' ), ); return array( @@ -611,39 +786,39 @@ private function get_users_input_schema(): array { ), array( 'title' => __( 'Get a single readable user by email address' ), - 'required' => array( 'user_email' ), + 'required' => array( 'email' ), 'additionalProperties' => false, 'properties' => array( - 'user_email' => array( + 'email' => array( 'type' => 'string', 'format' => 'email', 'description' => __( 'Retrieve a single readable user by email address. Resolving another user by email requires permission to list or edit users.' ), ), - 'fields' => $fields, + 'fields' => $fields, ), ), array( - 'title' => __( 'Get a single readable user by login' ), - 'required' => array( 'user_login' ), + 'title' => __( 'Get a single readable user by username' ), + 'required' => array( 'username' ), 'additionalProperties' => false, 'properties' => array( - 'user_login' => array( + 'username' => array( 'type' => 'string', - 'description' => __( 'Retrieve a single readable user by login. Resolving another user by login requires permission to list or edit users.' ), + 'description' => __( 'Retrieve a single readable user by username. Resolving another user by username requires permission to list or edit users.' ), ), - 'fields' => $fields, + 'fields' => $fields, ), ), array( - 'title' => __( 'Get a single readable user by nicename' ), - 'required' => array( 'user_nicename' ), + 'title' => __( 'Get a single readable user by slug' ), + 'required' => array( 'slug' ), 'additionalProperties' => false, 'properties' => array( - 'user_nicename' => array( + 'slug' => array( 'type' => 'string', - 'description' => __( 'Retrieve a single readable user by nicename.' ), + 'description' => __( 'Retrieve a single readable user by slug.' ), ), - 'fields' => $fields, + 'fields' => $fields, ), ), array( @@ -676,8 +851,9 @@ private function get_users_input_schema(): array { ), ), ), - 'description' => __( 'Limit results to users with published posts. Use true for all post types, or provide post type names.' ), + 'description' => __( 'Limit results to users with published posts. Use true for all publicly viewable post types, or provide post type names.' ), ), + 'include' => $include, 'fields' => $fields, 'page' => array( 'type' => 'integer', @@ -697,162 +873,116 @@ private function get_users_input_schema(): array { } /** - * Builds the output schema for the `core/users` ability. + * Builds the output schema for the `core/read-users` ability. * - * @since 6.9.0 + * No user field is marked required because the `fields` input lets the caller + * request any subset, and restricted fields are omitted when unavailable. + * Single-user mode returns the user object directly, while collection mode returns + * a paginated wrapper. + * + * @since 7.1.0 * * @return array The output JSON Schema. */ private function get_users_output_schema(): array { - $user_properties = array( - 'id' => array( - 'type' => 'integer', - 'description' => __( 'The user ID.' ), - ), - 'display_name' => array( - 'type' => 'string', - 'description' => __( 'The display name for the user.' ), - ), - 'description' => array( - 'type' => 'string', - 'description' => __( 'Description of the user.' ), - ), - 'user_url' => array( - 'type' => 'string', - 'description' => __( 'URL of the user.' ), - ), - 'link' => array( - 'type' => 'string', - 'description' => __( 'Author archive URL for the user.' ), - ), - 'user_nicename' => array( - 'type' => 'string', - 'description' => __( 'An alphanumeric identifier for the user.' ), - ), - 'user_login' => array( - 'type' => 'string', - 'description' => __( 'Login name for the user. Present when the current user can view it.' ), - ), - 'user_email' => array( - 'type' => 'string', - 'format' => 'email', - 'description' => __( 'The email address for the user. Present when the current user can view it.' ), - ), - 'first_name' => array( - 'type' => 'string', - 'description' => __( 'First name for the user. Present when the current user can view it.' ), - ), - 'last_name' => array( - 'type' => 'string', - 'description' => __( 'Last name for the user. Present when the current user can view it.' ), - ), - 'nickname' => array( - 'type' => 'string', - 'description' => __( 'The nickname for the user. Present when the current user can view it.' ), - ), - 'locale' => array( - 'type' => 'string', - 'description' => __( 'Locale for the user. Present when the current user can view it.' ), - ), - 'user_registered' => array( - 'type' => 'string', - 'format' => 'date-time', - 'description' => __( 'Registration date for the user. Present when the current user can view it.' ), - ), - 'roles' => array( - 'type' => 'array', - 'description' => __( 'Roles assigned to the user. Present when the current user can view them.' ), - 'items' => array( - 'type' => 'string', - 'enum' => $this->get_role_names(), - ), - ), + $user_schema = array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => $this->get_user_properties(), ); - if ( get_option( 'show_avatars' ) ) { - $user_properties['avatar_urls'] = array( - 'type' => 'object', - 'description' => __( 'Avatar URLs for the user at various sizes.' ), - 'additionalProperties' => array( - 'type' => 'string', - ), - ); - } - - return array( + $collection_schema = array( 'type' => 'object', 'additionalProperties' => false, 'required' => array( 'users', 'total', 'total_pages' ), 'properties' => array( 'users' => array( 'type' => 'array', - 'description' => __( 'The readable users matching the request. A single-element list when requested by a unique identifier.' ), - 'items' => array( - 'type' => 'object', - 'additionalProperties' => false, - 'properties' => $user_properties, - ), + 'description' => __( 'The readable users matching the collection request.' ), + 'items' => $user_schema, ), 'total' => array( 'type' => 'integer', - 'description' => __( 'Total number of users matching the query, across all pages, after applying the permission filter to the query. Surfaced over REST as the X-WP-Total header.' ), + 'description' => __( 'Total number of users matching the query, across all pages.' ), ), 'total_pages' => array( 'type' => 'integer', - 'description' => __( 'Total number of query result pages available after applying the permission filter to the query. Surfaced over REST as the X-WP-TotalPages header.' ), + 'description' => __( 'Total number of result pages available for the query.' ), ), ), ); + + return array( + 'oneOf' => array( + $user_schema, + $collection_schema, + ), + ); } /** * Formats a user into the ability output shape. * - * @since 6.9.0 + * Only the requested fields the current user can see are included, except + * `id`, which {@see self::normalize_fields()} always requests. * - * @param WP_User $user The user object. + * @since 7.1.0 + * + * @param \WP_User $user The user object. * @param string[] $fields The requested field names. - * @return array The formatted user data. - */ - private function format_user( WP_User $user, array $fields ): array { + * @return array|\stdClass The formatted user data. An empty + * result is returned as an object so + * it serializes as `{}` rather than + * `[]`; unreachable while `id` is + * ungated, since REST post-processing + * (`_fields`) cannot handle a + * top-level object response. + */ + private function format_user( WP_User $user, array $fields ) { $fields_requested = static function ( string $field ) use ( $fields ): bool { return in_array( $field, $fields, true ); }; $user_id = (int) $user->ID; $can_view_sensitive = $this->is_current_user( $user ) || current_user_can( 'edit_user', $user_id ); - $can_view_roles = current_user_can( 'list_users' ) || current_user_can( 'edit_user', $user_id ); $data = array(); if ( $fields_requested( 'id' ) ) { $data['id'] = $user_id; } - if ( $fields_requested( 'display_name' ) ) { - $data['display_name'] = (string) $user->display_name; + if ( $fields_requested( 'name' ) ) { + $data['name'] = (string) $user->display_name; } if ( $fields_requested( 'description' ) ) { $data['description'] = (string) $user->description; } - if ( $fields_requested( 'user_url' ) ) { - $data['user_url'] = (string) $user->user_url; + if ( $fields_requested( 'url' ) ) { + $data['url'] = (string) $user->user_url; } if ( $fields_requested( 'link' ) ) { $data['link'] = (string) get_author_posts_url( $user_id, $user->user_nicename ); } - if ( $fields_requested( 'user_nicename' ) ) { - $data['user_nicename'] = (string) $user->user_nicename; + if ( $fields_requested( 'slug' ) ) { + $data['slug'] = (string) $user->user_nicename; } + // The schemas always declare avatar_urls; availability is enforced here, + // since the option can change after the schemas are registered. if ( $fields_requested( 'avatar_urls' ) && get_option( 'show_avatars' ) ) { - $data['avatar_urls'] = rest_get_avatar_urls( $user ); + $data['avatar_urls'] = array_map( + static function ( $url ) { + return is_string( $url ) ? $url : null; + }, + rest_get_avatar_urls( $user ) + ); } if ( $can_view_sensitive ) { - if ( $fields_requested( 'user_login' ) ) { - $data['user_login'] = (string) $user->user_login; + if ( $fields_requested( 'username' ) ) { + $data['username'] = (string) $user->user_login; } - if ( $fields_requested( 'user_email' ) ) { - $data['user_email'] = (string) $user->user_email; + if ( $fields_requested( 'email' ) ) { + $data['email'] = is_email( $user->user_email ) ? (string) $user->user_email : null; } if ( $fields_requested( 'first_name' ) ) { $data['first_name'] = (string) $user->first_name; @@ -866,33 +996,24 @@ private function format_user( WP_User $user, array $fields ): array { if ( $fields_requested( 'locale' ) ) { $data['locale'] = (string) get_user_locale( $user ); } - if ( $fields_requested( 'user_registered' ) ) { + if ( $fields_requested( 'registered_date' ) ) { $registered_timestamp = strtotime( $user->user_registered ); if ( false !== $registered_timestamp ) { - $data['user_registered'] = gmdate( 'c', $registered_timestamp ); + $data['registered_date'] = gmdate( 'c', $registered_timestamp ); } } } - if ( $fields_requested( 'roles' ) && $can_view_roles ) { + // Roles reveal a user's privilege level, so they are gated like the other + // sensitive fields: visible only for the current user or a user the caller + // can edit. `list_users` alone (which grants no edit rights) is not enough, + // matching the REST users controller, where `roles` is an edit-context + // field and rows the caller cannot edit are dropped from collections. + if ( $fields_requested( 'roles' ) && $can_view_sensitive ) { $data['roles'] = $this->normalize_string_list( $user->roles ); } - return $data; - } - - /** - * Returns a generic not-found error for missing or inaccessible user lookups. - * - * @since 6.9.0 - * - * @return WP_Error Not found error. - */ - private function not_found_error(): WP_Error { - return new WP_Error( - 'user_not_found', - __( 'The requested user was not found.' ), - array( 'status' => 404 ) - ); + // An empty result must serialize as a JSON object, not an empty array. + return array() === $data ? (object) $data : $data; } } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php index 22c44e5ae2f78..e43a22cc213f3 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php @@ -1,9 +1,9 @@ */ private static $fixture_ids = array(); @@ -22,7 +22,7 @@ class Tests_Abilities_API_WpRegisterCoreUsersAbility extends WP_UnitTestCase { /** * Administrator user ID. * - * @since 6.9.0 + * @since 7.1.0 * @var int */ private $admin_id; @@ -30,7 +30,7 @@ class Tests_Abilities_API_WpRegisterCoreUsersAbility extends WP_UnitTestCase { /** * Subscriber user ID. * - * @since 6.9.0 + * @since 7.1.0 * @var int */ private $subscriber_id; @@ -38,7 +38,7 @@ class Tests_Abilities_API_WpRegisterCoreUsersAbility extends WP_UnitTestCase { /** * Author user ID with a published post. * - * @since 6.9.0 + * @since 7.1.0 * @var int */ private $public_author_id; @@ -46,7 +46,7 @@ class Tests_Abilities_API_WpRegisterCoreUsersAbility extends WP_UnitTestCase { /** * Author post ID. * - * @since 6.9.0 + * @since 7.1.0 * @var int */ private $public_post_id; @@ -54,7 +54,7 @@ class Tests_Abilities_API_WpRegisterCoreUsersAbility extends WP_UnitTestCase { /** * Original show_avatars option. * - * @since 6.9.0 + * @since 7.1.0 * @var mixed */ private $show_avatars; @@ -62,7 +62,7 @@ class Tests_Abilities_API_WpRegisterCoreUsersAbility extends WP_UnitTestCase { /** * Set up before the class. * - * @since 6.9.0 + * @since 7.1.0 */ public static function set_up_before_class(): void { parent::set_up_before_class(); @@ -91,6 +91,10 @@ public static function set_up_before_class(): void { ) ); + if ( is_multisite() ) { + grant_super_admin( self::$fixture_ids['administrator'] ); + } + self::$fixture_ids['editor'] = self::factory()->user->create( array( 'role' => 'editor', @@ -148,11 +152,15 @@ public static function set_up_before_class(): void { /** * Tear down after the class. * - * @since 6.9.0 + * @since 7.1.0 */ public static function tear_down_after_class(): void { wp_delete_post( self::$fixture_ids['public_post'], true ); + if ( is_multisite() ) { + revoke_super_admin( self::$fixture_ids['administrator'] ); + } + foreach ( array( 'administrator', 'editor', 'author', 'contributor', 'subscriber', 'public_author' ) as $fixture_name ) { wp_delete_user( self::$fixture_ids[ $fixture_name ] ); } @@ -175,7 +183,7 @@ public static function tear_down_after_class(): void { /** * Set up test case. * - * @since 6.9.0 + * @since 7.1.0 */ public function set_up(): void { parent::set_up(); @@ -187,18 +195,16 @@ public function set_up(): void { $this->subscriber_id = self::$fixture_ids['subscriber']; $this->public_author_id = self::$fixture_ids['public_author']; $this->public_post_id = self::$fixture_ids['public_post']; - - $this->register_core_users_ability(); } /** * Tear down test case. * - * @since 6.9.0 + * @since 7.1.0 */ public function tear_down(): void { - if ( wp_has_ability( 'core/users' ) ) { - wp_unregister_ability( 'core/users' ); + if ( wp_has_ability( 'core/read-users' ) ) { + wp_unregister_ability( 'core/read-users' ); } update_option( 'show_avatars', $this->show_avatars ); @@ -208,13 +214,13 @@ public function tear_down(): void { } /** - * Registers the core/users ability inside a faked init action. + * Registers the core/read-users ability inside a faked init action. * - * @since 6.9.0 + * @since 7.1.0 */ private function register_core_users_ability(): void { - if ( wp_has_ability( 'core/users' ) ) { - wp_unregister_ability( 'core/users' ); + if ( wp_has_ability( 'core/read-users' ) ) { + wp_unregister_ability( 'core/read-users' ); } global $wp_current_filter; @@ -229,53 +235,50 @@ private function register_core_users_ability(): void { /** * The ability is registered in the `user` category and flagged read-only. * - * @ticket 64146 + * @ticket 64657 */ public function test_core_users_ability_is_registered(): void { $this->register_core_users_ability(); - $ability = wp_get_ability( 'core/users' ); + $ability = wp_get_ability( 'core/read-users' ); $this->assertInstanceOf( WP_Ability::class, $ability, 'The users ability should be registered.' ); $this->assertSame( 'user', $ability->get_category(), 'The users ability should use the user category.' ); $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ), 'The users ability should be exposed over REST.' ); - $this->assertTrue( $ability->get_meta_item( 'pagination', false ), 'The users ability should advertise pagination support.' ); $annotations = $ability->get_meta_item( 'annotations', array() ); $this->assertTrue( $annotations['readonly'], 'The users ability should be marked read-only.' ); $this->assertFalse( $annotations['destructive'], 'The users ability should not be marked destructive.' ); } - /** * The input schema exposes strict single-user and collection modes. * - * @ticket 64146 + * @ticket 64657 */ public function test_core_users_input_schema_exposes_strict_modes(): void { $this->register_core_users_ability(); - $schema = wp_get_ability( 'core/users' )->get_input_schema(); + $schema = wp_get_ability( 'core/read-users' )->get_input_schema(); $this->assertSame( 'object', $schema['type'], 'The users ability input schema should describe an object.' ); $this->assertEquals( (object) array(), $schema['default'], 'The users ability input schema should default to empty collection mode.' ); $this->assertCount( 5, $schema['oneOf'], 'The users ability input schema should expose four lookup modes and collection mode.' ); $this->assertSame( array( 'id' ), $schema['oneOf'][0]['required'], 'The first input mode should require an ID.' ); - $this->assertSame( array( 'user_email' ), $schema['oneOf'][1]['required'], 'The second input mode should require a user email.' ); - $this->assertSame( array( 'user_login' ), $schema['oneOf'][2]['required'], 'The third input mode should require a user login.' ); - $this->assertSame( array( 'user_nicename' ), $schema['oneOf'][3]['required'], 'The fourth input mode should require a user nicename.' ); + $this->assertSame( array( 'email' ), $schema['oneOf'][1]['required'], 'The second input mode should require an email.' ); + $this->assertSame( array( 'username' ), $schema['oneOf'][2]['required'], 'The third input mode should require a username.' ); + $this->assertSame( array( 'slug' ), $schema['oneOf'][3]['required'], 'The fourth input mode should require a slug.' ); $this->assertArrayNotHasKey( 'required', $schema['oneOf'][4], 'Collection mode should allow an empty request.' ); $collection_properties = $schema['oneOf'][4]['properties']; $this->assertEqualSets( - array( 'roles', 'has_published_posts', 'fields', 'page', 'per_page' ), + array( 'roles', 'has_published_posts', 'include', 'fields', 'page', 'per_page' ), array_keys( $collection_properties ), 'Collection mode should expose only the supported query parameters.' ); $excluded_properties = array( 'search', - 'include', 'exclude', 'email', 'username', @@ -298,6 +301,7 @@ public function test_core_users_input_schema_exposes_strict_modes(): void { $fields = $schema['oneOf'][4]['properties']['fields']['items']['enum']; $this->assertContains( 'roles', $fields, 'The fields enum should expose the roles field.' ); $this->assertContains( 'avatar_urls', $fields, 'The fields enum should expose avatar_urls when avatars are enabled.' ); + $this->assertSame( 1, $schema['oneOf'][4]['properties']['fields']['minItems'], 'The fields option should require at least one field when provided.' ); $role_names = $schema['oneOf'][4]['properties']['roles']['items']['enum']; $this->assertEqualSets( array_keys( wp_roles()->roles ), $role_names, 'The roles query enum should expose registered role names.' ); @@ -306,128 +310,246 @@ public function test_core_users_input_schema_exposes_strict_modes(): void { $this->assertContains( 'post', $post_type_names, 'The has_published_posts enum should expose public post types.' ); $this->assertNotContains( 'revision', $post_type_names, 'The has_published_posts enum should omit non-public post types.' ); - $output_schema = wp_get_ability( 'core/users' )->get_output_schema(); - $user_properties = $output_schema['properties']['users']['items']['properties']; - $this->assertSame( 'date-time', $user_properties['user_registered']['format'], 'The user_registered output schema should use date-time format.' ); - $this->assertEqualSets( array_keys( wp_roles()->roles ), $user_properties['roles']['items']['enum'], 'The roles output enum should expose registered role names.' ); + $this->assertSame( 1, $collection_properties['include']['minItems'], 'The include option should require at least one user ID.' ); + $this->assertTrue( $collection_properties['include']['uniqueItems'], 'The include option should reject duplicate user IDs.' ); + $this->assertSame( 'integer', $collection_properties['include']['items']['type'], 'The include option should contain user IDs.' ); + $this->assertSame( 1, $collection_properties['include']['items']['minimum'], 'The include option should contain positive user IDs.' ); + + $output_schema = wp_get_ability( 'core/read-users' )->get_output_schema(); + $user_schema = $output_schema['oneOf'][0]; + $collection_schema = $output_schema['oneOf'][1]; + $user_properties = $user_schema['properties']; + + $this->assertCount( 2, $output_schema['oneOf'], 'The output schema should describe single-user and collection responses.' ); + $this->assertArrayNotHasKey( 'required', $user_schema, 'Single-user fields should remain optional.' ); + $this->assertSame( array( 'users', 'total', 'total_pages' ), $collection_schema['required'], 'Collection responses should require the wrapper fields.' ); + $this->assertSame( 'date-time', $user_properties['registered_date']['format'], 'The registered_date output schema should use date-time format.' ); + $this->assertSame( 'string', $user_properties['roles']['items']['type'], 'The roles output schema should describe role name strings.' ); + $this->assertArrayNotHasKey( 'enum', $user_properties['roles']['items'], 'The roles output schema must not pin an enum, so a role registered after the schema snapshot still validates.' ); } /** - * Avatar fields are not requestable when avatars are disabled. + * Avatar availability is enforced when formatting, not in the schemas. * - * @ticket 64146 + * The registered schemas are a registration-time snapshot, so they always + * declare avatar_urls; the show_avatars option is honored per call instead, + * even when it changes after the ability is registered. + * + * @ticket 64657 */ public function test_avatar_urls_respects_show_avatars_option(): void { update_option( 'show_avatars', 0 ); $this->register_core_users_ability(); - $ability = wp_get_ability( 'core/users' ); + $ability = wp_get_ability( 'core/read-users' ); $input_schema = $ability->get_input_schema(); $output_schema = $ability->get_output_schema(); - $this->assertNotContains( 'avatar_urls', $input_schema['oneOf'][0]['properties']['fields']['items']['enum'], 'The fields enum should omit avatar_urls when avatars are disabled.' ); - $this->assertArrayNotHasKey( 'avatar_urls', $output_schema['properties']['users']['items']['properties'], 'The output schema should omit avatar_urls when avatars are disabled.' ); + $this->assertContains( 'avatar_urls', $input_schema['oneOf'][0]['properties']['fields']['items']['enum'], 'The fields enum should keep declaring avatar_urls when avatars are disabled.' ); + $this->assertArrayHasKey( 'avatar_urls', $output_schema['oneOf'][0]['properties'], 'The output schema should keep declaring avatar_urls when avatars are disabled.' ); wp_set_current_user( $this->subscriber_id ); $result = $ability->execute( array( 'id' => $this->subscriber_id ) ); $this->assertIsArray( $result, 'The current user should still be readable when avatars are disabled.' ); - $this->assertArrayNotHasKey( 'avatar_urls', $result['users'][0], 'The ability result should omit avatar_urls when avatars are disabled.' ); + $this->assertArrayNotHasKey( 'avatar_urls', $result, 'The ability result should omit avatar_urls when avatars are disabled.' ); + + // Enabling the option after registration takes effect immediately; the + // registration-time schema must not reject the field. + update_option( 'show_avatars', 1 ); + $result = $ability->execute( array( 'id' => $this->subscriber_id ) ); + + $this->assertIsArray( $result, 'Default-fields lookups should succeed after avatars are enabled post-registration.' ); + $this->assertArrayHasKey( 'avatar_urls', $result, 'The ability result should include avatar_urls once avatars are enabled.' ); + } + + /** + * Omitted fields return a lean default shape. + * + * @ticket 64657 + */ + public function test_omitted_fields_return_lean_defaults(): void { + wp_set_current_user( $this->subscriber_id ); + $this->register_core_users_ability(); + + $result = wp_get_ability( 'core/read-users' )->execute( array( 'id' => $this->subscriber_id ) ); + + $this->assertIsArray( $result, 'A current-user lookup should return an array.' ); + $this->assertSame( + array( 'id', 'name', 'link', 'slug', 'avatar_urls' ), + array_keys( $result ), + 'Omitted fields should return the lean default field set.' + ); + $this->assertArrayNotHasKey( 'email', $result, 'Default fields should not include sensitive user fields.' ); + $this->assertArrayNotHasKey( 'description', $result, 'Default fields should omit less common read-context fields.' ); } /** * Logged-out users cannot run the ability. * - * @ticket 64146 + * @ticket 64657 */ public function test_core_users_requires_logged_in_user(): void { $this->register_core_users_ability(); - $result = wp_get_ability( 'core/users' )->execute( array() ); + $result = wp_get_ability( 'core/read-users' )->execute( array() ); $this->assertWPError( $result, 'Logged-out users should not be allowed to execute the users ability.' ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Logged-out users should receive an invalid permissions error.' ); } /** - * The current user can read themselves by ID, user email, and user login. + * The current user can read themselves by ID, email, and username. * - * @ticket 64146 + * @ticket 64657 */ public function test_current_user_can_read_themselves_by_sensitive_identifiers(): void { wp_set_current_user( $this->subscriber_id ); $this->register_core_users_ability(); - $ability = wp_get_ability( 'core/users' ); + $ability = wp_get_ability( 'core/read-users' ); $result = $ability->execute( array( 'id' => $this->subscriber_id, - 'fields' => array( 'id', 'user_email', 'user_login', 'roles' ), + 'fields' => array( 'id', 'email', 'username', 'roles' ), ) ); $this->assertIsArray( $result, 'A user should be able to read themselves by ID.' ); - $this->assertSame( $this->subscriber_id, $result['users'][0]['id'], 'The ID lookup should return the current user.' ); - $this->assertSame( 'core-users-ability-subscriber@example.com', $result['users'][0]['user_email'], 'The current user should receive their own email.' ); - $this->assertSame( 'core_users_ability_subscriber', $result['users'][0]['user_login'], 'The current user should receive their own login.' ); - $this->assertContains( 'subscriber', $result['users'][0]['roles'], 'The current user should receive their own roles.' ); + $this->assertSame( $this->subscriber_id, $result['id'], 'The ID lookup should return the current user.' ); + $this->assertSame( 'core-users-ability-subscriber@example.com', $result['email'], 'The current user should receive their own email.' ); + $this->assertSame( 'core_users_ability_subscriber', $result['username'], 'The current user should receive their own username.' ); + $this->assertContains( 'subscriber', $result['roles'], 'The current user should receive their own roles.' ); - $result = $ability->execute( array( 'user_email' => 'core-users-ability-subscriber@example.com' ) ); + $result = $ability->execute( array( 'email' => 'core-users-ability-subscriber@example.com' ) ); $this->assertIsArray( $result, 'A user should be able to read themselves by email.' ); - $this->assertSame( $this->subscriber_id, $result['users'][0]['id'], 'The email lookup should return the current user.' ); + $this->assertSame( $this->subscriber_id, $result['id'], 'The email lookup should return the current user.' ); - $result = $ability->execute( array( 'user_login' => 'core_users_ability_subscriber' ) ); - $this->assertIsArray( $result, 'A user should be able to read themselves by login.' ); - $this->assertSame( $this->subscriber_id, $result['users'][0]['id'], 'The login lookup should return the current user.' ); + $result = $ability->execute( array( 'username' => 'core_users_ability_subscriber' ) ); + $this->assertIsArray( $result, 'A user should be able to read themselves by username.' ); + $this->assertSame( $this->subscriber_id, $result['id'], 'The username lookup should return the current user.' ); $result = $ability->execute( array( 'id' => $this->subscriber_id, - 'fields' => array( 'id', 'user_registered' ), + 'fields' => array( 'id', 'registered_date' ), ) ); $this->assertIsArray( $result, 'A user should be able to request their registration date.' ); $this->assertSame( gmdate( 'c', strtotime( get_userdata( $this->subscriber_id )->user_registered ) ), - $result['users'][0]['user_registered'], + $result['registered_date'], 'The registration date should be formatted as an ISO 8601 date-time string.' ); } /** - * Public-author users can be read by ID or user nicename by logged-in users. + * Public-author users can be read by ID or slug by logged-in users. * - * @ticket 64146 + * @ticket 64657 */ - public function test_public_author_can_be_read_by_id_and_user_nicename(): void { + public function test_public_author_can_be_read_by_id_and_slug(): void { wp_set_current_user( $this->subscriber_id ); $this->register_core_users_ability(); - $ability = wp_get_ability( 'core/users' ); + $ability = wp_get_ability( 'core/read-users' ); $result = $ability->execute( array( 'id' => $this->public_author_id, - 'fields' => array( 'id', 'user_nicename', 'user_email' ), + 'fields' => array( 'id', 'slug', 'email' ), ) ); $this->assertIsArray( $result, 'A logged-in user should be able to read a public author by ID.' ); - $this->assertSame( $this->public_author_id, $result['users'][0]['id'], 'The ID lookup should return the public author.' ); - $this->assertSame( 'core-users-ability-author', $result['users'][0]['user_nicename'], 'The public author nicename should be returned.' ); - $this->assertArrayNotHasKey( 'user_email', $result['users'][0], 'Public-author access should not expose another user email.' ); + $this->assertSame( $this->public_author_id, $result['id'], 'The ID lookup should return the public author.' ); + $this->assertSame( 'core-users-ability-author', $result['slug'], 'The public author slug should be returned.' ); + $this->assertArrayNotHasKey( 'email', $result, 'Public-author access should not expose another user email.' ); - $result = $ability->execute( array( 'user_nicename' => 'core-users-ability-author' ) ); + $result = $ability->execute( array( 'slug' => 'core-users-ability-author' ) ); - $this->assertIsArray( $result, 'A logged-in user should be able to read a public author by nicename.' ); - $this->assertSame( $this->public_author_id, $result['users'][0]['id'], 'The nicename lookup should return the public author.' ); + $this->assertIsArray( $result, 'A logged-in user should be able to read a public author by slug.' ); + $this->assertSame( $this->public_author_id, $result['id'], 'The slug lookup should return the public author.' ); } /** - * User email and login lookups for another user require list or edit permissions across roles. + * A single-user lookup resolves an author whose posts are all private, while + * collection mode does not. * - * @ticket 64146 + * The two modes enforce access differently. A single-user lookup calls + * count_user_posts(), which counts private posts for a caller holding + * read_private_posts, matching the REST users controller. Collection mode is + * filtered by WP_User_Query's has_published_posts, which matches published posts + * only, so it never returns such an author. + * + * This exposes nothing new. An editor who can read the private post already sees + * its author through the post itself. + * + * @ticket 64657 + */ + public function test_private_only_author_resolves_by_lookup_but_not_in_a_collection(): void { + $author_id = self::factory()->user->create( + array( + 'role' => 'author', + 'user_nicename' => 'core-users-ability-private-author', + ) + ); + self::factory()->post->create( + array( + 'post_author' => $author_id, + 'post_status' => 'private', + 'post_type' => 'post', + ) + ); + + wp_set_current_user( self::$fixture_ids['editor'] ); + $this->register_core_users_ability(); + + $this->assertFalse( current_user_can( 'list_users' ), 'An editor should not be able to list users.' ); + $this->assertFalse( current_user_can( 'edit_user', $author_id ), 'An editor should not be able to edit the author.' ); + $this->assertTrue( current_user_can( 'read_private_posts' ), 'An editor should be able to read private posts.' ); + + $ability = wp_get_ability( 'core/read-users' ); + + $result = $ability->execute( + array( + 'slug' => 'core-users-ability-private-author', + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'An editor should resolve an author whose posts are all private.' ); + $this->assertSame( $author_id, $result['id'], 'The slug lookup should return the private-only author.' ); + + $result = $ability->execute( + array( + 'include' => array( $author_id ), + 'fields' => array( 'id' ), + 'per_page' => 100, + ) + ); + + $this->assertIsArray( $result, 'The collection request should succeed.' ); + $this->assertSame( array(), $result['users'], 'Collection mode should omit an author whose posts are all private.' ); + + // The lookup is gated on the capability, not merely on being logged in. + wp_set_current_user( $this->subscriber_id ); + + $result = $ability->execute( + array( + 'slug' => 'core-users-ability-private-author', + 'fields' => array( 'id' ), + ) + ); + + $this->assertWPError( $result, 'A subscriber should not resolve an author whose posts are all private.' ); + } + + /** + * Email and username lookups for another user require list or edit permissions across roles. + * + * @ticket 64657 * * @dataProvider data_roles_for_sensitive_identifier_lookup_permissions * @@ -438,32 +560,32 @@ public function test_roles_have_expected_sensitive_identifier_lookup_permissions wp_set_current_user( self::$fixture_ids[ $role ] ); $this->register_core_users_ability(); - $ability = wp_get_ability( 'core/users' ); + $ability = wp_get_ability( 'core/read-users' ); - $result = $ability->execute( array( 'user_email' => 'core-users-ability-author@example.com' ) ); + $result = $ability->execute( array( 'email' => 'core-users-ability-author@example.com' ) ); if ( $can_resolve ) { $this->assertIsArray( $result, sprintf( 'The %s role should be able to resolve another user by email.', $role ) ); - $this->assertSame( $this->public_author_id, $result['users'][0]['id'], sprintf( 'The email lookup should return the public author for the %s role.', $role ) ); + $this->assertSame( $this->public_author_id, $result['id'], sprintf( 'The email lookup should return the public author for the %s role.', $role ) ); } else { $this->assertWPError( $result, sprintf( 'The %s role should not be able to resolve another user by email.', $role ) ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), sprintf( 'Email lookup denial for the %s role should use the invalid permissions error.', $role ) ); } - $result = $ability->execute( array( 'user_login' => 'core_users_ability_author' ) ); + $result = $ability->execute( array( 'username' => 'core_users_ability_author' ) ); if ( $can_resolve ) { - $this->assertIsArray( $result, sprintf( 'The %s role should be able to resolve another user by login.', $role ) ); - $this->assertSame( $this->public_author_id, $result['users'][0]['id'], sprintf( 'The login lookup should return the public author for the %s role.', $role ) ); + $this->assertIsArray( $result, sprintf( 'The %s role should be able to resolve another user by username.', $role ) ); + $this->assertSame( $this->public_author_id, $result['id'], sprintf( 'The username lookup should return the public author for the %s role.', $role ) ); return; } - $this->assertWPError( $result, sprintf( 'The %s role should not be able to resolve another user by login.', $role ) ); - $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), sprintf( 'Login lookup denial for the %s role should use the invalid permissions error.', $role ) ); + $this->assertWPError( $result, sprintf( 'The %s role should not be able to resolve another user by username.', $role ) ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), sprintf( 'Username lookup denial for the %s role should use the invalid permissions error.', $role ) ); } /** * Data provider for role-based sensitive identifier lookup checks. * - * @ticket 64146 + * @ticket 64657 * * @return array */ @@ -480,13 +602,13 @@ public static function data_roles_for_sensitive_identifier_lookup_permissions(): /** * Empty collection mode returns only public authors for users without list_users. * - * @ticket 64146 + * @ticket 64657 */ public function test_empty_collection_mode_restricts_users_without_list_users_to_public_authors(): void { wp_set_current_user( $this->subscriber_id ); $this->register_core_users_ability(); - $result = wp_get_ability( 'core/users' )->execute( array() ); + $result = wp_get_ability( 'core/read-users' )->execute( array() ); $this->assertIsArray( $result, 'Collection mode should return an array for logged-in users.' ); $this->assertContains( $this->public_author_id, wp_list_pluck( $result['users'], 'id' ), 'Collection mode should include public authors.' ); @@ -496,21 +618,254 @@ public function test_empty_collection_mode_restricts_users_without_list_users_to $this->assertIsInt( $result['total_pages'], 'Collection mode should include an integer total_pages value.' ); } + /** + * Collection include limits results to the requested users. + * + * @ticket 64657 + */ + public function test_collection_include_limits_results(): void { + wp_set_current_user( $this->admin_id ); + $this->register_core_users_ability(); + + $result = wp_get_ability( 'core/read-users' )->execute( + array( + 'include' => array( $this->public_author_id, $this->subscriber_id ), + 'fields' => array( 'id' ), + 'per_page' => 100, + ) + ); + + $this->assertIsArray( $result, 'An included user query should return an array.' ); + $this->assertEqualSets( + array( $this->public_author_id, $this->subscriber_id ), + wp_list_pluck( $result['users'], 'id' ), + 'Included user queries should return exactly the readable included users.' + ); + $this->assertSame( 2, $result['total'], 'Included user queries should report the matching included total.' ); + } + + /** + * Collection results keep the default ordering, not the order of the include list. + * + * The query deliberately leaves `orderby` alone rather than setting it to + * `include`, so that queries over the same users can share a WP_User_Query cache + * entry. This test fails if that ordering is ever applied. + * + * @ticket 64657 + */ + public function test_include_order_does_not_control_the_result_order(): void { + wp_set_current_user( $this->admin_id ); + $this->register_core_users_ability(); + + $ability = wp_get_ability( 'core/read-users' ); + + // WP_User_Query orders by user_login ascending by default, and + // 'core_users_ability_admin' sorts before 'core_users_ability_subscriber'. + $expected = array( $this->admin_id, $this->subscriber_id ); + + foreach ( array( $expected, array_reverse( $expected ) ) as $include ) { + $result = $ability->execute( + array( + 'include' => $include, + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'An included user query should succeed.' ); + $this->assertSame( + $expected, + wp_list_pluck( $result['users'], 'id' ), + 'Included users should be ordered by login, whichever order the include list uses.' + ); + } + } + + /** + * Collection include still respects row-level read permissions. + * + * @ticket 64657 + */ + public function test_collection_include_respects_read_permissions(): void { + wp_set_current_user( $this->subscriber_id ); + $this->register_core_users_ability(); + + $result = wp_get_ability( 'core/read-users' )->execute( + array( + 'include' => array( $this->admin_id, $this->public_author_id ), + 'fields' => array( 'id' ), + 'per_page' => 100, + ) + ); + + $this->assertIsArray( $result, 'An included user query should return an array.' ); + $this->assertSame( + array( $this->public_author_id ), + wp_list_pluck( $result['users'], 'id' ), + 'Included user queries should omit users the current user cannot read.' + ); + } + + /** + * A post type that is public but not publicly viewable never makes a user visible. + * + * `public => true` does not mean a post type can be viewed on the front end, so the + * ability uses is_post_type_viewable() everywhere. A boolean has_published_posts + * must not fall back to WP_User_Query's own reading of `true`, which resolves to + * get_post_types( array( 'public' => true ) ) and would match such an author. + * + * @ticket 64657 + */ + public function test_public_but_not_viewable_post_type_never_exposes_an_author(): void { + register_post_type( + 'wpai_not_viewable_pt', + array( + 'public' => true, + 'publicly_queryable' => false, + ) + ); + + $this->assertContains( 'wpai_not_viewable_pt', get_post_types( array( 'public' => true ) ), 'The post type should be in the public set.' ); + $this->assertFalse( is_post_type_viewable( 'wpai_not_viewable_pt' ), 'The post type should not be publicly viewable.' ); + + $author_id = self::factory()->user->create( + array( + 'role' => 'author', + 'user_nicename' => 'core-users-ability-hidden-author', + ) + ); + self::factory()->post->create( + array( + 'post_author' => $author_id, + 'post_status' => 'publish', + 'post_type' => 'wpai_not_viewable_pt', + ) + ); + + wp_set_current_user( $this->admin_id ); + $this->register_core_users_ability(); + + $ability = wp_get_ability( 'core/read-users' ); + + $schema = $ability->get_input_schema(); + $this->assertNotContains( + 'wpai_not_viewable_pt', + $schema['oneOf'][4]['properties']['has_published_posts']['oneOf'][1]['items']['enum'], + 'The post type should not be offered in the has_published_posts enum.' + ); + + $result = $ability->execute( + array( + 'has_published_posts' => true, + 'fields' => array( 'id' ), + 'per_page' => 100, + ) + ); + + $this->assertIsArray( $result, 'A boolean published-posts filter should execute.' ); + $this->assertNotContains( + $author_id, + wp_list_pluck( $result['users'], 'id' ), + 'A boolean published-posts filter should not match an author whose only post type is not publicly viewable.' + ); + + // The same author stays hidden from the single-user lookup used for public authors. + wp_set_current_user( $this->subscriber_id ); + + $result = $ability->execute( + array( + 'slug' => 'core-users-ability-hidden-author', + 'fields' => array( 'id' ), + ) + ); + + $this->assertWPError( $result, 'A non-viewable post type should not make its author publicly readable.' ); + + unregister_post_type( 'wpai_not_viewable_pt' ); + } + + /** + * Collection mode excludes a caller with no published posts, even via include, + * while single-user mode still lets them read themselves. + * + * This mirrors WordPress core: a caller without permission to list users only sees + * public authors in a collection, and reads their own account through a single-user + * lookup (like the REST `/users/me` endpoint) rather than the collection. + * + * @ticket 64657 + */ + public function test_collection_mode_excludes_self_without_published_posts(): void { + wp_set_current_user( $this->subscriber_id ); + $this->register_core_users_ability(); + + $ability = wp_get_ability( 'core/read-users' ); + + $collection = $ability->execute( + array( + 'include' => array( $this->subscriber_id ), + 'fields' => array( 'id' ), + 'per_page' => 100, + ) + ); + + $this->assertIsArray( $collection, 'A collection include query should return an array.' ); + $this->assertSame( array(), $collection['users'], 'Collection mode should exclude a caller with no published posts, even when they include their own ID.' ); + $this->assertSame( 0, $collection['total'], 'The excluded self should not be counted in the collection total.' ); + + $single = $ability->execute( + array( + 'id' => $this->subscriber_id, + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $single, 'A single-user self lookup should return an array.' ); + $this->assertSame( $this->subscriber_id, $single['id'], 'Single-user mode should still let the caller read themselves.' ); + } + + /** + * Include is a collection-only option. + * + * @ticket 64657 + */ + public function test_include_cannot_be_combined_with_single_user_modes(): void { + wp_set_current_user( $this->admin_id ); + $this->register_core_users_ability(); + + $by_id = wp_get_ability( 'core/read-users' )->execute( + array( + 'id' => $this->subscriber_id, + 'include' => array( $this->subscriber_id ), + ) + ); + + $by_username = wp_get_ability( 'core/read-users' )->execute( + array( + 'username' => 'core_users_ability_subscriber', + 'include' => array( $this->subscriber_id ), + ) + ); + + $this->assertWPError( $by_id, 'ID plus include should fail validation.' ); + $this->assertWPError( $by_username, 'Username plus include should fail validation.' ); + $this->assertSame( 'ability_invalid_input', $by_id->get_error_code(), 'ID plus include should return an input error.' ); + $this->assertSame( 'ability_invalid_input', $by_username->get_error_code(), 'Username plus include should return an input error.' ); + } + /** * Collection mode for users without list_users honors public post type filters. * - * @ticket 64146 + * @ticket 64657 */ public function test_collection_mode_for_users_without_list_users_uses_public_post_types(): void { register_post_type( - 'wp_public_pt', + 'wpai_public_pt', array( 'public' => true, 'show_in_rest' => false, ) ); register_post_type( - 'wp_private_pt', + 'wpai_private_pt', array( 'public' => false, ) @@ -522,33 +877,33 @@ public function test_collection_mode_for_users_without_list_users_uses_public_po array( 'post_author' => $public_author_id, 'post_status' => 'publish', - 'post_type' => 'wp_public_pt', + 'post_type' => 'wpai_public_pt', ) ); $private_post_id = self::factory()->post->create( array( 'post_author' => $private_author_id, 'post_status' => 'publish', - 'post_type' => 'wp_private_pt', + 'post_type' => 'wpai_private_pt', ) ); try { - $this->assertFalse( get_post_type_object( 'wp_public_pt' )->show_in_rest, 'The public fixture post type should remain hidden from REST.' ); + $this->assertFalse( get_post_type_object( 'wpai_public_pt' )->show_in_rest, 'The public fixture post type should remain hidden from REST.' ); wp_set_current_user( $this->subscriber_id ); $this->register_core_users_ability(); - $ability = wp_get_ability( 'core/users' ); + $ability = wp_get_ability( 'core/read-users' ); $schema = $ability->get_input_schema(); $enum = $schema['oneOf'][4]['properties']['has_published_posts']['oneOf'][1]['items']['enum']; - $this->assertContains( 'wp_public_pt', $enum, 'The has_published_posts enum should include public post types even when hidden from REST.' ); - $this->assertNotContains( 'wp_private_pt', $enum, 'The has_published_posts enum should omit private post types.' ); + $this->assertContains( 'wpai_public_pt', $enum, 'The has_published_posts enum should include public post types even when hidden from REST.' ); + $this->assertNotContains( 'wpai_private_pt', $enum, 'The has_published_posts enum should omit private post types.' ); $result = $ability->execute( array( - 'has_published_posts' => array( 'wp_public_pt' ), + 'has_published_posts' => array( 'wpai_public_pt' ), 'fields' => array( 'id' ), 'per_page' => 100, ) @@ -562,7 +917,7 @@ public function test_collection_mode_for_users_without_list_users_uses_public_po $result = $ability->execute( array( - 'has_published_posts' => array( 'wp_private_pt' ), + 'has_published_posts' => array( 'wpai_private_pt' ), 'fields' => array( 'id' ), ) ); @@ -574,21 +929,21 @@ public function test_collection_mode_for_users_without_list_users_uses_public_po wp_delete_post( $private_post_id, true ); wp_delete_user( $public_author_id ); wp_delete_user( $private_author_id ); - unregister_post_type( 'wp_public_pt' ); - unregister_post_type( 'wp_private_pt' ); + unregister_post_type( 'wpai_public_pt' ); + unregister_post_type( 'wpai_private_pt' ); } } /** * Administrators can query by role and receive roles. * - * @ticket 64146 + * @ticket 64657 */ public function test_admin_can_query_by_role_and_receive_roles(): void { wp_set_current_user( $this->admin_id ); $this->register_core_users_ability(); - $result = wp_get_ability( 'core/users' )->execute( + $result = wp_get_ability( 'core/read-users' )->execute( array( 'roles' => array( 'author' ), 'fields' => array( 'id', 'roles' ), @@ -606,7 +961,7 @@ public function test_admin_can_query_by_role_and_receive_roles(): void { /** * Field visibility for another public author matches the current user's role. * - * @ticket 64146 + * @ticket 64657 * * @dataProvider data_roles_for_another_public_author_field_visibility * @@ -618,33 +973,33 @@ public function test_roles_have_expected_field_visibility_for_another_public_aut wp_set_current_user( self::$fixture_ids[ $role ] ); $this->register_core_users_ability(); - $result = wp_get_ability( 'core/users' )->execute( + $result = wp_get_ability( 'core/read-users' )->execute( array( 'id' => $this->public_author_id, - 'fields' => array( 'id', 'user_email', 'roles' ), + 'fields' => array( 'id', 'email', 'roles' ), ) ); $this->assertIsArray( $result, sprintf( 'The %s role should be able to execute a public-author lookup.', $role ) ); - $this->assertSame( $this->public_author_id, $result['users'][0]['id'], sprintf( 'The %s role should receive the requested public author.', $role ) ); - $this->assertSame( $can_view_sensitive, array_key_exists( 'user_email', $result['users'][0] ), sprintf( 'The %s role email visibility should match expectations.', $role ) ); - $this->assertSame( $can_view_roles, array_key_exists( 'roles', $result['users'][0] ), sprintf( 'The %s role roles visibility should match expectations.', $role ) ); + $this->assertSame( $this->public_author_id, $result['id'], sprintf( 'The %s role should receive the requested public author.', $role ) ); + $this->assertSame( $can_view_sensitive, array_key_exists( 'email', $result ), sprintf( 'The %s role email visibility should match expectations.', $role ) ); + $this->assertSame( $can_view_roles, array_key_exists( 'roles', $result ), sprintf( 'The %s role roles visibility should match expectations.', $role ) ); if ( $can_view_sensitive ) { - $this->assertSame( 'core-users-ability-author@example.com', $result['users'][0]['user_email'], sprintf( 'The %s role should receive the public author email when allowed.', $role ) ); + $this->assertSame( 'core-users-ability-author@example.com', $result['email'], sprintf( 'The %s role should receive the public author email when allowed.', $role ) ); } if ( ! $can_view_roles ) { return; } - $this->assertContains( 'author', $result['users'][0]['roles'], sprintf( 'The %s role should receive the public author role when allowed.', $role ) ); + $this->assertContains( 'author', $result['roles'], sprintf( 'The %s role should receive the public author role when allowed.', $role ) ); } /** * Data provider for role-based field visibility checks. * - * @ticket 64146 + * @ticket 64657 * * @return array */ @@ -658,16 +1013,91 @@ public static function data_roles_for_another_public_author_field_visibility(): ); } + /** + * A caller who can list users but not edit them does not receive another + * user's roles. + * + * `list_users` grants no edit rights, so it must not disclose a user's roles, + * matching the REST users controller, which confines `roles` to users the + * caller can edit. + * + * @ticket 64657 + */ + public function test_list_users_without_edit_does_not_expose_other_users_roles(): void { + add_role( + 'core_users_ability_list_only', + 'List Only', + array( + 'read' => true, + 'list_users' => true, + ) + ); + + $lister_id = self::factory()->user->create( array( 'role' => 'core_users_ability_list_only' ) ); + + try { + wp_set_current_user( $lister_id ); + $this->register_core_users_ability(); + + $result = wp_get_ability( 'core/read-users' )->execute( + array( + 'id' => $this->public_author_id, + 'fields' => array( 'id', 'roles' ), + ) + ); + + $this->assertIsArray( $result, 'A list-only caller should still resolve a public author.' ); + $this->assertArrayNotHasKey( 'roles', $result, 'A caller who cannot edit a user must not receive that user\'s roles.' ); + } finally { + wp_delete_user( $lister_id ); + remove_role( 'core_users_ability_list_only' ); + } + } + + /** + * A role registered after the ability's schema snapshot does not fail output + * validation when a returned user holds it. + * + * The output schema is a registration-time snapshot; a role added afterwards + * still appears on a user, so the roles output must not be pinned to a frozen + * enum that would reject the value and fail the whole call. + * + * @ticket 64657 + */ + public function test_roles_registered_after_registration_do_not_fail_output_validation(): void { + wp_set_current_user( $this->admin_id ); + $this->register_core_users_ability(); + + // Register the role only after the ability (and its output schema) exist. + add_role( 'core_users_ability_late_role', 'Late Role', array( 'read' => true ) ); + $user_id = self::factory()->user->create( array( 'role' => 'core_users_ability_late_role' ) ); + + try { + $result = wp_get_ability( 'core/read-users' )->execute( + array( + 'id' => $user_id, + 'fields' => array( 'id', 'roles' ), + ) + ); + + $this->assertIsArray( $result, 'Reading a user with a role added after registration should not fail output validation.' ); + $this->assertContains( 'core_users_ability_late_role', $result['roles'], 'The late-registered role should be returned.' ); + } finally { + wp_delete_user( $user_id ); + remove_role( 'core_users_ability_late_role' ); + } + } + /** * Role filtering requires list_users. * - * @ticket 64146 + * @ticket 64657 */ public function test_role_filter_requires_list_users(): void { wp_set_current_user( $this->subscriber_id ); $this->register_core_users_ability(); - $result = wp_get_ability( 'core/users' )->execute( array( 'roles' => array( 'author' ) ) ); + $result = wp_get_ability( 'core/read-users' )->execute( array( 'roles' => array( 'author' ) ) ); $this->assertWPError( $result, 'A subscriber should not be able to filter users by role.' ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Role filter denial should use the invalid permissions error.' ); @@ -676,34 +1106,441 @@ public function test_role_filter_requires_list_users(): void { /** * Restricted fields are omitted per user instead of failing the whole result. * - * @ticket 64146 + * @ticket 64657 */ public function test_restricted_requested_fields_are_omitted_per_user(): void { wp_set_current_user( $this->subscriber_id ); $this->register_core_users_ability(); - $result = wp_get_ability( 'core/users' )->execute( + $result = wp_get_ability( 'core/read-users' )->execute( array( 'id' => $this->public_author_id, - 'fields' => array( 'id', 'user_email', 'roles' ), + 'fields' => array( 'id', 'email', 'roles' ), ) ); $this->assertIsArray( $result, 'A public-author lookup should return an array.' ); - $this->assertSame( array( 'id' ), array_keys( $result['users'][0] ), 'Restricted requested fields should be omitted instead of failing the request.' ); - $this->assertSame( $this->public_author_id, $result['users'][0]['id'], 'The public-author lookup should still return unrestricted fields.' ); + $this->assertSame( array( 'id' ), array_keys( $result ), 'Restricted requested fields should be omitted instead of failing the request.' ); + $this->assertSame( $this->public_author_id, $result['id'], 'The public-author lookup should still return unrestricted fields.' ); + } + + /** + * A fully redacted field request still returns the user's ID. + * + * The `id` field is always included, matching the REST users controller + * where `id` is present in every context. This also guarantees the result + * serializes as a JSON object (`{}`-shaped, never `[]`), honoring the + * declared output type without returning a top-level empty object, which + * REST post-processing (`_fields`) cannot handle. + * + * @ticket 64657 + */ + public function test_fully_redacted_field_request_still_returns_id(): void { + wp_set_current_user( $this->subscriber_id ); + $this->register_core_users_ability(); + + $ability = wp_get_ability( 'core/read-users' ); + + $result = $ability->execute( + array( + 'id' => $this->public_author_id, + 'fields' => array( 'email', 'roles' ), + ) + ); + + $this->assertIsArray( $result, 'A fully redacted field request should still succeed.' ); + $this->assertSame( array( 'id' => $this->public_author_id ), $result, 'A fully redacted field request should return only the user ID.' ); + $this->assertSame( + sprintf( '{"id":%d}', $this->public_author_id ), + wp_json_encode( $result ), + 'A fully redacted field request should serialize as a JSON object.' + ); + + $collection = $ability->execute( + array( + 'include' => array( $this->public_author_id ), + 'fields' => array( 'email' ), + ) + ); + + $this->assertIsArray( $collection, 'A collection with redacted fields should return the wrapper.' ); + $this->assertSame( + array( 'id' => $this->public_author_id ), + $collection['users'][0], + 'Redacted collection entries should still contain the user ID.' + ); + } + + /** + * Schema-valid object input behaves like its array form. + * + * Schema validation accepts `stdClass` input (the input schema's own + * `default` is one), so it must not be discarded and silently turned into + * an unrestricted collection query. + * + * @ticket 64657 + */ + public function test_object_input_behaves_like_array_input(): void { + wp_set_current_user( $this->subscriber_id ); + $this->register_core_users_ability(); + + $result = wp_get_ability( 'core/read-users' )->execute( + (object) array( + 'id' => $this->subscriber_id, + 'fields' => array( 'id', 'email' ), + ) + ); + + $this->assertIsArray( $result, 'Object input should execute the single-user lookup.' ); + $this->assertSame( $this->subscriber_id, $result['id'], 'Object input must resolve the requested user, not fall back to collection mode.' ); + $this->assertSame( 'core-users-ability-subscriber@example.com', $result['email'], 'Object input should honor the requested fields.' ); + } + + /** + * REST-style string input is coerced by the normalizers. + * + * Read-only abilities run over REST `GET`, where booleans arrive as the + * string 'true', arrays may arrive as CSV strings, and integers arrive as + * numeric strings. Those values pass schema validation, which coerces only + * for the check, so the normalizers must accept the string forms instead + * of silently dropping the filters they carry. + * + * @ticket 64657 + */ + public function test_rest_style_string_input_is_coerced(): void { + wp_set_current_user( $this->admin_id ); + $this->register_core_users_ability(); + + $ability = wp_get_ability( 'core/read-users' ); + + $result = $ability->execute( + array( + 'has_published_posts' => 'true', + 'fields' => array( 'id' ), + 'per_page' => 100, + ) + ); + + $this->assertIsArray( $result, 'A string boolean published-posts filter should execute.' ); + $ids = wp_list_pluck( $result['users'], 'id' ); + $this->assertContains( $this->public_author_id, $ids, 'The published-posts filter should include public authors.' ); + $this->assertNotContains( $this->subscriber_id, $ids, 'A string "true" must enable the published-posts filter rather than being silently dropped.' ); + + $result = $ability->execute( + array( + 'roles' => 'editor', + 'fields' => array( 'id' ), + 'per_page' => 100, + ) + ); + + $this->assertIsArray( $result, 'A CSV roles filter should execute.' ); + $ids = wp_list_pluck( $result['users'], 'id' ); + $this->assertContains( self::$fixture_ids['editor'], $ids, 'The roles filter should include editors.' ); + $this->assertNotContains( $this->subscriber_id, $ids, 'A CSV roles value must filter by role rather than being silently dropped.' ); + + $result = $ability->execute( + array( + 'include' => (string) $this->subscriber_id, + 'fields' => 'id,email', + ) + ); + + $this->assertIsArray( $result, 'A CSV include filter should execute.' ); + $this->assertSame( array( $this->subscriber_id ), wp_list_pluck( $result['users'], 'id' ), 'A string include value must limit the query to the included IDs.' ); + $this->assertSame( 'core-users-ability-subscriber@example.com', $result['users'][0]['email'], 'A CSV fields value must select the requested fields.' ); + + // IDs that are distinct as strings but equal as integers ('7' vs '07') + // pass schema validation; they must still collapse to one filtered ID. + $result = $ability->execute( + array( + 'include' => array( (string) $this->subscriber_id, '0' . $this->subscriber_id ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'A string-duplicate include filter should execute.' ); + $this->assertSame( array( $this->subscriber_id ), wp_list_pluck( $result['users'], 'id' ), 'String-duplicate IDs must be deduplicated, not silently drop the include filter.' ); + $this->assertSame( 1, $result['total'], 'String-duplicate IDs should count as one included user.' ); + + // Scalars arrive as numeric strings over GET. + $result = $ability->execute( + array( + 'id' => (string) $this->subscriber_id, + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'A numeric-string ID lookup should execute.' ); + $this->assertSame( $this->subscriber_id, $result['id'], 'A numeric-string ID must resolve the single-user lookup.' ); + + $result = $ability->execute( + array( + 'per_page' => '1', + 'page' => '2', + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'Numeric-string pagination should execute.' ); + $this->assertCount( 1, $result['users'], 'A numeric-string per_page must limit the page size.' ); + $this->assertGreaterThan( 1, $result['total_pages'], 'A numeric-string per_page must be reflected in total_pages.' ); + } + + /** + * An unknown requested field name fails schema validation. + * + * Unlike inaccessible fields, which are omitted per user, a field name that is + * not part of the supported set is rejected before the ability executes. + * + * @ticket 64657 + */ + public function test_unknown_requested_field_fails_schema_validation(): void { + wp_set_current_user( $this->admin_id ); + $this->register_core_users_ability(); + + $result = wp_get_ability( 'core/read-users' )->execute( + array( + 'id' => $this->admin_id, + '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.' ); + } + + /** + * Requesting an unavailable field omits it instead of failing. + * + * When avatars are disabled, avatar_urls stays requestable (the schema is a + * registration-time snapshot) and is omitted per user, matching how + * permission-restricted fields behave. + * + * @ticket 64657 + */ + public function test_unavailable_requested_field_is_omitted(): void { + update_option( 'show_avatars', 0 ); + wp_set_current_user( $this->admin_id ); + $this->register_core_users_ability(); + + $result = wp_get_ability( 'core/read-users' )->execute( + array( + 'id' => $this->admin_id, + 'fields' => array( 'id', 'avatar_urls' ), + ) + ); + + $this->assertIsArray( $result, 'Requesting avatar_urls while avatars are disabled should still succeed.' ); + $this->assertSame( array( 'id' ), array_keys( $result ), 'The avatar_urls field should be omitted while avatars are disabled.' ); + } + + /** + * Creates a user whose stored email is empty. + * + * WordPress allows this: wp_insert_user() rejects an empty login and an empty + * nicename, but never an empty email. Imports and single sign-on plugins can + * leave such rows behind. + * + * @ticket 64657 + * + * @return int The new user ID. + */ + private function create_user_without_email(): int { + $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + wp_update_user( + array( + 'ID' => $user_id, + 'user_email' => '', + ) + ); + clean_user_cache( $user_id ); + + $this->assertSame( '', get_userdata( $user_id )->user_email, 'The fixture user should have an empty stored email.' ); + + return $user_id; + } + + /** + * A user with an empty stored email is still readable. + * + * WP_Ability::execute() validates the result against the output schema, so an + * email the schema cannot describe must be reported as null rather than passed + * through, which would fail the whole lookup. + * + * @ticket 64657 + */ + public function test_empty_stored_email_does_not_fail_single_user_output(): void { + $user_id = $this->create_user_without_email(); + + wp_set_current_user( $this->admin_id ); + $this->register_core_users_ability(); + + $result = wp_get_ability( 'core/read-users' )->execute( + array( + 'id' => $user_id, + 'fields' => array( 'id', 'email' ), + ) + ); + + $this->assertIsArray( $result, 'A user with an empty stored email should still be readable.' ); + $this->assertArrayHasKey( 'email', $result, 'A viewable email field should be present even when there is no address.' ); + $this->assertNull( $result['email'], 'An empty stored email should be reported as null.' ); + } + + /** + * A stored email that is_email() rejects is reported as null. + * + * WordPress runs sanitize_email() when saving. Depending on the WordPress + * version, saving `a@@b.c` either stores an empty string or repairs it to + * `a@b.c`, which is one character too short for is_email(). The declared + * `email` format only holds because such values become null. + * + * @ticket 64657 + */ + public function test_invalid_stored_email_is_reported_as_null(): void { + $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + wp_update_user( + array( + 'ID' => $user_id, + 'user_email' => 'a@@b.c', + ) + ); + clean_user_cache( $user_id ); + + $stored_email = get_userdata( $user_id )->user_email; + + $this->assertContains( $stored_email, array( '', 'a@b.c' ), 'WordPress should either discard or repair the invalid address.' ); + $this->assertFalse( is_email( $stored_email ), 'The stored address should still be rejected by is_email().' ); + + wp_set_current_user( $this->admin_id ); + $this->register_core_users_ability(); + + $result = wp_get_ability( 'core/read-users' )->execute( + array( + 'id' => $user_id, + 'fields' => array( 'id', 'email' ), + ) + ); + + $this->assertIsArray( $result, 'A user with an unusable stored email should still be readable.' ); + $this->assertNull( $result['email'], 'An email that is_email() rejects should be reported as null.' ); + } + + /** + * One user with an empty stored email does not break a whole collection page. + * + * Collection output is validated as a single value, so an invalid field on one + * row rejects every row on the page, not just the offending user. + * + * @ticket 64657 + */ + public function test_empty_stored_email_does_not_fail_collection_output(): void { + $user_id = $this->create_user_without_email(); + + wp_set_current_user( $this->admin_id ); + $this->register_core_users_ability(); + + $result = wp_get_ability( 'core/read-users' )->execute( + array( + 'fields' => array( 'id', 'email' ), + 'per_page' => 100, + ) + ); + + $this->assertIsArray( $result, 'One user with an empty stored email should not fail the whole collection.' ); + + $ids = wp_list_pluck( $result['users'], 'id' ); + $this->assertContains( $this->admin_id, $ids, 'Users with valid emails should still be returned.' ); + $this->assertContains( $user_id, $ids, 'The user with an empty stored email should still be returned.' ); + } + + /** + * A suppressed avatar URL does not break the default field set. + * + * get_avatar_url() documents `string|false` as its return type, and plugins that + * disable Gravatar short-circuit the avatar data. Since avatar_urls is part of the + * default field set, a false URL would fail every call rather than only the ones + * that opt in. + * + * @ticket 64657 + */ + public function test_suppressed_avatar_url_does_not_fail_output(): void { + update_option( 'show_avatars', 1 ); + wp_set_current_user( $this->admin_id ); + $this->register_core_users_ability(); + + add_filter( + 'pre_get_avatar_data', + static function ( $args ) { + $args['url'] = false; + + return $args; + } + ); + + $result = wp_get_ability( 'core/read-users' )->execute( array( 'id' => $this->admin_id ) ); + + $this->assertIsArray( $result, 'A suppressed avatar URL should not fail a default-fields lookup.' ); + $this->assertSame( $this->admin_id, $result['id'], 'The lookup should still resolve the requested user.' ); + $this->assertSame( + array( + 24 => null, + 48 => null, + 96 => null, + ), + $result['avatar_urls'], + 'Every avatar size should be reported as null when no URL can be resolved.' + ); + } + + /** + * Avatar sizes that resolve to a URL survive when other sizes do not. + * + * @ticket 64657 + */ + public function test_partially_suppressed_avatar_urls_keep_the_resolved_sizes(): void { + update_option( 'show_avatars', 1 ); + wp_set_current_user( $this->admin_id ); + $this->register_core_users_ability(); + + add_filter( + 'pre_get_avatar_data', + static function ( $args ) { + if ( 24 === $args['size'] ) { + $args['url'] = false; + } + + return $args; + } + ); + + $result = wp_get_ability( 'core/read-users' )->execute( + array( + 'id' => $this->admin_id, + 'fields' => array( 'id', 'avatar_urls' ), + ) + ); + + $this->assertIsArray( $result, 'A partially suppressed avatar set should not fail the lookup.' ); + $this->assertSame( array( 24, 48, 96 ), array_keys( $result['avatar_urls'] ), 'Every avatar size should still be reported.' ); + $this->assertNull( $result['avatar_urls'][24], 'A size with no resolvable URL should be null.' ); + $this->assertIsString( $result['avatar_urls'][48], 'A size that resolves should keep its URL.' ); + $this->assertIsString( $result['avatar_urls'][96], 'A size that resolves should keep its URL.' ); } /** * Missing or inaccessible single-user lookups fail closed. * - * @ticket 64146 + * @ticket 64657 */ public function test_missing_single_user_lookup_fails_closed(): void { wp_set_current_user( $this->admin_id ); $this->register_core_users_ability(); - $result = wp_get_ability( 'core/users' )->execute( array( 'id' => 999999 ) ); + $result = wp_get_ability( 'core/read-users' )->execute( array( 'id' => 999999 ) ); $this->assertWPError( $result, 'Missing single-user lookups should fail closed.' ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Missing single-user lookups should use the invalid permissions error.' );