From 72e77ad381bf8c58768450d5e11e72bc9b1d978b Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 21 Oct 2026 15:36:38 +0100 Subject: [PATCH 1/5] Abilities API: Return the empty result for an `author_slug` that names no user. The `core/content-query` ability rejected an `author_slug` filter that names no user as an invalid filter, and looked the slug up against the users the current user may see, with a rule of its own. The other filters, such as `ids` and `slugs`, give the empty result when nothing matches their value. Treat `author_slug` the same way, to fix possible inconsistencies: a slug that names no user now matches no post, like a user without posts the current user may read, and the lookup of visible users is removed. A value that is not a string is still rejected. Follow-up to [64388]. See #66268. --- .../abilities/class-wp-abilities-content.php | 64 ++------ .../wpRegisterCoreContentAbility.php | 140 ++++++------------ 2 files changed, 63 insertions(+), 141 deletions(-) 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.' ); } /** From dca36e572735803ef4670b63fb37f10ff19ceefd Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 21 Oct 2026 20:56:59 +0100 Subject: [PATCH 2/5] Abilities API: Replace the `author_slug` filter of `core/content-query` with an `author` object. The `core/content-query` ability filtered posts by author through a `where.author_slug` string, while each post returns its author as an `author` object with `id`, `name`, and `slug`. The REST API posts collection filters by author ID, which the ability did not take. Replace the filter with a `where.author` object that takes the `id` or the `slug` the `author` field returns, so a result's author can be given back as a filter. An ID needs no user lookup. When both are given they must name the same user. An ID or slug that names no user matches no post, as before for a slug. A value that is not an object with an ID or a slug, an ID that is not a positive integer, and a slug that is not a string are rejected as invalid filters. Follow-up to [64388]. See #66268. --- .../abilities/class-wp-abilities-content.php | 112 ++- .../wpRegisterCoreContentAbility.php | 678 ++++++++++++++++-- 2 files changed, 693 insertions(+), 97 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-content.php b/src/wp-includes/abilities/class-wp-abilities-content.php index 4a4b46b1690df..c94169557e27f 100644 --- a/src/wp-includes/abilities/class-wp-abilities-content.php +++ b/src/wp-includes/abilities/class-wp-abilities-content.php @@ -450,26 +450,72 @@ private function execute_content_query( $input = array() ) { } } - $author = null; - if ( isset( $where['author_slug'] ) ) { + /* + * The author filter is an object with an `id` or a `slug`, the keys the `author` + * field returns. Null, int, or false: no filter, the user ID to query, or a filter + * that names no user, which matches no post (see below). + */ + $author_id = null; + if ( array_key_exists( 'author', $where ) ) { if ( ! post_type_supports( $post_type, 'author' ) ) { return $this->invalid_filter_error( - 'where[author_slug]', + 'where[author]', /* translators: %s: Parameter. */ - sprintf( __( 'The %s filter is only supported for post types that support authors.' ), 'author_slug' ) + sprintf( __( 'The %s filter is only supported for post types that support authors.' ), 'author' ) ); } - if ( ! is_string( $where['author_slug'] ) ) { + /* + * An object without `id` or `slug`, such as `{}` or an empty string, and one with + * an unknown key, such as a list or a misspelled property, name no user and must not + * fall through to an unrestricted query, like an empty `ids` filter. + */ + $author_filter = $this->get_input_object( $where, 'author' ); + if ( empty( $author_filter ) || ! empty( array_diff_key( $author_filter, array_flip( array( 'id', 'slug' ) ) ) ) ) { return $this->invalid_filter_error( - 'where[author_slug]', + 'where[author]', /* translators: %s: Parameter. */ - sprintf( __( 'The %s filter must be a string.' ), 'author_slug' ) + sprintf( __( 'The %s filter must be an object with an id or a slug.' ), 'author' ) ); } - // A slug that names no user stays false, see below. - $author = get_user_by( 'slug', $where['author_slug'] ); + /* + * An `id` of 0 must not reach the query: WP_Query reads `author => 0` as no author + * filter, which would return every post of the type. + */ + if ( array_key_exists( 'id', $author_filter ) ) { + $author_id = $this->parse_filter_int( $author_filter['id'], 1 ); + if ( null === $author_id ) { + return $this->invalid_filter_error( + 'where[author][id]', + /* translators: %s: Parameter. */ + sprintf( __( 'The %s ID must be a positive integer.' ), 'author' ) + ); + } + } + + if ( array_key_exists( 'slug', $author_filter ) ) { + if ( ! is_string( $author_filter['slug'] ) || '' === $author_filter['slug'] ) { + return $this->invalid_filter_error( + 'where[author][slug]', + /* translators: %s: Parameter. */ + sprintf( __( 'The %s slug must be a string.' ), 'author' ) + ); + } + + /* + * The slug is looked up as given, like the `slug` of the users endpoint: the + * collation of the users table decides whether the case matters. A slug that + * names no user, or names another user than `id`, matches no post, as a post + * must match every given filter. + */ + $user = get_user_by( 'slug', $author_filter['slug'] ); + if ( ! $user || ( null !== $author_id && $user->ID !== $author_id ) ) { + $author_id = false; + } else { + $author_id = $user->ID; + } + } } /* @@ -563,8 +609,8 @@ private function execute_content_query( $input = array() ) { $query_args['post_name__in'] = $slugs; } - if ( $author ) { - $query_args['author'] = $author->ID; + if ( is_int( $author_id ) ) { + $query_args['author'] = $author_id; } if ( null !== $parent ) { @@ -572,10 +618,11 @@ private function execute_content_query( $input = array() ) { } /* - * An author_slug that names no user matches no post, like IDs and slugs that name no - * post, rather than failing the query. + * An author filter that names no user matches no post, like IDs and slugs that name + * no post, rather than failing the query. An `id` that names no user needs no lookup: + * the query matches no post on its own. */ - if ( false === $author ) { + if ( false === $author_id ) { return $this->prepare_output( array(), 0, 0 ); } @@ -1037,7 +1084,7 @@ private function get_post_properties(): array { ), 'slug' => array( 'type' => array( 'string', 'null' ), - 'description' => __( "The author's user slug (nicename), as the REST API users endpoint returns it in `slug`, and as the `author_slug` filter takes it. Null when the author no longer exists." ), + 'description' => __( "The author's user slug (nicename), as the REST API users endpoint returns it in `slug`, and as the `author.slug` filter takes it. Null when the author no longer exists." ), ), ), 'description' => __( 'The post author. Present when the post type supports authors.' ), @@ -1103,7 +1150,7 @@ private function get_fields_input_schema(): array { * The input has three members: `where` selects the posts, `fields` selects the fields * returned for each post, and `pagination` selects the page of results. `where` holds * the post type to query in `type`, and any of the filters `ids`, `slugs`, `status`, - * `author_slug`, and `parent`, which all combine. Every object sets + * `author`, and `parent`, which all combine. Every object sets * `additionalProperties: false`, so an unknown key, such as a misspelled filter, fails * validation instead of being silently ignored. * @@ -1124,12 +1171,12 @@ private function get_content_query_input_schema( array $post_types, array $statu 'additionalProperties' => false, 'required' => array( 'type' ), 'properties' => array( - 'type' => array( + 'type' => array( 'type' => 'string', 'enum' => $post_types, 'description' => __( 'Post type to query for readable posts.' ), ), - 'ids' => array( + 'ids' => array( 'type' => 'array', 'uniqueItems' => true, 'minItems' => 1, @@ -1140,7 +1187,7 @@ private function get_content_query_input_schema( array $post_types, array $statu ), 'description' => __( 'Limit the query to these post IDs. The order of the IDs does not affect the order of the results. If `pagination.per_page` is omitted, every matching post is returned on the first page.' ), ), - 'slugs' => array( + 'slugs' => array( 'type' => 'array', 'uniqueItems' => true, 'minItems' => 1, @@ -1151,7 +1198,7 @@ private function get_content_query_input_schema( array $post_types, array $statu ), 'description' => __( 'Limit the query to posts with these slugs. Each slug is sanitized as a post slug is, so `Hello-World` matches `hello-world`. A slug can match several posts, such as pages under different parents, and every match is returned. The order of the slugs does not affect the order of the results. If `pagination.per_page` is omitted, every matching post is returned on the first page, up to the `per_page` maximum.' ), ), - 'status' => array( + 'status' => array( 'type' => 'array', 'uniqueItems' => true, 'minItems' => 1, @@ -1161,18 +1208,31 @@ private function get_content_query_input_schema( array $post_types, array $statu ), 'description' => __( 'Filter readable posts by one or more post statuses. Defaults to publish, or, when `ids` or `slugs` is given, to every status the current user may query. Statuses that are not publicly viewable, other than private, require edit access.' ), ), - '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`. A slug that names no user matches no post. Only supported for post types that support authors." ), + 'author' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'minProperties' => 1, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'The user ID of the author, as `author.id` is returned.' ), + ), + 'slug' => array( + 'type' => 'string', + 'minLength' => 1, + 'description' => __( "The author's user slug (nicename), as `author.slug` is returned and as the REST API users endpoint returns it in `slug`." ), + ), + ), + 'description' => __( 'Filter by the post author, given as the `id` or the `slug` the `author` field of a post returns. When both are given they must name the same user. An ID or slug that names no user matches no post. Only supported for post types that support authors.' ), ), - 'parent' => array( + 'parent' => array( 'type' => 'integer', 'minimum' => 0, 'description' => __( 'Filter by parent post ID. Only supported for hierarchical post types. Use 0 for top-level posts.' ), ), ), - 'description' => __( 'Selects the posts to return: the readable posts of a `type`, filtered by any of `ids`, `slugs`, `status`, `author_slug`, and `parent`. A post must match every given filter.' ), + 'description' => __( 'Selects the posts to return: the readable posts of a `type`, filtered by any of `ids`, `slugs`, `status`, `author`, and `parent`. A post must match every given filter.' ), ), 'fields' => $this->get_fields_input_schema(), 'pagination' => array( diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index e7632e1eb1bcb..f8e82eeb032c1 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -328,7 +328,7 @@ public function test_input_schema_models_a_flat_where(): void { $this->assertArrayNotHasKey( 'title', $where, 'The where option should not have a title.' ); $this->assertSame( array( 'type' ), $where['required'], 'The where option should require a post type.' ); $this->assertFalse( $where['additionalProperties'], 'The where option should reject unknown keys.' ); - $this->assertSame( array( 'type', 'ids', 'slugs', 'status', 'author_slug', 'parent' ), array_keys( $where['properties'] ), 'The where option should take a post type and the filters, in that order.' ); + $this->assertSame( array( 'type', 'ids', 'slugs', 'status', 'author', 'parent' ), array_keys( $where['properties'] ), 'The where option should take a post type and the filters, in that order.' ); $this->assertSame( 'string', $where['properties']['type']['type'], 'The where option should take the post type as a string.' ); $this->assertSameSets( array( 'post', 'page' ), $where['properties']['type']['enum'], 'Only the post types marked show_in_abilities should be exposed.' ); @@ -358,8 +358,18 @@ public function test_input_schema_models_a_flat_where(): void { $this->assertSame( 'string', $status['items']['type'], 'The status filter should contain status names.' ); $this->assertSameSets( array_values( get_post_stati( array( 'internal' => false ) ) ), $status['items']['enum'], 'The status filter should expose the statuses that are not internal.' ); - $this->assertSame( 'string', $where['properties']['author_slug']['type'], 'The author_slug filter should be a string.' ); - $this->assertSame( 1, $where['properties']['author_slug']['minLength'], 'The author_slug filter should not be empty.' ); + $author = $where['properties']['author']; + + $this->assertSame( 'object', $author['type'], 'The author filter should describe an object.' ); + $this->assertFalse( $author['additionalProperties'], 'The author filter should reject unknown keys.' ); + $this->assertSame( 1, $author['minProperties'], 'The author filter should require an ID or a slug.' ); + $this->assertArrayNotHasKey( 'required', $author, 'The author filter should not require a particular key.' ); + $this->assertSame( array( 'id', 'slug' ), array_keys( $author['properties'] ), 'The author filter should take an ID and a slug, in that order.' ); + $this->assertSame( 'integer', $author['properties']['id']['type'], 'The author ID should be an integer.' ); + $this->assertSame( 1, $author['properties']['id']['minimum'], 'The author ID should be positive.' ); + $this->assertSame( 'string', $author['properties']['slug']['type'], 'The author slug should be a string.' ); + $this->assertSame( 1, $author['properties']['slug']['minLength'], 'The author slug should not be empty.' ); + $this->assertArrayNotHasKey( 'author_slug', $where['properties'], 'The author_slug filter should be replaced by the author filter.' ); $this->assertSame( 'integer', $where['properties']['parent']['type'], 'The parent filter should be an integer.' ); $this->assertSame( 0, $where['properties']['parent']['minimum'], 'The parent filter should accept 0 for top-level posts.' ); @@ -418,6 +428,7 @@ public function test_input_schema_models_a_flat_where(): void { $input_properties = array( 'root' => $schema['properties'], 'where' => $where['properties'], + 'author' => $author['properties'], 'pagination' => $pagination['properties'], ); foreach ( $input_properties as $location => $properties ) { @@ -442,6 +453,22 @@ public function test_input_schema_omits_defaults(): void { $this->assertSame( array(), $this->find_schema_keywords( $schema, array( 'default' ) ), 'The input schema should not declare a default at any depth.' ); } + /** + * The `author_slug` filter is replaced by the `author` filter, so no description in the + * input or output schema refers to it any more. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_schemas_do_not_mention_the_author_slug_filter(): void { + $this->register_ability(); + + $ability = wp_get_ability( 'core/content-query' ); + + $this->assertStringNotContainsString( 'author_slug', wp_json_encode( $ability->get_input_schema() ), 'The input schema should not mention the author_slug filter.' ); + $this->assertStringNotContainsString( 'author_slug', wp_json_encode( $ability->get_output_schema() ), 'The output schema should not mention the author_slug filter.' ); + } + /** * `where.ids` combines with a `status` filter: a post must have one of the IDs and one of the * statuses. @@ -576,6 +603,46 @@ public function data_invalid_inputs(): array { ), ), ), + 'author_slug filter' => array( + 'input' => array( + 'where' => array( + 'type' => 'post', + 'author_slug' => 'author-slug', + ), + ), + ), + 'empty author' => array( + 'input' => array( + 'where' => array( + 'type' => 'post', + 'author' => array(), + ), + ), + ), + 'unknown author key' => array( + 'input' => array( + 'where' => array( + 'type' => 'post', + 'author' => array( 'name' => 'x' ), + ), + ), + ), + 'author ID of 0' => array( + 'input' => array( + 'where' => array( + 'type' => 'post', + 'author' => array( 'id' => 0 ), + ), + ), + ), + 'empty author slug' => array( + 'input' => array( + 'where' => array( + 'type' => 'post', + 'author' => array( 'slug' => '' ), + ), + ), + ), ); } @@ -3225,7 +3292,7 @@ public function test_query_filters_pages_by_parent(): void { } /** - * The author_slug filter is rejected for post types without author support, mirroring REST. + * The author filter is rejected for post types without author support. * * @ticket 66268 * @since 7.2.0 @@ -3246,24 +3313,64 @@ public function test_query_rejects_author_filter_for_post_type_without_author_su $result = wp_get_ability( 'core/content-query' )->execute( array( 'where' => array( - 'type' => 'no_author_cpt', - 'author_slug' => get_userdata( self::$user_ids['author'] )->user_nicename, + 'type' => 'no_author_cpt', + 'author' => array( 'slug' => get_userdata( self::$user_ids['author'] )->user_nicename ), ), ) ); - $this->assertWPError( $result, 'The author_slug filter should be rejected for post types without author support.' ); + $this->assertWPError( $result, 'The author filter should be rejected for post types without author support.' ); $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'Unsupported author filters should return a filter error.' ); $this->assertSame( - array( 'where[author_slug]' => $result->get_error_message() ), + array( 'where[author]' => $result->get_error_message() ), $result->get_error_data()['params'], - 'The error data should map the unsupported author_slug filter to the error message.' + 'The error data should map the unsupported author filter to the error message.' ); + $this->assertSame( 400, $result->get_error_data()['status'], 'The unsupported author filter should be reported as a bad request.' ); } /** - * The author_slug filter narrows queries to posts by the given author, and each post - * returns its author's slug as `author.slug`. + * For a post type without author support, the author filter is rejected before its value + * is checked, so a malformed value reports the missing author support. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_execute_callback_rejects_author_filter_for_post_type_without_author_support_before_its_value(): void { + register_post_type( + 'no_author_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title' ), + ) + ); + + $this->login_as( 'administrator' ); + $execute = $this->get_ability_callbacks()['execute_callback']; + + $result = $execute( + array( + 'where' => array( + 'type' => 'no_author_cpt', + 'author' => 'not-an-object', + ), + ) + ); + + $this->assertWPError( $result, 'The author filter should be rejected for post types without author support whatever its value.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'The unsupported author filter should fail as an invalid filter before its value is checked.' ); + $this->assertSame( 'The author filter is only supported for post types that support authors.', $result->get_error_message(), 'The error should report the missing author support, not the malformed value.' ); + $this->assertSame( + array( 'where[author]' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the unsupported author filter, not its value, to the error message.' + ); + } + + /** + * The author filter narrows queries to posts by the author with the given slug, and each + * post returns that slug as `author.slug`. * * @ticket 66268 * @since 7.2.0 @@ -3289,18 +3396,186 @@ public function test_query_filters_posts_by_author(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( 'where' => array( - 'type' => 'post', - 'author_slug' => $author_slug, + 'type' => 'post', + 'author' => array( 'slug' => $author_slug ), ), 'fields' => array( 'id', 'author.slug' ), 'pagination' => array( 'per_page' => 100 ), ) ); - $ids = wp_list_pluck( $result['items'], 'id' ); - $this->assertContains( $mine_id, $ids, 'The author_slug filter should include the author\'s posts.' ); - $this->assertNotContains( $other_id, $ids, 'The author_slug filter should exclude other authors\' posts.' ); - $this->assertSame( array( $author_slug ), array_unique( wp_list_pluck( wp_list_pluck( $result['items'], 'author' ), 'slug' ) ), 'Each post should return its author\'s slug.' ); + $this->assertIsArray( $result, 'An author slug should pass validation.' ); + + $ids = wp_list_pluck( $result['items'], 'id' ); + + $this->assertContains( $mine_id, $ids, 'The author slug should include the author\'s posts.' ); + $this->assertNotContains( $other_id, $ids, 'The author slug should exclude other authors\' posts.' ); + $this->assertSame( array( $author_slug ), array_unique( wp_list_pluck( wp_list_pluck( $result['items'], 'author' ), 'slug' ) ), 'Each post should return the author slug the filter named.' ); + } + + /** + * The author filter narrows queries to posts by the author with the given ID, and each + * post returns that ID as `author.id`. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_query_filters_posts_by_author_id(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $mine_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'publish', + ) + ); + $other_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author_secondary'], + 'post_status' => 'publish', + ) + ); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'author' => array( 'id' => self::$user_ids['author'] ), + ), + 'fields' => array( 'id', 'author.id' ), + 'pagination' => array( 'per_page' => 100 ), + ) + ); + + $this->assertIsArray( $result, 'An author ID should pass validation.' ); + + $ids = wp_list_pluck( $result['items'], 'id' ); + + $this->assertContains( $mine_id, $ids, 'The author ID should include the author\'s posts.' ); + $this->assertNotContains( $other_id, $ids, 'The author ID should exclude other authors\' posts.' ); + $this->assertSame( array( self::$user_ids['author'] ), array_unique( wp_list_pluck( wp_list_pluck( $result['items'], 'author' ), 'id' ) ), 'Each post should return the author ID the filter named.' ); + } + + /** + * An author filter given as an object behaves like its array form. + * + * Schema validation accepts a `stdClass` for every object in the input, so the filter + * must read the ID and the slug from either form. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_author_filter_given_as_an_object_behaves_like_an_array(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'publish', + ) + ); + $ability = wp_get_ability( 'core/content-query' ); + $query = static function ( $author ) use ( $ability ) { + return $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'author' => $author, + ), + 'fields' => array( 'id' ), + ) + ); + }; + + $expected = array( + 'items' => array( + array( 'id' => $post_id ), + ), + 'pagination' => array( + 'total' => 1, + 'total_pages' => 1, + ), + ); + + $this->assertSame( $expected, $query( array( 'id' => self::$user_ids['author'] ) ), 'Precondition: the array form should return the author\'s post.' ); + $this->assertSame( $expected, $query( (object) array( 'id' => self::$user_ids['author'] ) ), 'An object author filter with an ID should return the author\'s post.' ); + $this->assertSame( $expected, $query( (object) array( 'slug' => get_userdata( self::$user_ids['author'] )->user_nicename ) ), 'An object author filter with a slug should return the author\'s post.' ); + } + + /** + * An author filter with both an ID and a slug matches a post only when both name its + * author, since a post must match every given filter. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_author_filter_with_an_id_and_a_slug_matches_posts_only_when_both_name_the_author(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'publish', + ) + ); + $author_id = self::$user_ids['author']; + $author_slug = get_userdata( $author_id )->user_nicename; + $other_slug = get_userdata( self::$user_ids['author_secondary'] )->user_nicename; + $ability = wp_get_ability( 'core/content-query' ); + $query = static function ( array $author ) use ( $ability ) { + return $ability->execute( + array( + 'where' => array( + 'type' => 'post', + 'author' => $author, + ), + 'fields' => array( 'id' ), + ) + ); + }; + + $same_user = $query( + array( + 'id' => $author_id, + 'slug' => $author_slug, + ) + ); + + $this->assertIsArray( $same_user, 'An ID and a slug that name the same user should pass validation.' ); + $this->assertSame( array( $post_id ), wp_list_pluck( $same_user['items'], 'id' ), 'An ID and a slug that name the same user should return that user\'s posts.' ); + $this->assertSame( + self::EMPTY_RESULT, + $query( + array( + 'id' => $author_id, + 'slug' => $other_slug, + ) + ), + 'An ID and a slug that name different users should match no post.' + ); + $this->assertSame( + self::EMPTY_RESULT, + $query( + array( + 'id' => $author_id, + 'slug' => 'no-such-user', + ) + ), + 'An ID next to a slug that names no user should match no post.' + ); + $this->assertSame( + self::EMPTY_RESULT, + $query( + array( + 'id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER, + 'slug' => $author_slug, + ) + ), + 'A slug next to an ID that names no user should match no post.' + ); } /** @@ -5036,64 +5311,300 @@ public function test_malformed_date_is_null( string $field, string $column ): vo } /** - * 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. + * Returns `where.author` values that are not an object holding an ID or a slug: values + * that are not objects, empty objects, and objects with an unknown property. + * + * Schema validation rejects them all, so the test calls the execute callback directly. + * + * @return array The `where.author` values. + */ + public function data_author_filters_without_an_id_or_a_slug(): array { + return array( + 'string' => array( + 'author' => 'author-slug', + ), + 'integer' => array( + 'author' => 5, + ), + 'boolean' => array( + 'author' => true, + ), + 'list' => array( + 'author' => array( 'author-slug' ), + ), + 'null' => array( + 'author' => null, + ), + 'empty array' => array( + 'author' => array(), + ), + 'empty object' => array( + 'author' => (object) array(), + ), + 'unknown property' => array( + 'author' => array( 'name' => 'x' ), + ), + 'unknown property next to a slug' => array( + 'author' => array( + 'slug' => 'author-slug', + 'name' => 'x', + ), + ), + ); + } + + /** + * Called directly, an author filter that is not an object holding an ID or a slug is + * rejected rather than dropped, which would widen the query to every author's posts. * * @ticket 66268 + * @dataProvider data_author_filters_without_an_id_or_a_slug + * @since 7.2.0 + * + * @param mixed $author The `where.author` value to send. */ - public function test_execute_callback_rejects_an_author_slug_that_is_not_a_string(): void { + public function test_execute_callback_rejects_an_author_filter_without_an_id_or_a_slug( $author ): void { $this->login_as( 'administrator' ); $execute = $this->get_ability_callbacks()['execute_callback']; $result = $execute( array( 'where' => array( - 'type' => 'post', - 'author_slug' => 5, + 'type' => 'post', + 'author' => $author, ), ) ); - $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->assertWPError( $result, 'An author filter without an ID or a slug must not silently widen the query to all authors.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An author filter without an ID or a slug should fail as an invalid filter.' ); $this->assertSame( - array( 'where[author_slug]' => $result->get_error_message() ), + array( 'where[author]' => $result->get_error_message() ), $result->get_error_data()['params'], - 'The error data should map the malformed author_slug filter to the error message.' + 'The error data should map the malformed author filter to the error message.' ); + $this->assertSame( 400, $result->get_error_data()['status'], 'The malformed author filter should be reported as a bad request.' ); } /** - * 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. + * Returns `where.author.id` values that are not a positive integer. + * + * Schema validation rejects them all, so the test calls the execute callback directly. + * + * @return array The `where.author.id` values. + */ + public function data_author_ids_that_are_not_positive_integers(): array { + return array( + 'zero' => array( + 'id' => 0, + ), + 'negative number' => array( + 'id' => -5, + ), + 'fraction' => array( + 'id' => 2.5, + ), + 'non-digit string' => array( + 'id' => 'abc', + ), + 'true' => array( + 'id' => true, + ), + 'false' => array( + 'id' => false, + ), + 'null' => array( + 'id' => null, + ), + ); + } + + /** + * Called directly, an author ID that is not a positive integer is rejected rather than + * coerced, which could match another user's posts or every author's posts. * * @ticket 66268 + * @dataProvider data_author_ids_that_are_not_positive_integers + * @since 7.2.0 + * + * @param mixed $id The `where.author.id` value to send. */ - public function test_author_slug_filter_that_names_no_user_matches_no_post(): void { + public function test_execute_callback_rejects_an_author_id_that_is_not_a_positive_integer( $id ): void { $this->login_as( 'administrator' ); $execute = $this->get_ability_callbacks()['execute_callback']; $result = $execute( array( 'where' => array( - 'type' => 'post', - 'author_slug' => 'no-such-user', + 'type' => 'post', + 'author' => array( 'id' => $id ), ), ) ); - $this->assertSame( self::EMPTY_RESULT, $result, 'An author_slug that names no user should match no post.' ); + $this->assertWPError( $result, 'An author ID that is not a positive integer must not be coerced into a query.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An author ID that is not a positive integer should fail as an invalid filter.' ); + $this->assertSame( + array( 'where[author][id]' => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should map the malformed author ID to the error message.' + ); + $this->assertSame( 400, $result->get_error_data()['status'], 'The malformed author ID should be reported as a bad request.' ); } /** - * An author_slug filter is looked up as given, like the `slug` filter of the REST API users + * Called directly, an author ID given as a string of digits is accepted like a `parent` + * ID, and names the user with that ID. + * + * @ticket 66268 + * @since 7.2.0 + */ + public function test_execute_callback_accepts_an_author_id_given_as_a_string_of_digits(): void { + $mine_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'publish', + ) + ); + self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author_secondary'], + 'post_status' => 'publish', + ) + ); + + $this->login_as( 'administrator' ); + $execute = $this->get_ability_callbacks()['execute_callback']; + + $result = $execute( + array( + 'where' => array( + 'type' => 'post', + 'author' => array( 'id' => (string) self::$user_ids['author'] ), + ), + 'fields' => array( 'id' ), + ) + ); + + $this->assertIsArray( $result, 'An author ID given as a string of digits should be accepted.' ); + $this->assertSame( array( $mine_id ), wp_list_pluck( $result['items'], 'id' ), 'An author ID given as a string of digits should return only that author\'s posts.' ); + } + + /** + * Returns `where.author.slug` values that are not a string, and the empty string. + * + * Schema validation rejects them all, so the test calls the execute callback directly. + * + * @return array The `where.author.slug` values. + */ + public function data_author_slugs_that_are_not_strings(): array { + return array( + 'integer' => array( + 'slug' => 5, + ), + 'boolean' => array( + 'slug' => true, + ), + 'list' => array( + 'slug' => array( 'author-slug' ), + ), + 'object' => array( + 'slug' => (object) array( 'slug' => 'author-slug' ), + ), + 'null' => array( + 'slug' => null, + ), + 'empty string' => array( + 'slug' => '', + ), + ); + } + + /** + * Called directly, an author slug that is not a string, or is empty, is rejected rather + * than silently dropped, which would widen the query to every author's posts. + * + * @ticket 66268 + * @dataProvider data_author_slugs_that_are_not_strings + * @since 7.2.0 + * + * @param mixed $slug The `where.author.slug` value to send. + */ + public function test_execute_callback_rejects_an_author_slug_that_is_not_a_string( $slug ): void { + $this->login_as( 'administrator' ); + $execute = $this->get_ability_callbacks()['execute_callback']; + + $result = $execute( + array( + 'where' => array( + 'type' => 'post', + 'author' => array( 'slug' => $slug ), + ), + ) + ); + + $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 to the error message.' + ); + $this->assertSame( 400, $result->get_error_data()['status'], 'The malformed author slug should be reported as a bad request.' ); + } + + /** + * Returns `where.author` values that name no user: an ID no user has, and a slug no user + * has. + * + * @return array}> The `where.author` values. + */ + public function data_author_filters_that_name_no_user(): array { + return array( + 'ID' => array( + 'author' => array( 'id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ), + ), + 'slug' => array( + 'author' => array( 'slug' => 'no-such-user' ), + ), + ); + } + + /** + * An author 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_filters_that_name_no_user + * @since 7.2.0 + * + * @param array $author The `where.author` value to send. + */ + public function test_author_filter_that_names_no_user_matches_no_post( array $author ): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'author' => $author, + ), + ) + ); + + $this->assertSame( self::EMPTY_RESULT, $result, 'An author filter that names no user should match no post.' ); + } + + /** + * An author slug is looked up as given, like the `slug` filter of the REST API users * endpoint, so with the database's case-insensitive collation a slug in capitals names the * author too. * * @ticket 66268 * @since 7.2.0 */ - public function test_author_slug_filter_matches_a_slug_in_capitals(): void { + public function test_author_filter_matches_a_slug_in_capitals(): void { wp_update_user( array( 'ID' => self::$user_ids['author'], @@ -5120,9 +5631,9 @@ public function test_author_slug_filter_matches_a_slug_in_capitals(): void { $result = wp_get_ability( 'core/content-query' )->execute( array( 'where' => array( - 'type' => 'post', - 'author_slug' => 'AUTHOR-SLUG', - 'ids' => array( $author_post_id, $other_post_id ), + 'type' => 'post', + 'author' => array( 'slug' => 'AUTHOR-SLUG' ), + 'ids' => array( $author_post_id, $other_post_id ), ), 'fields' => array( 'id', 'author.slug' ), ) @@ -5664,13 +6175,13 @@ public function test_execute_callback_defaults_a_pagination_that_is_not_an_objec } /** - * 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. + * An author filter matches only the posts the current user may read, so a user without + * such posts gives the same empty result as an ID or a slug that names no user. * * @ticket 66268 * @since 7.2.0 */ - public function test_author_slug_filter_matches_no_post_for_a_user_without_readable_posts(): void { + public function test_author_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( @@ -5680,48 +6191,58 @@ public function test_author_slug_filter_matches_no_post_for_a_user_without_reada ); $execute = $this->get_ability_callbacks()['execute_callback']; - $query = static function ( array $statuses, string $author_slug ) use ( $execute ) { + $query = static function ( array $statuses, array $author ) use ( $execute ) { return $execute( array( 'where' => array( - 'type' => 'post', - 'status' => $statuses, - 'author_slug' => $author_slug, + 'type' => 'post', + 'status' => $statuses, + 'author' => $author, ), '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; + $author_slug = array( 'slug' => get_userdata( self::$user_ids['author'] )->user_nicename ); + $author_secondary_id = array( 'id' => self::$user_ids['author_secondary'] ); + $author_secondary_slug = array( 'slug' => get_userdata( self::$user_ids['author_secondary'] )->user_nicename ); + $subscriber_slug = array( 'slug' => get_userdata( self::$user_ids['subscriber'] )->user_nicename ); + $missing_id = array( 'id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $missing_slug = array( 'slug' => 'no-such-user' ); $this->login_as( 'subscriber' ); $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.' ); - $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->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $author_secondary_slug ), 'A subscriber should get the empty result for the slug of an author without published posts.' ); + $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $author_secondary_id ), 'A subscriber should get the empty result for the ID of an author without published posts.' ); + $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $missing_slug ), 'A subscriber should get the empty result for a slug that names no user.' ); + $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $missing_id ), 'A subscriber should get the empty result for an ID that names no user.' ); $this->login_as( 'editor' ); $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.' ); + $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $missing_slug ), 'An editor should get the empty result for a slug that names no user.' ); + $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $missing_id ), 'An editor should get the empty result for an ID 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.' ); + $this->assertIsArray( $drafts, 'An editor should filter by the slug of an author without published posts.' ); + $this->assertSame( array( $draft_id ), wp_list_pluck( $drafts['items'], 'id' ), 'The slug should return that author\'s drafts.' ); + + $drafts_by_id = $query( array( 'draft' ), $author_secondary_id ); + $this->assertIsArray( $drafts_by_id, 'An editor should filter by the ID of an author without published posts.' ); + $this->assertSame( array( $draft_id ), wp_list_pluck( $drafts_by_id['items'], 'id' ), 'The ID should return that author\'s drafts.' ); } /** - * On multisite, an author_slug filter that names a user of another site matches no post, - * like a slug that names no user. + * On multisite, an author filter that names a user of another site, by ID or by slug, + * matches no post, like an ID or a slug that names no user. * * @ticket 66268 * @group ms-required * @since 7.2.0 */ - public function test_author_slug_filter_matches_no_post_for_a_user_of_another_site(): void { + public function test_author_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(); @@ -5729,37 +6250,41 @@ public function test_author_slug_filter_matches_no_post_for_a_user_of_another_si wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); $execute = $this->get_ability_callbacks()['execute_callback']; - $query = static function ( string $author_slug ) use ( $execute ) { + $query = static function ( array $author ) use ( $execute ) { return $execute( array( 'where' => array( - 'type' => 'post', - 'author_slug' => $author_slug, + 'type' => 'post', + 'author' => $author, ), ) ); }; - $is_member = is_user_member_of_blog( $other_user_id, $site_id ); - $other = $query( 'other-site-user' ); - $missing = $query( 'no-such-user' ); + $is_member = is_user_member_of_blog( $other_user_id, $site_id ); + $other_by_slug = $query( array( 'slug' => 'other-site-user' ) ); + $other_by_id = $query( array( 'id' => $other_user_id ) ); + $missing_slug = $query( array( 'slug' => 'no-such-user' ) ); + $missing_id = $query( array( 'id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ) ); restore_current_blog(); $this->assertFalse( $is_member, 'Precondition: the other user should not be a member of the site.' ); - $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.' ); + $this->assertSame( self::EMPTY_RESULT, $missing_slug, 'Precondition: a slug that names no user should match no post.' ); + $this->assertSame( self::EMPTY_RESULT, $missing_id, 'Precondition: an ID that names no user should match no post.' ); + $this->assertSame( self::EMPTY_RESULT, $other_by_slug, 'The slug of a user of another site should match no post of the site.' ); + $this->assertSame( self::EMPTY_RESULT, $other_by_id, 'The ID of a user of another site should match no post of the site.' ); } /** - * On multisite, an author_slug filter still names an author who is not a member of the - * site, such as a super admin, when they have published posts there. + * On multisite, an author filter still names an author who is not a member of the site, + * such as a super admin, when they have published posts there. * * @ticket 66268 * @group ms-required * @since 7.2.0 */ - public function test_author_slug_filter_names_authors_who_are_not_site_members(): void { + public function test_author_filter_names_authors_who_are_not_site_members(): void { $super_admin_id = self::factory()->user->create( array( 'user_nicename' => 'network-author' ) ); grant_super_admin( $super_admin_id ); $site_id = self::factory()->blog->create(); @@ -5770,11 +6295,20 @@ public function test_author_slug_filter_names_authors_who_are_not_site_members() wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); $is_member = is_user_member_of_blog( $super_admin_id, $site_id ); - $result = $execute( + $by_slug = $execute( + array( + 'where' => array( + 'type' => 'post', + 'author' => array( 'slug' => 'network-author' ), + ), + 'fields' => array( 'id' ), + ) + ); + $by_id = $execute( array( 'where' => array( - 'type' => 'post', - 'author_slug' => 'network-author', + 'type' => 'post', + 'author' => array( 'id' => $super_admin_id ), ), 'fields' => array( 'id' ), ) @@ -5784,7 +6318,9 @@ public function test_author_slug_filter_names_authors_who_are_not_site_members() revoke_super_admin( $super_admin_id ); $this->assertFalse( $is_member, 'Precondition: the super admin should not be a member of the site.' ); - $this->assertIsArray( $result, 'An author with published posts on the site should be filterable without being a member.' ); - $this->assertSame( array( $post_id ), wp_list_pluck( $result['items'], 'id' ), 'The filter should return the author\'s posts on the site.' ); + $this->assertIsArray( $by_slug, 'An author with published posts on the site should be filterable by slug without being a member.' ); + $this->assertSame( array( $post_id ), wp_list_pluck( $by_slug['items'], 'id' ), 'The slug should return the author\'s posts on the site.' ); + $this->assertIsArray( $by_id, 'An author with published posts on the site should be filterable by ID without being a member.' ); + $this->assertSame( array( $post_id ), wp_list_pluck( $by_id['items'], 'id' ), 'The ID should return the author\'s posts on the site.' ); } } From 45cb78251553e680c2d83a38142136e6ea3288b8 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 21 Oct 2026 21:28:33 +0100 Subject: [PATCH 3/5] Abilities API: Tighten the `author` filter descriptions and tests of `core/content-query`. Say in the schema that an ID and a slug naming different users match no post, mention the filter in the output `author.id` description, and ask for a non-empty slug in the error message. Cover an empty-string `author`, a whole float ID, and which problem an author filter with several reports. Follow-up to [64388]. See #66268. --- .../abilities/class-wp-abilities-content.php | 6 +- .../wpRegisterCoreContentAbility.php | 123 ++++++++++++++++-- 2 files changed, 113 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-content.php b/src/wp-includes/abilities/class-wp-abilities-content.php index c94169557e27f..8503ed284a1b5 100644 --- a/src/wp-includes/abilities/class-wp-abilities-content.php +++ b/src/wp-includes/abilities/class-wp-abilities-content.php @@ -499,7 +499,7 @@ private function execute_content_query( $input = array() ) { return $this->invalid_filter_error( 'where[author][slug]', /* translators: %s: Parameter. */ - sprintf( __( 'The %s slug must be a string.' ), 'author' ) + sprintf( __( 'The %s slug must be a non-empty string.' ), 'author' ) ); } @@ -1076,7 +1076,7 @@ private function get_post_properties(): array { 'properties' => array( 'id' => array( 'type' => 'integer', - 'description' => __( 'The user ID of the author.' ), + 'description' => __( 'The user ID of the author, as the `author.id` filter takes it.' ), ), 'name' => array( 'type' => array( 'string', 'null' ), @@ -1224,7 +1224,7 @@ private function get_content_query_input_schema( array $post_types, array $statu 'description' => __( "The author's user slug (nicename), as `author.slug` is returned and as the REST API users endpoint returns it in `slug`." ), ), ), - 'description' => __( 'Filter by the post author, given as the `id` or the `slug` the `author` field of a post returns. When both are given they must name the same user. An ID or slug that names no user matches no post. Only supported for post types that support authors.' ), + 'description' => __( 'Filter by the post author, given as the `id` or the `slug` the `author` field of a post returns. An ID or slug that names no user, or an ID and a slug that name different users, match 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 f8e82eeb032c1..0bb31765ff3b2 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -5312,7 +5312,7 @@ public function test_malformed_date_is_null( string $field, string $column ): vo /** * Returns `where.author` values that are not an object holding an ID or a slug: values - * that are not objects, empty objects, and objects with an unknown property. + * that are not objects, empty values, and objects with an unknown property. * * Schema validation rejects them all, so the test calls the execute callback directly. * @@ -5335,6 +5335,9 @@ public function data_author_filters_without_an_id_or_a_slug(): array { 'null' => array( 'author' => null, ), + 'empty string' => array( + 'author' => '', + ), 'empty array' => array( 'author' => array(), ), @@ -5453,13 +5456,32 @@ public function test_execute_callback_rejects_an_author_id_that_is_not_a_positiv } /** - * Called directly, an author ID given as a string of digits is accepted like a `parent` - * ID, and names the user with that ID. + * Returns the scalar types, other than integer, that carry a whole number. + * + * @return array The type names, as `settype()` takes them. + */ + public function data_author_id_whole_number_types(): array { + return array( + 'string of digits' => array( + 'type' => 'string', + ), + 'whole float' => array( + 'type' => 'float', + ), + ); + } + + /** + * Called directly, an author ID given as a string of digits or a whole float is accepted + * like a `parent` ID, and names the user with that ID. * * @ticket 66268 + * @dataProvider data_author_id_whole_number_types * @since 7.2.0 + * + * @param string $type The type to give the ID as. */ - public function test_execute_callback_accepts_an_author_id_given_as_a_string_of_digits(): void { + public function test_execute_callback_accepts_an_author_id_given_as_a_whole_number( string $type ): void { $mine_id = self::factory()->post->create( array( 'post_author' => self::$user_ids['author'], @@ -5473,6 +5495,9 @@ public function test_execute_callback_accepts_an_author_id_given_as_a_string_of_ ) ); + $author_id = self::$user_ids['author']; + settype( $author_id, $type ); + $this->login_as( 'administrator' ); $execute = $this->get_ability_callbacks()['execute_callback']; @@ -5480,24 +5505,96 @@ public function test_execute_callback_accepts_an_author_id_given_as_a_string_of_ array( 'where' => array( 'type' => 'post', - 'author' => array( 'id' => (string) self::$user_ids['author'] ), + 'author' => array( 'id' => $author_id ), ), 'fields' => array( 'id' ), ) ); - $this->assertIsArray( $result, 'An author ID given as a string of digits should be accepted.' ); - $this->assertSame( array( $mine_id ), wp_list_pluck( $result['items'], 'id' ), 'An author ID given as a string of digits should return only that author\'s posts.' ); + $this->assertIsArray( $result, "An author ID given as a {$type} should be accepted." ); + $this->assertSame( array( $mine_id ), wp_list_pluck( $result['items'], 'id' ), "An author ID given as a {$type} should return only that author's posts." ); + } + + /** + * Returns author filters with more than one problem, with the parameter the error + * should name: the object's shape is checked before its ID, and the ID before its slug. + * + * @return array, param: string}> The filters and parameters. + */ + public function data_author_filters_with_several_problems(): array { + return array( + 'unknown property next to a bad id' => array( + 'author' => array( + 'id' => 0, + 'name' => 'x', + ), + 'param' => 'where[author]', + ), + 'bad id and bad slug' => array( + 'author' => array( + 'id' => 0, + 'slug' => 5, + ), + 'param' => 'where[author][id]', + ), + 'bad id and empty slug' => array( + 'author' => array( + 'id' => 0, + 'slug' => '', + ), + 'param' => 'where[author][id]', + ), + 'good id and bad slug' => array( + 'author' => array( + 'id' => 1, + 'slug' => 5, + ), + 'param' => 'where[author][slug]', + ), + ); + } + + /** + * Called directly, an author filter with several problems reports the first one found: + * the object's shape, then the ID, then the slug. + * + * @ticket 66268 + * @dataProvider data_author_filters_with_several_problems + * @since 7.2.0 + * + * @param array $author The `where.author` value to send. + * @param string $param The parameter the error should name. + */ + public function test_execute_callback_reports_the_first_problem_of_an_author_filter( array $author, string $param ): void { + $this->login_as( 'administrator' ); + $execute = $this->get_ability_callbacks()['execute_callback']; + + $result = $execute( + array( + 'where' => array( + 'type' => 'post', + 'author' => $author, + ), + ) + ); + + $this->assertWPError( $result, 'An author filter with several problems should be rejected.' ); + $this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An author filter with several problems should fail as an invalid filter.' ); + $this->assertSame( + array( $param => $result->get_error_message() ), + $result->get_error_data()['params'], + 'The error data should name the first problem found in the author filter.' + ); } /** - * Returns `where.author.slug` values that are not a string, and the empty string. + * Returns `where.author.slug` values that are not a non-empty string. * * Schema validation rejects them all, so the test calls the execute callback directly. * * @return array The `where.author.slug` values. */ - public function data_author_slugs_that_are_not_strings(): array { + public function data_author_slugs_that_are_not_non_empty_strings(): array { return array( 'integer' => array( 'slug' => 5, @@ -5525,12 +5622,12 @@ public function data_author_slugs_that_are_not_strings(): array { * than silently dropped, which would widen the query to every author's posts. * * @ticket 66268 - * @dataProvider data_author_slugs_that_are_not_strings + * @dataProvider data_author_slugs_that_are_not_non_empty_strings * @since 7.2.0 * * @param mixed $slug The `where.author.slug` value to send. */ - public function test_execute_callback_rejects_an_author_slug_that_is_not_a_string( $slug ): void { + public function test_execute_callback_rejects_an_author_slug_that_is_not_a_non_empty_string( $slug ): void { $this->login_as( 'administrator' ); $execute = $this->get_ability_callbacks()['execute_callback']; @@ -5543,8 +5640,8 @@ public function test_execute_callback_rejects_an_author_slug_that_is_not_a_strin ) ); - $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->assertWPError( $result, 'An author slug that is not a non-empty 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 non-empty string should fail as an invalid filter.' ); $this->assertSame( array( 'where[author][slug]' => $result->get_error_message() ), $result->get_error_data()['params'], From 00af6c7ae1044d0ba5305980aa091f0cdd5eb247 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 22 Oct 2026 11:09:03 +0100 Subject: [PATCH 4/5] Abilities API: Drop the tests that check for the removed `author_slug` filter of `core/content-query`. Follow-up to [64388]. See #66268. --- .../wpRegisterCoreContentAbility.php | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php index 0bb31765ff3b2..dc1eb2ce693e9 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -369,7 +369,6 @@ public function test_input_schema_models_a_flat_where(): void { $this->assertSame( 1, $author['properties']['id']['minimum'], 'The author ID should be positive.' ); $this->assertSame( 'string', $author['properties']['slug']['type'], 'The author slug should be a string.' ); $this->assertSame( 1, $author['properties']['slug']['minLength'], 'The author slug should not be empty.' ); - $this->assertArrayNotHasKey( 'author_slug', $where['properties'], 'The author_slug filter should be replaced by the author filter.' ); $this->assertSame( 'integer', $where['properties']['parent']['type'], 'The parent filter should be an integer.' ); $this->assertSame( 0, $where['properties']['parent']['minimum'], 'The parent filter should accept 0 for top-level posts.' ); @@ -453,22 +452,6 @@ public function test_input_schema_omits_defaults(): void { $this->assertSame( array(), $this->find_schema_keywords( $schema, array( 'default' ) ), 'The input schema should not declare a default at any depth.' ); } - /** - * The `author_slug` filter is replaced by the `author` filter, so no description in the - * input or output schema refers to it any more. - * - * @ticket 66268 - * @since 7.2.0 - */ - public function test_schemas_do_not_mention_the_author_slug_filter(): void { - $this->register_ability(); - - $ability = wp_get_ability( 'core/content-query' ); - - $this->assertStringNotContainsString( 'author_slug', wp_json_encode( $ability->get_input_schema() ), 'The input schema should not mention the author_slug filter.' ); - $this->assertStringNotContainsString( 'author_slug', wp_json_encode( $ability->get_output_schema() ), 'The output schema should not mention the author_slug filter.' ); - } - /** * `where.ids` combines with a `status` filter: a post must have one of the IDs and one of the * statuses. @@ -603,14 +586,6 @@ public function data_invalid_inputs(): array { ), ), ), - 'author_slug filter' => array( - 'input' => array( - 'where' => array( - 'type' => 'post', - 'author_slug' => 'author-slug', - ), - ), - ), 'empty author' => array( 'input' => array( 'where' => array( From 69a0d1665ac8266916b0e11f5a87c2cd7741c36b Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 22 Oct 2026 11:33:54 +0100 Subject: [PATCH 5/5] Abilities API: Document that the `author.id` filter of `core/content-query` matches the stored author ID. The ID is not looked up, so the posts of a deleted user still match it. Say so in the schema description and the code comment, test it, and keep the empty-result test for slugs only. Follow-up to [64388]. See #66268. --- .../abilities/class-wp-abilities-content.php | 9 ++- .../wpRegisterCoreContentAbility.php | 77 +++++++++++++------ 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-content.php b/src/wp-includes/abilities/class-wp-abilities-content.php index 8503ed284a1b5..cf64f0a5f0f28 100644 --- a/src/wp-includes/abilities/class-wp-abilities-content.php +++ b/src/wp-includes/abilities/class-wp-abilities-content.php @@ -618,9 +618,10 @@ private function execute_content_query( $input = array() ) { } /* - * An author filter that names no user matches no post, like IDs and slugs that name - * no post, rather than failing the query. An `id` that names no user needs no lookup: - * the query matches no post on its own. + * A slug that names no user, or an ID and a slug that name different users, match no + * post, like IDs and slugs that name no post, rather than failing the query. An `id` is + * not looked up: the query matches the stored author ID, so the posts of a deleted user + * still match it. */ if ( false === $author_id ) { return $this->prepare_output( array(), 0, 0 ); @@ -1224,7 +1225,7 @@ private function get_content_query_input_schema( array $post_types, array $statu 'description' => __( "The author's user slug (nicename), as `author.slug` is returned and as the REST API users endpoint returns it in `slug`." ), ), ), - 'description' => __( 'Filter by the post author, given as the `id` or the `slug` the `author` field of a post returns. An ID or slug that names no user, or an ID and a slug that name different users, match no post. Only supported for post types that support authors.' ), + 'description' => __( 'Filter by the post author, given as the `id` or the `slug` the `author` field of a post returns. An `id` matches the author ID stored on the post, even when that user no longer exists. A `slug` that names no user, or an `id` and a `slug` that name different users, match 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 dc1eb2ce693e9..2f495f6e095bb 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php @@ -3549,7 +3549,7 @@ public function test_author_filter_with_an_id_and_a_slug_matches_posts_only_when 'slug' => $author_slug, ) ), - 'A slug next to an ID that names no user should match no post.' + 'A slug next to an ID of another user should match no post.' ); } @@ -5626,46 +5626,75 @@ public function test_execute_callback_rejects_an_author_slug_that_is_not_a_non_e } /** - * Returns `where.author` values that name no user: an ID no user has, and a slug no user - * has. + * An author slug that names no user matches no post, rather than being dropped, which + * would widen the query to every author's posts. * - * @return array}> The `where.author` values. + * @ticket 66268 + * @since 7.2.0 */ - public function data_author_filters_that_name_no_user(): array { - return array( - 'ID' => array( - 'author' => array( 'id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ), - ), - 'slug' => array( - 'author' => array( 'slug' => 'no-such-user' ), - ), + public function test_author_filter_with_a_slug_that_names_no_user_matches_no_post(): void { + self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'publish', + ) ); + + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content-query' )->execute( + array( + 'where' => array( + 'type' => 'post', + 'author' => array( 'slug' => 'no-such-user' ), + ), + ) + ); + + $this->assertSame( self::EMPTY_RESULT, $result, 'An author slug that names no user should match no post.' ); } /** - * An author filter that names no user matches no post, rather than being dropped, which - * would widen the query to every author's posts. + * An author ID matches the author ID stored on the post without looking the user up, so + * the posts of a deleted user are still found by the `author.id` their output returns. * * @ticket 66268 - * @dataProvider data_author_filters_that_name_no_user * @since 7.2.0 - * - * @param array $author The `where.author` value to send. */ - public function test_author_filter_that_names_no_user_matches_no_post( array $author ): void { + public function test_author_filter_by_id_matches_the_posts_of_a_deleted_user(): void { + // No user has this ID, as when the author was deleted after writing the post. + $orphan_id = self::factory()->post->create( + array( + 'post_author' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER, + 'post_status' => 'publish', + ) + ); + self::factory()->post->create( + array( + 'post_author' => self::$user_ids['author'], + 'post_status' => 'publish', + ) + ); + + $this->assertFalse( get_userdata( REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ), 'Precondition: no user should have the stored author ID.' ); + $this->login_as( 'administrator' ); $this->register_ability(); $result = wp_get_ability( 'core/content-query' )->execute( array( - 'where' => array( + 'where' => array( 'type' => 'post', - 'author' => $author, + 'author' => array( 'id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ), ), + 'fields' => array( 'id', 'author' ), ) ); - $this->assertSame( self::EMPTY_RESULT, $result, 'An author filter that names no user should match no post.' ); + $this->assertIsArray( $result, 'The stored author ID of a deleted user should be accepted as a filter.' ); + $this->assertSame( array( $orphan_id ), wp_list_pluck( $result['items'], 'id' ), 'The filter should return the posts that store the deleted user\'s ID.' ); + $this->assertSame( REST_TESTS_IMPOSSIBLY_HIGH_NUMBER, $result['items'][0]['author']['id'], 'The returned author ID should be the one the filter named.' ); } /** @@ -6290,12 +6319,12 @@ public function test_author_filter_matches_no_post_for_a_user_without_readable_p $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $author_secondary_slug ), 'A subscriber should get the empty result for the slug of an author without published posts.' ); $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $author_secondary_id ), 'A subscriber should get the empty result for the ID of an author without published posts.' ); $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $missing_slug ), 'A subscriber should get the empty result for a slug that names no user.' ); - $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $missing_id ), 'A subscriber should get the empty result for an ID that names no user.' ); + $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $missing_id ), 'A subscriber should get the empty result for an ID no post stores.' ); $this->login_as( 'editor' ); $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' ), $missing_slug ), 'An editor should get the empty result for a slug that names no user.' ); - $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $missing_id ), 'An editor should get the empty result for an ID that names no user.' ); + $this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $missing_id ), 'An editor should get the empty result for an ID no post stores.' ); $drafts = $query( array( 'draft' ), $author_secondary_slug ); $this->assertIsArray( $drafts, 'An editor should filter by the slug of an author without published posts.' ); @@ -6343,7 +6372,7 @@ public function test_author_filter_matches_no_post_for_a_user_of_another_site(): $this->assertFalse( $is_member, 'Precondition: the other user should not be a member of the site.' ); $this->assertSame( self::EMPTY_RESULT, $missing_slug, 'Precondition: a slug that names no user should match no post.' ); - $this->assertSame( self::EMPTY_RESULT, $missing_id, 'Precondition: an ID that names no user should match no post.' ); + $this->assertSame( self::EMPTY_RESULT, $missing_id, 'Precondition: an ID no post stores should match no post.' ); $this->assertSame( self::EMPTY_RESULT, $other_by_slug, 'The slug of a user of another site should match no post of the site.' ); $this->assertSame( self::EMPTY_RESULT, $other_by_id, 'The ID of a user of another site should match no post of the site.' ); }