diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php index 3f7e35dfe82e3..f8d0d30f19189 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php @@ -52,6 +52,10 @@ public function __construct() { */ public function register_routes() { + // The administrator reaches wp_initialize_site(), which only runs on creation. + $create_args = $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ); + $create_args['user'] = $this->get_user_param_schema( __( 'The site administrator, set when the site is created. Accepts a user ID or "me".' ) ); + register_rest_route( $this->namespace, '/' . $this->rest_base, @@ -66,16 +70,16 @@ public function register_routes() { 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), 'permission_callback' => array( $this, 'create_item_permissions_check' ), - 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), + 'args' => $create_args, ), 'allow_batch' => $this->allow_batch, 'schema' => array( $this, 'get_public_item_schema' ), ) ); - // Title and administrator reach wp_initialize_site(), which only runs on creation. + // The site title reaches wp_initialize_site(), which only runs on creation. $update_args = $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ); - unset( $update_args['title'], $update_args['user_id'] ); + unset( $update_args['blogname'] ); register_rest_route( $this->namespace, @@ -133,18 +137,65 @@ public function get_items_permissions_check( $request ) { return $multisite_support; } - if ( $this->check_edit_permission() ) { + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + + $can_edit = $this->check_edit_permission(); + $is_own = $this->is_own_user_filter( $request ); + + if ( 'edit' === $context && ! $can_edit ) { + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit sites.' ), array( 'status' => rest_authorization_required_code() ) ); + } + + // Only users who can manage sites may list the sites of another user. + if ( ! empty( $request['user'] ) && ! $is_own && ! $can_edit ) { + return new WP_Error( 'rest_forbidden_user', __( 'Sorry, you are not allowed to list the sites of other users.' ), array( 'status' => rest_authorization_required_code() ) ); + } + + // Fields outside the view context must not be inferable through filters or ordering either. + if ( ! $can_edit && $this->has_edit_only_filter( $request ) ) { + return new WP_Error( 'rest_forbidden_param', __( 'Sorry, you are not allowed to filter or order sites by these parameters.' ), array( 'status' => 403 ) ); + } + + // A user may list their own sites from any site, but only in the view context. + if ( 'view' === $context && $is_own ) { return true; } - // Without that capability a user may still ask for their own sites. - if ( $this->is_own_user_filter( $request ) ) { + if ( ! is_main_site() ) { + return new WP_Error( 'rest_cannot_view_not_on_main_site', __( 'Sorry, sites can only be viewed from the main site.' ), array( 'status' => 403 ) ); + } + + if ( $can_edit ) { return true; } return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to view sites.' ), array( 'status' => rest_authorization_required_code() ) ); } + /** + * Checks whether the request filters or orders by fields outside the view context. + * + * Users listing only their own sites do not see these fields, so filtering + * or ordering by them would give their values away. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return bool Whether the request uses a filter that needs the edit context. + */ + protected function has_edit_only_filter( $request ) { + $params = array( 'archived', 'mature', 'spam', 'deleted', 'lang_id', 'lang_id_exclude', 'before', 'after' ); + + foreach ( $params as $param ) { + // The language filters default to an empty list, which does not filter. + if ( isset( $request[ $param ] ) && array() !== $request[ $param ] ) { + return true; + } + } + + return in_array( $request['orderby'], array( 'registered', 'last_updated' ), true ); + } + /** * Checks whether the request is limited to the sites of the current user. * @@ -160,11 +211,45 @@ protected function is_own_user_filter( $request ) { return false; } - if ( 'me' === $user ) { - return is_user_logged_in(); - } + return is_user_logged_in() && get_current_user_id() === $this->get_user_id_from_param( $user ); + } - return is_user_logged_in() && get_current_user_id() === (int) $user; + /** + * Retrieves the schema of a user parameter, which accepts a user ID or "me". + * + * @since 7.2.0 + * + * @param string $description Parameter description. + * @return array Parameter schema. + */ + protected function get_user_param_schema( $description ) { + return array( + 'description' => $description, + // Without a top-level type the request skips validating and sanitizing the parameter. + 'type' => array( 'integer', 'string' ), + 'anyOf' => array( + array( + 'type' => 'integer', + 'minimum' => 1, + ), + array( + 'type' => 'string', + 'enum' => array( 'me' ), + ), + ), + ); + } + + /** + * Resolves a user parameter to a user ID, "me" being the current user. + * + * @since 7.2.0 + * + * @param int|string $user A user ID or "me". + * @return int User ID, 0 for "me" when logged out. + */ + protected function get_user_id_from_param( $user ) { + return 'me' === $user ? get_current_user_id() : (int) $user; } /** @@ -231,7 +316,7 @@ public function get_items( $request ) { $user = $request['user']; if ( ! empty( $user ) ) { - $user_id = ( 'me' === $user ) ? get_current_user_id() : (int) $user; + $user_id = $this->get_user_id_from_param( $user ); $site_ids = $this->get_user_site_ids( $user_id ); if ( ! empty( $prepared_args['site__in'] ) ) { @@ -297,8 +382,19 @@ public function get_items( $request ) { $prepared_args['fields'] = 'ids'; } - $query = new WP_Site_Query(); - $query_result = $query->query( $prepared_args ); + // A user filter that matched no sites leaves an impossible ID, so there is nothing to query. + $has_no_sites = isset( $prepared_args['site__in'] ) && array( 0 ) === $prepared_args['site__in']; + + if ( $has_no_sites ) { + $query_result = array(); + $total_sites = 0; + $max_pages = 0; + } else { + $query = new WP_Site_Query(); + $query_result = $query->query( $prepared_args ); + $total_sites = $query->found_sites; + $max_pages = $query->max_num_pages; + } $sites = array(); @@ -309,10 +405,7 @@ public function get_items( $request ) { } } - $total_sites = $query->found_sites; - $max_pages = $query->max_num_pages; - - if ( $total_sites < 1 ) { + if ( ! $has_no_sites && $total_sites < 1 ) { // Out-of-bounds, run the query again without LIMIT for total count. unset( $prepared_args['number'], $prepared_args['offset'] ); @@ -408,8 +501,18 @@ public function get_item_permissions_check( $request ) { } // Check capabilities before looking up the site, so unauthorized users can't probe which site IDs exist. - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - $is_member = in_array( $context, array( 'view', 'embed' ), true ) && is_user_member_of_blog( get_current_user_id(), (int) $request['id'] ); + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + + if ( 'edit' === $context && ! $this->check_edit_permission() ) { + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit sites.' ), array( 'status' => rest_authorization_required_code() ) ); + } + + $is_member = 'view' === $context && is_user_member_of_blog( get_current_user_id(), (int) $request['id'] ); + + // Members may read their own sites from any site, everything else needs the main site. + if ( ! $is_member && ! is_main_site() ) { + return new WP_Error( 'rest_cannot_view_not_on_main_site', __( 'Sorry, sites can only be viewed from the main site.' ), array( 'status' => 403 ) ); + } if ( ! $is_member && ! $this->check_edit_permission() ) { return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to view sites.' ), array( 'status' => rest_authorization_required_code() ) ); @@ -487,6 +590,10 @@ public function create_item_permissions_check( $request ) { return $multisite_support; } + if ( ! is_main_site() ) { + return new WP_Error( 'rest_cannot_create_not_on_main_site', __( 'Sorry, sites can only be created from the main site.' ), array( 'status' => 403 ) ); + } + if ( ! current_user_can( 'create_sites' ) ) { return new WP_Error( 'rest_cannot_create', __( 'Sorry, you are not allowed to create sites.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -607,6 +714,10 @@ public function update_item_permissions_check( $request ) { return $multisite_support; } + if ( ! is_main_site() ) { + return new WP_Error( 'rest_cannot_edit_not_on_main_site', __( 'Sorry, sites can only be edited from the main site.' ), array( 'status' => 403 ) ); + } + if ( ! $this->check_edit_permission() ) { return new WP_Error( 'rest_cannot_edit', __( 'Sorry, you are not allowed to edit this site.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -706,6 +817,10 @@ public function delete_item_permissions_check( $request ) { return $multisite_support; } + if ( ! is_main_site() ) { + return new WP_Error( 'rest_cannot_delete_not_on_main_site', __( 'Sorry, sites can only be deleted from the main site.' ), array( 'status' => 403 ) ); + } + if ( ! $this->check_delete_permission( (int) $request['id'] ) ) { return new WP_Error( 'rest_cannot_delete', __( 'Sorry, you are not allowed to delete this site.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -815,6 +930,11 @@ public function prepare_item_for_response( $site, $request ) { return apply_filters( 'rest_prepare_site', new WP_REST_Response( array() ), $site, $request ); } + // Without a context every field would be prepared, including the ones that switch sites. + if ( empty( $request['context'] ) ) { + $request['context'] = 'view'; + } + $data = array(); $fields = $this->get_fields_for_response( $request ); @@ -874,17 +994,21 @@ public function prepare_item_for_response( $site, $request ) { $data['lang_id'] = (int) $site->lang_id; } - // These four are not columns of the sites table. Reading one switches to the site. + // These five are not columns of the sites table. Reading one switches to the site. if ( rest_is_field_included( 'blogname', $fields ) ) { $data['blogname'] = $site->blogname; } if ( rest_is_field_included( 'siteurl', $fields ) ) { - $data['siteurl'] = $site->siteurl; + $data['siteurl'] = get_site_url( (int) $site->blog_id ); } if ( rest_is_field_included( 'home', $fields ) ) { - $data['home'] = $site->home; + $data['home'] = get_home_url( (int) $site->blog_id ); + } + + if ( rest_is_field_included( 'admin_url', $fields ) ) { + $data['admin_url'] = get_admin_url( (int) $site->blog_id ); } if ( rest_is_field_included( 'post_count', $fields ) ) { @@ -998,18 +1122,22 @@ protected function prepare_item_for_database( $request ) { /* * Title, administrator and the initial options reach wp_initialize_site() * through wp_insert_site(), so they only apply while the site is created. + * A POST to an existing site is an update, so the ID tells them apart. */ - if ( WP_REST_Server::CREATABLE === $request->get_method() ) { - if ( isset( $request['title'] ) ) { - $prepared_site['title'] = $request['title']; + if ( empty( $request['id'] ) ) { + if ( isset( $request['blogname'] ) ) { + // wp_initialize_site() unslashes the title before storing it. + $prepared_site['title'] = wp_slash( $request['blogname'] ); } - if ( isset( $request['user_id'] ) ) { - if ( ! get_userdata( (int) $request['user_id'] ) ) { - return new WP_Error( 'rest_site_invalid_user_id', __( 'Invalid user ID.' ), array( 'status' => 400 ) ); + if ( isset( $request['user'] ) ) { + $user_id = $this->get_user_id_from_param( $request['user'] ); + + if ( ! get_userdata( $user_id ) ) { + return new WP_Error( 'rest_user_invalid_id', __( 'Invalid user ID.' ), array( 'status' => 400 ) ); } - $prepared_site['user_id'] = (int) $request['user_id']; + $prepared_site['user_id'] = $user_id; } } @@ -1291,28 +1419,28 @@ public function get_item_schema() { 'description' => __( 'When the site was registered, in the site\'s timezone.' ), 'type' => 'string', 'format' => 'date-time', - 'context' => array( 'view', 'edit', 'embed' ), + 'context' => array( 'edit' ), 'readonly' => true, ), 'registered_gmt' => array( 'description' => __( 'When the site was registered, as GMT.' ), 'type' => 'string', 'format' => 'date-time', - 'context' => array( 'view', 'edit' ), + 'context' => array( 'edit' ), 'readonly' => true, ), 'last_updated' => array( 'description' => __( 'When the site was last updated, in the site\'s timezone.' ), 'type' => 'string', 'format' => 'date-time', - 'context' => array( 'view', 'edit', 'embed' ), + 'context' => array( 'edit' ), 'readonly' => true, ), 'last_updated_gmt' => array( 'description' => __( 'When the site was last updated, as GMT.' ), 'type' => 'string', 'format' => 'date-time', - 'context' => array( 'view', 'edit' ), + 'context' => array( 'edit' ), 'readonly' => true, ), 'public' => array( @@ -1322,40 +1450,39 @@ public function get_item_schema() { 'default' => true, ), 'archived' => array( - 'context' => array( 'view', 'edit', 'embed' ), + 'context' => array( 'edit' ), 'description' => __( 'Whether the site is archived. Default false.' ), 'type' => 'boolean', 'default' => false, ), 'mature' => array( - 'context' => array( 'view', 'edit', 'embed' ), + 'context' => array( 'edit' ), 'description' => __( 'Whether the site is mature. Default false.' ), 'type' => 'boolean', 'default' => false, ), 'spam' => array( - 'context' => array( 'view', 'edit', 'embed' ), + 'context' => array( 'edit' ), 'description' => __( 'Whether the site is spam. Default false.' ), 'type' => 'boolean', 'default' => false, ), 'deleted' => array( - 'context' => array( 'view', 'edit', 'embed' ), + 'context' => array( 'edit' ), 'description' => __( 'Whether the site is deleted. Default false.' ), 'type' => 'boolean', 'default' => false, ), 'lang_id' => array( - 'context' => array( 'view', 'edit', 'embed' ), + 'context' => array( 'edit' ), 'description' => __( 'The site\'s language ID. Currently unused. Default 0.' ), 'type' => 'integer', 'default' => 0, ), 'blogname' => array( - 'description' => __( 'Site name, stored in the blogname option.' ), + 'description' => __( 'Site title, stored in the blogname option. Can only be set when the site is created.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), - 'readonly' => true, ), 'siteurl' => array( 'description' => __( 'Site address, stored in the siteurl option.' ), @@ -1371,28 +1498,28 @@ public function get_item_schema() { 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), + 'admin_url' => array( + 'description' => __( 'Admin dashboard address of the site.' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), 'post_count' => array( 'description' => __( 'Number of posts on the site.' ), 'type' => 'integer', - 'context' => array( 'view', 'edit' ), + 'context' => array( 'edit' ), 'readonly' => true, ), - 'title' => array( - 'description' => __( 'Site title, set when the site is created. Default is the word "Site" followed by the site ID.' ), - 'type' => 'string', - 'context' => array(), - ), - 'user_id' => array( - 'description' => __( 'User ID of the site administrator, set when the site is created.' ), - 'type' => 'integer', - 'context' => array(), - ), ), ); // Not all sites support site meta, do not register if the site does not support it. if ( is_site_meta_supported() ) { $schema['properties']['meta'] = $this->meta->get_field_schema(); + + // Registered site meta may hold sensitive data, so it is not shown to site members. + $schema['properties']['meta']['context'] = array( 'edit' ); } $this->schema = $schema; @@ -1493,10 +1620,7 @@ public function get_collection_params() { 'site__in', ), ); - $query_params['user'] = array( - 'description' => __( 'Limit result set to the sites a user is a member of. Accepts a user ID or "me".' ), - 'type' => 'string', - ); + $query_params['user'] = $this->get_user_param_schema( __( 'Limit result set to the sites a user is a member of. Accepts a user ID or "me".' ) ); $status_descriptions = array( 'public' => __( 'Limit result set to sites with a specific public status.' ), diff --git a/tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php b/tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php index 601acddea1168..da0232e91fdd8 100644 --- a/tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php +++ b/tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php @@ -185,7 +185,9 @@ protected function reload_rest_server() { public function test_get_value() { update_site_meta( self::$site_id, 'test_single', 'testvalue' ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -205,7 +207,9 @@ public function test_get_value() { public function test_get_multi_value() { add_site_meta( self::$site_id, 'test_multi', 'value1' ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -233,7 +237,9 @@ public function test_get_multi_value() { public function test_get_unregistered() { update_site_meta( self::$site_id, 'test_unregistered', 'value1' ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -250,7 +256,9 @@ public function test_get_unregistered() { public function test_get_registered_no_api_access() { update_site_meta( self::$site_id, 'test_no_rest', 'for_the_wicked' ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -267,7 +275,9 @@ public function test_get_registered_no_api_access() { public function test_get_registered_api_disabled() { update_site_meta( self::$site_id, 'test_rest_disabled', 'sleepless_nights' ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -316,7 +326,9 @@ public function test_get_value_types() { update_site_meta( self::$site_id, 'test_number', '42' ); update_site_meta( self::$site_id, 'test_bool', 1 ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -343,7 +355,9 @@ public function test_get_value_types() { public function test_get_value_custom_name() { update_site_meta( self::$site_id, 'test_custom_name', 'janet' ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -384,6 +398,7 @@ public function test_set_value() { ); $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); $request->set_body_params( $data ); + $request->set_query_params( array( 'context' => 'edit' ) ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -458,10 +473,52 @@ public function test_set_duplicate_single_value() { ); $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); $request->set_body_params( $data ); + $request->set_query_params( array( 'context' => 'edit' ) ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); $this->assertSame( 'test_value', get_site_meta( self::$site_id, 'test_single', true ) ); } + + /** + * Registered site meta may hold sensitive data, so site members reading + * their own site in the view context do not get it. + * + * @ticket 40365 + * @covers WP_REST_Sites_Controller::prepare_item_for_response + */ + public function test_site_member_does_not_see_meta_in_view_context() { + update_site_meta( self::$site_id, 'test_single', 'testvalue' ); + + $member_id = self::factory()->user->create(); + add_user_to_blog( self::$site_id, $member_id, 'subscriber' ); + wp_set_current_user( $member_id ); + + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayNotHasKey( 'meta', $response->get_data() ); + } + + /** + * The meta is only part of the edit context. + * + * @ticket 40365 + * @covers WP_REST_Sites_Controller::get_item_schema + */ + public function test_meta_is_only_in_the_edit_context() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + $properties = $response->get_data()['schema']['properties']; + + $this->assertSame( array( 'edit' ), $properties['meta']['context'] ); + + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayNotHasKey( 'meta', $response->get_data(), 'Even a super admin does not get meta in the view context.' ); + } } diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index a7bac5f3a57cf..51fd4a1630507 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -230,25 +230,26 @@ public function test_get_items_me_filter_forbidden_when_logged_out() { $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_forbidden_context', $response, 401 ); + // Logged out, "me" is no one, so it is treated like another user's ID. + $this->assertErrorResponse( 'rest_forbidden_user', $response, 401 ); } /** - * A non-numeric `user` value casts to `0` via `(int)`, which must not be - * allowed to match a logged-out request's `get_current_user_id()` (also - * `0`) and grant the own-sites-filter bypass. + * A non-numeric `user` value is rejected by validation, so it never reaches + * the permission check, where an `(int)` cast to `0` could match a logged-out + * request's `get_current_user_id()`. * * @ticket 40365 - * @covers ::get_items_permissions_check + * @covers ::get_user_param_schema * @group ms-required */ - public function test_get_items_non_numeric_user_filter_forbidden_when_logged_out() { + public function test_get_items_rejects_a_non_numeric_user() { $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); $request->set_param( 'user', 'xyz' ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_forbidden_context', $response, 401 ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); } /** @@ -328,14 +329,14 @@ public function test_get_item_view_context_allowed_for_site_member() { } /** - * A member of the site (even without `manage_sites`) can also view it - * in the `embed` context, same as the default `view` context. + * A member of the site without `manage_sites` can only use the view + * context, so the `embed` context is forbidden. * * @ticket 40365 * @covers ::get_item_permissions_check * @group ms-required */ - public function test_get_item_embed_context_allowed_for_site_member() { + public function test_get_item_embed_context_forbidden_for_site_member() { $blog_id = self::factory()->blog->create(); $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); add_user_to_blog( $blog_id, $user_id, 'subscriber' ); @@ -347,7 +348,7 @@ public function test_get_item_embed_context_allowed_for_site_member() { $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 200, $response->get_status() ); + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); } /** @@ -571,6 +572,7 @@ public function test_update_item_allows_a_site_to_keep_its_own_domain_and_path() $request->set_param( 'domain', WP_TESTS_DOMAIN ); $request->set_param( 'path', '/keep/' ); $request->set_param( 'mature', 1 ); + $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); @@ -751,6 +753,7 @@ public function test_update_item() { $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); $request->set_param( 'path', '/incididunt/' ); $request->set_param( 'mature', 1 ); + $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); @@ -1052,10 +1055,11 @@ public function test_prepare_item() { $this->assertEquals( $site->path, $data['path'] ); $this->assertEquals( mysql_to_rfc3339( $site->registered ), $data['registered_gmt'] ); $this->assertEquals( $site->blogname, $data['blogname'] ); - $this->assertEquals( $site->home, $data['home'] ); - $this->assertEquals( $site->siteurl, $data['siteurl'] ); + $this->assertSame( get_home_url( $blog_id ), $data['home'] ); + $this->assertSame( get_site_url( $blog_id ), $data['siteurl'] ); $this->assertIsBool( $data['public'] ); $this->assertIsInt( $data['post_count'] ); + $this->assertSame( get_admin_url( $blog_id ), $data['admin_url'] ); } /** @@ -1086,9 +1090,8 @@ public function test_get_item_schema() { 'blogname', 'siteurl', 'home', + 'admin_url', 'post_count', - 'title', - 'user_id', 'meta', ); @@ -1101,13 +1104,34 @@ public function test_get_item_schema() { $this->assertTrue( $properties['id']['readonly'] ); $this->assertTrue( $properties['network']['readonly'] ); $this->assertTrue( $properties['registered']['readonly'] ); - $this->assertTrue( $properties['blogname']['readonly'] ); + $this->assertArrayNotHasKey( 'readonly', $properties['blogname'], 'The site title is set when the site is created.' ); $this->assertEquals( 'boolean', $properties['public']['type'] ); $this->assertEquals( 'string', $properties['domain']['type'] ); - // Write-only, so they carry no context. - $this->assertSame( array(), $properties['title']['context'] ); - $this->assertSame( array(), $properties['user_id']['context'] ); + $this->assertTrue( $properties['admin_url']['readonly'] ); + $this->assertSame( 'uri', $properties['admin_url']['format'] ); + $this->assertSame( array( 'view', 'edit', 'embed' ), $properties['admin_url']['context'] ); + + $edit_only = array( + 'registered', + 'registered_gmt', + 'last_updated', + 'last_updated_gmt', + 'archived', + 'mature', + 'spam', + 'deleted', + 'lang_id', + 'post_count', + ); + + if ( is_site_meta_supported() ) { + $edit_only[] = 'meta'; + } + + foreach ( $edit_only as $property ) { + $this->assertSame( array( 'edit' ), $properties[ $property ]['context'], "$property should only be in the edit context." ); + } } /** @@ -1123,7 +1147,9 @@ public function test_get_item_uses_the_schema_types() { $blog_id = self::factory()->blog->create( array( 'path' => '/tempora/' ) ); $site = get_site( $blog_id ); - $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); @@ -1266,12 +1292,13 @@ public function test_create_item_sets_the_title_and_the_administrator() { $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); $request->set_param( 'domain', WP_TESTS_DOMAIN ); $request->set_param( 'path', '/voluptas/' ); - $request->set_param( 'title', 'Voluptas' ); - $request->set_param( 'user_id', $user_id ); + $request->set_param( 'blogname', 'Voluptas' ); + $request->set_param( 'user', $user_id ); $response = rest_get_server()->dispatch( $request ); $this->assertEquals( 201, $response->get_status() ); + $this->assertEquals( 'Voluptas', $response->get_data()['blogname'] ); $blog_id = $response->get_data()['id']; @@ -1292,18 +1319,17 @@ public function test_create_item_sets_the_title_and_the_administrator() { * @covers ::create_item * @group ms-required */ - public function test_create_item_rejects_an_unknown_user_id() { + public function test_create_item_rejects_an_unknown_user() { wp_set_current_user( self::$superadmin_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); $request->set_param( 'domain', WP_TESTS_DOMAIN ); $request->set_param( 'path', '/quisquam/' ); - $request->set_param( 'user_id', 99999 ); + $request->set_param( 'user', 99999 ); $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 400, $response->get_status() ); - $this->assertEquals( 'rest_site_invalid_user_id', $response->get_data()['code'] ); + $this->assertErrorResponse( 'rest_user_invalid_id', $response, 400 ); // The check runs before wp_insert_site(), so nothing was created. $this->assertEquals( 0, get_blog_id_from_url( WP_TESTS_DOMAIN, '/quisquam/' ) ); @@ -1327,7 +1353,8 @@ public function test_update_item_does_not_accept_the_creation_fields() { } } - $this->assertArrayNotHasKey( 'title', $args ); + $this->assertArrayNotHasKey( 'blogname', $args ); + $this->assertArrayNotHasKey( 'user', $args ); $this->assertArrayNotHasKey( 'user_id', $args ); $this->assertArrayNotHasKey( 'network', $args ); $this->assertArrayHasKey( 'domain', $args ); @@ -1463,7 +1490,7 @@ public function test_get_items_does_not_switch_blogs_for_table_columns() { $site = $response->get_data()[0]; - foreach ( array( 'blogname', 'siteurl', 'home', 'post_count', 'meta' ) as $field ) { + foreach ( array( 'blogname', 'siteurl', 'home', 'admin_url', 'post_count', 'meta' ) as $field ) { $this->assertArrayNotHasKey( $field, $site ); } @@ -1627,6 +1654,7 @@ public function test_update_item_keeps_fields_that_were_not_sent() { $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); $request->set_param( 'archived', 1 ); + $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); @@ -1892,7 +1920,9 @@ public function test_get_item_exposes_site_meta() { $blog_id = self::factory()->blog->create(); update_site_meta( $blog_id, 'rest_test_site_meta', 'from blogmeta' ); - $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); @@ -2000,7 +2030,7 @@ public function test_get_items_filter_user_no_access() { $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); $request->set_param( 'user', (string) $user_id ); $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 403, $response->get_status() ); + $this->assertErrorResponse( 'rest_forbidden_user', $response, 403 ); } /** @@ -2452,4 +2482,1020 @@ public function test_site_in_network_filter_can_block_the_current_network() { $this->assertErrorResponse( $error_code, $response, 403 ); } } + + /** + * Data provider for the permission checks made away from the main site. + * + * @return array[] + */ + public function data_permission_checks_off_the_main_site() { + return array( + 'list' => array( 'GET', false, 'get_items_permissions_check', 'rest_cannot_view_not_on_main_site' ), + 'read' => array( 'GET', true, 'get_item_permissions_check', 'rest_cannot_view_not_on_main_site' ), + 'create' => array( 'POST', false, 'create_item_permissions_check', 'rest_cannot_create_not_on_main_site' ), + 'update' => array( 'PUT', true, 'update_item_permissions_check', 'rest_cannot_edit_not_on_main_site' ), + 'delete' => array( 'DELETE', true, 'delete_item_permissions_check', 'rest_cannot_delete_not_on_main_site' ), + ); + } + + /** + * Even a super admin can only manage sites from the main site. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @covers ::get_item_permissions_check + * @covers ::create_item_permissions_check + * @covers ::update_item_permissions_check + * @covers ::delete_item_permissions_check + * @group ms-required + * @dataProvider data_permission_checks_off_the_main_site + * + * @param string $method HTTP method. + * @param bool $single Whether the request is for a single site. + * @param string $permission_check Permission check method name. + * @param string $error_code Expected error code. + */ + public function test_permission_checks_require_the_main_site( $method, $single, $permission_check, $error_code ) { + $subsite_id = self::factory()->blog->create( array( 'path' => '/off-main/' ) ); + $target_id = self::factory()->blog->create( array( 'path' => '/off-main-target/' ) ); + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( $method, $single ? '/wp/v2/sites/' . $target_id : '/wp/v2/sites' ); + if ( $single ) { + $request->set_param( 'id', $target_id ); + } + + switch_to_blog( $subsite_id ); + $result = $this->endpoint->$permission_check( $request ); + restore_current_blog(); + + $this->assertWPError( $result ); + $this->assertSame( $error_code, $result->get_error_code() ); + $this->assertSame( 403, $result->get_error_data()['status'] ); + + // The same request passes on the main site. + $this->assertTrue( $this->endpoint->$permission_check( $request ) ); + } + + /** + * A site cannot be created through a request served by a subsite. + * + * @ticket 40365 + * @covers ::create_item_permissions_check + * @group ms-required + */ + public function test_create_item_from_a_subsite_is_forbidden() { + $subsite_id = self::factory()->blog->create( array( 'path' => '/creator/' ) ); + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/from-a-subsite/' ); + + switch_to_blog( $subsite_id ); + $response = rest_get_server()->dispatch( $request ); + restore_current_blog(); + + $this->assertErrorResponse( 'rest_cannot_create_not_on_main_site', $response, 403 ); + $this->assertEquals( 0, get_blog_id_from_url( WP_TESTS_DOMAIN, '/from-a-subsite/' ) ); + } + + /** + * Data provider for listing sites away from the main site. + * + * @return array[] + */ + public function data_get_items_off_the_main_site() { + return array( + 'own sites as me' => array( 'me', 'view', true ), + 'own sites by ID' => array( 'own', 'view', true ), + 'own sites in edit context' => array( 'me', 'edit', 'rest_cannot_view_not_on_main_site' ), + 'own sites in embed' => array( 'me', 'embed', 'rest_cannot_view_not_on_main_site' ), + 'every site' => array( '', 'view', 'rest_cannot_view_not_on_main_site' ), + 'another user' => array( 'other', 'view', 'rest_cannot_view_not_on_main_site' ), + ); + } + + /** + * Only a user listing their own sites in the view context is let through away + * from the main site, even a super admin needs the main site for everything else. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-required + * @dataProvider data_get_items_off_the_main_site + * + * @param string $user The user filter: 'me', 'own', 'other' or empty. + * @param string $context Request context. + * @param true|string $expected True when allowed, otherwise the error code. + */ + public function test_get_items_permissions_check_off_the_main_site( $user, $context, $expected ) { + $subsite_id = self::factory()->blog->create( array( 'path' => '/listing/' ) ); + $other_id = self::factory()->user->create(); + wp_set_current_user( self::$superadmin_id ); + + $users = array( + 'me' => 'me', + 'own' => (string) self::$superadmin_id, + 'other' => (string) $other_id, + '' => '', + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', $users[ $user ] ); + $request->set_param( 'context', $context ); + + switch_to_blog( $subsite_id ); + $result = $this->endpoint->get_items_permissions_check( $request ); + restore_current_blog(); + + if ( true === $expected ) { + $this->assertTrue( $result ); + } else { + $this->assertWPError( $result ); + $this->assertSame( $expected, $result->get_error_code() ); + $this->assertSame( 403, $result->get_error_data()['status'] ); + } + } + + /** + * A member lists their own sites through a request served by a subsite. + * + * @ticket 40365 + * @covers ::get_items + * @group ms-required + */ + public function test_get_items_me_filter_from_a_subsite() { + $blog_ids = self::factory()->blog->create_many( 2 ); + $user_id = self::factory()->user->create(); + + foreach ( $blog_ids as $blog_id ) { + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + } + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + + switch_to_blog( $blog_ids[0] ); + $response = rest_get_server()->dispatch( $request ); + restore_current_blog(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEqualSets( array_merge( array( 1 ), $blog_ids ), wp_list_pluck( $response->get_data(), 'id' ) ); + } + + /** + * On the main site, the own-user filter only covers the view context, any + * other context needs the capability to manage sites. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-required + */ + public function test_get_items_me_filter_needs_the_capability_outside_the_view_context() { + $user_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); + + foreach ( array( 'edit', 'embed' ) as $context ) { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + $request->set_param( 'context', $context ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); + } + + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + $request->set_param( 'context', 'edit' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Away from the main site, only a member reading in the view context is let + * through, whether or not the site exists. + * + * @ticket 40365 + * @covers ::get_item_permissions_check + * @group ms-required + */ + public function test_get_item_permissions_check_off_the_main_site() { + $subsite_id = self::factory()->blog->create( array( 'path' => '/reader/' ) ); + $target_id = self::factory()->blog->create( array( 'path' => '/read-target/' ) ); + $member_id = self::factory()->user->create(); + $outsider = self::factory()->user->create(); + add_user_to_blog( $target_id, $member_id, 'subscriber' ); + + $check = function ( $user_id, $site_id, $context ) use ( $subsite_id ) { + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $site_id ); + $request->set_param( 'id', $site_id ); + $request->set_param( 'context', $context ); + + switch_to_blog( $subsite_id ); + $result = $this->endpoint->get_item_permissions_check( $request ); + restore_current_blog(); + + return $result; + }; + + $this->assertTrue( $check( $member_id, $target_id, 'view' ), 'A member may read the site in the view context.' ); + + $result = $check( $member_id, $target_id, 'edit' ); + + $this->assertWPError( $result ); + $this->assertSame( 'rest_forbidden_context', $result->get_error_code(), 'The edit context needs the capability before the site matters.' ); + + $denied = array( + 'member in embed context' => array( $member_id, $target_id, 'embed' ), + 'non-member' => array( $outsider, $target_id, 'view' ), + 'super admin' => array( self::$superadmin_id, $target_id, 'view' ), + 'logged out' => array( 0, $target_id, 'view' ), + 'member, unknown site' => array( $member_id, 999999, 'view' ), + 'outsider, unknown site' => array( $outsider, 999999, 'view' ), + ); + + foreach ( $denied as $label => $args ) { + $result = $check( ...$args ); + + $this->assertWPError( $result, $label ); + $this->assertSame( 'rest_cannot_view_not_on_main_site', $result->get_error_code(), $label ); + $this->assertSame( 403, $result->get_error_data()['status'], $label ); + } + } + + /** + * The fields that are not in the view context and the meta are left out for + * site members and super admins alike, and are part of the edit context. + * + * @ticket 40365 + * @covers ::prepare_item_for_response + * @group ms-required + */ + public function test_edit_only_fields_are_left_out_of_the_view_context() { + $blog_id = self::factory()->blog->create( array( 'path' => '/contexts/' ) ); + $member_id = self::factory()->user->create(); + add_user_to_blog( $blog_id, $member_id, 'subscriber' ); + + $edit_only = array( + 'registered', + 'registered_gmt', + 'last_updated', + 'last_updated_gmt', + 'archived', + 'mature', + 'spam', + 'deleted', + 'lang_id', + 'post_count', + 'meta', + ); + + foreach ( array( $member_id, self::$superadmin_id ) as $user_id ) { + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( $blog_id, $data['id'] ); + $this->assertSame( get_admin_url( $blog_id ), $data['admin_url'] ); + + foreach ( $edit_only as $field ) { + $this->assertArrayNotHasKey( $field, $data, "$field should not be in the view context." ); + } + } + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'context', 'edit' ); + + $data = rest_get_server()->dispatch( $request )->get_data(); + + if ( ! is_site_meta_supported() ) { + $edit_only = array_diff( $edit_only, array( 'meta' ) ); + } + + foreach ( $edit_only as $field ) { + $this->assertArrayHasKey( $field, $data, "$field should be in the edit context." ); + } + } + + /** + * Preparing a site without a context does not prepare the edit-only fields. + * + * @ticket 40365 + * @covers ::prepare_item_for_response + * @group ms-required + */ + public function test_prepare_item_without_a_context_does_not_switch_sites() { + $blog_id = self::factory()->blog->create( array( 'path' => '/no-context/' ) ); + + $switches = 0; + $counter = static function () use ( &$switches ) { + ++$switches; + }; + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( '_fields', 'id,post_count' ); + + add_action( 'switch_blog', $counter ); + $data = $this->endpoint->prepare_item_for_response( get_site( $blog_id ), $request )->get_data(); + remove_action( 'switch_blog', $counter ); + + $this->assertSame( 0, $switches ); + $this->assertSame( array( 'id' => $blog_id ), $data ); + } + + /** + * The site title is set from the blogname and keeps its backslashes. + * + * @ticket 40365 + * @covers ::prepare_item_for_database + * @group ms-required + */ + public function test_create_item_keeps_backslashes_in_the_blogname() { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/backslash/' ); + $request->set_param( 'blogname', 'C:\\Sites\\Example' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + $this->assertSame( 'C:\\Sites\\Example', get_blog_option( $response->get_data()['id'], 'blogname' ) ); + } + + /** + * Without a blogname the site gets the default title. + * + * @ticket 40365 + * @covers ::prepare_item_for_database + * @group ms-required + */ + public function test_create_item_without_a_blogname_uses_the_default_title() { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/untitled/' ); + + $response = rest_get_server()->dispatch( $request ); + $blog_id = $response->get_data()['id']; + + $this->assertEquals( 201, $response->get_status() ); + $this->assertSame( sprintf( 'Site %d', $blog_id ), get_blog_option( $blog_id, 'blogname' ) ); + } + + /** + * Creating a site accepts the site title and the administrator. + * + * @ticket 40365 + * @covers ::register_routes + */ + public function test_create_item_accepts_the_creation_fields() { + $routes = rest_get_server()->get_routes(); + $args = array(); + + foreach ( $routes['/wp/v2/sites'] as $handler ) { + if ( ! empty( $handler['methods']['POST'] ) ) { + $args = $handler['args']; + } + } + + $this->assertArrayHasKey( 'blogname', $args ); + $this->assertArrayHasKey( 'user', $args ); + $this->assertSame( array( 'integer', 'string' ), $args['user']['type'] ); + $this->assertSame( + array( + array( + 'type' => 'integer', + 'minimum' => 1, + ), + array( + 'type' => 'string', + 'enum' => array( 'me' ), + ), + ), + $args['user']['anyOf'] + ); + $this->assertArrayNotHasKey( 'user_id', $args ); + $this->assertArrayNotHasKey( 'title', $args ); + } + + /** + * A POST to an existing site is an update, so the creation fields are ignored. + * + * @ticket 40365 + * @covers ::prepare_item_for_database + * @group ms-required + */ + public function test_update_item_ignores_the_creation_fields() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( + array( + 'path' => '/kept-title/', + 'title' => 'Kept title', + ) + ); + + foreach ( array( 'POST', 'PUT' ) as $method ) { + $request = new WP_REST_Request( $method, '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'blogname', 'Changed title' ); + $request->set_param( 'user', 99999 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status(), $method ); + $this->assertSame( 'Kept title', get_blog_option( $blog_id, 'blogname' ), $method ); + + // The update route does not register the parameter, so it is not validated either. + $request->set_param( 'user', 'xyz' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status(), $method ); + $this->assertSame( 'Kept title', get_blog_option( $blog_id, 'blogname' ), $method ); + } + } + + /** + * Data provider for the collection parameters that only apply with the + * capability to manage sites. + * + * @return array[] + */ + public function data_get_items_hidden_field_params() { + return array( + 'archived' => array( 'archived', true ), + 'mature' => array( 'mature', true ), + 'spam' => array( 'spam', true ), + 'deleted' => array( 'deleted', true ), + 'lang_id' => array( 'lang_id', array( 7 ) ), + 'lang_id_exclude' => array( 'lang_id_exclude', array( 0 ) ), + 'before' => array( 'before', '2000-01-01T00:00:00' ), + 'after' => array( 'after', '2999-01-01T00:00:00' ), + 'spam false' => array( 'spam', false ), + 'orderby date' => array( 'orderby', 'registered' ), + 'orderby update' => array( 'orderby', 'last_updated' ), + ); + } + + /** + * Users listing their own sites cannot filter or order by the fields they + * cannot see, otherwise the results would give the hidden values away. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @covers ::has_edit_only_filter + * @group ms-required + * @dataProvider data_get_items_hidden_field_params + * + * @param string $param Collection parameter. + * @param mixed $value Value that would narrow or reorder the results. + */ + public function test_get_items_me_filter_rejects_hidden_field_params( $param, $value ) { + $blog_id = self::factory()->blog->create( array( 'path' => '/hidden-params/' ) ); + $user_id = self::factory()->user->create(); + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + $request->set_param( $param, $value ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_forbidden_param', $response, 403 ); + } + + /** + * A super admin listing their own sites can still use every filter. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-required + * @dataProvider data_get_items_hidden_field_params + * + * @param string $param Collection parameter. + * @param mixed $value Value that would narrow or reorder the results. + */ + public function test_get_items_me_filter_allows_hidden_field_params_for_super_admins( $param, $value ) { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + $request->set_param( $param, $value ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * The empty default of the language filters and the default order are not + * treated as filters, so a plain own-sites request is still allowed. + * + * @ticket 40365 + * @covers ::has_edit_only_filter + * @group ms-required + */ + public function test_get_items_me_filter_allows_the_default_params() { + $user_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + $request->set_param( 'lang_id', array() ); + $request->set_param( 'orderby', 'domain' ); + $request->set_param( 'public', true ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Data provider for user filters that match no sites. + * + * @return array[] + */ + public function data_get_items_user_filter_without_sites() { + return array( + 'GET, no memberships' => array( 'GET', false ), + 'HEAD, no memberships' => array( 'HEAD', false ), + 'GET, include outside the set' => array( 'GET', true ), + ); + } + + /** + * When the user filter matches no sites there is nothing to query, but the + * query filter still runs and the response looks like any empty collection. + * + * @ticket 40365 + * @covers ::get_items + * @group ms-required + * @dataProvider data_get_items_user_filter_without_sites + * + * @param string $method HTTP method. + * @param bool $with_include Whether to include a site the user is not a member of. + */ + public function test_get_items_user_filter_without_sites_skips_the_query( $method, $with_include ) { + $blog_id = self::factory()->blog->create( array( 'path' => '/not-mine/' ) ); + $user_id = self::factory()->user->create(); + + // Users are members of the main site, which the filter then excludes. + remove_user_from_blog( $user_id, 1 ); + + if ( $with_include ) { + add_user_to_blog( 1, $user_id, 'subscriber' ); + } + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( $method, '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + + if ( $with_include ) { + $request->set_param( 'include', array( $blog_id ) ); + } + + $filtered = 0; + $queries = 0; + + $count_query = static function () use ( &$queries ) { + ++$queries; + }; + $count_filter = static function ( $args ) use ( &$filtered, $count_query ) { + ++$filtered; + + // Looking up the user's sites queries too, so only what follows the filter counts. + add_action( 'pre_get_sites', $count_query ); + + return $args; + }; + + add_filter( 'rest_site_query', $count_filter ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'rest_site_query', $count_filter ); + remove_action( 'pre_get_sites', $count_query ); + + $headers = $response->get_headers(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( array(), $response->get_data() ); + $this->assertSame( '0', $headers['X-WP-Total'] ); + $this->assertSame( '0', $headers['X-WP-TotalPages'] ); + $this->assertSame( 1, $filtered, 'The query filter should still run.' ); + $this->assertSame( 0, $queries, 'No site query should run.' ); + } + + /** + * A plugin can still widen an empty user filter through the query filter. + * + * @ticket 40365 + * @covers ::get_items + * @group ms-required + */ + public function test_get_items_user_filter_without_sites_can_be_widened() { + $blog_id = self::factory()->blog->create( array( 'path' => '/widened/' ) ); + $user_id = self::factory()->user->create(); + remove_user_from_blog( $user_id, 1 ); + wp_set_current_user( $user_id ); + + $widen = static function ( $args ) use ( $blog_id ) { + $args['site__in'] = array( $blog_id ); + return $args; + }; + + add_filter( 'rest_site_query', $widen ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'rest_site_query', $widen ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( array( $blog_id ), wp_list_pluck( $response->get_data(), 'id' ) ); + } + + /** + * The addresses go through the URL functions, so their filters apply. + * + * @ticket 40365 + * @covers ::prepare_item_for_response + * @group ms-required + */ + public function test_prepare_item_uses_the_filtered_site_addresses() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/filtered-urls/' ) ); + + $home_filter = static function ( $url, $path, $scheme, $filtered_blog_id ) use ( $blog_id ) { + return $blog_id === $filtered_blog_id ? 'https://home.example.org/' : $url; + }; + $site_filter = static function ( $url, $path, $scheme, $filtered_blog_id ) use ( $blog_id ) { + return $blog_id === $filtered_blog_id ? 'https://site.example.org/' : $url; + }; + + add_filter( 'home_url', $home_filter, 10, 4 ); + add_filter( 'site_url', $site_filter, 10, 4 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'home_url', $home_filter, 10 ); + remove_filter( 'site_url', $site_filter, 10 ); + + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 'https://home.example.org/', $data['home'] ); + $this->assertSame( 'https://site.example.org/', $data['siteurl'] ); + } + + /** + * Data provider for user parameter values that are neither a user ID nor "me". + * + * @return array[] + */ + public function data_invalid_user_params() { + return array( + 'word' => array( 'xyz' ), + 'zero' => array( '0' ), + 'zero integer' => array( 0 ), + 'negative' => array( -1 ), + 'me with extra' => array( 'me2' ), + 'empty' => array( '' ), + 'decimal' => array( '5.5' ), + 'list' => array( array( 1 ) ), + ); + } + + /** + * The collection filter only accepts a user ID or "me". + * + * @ticket 40365 + * @covers ::get_user_param_schema + * @group ms-required + * @dataProvider data_invalid_user_params + * + * @param mixed $user User parameter value. + */ + public function test_get_items_rejects_an_invalid_user( $user ) { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', $user ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Creating a site only accepts a user ID or "me" as the administrator. + * + * @ticket 40365 + * @covers ::get_user_param_schema + * @group ms-required + * @dataProvider data_invalid_user_params + * + * @param mixed $user User parameter value. + */ + public function test_create_item_rejects_an_invalid_user( $user ) { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/invalid-user/' ); + $request->set_param( 'user', $user ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + $this->assertEquals( 0, get_blog_id_from_url( WP_TESTS_DOMAIN, '/invalid-user/' ) ); + } + + /** + * A numeric string is sanitized to an integer, "me" is kept as it is. + * + * @ticket 40365 + * @covers ::get_user_param_schema + * @group ms-required + */ + public function test_get_items_sanitizes_the_user() { + wp_set_current_user( self::$superadmin_id ); + + $seen = array(); + $capture = static function ( $args, $request ) use ( &$seen ) { + $seen[] = $request['user']; + return $args; + }; + + add_filter( 'rest_site_query', $capture, 10, 2 ); + + foreach ( array( (string) self::$superadmin_id, 'me' ) as $user ) { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', $user ); + + $this->assertEquals( 200, rest_get_server()->dispatch( $request )->get_status() ); + } + + remove_filter( 'rest_site_query', $capture, 10 ); + + $this->assertSame( array( self::$superadmin_id, 'me' ), $seen ); + } + + /** + * "me" resolves to the current user, a numeric value to its integer. + * + * @ticket 40365 + * @covers ::get_user_id_from_param + */ + public function test_get_user_id_from_param() { + $method = $this->get_reflective_method( 'get_user_id_from_param' ); + + $this->assertSame( 0, $method->invoke( $this->endpoint, 'me' ), 'Logged out, "me" is no user.' ); + + wp_set_current_user( self::$superadmin_id ); + + $this->assertSame( self::$superadmin_id, $method->invoke( $this->endpoint, 'me' ) ); + $this->assertSame( 7, $method->invoke( $this->endpoint, '7' ) ); + $this->assertSame( 7, $method->invoke( $this->endpoint, 7 ) ); + } + + /** + * Data provider for the ways to name the administrator of a new site. + * + * @return array[] + */ + public function data_create_item_user_values() { + return array( + 'me' => array( 'me', false ), + 'integer' => array( 'id', false ), + 'numeric string' => array( 'string', false ), + 'JSON integer' => array( 'id', true ), + 'JSON string' => array( 'string', true ), + ); + } + + /** + * The administrator can be given as "me", an integer or a numeric string, + * in the query or a JSON body, and the new site then shows up in their list. + * + * @ticket 40365 + * @covers ::prepare_item_for_database + * @group ms-required + * @dataProvider data_create_item_user_values + * + * @param string $kind Which value to send: 'me', 'id' or 'string'. + * @param bool $json Whether to send the parameters as a JSON body. + */ + public function test_create_item_sets_the_user_as_administrator( $kind, $json ) { + wp_set_current_user( self::$superadmin_id ); + + $values = array( + 'me' => 'me', + 'id' => self::$superadmin_id, + 'string' => (string) self::$superadmin_id, + ); + + $params = array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/administered/', + 'user' => $values[ $kind ], + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + + if ( $json ) { + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + } else { + $request->set_body_params( $params ); + } + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $blog_id = $response->get_data()['id']; + + $this->assertTrue( is_user_member_of_blog( self::$superadmin_id, $blog_id ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + + $this->assertContains( $blog_id, wp_list_pluck( rest_get_server()->dispatch( $request )->get_data(), 'id' ) ); + } + + /** + * Without the capability to manage sites, a user may only filter by their + * own ID or "me", whichever site serves the request. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-required + */ + public function test_get_items_another_user_needs_the_capability() { + $subsite_id = self::factory()->blog->create( array( 'path' => '/other-user/' ) ); + $user_id = self::factory()->user->create(); + $other_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', $other_id ); + + $this->assertErrorResponse( 'rest_forbidden_user', rest_get_server()->dispatch( $request ), 403 ); + + switch_to_blog( $subsite_id ); + $response = rest_get_server()->dispatch( $request ); + restore_current_blog(); + + $this->assertErrorResponse( 'rest_forbidden_user', $response, 403 ); + + foreach ( array( 'me', $user_id, (string) $user_id ) as $own ) { + $request->set_param( 'user', $own ); + + $this->assertEquals( 200, rest_get_server()->dispatch( $request )->get_status(), 'A user can list their own sites.' ); + } + } + + /** + * A logged-out request cannot filter by a user ID. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-required + */ + public function test_get_items_user_filter_forbidden_when_logged_out() { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', self::$superadmin_id ); + + $this->assertErrorResponse( 'rest_forbidden_user', rest_get_server()->dispatch( $request ), 401 ); + } + + /** + * A super admin can list the sites of any user. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-required + */ + public function test_get_items_super_admin_can_filter_by_another_user() { + $blog_id = self::factory()->blog->create( array( 'path' => '/someone-else/' ) ); + $user_id = self::factory()->user->create(); + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', $user_id ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertContains( $blog_id, wp_list_pluck( $response->get_data(), 'id' ) ); + } + + /** + * Data provider for the edit context checks of both read routes. + * + * @return array[] + */ + public function data_edit_context_permission_checks() { + return array( + 'collection' => array( 'get_items_permissions_check', false ), + 'single site' => array( 'get_item_permissions_check', true ), + ); + } + + /** + * The edit context needs the capability to manage sites, even for a member + * reading their own site or listing their own sites. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @covers ::get_item_permissions_check + * @group ms-required + * @dataProvider data_edit_context_permission_checks + * + * @param string $permission_check Permission check method name. + * @param bool $single Whether the request is for a single site. + */ + public function test_edit_context_needs_the_capability( $permission_check, $single ) { + $subsite_id = self::factory()->blog->create( array( 'path' => '/edit-context/' ) ); + $member_id = self::factory()->user->create(); + add_user_to_blog( $subsite_id, $member_id, 'administrator' ); + + $request = new WP_REST_Request( 'GET', $single ? '/wp/v2/sites/' . $subsite_id : '/wp/v2/sites' ); + $request->set_param( 'context', 'edit' ); + + if ( $single ) { + $request->set_param( 'id', $subsite_id ); + } else { + $request->set_param( 'user', 'me' ); + } + + $assert_forbidden = function ( $result, $status, $message ) { + $this->assertWPError( $result, $message ); + $this->assertSame( 'rest_forbidden_context', $result->get_error_code(), $message ); + $this->assertSame( $status, $result->get_error_data()['status'], $message ); + }; + + $assert_forbidden( $this->endpoint->$permission_check( $request ), 401, 'Logged out.' ); + + // Even an administrator of the site is not allowed to use the edit context. + wp_set_current_user( $member_id ); + + $assert_forbidden( $this->endpoint->$permission_check( $request ), 403, 'A member on the main site.' ); + + switch_to_blog( $subsite_id ); + $result = $this->endpoint->$permission_check( $request ); + restore_current_blog(); + + $assert_forbidden( $result, 403, 'A member on their own site.' ); + + wp_set_current_user( self::$superadmin_id ); + + $this->assertTrue( $this->endpoint->$permission_check( $request ), 'A super admin can use the edit context.' ); + } + + /** + * The edit context is checked before the site is looked up, so it does not + * tell an unknown site apart from an existing one. + * + * @ticket 40365 + * @covers ::get_item_permissions_check + * @group ms-required + */ + public function test_get_item_edit_context_does_not_reveal_site_existence() { + $blog_id = self::factory()->blog->create( array( 'path' => '/edit-existence/' ) ); + $user_id = self::factory()->user->create(); + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + wp_set_current_user( $user_id ); + + foreach ( array( $blog_id, REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ) as $id ) { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $id ); + $request->set_param( 'context', 'edit' ); + + $this->assertErrorResponse( 'rest_forbidden_context', rest_get_server()->dispatch( $request ), 403 ); + } + } } diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index b22ff76829e1c..3e0b4c9305821 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11004,7 +11004,22 @@ mockedApiResponse.Schema = { }, "user": { "description": "Limit result set to the sites a user is a member of. Accepts a user ID or \"me\".", - "type": "string", + "type": [ + "integer", + "string" + ], + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "string", + "enum": [ + "me" + ] + } + ], "required": false }, "public": { @@ -11117,14 +11132,29 @@ mockedApiResponse.Schema = { "type": "integer", "required": false }, - "title": { - "description": "Site title, set when the site is created. Default is the word \"Site\" followed by the site ID.", + "blogname": { + "description": "Site title, stored in the blogname option. Can only be set when the site is created.", "type": "string", "required": false }, - "user_id": { - "description": "User ID of the site administrator, set when the site is created.", - "type": "integer", + "user": { + "description": "The site administrator, set when the site is created. Accepts a user ID or \"me\".", + "type": [ + "integer", + "string" + ], + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "string", + "enum": [ + "me" + ] + } + ], "required": false } }