diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 0eb87a4581589..dd6a783bb8a32 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'; + ( new 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..be850b6cc3052 --- /dev/null +++ b/src/wp-includes/abilities/class-wp-users-abilities.php @@ -0,0 +1,898 @@ +register_get_users(); + } + + /** + * Registers the read-only `core/users` ability. + * + * @since 6.9.0 + */ + 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' => $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, + '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 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 = $this->get_lookup_type( $input ); + if ( '' === $lookup_type ) { + return true; + } + + $user = $this->find_user( $input ); + if ( ! $user instanceof WP_User || ! $this->is_user_member_of_site( $user ) ) { + return false; + } + + return $this->can_read_user_for_lookup( $user, $lookup_type ); + } + + /** + * 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 function execute_get_users( $input = array() ) { + $input = is_array( $input ) ? $input : array(); + $fields = $this->normalize_fields( $input ); + + $lookup_type = $this->get_lookup_type( $input ); + if ( '' !== $lookup_type ) { + $user = $this->find_user( $input ); + if ( ! $user instanceof WP_User + || ! $this->is_user_member_of_site( $user ) + || ! $this->can_read_user_for_lookup( $user, $lookup_type ) + ) { + return $this->not_found_error(); + } + + return array( + 'users' => array( $this->format_user( $user, $fields ) ), + 'total' => 1, + 'total_pages' => 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, + 'offset' => ( $page - 1 ) * $per_page, + 'count_total' => true, + ); + + if ( ! empty( $input['roles'] ) && current_user_can( 'list_users' ) ) { + $query_args['role__in'] = $this->normalize_string_list( $input['roles'] ); + } + + if ( current_user_can( 'list_users' ) ) { + $has_published_posts = $this->normalize_has_published_posts( $input ); + if ( null !== $has_published_posts ) { + $query_args['has_published_posts'] = $has_published_posts; + } + } else { + $public_post_types = $this->get_public_post_types(); + $has_published_posts = $this->normalize_has_published_posts( $input ); + + 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 ); + + $users = array(); + foreach ( $query->get_results() as $user ) { + if ( ! $user instanceof WP_User || ! $this->is_user_member_of_site( $user ) || ! $this->can_read_user( $user ) ) { + continue; + } + + $users[] = $this->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 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 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; + } + } + + 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 function find_user( array $input ): ?WP_User { + if ( array_key_exists( 'id', $input ) ) { + $user = get_userdata( $this->input_int( $input['id'] ) ); + return $user instanceof WP_User ? $user : null; + } + + 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 ( 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 ( 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; + } + + 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 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. + * + * User email and login 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 function can_read_user_for_lookup( WP_User $user, string $lookup_type ): bool { + if ( $this->is_current_user( $user ) ) { + return true; + } + + if ( current_user_can( 'edit_user', $user->ID ) || current_user_can( 'list_users' ) ) { + return true; + } + + if ( 'user_email' === $lookup_type || 'user_login' === $lookup_type ) { + return false; + } + + return $this->is_public_author( $user ); + } + + /** + * Checks whether a user may be included in collection results. + * + * @since 6.9.0 + * + * @param WP_User $user User object. + * @return bool Whether the user can be read. + */ + private function can_read_user( WP_User $user ): bool { + return $this->is_current_user( $user ) + || current_user_can( 'edit_user', $user->ID ) + || current_user_can( 'list_users' ) + || $this->is_public_author( $user ); + } + + /** + * Checks whether the current user is the target user. + * + * @since 6.9.0 + * + * @param WP_User $user User object. + * @return bool Whether the current user is the target user. + */ + private function is_current_user( WP_User $user ): bool { + return get_current_user_id() === (int) $user->ID; + } + + /** + * Checks whether a user has published posts in public post types. + * + * @since 6.9.0 + * + * @param WP_User $user User object. + * @return bool Whether the user is publicly visible as an author. + */ + private function is_public_author( WP_User $user ): bool { + $post_types = $this->get_public_post_types(); + if ( array() === $post_types ) { + return false; + } + + return count_user_posts( (int) $user->ID, $post_types ) > 0; + } + + /** + * Returns public post types. + * + * @since 6.9.0 + * + * @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 ) ) { + continue; + } + + $post_types[] = $post_type; + } + + $this->public_post_types = $post_types; + + return $this->public_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 function normalize_fields( array $input ): array { + $available_fields = $this->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 function get_fields(): array { + $include_avatars = (bool) get_option( 'show_avatars' ); + + if ( null !== $this->fields && $include_avatars === $this->fields_include_avatars ) { + return $this->fields; + } + + $fields = $this->read_fields; + + if ( $include_avatars ) { + $fields[] = 'avatar_urls'; + } + + $this->fields = array_merge( $fields, $this->sensitive_fields, array( 'roles' ) ); + $this->fields_include_avatars = $include_avatars; + + 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; + } + + /** + * 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 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 ) ); + } + + /** + * 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 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 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 = $this->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 function get_users_input_schema(): array { + $role_names = $this->get_role_names(); + $public_post_types = $this->get_public_post_types(); + $fields = array( + 'type' => 'array', + 'uniqueItems' => true, + 'items' => array( + 'type' => 'string', + 'enum' => $this->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', + 'default' => (object) array(), + '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( 'user_email' ), + 'additionalProperties' => false, + 'properties' => 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, + ), + ), + array( + 'title' => __( 'Get a single readable user by login' ), + 'required' => array( 'user_login' ), + 'additionalProperties' => false, + 'properties' => array( + 'user_login' => array( + 'type' => 'string', + 'description' => __( 'Retrieve a single readable user by login. Resolving another user by login requires permission to list or edit users.' ), + ), + 'fields' => $fields, + ), + ), + array( + 'title' => __( 'Get a single readable user by nicename' ), + 'required' => array( 'user_nicename' ), + 'additionalProperties' => false, + 'properties' => array( + 'user_nicename' => array( + 'type' => 'string', + 'description' => __( 'Retrieve a single readable user by nicename.' ), + ), + 'fields' => $fields, + ), + ), + array( + 'title' => __( 'Query readable users' ), + 'additionalProperties' => false, + 'properties' => array( + 'roles' => array( + 'type' => 'array', + 'uniqueItems' => true, + 'minItems' => 1, + 'items' => array( + 'type' => 'string', + 'enum' => $role_names, + ), + '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', + 'enum' => $public_post_types, + ), + ), + ), + '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 function get_users_output_schema(): array { + $user_properties = array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The user ID.' ), + ), + 'display_name' => array( + 'type' => 'string', + 'description' => __( 'The display name for the user.' ), + ), + 'description' => array( + 'type' => 'string', + 'description' => __( 'Description of the user.' ), + ), + 'user_url' => array( + 'type' => 'string', + 'description' => __( 'URL of the user.' ), + ), + 'link' => array( + 'type' => 'string', + 'description' => __( 'Author archive URL for the user.' ), + ), + 'user_nicename' => array( + 'type' => 'string', + 'description' => __( 'An alphanumeric identifier for the user.' ), + ), + 'user_login' => array( + 'type' => 'string', + 'description' => __( 'Login name for the user. Present when the current user can view it.' ), + ), + 'user_email' => array( + 'type' => 'string', + 'format' => 'email', + 'description' => __( 'The email address for the user. Present when the current user can view it.' ), + ), + 'first_name' => array( + 'type' => 'string', + 'description' => __( 'First name for the user. Present when the current user can view it.' ), + ), + 'last_name' => array( + 'type' => 'string', + 'description' => __( 'Last name for the user. Present when the current user can view it.' ), + ), + 'nickname' => array( + 'type' => 'string', + 'description' => __( 'The nickname for the user. Present when the current user can view it.' ), + ), + 'locale' => array( + 'type' => 'string', + 'description' => __( 'Locale for the user. Present when the current user can view it.' ), + ), + 'user_registered' => array( + 'type' => 'string', + 'format' => 'date-time', + 'description' => __( 'Registration date for the user. Present when the current user can view it.' ), + ), + 'roles' => array( + 'type' => 'array', + 'description' => __( 'Roles assigned to the user. Present when the current user can view them.' ), + 'items' => array( + 'type' => 'string', + 'enum' => $this->get_role_names(), + ), + ), + ); + + 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 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 = $this->is_current_user( $user ) || current_user_can( 'edit_user', $user_id ); + $can_view_roles = current_user_can( 'list_users' ) || current_user_can( 'edit_user', $user_id ); + + $data = array(); + + if ( $fields_requested( 'id' ) ) { + $data['id'] = $user_id; + } + if ( $fields_requested( 'display_name' ) ) { + $data['display_name'] = (string) $user->display_name; + } + if ( $fields_requested( 'description' ) ) { + $data['description'] = (string) $user->description; + } + 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( '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( 'user_login' ) ) { + $data['user_login'] = (string) $user->user_login; + } + if ( $fields_requested( 'user_email' ) ) { + $data['user_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( 'user_registered' ) ) { + $registered_timestamp = strtotime( $user->user_registered ); + if ( false !== $registered_timestamp ) { + $data['user_registered'] = gmdate( 'c', $registered_timestamp ); + } + } + } + + if ( $fields_requested( 'roles' ) && $can_view_roles ) { + $data['roles'] = $this->normalize_string_list( $user->roles ); + } + + return $data; + } + + /** + * Returns a generic not-found error for missing or inaccessible user lookups. + * + * @since 6.9.0 + * + * @return WP_Error Not found error. + */ + private function not_found_error(): WP_Error { + return new WP_Error( + 'user_not_found', + __( 'The requested user was not found.' ), + array( 'status' => 404 ) + ); + } +} diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php new file mode 100644 index 0000000000000..22c44e5ae2f78 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreUsersAbility.php @@ -0,0 +1,711 @@ + + */ + private static $fixture_ids = array(); + + /** + * Administrator user ID. + * + * @since 6.9.0 + * @var int + */ + private $admin_id; + + /** + * Subscriber user ID. + * + * @since 6.9.0 + * @var int + */ + private $subscriber_id; + + /** + * Author user ID with a published post. + * + * @since 6.9.0 + * @var int + */ + private $public_author_id; + + /** + * Author post ID. + * + * @since 6.9.0 + * @var int + */ + private $public_post_id; + + /** + * Original show_avatars option. + * + * @since 6.9.0 + * @var mixed + */ + private $show_avatars; + + /** + * Set up before the class. + * + * @since 6.9.0 + */ + 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 ); + + add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); + add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + + 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', + ) + ); + } + + /** + * Tear down after the class. + * + * @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 ); + + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + parent::tear_down_after_class(); + } + + /** + * 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::$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(); + } + + /** + * Tear down test case. + * + * @since 6.9.0 + */ + public function tear_down(): void { + if ( wp_has_ability( 'core/users' ) ) { + wp_unregister_ability( 'core/users' ); + } + + 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 { + ( new 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 { + $this->register_core_users_ability(); + + $ability = wp_get_ability( 'core/users' ); + + $this->assertInstanceOf( WP_Ability::class, $ability, 'The users ability should be registered.' ); + $this->assertSame( 'user', $ability->get_category(), 'The users ability should use the user category.' ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ), 'The users ability should be exposed over REST.' ); + $this->assertTrue( $ability->get_meta_item( 'pagination', false ), 'The users ability should advertise pagination support.' ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertTrue( $annotations['readonly'], 'The users ability should be marked read-only.' ); + $this->assertFalse( $annotations['destructive'], 'The users ability should not be marked destructive.' ); + } + + + /** + * The input schema exposes strict single-user and collection modes. + * + * @ticket 64146 + */ + 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'], 'The users ability input schema should describe an object.' ); + $this->assertEquals( (object) array(), $schema['default'], 'The users ability input schema should default to empty collection mode.' ); + $this->assertCount( 5, $schema['oneOf'], 'The users ability input schema should expose four lookup modes and collection mode.' ); + + $this->assertSame( array( 'id' ), $schema['oneOf'][0]['required'], 'The first input mode should require an ID.' ); + $this->assertSame( array( 'user_email' ), $schema['oneOf'][1]['required'], 'The second input mode should require a user email.' ); + $this->assertSame( array( 'user_login' ), $schema['oneOf'][2]['required'], 'The third input mode should require a user login.' ); + $this->assertSame( array( 'user_nicename' ), $schema['oneOf'][3]['required'], 'The fourth input mode should require a user nicename.' ); + $this->assertArrayNotHasKey( 'required', $schema['oneOf'][4], 'Collection mode should allow an empty request.' ); + + $collection_properties = $schema['oneOf'][4]['properties']; + $this->assertEqualSets( + array( 'roles', 'has_published_posts', 'fields', 'page', 'per_page' ), + array_keys( $collection_properties ), + 'Collection mode should expose only the supported query parameters.' + ); + $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, sprintf( 'Collection mode should not expose %s.', $excluded_property ) ); + } + + $fields = $schema['oneOf'][4]['properties']['fields']['items']['enum']; + $this->assertContains( 'roles', $fields, 'The fields enum should expose the roles field.' ); + $this->assertContains( 'avatar_urls', $fields, 'The fields enum should expose avatar_urls when avatars are enabled.' ); + + $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'], '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.' ); + } + + /** + * 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'], '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, '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.' ); + } + + /** + * Logged-out users cannot run the ability. + * + * @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, 'Logged-out users should not be allowed to execute the users ability.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Logged-out users should receive an invalid permissions error.' ); + } + + /** + * The current user can read themselves by ID, user email, and user login. + * + * @ticket 64146 + */ + 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' ); + + $result = $ability->execute( + array( + 'id' => $this->subscriber_id, + 'fields' => array( 'id', 'user_email', 'user_login', '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, '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, '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, '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'], + 'The registration date should be formatted as an ISO 8601 date-time string.' + ); + } + + /** + * Public-author users can be read by ID or user nicename by logged-in users. + * + * @ticket 64146 + */ + 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' ); + + $result = $ability->execute( + array( + 'id' => $this->public_author_id, + 'fields' => array( 'id', 'user_nicename', 'user_email' ), + ) + ); + + $this->assertIsArray( $result, 'A logged-in user should be able to read a public author by ID.' ); + $this->assertSame( $this->public_author_id, $result['users'][0]['id'], 'The ID lookup should return the public author.' ); + $this->assertSame( 'core-users-ability-author', $result['users'][0]['user_nicename'], 'The public author nicename should be returned.' ); + $this->assertArrayNotHasKey( 'user_email', $result['users'][0], 'Public-author access should not expose another user email.' ); + + $result = $ability->execute( array( 'user_nicename' => 'core-users-ability-author' ) ); + + $this->assertIsArray( $result, 'A logged-in user should be able to read a public author by nicename.' ); + $this->assertSame( $this->public_author_id, $result['users'][0]['id'], 'The nicename lookup should return the public author.' ); + } + + /** + * 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_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' ) ); + 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' ) ); + 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; + } + + $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 ) ); + } + + /** + * 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 ), + ); + } + + /** + * 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 ); + $this->register_core_users_ability(); + + $result = wp_get_ability( 'core/users' )->execute( array() ); + + $this->assertIsArray( $result, 'Collection mode should return an array for logged-in users.' ); + $this->assertContains( $this->public_author_id, wp_list_pluck( $result['users'], 'id' ), 'Collection mode should include public authors.' ); + $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.' ); + } + + /** + * 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, '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' ); + $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' ), + 'per_page' => 100, + ) + ); + + $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, '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( + 'has_published_posts' => array( 'wp_private_pt' ), + 'fields' => array( 'id' ), + ) + ); + + $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 ); + 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. + * + * @ticket 64146 + */ + 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( + 'roles' => array( 'author' ), + 'fields' => array( 'id', 'roles' ), + 'per_page' => 100, + ) + ); + + $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'], '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. + * + * @ticket 64146 + */ + 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, '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.' ); + } + + /** + * 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 ); + $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, '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.' ); + } +}