diff --git a/includes/class-map-rest-api.php b/includes/class-map-rest-api.php index abf2627..8004991 100644 --- a/includes/class-map-rest-api.php +++ b/includes/class-map-rest-api.php @@ -292,23 +292,39 @@ public static function revoke_invite( \WP_REST_Request $request ): \WP_REST_Resp * @return \WP_REST_Response */ public static function get_suggested_authors( \WP_REST_Request $request ): \WP_REST_Response { - $post_id = (int) $request->get_param( 'post_id' ); - $search = (string) $request->get_param( 'search' ); + $post_id = (int) $request->get_param( 'post_id' ); + $search = (string) $request->get_param( 'search' ); + + // Require a minimum search length to prevent user enumeration. + // On large networks, require a longer search term to limit scope. + $is_large_network = function_exists( 'wp_is_large_network' ) && \wp_is_large_network( 'users' ); + $min_length = $is_large_network ? 5 : 2; + if ( mb_strlen( $search ) < $min_length ) { + return new \WP_REST_Response( array(), 200 ); + } + $post = get_post( $post_id ); $existing_ids = Co_Authors::get_co_authors( $post_id ); $existing_ids[] = (int) $post->post_author; + // On large networks, restrict to indexed columns only for performance. + // Allow email search only for users who can already view user details. + $search_columns = $is_large_network + ? array( 'user_login', 'user_nicename' ) + : array( 'user_login', 'user_nicename', 'display_name' ); + + if ( current_user_can( 'list_users' ) ) { + $search_columns[] = 'user_email'; + } + $args = array( 'capability' => array( 'edit_posts' ), 'number' => 20, 'exclude' => $existing_ids, - 'search_columns' => array( 'user_login', 'user_nicename', 'user_email', 'display_name' ), + 'search' => '*' . $search . '*', + 'search_columns' => $search_columns, ); - if ( '' !== $search ) { - $args['search'] = '*' . $search . '*'; - } - $users = get_users( $args ); $result = array(); foreach ( $users as $user ) { diff --git a/tests/Test_Rest_API.php b/tests/Test_Rest_API.php index 0300b96..a0e1a1a 100644 --- a/tests/Test_Rest_API.php +++ b/tests/Test_Rest_API.php @@ -180,6 +180,85 @@ public function test_invite_management_forbidden_for_co_author(): void { $this->assertSame( 403, $response->get_status() ); } + // ------------------------------------------------------------------------- + // Suggested authors + // ------------------------------------------------------------------------- + + public function test_suggested_authors_returns_empty_for_short_search(): void { + wp_set_current_user( $this->author_id ); + + $request = new WP_REST_Request( 'GET', '/multi-author-posts/v1/posts/' . $this->post_id . '/suggested-authors' ); + $request->set_param( 'search', 'a' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array(), $response->get_data() ); + } + + public function test_suggested_authors_returns_empty_for_empty_search(): void { + wp_set_current_user( $this->author_id ); + + $request = new WP_REST_Request( 'GET', '/multi-author-posts/v1/posts/' . $this->post_id . '/suggested-authors' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array(), $response->get_data() ); + } + + public function test_suggested_authors_does_not_search_by_email_for_authors(): void { + $target = self::factory()->user->create( + array( + 'role' => 'author', + 'user_email' => 'unique-test-email@example.com', + 'display_name' => 'Test Target', + ) + ); + wp_set_current_user( $this->author_id ); + + $request = new WP_REST_Request( 'GET', '/multi-author-posts/v1/posts/' . $this->post_id . '/suggested-authors' ); + $request->set_param( 'search', 'unique-test-email' ); + $response = rest_get_server()->dispatch( $request ); + + $ids = array_column( $response->get_data(), 'id' ); + $this->assertNotContains( $target, $ids ); + } + + public function test_suggested_authors_searches_by_email_for_admins(): void { + $admin = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $target = self::factory()->user->create( + array( + 'role' => 'author', + 'user_email' => 'admin-findable@example.com', + 'display_name' => 'Hidden Name', + ) + ); + wp_set_current_user( $admin ); + + $request = new WP_REST_Request( 'GET', '/multi-author-posts/v1/posts/' . $this->post_id . '/suggested-authors' ); + $request->set_param( 'search', 'admin-findable' ); + $response = rest_get_server()->dispatch( $request ); + + $ids = array_column( $response->get_data(), 'id' ); + $this->assertContains( $target, $ids ); + } + + public function test_suggested_authors_returns_results_for_valid_search(): void { + $target = self::factory()->user->create( + array( + 'role' => 'author', + 'display_name' => 'Searchable Author', + ) + ); + wp_set_current_user( $this->author_id ); + + $request = new WP_REST_Request( 'GET', '/multi-author-posts/v1/posts/' . $this->post_id . '/suggested-authors' ); + $request->set_param( 'search', 'Searchable' ); + $response = rest_get_server()->dispatch( $request ); + + $ids = array_column( $response->get_data(), 'id' ); + $this->assertContains( $target, $ids ); + } + public function test_editor_can_manage_co_authors_via_edit_others_posts(): void { // An editor (who has edit_others_posts) can manage co-authors even // though they are not the post author.