diff --git a/src/wp-includes/abilities/class-wp-abilities-content.php b/src/wp-includes/abilities/class-wp-abilities-content.php index 725301fd6a0f3..cf64f0a5f0f28 100644 --- a/src/wp-includes/abilities/class-wp-abilities-content.php +++ b/src/wp-includes/abilities/class-wp-abilities-content.php @@ -450,24 +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' ) ); } - $author = $this->get_author_by_slug( $where['author_slug'], $post_type_object ); - if ( ! $author ) { + /* + * 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 the slug of an existing user.' ), 'author_slug' ) + sprintf( __( 'The %s filter must be an object with an id or a slug.' ), 'author' ) ); } + + /* + * 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 non-empty 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; + } + } } /* @@ -561,14 +609,24 @@ private function execute_content_query( $input = array() ) { $query_args['post_name__in'] = $slugs; } - if ( null !== $author ) { - $query_args['author'] = $author->ID; + if ( is_int( $author_id ) ) { + $query_args['author'] = $author_id; } if ( null !== $parent ) { $query_args['post_parent'] = $parent; } + /* + * 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 ); + } + $query = new WP_Query( $query_args ); $total = $this->get_query_total( $query, $query_args, $page ); @@ -745,50 +803,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. * @@ -1063,7 +1077,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' ), @@ -1071,7 +1085,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.' ), @@ -1137,7 +1151,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. * @@ -1158,12 +1172,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, @@ -1174,7 +1188,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, @@ -1185,7 +1199,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, @@ -1195,18 +1209,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`. 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. 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( + '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 08c6df936aecb..2f495f6e095bb 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,17 @@ 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->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 +427,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 ) { @@ -576,6 +586,38 @@ public function data_invalid_inputs(): array { ), ), ), + '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 +3267,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 +3288,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 +3371,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 of another user should match no post.' + ); } /** @@ -5036,64 +5286,426 @@ public function test_malformed_date_is_null( string $field, string $column ): vo } /** - * Returns author slugs that do not name a user. + * Returns `where.author` values that are not an object holding an ID or a slug: values + * that are not objects, empty values, and objects with an unknown property. * - * @return array The author slug filter value. + * Schema validation rejects them all, so the test calls the execute callback directly. + * + * @return array The `where.author` values. */ - public function data_author_slugs_that_name_no_user(): array { + public function data_author_filters_without_an_id_or_a_slug(): array { return array( - 'unknown slug' => array( 'no-such-user' ), - 'not a string' => array( 5 ), + 'string' => array( + 'author' => 'author-slug', + ), + 'integer' => array( + 'author' => 5, + ), + 'boolean' => array( + 'author' => true, + ), + 'list' => array( + 'author' => array( 'author-slug' ), + ), + 'null' => array( + 'author' => null, + ), + 'empty string' => array( + 'author' => '', + ), + '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', + ), + ), ); } /** - * 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. + * 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_slugs_that_name_no_user + * @dataProvider data_author_filters_without_an_id_or_a_slug + * @since 7.2.0 * - * @param mixed $author_slug The author slug filter value. + * @param mixed $author The `where.author` value to send. */ - public function test_execute_callback_rejects_an_author_slug_that_names_no_user( $author_slug ): void { - wp_update_user( + 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( - 'ID' => self::$user_ids['author'], - 'user_nicename' => 'author-slug', + 'where' => array( + 'type' => 'post', + 'author' => $author, + ), ) ); + $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]' => $result->get_error_message() ), + $result->get_error_data()['params'], + '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.' ); + } + + /** + * 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_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' => $author_slug, + 'type' => 'post', + 'author' => array( 'id' => $id ), ), ) ); - $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->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_slug]' => $result->get_error_message() ), + array( 'where[author][id]' => $result->get_error_message() ), $result->get_error_data()['params'], - 'The error data should map the unmatched author_slug filter to the error message.' + '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 + * 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_whole_number( string $type ): 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', + ) + ); + + $author_id = self::$user_ids['author']; + settype( $author_id, $type ); + + $this->login_as( 'administrator' ); + $execute = $this->get_ability_callbacks()['execute_callback']; + + $result = $execute( + array( + 'where' => array( + 'type' => 'post', + 'author' => array( 'id' => $author_id ), + ), + 'fields' => array( 'id' ), + ) + ); + + $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 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_non_empty_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_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_non_empty_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 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'], + '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.' ); + } + + /** + * An author slug that names no user matches no post, rather than being dropped, which + * would widen the query to every author's posts. + * + * @ticket 66268 + * @since 7.2.0 + */ + 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 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 + * @since 7.2.0 + */ + 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( + 'type' => 'post', + 'author' => array( 'id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ), + ), + 'fields' => array( 'id', 'author' ), + ) + ); + + $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.' ); + } + + /** + * 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 +5732,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,14 +6276,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 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_only_names_users_the_current_user_may_see(): 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( @@ -5681,90 +6292,58 @@ 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, array $author ) use ( $execute ) { return $execute( array( 'where' => array( - 'type' => 'post', - 'status' => $statuses, - 'author_slug' => get_userdata( $user_id )->user_nicename, + 'type' => 'post', + 'status' => $statuses, + 'author' => $author, ), 'fields' => array( 'id' ), ) ); }; + $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' ), 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->login_as( 'editor' ); - $drafts = $query( array( 'draft' ), self::$user_ids['author_secondary'] ); - $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->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 no post stores.' ); $this->login_as( 'editor' ); - $hidden = $execute( $input ); + $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 no post stores.' ); - $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.' ); + $drafts = $query( array( 'draft' ), $author_secondary_slug ); + $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.' ); - 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.' ); + $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 does not tell an editor of one site whether a user - * of another site exists. + * 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_hides_users_of_other_sites(): 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(); @@ -5772,38 +6351,41 @@ public function test_author_slug_filter_hides_users_of_other_sites(): void { 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->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_slug, 'Precondition: a slug 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.' ); } /** - * 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(); @@ -5814,11 +6396,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_slug' => 'network-author', + 'type' => 'post', + 'author' => array( 'slug' => 'network-author' ), + ), + 'fields' => array( 'id' ), + ) + ); + $by_id = $execute( + array( + 'where' => array( + 'type' => 'post', + 'author' => array( 'id' => $super_admin_id ), ), 'fields' => array( 'id' ), ) @@ -5828,7 +6419,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.' ); } }