diff --git a/src/wp-includes/rest-api/class-wp-rest-request.php b/src/wp-includes/rest-api/class-wp-rest-request.php index 6ed6ce667432c..3257194e547d2 100644 --- a/src/wp-includes/rest-api/class-wp-rest-request.php +++ b/src/wp-includes/rest-api/class-wp-rest-request.php @@ -161,6 +161,18 @@ public function get_headers() { return $this->headers; } + /** + * Determines if the request is the given method. + * + * @since 6.8.0 + * + * @param string $method HTTP method. + * @return bool Whether the request is of the given method. + */ + public function is_method( $method ) { + return $this->get_method() === strtoupper( $method ); + } + /** * Canonicalizes the header name. * diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php index 84bcea052cf32..60e7f5ba8986b 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php @@ -333,6 +333,12 @@ public function get_items( $request ) { } $prepared_args['search'] = '*' . $prepared_args['search'] . '*'; } + + $is_head_request = $request->is_method( 'head' ); + if ( $is_head_request ) { + // Force the 'fields' argument. For HEAD requests, only user IDs are required. + $prepared_args['fields'] = 'id'; + } /** * Filters WP_User_Query arguments when querying users via the REST API. * @@ -347,14 +353,16 @@ public function get_items( $request ) { $query = new WP_User_Query( $prepared_args ); - $users = array(); + if ( ! $is_head_request ) { + $users = array(); - foreach ( $query->get_results() as $user ) { - $data = $this->prepare_item_for_response( $user, $request ); - $users[] = $this->prepare_response_for_collection( $data ); + foreach ( $query->get_results() as $user ) { + $data = $this->prepare_item_for_response( $user, $request ); + $users[] = $this->prepare_response_for_collection( $data ); + } } - $response = rest_ensure_response( $users ); + $response = $is_head_request ? new WP_REST_Response() : rest_ensure_response( $users ); // Store pagination values for headers then unset for count query. $per_page = (int) $prepared_args['number']; @@ -480,6 +488,10 @@ public function get_item( $request ) { return $user; } + if ( $request->is_method( 'head' ) ) { + return new WP_REST_Response(); + } + $user = $this->prepare_item_for_response( $user, $request ); $response = rest_ensure_response( $user ); diff --git a/tests/phpunit/tests/rest-api/rest-users-controller.php b/tests/phpunit/tests/rest-api/rest-users-controller.php index e9576a2c9bf97..b7d774443126f 100644 --- a/tests/phpunit/tests/rest-api/rest-users-controller.php +++ b/tests/phpunit/tests/rest-api/rest-users-controller.php @@ -233,14 +233,29 @@ public function test_get_items() { $this->check_user_data( $userdata, $data, 'view', $data['_links'] ); } - public function test_get_items_with_edit_context() { + /** + * @dataProvider data_readable_http_methods + * @ticket 56481 + * + * @param string $method HTTP method to use. + */ + public function test_get_items_with_edit_context( $method ) { wp_set_current_user( self::$user ); - $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request = new WP_REST_Request( $method, '/wp/v2/users' ); $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); + $this->assertSame( + 200, + $response->get_status(), + sprintf( 'Expected HTTP status code 200 but got %s.', $response->get_status() ) + ); + + if ( 'HEAD' === $method ) { + $this->assertNull( $response->get_data(), 'Expected null response data for HEAD request, but received non-null data.' ); + return; + } $all_data = $response->get_data(); $data = $all_data[0]; @@ -248,9 +263,27 @@ public function test_get_items_with_edit_context() { $this->check_user_data( $userdata, $data, 'edit', $data['_links'] ); } - public function test_get_items_with_edit_context_without_permission() { + /** + * Data provider intended to provide HTTP method names for testing GET and HEAD requests. + * + * @return array + */ + public function data_readable_http_methods() { + return array( + 'GET request' => array( 'GET' ), + 'HEAD request' => array( 'HEAD' ), + ); + } + + /** + * @dataProvider data_readable_http_methods + * @ticket 56481 + * + * @param string $method HTTP method to use. + */ + public function test_get_items_with_edit_context_without_permission( $method ) { // Test with a user not logged in. - $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request = new WP_REST_Request( $method, '/wp/v2/users' ); $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); @@ -260,7 +293,7 @@ public function test_get_items_with_edit_context_without_permission() { // capability in question: 'list_users'. wp_set_current_user( self::$editor ); - $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request = new WP_REST_Request( $method, '/wp/v2/users' ); $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); @@ -318,14 +351,20 @@ public function test_get_items_unauthenticated_does_not_include_users_without_pu $this->assertNotContains( self::$user, $user_ids ); } - public function test_get_items_pagination_headers() { + /** + * @dataProvider data_readable_http_methods + * @ticket 56481 + * + * @param string $method HTTP method to use. + */ + public function test_get_items_pagination_headers( $method ) { $total_users = self::$total_users; $total_pages = (int) ceil( $total_users / 10 ); wp_set_current_user( self::$user ); // Start of the index. - $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request = new WP_REST_Request( $method, '/wp/v2/users' ); $response = rest_get_server()->dispatch( $request ); $headers = $response->get_headers(); $this->assertSame( $total_users, $headers['X-WP-Total'] ); @@ -343,7 +382,7 @@ public function test_get_items_pagination_headers() { self::factory()->user->create(); ++$total_users; ++$total_pages; - $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request = new WP_REST_Request( $method, '/wp/v2/users' ); $request->set_param( 'page', 3 ); $response = rest_get_server()->dispatch( $request ); $headers = $response->get_headers(); @@ -365,7 +404,7 @@ public function test_get_items_pagination_headers() { $this->assertStringContainsString( '<' . $next_link . '>; rel="next"', $headers['Link'] ); // Last page. - $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request = new WP_REST_Request( $method, '/wp/v2/users' ); $request->set_param( 'page', $total_pages ); $response = rest_get_server()->dispatch( $request ); $headers = $response->get_headers(); @@ -381,7 +420,7 @@ public function test_get_items_pagination_headers() { $this->assertStringNotContainsString( 'rel="next"', $headers['Link'] ); // Out of bounds. - $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request = new WP_REST_Request( $method, '/wp/v2/users' ); $request->set_param( 'page', 100 ); $response = rest_get_server()->dispatch( $request ); $headers = $response->get_headers(); @@ -410,14 +449,24 @@ public function test_get_items_per_page() { $this->assertCount( 5, $response->get_data() ); } - public function test_get_items_page() { + /** + * @dataProvider data_readable_http_methods + * @ticket 56481 + * + * @param string $method HTTP method to use. + */ + public function test_get_items_page( $method ) { wp_set_current_user( self::$user ); - $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request = new WP_REST_Request( $method, '/wp/v2/users' ); $request->set_param( 'per_page', 5 ); $request->set_param( 'page', 2 ); $response = rest_get_server()->dispatch( $request ); - $this->assertCount( 5, $response->get_data() ); + + if ( 'HEAD' !== $method ) { + $this->assertCount( 5, $response->get_data() ); + } + $prev_link = add_query_arg( array( 'per_page' => 5, @@ -1207,10 +1256,16 @@ public function test_get_item_published_author_wrong_context() { $this->assertErrorResponse( 'rest_user_cannot_view', $response, 401 ); } - public function test_get_current_user() { + /** + * @dataProvider data_readable_http_methods + * @ticket 56481 + * + * @param string $method HTTP method to use. + */ + public function test_get_current_user( $method ) { wp_set_current_user( self::$user ); - $request = new WP_REST_Request( 'GET', '/wp/v2/users/me' ); + $request = new WP_REST_Request( $method, '/wp/v2/users/me' ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); $this->check_get_user_response( $response, 'view' ); @@ -1218,6 +1273,10 @@ public function test_get_current_user() { $headers = $response->get_headers(); $this->assertArrayNotHasKey( 'Location', $headers ); + if ( 'HEAD' === $method ) { + // HEAD responses only contain headers. Bail. + return; + } $links = $response->get_links(); $this->assertSame( rest_url( 'wp/v2/users/' . self::$user ), $links['self'][0]['href'] ); } @@ -3100,6 +3159,79 @@ public function data_get_default_data() { ); } + /** + * @ticket 56481 + */ + public function test_get_item_with_head_request_should_not_prepare_user_data() { + wp_set_current_user( self::$user ); + $request = new WP_REST_Request( 'HEAD', sprintf( '/wp/v2/users/%d', self::$user ) ); + + $hook_name = 'rest_prepare_user'; + + $filter = new MockAction(); + $callback = array( $filter, 'filter' ); + add_filter( $hook_name, $callback ); + $response = rest_get_server()->dispatch( $request ); + remove_filter( $hook_name, $callback ); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + + $this->assertSame( 0, $filter->get_call_count(), 'The "' . $hook_name . '" filter was called when it should not be for HEAD requests.' ); + $this->assertNull( $response->get_data(), 'The server should not generate a body in response to a HEAD request.' ); + } + + /** + * @dataProvider data_readable_http_methods + * @ticket 56481 + * + * @param string $method HTTP method to use. + */ + public function test_get_items_returns_only_fetches_ids_for_head_requests( $method ) { + $is_head_request = 'HEAD' === $method; + $request = new WP_REST_Request( $method, '/wp/v2/users' ); + + $filter = new MockAction(); + + add_filter( 'pre_user_query', array( $filter, 'filter' ), 10, 2 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + if ( $is_head_request ) { + $this->assertNull( $response->get_data() ); + } else { + $this->assertNotEmpty( $response->get_data() ); + } + + $args = $filter->get_args(); + $this->assertTrue( isset( $args[0][0] ), 'Query parameters were not captured.' ); + $this->assertInstanceOf( WP_User_Query::class, $args[0][0], 'Query parameters were not captured.' ); + + /** @var WP_User $query */ + $query = $args[0][0]; + + if ( $is_head_request ) { + $this->assertArrayHasKey( 'fields', $query->query_vars, 'The fields parameter is not set in the query vars.' ); + $this->assertSame( 'id', $query->query_vars['fields'], 'The query must fetch only user IDs.' ); + } else { + $this->assertTrue( + ! array_key_exists( 'fields', $query->query_vars ) || 'id' !== $query->query_vars['fields'], + 'The fields parameter should not be forced to "id" for non-HEAD requests.' + ); + } + + if ( ! $is_head_request ) { + return; + } + + global $wpdb; + $users_table = preg_quote( $wpdb->users, '/' ); + $pattern = '/SELECT SQL_CALC_FOUND_ROWS wptests_users.ID\n\s+FROM\s+' . $users_table . '/is'; + + // Assert that the SQL query only fetches the id column. + $this->assertMatchesRegularExpression( $pattern, $query->request, 'The SQL query does not match the expected string.' ); + } + protected function check_user_data( $user, $data, $context, $links ) { $this->assertSame( $user->ID, $data['id'] ); $this->assertSame( $user->display_name, $data['name'] );