diff --git a/src/wp-includes/abilities/class-wp-abilities-content.php b/src/wp-includes/abilities/class-wp-abilities-content.php index 725301fd6a0f3..4a4b46b1690df 100644 --- a/src/wp-includes/abilities/class-wp-abilities-content.php +++ b/src/wp-includes/abilities/class-wp-abilities-content.php @@ -460,14 +460,16 @@ private function execute_content_query( $input = array() ) { ); } - $author = $this->get_author_by_slug( $where['author_slug'], $post_type_object ); - if ( ! $author ) { + if ( ! is_string( $where['author_slug'] ) ) { return $this->invalid_filter_error( 'where[author_slug]', /* translators: %s: Parameter. */ - sprintf( __( 'The %s filter must be the slug of an existing user.' ), 'author_slug' ) + sprintf( __( 'The %s filter must be a string.' ), 'author_slug' ) ); } + + // A slug that names no user stays false, see below. + $author = get_user_by( 'slug', $where['author_slug'] ); } /* @@ -561,7 +563,7 @@ private function execute_content_query( $input = array() ) { $query_args['post_name__in'] = $slugs; } - if ( null !== $author ) { + if ( $author ) { $query_args['author'] = $author->ID; } @@ -569,6 +571,14 @@ private function execute_content_query( $input = array() ) { $query_args['post_parent'] = $parent; } + /* + * An author_slug that names no user matches no post, like IDs and slugs that name no + * post, rather than failing the query. + */ + if ( false === $author ) { + return $this->prepare_output( array(), 0, 0 ); + } + $query = new WP_Query( $query_args ); $total = $this->get_query_total( $query, $query_args, $page ); @@ -745,50 +755,6 @@ private function is_field_requested( string $path, array $fields ): bool { return rest_is_field_included( $path, $fields ); } - /** - * Looks up the user an author slug names. - * - * The slug is the user's nicename, which the REST API users endpoint returns as `slug`. It is - * looked up as given, like that endpoint's `slug` filter, so the database collation decides - * whether a variant in another case matches. A user the current user may not see is reported - * like a missing one. The current user can see themselves, any user of the site when they can - * list users, and authors with posts in a publicly viewable post type. A user who can edit - * others' posts of the post type can also see any user of the site, since they may make any of - * them the author, so for them the lookup does tell whether such an account exists. - * - * On multisite the lookup searches the whole network, so posts by authors who are not - * members of the site, such as super admins, can still be filtered. Those users are only - * visible as the current user or as authors with posts in a publicly viewable post type, so - * the lookup does not tell whether an account exists elsewhere on the network. - * - * @since 7.2.0 - * - * @param mixed $slug The author slug. - * @param WP_Post_Type $post_type_object The post type the author is looked up for. - * @return WP_User|null The user, or null when the slug does not name a visible user. - */ - private function get_author_by_slug( $slug, WP_Post_Type $post_type_object ): ?WP_User { - $user = is_string( $slug ) ? get_user_by( 'slug', $slug ) : false; - if ( ! $user ) { - return null; - } - - if ( get_current_user_id() === $user->ID ) { - return $user; - } - - // The capabilities only reveal users of the site, not of the whole network. - if ( is_user_member_of_blog( $user->ID ) - && ( current_user_can( 'list_users' ) || current_user_can( $post_type_object->cap->edit_others_posts ) ) - ) { - return $user; - } - - $public_post_types = array_values( array_filter( get_post_types(), 'is_post_type_viewable' ) ); - - return ! empty( $public_post_types ) && count_user_posts( $user->ID, $public_post_types ) > 0 ? $user : null; - } - /** * Returns a post type exposed through the Abilities API. * @@ -1198,7 +1164,7 @@ private function get_content_query_input_schema( array $post_types, array $statu 'author_slug' => array( 'type' => 'string', 'minLength' => 1, - 'description' => __( "Filter by the author's user slug (nicename), as the REST API users endpoint returns it in `slug`. Only supported for post types that support authors." ), + 'description' => __( "Filter by the author's user slug (nicename), as the REST API users endpoint returns it in `slug`. A slug that names no user matches no post. Only supported for post types that support authors." ), ), 'parent' => array( 'type' => 'integer', diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 08c6df936aecb..e7632e1eb1bcb 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -5036,34 +5036,40 @@ public function test_malformed_date_is_null( string $field, string $column ): vo } /** - * Returns author slugs that do not name a user. + * An author_slug filter that is not a string is rejected rather than silently dropped, + * which would widen the query to every author's posts. * - * @return array The author slug filter value. + * @ticket 66268 */ - public function data_author_slugs_that_name_no_user(): array { - return array( - 'unknown slug' => array( 'no-such-user' ), - 'not a string' => array( 5 ), + public function test_execute_callback_rejects_an_author_slug_that_is_not_a_string(): void { + $this->login_as( 'administrator' ); + $execute = $this->get_ability_callbacks()['execute_callback']; + + $result = $execute( + array( + 'where' => array( + 'type' => 'post', + 'author_slug' => 5, + ), + ) + ); + + $this->assertWPError( $result, 'An author_slug that is not a string must not silently widen the query to all authors.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An author_slug that is not a string should fail as an invalid filter.' ); + $this->assertSame( + array( 'where[author_slug]' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the malformed author_slug filter to the error message.' ); } /** - * An author_slug filter that does not name a user is rejected rather than silently - * dropped, which would widen the query to every author's posts. + * An author_slug filter that names no user matches no post, rather than being dropped, + * which would widen the query to every author's posts. * * @ticket 66268 - * @dataProvider data_author_slugs_that_name_no_user - * - * @param mixed $author_slug The author slug filter value. */ - public function test_execute_callback_rejects_an_author_slug_that_names_no_user( $author_slug ): void { - wp_update_user( - array( - 'ID' => self::$user_ids['author'], - 'user_nicename' => 'author-slug', - ) - ); - + public function test_author_slug_filter_that_names_no_user_matches_no_post(): void { $this->login_as( 'administrator' ); $execute = $this->get_ability_callbacks()['execute_callback']; @@ -5071,18 +5077,12 @@ public function test_execute_callback_rejects_an_author_slug_that_names_no_user( array( 'where' => array( 'type' => 'post', - 'author_slug' => $author_slug, + 'author_slug' => 'no-such-user', ), ) ); - $this->assertWPError( $result, 'An author_slug that names no user must not silently widen the query to all authors.' ); - $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An unhonorable author_slug filter should fail closed as an invalid filter.' ); - $this->assertSame( - array( 'where[author_slug]' => $result->get_error_message() ), - $result->get_error_data()['params'], - 'The error data should map the unmatched author_slug filter to the error message.' - ); + $this->assertSame( self::EMPTY_RESULT, $result, 'An author_slug that names no user should match no post.' ); } /** @@ -5664,14 +5664,13 @@ public function test_execute_callback_defaults_a_pagination_that_is_not_an_objec } /** - * An author_slug filter names only users the current user may see: a user without - * published posts is reported like a missing one to a subscriber, but not to an editor, - * who can edit their posts. + * An author_slug filter matches only the posts the current user may read, so a user + * without such posts gives the same empty result as a slug that names no user. * * @ticket 66268 * @since 7.2.0 */ - public function test_author_slug_filter_only_names_users_the_current_user_may_see(): void { + public function test_author_slug_filter_matches_no_post_for_a_user_without_readable_posts(): void { $published_id = self::factory()->post->create( array( 'post_author' => self::$user_ids['author'] ) ); $draft_id = self::factory()->post->create( array( @@ -5681,90 +5680,48 @@ public function test_author_slug_filter_only_names_users_the_current_user_may_se ); $execute = $this->get_ability_callbacks()['execute_callback']; - $query = static function ( array $statuses, int $user_id ) use ( $execute ) { + $query = static function ( array $statuses, string $author_slug ) use ( $execute ) { return $execute( array( 'where' => array( 'type' => 'post', 'status' => $statuses, - 'author_slug' => get_userdata( $user_id )->user_nicename, + 'author_slug' => $author_slug, ), 'fields' => array( 'id' ), ) ); }; + $author_slug = get_userdata( self::$user_ids['author'] )->user_nicename; + $author_secondary_slug = get_userdata( self::$user_ids['author_secondary'] )->user_nicename; + $subscriber_slug = get_userdata( self::$user_ids['subscriber'] )->user_nicename; + $this->login_as( 'subscriber' ); - $public = $query( array( 'publish' ), self::$user_ids['author'] ); + $public = $query( array( 'publish' ), $author_slug ); $this->assertIsArray( $public, 'A subscriber should filter by an author with published posts.' ); $this->assertSame( array( $published_id ), wp_list_pluck( $public['items'], 'id' ), 'The filter should return that author\'s posts.' ); - - $hidden = $query( array( 'publish' ), self::$user_ids['author_secondary'] ); - $this->assertWPError( $hidden, 'A subscriber should not learn that an author without published posts exists.' ); - $this->assertSame( 'content_invalid_filter', $hidden->get_error_code(), 'A hidden author should be reported like a missing one.' ); - $this->assertSame( - array( 'where[author_slug]' => $hidden->get_error_message() ), - $hidden->get_error_data()['params'], - 'The error data should map the hidden author_slug filter to the error message.' - ); + $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $author_secondary_slug ), 'A subscriber should get the empty result for an author without published posts.' ); + $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), 'no-such-user' ), 'A subscriber should get the empty result for a slug that names no user.' ); $this->login_as( 'editor' ); - $drafts = $query( array( 'draft' ), self::$user_ids['author_secondary'] ); + $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $subscriber_slug ), 'An editor should get the empty result for a user without posts.' ); + $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), 'no-such-user' ), 'An editor should get the empty result for a slug that names no user.' ); + + $drafts = $query( array( 'draft' ), $author_secondary_slug ); $this->assertIsArray( $drafts, 'An editor should filter by an author without published posts.' ); $this->assertSame( array( $draft_id ), wp_list_pluck( $drafts['items'], 'id' ), 'The filter should return that author\'s drafts.' ); } /** - * For a post type with its own capabilities, an author_slug filter names a user without - * published posts to the users who can edit others' posts of that post type, not to those - * who can edit others' posts. - * - * @ticket 66268 - * @since 7.2.0 - */ - public function test_author_slug_filter_follows_the_capabilities_of_the_queried_post_type(): void { - $this->register_post_type_with_own_capabilities(); - - $draft_id = self::factory()->post->create( - array( - 'post_type' => 'book', - 'post_author' => self::$user_ids['author_secondary'], - 'post_status' => 'draft', - ) - ); - - $execute = $this->get_ability_callbacks()['execute_callback']; - $input = array( - 'where' => array( - 'type' => 'book', - 'status' => array( 'draft' ), - 'author_slug' => get_userdata( self::$user_ids['author_secondary'] )->user_nicename, - ), - 'fields' => array( 'id' ), - ); - - $this->login_as( 'editor' ); - $hidden = $execute( $input ); - - $this->assertWPError( $hidden, 'An editor, who can edit others\' posts but not others\' books, should not learn that an author without published posts exists.' ); - $this->assertSame( 'content_invalid_filter', $hidden->get_error_code(), 'An author hidden from the editor should be reported like a missing one.' ); - - wp_set_current_user( $this->create_subscriber_with_capabilities( array( 'edit_books', 'edit_others_books' ) ) ); - $drafts = $execute( $input ); - - $this->assertIsArray( $drafts, 'A user who can edit others\' books should filter by an author without published posts.' ); - $this->assertSame( array( $draft_id ), wp_list_pluck( $drafts['items'], 'id' ), 'The filter should return that author\'s draft books.' ); - } - - /** - * On multisite, an author_slug filter does not tell an editor of one site whether a user - * of another site exists. + * On multisite, an author_slug filter that names a user of another site matches no post, + * like a slug that names no user. * * @ticket 66268 * @group ms-required * @since 7.2.0 */ - public function test_author_slug_filter_hides_users_of_other_sites(): void { + public function test_author_slug_filter_matches_no_post_for_a_user_of_another_site(): void { $other_user_id = self::factory()->user->create( array( 'user_nicename' => 'other-site-user' ) ); $site_id = self::factory()->blog->create(); @@ -5790,9 +5747,8 @@ public function test_author_slug_filter_hides_users_of_other_sites(): void { restore_current_blog(); $this->assertFalse( $is_member, 'Precondition: the other user should not be a member of the site.' ); - $this->assertWPError( $missing, 'Precondition: a slug that names no user should be rejected.' ); - $this->assertWPError( $other, 'An editor should not learn that a user of another site exists.' ); - $this->assertSame( $missing->get_error_code(), $other->get_error_code(), 'A user of another site should be reported like a missing one.' ); + $this->assertSame( self::EMPTY_RESULT, $missing, 'Precondition: a slug that names no user should match no post.' ); + $this->assertSame( self::EMPTY_RESULT, $other, 'A user of another site should match no post of the site.' ); } /**