From e846a9c1b69bae99e29512f85552d0b06eb398cc Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 25 Jun 2026 14:56:48 +0100 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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.' ); } }